# Lottery Game * PancakeSwap Lottery - 跟一般認知的樂透很像 ![](https://i.imgur.com/AvBTgrF.png) * Pool Together - 主打不會輸錢的遊戲,每周開獎一次,把大家的錢拿去集資放貸,贏家獲得放貸的利息 ![](https://i.imgur.com/0U2LNBo.png) * APR 已經是穩定幣可以獲得的最高收益了 * 這期沒中獎會自動進入下一期 (彩票遞延) * 防止開獎前才進場,兩個禮拜內提款有懲罰 * blockhash 作為隨機數的缺點是 blockhash 是已知的 <!-- * Curve ![](https://i.imgur.com/HWcs32C.png) ![](https://i.imgur.com/lXK2lAR.png) * https://yearn.finance/#/vaults * https://www.convexfinance.com/stake * Mochi 戰爭 * FOMO3D 非常有名的資金盤遊戲 - Key 的價格大約依照 Key 發行量的平方根遞增 ![](https://i.imgur.com/pADmIO2.png) * 被破解方式是占用區塊讓時間內沒有人可以買 key * Olympus Dao (3, 3) - https://app.olympusdao.finance/#/dashboard * 算法穩定幣的一種,其他類別都陷入死亡螺旋,OHM 算堅持比較久 * OHM is backed by DAI but not pegged * 價格低於一美元,會回購並銷毀代幣,高於一美元會大量增發給 OHM 質押者,增發的基礎來自於高溢價,用大量增發 OHM 增加供給 * DeFi 2.0: 控制流動性 * 用 LP token 折價買入 Bond,使協議可以控制流動性 (Bond 有幾天的鎖定期),為協議提供收入同時減少 OHM 流通 ![](https://i.imgur.com/70AOZOE.png) --> # Pool Together 運作 (不含跨鏈) 1. 存款時,與 AAVE 互動,將 USDC 存入 AAVE 並質押 AAVE。 2. 每周開獎一次,開獎需在兩周內領取,否則存入下一期(充公)。 3. 中獎機率的計算方式根據 TWAB(time weighted average balance),如果存款 500 一半的時間,平均下來就是 250 。 4. TWAB 越大會有越多的 picks 提供兌獎。 5. 中獎計算方式: ![](https://i.imgur.com/IwQtP9b.png) 5. 獎金分布: 不同 tier 可能中獎人數不同,依照每個 tier 的分布決定該 tier 中獎的人可以分多少。 6. Pool 代幣,只能影響 governance (合約相關參數、或是要不要新開質押池之類的),質押 Pool 或 Pool/ETH LP 會有更多 Pool。但是 Pool 本身沒有價值基礎,一直增發只會提高賣壓 (上限 10m)。 # Pool Together Code Review - V4 的合約有分成 core, periphery, Timelocks, TWAB delegator, RNG services。Core 的部分是核心,負責定義整個項目的核心規則,其餘的比較像是輔助執行核心的計算工具,也就是其實可以另外寫合約呼叫core。 - Pool Together 的運作有幾個重點要搞清楚: ![](https://i.imgur.com/6f2f4Yd.png) - 存 USDC 進入 YieldSourcePrizePool, 會獲得1:1的 tickets 作為憑證 - prizeSplitStrategy 計算 PrizePool 裡面的價值,大於 tickets 的部分是獎金,因此把作為獎金的 tickets 轉移到 reserve。 - 然後 reserve 再把 tickets 轉移到 prizeDistributor - 使用者可以從 prizeDistributor 那裡獲得作為獎金的 tickets 到目前為止有個疑點是為甚麼中間會有 reserve 存在,還有就是每一輪的抽獎金額不同,這些資訊被寫進 prizeDistribution,具體怎麼紀錄的還不清楚。 - 假設 tickets 已經到達 prizeDistributor,中獎的人要怎麼提領 tickets 呢? ![](https://i.imgur.com/PurdOZG.png) - 紀錄過去的提領紀錄,確保不會重複提領 ```solidity= /// @notice Maps users => drawId => paid out balance mapping(address => mapping(uint256 => uint256)) internal userDrawPayouts; ``` - 輸入中獎序號,drawCalculator 負責計算金額,確定大於之前提領的總額,然後取得多的部分 ```solidity= /* ============ External Functions ============ */ /// @inheritdoc IPrizeDistributor function claim( address _user, uint32[] calldata _drawIds, bytes calldata _data // 中獎序號的部分 ) external override returns (uint256) { (uint256[] memory drawPayouts, ) = drawCalculator.calculate(_user, _drawIds, _data); // neglect the prizeCounts since we are not interested in them here // 迴圈是因為可以一次提領好幾輪的獎金 for (uint256 payoutIndex = 0; payoutIndex < drawPayoutsLength; payoutIndex++) { ... // 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; ... } _awardPayout(_user, totalPayout); return totalPayout; } ``` - drawCalculator 根據 PrizeDistributionSource 取得獎池資訊 ```solidity= struct PrizeDistribution { uint8 bitRangeSize; // 一個彩票號碼佔多少 bit,ex: 4 bits uint8 matchCardinality; // 有幾個彩票號碼,ex: 8 uint32 startTimestampOffset; // 計算 TWAB 開始時間 uint32 endTimestampOffset; // 計算 TWAB 結束時間 uint32 maxPicksPerUser; // 一個人最多幾張彩票 uint32 expiryDuration; // 兌獎時間最多距離該輪開始多久 uint104 numberOfPicks; // 獎池最多可以有幾張彩票 uint32[16] tiers; // 獎項等級,最多 16 級 uint256 prize; // 獎池大小 } ``` - drawCalculator 計算環節, 1. 把 _pickIndicesForDraws decode 成 pickIndices,是雙重陣列是因為不同 draw 會有不同組序號 2. 計算在不同 draw 的 TWAB,並 normalized 成比例 3. 雜湊使用者的地址 ```solidity= function calculate( address _user, uint32[] calldata _drawIds, bytes calldata _pickIndicesForDraws ) external view returns (uint256[] memory, bytes memory) { // 解碼抽獎序號 uint64[][] memory pickIndices = abi.decode(_pickIndicesForDraws, (uint64 [][])); ... // 取得獎金分發資訊 IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions = prizeDistributionSource .getPrizeDistributions(_drawIds); // 取得 normalized 過的 TWAB uint256[] memory userBalances = _getNormalizedBalancesAt(_user, draws, _prizeDistributions); // 雜湊使用者的錢包地址 bytes32 _userRandomNumber = keccak256(abi.encodePacked(_user)); //計算獎金 return _calculatePrizesAwardable( userBalances, _userRandomNumber, draws, pickIndices, _prizeDistributions ); } ``` - _calculatePrizesAwardable 計算 totalUserPicks,是 user 在每一個 draw 中能提交序號的最大上限 ```solidity= function _calculatePrizesAwardable( uint256[] memory _normalizedUserBalances, bytes32 _userRandomNumber, IDrawBeacon.Draw[] memory _draws, uint64[][] memory _pickIndicesForDraws, IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions ) internal view returns (uint256[] memory prizesAwardable, bytes memory prizeCounts) { // 獎金 uint256[] memory _prizesAwardable = new uint256[](_normalizedUserBalances.length); // 獎項等級 uint256[][] memory _prizeCounts = new uint256[][](_normalizedUserBalances.length); // calculate prizes awardable for each Draw passed for (uint32 drawIndex = 0; drawIndex < _draws.length; drawIndex++) { // 計算 user 在每一個 draw 中能提交序號的最大上限 uint64 totalUserPicks = _calculateNumberOfUserPicks( _prizeDistributions[drawIndex], _normalizedUserBalances[drawIndex] ); ... (_prizesAwardable[drawIndex], _prizeCounts[drawIndex]) = _calculate( _draws[drawIndex].winningRandomNumber, // 每一輪抽獎的中獎數字 totalUserPicks, // 序號上限 _userRandomNumber, // user 地址雜湊出的隨機數 _pickIndicesForDraws[drawIndex], // 提交序號 _prizeDistributions[drawIndex] // 獎金分配 ); } } ``` - _calculateNumberOfUserPicks 利用TWAB計算提交序號上限 ```solidity= function _calculateNumberOfUserPicks( IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution, uint256 _normalizedUserBalance ) internal pure returns (uint64) { return uint64((_normalizedUserBalance * _prizeDistribution.numberOfPicks) / 1 ether); } // 1 ether = 1e18 /** * @notice Calculates the normalized balance of a user against the total supply for timestamps * @param _user The user to consider * @param _draws The draws we are looking at * @param _prizeDistributions The prize tiers to consider (needed for draw timestamp offsets) * @return An array of normalized balances */ // 計算標準化的 TWAB function _getNormalizedBalancesAt( address _user, IDrawBeacon.Draw[] memory _draws, IPrizeDistributionSource.PrizeDistribution[] memory _prizeDistributions ) internal view returns (uint256[] memory) { ... // 先算使用者的 TWAB uint256[] memory balances = ticket.getAverageBalancesBetween( _user, _timestampsWithStartCutoffTimes, _timestampsWithEndCutoffTimes ); // 再算全部使用者的 TWAB uint256[] memory totalSupplies = ticket.getAverageTotalSuppliesBetween( _timestampsWithStartCutoffTimes, _timestampsWithEndCutoffTimes ); uint256[] memory normalizedBalances = new uint256[](drawsLength); // divide balances by total supplies (normalize) // balances / supplies 就是 normalizedBalances for (uint256 i = 0; i < drawsLength; i++) { if(totalSupplies[i] == 0){ normalizedBalances[i] = 0; } else { normalizedBalances[i] = (balances[i] * 1 ether) / totalSupplies[i]; // 乘 1 ether 是因為 solidity 沒有浮點數,之後要除掉 } } return normalizedBalances; } ``` - _calculate 計算獲得獎金和獎項等級 ```solidity= function _calculate( uint256 _winningRandomNumber, uint256 _totalUserPicks, bytes32 _userRandomNumber, uint64[] memory _picks, IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution ) internal pure returns (uint256 prize, uint256[] memory prizeCounts) { // create bitmasks for the PrizeDistribution // 把中獎號碼做成 masks,給兌獎時需要 uint256[] memory masks = _createBitMasks(_prizeDistribution); ... // for each pick, find number of matching numbers and calculate prize distributions index for (uint32 index = 0; index < picksLength; index++) { // 注意這兩個條件,序號要小於 _totalUserPicks,並且都是遞增,代表如果計算來的 _totalUserPicks 是 20,那麼能提交的序號最多是 1-20 require(_picks[index] < _totalUserPicks, "DrawCalc/insufficient-user-picks"); if (index > 0) { require(_picks[index] > _picks[index - 1], "DrawCalc/picks-ascending"); } // hash the user random number with the pick value // 把序號和 _userRandomNumber 雜湊成兌獎號碼 uint256 randomNumberThisPick = uint256( keccak256(abi.encode(_userRandomNumber, _picks[index])) ); // 計算該號碼中幾等獎 uint8 tiersIndex = _calculateTierIndex( randomNumberThisPick, _winningRandomNumber, masks ); ... } ... // return the absolute amount of prize awardable // div by 1e9 as prize tiers are base 1e9 // 獎項累積分成 * 獎池 prize = (prizeFraction * _prizeDistribution.prize) / 1e9; } ``` - 兌獎方式 ![](https://i.imgur.com/4IAwEEM.png) ![](https://i.imgur.com/NEs7oys.png) ```solidity= function _createBitMasks(IPrizeDistributionSource.PrizeDistribution memory _prizeDistribution) internal pure returns (uint256[] memory) { uint256[] memory masks = new uint256[](_prizeDistribution.matchCardinality); masks[0] = (2**_prizeDistribution.bitRangeSize) - 1; for (uint8 maskIndex = 1; maskIndex < _prizeDistribution.matchCardinality; maskIndex++) { // shift mask bits to correct position and insert in result mask array masks[maskIndex] = masks[maskIndex - 1] << _prizeDistribution.bitRangeSize; } return masks; } ``` ```solidity= function _calculateTierIndex( uint256 _randomNumberThisPick, uint256 _winningRandomNumber, uint256[] memory _masks ) internal pure returns (uint8) { uint8 numberOfMatches = 0; uint8 masksLength = uint8(_masks.length); // main number matching loop for (uint8 matchIndex = 0; matchIndex < masksLength; matchIndex++) { uint256 mask = _masks[matchIndex]; // 如果兩個不相等就跳出迴圈,相等就繼續下一輪 if ((_randomNumberThisPick & mask) != (_winningRandomNumber & mask)) { // there are no more sequential matches since this comparison is not a match ... } // else there was a match numberOfMatches++; } return masksLength - numberOfMatches; } ``` - 對於 prizeDistributor 來說,只要有 drawBuffer 和 prizeDistribution source 就可以正常發放獎勵。drawBuffer 負責透過 drawBeacon 向 chainlink 取得隨機數字做為中獎號碼,中獎號碼必須隱私,因為 hash 的結果是固定的,如果中獎號碼公開,作弊者可以先計算需要多少 pick 就能獲得多少獎項。 - 任何人都可以啟動 startDraw,startDraw 必須準備 link 代幣支付預言機取得 rng 的費用 ![](https://i.imgur.com/tIBQ5YB.png) ```solidity= /** * @notice RNG Request * @param id RNG request ID * @param lockBlock Block number that the RNG request is locked * @param requestedAt Time when RNG is requested */ struct RngRequest { uint32 id; uint32 lockBlock; uint64 requestedAt; //要求時間 } /// @inheritdoc IDrawBeacon function startDraw() external override requireCanStartDraw { // 取得代幣地址和要求數量 (address feeToken, uint256 requestFee) = rng.getRequestFee(); // 送錢,allowance 代表不是立即轉帳,而是先授權給 rngService 使用 if (feeToken != address(0) && requestFee > 0) { IERC20(feeToken).safeIncreaseAllowance(address(rng), requestFee); } // 送出請求 (uint32 requestId, uint32 lockBlock) = rng.requestRandomNumber(); rngRequest.id = requestId; // ID rngRequest.lockBlock = lockBlock; // ?? rngRequest.requestedAt = _currentTime(); //要求時間 emit DrawStarted(requestId, lockBlock); } ``` - 在啟動新的 draw 之前,要先確認幾件事情 ```solidity= // modifier 是修飾子,_ 代表函式的執行步驟,可以看到在進行真正的執行步驟前,會進行兩項審查 modifier requireCanStartDraw() { // 上個 beacon 必須結束 require(_isBeaconPeriodOver(), "DrawBeacon/beacon-period-not-over"); // 不可以有 request 過的紀錄 require(!isRngRequested(), "DrawBeacon/rng-already-requested"); _; } function _isBeaconPeriodOver() internal view returns (bool) { return _beaconPeriodEndAt() <= _currentTime(); } function isRngRequested() public view override returns (bool) { return rngRequest.id != 0; } ``` - 向 rng 取得 randomNumber 不是立即的,也有可能會索取失敗。失敗的話,需要取消這次的 request 並且重新發送一個。 ```solidity= /// @inheritdoc IDrawBeacon function cancelDraw() external override { // 超過 rng 的等待時間了 require(isRngTimedOut(), "DrawBeacon/rng-not-timedout"); uint32 requestId = rngRequest.id; uint32 lockBlock = rngRequest.lockBlock; delete rngRequest; // 取消 request emit DrawCancelled(requestId, lockBlock); } ``` - 如果 rng 成功,該 draw 就算完成。 ```solidity= /// @inheritdoc IDrawBeacon function completeDraw() external override requireCanCompleteRngRequest { uint256 randomNumber = rng.randomNumber(rngRequest.id); // 取得隨機數字 ... // create Draw struct IDrawBeacon.Draw memory _draw = IDrawBeacon.Draw({ winningRandomNumber: randomNumber, // 中獎號碼 drawId: _nextDrawId, // draw 編號 timestamp: rngRequest.requestedAt, // must use the startAward() timestamp to prevent front-running // 發送要求時間 beaconPeriodStartedAt: _beaconPeriodStartedAt, // 這個 beacon 可以開始的時間 beaconPeriodSeconds: _beaconPeriodSeconds // beacon 持續時間 }); drawBuffer.pushDraw(_draw); // 把新的 draw 推上 drawBuffer // to avoid clock drift, we should calculate the start time based on the previous period start time. // 計算下個 beacon 可以開始的時間 uint64 nextBeaconPeriodStartedAt = _calculateNextBeaconPeriodStartTime( _beaconPeriodStartedAt, _beaconPeriodSeconds, _time ); beaconPeriodStartedAt = nextBeaconPeriodStartedAt; nextDrawId = _nextDrawId + 1; // 更新 draw 編號 // Reset the rngRequest state so Beacon period can start again. // 刪除舊的 request delete rngRequest; emit DrawCompleted(randomNumber); emit BeaconPeriodStarted(nextBeaconPeriodStartedAt); } ``` ## Yield Source 流程 * 當使用者將 USDC 存入 prizePool 之後,會獲得 ticket 作為憑證,ticket 同時也是中獎可以領取的獎勵。prizePool 獲得 usdc 後存入 AtokenYieldSource,再由 YieldSource 存入 AAVE 的 lendingPool。AtokenYieldSource 會給 prizePool shares 作為憑證。YieldSource 不能更改,意味只能以 AAVE USDC 作為收益來源。 ![](https://i.imgur.com/GOE7zQf.png) * 從 etherscan 的交易紀錄來看 ![](https://i.imgur.com/f8k7ig6.png) * code ```solidity= 1. PrizePool // 起始的 function function depositTo(address _to, uint256 _amount) external override nonReentrant canAddLiquidity(_amount) { _depositTo(msg.sender, _to, _amount); } function _depositTo(address _operator, address _to, uint256 _amount) internal { require(_canDeposit(_to, _amount), "PrizePool/exceeds-balance-cap"); ITicket _ticket = ticket; // user 存入 USDC _token().safeTransferFrom(_operator, address(this), _amount); // 發 ticket 給 user _mint(_to, _amount, _ticket); _supply(_amount); emit Deposited(_operator, _to, _ticket, _amount); } function _supply(uint256 _mintAmount) internal override { _token().safeIncreaseAllowance(address(yieldSource), _mintAmount); yieldSource.supplyTokenTo(_mintAmount, address(this)); // 呼叫 yieldSource } // 任何人都可以呼叫這個函數,但不透過 prizePool 就不能參加抽獎 2. yieldSource function supplyTokenTo(uint256 mintAmount, address to) external override nonReentrant { uint256 shares = _tokenToShares(mintAmount); // 計算分發多少 share require(shares > 0, "ATokenYieldSource/shares-gt-zero"); _depositToAave(mintAmount); // 存 USDC 進入 AAVE _mint(to, shares); // emit SuppliedTokenTo(msg.sender, shares, mintAmount, to); } function _depositToAave(uint256 mintAmount) internal { address _underlyingAssetAddress = _tokenAddress(); // USDC address ILendingPool __lendingPool = _lendingPool(); // AAVE lending pool IERC20 _depositToken = IERC20(_underlyingAssetAddress); // 從 prizePool 取得 USDC _depositToken.safeTransferFrom(msg.sender, address(this), mintAmount); // 存入 lending pool 獲得 aUSDC __lendingPool.deposit(_underlyingAssetAddress, mintAmount, address(this), REFERRAL_CODE); } ``` * 相反的,如果使用者想退出,就呼叫 withdrawFrom ,然後銷毀 ticket ```solidity= function withdrawFrom(address _from, uint256 _amount) external override nonReentrant returns (uint256) { ITicket _ticket = ticket; // burn the tickets _ticket.controllerBurnFrom(msg.sender, _from, _amount); // redeem the tickets uint256 _redeemed = _redeem(_amount); _token().safeTransfer(_from, _redeemed); emit Withdrawal(msg.sender, _from, _ticket, _amount, _redeemed); return _redeemed; } ``` * 可以發現,每一次有人要參加遊戲,就只是把 USDC 存進 AAVE 裡,那這樣的報酬應該跟 AAVE 的借貸利率一樣。但是... ![](https://i.imgur.com/MZX1wCk.png) ![](https://i.imgur.com/44Bz2Ya.png) 這邊 Pool Together 計算出來的報酬率 13.67%,明顯高於 AAVE。而文件寫的APR計算方式是: ![](https://i.imgur.com/WlQISh1.png) ## 分獎勵給 prizeDistributor * 所有的獎金都是以 ticket 的形式從 prizeDistributor 發送出去。透過 periphery/PrizeFlush 呼叫 prizeSplitStrategy 把 ticket 從 prizePool 送到 reserve 然後再到 prizeDistributor。 * 每次 ticket 經過 reserve,都會觸發 _checkpoint(),並更新 reserve accumulators。便可以計算兩個時間點中間,prize pool 總共獲得的多少收益。我猜測項目是以 reserve accumulators 的紀錄計算每一輪次可以發放多少獎金。 ![](https://i.imgur.com/E1bxVtB.png) ```solidity= 1. PrizeFlush // 呼叫 prizeSplitStrategy,之後計算 reserve 有的 token 數量,並轉移到 prize Distributor function flush() external override onlyManagerOrOwner returns (bool) { // Captures interest from PrizePool and distributes funds using a PrizeSplitStrategy. // 獎金送到 reserve strategy.distribute(); // After funds are distributed using PrizeSplitStrategy we EXPECT funds to be located in the Reserve. // 計算 reserve 有多少 ticket 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) { ... // Create checkpoint and transfers new total balance to PrizeDistributor // reserve 轉移 ticket 到 prizeDistributor _reserve.withdrawTo(_destination, _amount); emit Flushed(_destination, _amount); return true; } return false; } 2. prizeSplitStrategy // 計算 prizePool 累積多少 interest 了,如果有多的,就轉移到 reserve // function distribute() external override returns (uint256) { uint256 prize = prizePool.captureAwardBalance(); ... // 讓 prize pool 鑄造 ticket 給 reserve // 值得注意的是 這裡並不的將 prize 分量的 ticket 分出去,而是 (prize - 總發放 ticket) uint256 prizeRemaining = _distributePrizeSplits(prize); emit Distributed(prize - prizeRemaining); return prize; } 3. prizePool function captureAwardBalance() external override nonReentrant returns (uint256) { ... // 計算 yieldSource 等值多少 USDC uint256 currentBalance = _balance(); // 計算總利息利息 uint256 totalInterest = (currentBalance > ticketTotalSupply) ? currentBalance - ticketTotalSupply : 0; // 計算比上次計算多出來的利息 uint256 unaccountedPrizeBalance = (totalInterest > currentAwardBalance) ? totalInterest - currentAwardBalance : 0; ... // 回傳總共的利息 // 不回傳多出來的利息是因為每次鑄造新的 ticket 給 reserve,currentAwardBalance 都會降低 return currentAwardBalance; } 4. prizeSplit & PrizeSplitStrategy function _distributePrizeSplits(uint256 _prize) internal returns (uint256) { ... for (uint256 index = 0; index < prizeSplitsLength; index++) { ... // 呼叫 prizePool 鑄造 tickets 給 reserve // 只有 prizePool 有 ticket 的控制權 _awardPrizeSplitAmount(split.target, _splitAmount); // Update the remaining prize amount after distributing the prize split percentage. _prizeTemp -= _splitAmount; } return _prizeTemp; } function _awardPrizeSplitAmount(address _to, uint256 _amount) internal override { ... prizePool.award(_to, _amount); ... } 6. prizePool function award(address _to, uint256 _amount) external override onlyPrizeStrategy { ... uint256 currentAwardBalance = _currentAwardBalance; ... // 計算總發放 interest unchecked { _currentAwardBalance = currentAwardBalance - _amount; } ITicket _ticket = ticket; _mint(_to, _amount, _ticket); emit Awarded(_to, _ticket, _amount); } 5. reserve /// @inheritdoc IReserve // 建立 checkpoint,並轉移 ticket 到 prize distributor function withdrawTo(address _recipient, uint256 _amount) external override onlyManagerOrOwner { _checkpoint(); withdrawAccumulator += uint224(_amount); token.safeTransfer(_recipient, _amount); emit Withdrawn(_recipient, _amount); } /// @notice Records the currently accrued reserve amount. // 每次 ticket 經過 reserve 都會更新 reserveAccumulators // reserveAccumulators 紀錄該時間點的總 ticket 數量,包含之前時間點的加總 function _checkpoint() internal { ... /** * IF tokens have been deposited into Reserve contract since the last checkpoint * create a new Reserve balance checkpoint. The will will update multiple times in a single block. */ // amount 必須增加 if (_balanceOfReserve + _withdrawAccumulator > newestObservation.amount) { uint32 nowTime = uint32(block.timestamp); // checkpointAccumulator = currentBalance + totalWithdraws uint224 newReserveAccumulator = uint224(_balanceOfReserve) + _withdrawAccumulator; // IF newestObservation IS NOT in the current block. // CREATE observation in the accumulators ring buffer. // 時間必須增加 if (newestObservation.timestamp != nowTime) { reserveAccumulators[_nextIndex] = ObservationLib.Observation({ amount: newReserveAccumulator, timestamp: nowTime }); ... } // ELSE IF newestObservation IS in the current block. // UPDATE the checkpoint previously created in block history. else { reserveAccumulators[newestIndex] = ObservationLib.Observation({ amount: newReserveAccumulator, timestamp: nowTime }); } ... } } ``` ## 取得每一期獎金資訊 (還不太清楚) * PrizeTierHistory 紀錄每個 drawId 的獎金, PrizeDistributionFactory 結合 PrizeTier 的資料推到 PrizeDistributionBuffer 上面。但是誰把資料傳給 PrizeTierHistory 的還不清楚。 ```solidity= // @inheritdoc IPrizeTierHistory // 只有 owner 能 push 資料 function push(PrizeTier calldata nextPrizeTier) override external onlyManagerOrOwner { _push(nextPrizeTier); } ``` owner address: [0xDa63D70332139E6A8eCA7513f4b6E2E0Dc93b693](https://etherscan.io/address/0xda63d70332139e6a8eca7513f4b6e2e0dc93b693) 這是一個 proxy factory,還不清楚是幹嘛的 ## [Launch Architecture](https://v4.docs.pooltogether.com/assets/images/V4_Launch_Architecture-353dc5476e169fd1d97c5ef24fdc9f0b.png) ## Yield Source Design - 流程介紹: 在 venus 存入 BUSD 可以獲得利息,同時 venus 會給與 XVS 作為獎勵,把 XVS 拿去 pancake swap 換成 BUSD 後重複存入 venus。 ![](https://i.imgur.com/vZR8XG7.png) 1. send BNB to VENUS VBNB and receive vBNB ([bscscan])(https://bscscan.com/tx/0x90d52854f04bbb2f5745545c6f9f4f64e2d44eb87460fae4ba85f6f1ceeda578) ![](https://i.imgur.com/GfDulpU.png) 2. claim XVS ([bscscan])(https://bscscan.com/tx/0x82518876d6c485b1498dd399d3a7d88a9bfae6f8e6f42cfbb50b5de3834581a7) 在不同 pool 進行 supply 和 borrow 會分開轉帳,可能有多筆 ![](https://i.imgur.com/HmqDhtl.png) 3. swap XVS for BUSD via PancakeSwap ([bscscan])(https://bscscan.com/tx/0xec6a84c97baa4871368fbdb52f7db0b3b75fbada48f3e9e3be98702fe31c07d2) ![](https://i.imgur.com/cfEHfWG.png) ```solidity= - VENUS/Vtoken // mint -> mintInternal -> mintFresh function mintFresh(address minter, uint mintAmount) internal returns (uint, uint) { ... /* * We call `doTransferIn` for the minter and the mintAmount. * Note: The vToken must handle variations between BEP-20 and BNB underlying. * `doTransferIn` reverts if anything goes wrong, since we can't be sure if * side-effects occurred. The function returns the amount actually transferred, * in case of a fee. On success, the vToken holds an additional `actualMintAmount` * of cash. 同時處理 BNB 和 BEP20 情況 */ // 存入代幣,回傳實際收到的數值 vars.actualMintAmount = doTransferIn(minter, mintAmount); /* * We get the current exchange rate and calculate the number of vTokens to be minted: * mintTokens = actualMintAmount / exchangeRate */ /* Exp 是一個 struct,專門處理浮點數運算?? struct Exp { uint mantissa; } 基本上精確到 18 位,先做分子的相乘在乘以 1e18 在除分母,處理 solidity 沒有浮點數的問題 */ (vars.mathErr, vars.mintTokens) = divScalarByExpTruncate(vars.actualMintAmount, Exp({mantissa: vars.exchangeRateMantissa})); require(vars.mathErr == MathError.NO_ERROR, "MINT_EXCHANGE_CALCULATION_FAILED"); ... /* We write previously calculated values into storage */ totalSupply = vars.totalSupplyNew; accountTokens[minter] = vars.accountTokensNew; /* We emit a Mint event, and a Transfer event */ emit Mint(minter, vars.actualMintAmount, vars.mintTokens); emit Transfer(address(this), minter, vars.mintTokens); ... return (uint(Error.NO_ERROR), vars.actualMintAmount); } - VENUS/comptroller // 對不同的 holders、對不同的 vtoken pool、對 borrow&supply 分開給予 XVS function claimVenus(address[] memory holders, VToken[] memory vTokens, bool borrowers, bool suppliers) public { ... for (uint i = 0; i < vTokens.length; i++) { ... if (borrowers) { ... for (j = 0; j < holders.length; j++) { // 把可以分發的 XVS 紀錄進 venusAccrued[address] 裡面 distributeBorrowerVenus(address(vToken), holders[j], borrowIndex); // 根據 venusAccrued 給予 XVS,grant成功回傳0 venusAccrued[holders[j]] = grantXVSInternal(holders[j], venusAccrued[holders[j]]); } } if (suppliers) { ... // same as top } } } } - PancakeSwap function swapExactTokensForTokens( uint amountIn, uint amountOutMin, address[] calldata path,// 多筆 path 給予最好的交易路徑 address to, uint deadline ) external virtual override ensure(deadline) returns (uint[] memory amounts) { amounts = PancakeLibrary.getAmountsOut(factory, amountIn, path); // 限制滑價或是交易路徑必須比直接換好 require(amounts[amounts.length - 1] >= amountOutMin, 'PancakeRouter: INSUFFICIENT_OUTPUT_AMOUNT'); ... _swap(amounts, path, to); } - yearn finance/harvest // 領取 staking 的獎勵並換成想要的代幣 function harvest() public { ... // 收割 voter 的獎勵 IVoterProxy(proxy).harvest(gauge); // 計算獲得的 crv uint256 _crv = IERC20(crv).balanceOf(address(this)); if (_crv > 0) { // 10% 拿去重複投票 uint256 _keepCRV = _crv.mul(keepCRV).div(FEE_DENOMINATOR); IERC20(crv).safeTransfer(voter, _keepCRV); _crv = _crv.sub(_keepCRV); // 90% 換成 USDC IERC20(crv).safeApprove(dex, 0); IERC20(crv).safeApprove(dex, _crv); // 路徑被決定好 CRV-> WETH -> USDC address[] memory path = new address[](3); path[0] = crv; path[1] = weth; path[2] = usdc; // 不限制滑價 Uni(dex).swapExactTokensForTokens(_crv, uint256(0), path, address(this), now.add(1800)); } ... // USDC 換成 EURS uint256 _usdc = IERC20(usdc).balanceOf(address(this)); if (_usdc > 0) { ... address[] memory path = new address[](2); path[0] = usdc; path[1] = eurs; Uni(uniswap).swapExactTokensForTokens(_usdc, uint256(0), path, address(this), now.add(1800)); } // EURS 存入 curve 的 liquidity pool uint256 _eurs = IERC20(eurs).balanceOf(address(this)); if (_eurs > 0) { IERC20(eurs).safeApprove(curve, 0); IERC20(eurs).safeApprove(curve, _eurs); ICurveFi(curve).add_liquidity([_eurs, 0], 0); } ... } ``` ## 博奕遊戲構思 - 首先,pool together 的號碼機制比較像是等兌獎號碼來撞你自己的號碼。因為開獎號碼是由預言機提供亂數,理論上有無限多種可能。而使用者的 ticket 是由地址和 picks 亂數而成,如果每一期的份額不變,使用者的彩票號碼也會不變。picks 是由系統決定,是中獎號碼的組合數。 這樣做的好處是節省 gas fee,使用除了存入 USDC 以外不需要多餘的操作,彩票號碼已經與份額綁定了。 - 大樂透的玩法是 6個 1-49 的號碼,不可重複,總共有 10,068,347,520 種組合,以 bit 來看,在 2^33 ~ 2^34 之間,最多就是 8 bytes。概念上在原本 pool together 的比對機制中,只能比較前 8 個數字,如果超過,就會發生某些數字,大樂透開獎號碼完全無法觸碰到。 - 另一個問題是怎麼解決有兩個人同時中獎的情形。其實回頭來看,pool together 並沒有預防這件事情,他在前面的步驟已經阻擋這個可能了嗎? keccak256 是產生 256 bits 亂數的 function,兌獎時產生 mask 只比較前 32 個 bit 是否一樣,因此這裡 picks 的供給上限就是 2^32。但是把地址和 picks 亂數產生的彩票號碼是會散在 2^256 的空間中,因此有可能發生兩個彩票號碼不一樣但是 mask 的部分完全一樣的情形(碰撞機率可以忽略, sha1 的碰撞機率 < 10e-19)。 ### PancakeSwap Prediction ![](https://i.imgur.com/hWzJ6PI.png) - 下注下個五分鐘的開始到結束的價格是上升還是下降( 非下注當下,對莊家最安全 ),價格由 chainlink 決定 - 一輪只能下一次注 - 莊家抽 3%,平均一輪下注 20BNB( 8000 USD ) - 獎金根據對手獎池決定,莊家沒有風險,抽 3% 水錢 - 價格不變得話,莊家通吃,對手盤沒下注而且輸的話,莊家通吃,贏的話還沒錢拿 ```solidity= // 狀態宣告: 買漲還是買跌 enum Position { Bull, Bear } // round 的開始、結束、停止下注、兩邊下注金額、獎池大小等資料 struct Round { uint256 epoch; uint256 startTimestamp; uint256 lockTimestamp; uint256 closeTimestamp; int256 lockPrice; int256 closePrice; uint256 lockOracleId; uint256 closeOracleId; uint256 totalAmount; uint256 bullAmount; uint256 bearAmount; uint256 rewardBaseCalAmount; uint256 rewardAmount; bool oracleCalled; } // 使用者的下注方向、金額、是否領獎 struct BetInfo { Position position; uint256 amount; bool claimed; // default false } // 每個輪次、每個使用者的下注狀態 mapping(uint256 => mapping(address => BetInfo)) public ledger; // 每個輪次的獎池狀態 mapping(uint256 => Round) public rounds; // 每個使用者參加的輪次 mapping(address => uint256[]) public userRounds; ``` ```mermaid sequenceDiagram participant User participant Governor participant Prediction participant oracle participant LendingPool loop startRound Governor ->> Prediction: executeRound() Prediction ->> oracle: (uint80 currentRoundId, int256 currentPrice) = _getPriceFromOracle() Prediction ->> Prediction: _safeLockRound(currentEpoch, currentRoundId, currentPrice); Prediction ->> Prediction: _safeEndRound(currentEpoch - 1, currentRoundId, currentPrice); Prediction ->> Prediction: _calculateRewards(currentEpoch - 1); Prediction ->> Prediction: _safeStartRound(currentEpoch+1); end loop bet User ->> Prediction: betBear(uint256 epoch) / betBull(uint256 epoch) Prediction ->> Prediction: depositAmount = balanceOf( address(this) ) Prediction ->> Prediction: update Round Info and Betting Info Prediction ->> LendingPool: deposit( uint256 depositAmount ) end loop claim User ->> Prediction: reward = claim(uint256[] calldata epochs) Prediction ->> LendingPool: withdraw( uint256 reward / exchangeRate + 1 ) Prediction ->> User: IERC20( tokenAddress ).safeTransfer( msg.sender, reward ) end ``` ## 運彩形式 Fixed odds vs pari mutuel - 運彩是由項目方做莊擔保,開出賭盤,賭客下注並獲得中獎獎勵的遊戲。項目方最大的風險是開盤風險,運彩公司開出來的賠率隱含兩隊的獲勝機率,當運彩公司錯估勝率時,就會被套利。EX: 假設市場認定A隊的勝率是70%,運彩公司開出來的勝率是50%,大家會下注A隊,運彩公司無法以B隊的資金支付A隊的資金。 - 即使運彩公司發現後改變賠率,仍然會有損失。因為下注成功的收益是根據購買當下的賠率決定,因此運彩公司也會隨時改變賠率。 - 台灣運彩很黑,抽水大約 12%,國外5%。 - 運彩公司的收益是水錢。EX:兩邊賠率都是 1.75:1.75。隱含的獲勝機率分別都是 0.5714,相加 114%,這代表你是為 114% 的勝率支付價錢,但是現實獲勝機率是 100%。多出來的 14% 是運彩公司的收益。 - 打水: 兩間運彩公司開出來的賠率不一致時的套利方式,分別購買兩邊賠率較高的隊伍賺取收益。 - 相關套利基本上是根據 fixed odds 規則進行的 - 賠率表示方式: ![](https://i.imgur.com/e2MieKd.png) +: 下注 100 能贏的金額 -: 要贏 100 必須下注的金額 - 盤口: 輸贏盤、大小分盤、讓分盤 - 走地盤、滾球: 賠率隨著賽況變動的盤口 ### pari mutuel - 法國開始,源自於賽馬,單純根據彩池大小分派獎金,因此下注時不知道可能獲得的收益。對於莊家風險較小。 ## 美國樂透 - 領獎期限: 180 天內 - 獎金稅率: 600 以上就要繳費,稅率最高 40 % - 種類: - Mega Millions美國大百萬樂透: - 一般號 70 選 5 + 特別號 25 選 1 - 美西時間每周二、周五晚上8點,開獎當日7:45pm截止購票 - Megaplier: 加 1 元購買,獲得翻倍的權利 - ![](https://i.imgur.com/v4QUKkv.png) - PowerBall: - 一般號 52 選 5 + 特別號 10 選 1 - 一注2元,加1元購買 powerplay,powerplay中獎可以翻倍 - ![](https://i.imgur.com/p0w2egC.png) ## 新加坡樂透 - TOTO - 買五個號碼(49選1),開獎開6個號碼加一個特別號 - ![](https://i.imgur.com/iJ9mg9P.png) - 4D - 0000~9999選一個號碼 - ![](https://i.imgur.com/0F8uKBA.png) - 中獎拿固定獎金 - ![](https://i.imgur.com/QCEk9Be.png) ### NFT 的形式 - 以 gas fee 來考量,Ethereum 上一張 NFT 價格 30 美金,polygon 上面 gas fee 0.03 美金, BSC 則是 0.3 美金,以這個角度 NFT 的實行還算可行 - NFT 的困難點在於沒有辦法確認各獎項的實際中獎人數。Pool Together 因為使用者不能選號,每個人的號碼是由 地址和份額 產生,確保的隨機性,用機率控制獎項的中獎人數。 - NFT 因為有了選號的權利,因此沒有隨機性,及有可能發生同一個獎項多人領取的情形,如果系統預先辨別,會發生第一個人領完之後後面的人不能領的狀況。 - 解決辦法分為鏈上和鏈下兩種,鏈下是由項目方先行計算可能的中獎人數,並推上區塊鏈系統,確保獎金分配。缺點是不夠自動化。 鏈上則是由中獎的人提交證明,待之後再由系統平均分配獎金給有提交的人。缺點是會有審核期,EX: 可以提交證明的期限是一個禮拜,等一個禮拜後才能收到獎金。 - 如果是以玩家利息作為獎金,且 NFT 形式的情況下,如何做到時間加權的效果,可能的方式就是越晚買越貴,ex: 一張彩票開始價格 100 U,經過一半時間後價格變為200,價格漲幅和時間剩餘比例成反比,這樣才是 fair game,中獎機率和提供利息的比例相同,假設利率不變。 - 假設三天開獎一次,4*24*3=288 個 15 分鐘,彩票金額會是一開始的 288 倍。 - 如果搭配一個禮拜的兌獎期,其實這段時間可以白嫖利息,大概來說,一開始就買的人打 0.7 折購買彩券就可以了。 - 以玩家獎金做為獎金不需要考慮這個問題。 ```solidity= 1. Lottery.sol // Represents the status of the lottery enum Status { NotStarted, // The lottery has not started yet Open, // The lottery is open for ticket purchases Closed, // The lottery is no longer open for ticket purchases Completed // The lottery has been closed and the numbers drawn } // All the needed info around a lottery struct LottoInfo { uint256 lotteryID; // ID for lotto Status lotteryStatus; // Status for lotto uint256 prizePoolInCake; // The amount of cake for prize money uint256 costPerTicket; // 一張彩票的價格 Cost per ticket in $cake uint8[] prizeDistribution; // 獎金分布 The distribution for prize money uint256 startingTimestamp; // Block timestamp for star of lotto uint256 closingTimestamp; // Block timestamp for end of entries uint16[] winningNumbers; // 中獎號碼 The winning numbers } // Lottery ID's to info mapping(uint256 => LottoInfo) internal allLotteries_; 2. LotteryNFT.sol // 彩票的擁有者、數字、是否領獎、屬於哪一輪次 struct TicketInfo { address owner; uint16[] numbers; bool claimed; uint256 lotteryId; } // 每個 ID 對應的狀態 Token ID => Token information mapping(uint256 => TicketInfo) internal ticketInfo_; // // User address => Lottery ID => Ticket IDs mapping(address => mapping(uint256 => uint256[])) internal userTickets_; ``` ```mermaid sequenceDiagram participant User participant Governor participant Lottery participant LotteryNFT participant YieldSourcePool loop createNewLotto Governor->>Lottery: 1. createNewLotto(<br>uint8[] calldata _prizeDistribution,<br>uint256 _prizePoolInCake,<br>uint256 _costPerTicket,<br>uint256 _startingTimestamp,<br>uint256 _closingTimestamp<br>) Governor->>Lottery: 2. pushRealWorldLottteryNumber( uint256 _lotteryId, uint256[] lotteryNumber ) Governor->>Lottery: 3. createNewLotto(<br>uint8[] calldata _prizeDistribution,<br>uint256 _prizePoolInCake,<br>uint256 _costPerTicket,<br>uint256 _startingTimestamp,<br>uint256 _closingTimestamp<br>) end loop Buy & Claim Ticket User->>Lottery:1. batchBuyLottoTicket(<br>uint256 _lotteryId,<br>uint8 _numberOfTickets,<br>uint16[] calldata _chosenNumbersForEachTicket<br>) Lottery ->>Lottery:costWithDiscount = costToBuyTicketsWithDiscount(_lotteryId, _numberOfTickets) User ->>Lottery:usdc.transferFrom(<br>msg.sender, <br>address(this),<br>costWithDiscount<br>); Lottery ->>LotteryNFT:ticketIds = batchMint(<br>msg.sender,<br>_lotteryId,<br>_numberOfTickets,<br>_chosenNumbersForEachTicket,<br>sizeOfLottery_<br>) LotteryNFT ->>LotteryNFT:ticketInfo_[totalSupply_] = TicketInfo(<br>_to,<br>numbers,<br>false,<br>_lotteryId<br>) Lottery ->>YieldSourcePool:deposit(address minter, uint costWithDiscount, address usdc) User ->>Lottery:2. claimReward(uint256 _lotteryId, uint256 _tokenId) Lottery ->>Lottery:matchingNumbers = _getNumberOfMatching(<br>nft_.getTicketNumbers(_tokenId),<br>allLotteries_[_lotteryId].winningNumbers<br>) Lottery ->>Lottery:prizeAmount = _prizeForMatching(<br>matchingNumbers,<br>_lotteryId<br>); User->>Lottery: 3. distributePrize(uint[] _lotteryIds ) Lottery ->>YieldSourcePool:withdraw(address minter, uint prizeAmount/exchangeRate+1, address usdc) Lottery ->>User:usdc.safeTransfer(address(msg.sender), prizeAmount); end ``` ### 小結 | | 樂透 | 二元選擇權 | 運彩 | |-------- | -------- | -------- | -------- | | 規則 | 對數字,按對中數字多寡分配獎金 | 預測價格漲跌,贏家拿走對手全部保證金 | 賭比賽結果勝負、大小分、讓分等等 | | 頻率 | 一周 | 五分 ~ 一天 | 數天不等,看比賽 | | 獎金來源 | 其他參賽者資金,該期彩券不保留| 對手盤資金 | 1. 固定賠率: 莊家資金<br>2. pari mutuel: 對手資金 | | 資料源 | 現實世界樂透遊戲,項目方自行上傳 | 1. chainlink 有股市、外匯、期貨資料<br>2. DEX的時間平均價格 | chainlink: 包含賠率、比賽結果 | | 對獎方式 | 1.鏈下計算中獎人數,官方上傳,馬上取得獎金<br>2.鏈上開獎後提供兌獎期(一周)<br>之後將獎金平均分配<br>3.頭獎類分獎池比例的有兌獎期<br>小獎固定獎金的可以馬上領走 | 立即 | 立即 | |合約複雜程度| 中 | 低 | 高 | 成本: chainlink 一次費用 0.1 Link ( 14*0.1 ),上傳 data 費用: ETH(25), Polygon(0.03), BSC(0.3) ## 獎金分配設計 - 計算每人分到的獎金: 獎金除以人數 - draw 178 1(1):0 2(4-1): 268817204 -> 11160*268817204 / 1e9 / 3 = 1000 3(16-4): 0 4(64-16): 215053763 -> 11160*215053763 / 1e9 / 48 = 50 5(256-64): 172043010 -> 11160*172043010 / 1e9 / 192 = 10 6(1024-256): 344086021 -> 11160*344086021 / 1e9 / 768 = 5 prize: 11160000000 - draw 180 1(1): 139275766 -> 7180*139275766 / 1e9 / 1 = 1000 2(4-1): 41782729 -> 7180*41782729 / 1e9 / 3 = 100 3(16-4): 83565460 -> 7180*83565460 / 1e9 / 12 = 50 4(64-16): 66852367 -> 7180*66852367 / 1e9 / 48 = 10 5(256-64): 133704735 -> 7180*133704735 / 1e9 / 192 = 5 6(1024-256): 534818942 -> 7180*534818942 / 1e9 / 768 = 5 prize: 7180000000 - 因為每一次分配獎金的比例不是鏈上計算,看起來是項目調整過,讓每個tier的獎金維持固定金額 - 其實獎金不應該這麼乾淨,1e6以下都是0,是因為計算獎池的時候有用 MINIMUM_PICK_COST = 1000000 // 1 USDC 調整過。 ## Deploy 合約的順序 ([Link](https://github.com/pooltogether/v4-mainnet/tree/main/deploy)) - BeaconTimelockTrigger 取代 L1TimelockTrigger 0. constants ```typescript= export const DRAW_BUFFER_CARDINALITY = 255; export const PRIZE_DISTRIBUTION_BUFFER_CARDINALITY = 180; // six months export const PRIZE_DISTRIBUTION_FACTORY_MINIMUM_PICK_COST = 1000000 // 1 USDC export const BEACON_START_TIME = Math.floor((new Date('2021-11-3T19:00:00.000Z')).getTime() / 1000) export const BEACON_PERIOD_SECONDS = 86400; // one day export const END_TIMESTAMP_OFFSET = 15 * 60 // 15 minutes export const RNG_TIMEOUT_SECONDS = 2 * 3600 // 2 hours export const EXPIRY_DURATION = 60 * 86400 // 2 months export const TOKEN_DECIMALS = 6; ``` - v1.0.1 - ATokenYieldSource - YieldSourcePrizePool - Ticket - PrizeSplitStrategy - Reserve - DrawBuffer - DrawBeacon - PrizeDistributionBuffer - DrawCalculator - DrawCalculatorTimelock - PrizeDistributor - PrizeFlush - L1TimelockTrigger : 被 BeaconTimelockTrigger 取代 - PrizeTierHistory - v1.1.0 - PrizeDistributionFactory - BeaconTimelockTrigger - v1.2.0: - TWABRewards - v1.3.0: - TWABDelegator - v1.4.0: - PrizeDistributionFactory - drawCalculatorTimelock - beaconTimelockTrigger