코드로 알아보는 PoolTogether V4-Periphery

jsoooo530
EWHA-CHAIN
Published in
38 min readJan 21, 2024

안녕하세요, Klaytn 데브 앰버서더 2기로 활동하고 있는 정지수입니다. 본 아티클은 PoolTogether V4 프로토콜에 대한 전체적인 소개부터 V4의 특징인 Draw Percentage Rate(DPR)에 대한 설명을 거쳐 상금 분배 원리를 다루는 V4 Periphery를 코드 기반으로 설명합니다.

1. 들어가며: PoolTogether

PoolTogether

1.1 손실 없는 복권 서비스, PoolTogether

PoolTogether는 2019년 중순 출시되었습니다. 초기 버전인 PoolTogether V1은 흥미로운 방법으로 자산을 늘릴 수 있도록 prize-linked savings를 모델로 디자인되었고 현재 V5까지 발전해왔습니다. PoolTogether에서 상금을 얻는 기본 원리는 간단합니다. 매주 진행되는 추첨, draw는 사용자들이 예치한 금액을 기반으로 prize가 결정됩니다. 사용자가 풀에 돈을 걸고 참여하면 해당 자금은 암호화폐 대출 프로토콜에 예치되고, 예치된 기간 동안 발생한 이자만 prize로 사용되기 때문입니다. 따라서 이러한 메커니즘을 통해 PoolTogether의 특징 중 하나인 원금 손실 없는 복권 서비스를 실현할 수 있습니다. PoolTogether은 이 모델을 이더리움 기반으로 Polygon, Ethereum, Optimism, Avalanche 총 네 가지 상금 풀을 운영하고 있습니다.

해당 글은 PoolTogether V4의 오픈 소스 코드를 기반으로 PoolTogether의 상금 분배 과정을 다루는 V4 Periphery 코드에 대해 최대한 깊고 기술적인 분석과 인사이트 발굴을 목적으로 작성되었습니다. Solidity와 Typescript 언어로 작성된 코드를 읽을 수 있다면 PoolTogether의 상금 분배 과정에 대해 저와 함께 차근차근 분석할 수 있을 것입니다.

1.2 Prize Distribution 훑어보기

PoolTogether V4 프로토콜의 아키텍처는 V4 Core, V4 Periphery, V4 Timelock, V4 TWAB Delegator 크게 네 가지의 스마트 컨트랙트에 의해 운용됩니다. 그 중에서 V4-periphery 는 상금 분배를 처리하고 이를 위해 관련 데이터를 Prize Distribution Buffer에 저장하는 Prize Distribution Factory를 가장 중요한 컨트랙트로 볼 수 있습니다.

이 단락에서 다룰 내용은 prize distribution으로, 상금 분배의 메커니즘을 다루는 V4 Periphery 코드를 분석하기 이전에 prerequisite으로써 알아보기 위해 마련하였습니다. 2022년 1월, 닉네임 Brendon이라는 PoolTogether 거버넌스에서 활동하는 인물이 새로운 상금 분배 모델을 적용하여 DPR이라는 개념을 처음으로 제안하였습니다. 2022년 12월, 거버넌스 ID PTIP-83으로 PoolTogether V4 Draw Percentage Rate(DPR) 업그레이드에 관한 투표가 진행되었습니다. POOL 토큰을 보유한 사용자들은 모두 투표에 참여하였고 최종적으로 업그레이드가 진행되어 모든 티어에 골고루 분산된 상금 당첨률을 보장하기 위한 방안으로써 2023년 1월, DPR이 도입되었습니다. DPR이 상금 분배 과정에 어떠한 영향을 주는지에 포커싱하면서 해당 글을 읽기를 바랍니다.

2. Prize Distribution: 상금 분배의 원리

V4 Periphery 코드 분석에 들어가기 전에 PoolTogether의 Prize Distribution(상금 분배 과정)을 정리하고, 이 원리를 이해하는 데에 필요한 용어들을 정의하면서 시작해보겠습니다.

2.1 용어 정리

PoolTogether V4는 매주 진행되는 추첨에서 상금 분배를 위해 ‘Tsunami’라는 명칭의 알고리즘을 사용합니다. 구체적으로 살펴보면 이 알고리즘은 pick와 당첨 번호 간의 비교를 통해 진행됩니다. 상금 분배 방식을 자세히 알아보기 위해 공식 문서에서 설명하는 관련 용어들을 아래와 같이 정리하였습니다.

dev.PoolTogether Docs
  • pick
    pick은 추첨 참여자에게 제공되는 256 비트의 랜덤 넘버입니다. 각각의 pick은 당첨 번호와 인덱스 단위로 비교되는데 이 비교 결과에 따라 어느 상금 티어에 해당되는 것인지 알 수 있습니다. 참고로 PoolTogether에서 당첨 번호는 공평한 상금 분배를 위해 제3의 RNG 서비스를 통해 무작위 난수로 생성됩니다.
  • cardinality, bit range, tier
    32비트는 8개의 4비트 숫자로 분해할 수 있는데, 이 때 8을 cardinality, 4를 bit range라고 합니다. cardinality와 bit range를 통해 상금 티어를 계산할 수 있습니다. 티어를 계산하는 공식은 아래와 같습니다.
    tier = cardinality - n(처음부터 일치하는 인덱스 개수)
    티어는 상금 분배 과정에서 상금의 등급이 적절히 나뉘어져 분배될 수 있도록 합니다. 티어가 0이라면 pick과 당첨 번호가 완전히 일치하기 때문에 가장 많은 상금을 받을 수 있습니다. 따라서 pick과 당첨번호가 일치하는 정도에 따라 티어 별로 적절히 분배된 상금을 받을 수 있겠습니다.
  • number of combinations
    bit range와 cardinality를 통해서 총 생성될 수 있는 pick의 개수를 구할 수 있습니다. 이를 number of combinations 또는 total picks라고 부릅니다. number of combinations를 계산하는 공식은 아래와 같습니다.
    number of combinations = (2^bit range)^cardinality
  • prize splitting
    사용자는 각 tier에 할당된 상금을 전부 다 가져갈 수는 없습니다. 같은 등급의 티어에 해당하는 당첨자가 여러 명일 수 있기 때문입니다. 따라서 당첨자가 가져갈 수 있는 상금을 계산하기 위해서는 해당 티어의 상금 개수를 구해야 합니다.
  • Draw Percentage Rate(DPR)
    DPR은 Total Value Locked(TVL)을 통해 상금이 발생할 확률을 조정하면서 평균적으로 상금이 예상 수익과 일치하도록 하는 개념입니다. 아래 예제를 통해 DPR이 상금 티어에 어떤 영향을 주는지 알아봅시다.
Draw Percentage Rate(DPR) — gov.pooltogether

DPR은 상금이 DPR $*$ TVL의 값과 일치하는 수준에서 발생하도록 합니다. 이 예제에서 Network TVL은 $60,000이기 때문에 전체 네트워크 대비 각각의 Pool A, B, C의 기여도는 0.166, 0.333, 0.5가 되고, 이것을 상금을 탈 수 있는 확률적 공간(chance)이라고 할 수 있습니다. 다음으로 해당 추첨에 대해 기대할 수 있는 상금 금액(Expected Value = chance * Total Prize)은 Pool A가 $166, Pool B가 $333, Pool C가 $500 입니다. 각 Pool의 TVL 대비 Expected Value를 비교해보면 모든 Pool에서 0.0166의 확률로 상금을 탈 것이라 예상할 수 있고, 따라서 DPR은 1.66%임을 알 수 있습니다.

반대로 생각해볼 수도 있겠습니다. DPR을 알고 있다면 전체 Network TVL을 알지 못해도 각 Pool의 chance를 계산할 수 있습니다. DPR은 네트워크에 걸쳐 모든 Pool에서 동일한 값이기 때문입니다. 따라서 DPR과 각 Pool의 TVL만 알고 있다면 상금 분배 각 티어 별로 상금 당첨 주기를 예상할 수 있습니다. 예를 들어 어떤 티어에 대해 상금이 $8333, 12개로 설정되어 있고, chance가 0.002라면, 하루에 해당 티어의 상금은 0.024번 탈 수 있으며 일 년에 8.760번 당첨될 수 있음을 의미합니다.

2.2 상금 분배의 과정

  • pick distribution
    각 prize pool은 매주 전체 네트워크 유동성 대비 기여도에 따라 pick을 분배받습니다. 어떤 pool이 유동성의 50%를 기여했다면 total picks의 절반이 할당되는 것입니다. 이제 각 사용자는 해당 prize pool의 유동성 대비 기여도에 따라 사용할 수 있는 pick을 할당받습니다. 어떤 사용자가 유동성의 20%를 기여했다면 해당 pool에 할당된 pick의 20%를 받을 수 있겠습니다. 사용자의 유동성 기여도는 Time-Weighted Average Balance를 통해 계산할 수 있으며 더 자세한 내용은 (TWAB 내용 있는 아티클 링크)를 통해 확인할 수 있습니다.
  • generating picks
    사용자가 어떤 추첨에 대해 2000개의 pick을 가지고 있다고 합시다. 이 2000개의 pick은 각각 사용자의 주소와 pick 인덱스를 가지고 keccak 해시 알고리즘을 통해 256비트의 랜덤 결과값으로 생성됩니다. 예를 들어 pick 12는 keccak(keccak(address),12) 로 생성되는 값입니다. 해당 사용자의 경우에는 0부터 1999의 인덱스로 2000개의 pick을 계산할 수 있겠습니다.
  • calculating and splitting prizes
    앞서 pick과 당첨 번호를 비교하고 그 두 값이 일치하는 정도를 계산하여 상금을 받을 수 있다고 했습니다. PoolTogether의 상금 분배는 각 티어에 할당되는 상금을 백분율로 나눠볼 수 있습니다.
dev.PoolTogether Docs

각 티어에 대해서는 해당 티어와 일치할 수 있는 조합의 수를 계산해서 상금을 공평하게 분배할 수 있도록 해야 합니다. 이는 해당 티어의 당첨자가 가져갈 수 있는 상금을 구하는 공식을 거쳐야 합니다.

  1. 해당 티어의 상금 개수(Number of prizes for a tier)

(2^bit range)^tier - (2^bit range)^(tier-1)

2. 해당 티어의 당첨자가 가져갈 수 있는 상금(Prize for a tier)

total prize * tier percentage / number of prizes for a tier

예를 들어 티어 0의 경우 모든 숫자가 일치해야 하기 때문에 티어 0에 해당하는 pick은 1가지 경우 뿐입니다. 하지만 티어 1부터는 pick이 될 수 있는 조합이 더 많아지기 때문에 여러 경우의 수가 도출됩니다. 이러한 과정을 거쳐 상금을 분배합니다.

  • claiming picks
    사용자는 자신의 pick 중에서 당첨된 pick에 대해 상금을 청구해야 Prize Distributor를 통해 계산된 상금을 받을 수 있습니다. 하지만 이 과정에서 청구 가능한 pick의 개수는 제한이 있습니다. 유동성의 기여도가 높은 사용자의 경우 아주 많은 개수의 pick을 받아가겠지만 이러한 대형 투자자들이 모든 상금을 가져가는 것을 막기 위함입니다.

2.3 프로세스

PoolTogether V4는 수익 획득, 상금 분배라는 두 가지 근본적인 프로세스를 포함합니다. 이 글에서는 상금 분배에 대해 집중적으로 설명하는 만큼, 상금 분배의 프로세스에 주목해보겠습니다.

PoolTogether V4의 전체적인 네트워크의 흐름은 아래의 이미지와 같습니다. 사용자가 네트워크에 자금을 예치하면 해당 자금은 Compound와 Yearn과 같은 서비스에 예치됩니다. 이 플랫폼들을 통해서 이자를 받을 수 있는데 이 금액은 곧 PoolTogether에서의 상금으로 지급됩니다.

PoolTogether Docs

Draw Beacon & Draw Buffer

매주 draw(추첨)는 Draw Beacon에서 생성됩니다. 생성된 draw는 마지막 256개의 draw를 저장하는 Draw Buffer에 푸시됩니다. draw는 난수와 타임스탬프로 구성되는데, 난수는 RNG 서비스로부터 받아오며 draw가 종료된 후에는 난수가 소모됩니다.

PoolTogether Docs

Prize Distributor

사용자는 Prize Distributor라는 컨트랙트를 사용하여 상금을 청구할 수 있습니다 Prize Distributor 컨트랙트는 상금 풀의 모든 유동성을 갖고 있기 때문에 어떤 draw에 대해 사용자가 상금을 청구하면 Prize Distributor는 Draw Calculator를 통해 지급액을 계산합니다.

PoolTogether Docs

2.4 V4 Periphery 를 통해 살펴보는 prize distribution

PoolTogether V4 프로토콜에서 우리가 살펴볼 V4 Periphery는 전체 프로토콜에서 상금 분배의 프로세스를 다루는 컴포넌트입니다. 이 컴포넌트는 프로토콜의 핵심 로직 중 하나인 상금 분배의 과정을 집약적으로 담아두기 위해 별도의 라이브러리에 넣은 것이라고 할 수 있습니다. PoolTogether 거버넌스에서는 “DPR(Draw Percentage Rate)” 도입을 위해 부분적으로 프로토콜 업그레이드를 진행했기 때문에 V4 Periphery 코드를 살펴보면 두 가지의 버전이 존재합니다. 다음 장에서는 이 두 가지 버전 중에서 DPR 업그레이드 이후의 Periphery 코드를 테스트케이스 위주로 살펴보겠습니다.

3. V4 Periphery 분석

앞 장에서 설명한 상금 분배의 프로세스와 DPR 업그레이드를 토대로 PoolTogether V4의 Periphery 코드를 Github에 있는 테스트 코드를 기반으로 분석해보겠습니다.

3.1 Prize Distribution FactoryV2

Periphery 코드에서 가장 핵심적인 기능을 하는 Prize Distribution FactoryV2는 상금 풀 내에서 상금 분배를 처리하는 기능을 합니다. Prize Tier History, Draw Buffer, Ticket을 활용하여 상금 분배에 관련된 데이터를 Prize Distribution Buffer에 저장하는 과정을 살펴볼 수 있습니다.

본격적인 작업이 들어가기 이전에 테스트 케이스 실행을 위한 상황 설정이 필요합니다. PrizeDistributionFactoryV2.test.ts 은 아래와 같이 기본 변수들을 설정했습니다.

beforeEach(async () => {
[wallet1, wallet2] = await getSigners();

const IPrizeTierHistoryV2 = await artifacts.readArtifact('IPrizeTierHistoryV2');
const IDrawBuffer = await artifacts.readArtifact('IDrawBuffer');
const IPrizeDistributionBuffer = await artifacts.readArtifact('IPrizeDistributionBuffer');
const ITicket = await artifacts.readArtifact('ITicket');

prizeTierHistory = await deployMockContract(wallet1, IPrizeTierHistoryV2.abi);

drawBuffer = await deployMockContract(wallet1, IDrawBuffer.abi);
prizeDistributionBuffer = await deployMockContract(wallet1, IPrizeDistributionBuffer.abi);

ticket = await deployMockContract(wallet1, ITicket.abi);

if (!isConstructorTest) {
prizeDistributionFactory = await deployPrizeDistributionFactory();
}
});

각각의 테스트 케이스를 수행하기 전에 wallet1, wallet2를 초기화합니다. 또한 시뮬레이션 환경에서의 모의 객체(mock object)를 설정하기 위해 prize tier, prize distribution buffer, ticket 등과 관련된 몇 가지 작업을 진행합니다.

추가적인 변수와 상황 설정

const drawDefault = {
winningRandomNumber: '0x1111111111111111111111111111111111111111111111111111111111111111',
drawId: 1,
timestamp: 1000,
beaconPeriodStartedAt: 0,
beaconPeriodSeconds: 100,
};

const dprDefault = '0.1'; // 10%
const dpr = parseUnits(dprDefault, '9');
const prizeDefault = '10';
const prize = toWei(prizeDefault);
const totalSupplyDefault = '1000';

const prizeTierDefault: IPrizeTierHistoryV2.PrizeTierV2Struct = {
bitRangeSize: 2,
drawId: 1,
maxPicksPerUser: 2,
expiryDuration: 3600,
endTimestampOffset: 300,
dpr,
prize,
tiers: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
};

const calculateNumberOfPicks = (
bitRangeSize: number,
cardinality: number,
prize: number,
dpr: number,
totalSupply: number,
) => {
const odds = BigNumber.from((dpr * totalSupply) / prize);
const totalPicks = BigNumber.from((2 ** bitRangeSize) ** cardinality);

return totalPicks.mul(odds);
}

이후의 구조에 대해 가볍게 살펴보겠습니다. 먼저 setupMocks 함수를 통해 테스트에서 수행할 mock 객체를 설정합니다. drawOptions, prizeTierOptions, totalSupply를 매개 변수로 받아 각각에 대해 mock 객체를 설정하여 필요한 환경을 구성하도록 합니다. describe(’constructor()’) 은 프로토콜의 초기화와 필수 요소가 올바르게 설정되어있는지 확인하는 테스트 케이스가 포함된 부분입니다. describe(’calculatePrizeDistribution()’) 은 calculatePrizeDistribution 함수가 예상대로 작동하고 다양한 시나리오에서 예상된 결과를 생성하는지 확인합니다. 특히 PrizeDistribution이 올바르게 계산되는지 여러 상황을 다루며 살펴봅니다. describe(’pushPrizeDistribution()’) 은 pushPrizeDistribution 함수에 대한 테스트를 다루며 Prize Distribution이 올바르게 푸시되는지 살펴봅니다. describe(’setPrizeDistribution()’) 은 setPrizeDistribution 함수에 대해 다루고 특히 소유자만이 이 함수를 호출할 수 있는지 확인하는 테스트를 수행합니다.

describe(’calculatePrizeDistribution()’)

가장 까다로운 calculatePrizeDistribution 함수의 테스트 케이스는 상금 분배 작업이 여러 상황에서 올바른 메커니즘으로 계산되는지 확인합니다.

  1. cardinality의 최소값이 1인지 확인
it('ensure minimum cardinality is 1', async () => {
await setupMocks({}, {}, toWei('0'));

const prizeDistributionObject = toObject(
await prizeDistributionFactory.calculatePrizeDistribution(1),
);

const prizeDistribution = createPrizeDistribution({
matchCardinality: 1,
numberOfPicks: toWei('0'),
});

expect(JSON.stringify(prizeDistributionObject)).to.equal(
JSON.stringify(prizeDistribution),
);
});

이 테스트 케이스에서는 minPickCost를 0으로 설정한 후, calculatePrizeDistribution(1)을 호출하여 cardinality가 1이상의 값이되는지 확인합니다.

  1. setupMocks({}, {}, toWei('0')) 함수를 호출, toWei('0')를 사용하여 티켓의 공급량을 0으로 설정합니다.
  2. prizeDistributionObject으로 prizeDistributionFactory.calculatePrizeDistribution(1)을 호출하여 값을 객체로 저장합니다.
  3. prizeDistribution에는 “matchCardinality"를 1로 설정하고 "numberOfPicks"를 0으로 설정해서 createPrizeDistribution 호출합니다.
  4. 이제 prizeDistributionObject와 prizeDistribution 객체를 비교하여 동일한지 확인합니다. 이 과정을 통해 티켓 공급량이 0인 경우에도 정상적으로 계산되는지 확인할 수 있습니다. contract를 통해 odds가 0인 경우는 상금이 티켓 공급량보다 훨씬 큰 값이라는 의미이고 이 경우에 cardinality에 1을 할당한다는 것을 알 수 있기 때문에 테스트 작업을 수행할 수 있습니다.

각각의 테스트 케이스는 이렇게 각 경우에 대해 수동적으로 설정된 prizeDistribution 객체와 자동으로 계산되는 prizeDistributionObject 객체를 비교함으로써 프로토콜의 유효성을 확인합니다.

2. 상금이 티켓 공급량보다 월등히 큰 경우 pick을 할당하지 않음

it('should not allocate any picks if prize is way greater than ticket supply', async () => {
// If prize is 1e9+ greater than dpr * totalSupply, odds will truncate down and return 0
const prize = parseUnits(totalSupplyDefault, 27);
await setupMocks({}, { prize });

const prizeDistributionObject = toObject(
await prizeDistributionFactory.calculatePrizeDistribution(1),
);

const prizeDistribution = createPrizeDistribution({
matchCardinality: 1,
numberOfPicks: BigNumber.from(0),
prize,
});

expect(JSON.stringify(prizeDistributionObject)).to.equal(
JSON.stringify(prizeDistribution),
);
});

이 케이스에서는 상금이 티켓 공급량보다 월등히 큰 경우에는 pick을 할당하지 않도록 처리하는지를 테스트합니다. 이를 위해 prize변수를 parseUnits(totalSupplyDefault, 27) 로 아주 크게 설정합니다. DPR * total supply 는 티켓 공급량과 같습니다. 이렇게 설정하면 상금이 티켓 공급량의 1e9 배 이상 크기 때문에 pick을 할당하지 않을 수 있습니다. contract 코드를 살펴보면 256비트의 정수인 변수, odds 가 정의되어 있습니다.

uint256 _odds = (_dpr * _totalSupply) / _prize;

해당 테스트 케이스에서는 odds의 값이 0이 됩니다. odds의 값이 0이 된다면 당첨 번호와 100프로 매칭되는 pick이 없다는 의미이기 때문에 pick에 아무것도 할당되지 않는 것으로 이해할 수 있습니다. 이러한 상황은 상금이 지나치게 많은 경우, 확률(odds)을 계산하는 과정에서 상금의 크기가 너무 큰 영향을 미치게 되어 균형이 깨졌다고 판단되기 때문에 odds이 0으로 할당되었다고 볼 수 있습니다.

여기서 상금이 DPR * totalSupply의 1e9배 이상 크다는 것은 추첨 과정에서 상금이 너무 많이 증가해서 일반적인 DPR 비율을 크게 초과한 것입니다. 모든 상금 풀에 동일하게 적용되는 DPR이란 상금을 탈 수 있는 확률, 주기로 쉽게 이해할 수 있겠습니다. DPR은 예상 수익에 따라 일정한 주기로 상금을 탈 수 있도록 조정되어야 하기 때문에 너무 극단적으로 낮거나 높지 않아야 합니다. 따라서 이러한 이유로 해당 테스트 케이스를 수행하는 것이라고 할 수 있습니다.

3. DPR이 극단적으로 낮은 경우

it('should handle when the dpr is extremely low', async () => {
await setupMocks({}, { dpr: parseUnits('0.000000001', '9') });

const prizeDistributionObject = toObject(
await prizeDistributionFactory.calculatePrizeDistribution(1),
);

const prizeDistribution = createPrizeDistribution({
matchCardinality: 16,
numberOfPicks: BigNumber.from(429),
});

expect(JSON.stringify(prizeDistributionObject)).to.equal(
JSON.stringify(prizeDistribution),
);
});

이 테스트 케이스에서는 DPR이 극단적으로 낮은 경우를 다루고 있습니다. 앞서 언급했듯이, DPR은 일정하게 상금에 당첨될 수 있도록 하는 도구적 장치인 만큼, 일정한 수준에서 유지되어야 합니다.

이 테스트 케이스를 수행하기 위해서는 setUpMocks 함수가 DPR이 낮은 경우를 설정하도록 합니다. 수동으로 설정되는 prizeDistribution 객체는 matchCardinality를 16으로, numberOfPicks(pick의 개수)를 큰 수로 설정하여 상금에 당첨되기 어려운 상황을 만들어줍니다. 이는 pick과 당첨 번호의 모든 인덱스가 동일해야 상금을 탈 수 있음을 의미합니다. 따라서 이러한 값으로 케이스를 세팅한다면 상금을 탈 수 있는 확률이 극단적으로 낮아집니다. DPR이 극단적으로 높은 경우는 이 케이스와 다르게 matchCardinality를 1로 조정하여 인덱스 하나만 매칭되어도 상금을 탈 수 있는 상황을 구성하여 상금 당첨 주기를 거버넌스에 따라 조정할 수 있겠습니다.

3.2 PrizeTierHistoryV2

PrizeTierHistoryV2는 prize tier, 즉 각 추첨 히스토리에 대한 티어와 해당 티어의 상금 데이터를 관리하기 위한 컨트랙트입니다. 상금 티어는 각 추첨에 대해 tier0부터의 상금액과 각 티어에 해당하는 당첨자 수에 대한 데이터를 포함하는데, 이러한 값은 DPR에 의해 조정되는 값입니다. 따라서 PrizeTierHistoryV2는 각 추첨에서 DPR에 따른 상금 티어 정보를 관리할 수 있습니다. PrizeTierHistoryV2.test.ts 파일을 통해 어떠한 방식으로 상금 티어를 관리할 수 있을지 살펴보겠습니다.

기본적으로 DPR은 10%이고, 티어는 0으로 채워진 길이 16의 배열로 설정됩니다. 또한 prizeTiers 배열로 여러 상금 티어에 대한 데이터를 정의하면서 시뮬레이션 수행을 위한 사전 데이터를 설정했습니다.

const dpr = parseUnits('0.1', 9); // 10%
const tiers = range(16, 0).map((i) => 0);

const prizeTiers: IPrizeTierHistoryV2.PrizeTierV2Struct[] = [
{
bitRangeSize: 5,
drawId: 1,
maxPicksPerUser: 10,
expiryDuration: 10000,
dpr,
endTimestampOffset: 3000,
prize: toWei('10000'),
tiers,
}, // ... (여러 Prize Tier 데이터)
];

const dprGT1e9 = parseUnits('1.1', 9);
const tiersGT1e9 = [
141787658, 0, 0, 0, 85072595, 0, 0, 136116152, 136116152, 108892921, 0, 0, 217785843,
174228675, 174228675, 0,
];

PrizeTierHistoryV2.test.ts 를 구성하는 테스트 케이스들의 구조를 살펴보겠습니다. 먼저 Getters와 관련된 테스트는 prizeTiers의 데이터를 호출하고 그 결과를 검증하는 부분입니다. Setters는 .push()와 .popAndPush() 함수에 대한 여러 케이스를 테스트하는 부분이고, 마지막으로 replace()는 기존 prizeTiers의 데이터를 다른 값으로 교체한 뒤의 이벤트를 확인합니다.

PrizeTierHistoryV2.test.ts는 특히 상금 티어의 데이터가 소유자 지갑으로부터 발생되었는지 테스트하는 부분이 특징입니다. 다음 테스트 케이스 사례를 통해 확인해보겠습니다.

  1. PrizeTiers를 권한이 있는 사용자(소유자/매니저)가 푸시하는지 확인
it('should fail to push PrizeTier into history from Unauthorized wallet', async () => {
await expect(
prizeTierHistoryV2.connect(wallet3).push(prizeTiers[0]),
).to.be.revertedWith('Manageable/caller-not-manager-or-owner');
});

PrizeTierHistoryV2.test.ts에서 시뮬레이션을 위해 설정하고 있는 가상의 상황은 wallet1의 주소를 컨트랙트를 배포한 사용자로, wallet2를 매니저로 설정하였습니다. 위의 사례처럼 소유자 및 매니저가 아닌 사용자의 지갑 주소로부터 PrizeTier 데이터가 저장되는 것을 막기 위해서라고 할 수 있습니다. 해당 테스트 케이스에서는 wallet3이 데이터를 푸시하는 상황을 설정하였고, 이 경우 테스트가 revert 되도록 합니다.

2. PrizeTiers의 DPR 값이 1e9를 초과하는 경우 실패하는지 확인

또한 DPR을 저장하는 변수의 값이 1e9를 넘지 않도록 하고 있습니다. 1e9는 10의 9승입니다. DPR은 분명히 확률적인 퍼센트인데 어떻게 1e9를 기준으로 설정하고 있는 것일까요. 아래 테스트를 통해 살펴보도록 하겠습니다.

it('should fail to push a PrizeTier if the dpr is greater than 1e9', async () => {
prizeTiers[0].dpr = dprGT1e9;

await expect(prizeTierHistoryV2.push(prizeTiers[0])).to.be.revertedWith(
'PTH/dpr-gt-100%',
);

prizeTiers[0].dpr = dpr;
});

앞서 우리는 DPR이 1e9보다 큰 값을 저장한 dprGT1e9 을 설정하였습니다. dprGT1e9는 parseUnit(’1.1’, 9) 로 선언되어 있는데 parseUnit이란 Ethereum 단위 변환을 위해 1.1를 10의 9승, 즉 1e9로 곱한 값으로 변환되는 값입니다. Ethereum에서는 작은 단위인 gwei를 기준으로 1e9를 곱해야 가장 큰 단위인 1 ether를 표현할 수 있기 때문에 이러한 방식의 단위 변환 방식을 사용합니다. DPR은 확률이기 때문에 1보다 작아야 합니다. 1e9가 DPR의 기준이 되는 이유는 이러한 단위 변환 방식을 반영한 값이기 때문에 해당 테스트 케이스에서 1.1를 변환한 값을 dprGT1e9로 설정하고 있습니다.

3.3 PrizeFlush

PrizeFlush는 상금 풀로부터 이자를 수집하고 Reserve 로 옮겨 최종 목적지인 prizeDistributor로 이동시키는 역할을 합니다. 성공적으로 자금을 받은 prizeDistributor에서는 Tier 계층에 따른 상금 분배가 이뤄지게 됩니다. Strategy란 상금 분배에서 상금 Tier와 일치하는 조합을 계산하여 각 티어에 대해 상금 수와 상금액을 결정하게 되는 계산 방식이라고 볼 수 있습니다. Reserve란 이러한 방식에 맞게 자금이 분배되어 저장되는 곳이라고 할 수 있겠습니다. Tier 0부터 시작하여 할당된 상금 중에서 한 명이 청구할 수 있는 상금액을 공정하게 계산하는 것이 목적이라고 할 수 있습니다. PrizeFlush.test.ts를 통해 Strategy를 사용한 상금 분배 과정에 대해 살펴보도록 하겠습니다.

사전에 필요한 변수들을 설정합니다. 상금이 올바르게 flush 된 이후에는 Reserve에서 ERC-20으로 ticket이 사용자들에게 발행됩니다.

let wallet1: SignerWithAddress;
let wallet2: SignerWithAddress;
let wallet3: SignerWithAddress;

let prizeFlush: Contract;
let reserve: Contract;
let ticket: Contract;
let strategy: MockContract;
let prizeFlushFactory: ContractFactory;
let reserveFactory: ContractFactory;
let erc20MintableFactory: ContractFactory;
let prizeSplitStrategyFactory: ContractFactory;

let destination: string;

before(async () => {
[wallet1, wallet2, wallet3] = await getSigners();

destination = wallet3.address;
erc20MintableFactory = await ethers.getContractFactory('ERC20Mintable');
prizeFlushFactory = await ethers.getContractFactory('PrizeFlush');
reserveFactory = await ethers.getContractFactory('ReserveHarness');
prizeSplitStrategyFactory = await ethers.getContractFactory('PrizeSplitStrategy');

let PrizeSplitStrategy = await artifacts.readArtifact('PrizeSplitStrategy');
strategy = await deployMockContract(wallet1, PrizeSplitStrategy.abi);
});

beforeEach(async () => {
ticket = await erc20MintableFactory.deploy('Ticket', 'TICK');
reserve = await reserveFactory.deploy(wallet1.address, ticket.address);
prizeFlush = await prizeFlushFactory.deploy(
wallet1.address,
destination,
strategy.address,
reserve.address,
);
await reserve.setManager(prizeFlush.address);
});

PrizeFlush 역시 권한이 없는 사용자가 destination이나 strategy 주소를 설정하는 것을 방지하기 위한 테스트를 수행합니다. PrizeFlush에서는 다만, destination 뿐만 아니라 strategy와 reserve 주소도 권한이 있는 소유자 및 매니저가 관리해야한다는 제약사항이 있음을 주목할 수 있습니다.

Strategy란 상금 풀에 대해 상금을 배포할 때 상황에 맞게 처리하는 계산 전략이라고 할 수 있습니다. 예를 들어 일정 금액의 상금을 여러 사람이 청구하는 상황이 일어났을 때 사전에 정의된 상금을 받아갈 수 있는 수상자 수를 주기적으로 설정하여 해당 풀에서 할당 가능한 상금의 몫을 받아가도록 하기 위함입니다. 상금 분배 strategy에 관해서는 V4 Core에 대한 문서(링크)를 확인해주시기 바랍니다.

Reserve 잔액이 있을 때 상금을 이동시키는지 확인

it('should succeed to flush prizes if positive balance on reserve.', async () => {
await strategy.mock.distribute.returns(toWei('100'));
await ticket.mint(reserve.address, toWei('100'));
await expect(prizeFlush.flush())
.to.emit(prizeFlush, 'Flushed')
.and.to.emit(reserve, 'Withdrawn');
});

이 테스트는 flush 함수가 Reserve에 양수의 잔액이 있을 때 상금을 PrizeDistributor 컨트랙트로 성공적으로 이동시키는지 확인하는 작업을 수행합니다. 특히 strategy.mock.distribute을 통해 이자를 가상으로 설정하여 Reserve에 ticket을 발행한 후 성공적으로 flush 되는지 확인합니다.

이해를 위해 PrizeFlush 컨트랙트의 flush 함수를 통해 flush의 과정을 살펴보겠습니다.

/// @inheritdoc IPrizeFlush
function flush() external override onlyManagerOrOwner returns (bool) {
// Captures interest from PrizePool and distributes funds using a PrizeSplitStrategy.
strategy.distribute();

// After funds are distributed using PrizeSplitStrategy we EXPECT funds to be located in the Reserve.
IReserve _reserve = reserve;
IERC20 _token = _reserve.getToken();
uint256 _amount = _token.balanceOf(address(_reserve));

// IF the tokens were succesfully moved to the Reserve, now move them to the destination (PrizeDistributor) address.
if (_amount > 0) {
address _destination = destination;

// Create checkpoint and transfers new total balance to PrizeDistributor
_reserve.withdrawTo(_destination, _amount);

emit Flushed(_destination, _amount);
return true;
}

return false;
}

상금이 되는 이자는 strategy, 여기서는 PrizeSplitStrategy를 통해 분배됩니다. 분배가 성공적으로 이뤄지면 자금은 Reserve에 저장되고 토큰인 ticket이 발행됩니다. flush의 최종 목표는 자금을 Prize Distributor로 옮기는 것입니다. Reserve는 자금을 잠시 저장하는 버퍼의 역할이라고 볼 수 있습니다. 따라서 Prize Distributor로 정해진 자금을 이동시키면 flush의 과정은 종료됩니다.

3.4 Prize Distributor

Prize Distributor는 Reserve에서 보유중인 ticket을 받아서, 사용자들이 청구(claim)한 pick에 대해 지급할 상금을 계산합니다. 상금을 계산하는 것은 Draw Calculator라는 컨트랙트를 통해 Prize Distribution Factory에서 준비해둔 파라미터를 사용하여 진행됩니다. Draw Calculator는 Tier 계층에 따라 사용자의 유동성 대비 기여도로 얼마를 상금으로 지급해야할지 결정론적인 방식으로 계산합니다. 한 사용자가 한 draw 기간 동안 예치한 평균 잔고가 많을수록 이길 확률이 높아지는 것입니다.

아래는 V4 Core의 Prize Distributor 컨트랙트에서 사용자가 상금을 청구할 때에 호출되는 claim 함수입니다. 사용자 정보, draw 정보, data 정보를 파라미터로 받은 다음, Draw Calculator의 calculate 함수를 사용하여 사용자에게 지급할 상금을 계산합니다. 사용자의 drawId에 대해 지급되는 상금을 업데이트, 총 상금을 누적합니다. 모든 단계가 끝나면 사용자에게 상금을 지급하게 됩니다.

/// @inheritdoc IPrizeDistributor
function claim(
address _user,
uint32[] calldata _drawIds,
bytes calldata _data
) external override returns (uint256) {

uint256 totalPayout;

(uint256[] memory drawPayouts, ) = drawCalculator.calculate(_user, _drawIds, _data); // neglect the prizeCounts since we are not interested in them here

uint256 drawPayoutsLength = drawPayouts.length;
for (uint256 payoutIndex = 0; payoutIndex < drawPayoutsLength; payoutIndex++) {
uint32 drawId = _drawIds[payoutIndex];
uint256 payout = drawPayouts[payoutIndex];
uint256 oldPayout = _getDrawPayoutBalanceOf(_user, drawId);
uint256 payoutDiff = 0;

// helpfully short-circuit, in case the user screwed something up.
require(payout > oldPayout, "PrizeDistributor/zero-payout");

unchecked {
payoutDiff = payout - oldPayout;
}

_setDrawPayoutBalanceOf(_user, drawId, payout);

totalPayout += payoutDiff;

emit ClaimedDraw(_user, drawId, payoutDiff);
}

_awardPayout(_user, totalPayout);

return totalPayout;
}

4. 마무리

지금까지 PoolTogether V4을 이루는 컴포넌트 중에서 V4 Periphery를 살펴보며 V4에서 구축하고 있는 상금 분배의 원리와 과정을 이해해보았습니다. 주목할 만한 것은 V4에서는 자체 거버넌스 투표를 통해 더욱 공평한 상금 분배를 목적으로 DPR을 도입하였다는 것이고, DPR을 통해 평균적으로 상금이 이자 수익률과 비슷한 수준으로 유지할 수 있었다는 것입니다. 현재 PoolTogether는 V5로 새롭게 업그레이드하였습니다. PoolTogether 팀이 향후 어떠한 방식으로 공정하고 투명한 프로토콜을 채택하고 발전해나갈 것인지 관망해볼 수 있을 것입니다.

5. 참고 레퍼런스

--

--