├── .husky └── pre-commit ├── .lintstagedrc ├── funding.json ├── .prettierignore ├── remappings.txt ├── .prettierrc ├── .solhint.json ├── .gitignore ├── test ├── mocks │ └── ERC20Mintable.sol ├── invariants │ ├── helpers │ │ ├── CurrentTime.sol │ │ ├── CurrentTimeConsumer.sol │ │ ├── DrawAccumulatorFuzzHarness.sol │ │ ├── TierCalculationFuzzHarness.sol │ │ ├── TieredLiquidityDistributorFuzzHarness.sol │ │ └── PrizePoolFuzzHarness.sol │ ├── DrawAccumulatorInvariants.t.sol │ ├── PrizePoolInvariants.t.sol │ └── TieredLiquidityDistributorInvariants.t.sol ├── wrappers │ ├── TierCalculationLibWrapper.sol │ └── DrawAccumulatorLibWrapper.sol ├── abstract │ ├── helper │ │ └── TieredLiquidityDistributorWrapper.sol │ └── TieredLiquidityDistributor.t.sol ├── extensions │ └── BlastPrizePool.t.sol └── libraries │ ├── TierCalculationLib.t.sol │ └── DrawAccumulatorLib.t.sol ├── .envrc.example ├── .gitmodules ├── LICENSE ├── package.json ├── .github └── workflows │ └── coverage.yml ├── foundry.toml ├── src ├── extensions │ └── BlastPrizePool.sol ├── libraries │ ├── TierCalculationLib.sol │ └── DrawAccumulatorLib.sol ├── abstract │ └── TieredLiquidityDistributor.sol └── PrizePool.sol └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run lint-staged && npm test 5 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.{json,md,sol,yml}": [ 3 | "npm run format" 4 | ], 5 | "*.sol": [ 6 | "npm run hint" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /funding.json: -------------------------------------------------------------------------------- 1 | { 2 | "opRetro": { 3 | "projectId": "0x45064f78302aeee0d4ea3667a39d3cffa25157b131aba8b1c0d9cfb7c085f87d" 4 | } 5 | } -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | artifacts 2 | broadcast 3 | cache 4 | cache_hardhat 5 | deployments 6 | lib 7 | out 8 | types 9 | 10 | package.json 11 | -------------------------------------------------------------------------------- /remappings.txt: -------------------------------------------------------------------------------- 1 | forge-std/=lib/forge-std/src/ 2 | prb-math/=lib/prb-math/src/ 3 | openzeppelin=lib/openzeppelin-contracts/contracts/ 4 | pt-v5-twab-controller/=lib/pt-v5-twab-controller/src/ 5 | ring-buffer-lib/=lib/ring-buffer-lib/src/ 6 | uniform-random-number/=lib/uniform-random-number/src/ 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-solidity" 4 | ], 5 | "overrides": [ 6 | { 7 | "files": "*.sol", 8 | "options": { 9 | "bracketSpacing": true, 10 | "printWidth": 100, 11 | "tabWidth": 2 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /.solhint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "solhint:recommended", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "avoid-low-level-calls": "off", 6 | "compiler-version": ["error", "0.8.19"], 7 | "func-visibility": "off", 8 | "no-empty-blocks": "off", 9 | "no-inline-assembly": "off" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiler files 2 | cache/ 3 | coverage/ 4 | out/ 5 | 6 | # Ignores development broadcast logs 7 | !/broadcast 8 | /broadcast/*/31337/ 9 | /broadcast/**/dry-run/ 10 | 11 | # Docs 12 | docs/ 13 | 14 | # Dotenv file 15 | .env 16 | .envrc 17 | 18 | # VSCode 19 | .history 20 | 21 | # Coverage 22 | lcov.info 23 | 24 | # Test Output Data 25 | data/ 26 | 27 | .DS_Store 28 | node_modules -------------------------------------------------------------------------------- /test/mocks/ERC20Mintable.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; 5 | 6 | contract ERC20Mintable is ERC20 { 7 | constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) {} 8 | 9 | function mint(address _account, uint256 _amount) public { 10 | _mint(_account, _amount); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /test/invariants/helpers/CurrentTime.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | /// @dev Because Foundry does not commit the state changes between invariant runs, we need to 5 | /// save the current timestamp in a contract with persistent storage. 6 | contract CurrentTime { 7 | uint256 public timestamp; 8 | 9 | constructor(uint256 _timestamp) { 10 | timestamp = _timestamp; 11 | } 12 | 13 | function set(uint _timestamp) external { 14 | timestamp = _timestamp; 15 | } 16 | 17 | function increase(uint256 _amount) external { 18 | timestamp += _amount; 19 | } 20 | } -------------------------------------------------------------------------------- /test/invariants/DrawAccumulatorInvariants.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { DrawAccumulatorFuzzHarness } from "./helpers/DrawAccumulatorFuzzHarness.sol"; 7 | import { Observation } from "../../src/libraries/DrawAccumulatorLib.sol"; 8 | 9 | contract DrawAccumulatorInvariants is Test { 10 | DrawAccumulatorFuzzHarness public accumulator; 11 | 12 | function setUp() external { 13 | accumulator = new DrawAccumulatorFuzzHarness(); 14 | } 15 | 16 | function invariant_future_plus_past_equals_total() external { 17 | Observation memory obs = accumulator.newestObservation(); 18 | assertEq(obs.available + obs.disbursed, accumulator.totalAdded()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.envrc.example: -------------------------------------------------------------------------------- 1 | # Mnemonic phrase 2 | export MNEMONIC="" 3 | 4 | # Private key 5 | export PRIVATE_KEY="" 6 | 7 | # Mainnet RPC URLs 8 | export MAINNET_RPC_URL="" 9 | export ARBITRUM_RPC_URL="" 10 | export OPTIMISM_RPC_URL="" 11 | export POLYGON_RPC_URL="" 12 | export BLAST_RPC_URL="" 13 | 14 | # Testnet RPC URLs 15 | export GOERLI_RPC_URL="" 16 | export ARBITRUM_GOERLI_RPC_URL="" 17 | export OPTIMISM_GOERLI_RPC_URL="" 18 | export POLYGON_MUMBAI_RPC_URL="" 19 | 20 | # Used for verifying contracts on Etherscan 21 | export ETHERSCAN_API_KEY="" 22 | export ARBITRUM_ETHERSCAN_API_KEY="" 23 | export OPTIMISM_ETHERSCAN_API_KEY="" 24 | export POLYGONSCAN_API_KEY="" 25 | 26 | # Used to run Hardhat scripts in fork mode 27 | export FORK_ENABLED=true 28 | 29 | # Used to report gas usage when running Forge tests 30 | export FORGE_GAS_REPORT=true 31 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "lib/forge-std"] 2 | path = lib/forge-std 3 | url = https://github.com/foundry-rs/forge-std 4 | branch = v1 5 | [submodule "lib/openzeppelin-contracts"] 6 | path = lib/openzeppelin-contracts 7 | url = https://github.com/openzeppelin/openzeppelin-contracts 8 | branch = release-v4.9 9 | [submodule "lib/prb-math"] 10 | path = lib/prb-math 11 | url = https://github.com/PaulRBerg/prb-math 12 | branch = v4 13 | [submodule "lib/ring-buffer-lib"] 14 | path = lib/ring-buffer-lib 15 | url = https://github.com/GenerationSoftware/ring-buffer-lib 16 | [submodule "lib/uniform-random-number"] 17 | path = lib/uniform-random-number 18 | url = https://github.com/generationsoftware/uniform-random-number 19 | [submodule "lib/pt-v5-twab-controller"] 20 | path = lib/pt-v5-twab-controller 21 | url = https://github.com/generationsoftware/pt-v5-twab-controller 22 | -------------------------------------------------------------------------------- /test/invariants/helpers/CurrentTimeConsumer.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import { CommonBase } from "forge-std/Base.sol"; 5 | import { CurrentTime } from "./CurrentTime.sol"; 6 | 7 | contract CurrentTimeConsumer is CommonBase { 8 | 9 | CurrentTime public currentTime; 10 | 11 | modifier useCurrentTime() { 12 | warpCurrentTime(); 13 | _; 14 | } 15 | 16 | modifier increaseCurrentTime(uint _amount) { 17 | currentTime.increase(_amount); 18 | warpCurrentTime(); 19 | _; 20 | } 21 | 22 | function warpTo(uint _timestamp) internal { 23 | require(_timestamp > currentTime.timestamp(), "CurrentTimeConsumer/warpTo: cannot warp to the past"); 24 | currentTime.set(_timestamp); 25 | warpCurrentTime(); 26 | } 27 | 28 | function warpCurrentTime() internal { 29 | vm.warp(currentTime.timestamp()); 30 | } 31 | } -------------------------------------------------------------------------------- /test/wrappers/TierCalculationLibWrapper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import { TierCalculationLib } from "../../src/libraries/TierCalculationLib.sol"; 5 | import { SD59x18 } from "prb-math/SD59x18.sol"; 6 | import { UD60x18 } from "prb-math/UD60x18.sol"; 7 | 8 | // Note: Need to store the results from the library in a variable to be picked up by forge coverage 9 | // See: https://github.com/foundry-rs/foundry/pull/3128#issuecomment-1241245086 10 | contract TierCalculationLibWrapper { 11 | function tierPrizeCountPerDraw(uint8 _tier, SD59x18 _odds) external pure returns (uint32) { 12 | uint32 result = TierCalculationLib.tierPrizeCountPerDraw(_tier, _odds); 13 | return result; 14 | } 15 | 16 | function getTierOdds( 17 | uint8 _tier, 18 | uint8 _numberOfTiers, 19 | uint24 _grandPrizePeriod 20 | ) external pure returns (SD59x18) { 21 | SD59x18 result = TierCalculationLib.getTierOdds(_tier, _numberOfTiers, _grandPrizePeriod); 22 | return result; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/invariants/PrizePoolInvariants.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console2.sol"; 5 | 6 | import { PrizePoolFuzzHarness } from "./helpers/PrizePoolFuzzHarness.sol"; 7 | import { Test } from "forge-std/Test.sol"; 8 | import { CurrentTime, CurrentTimeConsumer } from "./helpers/CurrentTimeConsumer.sol"; 9 | 10 | contract PrizePoolInvariants is Test, CurrentTimeConsumer { 11 | PrizePoolFuzzHarness public prizePoolHarness; 12 | 13 | function setUp() external { 14 | currentTime = new CurrentTime(365 days); 15 | prizePoolHarness = new PrizePoolFuzzHarness(currentTime); 16 | targetContract(address(prizePoolHarness)); 17 | } 18 | 19 | function invariant_balance_equals_accounted() external useCurrentTime { 20 | uint balance = prizePoolHarness.token().balanceOf(address(prizePoolHarness.prizePool())); 21 | uint accounted = prizePoolHarness.prizePool().accountedBalance(); 22 | assertEq(balance, accounted, "balance does not match accountedBalance"); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-2024 G9 Software Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@generationsoftware/pt-v5-prize-pool", 3 | "version": "1.0.0", 4 | "description": "PoolTogether V5 Prize Pool Contracts", 5 | "author": { 6 | "name": "G9 Software Inc.", 7 | "url": "https://github.com/GenerationSoftware" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/GenerationSoftware/pt-v5-prize-pool.git" 12 | }, 13 | "scripts": { 14 | "clean": "forge clean", 15 | "compile": "forge compile", 16 | "coverage": "forge coverage --report lcov && lcov --extract lcov.info -o lcov.info 'src/*' && genhtml lcov.info -o coverage", 17 | "format": "prettier --config .prettierrc --write \"**/*.{json,md,sol,yml}\"", 18 | "format:file": "prettier --config .prettierrc --write", 19 | "hint": "solhint --config \"./.solhint.json\" \"src/**/*.sol\"", 20 | "lint-staged": "lint-staged", 21 | "prepack": "npm run clean && npm run compile", 22 | "prepare": "husky install", 23 | "test": "forge test" 24 | }, 25 | "devDependencies": { 26 | "husky": "8.0.3", 27 | "lint-staged": "14.0.1", 28 | "prettier": "3.0.3", 29 | "prettier-plugin-solidity": "1.1.3", 30 | "solhint": "3.6.2", 31 | "solhint-plugin-prettier": "0.0.5" 32 | }, 33 | "files": [ 34 | "src/**", 35 | "out/**" 36 | ] 37 | } -------------------------------------------------------------------------------- /test/invariants/helpers/DrawAccumulatorFuzzHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console2.sol"; 5 | 6 | import { DrawAccumulatorLib, Observation } from "../../../src/libraries/DrawAccumulatorLib.sol"; 7 | import { E, SD59x18, sd, unwrap } from "prb-math/SD59x18.sol"; 8 | 9 | contract DrawAccumulatorFuzzHarness { 10 | using DrawAccumulatorLib for DrawAccumulatorLib.Accumulator; 11 | 12 | uint256 public totalAdded; 13 | 14 | DrawAccumulatorLib.Accumulator internal accumulator; 15 | 16 | uint16 currentDrawId = uint8(uint256(blockhash(block.number - 1))) + 1; 17 | 18 | function add(uint88 _amount, uint8 _drawInc) public returns (bool) { 19 | currentDrawId += (_drawInc / 16); 20 | bool result = accumulator.add(_amount, currentDrawId); 21 | totalAdded += _amount; 22 | return result; 23 | } 24 | 25 | function getDisbursedBetween(uint16 _start, uint16 _end) external view returns (uint256 result) { 26 | uint24 start = _start % (currentDrawId*2); 27 | uint24 end = start + _end % (currentDrawId*2); 28 | result = accumulator.getDisbursedBetween(start, end); 29 | } 30 | 31 | function newestObservation() external view returns (Observation memory) { 32 | return accumulator.observations[accumulator.newestDrawId()]; 33 | } 34 | 35 | function newestDrawId() external view returns (uint256) { 36 | return accumulator.newestDrawId(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: Tests with 100% Coverage 2 | 3 | on: ["push", "pull_request"] 4 | 5 | env: 6 | FOUNDRY_PROFILE: ci 7 | 8 | jobs: 9 | forge: 10 | strategy: 11 | fail-fast: true 12 | permissions: 13 | pull-requests: write 14 | name: Foundry project 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | with: 19 | submodules: recursive 20 | 21 | - name: Install Foundry 22 | uses: foundry-rs/foundry-toolchain@v1 23 | with: 24 | version: nightly 25 | 26 | - name: Run Forge build 27 | run: | 28 | forge --version 29 | forge build --sizes 30 | id: build 31 | 32 | - name: Run Forge test 33 | env: 34 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 35 | BLAST_RPC_URL: ${{ secrets.BLAST_RPC_URL }} 36 | run: | 37 | forge test 38 | id: test 39 | 40 | - name: Install lcov 41 | uses: hrishikesh-kadam/setup-lcov@v1.0.0 42 | 43 | - name: Run Forge coverage 44 | env: 45 | MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} 46 | BLAST_RPC_URL: ${{ secrets.BLAST_RPC_URL }} 47 | run: | 48 | forge coverage --report lcov && lcov --remove lcov.info -o lcov.info 'test/*' 49 | id: coverage 50 | 51 | - name: Report code coverage 52 | uses: zgosalvez/github-actions-report-lcov@v1.5.0 53 | with: 54 | coverage-files: lcov.info 55 | minimum-coverage: 99 56 | github-token: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /test/invariants/helpers/TierCalculationFuzzHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console2.sol"; 5 | 6 | import { TierCalculationLib } from "../../../src/libraries/TierCalculationLib.sol"; 7 | import { SD59x18, unwrap, convert } from "prb-math/SD59x18.sol"; 8 | import { CommonBase } from "forge-std/Base.sol"; 9 | 10 | contract TierCalculationFuzzHarness is CommonBase { 11 | uint8 public immutable grandPrizePeriod = 10; 12 | uint128 immutable eachUserBalance = 100e18; 13 | SD59x18 immutable vaultPortion = convert(1); 14 | uint8 immutable _userCount = 20; 15 | uint8 public immutable numberOfTiers = 5; 16 | 17 | uint public winnerCount; 18 | uint32 public draws; 19 | 20 | function awardDraw(uint256 winningRandomNumber) public returns (uint) { 21 | uint drawPrizeCount; 22 | for (uint8 t = 0; t < numberOfTiers; t++) { 23 | uint32 prizeCount = uint32(TierCalculationLib.prizeCount(t)); 24 | SD59x18 tierOdds = TierCalculationLib.getTierOdds(t, numberOfTiers, grandPrizePeriod); 25 | for (uint u = 1; u < _userCount + 1; u++) { 26 | address userAddress = vm.addr(u); 27 | for (uint32 p = 0; p < prizeCount; p++) { 28 | uint256 prn = TierCalculationLib.calculatePseudoRandomNumber( 29 | 1, 30 | address(this), 31 | userAddress, 32 | t, 33 | p, 34 | winningRandomNumber 35 | ); 36 | if ( 37 | TierCalculationLib.isWinner( 38 | prn, 39 | eachUserBalance, 40 | _userCount * eachUserBalance, 41 | vaultPortion, 42 | tierOdds 43 | ) 44 | ) { 45 | drawPrizeCount++; 46 | } 47 | } 48 | } 49 | } 50 | winnerCount += drawPrizeCount; 51 | draws++; 52 | return drawPrizeCount; 53 | } 54 | 55 | function averagePrizesPerDraw() public view returns (uint256) { 56 | return winnerCount / draws; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /foundry.toml: -------------------------------------------------------------------------------- 1 | [profile.default] 2 | src = 'src' 3 | out = 'out' 4 | libs = ['lib'] 5 | solc = "0.8.24" 6 | gas_reports_ignore = ["ERC20Mintable", "TwabController"] 7 | gas_limit = "18446744073709551615" # u64::MAX 8 | block_gas_limit = "18446744073709551615" # u64::MAX 9 | fs_permissions = [{ access = "read-write", path = "./data"}] 10 | optimizer = true 11 | via_ir = false 12 | ffi = true 13 | 14 | [profile.default.optimizer_details] 15 | peephole = true 16 | inliner = true 17 | jumpdest_remover = true 18 | order_literals = true 19 | deduplicate = true 20 | cse = true 21 | constant_optimizer = true 22 | yul = true 23 | 24 | [invariant] 25 | runs = 4 26 | depth = 400 27 | 28 | [fuzz] 29 | seed = "0x0ca1b799da18587180cdb11fc96564bf73af56a3f4e6981971452fc78cb0dcbc" 30 | 31 | [rpc_endpoints] 32 | mainnet = "${MAINNET_RPC_URL}" 33 | arbitrum = "${ARBITRUM_RPC_URL}" 34 | optimism = "${OPTIMISM_RPC_URL}" 35 | polygon = "${POLYGON_RPC_URL}" 36 | blast = "${BLAST_RPC_URL}" 37 | 38 | goerli = "${GOERLI_RPC_URL}" 39 | arbitrum-goerli = "${ARBITRUM_GOERLI_RPC_URL}" 40 | optimism-goerli = "${OPTIMISM_GOERLI_RPC_URL}" 41 | polygon-mumbai = "${POLYGON_MUMBAI_RPC_URL}" 42 | 43 | [etherscan] 44 | mainnet = { key = "${ETHERSCAN_API_KEY}", url = "https://api.etherscan.io/api" } 45 | arbitrum = { key = "${ARBITRUM_ETHERSCAN_API_KEY}", url = "https://api.arbiscan.io/api" } 46 | optimism = { key = "${OPTIMISM_ETHERSCAN_API_KEY}", url = "https://api-optimistic.etherscan.io/api" } 47 | polygon = { key = "${POLYGONSCAN_API_KEY}", url = "https://api.polygonscan.com/api" } 48 | 49 | goerli = { key = "${ETHERSCAN_API_KEY}", url = "https://api-goerli.etherscan.io/api" } 50 | arbitrum-goerli = { key = "${ARBITRUM_ETHERSCAN_API_KEY}", url = "https://api-goerli.arbiscan.io/api" } 51 | optimism-goerli = { key = "${OPTIMISM_ETHERSCAN_API_KEY}", url = "https://api-goerli-optimistic.etherscan.io/api" } 52 | polygon-mumbai = { key = "${POLYGONSCAN_API_KEY}", url = "https://api-testnet.polygonscan.com/api" } 53 | 54 | # See more config options https://github.com/foundry-rs/foundry/tree/master/config 55 | -------------------------------------------------------------------------------- /test/abstract/helper/TieredLiquidityDistributorWrapper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console2.sol"; 5 | 6 | import { TieredLiquidityDistributor, Tier } from "../../../src/abstract/TieredLiquidityDistributor.sol"; 7 | 8 | contract TieredLiquidityDistributorWrapper is TieredLiquidityDistributor { 9 | constructor( 10 | uint256 _tierLiquidityUtilizationRate, 11 | uint8 _numberOfTiers, 12 | uint8 _tierShares, 13 | uint8 _canaryShares, 14 | uint8 _reserveShares, 15 | uint24 _grandPrizePeriodDraws 16 | ) 17 | TieredLiquidityDistributor(_tierLiquidityUtilizationRate, _numberOfTiers, _tierShares, _canaryShares, _reserveShares, _grandPrizePeriodDraws) 18 | {} 19 | 20 | function awardDraw(uint8 _nextNumTiers, uint256 liquidity) external { 21 | _awardDraw(_lastAwardedDrawId + 1, _nextNumTiers, liquidity); 22 | } 23 | 24 | function consumeLiquidity(uint8 _tier, uint96 _liquidity) external { 25 | Tier memory _tierData = _getTier(_tier, numberOfTiers); 26 | _consumeLiquidity(_tierData, _tier, _liquidity); 27 | } 28 | 29 | function computeNewDistributions( 30 | uint8 _numberOfTiers, 31 | uint8 _nextNumberOfTiers, 32 | uint128 _currentPrizeTokenPerShare, 33 | uint256 _prizeTokenLiquidity 34 | ) external view returns (uint96, uint128) { 35 | (uint96 newReserve, uint128 newPrizeTokenPerShare) = _computeNewDistributions( 36 | _numberOfTiers, 37 | _nextNumberOfTiers, 38 | _currentPrizeTokenPerShare, 39 | _prizeTokenLiquidity 40 | ); 41 | return (newReserve, newPrizeTokenPerShare); 42 | } 43 | 44 | function estimateNumberOfTiersUsingPrizeCountPerDraw( 45 | uint32 _prizeCount 46 | ) external view returns (uint8) { 47 | uint8 result = _estimateNumberOfTiersUsingPrizeCountPerDraw(_prizeCount); 48 | return result; 49 | } 50 | 51 | function sumTierPrizeCounts(uint8 _numTiers) external view returns (uint32) { 52 | uint32 result = _sumTierPrizeCounts(_numTiers); 53 | return result; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /test/invariants/helpers/TieredLiquidityDistributorFuzzHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import { 5 | TieredLiquidityDistributor, 6 | Tier, 7 | convert, 8 | MINIMUM_NUMBER_OF_TIERS 9 | } from "../../../src/abstract/TieredLiquidityDistributor.sol"; 10 | 11 | contract TieredLiquidityDistributorFuzzHarness is TieredLiquidityDistributor { 12 | uint256 public totalAdded; 13 | uint256 public totalConsumed; 14 | 15 | constructor() TieredLiquidityDistributor(1e18, MINIMUM_NUMBER_OF_TIERS, 100, 5, 10, 365) {} 16 | 17 | function awardDraw(uint8 _nextNumTiers, uint96 liquidity) external { 18 | uint8 nextNumTiers = _nextNumTiers / 16; // map to [0, 15] 19 | nextNumTiers = nextNumTiers < MINIMUM_NUMBER_OF_TIERS ? MINIMUM_NUMBER_OF_TIERS : nextNumTiers; // ensure min tiers 20 | totalAdded += liquidity; 21 | _awardDraw(_lastAwardedDrawId + 1, nextNumTiers, liquidity); 22 | } 23 | 24 | function net() external view returns (uint256) { 25 | return totalAdded - totalConsumed; 26 | } 27 | 28 | function accountedLiquidity() external view returns (uint256) { 29 | uint256 availableLiquidity; 30 | 31 | for (uint8 i = 0; i < numberOfTiers; i++) { 32 | Tier memory tier = _getTier(i, numberOfTiers); 33 | availableLiquidity += _getTierRemainingLiquidity( 34 | tier.prizeTokenPerShare, 35 | prizeTokenPerShare, 36 | _numShares(i, numberOfTiers) 37 | ); 38 | } 39 | 40 | availableLiquidity += _reserve; 41 | 42 | return availableLiquidity; 43 | } 44 | 45 | function consumeLiquidity(uint8 _tier) external { 46 | uint8 tier = _tier % numberOfTiers; 47 | 48 | Tier memory tier_ = _getTier(tier, numberOfTiers); 49 | uint104 liq = uint104( 50 | _getTierRemainingLiquidity( 51 | tier_.prizeTokenPerShare, 52 | prizeTokenPerShare, 53 | _numShares(_tier, numberOfTiers) 54 | ) 55 | ); 56 | 57 | // half the time consume only half 58 | if (_tier > 128) { 59 | liq += _reserve / 2; 60 | } 61 | 62 | totalConsumed += liq; 63 | _consumeLiquidity(tier_, tier, liq); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /test/invariants/TieredLiquidityDistributorInvariants.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { TieredLiquidityDistributorFuzzHarness } from "./helpers/TieredLiquidityDistributorFuzzHarness.sol"; 7 | 8 | contract TieredLiquidityDistributorInvariants is Test { 9 | TieredLiquidityDistributorFuzzHarness public distributor; 10 | 11 | function setUp() external { 12 | distributor = new TieredLiquidityDistributorFuzzHarness(); 13 | } 14 | 15 | function testTiers_always_sum() external { 16 | uint256 expected = distributor.totalAdded() - distributor.totalConsumed(); 17 | uint256 accounted = distributor.accountedLiquidity(); 18 | 19 | // Uncomment to append delta data to local CSV file: 20 | // -------------------------------------------------------- 21 | // uint256 delta = expected > accounted ? expected - accounted : accounted - expected; 22 | // vm.writeLine(string.concat(vm.projectRoot(), "/data/tiers_accounted_liquidity_delta.csv"), string.concat(vm.toString(distributor.numberOfTiers()), ",", vm.toString(delta))); 23 | // assertApproxEqAbs(accounted, expected, 50); // run with high ceiling to avoid failures while recording data 24 | // -------------------------------------------------------- 25 | // Comment out to avoid failing test while recording data: 26 | assertEq(accounted, expected, "accounted equals expected"); 27 | // -------------------------------------------------------- 28 | } 29 | 30 | // Failure case regression test (2023-05-26) 31 | function testInvariantFailure_Case_2023_05_26() external { 32 | distributor.awardDraw(4, 253012247290373118207); 33 | distributor.awardDraw(4, 99152290762372054017); 34 | distributor.awardDraw(255, 792281625142643375935439); 35 | distributor.consumeLiquidity(1); 36 | distributor.consumeLiquidity(0); 37 | distributor.awardDraw(1, 2365); 38 | distributor.awardDraw(5, 36387); 39 | distributor.awardDraw(74, 486356342973499764); 40 | distributor.consumeLiquidity(174); 41 | distributor.consumeLiquidity(254); 42 | distributor.awardDraw(6, 2335051495798885129312); 43 | distributor.awardDraw(160, 543634559793817062402); 44 | distributor.awardDraw(187, 3765046993999626249); 45 | distributor.awardDraw(1, 196958881398058173458); 46 | uint256 expected = distributor.totalAdded() - distributor.totalConsumed(); 47 | assertEq(distributor.accountedLiquidity(), expected); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/extensions/BlastPrizePool.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.24; 3 | 4 | import { PrizePool, ConstructorParams } from "../PrizePool.sol"; 5 | 6 | // The rebasing WETH token on Blast 7 | IERC20Rebasing constant WETH = IERC20Rebasing(0x4300000000000000000000000000000000000004); 8 | 9 | /// @notice The Blast yield modes for WETH 10 | enum YieldMode { 11 | AUTOMATIC, 12 | VOID, 13 | CLAIMABLE 14 | } 15 | 16 | /// @notice The relevant interface for rebasing WETH on Blast 17 | interface IERC20Rebasing { 18 | function configure(YieldMode) external returns (uint256); 19 | function claim(address recipient, uint256 amount) external returns (uint256); 20 | function getClaimableAmount(address account) external view returns (uint256); 21 | } 22 | 23 | /// @notice Thrown if the prize token is not the expected token on Blast. 24 | /// @param prizeToken The prize token address 25 | /// @param expectedToken The expected token address 26 | error PrizeTokenNotExpectedToken(address prizeToken, address expectedToken); 27 | 28 | /// @notice Thrown if a yield donation is triggered when there is no claimable balance. 29 | error NoClaimableBalance(); 30 | 31 | /// @title PoolTogether V5 Blast Prize Pool 32 | /// @author G9 Software Inc. 33 | /// @notice A modified prize pool that opts in to claimable WETH yield on Blast and allows anyone to trigger 34 | /// a donation of the accrued yield to the prize pool. 35 | contract BlastPrizePool is PrizePool { 36 | 37 | /* ============ Constructor ============ */ 38 | 39 | /// @notice Constructs a new Blast Prize Pool. 40 | /// @dev Reverts if the prize token is not the expected WETH token on Blast. 41 | /// @param params A struct of constructor parameters 42 | constructor(ConstructorParams memory params) PrizePool(params) { 43 | if (address(params.prizeToken) != address(WETH)) { 44 | revert PrizeTokenNotExpectedToken(address(params.prizeToken), address(WETH)); 45 | } 46 | 47 | // Opt-in to claimable yield 48 | WETH.configure(YieldMode.CLAIMABLE); 49 | } 50 | 51 | /* ============ External Functions ============ */ 52 | 53 | /// @notice Returns the claimable WETH yield balance for this contract 54 | function claimableYieldBalance() external view returns (uint256) { 55 | return WETH.getClaimableAmount(address(this)); 56 | } 57 | 58 | /// @notice Claims the available WETH yield balance and donates it to the prize pool. 59 | /// @return The amount claimed and donated. 60 | function donateClaimableYield() external returns (uint256) { 61 | uint256 _claimableYieldBalance = WETH.getClaimableAmount(address(this)); 62 | if (_claimableYieldBalance == 0) { 63 | revert NoClaimableBalance(); 64 | } 65 | WETH.claim(address(this), _claimableYieldBalance); 66 | contributePrizeTokens(DONATOR, _claimableYieldBalance); 67 | return _claimableYieldBalance; 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /test/wrappers/DrawAccumulatorLibWrapper.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | import { DrawAccumulatorLib, Observation, RingBufferInfo } from "../../src/libraries/DrawAccumulatorLib.sol"; 6 | import { RingBufferLib } from "ring-buffer-lib/RingBufferLib.sol"; 7 | import { E, SD59x18, sd, unwrap } from "prb-math/SD59x18.sol"; 8 | 9 | // Note: Need to store the results from the library in a variable to be picked up by forge coverage 10 | // See: https://github.com/foundry-rs/foundry/pull/3128#issuecomment-1241245086 11 | contract DrawAccumulatorLibWrapper { 12 | DrawAccumulatorLib.Accumulator internal accumulator; 13 | 14 | function getRingBufferInfo() public view returns (RingBufferInfo memory) { 15 | return accumulator.ringBufferInfo; 16 | } 17 | 18 | function getDrawRingBuffer(uint16 index) public view returns (uint24) { 19 | return accumulator.drawRingBuffer[index]; 20 | } 21 | 22 | function setDrawRingBuffer(uint16 index, uint8 value) public { 23 | accumulator.drawRingBuffer[index] = value; 24 | } 25 | 26 | function getCardinality() public view returns (uint16) { 27 | return accumulator.ringBufferInfo.cardinality; 28 | } 29 | 30 | function getNextIndex() public view returns (uint16) { 31 | return accumulator.ringBufferInfo.nextIndex; 32 | } 33 | 34 | function setRingBufferInfo(uint16 nextIndex, uint16 cardinality) public { 35 | accumulator.ringBufferInfo.cardinality = cardinality; 36 | accumulator.ringBufferInfo.nextIndex = nextIndex; 37 | } 38 | 39 | function getObservation(uint24 drawId) public view returns (Observation memory) { 40 | return accumulator.observations[drawId]; 41 | } 42 | 43 | function add(uint256 _amount, uint24 _drawId) public returns (bool) { 44 | bool result = DrawAccumulatorLib.add(accumulator, _amount, _drawId); 45 | return result; 46 | } 47 | 48 | function newestObservation() public view returns (Observation memory) { 49 | Observation memory result = DrawAccumulatorLib.newestObservation(accumulator); 50 | return result; 51 | } 52 | 53 | /** 54 | * Requires endDrawId to be greater than (the newest draw id - 1) 55 | */ 56 | function getDisbursedBetween( 57 | uint24 _startDrawId, 58 | uint24 _endDrawId 59 | ) public view returns (uint256) { 60 | uint256 result = DrawAccumulatorLib.getDisbursedBetween( 61 | accumulator, 62 | _startDrawId, 63 | _endDrawId 64 | ); 65 | return result; 66 | } 67 | 68 | /** 69 | */ 70 | function binarySearch( 71 | uint16 _oldestIndex, 72 | uint16 _newestIndex, 73 | uint16 _cardinality, 74 | uint24 _targetLastClosedDrawId 75 | ) 76 | public 77 | view 78 | returns ( 79 | uint16 beforeOrAtIndex, 80 | uint24 beforeOrAtDrawId, 81 | uint16 afterOrAtIndex, 82 | uint24 afterOrAtDrawId 83 | ) 84 | { 85 | (beforeOrAtIndex, beforeOrAtDrawId, afterOrAtIndex, afterOrAtDrawId) = DrawAccumulatorLib 86 | .binarySearch( 87 | accumulator.drawRingBuffer, 88 | _oldestIndex, 89 | _newestIndex, 90 | _cardinality, 91 | _targetLastClosedDrawId 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/extensions/BlastPrizePool.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.24; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { TwabController } from "pt-v5-twab-controller/TwabController.sol"; 7 | import { BlastPrizePool, ConstructorParams, WETH, PrizeTokenNotExpectedToken, NoClaimableBalance } from "../../src/extensions/BlastPrizePool.sol"; 8 | import { IERC20 } from "../../src/PrizePool.sol"; 9 | 10 | contract BlastPrizePoolTest is Test { 11 | BlastPrizePool prizePool; 12 | 13 | address bob = makeAddr("bob"); 14 | address alice = makeAddr("alice"); 15 | 16 | address wethWhale = address(0x66714DB8F3397c767d0A602458B5b4E3C0FE7dd1); 17 | 18 | TwabController twabController; 19 | IERC20 prizeToken; 20 | address drawManager; 21 | 22 | uint256 TIER_SHARES = 100; 23 | uint256 CANARY_SHARES = 5; 24 | uint256 RESERVE_SHARES = 10; 25 | 26 | uint24 grandPrizePeriodDraws = 365; 27 | uint48 drawPeriodSeconds = 1 days; 28 | uint24 drawTimeout; 29 | uint48 firstDrawOpensAt; 30 | uint8 initialNumberOfTiers = 4; 31 | uint256 winningRandomNumber = 123456; 32 | uint256 tierLiquidityUtilizationRate = 1e18; 33 | 34 | uint256 blockNumber = 5213491; 35 | uint256 blockTimestamp = 1719236797; 36 | 37 | ConstructorParams params; 38 | 39 | function setUp() public { 40 | drawTimeout = 30; 41 | 42 | vm.createSelectFork("blast", blockNumber); 43 | vm.warp(blockTimestamp); 44 | 45 | prizeToken = IERC20(address(WETH)); 46 | twabController = new TwabController(uint32(drawPeriodSeconds), uint32(blockTimestamp - 1 days)); 47 | 48 | firstDrawOpensAt = uint48(blockTimestamp + 1 days); // set draw start 1 day into future 49 | 50 | drawManager = address(this); 51 | 52 | params = ConstructorParams( 53 | prizeToken, 54 | twabController, 55 | drawManager, 56 | tierLiquidityUtilizationRate, 57 | drawPeriodSeconds, 58 | firstDrawOpensAt, 59 | grandPrizePeriodDraws, 60 | initialNumberOfTiers, 61 | uint8(TIER_SHARES), 62 | uint8(CANARY_SHARES), 63 | uint8(RESERVE_SHARES), 64 | drawTimeout 65 | ); 66 | 67 | prizePool = new BlastPrizePool(params); 68 | prizePool.setDrawManager(address(this)); 69 | } 70 | 71 | function testWrongPrizeToken() public { 72 | params.prizeToken = IERC20(address(1)); 73 | vm.expectRevert(abi.encodeWithSelector(PrizeTokenNotExpectedToken.selector, address(1), address(WETH))); 74 | prizePool = new BlastPrizePool(params); 75 | } 76 | 77 | function testClaimableYield() public { 78 | assertEq(IERC20(address(WETH)).balanceOf(address(prizePool)), 0); 79 | 80 | // check balance 81 | assertEq(prizePool.claimableYieldBalance(), 0); 82 | 83 | // donate some tokens to the prize pool 84 | vm.startPrank(wethWhale); 85 | IERC20(address(WETH)).approve(address(prizePool), 1e18); 86 | prizePool.donatePrizeTokens(1e18); 87 | vm.stopPrank(); 88 | assertEq(prizePool.getDonatedBetween(1, 1), 1e18); 89 | 90 | // deal some ETH to the WETH contract and call addValue 91 | deal(address(WETH), 1e18 + address(WETH).balance); 92 | vm.startPrank(address(0x4300000000000000000000000000000000000000)); // REPORTER 93 | (bool success,) = address(WETH).call(abi.encodeWithSignature("addValue(uint256)", 0)); 94 | vm.stopPrank(); 95 | require(success, "addValue failed"); 96 | 97 | // check balance non-zero 98 | uint256 claimable = prizePool.claimableYieldBalance(); 99 | assertGt(claimable, 0); 100 | 101 | // trigger donation 102 | vm.startPrank(alice); 103 | uint256 donated = prizePool.donateClaimableYield(); 104 | vm.stopPrank(); 105 | 106 | assertEq(donated, claimable); 107 | assertEq(prizePool.getDonatedBetween(1, 1), 1e18 + donated); 108 | assertEq(prizePool.claimableYieldBalance(), 0); 109 | 110 | // reverts on donation of zero balance 111 | vm.expectRevert(abi.encodeWithSelector(NoClaimableBalance.selector)); 112 | prizePool.donateClaimableYield(); 113 | } 114 | 115 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | PoolTogether Brand 4 | 5 |

6 | 7 | # PoolTogether V5 Prize Pool 8 | 9 | [![Code Coverage](https://github.com/generationsoftware/pt-v5-prize-pool/actions/workflows/coverage.yml/badge.svg)](https://github.com/generationsoftware/pt-v5-prize-pool/actions/workflows/coverage.yml) 10 | [![built-with openzeppelin](https://img.shields.io/badge/built%20with-OpenZeppelin-3677FF)](https://docs.openzeppelin.com/) 11 | ![MIT license](https://img.shields.io/badge/license-MIT-blue) 12 | 13 | Have questions or want the latest news? 14 |
Join the PoolTogether Discord or follow us on Twitter: 15 | 16 | [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://pooltogether.com/discord) 17 | [![Twitter](https://badgen.net/badge/icon/twitter?icon=twitter&label)](https://twitter.com/PoolTogether_) 18 | 19 | ## Overview 20 | 21 | In PoolTogether V5 prizes are distributed through the Prize Pool contract. There is one Prize Pool deployed on each chain on which PT is deployed. The Prize Pool receives POOL tokens from Vaults, and releases the tokens as prizes in daily Draws. In this way, prize liquidity is isolated to a chain. 22 | 23 | - Accrued yield is sold by the Liquidator and sent to the Prize Pool. 24 | - Every "Draw" a random number is provided and given to the Prize Pool and the next set of prizes are available. 25 | - The Prize Pool determines a users chance of winning by reading historic data from the TWAB Controller. 26 | 27 | > [!important] 28 | > Only WETH has been audited as the `prizeToken` for the prize pool. Other prize tokens may lead to unknown complications due to differences in precision or mathematical limitations. 29 | 30 | ## Development 31 | 32 | ### Installation 33 | 34 | You may have to install the following tools to use this repository: 35 | 36 | - [Foundry](https://github.com/foundry-rs/foundry) to compile and test contracts 37 | - [direnv](https://direnv.net/) to handle environment variables 38 | - [lcov](https://github.com/linux-test-project/lcov) to generate the code coverage report 39 | 40 | Install dependencies: 41 | 42 | ``` 43 | npm i 44 | ``` 45 | 46 | ### Env 47 | 48 | Copy `.envrc.example` and write down the env variables needed to run this project. 49 | 50 | ``` 51 | cp .envrc.example .envrc 52 | ``` 53 | 54 | Once your env variables are setup, load them with: 55 | 56 | ``` 57 | direnv allow 58 | ``` 59 | 60 | ### Compile 61 | 62 | Run the following command to compile the contracts: 63 | 64 | ``` 65 | npm run compile 66 | ``` 67 | 68 | ### Coverage 69 | 70 | Forge is used for coverage, run it with: 71 | 72 | ``` 73 | npm run coverage 74 | ``` 75 | 76 | You can then consult the report by opening `coverage/index.html`: 77 | 78 | ``` 79 | open coverage/index.html 80 | ``` 81 | 82 | ### Code quality 83 | 84 | [Husky](https://typicode.github.io/husky/#/) is used to run [lint-staged](https://github.com/okonet/lint-staged) and tests when committing. 85 | 86 | [Prettier](https://prettier.io) is used to format TypeScript and Solidity code. Use it by running: 87 | 88 | ``` 89 | npm run format 90 | ``` 91 | 92 | [Solhint](https://protofire.github.io/solhint/) is used to lint Solidity files. Run it with: 93 | 94 | ``` 95 | npm run hint 96 | ``` 97 | 98 | ### Tests 99 | 100 | Test names including `SLOW` will be skipped on default test runs and need to be explicitly run. 101 | 102 | ### CI 103 | 104 | A default Github Actions workflow is setup to execute on push and pull request. 105 | 106 | It will build the contracts and run the test coverage. 107 | 108 | You can modify it here: [.github/workflows/coverage.yml](.github/workflows/coverage.yml) 109 | 110 | For the coverage to work, you will need to setup the `MAINNET_RPC_URL` repository secret in the settings of your Github repository. 111 | -------------------------------------------------------------------------------- /test/libraries/TierCalculationLib.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { TierCalculationLib } from "../../src/libraries/TierCalculationLib.sol"; 7 | import { MINIMUM_NUMBER_OF_TIERS, MAXIMUM_NUMBER_OF_TIERS } from "../../src/abstract/TieredLiquidityDistributor.sol"; 8 | import { TierCalculationLibWrapper } from "../wrappers/TierCalculationLibWrapper.sol"; 9 | import { SD59x18, sd, wrap, unwrap, convert } from "prb-math/SD59x18.sol"; 10 | import { UD60x18, ud } from "prb-math/UD60x18.sol"; 11 | 12 | contract TierCalculationLibTest is Test { 13 | TierCalculationLibWrapper wrapper; 14 | 15 | function setUp() public { 16 | wrapper = new TierCalculationLibWrapper(); 17 | } 18 | 19 | function testGetTierOdds_grandPrizeOdds() public { 20 | for (uint8 i = MINIMUM_NUMBER_OF_TIERS - 1; i <= MAXIMUM_NUMBER_OF_TIERS; i++) { 21 | // grand prize is always 1/365 22 | assertEq(unwrap(wrapper.getTierOdds(0, i, 365)), 2739726027397260); 23 | } 24 | } 25 | 26 | function testGetTierOdds_tier4() public { 27 | assertEq(unwrap(wrapper.getTierOdds(0, 4, 365)), 2739726027397260); 28 | assertEq(unwrap(wrapper.getTierOdds(1, 4, 365)), 8089033552608040); 29 | assertEq(unwrap(wrapper.getTierOdds(2, 4, 365)), 33163436331078433); 30 | assertEq(unwrap(wrapper.getTierOdds(3, 4, 365)), 1e18); 31 | } 32 | 33 | function testEstimatePrizeFrequencyInDraws() public { 34 | assertEq( 35 | TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(0, 4, 365), 365), 36 | 365 37 | ); 38 | assertEq( 39 | TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(1, 4, 365), 365), 40 | 124 41 | ); 42 | assertEq( 43 | TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(2, 4, 365), 365), 44 | 31 45 | ); 46 | assertEq( 47 | TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(3, 4, 365), 365), 48 | 1 49 | ); 50 | } 51 | 52 | function testPrizeCount() public { 53 | assertEq(TierCalculationLib.prizeCount(0), 1); 54 | } 55 | 56 | function testCalculateWinningZoneWithTierOdds() public { 57 | assertEq(TierCalculationLib.calculateWinningZone(1000, sd(0.333e18), sd(1e18)), 333); 58 | } 59 | 60 | function testCalculateWinningZoneWithVaultPortion() public { 61 | assertEq(TierCalculationLib.calculateWinningZone(1000, sd(1e18), sd(0.444e18)), 444); 62 | } 63 | 64 | function testCalculateWinningZoneWithPrizeCount() public { 65 | assertEq(TierCalculationLib.calculateWinningZone(1000, sd(1e18), sd(1e18)), 1000); 66 | } 67 | 68 | function testIsWinner_WinsAll() external { 69 | uint8 tier = 5; 70 | uint8 numberOfTiers = 6; 71 | vm.assume(tier < numberOfTiers); 72 | uint16 grandPrizePeriod = 365; 73 | SD59x18 tierOdds = TierCalculationLib.getTierOdds(tier, numberOfTiers, grandPrizePeriod); 74 | uint32 prizeCount = uint32(TierCalculationLib.prizeCount(tier)); 75 | SD59x18 vaultContribution = convert(int256(1)); 76 | 77 | uint wins; 78 | for (uint i = 0; i < prizeCount; i++) { 79 | if ( 80 | TierCalculationLib.isWinner( 81 | uint256(keccak256(abi.encode(i))), 82 | 1000, 83 | 1000, 84 | vaultContribution, 85 | tierOdds 86 | ) 87 | ) { 88 | wins++; 89 | } 90 | } 91 | 92 | assertApproxEqAbs(wins, prizeCount, 0); 93 | } 94 | 95 | function testIsWinner_HalfLiquidity() external { 96 | uint8 tier = 5; 97 | uint8 numberOfTiers = 6; 98 | vm.assume(tier < numberOfTiers); 99 | uint16 grandPrizePeriod = 365; 100 | SD59x18 tierOdds = TierCalculationLib.getTierOdds(tier, numberOfTiers, grandPrizePeriod); 101 | uint32 prizeCount = uint32(TierCalculationLib.prizeCount(tier)); 102 | SD59x18 vaultContribution = convert(int256(1)); 103 | 104 | uint wins; 105 | for (uint i = 0; i < prizeCount; i++) { 106 | if ( 107 | TierCalculationLib.isWinner( 108 | uint256(keccak256(abi.encode(i))), 109 | 500, 110 | 1000, 111 | vaultContribution, 112 | tierOdds 113 | ) 114 | ) { 115 | wins++; 116 | } 117 | } 118 | 119 | assertApproxEqAbs(wins, prizeCount / 2, 20); 120 | } 121 | 122 | function testTierPrizeCountPerDraw() public { 123 | assertEq(wrapper.tierPrizeCountPerDraw(3, wrap(0.5e18)), 32); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/libraries/TierCalculationLib.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | import { UniformRandomNumber } from "uniform-random-number/UniformRandomNumber.sol"; 6 | import { SD59x18, sd, unwrap, convert } from "prb-math/SD59x18.sol"; 7 | 8 | /// @title Tier Calculation Library 9 | /// @author PoolTogether Inc. Team 10 | /// @notice Provides helper functions to assist in calculating tier prize counts, frequency, and odds. 11 | library TierCalculationLib { 12 | /// @notice Calculates the odds of a tier occurring. 13 | /// @param _tier The tier to calculate odds for 14 | /// @param _numberOfTiers The total number of tiers 15 | /// @param _grandPrizePeriod The number of draws between grand prizes 16 | /// @return The odds that a tier should occur for a single draw. 17 | function getTierOdds( 18 | uint8 _tier, 19 | uint8 _numberOfTiers, 20 | uint24 _grandPrizePeriod 21 | ) internal pure returns (SD59x18) { 22 | int8 oneMinusNumTiers = 1 - int8(_numberOfTiers); 23 | return 24 | sd(1).div(sd(int24(_grandPrizePeriod))).pow( 25 | sd(int8(_tier) + oneMinusNumTiers).div(sd(oneMinusNumTiers)).sqrt() 26 | ); 27 | } 28 | 29 | /// @notice Estimates the number of draws between a tier occurring. 30 | /// @dev Limits the frequency to the grand prize period in draws. 31 | /// @param _tierOdds The odds for the tier to calculate the frequency of 32 | /// @param _grandPrizePeriod The number of draws between grand prizes 33 | /// @return The estimated number of draws between the tier occurring 34 | function estimatePrizeFrequencyInDraws(SD59x18 _tierOdds, uint24 _grandPrizePeriod) internal pure returns (uint24) { 35 | uint256 _prizeFrequencyInDraws = uint256(convert(sd(1e18).div(_tierOdds).ceil())); 36 | return _prizeFrequencyInDraws > _grandPrizePeriod ? _grandPrizePeriod : uint24(_prizeFrequencyInDraws); 37 | } 38 | 39 | /// @notice Computes the number of prizes for a given tier. 40 | /// @param _tier The tier to compute for 41 | /// @return The number of prizes 42 | function prizeCount(uint8 _tier) internal pure returns (uint256) { 43 | return 4 ** _tier; 44 | } 45 | 46 | /// @notice Determines if a user won a prize tier. 47 | /// @param _userSpecificRandomNumber The random number to use as entropy 48 | /// @param _userTwab The user's time weighted average balance 49 | /// @param _vaultTwabTotalSupply The vault's time weighted average total supply 50 | /// @param _vaultContributionFraction The portion of the prize that was contributed by the vault 51 | /// @param _tierOdds The odds of the tier occurring 52 | /// @return True if the user won the tier, false otherwise 53 | function isWinner( 54 | uint256 _userSpecificRandomNumber, 55 | uint256 _userTwab, 56 | uint256 _vaultTwabTotalSupply, 57 | SD59x18 _vaultContributionFraction, 58 | SD59x18 _tierOdds 59 | ) internal pure returns (bool) { 60 | if (_vaultTwabTotalSupply == 0) { 61 | return false; 62 | } 63 | 64 | /// The user-held portion of the total supply is the "winning zone". 65 | /// If the above pseudo-random number falls within the winning zone, the user has won this tier. 66 | /// However, we scale the size of the zone based on: 67 | /// - Odds of the tier occurring 68 | /// - Number of prizes 69 | /// - Portion of prize that was contributed by the vault 70 | 71 | return 72 | UniformRandomNumber.uniform(_userSpecificRandomNumber, _vaultTwabTotalSupply) < 73 | calculateWinningZone(_userTwab, _vaultContributionFraction, _tierOdds); 74 | } 75 | 76 | /// @notice Calculates a pseudo-random number that is unique to the user, tier, and winning random number. 77 | /// @param _drawId The draw id the user is checking 78 | /// @param _vault The vault the user deposited into 79 | /// @param _user The user 80 | /// @param _tier The tier 81 | /// @param _prizeIndex The particular prize index they are checking 82 | /// @param _winningRandomNumber The winning random number 83 | /// @return A pseudo-random number 84 | function calculatePseudoRandomNumber( 85 | uint24 _drawId, 86 | address _vault, 87 | address _user, 88 | uint8 _tier, 89 | uint32 _prizeIndex, 90 | uint256 _winningRandomNumber 91 | ) internal pure returns (uint256) { 92 | return 93 | uint256( 94 | keccak256(abi.encode(_drawId, _vault, _user, _tier, _prizeIndex, _winningRandomNumber)) 95 | ); 96 | } 97 | 98 | /// @notice Calculates the winning zone for a user. If their pseudo-random number falls within this zone, they win the tier. 99 | /// @param _userTwab The user's time weighted average balance 100 | /// @param _vaultContributionFraction The portion of the prize that was contributed by the vault 101 | /// @param _tierOdds The odds of the tier occurring 102 | /// @return The winning zone for the user. 103 | function calculateWinningZone( 104 | uint256 _userTwab, 105 | SD59x18 _vaultContributionFraction, 106 | SD59x18 _tierOdds 107 | ) internal pure returns (uint256) { 108 | return 109 | uint256(convert(convert(int256(_userTwab)).mul(_tierOdds).mul(_vaultContributionFraction))); 110 | } 111 | 112 | /// @notice Computes the estimated number of prizes per draw for a given tier and tier odds. 113 | /// @param _tier The tier 114 | /// @param _odds The odds of the tier occurring for the draw 115 | /// @return The estimated number of prizes per draw for the given tier and tier odds 116 | function tierPrizeCountPerDraw(uint8 _tier, SD59x18 _odds) internal pure returns (uint32) { 117 | return uint32(uint256(unwrap(sd(int256(prizeCount(_tier))).mul(_odds)))); 118 | } 119 | 120 | /// @notice Checks whether a tier is a valid tier 121 | /// @param _tier The tier to check 122 | /// @param _numberOfTiers The number of tiers 123 | /// @return True if the tier is valid, false otherwise 124 | function isValidTier(uint8 _tier, uint8 _numberOfTiers) internal pure returns (bool) { 125 | return _tier < _numberOfTiers; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /test/invariants/helpers/PrizePoolFuzzHarness.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/console2.sol"; 5 | 6 | import { CommonBase } from "forge-std/Base.sol"; 7 | import { StdCheats } from "forge-std/StdCheats.sol"; 8 | import { StdUtils } from "forge-std/StdUtils.sol"; 9 | import { UD2x18 } from "prb-math/UD2x18.sol"; 10 | import { SD1x18 } from "prb-math/SD1x18.sol"; 11 | import { TwabController } from "pt-v5-twab-controller/TwabController.sol"; 12 | 13 | import { CurrentTime, CurrentTimeConsumer } from "./CurrentTimeConsumer.sol"; 14 | import { PrizePool, ConstructorParams } from "../../../src/PrizePool.sol"; 15 | import { MINIMUM_NUMBER_OF_TIERS } from "../../../src/abstract/TieredLiquidityDistributor.sol"; 16 | import { ERC20Mintable } from "../../mocks/ERC20Mintable.sol"; 17 | 18 | contract PrizePoolFuzzHarness is CommonBase, StdCheats, StdUtils, CurrentTimeConsumer { 19 | PrizePool public prizePool; 20 | ERC20Mintable public token; 21 | TwabController twabController; 22 | 23 | uint256 public contributed; 24 | uint256 public withdrawn; 25 | uint256 public claimed; 26 | 27 | address claimer; 28 | 29 | address[4] public actors; 30 | address internal currentActor; 31 | 32 | uint256 tierLiquidityUtilizationRate = 1e18; 33 | address drawManager = address(this); 34 | uint48 drawPeriodSeconds = 1 hours; 35 | uint48 awardDrawStartsAt; 36 | uint24 grandPrizePeriod = 365; 37 | uint8 numberOfTiers = MINIMUM_NUMBER_OF_TIERS; 38 | uint8 tierShares = 100; 39 | uint8 canaryShares = 5; 40 | uint8 reserveShares = 10; 41 | uint24 drawTimeout = 5; 42 | 43 | constructor(CurrentTime _currentTime) { 44 | currentTime = _currentTime; 45 | warpCurrentTime(); 46 | // console2.log("constructor 1"); 47 | claimer = makeAddr("claimer"); 48 | // console2.log("constructor 2"); 49 | for (uint i = 0; i != actors.length; i++) { 50 | actors[i] = makeAddr(string(abi.encodePacked("actor", i))); 51 | } 52 | // console2.log("constructor 3"); 53 | 54 | // console2.log("constructor 4"); 55 | 56 | awardDrawStartsAt = uint48(currentTime.timestamp()); 57 | 58 | // console2.log("constructor 4.1"); 59 | 60 | token = new ERC20Mintable("name", "SYMBOL"); 61 | twabController = new TwabController( 62 | uint32(drawPeriodSeconds), 63 | uint32(awardDrawStartsAt) 64 | ); 65 | // console2.log("constructor 5"); 66 | // arbitrary mint 67 | twabController.mint(address(this), 100e18); 68 | 69 | // console2.log("constructor 6"); 70 | ConstructorParams memory params = ConstructorParams( 71 | token, 72 | twabController, 73 | drawManager, 74 | tierLiquidityUtilizationRate, 75 | drawPeriodSeconds, 76 | awardDrawStartsAt, 77 | grandPrizePeriod, 78 | numberOfTiers, 79 | tierShares, 80 | canaryShares, 81 | reserveShares, 82 | drawTimeout 83 | ); 84 | 85 | // console2.log("constructor 7"); 86 | 87 | prizePool = new PrizePool(params); 88 | } 89 | 90 | function deposit(uint88 _amount, uint256 actorSeed) public useCurrentTime prankActor(actorSeed) { 91 | twabController.mint(_actor(actorSeed), _amount); 92 | } 93 | 94 | function contributePrizeTokens(uint88 _amount, uint256 actorSeed) public increaseCurrentTime(_timeIncrease()) prankActor(actorSeed) { 95 | // console2.log("contributePrizeTokens"); 96 | contributed += _amount; 97 | token.mint(address(prizePool), _amount); 98 | prizePool.contributePrizeTokens(_actor(actorSeed), _amount); 99 | } 100 | 101 | function donatePrizeTokens(uint88 _amount, uint256 actorSeed) public increaseCurrentTime(_timeIncrease()) prankActor(actorSeed) { 102 | // console2.log("contributePrizeTokens"); 103 | address actor = _actor(actorSeed); 104 | token.mint(address(actor), _amount); 105 | vm.startPrank(actor); 106 | token.approve(address(prizePool), _amount); 107 | contributed += _amount; 108 | prizePool.donatePrizeTokens(_amount); 109 | vm.stopPrank(); 110 | } 111 | 112 | function contributeReserve(uint88 _amount, uint256 actorSeed) public increaseCurrentTime(_timeIncrease()) prankActor(actorSeed) { 113 | if (prizePool.isShutdown()) { 114 | return; 115 | } 116 | contributed += _amount; 117 | token.mint(_actor(actorSeed), _amount); 118 | token.approve(address(prizePool), _amount); 119 | prizePool.contributeReserve(_amount); 120 | } 121 | 122 | function shutdown() public increaseCurrentTime(_timeIncrease()) { 123 | vm.warp(prizePool.shutdownAt()); 124 | } 125 | 126 | function allocateRewardFromReserve(uint256 actorSeed) public increaseCurrentTime(_timeIncrease()) prankDrawManager { 127 | // console2.log("allocateRewardFromReserve"); 128 | uint96 amount = prizePool.reserve(); 129 | withdrawn += amount; 130 | prizePool.allocateRewardFromReserve(_actor(actorSeed), amount); 131 | } 132 | 133 | function withdrawClaimReward() public increaseCurrentTime(_timeIncrease()) { 134 | // console2.log("withdrawClaimReward"); 135 | vm.startPrank(claimer); 136 | prizePool.withdrawRewards(address(claimer), prizePool.rewardBalance(claimer)); 137 | vm.stopPrank(); 138 | } 139 | 140 | function claimPrizes() public useCurrentTime { 141 | // console2.log("claimPrizes"); 142 | // console2.log("prizePool.numberOfTiers()", prizePool.numberOfTiers()); 143 | if (prizePool.getLastAwardedDrawId() == 0) { 144 | // console2.log("skiipping"); 145 | return; 146 | } 147 | for (uint i = 0; i < actors.length; i++) { 148 | _claimFor(actors[i]); 149 | } 150 | } 151 | 152 | function withdrawShutdownBalance(uint256 _actorSeed) public increaseCurrentTime(_timeIncrease()) prankActor(_actorSeed) { 153 | // console2.log("withdrawShutdownBalance withdrawShutdownBalance withdrawShutdownBalance withdrawShutdownBalance"); 154 | address actor = _actor(_actorSeed); 155 | if (prizePool.shutdownBalanceOf(address(this), actor) > 0) { 156 | // console2.log("HAS A SHUTDOWN BALANCE"); 157 | } 158 | prizePool.withdrawShutdownBalance(address(this), actor); 159 | } 160 | 161 | function awardDraw() public useCurrentTime prankDrawManager { 162 | // console2.log("AWARDING"); 163 | uint24 drawId = prizePool.getDrawIdToAward(); 164 | uint256 drawToAwardClosesAt = prizePool.drawClosesAt(drawId); 165 | if (drawToAwardClosesAt > currentTime.timestamp()) { 166 | warpTo(drawToAwardClosesAt); 167 | } 168 | prizePool.awardDraw(uint256(keccak256(abi.encode(block.timestamp)))); 169 | // console2.log("SUCCESSSSS AWARDED DRAW"); 170 | } 171 | 172 | function _actor(uint256 actorIndexSeed) internal view returns (address) { 173 | return actors[_bound(actorIndexSeed, 0, actors.length - 1)]; 174 | } 175 | 176 | function _timeIncrease() internal view returns (uint256) { 177 | uint amount = _bound(uint256(keccak256(abi.encode(block.timestamp))), 0, drawPeriodSeconds/2); 178 | // console2.log("amount", amount); 179 | return amount; 180 | } 181 | 182 | function _claimFor(address actor_) internal { 183 | uint8 numTiers = prizePool.numberOfTiers(); 184 | for (uint8 i = 0; i < numTiers; i++) { 185 | for (uint32 p = 0; p < prizePool.getTierPrizeCount(i); p++) { 186 | if ( 187 | prizePool.isWinner(address(this), actor_, i, p) && 188 | !prizePool.wasClaimed(address(this), actor_, i, p) && 189 | prizePool.claimCount() < 4**2 // prevent claiming all prizes 190 | ) { 191 | // console2.log("CLAIMING"); 192 | uint256 prizeSize = prizePool.getTierPrizeSize(i); 193 | if (prizeSize > 0) { 194 | claimed += prizePool.claimPrize( 195 | actor_, 196 | i, 197 | p, 198 | address(this), 199 | uint96(prizeSize/10), 200 | address(claimer) 201 | ); 202 | } 203 | } 204 | } 205 | } 206 | } 207 | 208 | modifier prankActor(uint256 actorIndexSeed) { 209 | currentActor = _actor(actorIndexSeed); 210 | vm.startPrank(currentActor); 211 | _; 212 | vm.stopPrank(); 213 | } 214 | 215 | modifier prankDrawManager() { 216 | vm.startPrank(drawManager); 217 | _; 218 | vm.stopPrank(); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/libraries/DrawAccumulatorLib.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.24; 4 | 5 | import { SafeCast } from "openzeppelin/utils/math/SafeCast.sol"; 6 | import { RingBufferLib } from "ring-buffer-lib/RingBufferLib.sol"; 7 | 8 | // The maximum number of observations that can be recorded. 9 | uint16 constant MAX_OBSERVATION_CARDINALITY = 366; 10 | 11 | /// @notice Thrown when adding balance for draw zero. 12 | error AddToDrawZero(); 13 | 14 | /// @notice Thrown when an action can't be done on a closed draw. 15 | /// @param drawId The ID of the closed draw 16 | /// @param newestDrawId The newest draw ID 17 | error DrawAwarded(uint24 drawId, uint24 newestDrawId); 18 | 19 | /// @notice Thrown when a draw range is not strictly increasing. 20 | /// @param startDrawId The start draw ID of the range 21 | /// @param endDrawId The end draw ID of the range 22 | error InvalidDrawRange(uint24 startDrawId, uint24 endDrawId); 23 | 24 | /// @notice The accumulator observation record 25 | /// @param available The total amount available as of this Observation 26 | /// @param disbursed The total amount disbursed in the past 27 | struct Observation { 28 | uint96 available; 29 | uint160 disbursed; 30 | } 31 | 32 | /// @notice The metadata for using the ring buffer. 33 | struct RingBufferInfo { 34 | uint16 nextIndex; 35 | uint16 cardinality; 36 | } 37 | 38 | /// @title Draw Accumulator Lib 39 | /// @author G9 Software Inc. 40 | /// @notice This contract distributes tokens over time according to an exponential weighted average. 41 | /// Time is divided into discrete "draws", of which each is allocated tokens. 42 | library DrawAccumulatorLib { 43 | 44 | /// @notice An accumulator for a draw. 45 | /// @param ringBufferInfo The metadata for the drawRingBuffer 46 | /// @param drawRingBuffer The ring buffer of draw ids 47 | /// @param observations The observations for each draw id 48 | struct Accumulator { 49 | RingBufferInfo ringBufferInfo; // 32 bits 50 | uint24[366] drawRingBuffer; // 8784 bits 51 | // 8784 + 32 = 8816 bits in total 52 | // 256 * 35 = 8960 53 | // 8960 - 8816 = 144 bits left over 54 | mapping(uint256 drawId => Observation observation) observations; 55 | } 56 | 57 | /// @notice Adds balance for the given draw id to the accumulator. 58 | /// @param accumulator The accumulator to add to 59 | /// @param _amount The amount of balance to add 60 | /// @param _drawId The draw id to which to add balance to. This must be greater than or equal to the previous 61 | /// addition's draw id. 62 | /// @return True if a new observation was created, false otherwise. 63 | function add( 64 | Accumulator storage accumulator, 65 | uint256 _amount, 66 | uint24 _drawId 67 | ) internal returns (bool) { 68 | if (_drawId == 0) { 69 | revert AddToDrawZero(); 70 | } 71 | RingBufferInfo memory ringBufferInfo = accumulator.ringBufferInfo; 72 | 73 | uint24 newestDrawId_ = accumulator.drawRingBuffer[ 74 | RingBufferLib.newestIndex(ringBufferInfo.nextIndex, MAX_OBSERVATION_CARDINALITY) 75 | ]; 76 | 77 | if (_drawId < newestDrawId_) { 78 | revert DrawAwarded(_drawId, newestDrawId_); 79 | } 80 | 81 | mapping(uint256 drawId => Observation observation) storage accumulatorObservations = accumulator 82 | .observations; 83 | Observation memory newestObservation_ = accumulatorObservations[newestDrawId_]; 84 | if (_drawId != newestDrawId_) { 85 | uint16 cardinality = ringBufferInfo.cardinality; 86 | if (ringBufferInfo.cardinality < MAX_OBSERVATION_CARDINALITY) { 87 | cardinality += 1; 88 | } else { 89 | // Delete the old observation to save gas (older than 1 year) 90 | delete accumulatorObservations[accumulator.drawRingBuffer[ringBufferInfo.nextIndex]]; 91 | } 92 | 93 | accumulator.drawRingBuffer[ringBufferInfo.nextIndex] = _drawId; 94 | accumulatorObservations[_drawId] = Observation({ 95 | available: SafeCast.toUint96(_amount), 96 | disbursed: SafeCast.toUint160( 97 | newestObservation_.disbursed + 98 | newestObservation_.available 99 | ) 100 | }); 101 | 102 | accumulator.ringBufferInfo = RingBufferInfo({ 103 | nextIndex: uint16(RingBufferLib.nextIndex(ringBufferInfo.nextIndex, MAX_OBSERVATION_CARDINALITY)), 104 | cardinality: cardinality 105 | }); 106 | 107 | return true; 108 | } else { 109 | accumulatorObservations[newestDrawId_] = Observation({ 110 | available: SafeCast.toUint96(newestObservation_.available + _amount), 111 | disbursed: newestObservation_.disbursed 112 | }); 113 | 114 | return false; 115 | } 116 | } 117 | 118 | /// @notice Returns the newest draw id from the accumulator. 119 | /// @param accumulator The accumulator to get the newest draw id from 120 | /// @return The newest draw id 121 | function newestDrawId(Accumulator storage accumulator) internal view returns (uint256) { 122 | return 123 | accumulator.drawRingBuffer[ 124 | RingBufferLib.newestIndex(accumulator.ringBufferInfo.nextIndex, MAX_OBSERVATION_CARDINALITY) 125 | ]; 126 | } 127 | 128 | /// @notice Returns the newest draw id from the accumulator. 129 | /// @param accumulator The accumulator to get the newest draw id from 130 | /// @return The newest draw id 131 | function newestObservation(Accumulator storage accumulator) internal view returns (Observation memory) { 132 | return accumulator.observations[ 133 | newestDrawId(accumulator) 134 | ]; 135 | } 136 | 137 | /// @notice Gets the balance that was disbursed between the given start and end draw ids, inclusive. 138 | /// @param _accumulator The accumulator to get the disbursed balance from 139 | /// @param _startDrawId The start draw id, inclusive 140 | /// @param _endDrawId The end draw id, inclusive 141 | /// @return The disbursed balance between the given start and end draw ids, inclusive 142 | function getDisbursedBetween( 143 | Accumulator storage _accumulator, 144 | uint24 _startDrawId, 145 | uint24 _endDrawId 146 | ) internal view returns (uint256) { 147 | if (_startDrawId > _endDrawId) { 148 | revert InvalidDrawRange(_startDrawId, _endDrawId); 149 | } 150 | 151 | RingBufferInfo memory ringBufferInfo = _accumulator.ringBufferInfo; 152 | 153 | if (ringBufferInfo.cardinality == 0) { 154 | return 0; 155 | } 156 | 157 | uint16 oldestIndex = uint16( 158 | RingBufferLib.oldestIndex( 159 | ringBufferInfo.nextIndex, 160 | ringBufferInfo.cardinality, 161 | MAX_OBSERVATION_CARDINALITY 162 | ) 163 | ); 164 | uint16 newestIndex = uint16( 165 | RingBufferLib.newestIndex(ringBufferInfo.nextIndex, ringBufferInfo.cardinality) 166 | ); 167 | 168 | uint24 oldestDrawId = _accumulator.drawRingBuffer[oldestIndex]; 169 | uint24 _newestDrawId = _accumulator.drawRingBuffer[newestIndex]; 170 | 171 | if (_endDrawId < oldestDrawId || _startDrawId > _newestDrawId) { 172 | // if out of range, return 0 173 | return 0; 174 | } 175 | 176 | Observation memory atOrAfterStart; 177 | if (_startDrawId <= oldestDrawId || ringBufferInfo.cardinality == 1) { 178 | atOrAfterStart = _accumulator.observations[oldestDrawId]; 179 | } else { 180 | // check if the start draw has an observation, otherwise search for the earliest observation after 181 | atOrAfterStart = _accumulator.observations[_startDrawId]; 182 | if (atOrAfterStart.available == 0 && atOrAfterStart.disbursed == 0) { 183 | (, , , uint24 afterOrAtDrawId) = binarySearch( 184 | _accumulator.drawRingBuffer, 185 | oldestIndex, 186 | newestIndex, 187 | ringBufferInfo.cardinality, 188 | _startDrawId 189 | ); 190 | atOrAfterStart = _accumulator.observations[afterOrAtDrawId]; 191 | } 192 | } 193 | 194 | Observation memory atOrBeforeEnd; 195 | if (_endDrawId >= _newestDrawId || ringBufferInfo.cardinality == 1) { 196 | atOrBeforeEnd = _accumulator.observations[_newestDrawId]; 197 | } else { 198 | // check if the end draw has an observation, otherwise search for the latest observation before 199 | atOrBeforeEnd = _accumulator.observations[_endDrawId]; 200 | if (atOrBeforeEnd.available == 0 && atOrBeforeEnd.disbursed == 0) { 201 | (, uint24 beforeOrAtDrawId, , ) = binarySearch( 202 | _accumulator.drawRingBuffer, 203 | oldestIndex, 204 | newestIndex, 205 | ringBufferInfo.cardinality, 206 | _endDrawId 207 | ); 208 | atOrBeforeEnd = _accumulator.observations[beforeOrAtDrawId]; 209 | } 210 | } 211 | 212 | return atOrBeforeEnd.available + atOrBeforeEnd.disbursed - atOrAfterStart.disbursed; 213 | } 214 | 215 | /// @notice Binary searches an array of draw ids for the given target draw id. 216 | /// @dev The _targetDrawId MUST exist between the range of draws at _oldestIndex and _newestIndex (inclusive) 217 | /// @param _drawRingBuffer The array of draw ids to search 218 | /// @param _oldestIndex The oldest index in the ring buffer 219 | /// @param _newestIndex The newest index in the ring buffer 220 | /// @param _cardinality The number of items in the ring buffer 221 | /// @param _targetDrawId The target draw id to search for 222 | /// @return beforeOrAtIndex The index of the observation occurring at or before the target draw id 223 | /// @return beforeOrAtDrawId The draw id of the observation occurring at or before the target draw id 224 | /// @return afterOrAtIndex The index of the observation occurring at or after the target draw id 225 | /// @return afterOrAtDrawId The draw id of the observation occurring at or after the target draw id 226 | function binarySearch( 227 | uint24[366] storage _drawRingBuffer, 228 | uint16 _oldestIndex, 229 | uint16 _newestIndex, 230 | uint16 _cardinality, 231 | uint24 _targetDrawId 232 | ) 233 | internal 234 | view 235 | returns ( 236 | uint16 beforeOrAtIndex, 237 | uint24 beforeOrAtDrawId, 238 | uint16 afterOrAtIndex, 239 | uint24 afterOrAtDrawId 240 | ) 241 | { 242 | uint16 leftSide = _oldestIndex; 243 | uint16 rightSide = _newestIndex < leftSide ? leftSide + _cardinality - 1 : _newestIndex; 244 | uint16 currentIndex; 245 | 246 | while (true) { 247 | // We start our search in the middle of the `leftSide` and `rightSide`. 248 | // After each iteration, we narrow down the search to the left or the right side while still starting our search in the middle. 249 | currentIndex = (leftSide + rightSide) / 2; 250 | 251 | beforeOrAtIndex = uint16(RingBufferLib.wrap(currentIndex, _cardinality)); 252 | beforeOrAtDrawId = _drawRingBuffer[beforeOrAtIndex]; 253 | 254 | afterOrAtIndex = uint16(RingBufferLib.nextIndex(currentIndex, _cardinality)); 255 | afterOrAtDrawId = _drawRingBuffer[afterOrAtIndex]; 256 | 257 | bool targetAtOrAfter = beforeOrAtDrawId <= _targetDrawId; 258 | 259 | // Check if we've found the corresponding Observation. 260 | if (targetAtOrAfter && _targetDrawId <= afterOrAtDrawId) { 261 | break; 262 | } 263 | 264 | // If `beforeOrAtTimestamp` is greater than `_target`, then we keep searching lower. To the left of the current index. 265 | if (!targetAtOrAfter) { 266 | rightSide = currentIndex - 1; 267 | } else { 268 | // Otherwise, we keep searching higher. To the left of the current index. 269 | leftSide = currentIndex + 1; 270 | } 271 | } 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /test/libraries/DrawAccumulatorLib.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { SD59x18, sd } from "prb-math/SD59x18.sol"; 7 | 8 | import { DrawAccumulatorLib, AddToDrawZero, DrawAwarded, InvalidDrawRange, Observation } from "../../src/libraries/DrawAccumulatorLib.sol"; 9 | import { DrawAccumulatorLibWrapper } from "../wrappers/DrawAccumulatorLibWrapper.sol"; 10 | 11 | contract DrawAccumulatorLibTest is Test { 12 | using DrawAccumulatorLib for DrawAccumulatorLib.Accumulator; 13 | 14 | DrawAccumulatorLib.Accumulator accumulator; 15 | DrawAccumulatorLibWrapper wrapper; 16 | uint contribution = 10000; 17 | 18 | function setUp() public { 19 | /* 20 | Alpha of 0.9 and contribution of 10000 result in disbursal of: 21 | 22 | 1 1000 23 | 2 900 24 | 3 810 25 | 4 729 26 | 5 656 27 | 6 590 28 | ... 29 | */ 30 | wrapper = new DrawAccumulatorLibWrapper(); 31 | } 32 | 33 | function testAdd_emitsAddToDrawZero() public { 34 | vm.expectRevert(abi.encodeWithSelector(AddToDrawZero.selector)); 35 | add(0); 36 | } 37 | 38 | function testAdd_emitsDrawAwarded() public { 39 | add(4); 40 | vm.expectRevert(abi.encodeWithSelector(DrawAwarded.selector, 3, 4)); 41 | add(3); 42 | } 43 | 44 | function testAddOne() public { 45 | DrawAccumulatorLib.add(accumulator, 100, 1); 46 | assertEq(accumulator.ringBufferInfo.cardinality, 1); 47 | assertEq(accumulator.ringBufferInfo.nextIndex, 1); 48 | assertEq(accumulator.drawRingBuffer[0], 1); 49 | assertEq(accumulator.observations[1].available, 100); 50 | } 51 | 52 | function testAddSame() public { 53 | wrapper.add(100, 1); 54 | wrapper.add(200, 1); 55 | 56 | assertEq(wrapper.getCardinality(), 1); 57 | assertEq(wrapper.getRingBufferInfo().nextIndex, 1); 58 | assertEq(wrapper.getDrawRingBuffer(0), 1); 59 | assertEq(wrapper.getObservation(1).available, 300); 60 | } 61 | 62 | function testAddSecond() public { 63 | DrawAccumulatorLib.add(accumulator, 100, 1); 64 | DrawAccumulatorLib.add(accumulator, 200, 3); 65 | 66 | assertEq(accumulator.ringBufferInfo.cardinality, 2); 67 | assertEq(accumulator.ringBufferInfo.nextIndex, 2); 68 | assertEq(accumulator.drawRingBuffer[0], 1); 69 | assertEq(accumulator.drawRingBuffer[1], 3); 70 | 71 | // 100 - 19 = 81 72 | 73 | assertEq(accumulator.observations[3].available, 200); 74 | } 75 | 76 | function testAddOne_deleteExpired() public { 77 | // set up accumulator as if we had just completed a buffer loop: 78 | for (uint16 i = 0; i < 366; i++) { 79 | wrapper.add(100, i + 1); 80 | assertEq(wrapper.getCardinality(), i + 1); 81 | assertEq(wrapper.getNextIndex(), i == 365 ? 0 : i + 1); 82 | assertEq(wrapper.getDrawRingBuffer(i), i + 1); 83 | assertGe(wrapper.getObservation(i + 1).available, wrapper.getObservation(i).available); 84 | } 85 | 86 | assertEq(wrapper.getCardinality(), 366); 87 | 88 | wrapper.add(200, 367); 89 | assertEq(wrapper.getCardinality(), 366); 90 | assertEq(wrapper.getNextIndex(), 1); 91 | assertEq(wrapper.getDrawRingBuffer(0), 367); 92 | assertGt(wrapper.getObservation(367).available, wrapper.getObservation(366).available); 93 | assertEq(wrapper.getObservation(1).available, 0); // deleted draw 1 94 | } 95 | 96 | function test_newestObservation() public { 97 | wrapper.add(100, 1); 98 | Observation memory observation = wrapper.newestObservation(); 99 | assertEq(observation.available, 100); 100 | 101 | wrapper.add(200, 2); 102 | observation = wrapper.newestObservation(); 103 | assertEq(observation.available, 200); 104 | assertEq(observation.disbursed, 100); 105 | } 106 | 107 | function testGetDisbursedBetweenEmpty() public { 108 | assertEq(getDisbursedBetween(1, 4), 0); 109 | } 110 | 111 | function testGetDisbursedBetween_invalidRange() public { 112 | vm.expectRevert(abi.encodeWithSelector(InvalidDrawRange.selector, 2, 1)); 113 | getDisbursedBetween(2, 1); 114 | } 115 | 116 | function testGetDisbursedBetween_endMoreThanOneBeforeLast() public { 117 | add(1); 118 | add(2); 119 | add(4); 120 | assertEq(getDisbursedBetween(1, 2), 2e5); // end draw ID is more than 2 before last observation (4) 121 | } 122 | 123 | function testGetDisbursedBetween_endOneLessThanLast() public { 124 | add(1); // 1000 125 | add(2); // 1000 + 900 126 | // 900 + 810 127 | add(4); // 1000 + 810 + 729 128 | assertApproxEqAbs(getDisbursedBetween(1, 3), 2e5, 1); // end draw ID is 1 before last observation (4) 129 | } 130 | 131 | function testGetDisbursedBetween_binarySearchBothStartAndEnd() public { 132 | // here we want to test a case where the algorithm must binary search both the start and end observations 133 | add(1); // 1000 134 | add(2); // 1000 + 900 135 | add(3); // 1000 + 900 + 810 136 | add(4); // 1000 + 900 + 810 + 729 137 | add(5); 138 | add(6); 139 | assertApproxEqAbs(getDisbursedBetween(3, 4), 2e5, 1); // end draw ID is more than 2 before last observation (6) and start is not at earliest (1) 140 | } 141 | 142 | function testGetDisbursedBetween_binarySearchBothStartAndEnd_2ndScenario() public { 143 | // here we want to test a case where the algorithm must binary search both the start and end observations 144 | add(1); // 1000 145 | add(2); // 1000 + 900 146 | add(3); // 1000 + 900 + 810 147 | add(4); // 1000 + 900 + 810 + 729 148 | add(5); 149 | add(6); 150 | assertApproxEqAbs(getDisbursedBetween(2, 4), 3e5, 1); // end draw ID is more than 2 before last observation (6) and start is not at earliest (1) 151 | } 152 | 153 | function testGetDisbursedBetween_binarySearchBothStartAndEnd_3rdScenario() public { 154 | // here we want to test a case where the algorithm must binary search both the start and end observations 155 | add(1); // 1000 156 | add(2); // 1000 + 900 157 | add(3); // 1000 + 900 + 810 158 | add(4); // 1000 + 900 + 810 + 729 159 | add(5); // 1000 + 900 + 810 + 729 + 656 160 | add(6); 161 | add(7); 162 | assertApproxEqAbs(getDisbursedBetween(2, 5), 4e5, 2); // end draw ID is more than 2 before last observation (6) and start is not at earliest (1) 163 | } 164 | 165 | function testGetDisbursedBetween_binarySearchBothStartAndEnd_4thScenario() public { 166 | // here we want to test a case where the algorithm must binary search both the start and end observations 167 | add(1); // 1000 168 | add(2); // 1000 + 900 169 | // 900 + 810 170 | // 810 + 729 171 | add(5); // 1000 + 729 + 656 172 | add(6); 173 | add(7); 174 | assertApproxEqAbs(getDisbursedBetween(2, 5), 2e5, 2); // end draw ID is more than 2 before last observation (6) and start is not at earliest (1) 175 | } 176 | 177 | function testGetDisbursedBetween_onOne() public { 178 | add(1); 179 | /* 180 | should include draw 1, 2, 3 and 4: 181 | 1 1000 182 | 2 900 183 | 3 810 184 | 4 729 185 | */ 186 | assertEq(getDisbursedBetween(1, 4), 1e5); 187 | } 188 | 189 | function testGetDisbursedBetween_beforeOne() public { 190 | add(4); 191 | // should include draw 2, 3 and 4 192 | assertEq(getDisbursedBetween(2, 3), 0); 193 | } 194 | 195 | function testGetDisbursedBetween_endOnOne() public { 196 | add(4); 197 | // should include draw 2, 3 and 4 198 | assertEq(getDisbursedBetween(2, 4), 1e5); 199 | } 200 | 201 | function testGetDisbursedBetween_startOnOne() public { 202 | add(4); 203 | // should include draw 2, 3 and 4 204 | assertEq(getDisbursedBetween(4, 4), 1e5); 205 | } 206 | 207 | function testGetDisbursedBetween_afterOne() public { 208 | add(1); 209 | // should include draw 2, 3 and 4 210 | assertEq(getDisbursedBetween(2, 4), 0); 211 | } 212 | 213 | function testGetDisbursedBetween_beforeOnTwo() public { 214 | add(4); 215 | add(5); 216 | /* 217 | should include draw 1, 2, 3 and 4: 218 | 1 1000 219 | 2 900 220 | 3 810 + 1000 221 | 4 729 + 900 222 | */ 223 | assertEq(getDisbursedBetween(1, 4), 1e5); 224 | } 225 | 226 | function testGetDisbursedBetween_aroundFirstOfTwo() public { 227 | add(4); 228 | add(6); 229 | /* 230 | should include draw 1, 2, 3 and 4: 231 | 1 1000 232 | 2 900 233 | 3 810 + 1000 234 | 4 729 + 900 235 | */ 236 | assertEq(getDisbursedBetween(1, 5), 1e5); 237 | } 238 | 239 | function testGetDisbursedBetween_acrossTwo() public { 240 | add(2); 241 | add(4); 242 | /* 243 | should include draw 2, 3 and 4: 244 | 2 1000 245 | 3 900 246 | 4 810 + 1000 247 | 5 729 + 900 248 | */ 249 | assertEq(getDisbursedBetween(1, 4), 2e5); 250 | } 251 | 252 | function testGetDisbursedBetween_onOneBetweenTwo() public { 253 | add(2); 254 | add(4); 255 | /* 256 | should include draw 2, 3 and 4: 257 | 2 1000 258 | 3 900 259 | 4 810 + 1000 260 | 5 729 + 900 261 | */ 262 | assertEq(getDisbursedBetween(3, 3), 0); 263 | } 264 | 265 | function testGetDisbursedBetween_betweenTwo() public { 266 | add(1); 267 | add(4); 268 | /* 269 | should include draw 1, 2, 3 and 4: 270 | 1 1000 271 | 2 900 272 | 3 810 273 | 4 729 + 1000 274 | */ 275 | assertEq(getDisbursedBetween(2, 3), 0); 276 | } 277 | 278 | function testGetDisbursedBetween_aroundLastOfTwo() public { 279 | add(1); 280 | add(4); 281 | /* 282 | should include draw 1, 2, 3 and 4: 283 | 1 1000 284 | 2 900 285 | 3 810 286 | 4 729 + 1000 287 | */ 288 | assertEq(getDisbursedBetween(3, 4), 1e5); 289 | } 290 | 291 | function testGetDisbursedBetween_AfterLast() public { 292 | add(1); 293 | add(4); 294 | // 1 1000 295 | // 2 900 296 | // 3 810 297 | // 4 729 + 1000 298 | // 5 656 + 900 299 | // 6 590 + 810 300 | assertEq(getDisbursedBetween(6, 6), 0); 301 | } 302 | 303 | function testBinarySearchTwoWithFirstMatchingTarget() public { 304 | fillDrawRingBuffer([1, 3, 0, 0, 0]); 305 | ( 306 | uint16 beforeOrAtIndex, 307 | uint24 beforeOrAtDrawId, 308 | uint16 afterOrAtIndex, 309 | uint24 afterOrAtDrawId 310 | ) = wrapper.binarySearch(0, 2, 2, 1); 311 | assertEq(beforeOrAtIndex, 0); 312 | assertEq(beforeOrAtDrawId, 1); 313 | assertEq(afterOrAtIndex, 1); 314 | assertEq(afterOrAtDrawId, 3); 315 | } 316 | 317 | function testBinarySearchMatchingTarget() public { 318 | fillDrawRingBuffer([1, 2, 3, 4, 5]); 319 | ( 320 | uint16 beforeOrAtIndex, 321 | uint24 beforeOrAtDrawId, 322 | uint16 afterOrAtIndex, 323 | uint24 afterOrAtDrawId 324 | ) = wrapper.binarySearch(0, 4, 5, 3); 325 | assertEq(beforeOrAtIndex, 2); 326 | assertEq(beforeOrAtDrawId, 3); 327 | assertEq(afterOrAtIndex, 3); 328 | assertEq(afterOrAtDrawId, 4); 329 | } 330 | 331 | function testBinarySearchFirstMatchingTarget() public { 332 | fillDrawRingBuffer([1, 2, 3, 4, 5]); 333 | ( 334 | uint16 beforeOrAtIndex, 335 | uint24 beforeOrAtDrawId, 336 | uint16 afterOrAtIndex, 337 | uint24 afterOrAtDrawId 338 | ) = wrapper.binarySearch(0, 4, 5, 1); 339 | assertEq(beforeOrAtIndex, 0); 340 | assertEq(beforeOrAtDrawId, 1); 341 | assertEq(afterOrAtIndex, 1); 342 | assertEq(afterOrAtDrawId, 2); 343 | } 344 | 345 | function testBinarySearchLastMatchingTarget() public { 346 | fillDrawRingBuffer([1, 2, 3, 4, 5]); 347 | ( 348 | uint16 beforeOrAtIndex, 349 | uint24 beforeOrAtDrawId, 350 | uint16 afterOrAtIndex, 351 | uint24 afterOrAtDrawId 352 | ) = wrapper.binarySearch(0, 4, 5, 5); 353 | assertEq(beforeOrAtIndex, 3); 354 | assertEq(beforeOrAtDrawId, 4); 355 | assertEq(afterOrAtIndex, 4); 356 | assertEq(afterOrAtDrawId, 5); 357 | } 358 | 359 | function testBinarySearchTargetBetween() public { 360 | fillDrawRingBuffer([2, 4, 5, 6, 7]); 361 | ( 362 | uint16 beforeOrAtIndex, 363 | uint24 beforeOrAtDrawId, 364 | uint16 afterOrAtIndex, 365 | uint24 afterOrAtDrawId 366 | ) = wrapper.binarySearch(0, 4, 5, 3); 367 | assertEq(beforeOrAtIndex, 0); 368 | assertEq(beforeOrAtDrawId, 2); 369 | assertEq(afterOrAtIndex, 1); 370 | assertEq(afterOrAtDrawId, 4); 371 | } 372 | 373 | function fillDrawRingBuffer(uint8[5] memory values) internal { 374 | for (uint16 i = 0; i < values.length; i++) { 375 | wrapper.setDrawRingBuffer(i, values[i]); 376 | } 377 | wrapper.setRingBufferInfo(uint16(values.length), uint16(values.length)); 378 | } 379 | 380 | function add(uint16 drawId) internal { 381 | wrapper.add(1e5, drawId); 382 | } 383 | 384 | function getDisbursedBetween( 385 | uint16 _startDrawId, 386 | uint16 _endDrawId 387 | ) internal view returns (uint256) { 388 | return wrapper.getDisbursedBetween(_startDrawId, _endDrawId); 389 | } 390 | } 391 | -------------------------------------------------------------------------------- /test/abstract/TieredLiquidityDistributor.t.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.19; 3 | 4 | import "forge-std/Test.sol"; 5 | 6 | import { TierCalculationLib } from "../../src/libraries/TierCalculationLib.sol"; 7 | import { TieredLiquidityDistributorWrapper } from "./helper/TieredLiquidityDistributorWrapper.sol"; 8 | import { 9 | UD60x18, 10 | NumberOfTiersLessThanMinimum, 11 | NumberOfTiersGreaterThanMaximum, 12 | TierLiquidityUtilizationRateGreaterThanOne, 13 | TierLiquidityUtilizationRateCannotBeZero, 14 | InsufficientLiquidity, 15 | convert, 16 | SD59x18, 17 | sd, 18 | MAXIMUM_NUMBER_OF_TIERS, 19 | MINIMUM_NUMBER_OF_TIERS, 20 | NUMBER_OF_CANARY_TIERS 21 | } from "../../src/abstract/TieredLiquidityDistributor.sol"; 22 | 23 | contract TieredLiquidityDistributorTest is Test { 24 | 25 | event ReserveConsumed(uint256 amount); 26 | 27 | TieredLiquidityDistributorWrapper public distributor; 28 | 29 | uint24 grandPrizePeriodDraws; 30 | uint8 numberOfTiers; 31 | uint8 tierShares; 32 | uint8 canaryShares; 33 | uint8 reserveShares; 34 | uint256 tierLiquidityUtilizationRate; 35 | 36 | function setUp() external { 37 | numberOfTiers = MINIMUM_NUMBER_OF_TIERS; 38 | tierShares = 100; 39 | canaryShares = 5; 40 | reserveShares = 10; 41 | grandPrizePeriodDraws = 365; 42 | tierLiquidityUtilizationRate = 1e18; 43 | 44 | distributor = new TieredLiquidityDistributorWrapper( 45 | tierLiquidityUtilizationRate, 46 | numberOfTiers, 47 | tierShares, 48 | canaryShares, 49 | reserveShares, 50 | grandPrizePeriodDraws 51 | ); 52 | } 53 | 54 | function testAwardDraw_invalid_num_tiers() public { 55 | vm.expectRevert(abi.encodeWithSelector(NumberOfTiersLessThanMinimum.selector, 1)); 56 | distributor.awardDraw(1, 100); 57 | } 58 | 59 | function testAwardDraw() public { 60 | uint liq1 = 320e18; 61 | distributor.awardDraw(5, liq1); 62 | assertEq(distributor.getTierRemainingLiquidity(0), uint(tierShares) * 1e18, "tier 0 accrued fully"); 63 | assertEq(distributor.getTierRemainingLiquidity(1), uint(tierShares) * 1e18, "daily tier"); 64 | assertEq(distributor.getTierRemainingLiquidity(2), uint(tierShares) * 1e18, "canary accrued one draw 1"); 65 | assertEq(distributor.getTierRemainingLiquidity(3), uint(canaryShares) * 1e18, "canary accrued one draw 2"); 66 | assertEq(distributor.getTierRemainingLiquidity(4), uint(canaryShares) * 1e18, "reduced tier has nothing"); 67 | assertEq(distributor.reserve(), uint(reserveShares) * 1e18, "reserve"); 68 | assertEq(_computeLiquidity(), liq1, "total"); 69 | } 70 | 71 | function testAwardDraw_liquidity_shrinkTiers1() public { 72 | uint liq1 = 320e18; //distributor.computeTotalShares(5) * 1e18; // 5 tiers => 3 normal, 2 canary. => 320e18 total funds. 73 | distributor.awardDraw(5, liq1); 74 | 75 | assertEq(distributor.getTierRemainingLiquidity(0), uint(tierShares) * 1e18, "tier 0"); 76 | assertEq(distributor.getTierRemainingLiquidity(1), uint(tierShares) * 1e18, "tier 1"); 77 | assertEq(distributor.getTierRemainingLiquidity(2), uint(tierShares) * 1e18, "daily tier"); 78 | assertEq(distributor.getTierRemainingLiquidity(3), uint(canaryShares) * 1e18, "canary 1"); 79 | assertEq(distributor.getTierRemainingLiquidity(4), uint(canaryShares) * 1e18, "canary 2"); 80 | assertEq(distributor.getTierRemainingLiquidity(5), 0, "nothing beyond"); 81 | 82 | // we are shrinking, so we'll recoup the two canaries = 110e18 83 | uint liq2 = 110e18; 84 | distributor.awardDraw(4, liq2); 85 | assertEq(distributor.getTierRemainingLiquidity(0), uint(tierShares) * 2e18, "tier 0 accrued fully"); 86 | assertEq(distributor.getTierRemainingLiquidity(1), uint(tierShares) * 2e18, "daily tier"); 87 | assertEq(distributor.getTierRemainingLiquidity(2), uint(canaryShares) * 1e18, "canary accrued one draw 1"); 88 | assertEq(distributor.getTierRemainingLiquidity(3), uint(canaryShares) * 1e18, "canary accrued one draw 2"); 89 | assertEq(distributor.getTierRemainingLiquidity(4), 0, "reduced tier has nothing"); 90 | assertEq(distributor.reserve(), uint(reserveShares) * 2e18, "reserve"); 91 | assertEq(_computeLiquidity(), liq1 + liq2, "total"); 92 | } 93 | 94 | function testAwardDraw_liquidity_shrinkTiers2() public { 95 | distributor.awardDraw(7, 520e18); // 5 normal, 2 canary and reserve 96 | // reclaim 2 canary and 2 regs => 210e18 reclaimed 97 | distributor.awardDraw(5, 110e18); // 3 normal, 2 canary 98 | 99 | assertEq(distributor.getTierRemainingLiquidity(0), 200e18, "tier 0"); 100 | assertEq(distributor.getTierRemainingLiquidity(1), 200e18, "tier 1"); 101 | assertEq(distributor.getTierRemainingLiquidity(2), 200e18, "daily"); 102 | assertEq(distributor.getTierRemainingLiquidity(3), 5e18, "canary 1"); 103 | assertEq(distributor.getTierRemainingLiquidity(4), 5e18, "canary 2"); 104 | assertEq(distributor.getTierRemainingLiquidity(5), 0); 105 | assertEq(distributor.reserve(), 20e18, "reserve"); 106 | 107 | assertEq(_computeLiquidity(), 630e18, "total liquidity"); 108 | } 109 | 110 | function testAwardDraw_liquidity_sameTiers() public { 111 | distributor.awardDraw(5, 100e18); 112 | distributor.awardDraw(5, 100e18); 113 | assertEq(_computeLiquidity(), 200e18); 114 | } 115 | 116 | function testAwardDraw_liquidity_growTiers1() public { 117 | distributor.awardDraw(5, 320e18); 118 | assertEq(_computeLiquidity(), 320e18, "total liquidity for first draw"); 119 | distributor.awardDraw(6, 420e18); 120 | assertEq(_computeLiquidity(), 740e18, "total liquidity for second draw"); 121 | } 122 | 123 | function testAwardDraw_liquidity_growTiers2() public { 124 | distributor.awardDraw(5, 320e18); // 3 tiers and 2 canary. 3 tiers stay, canaries reclaimed. 125 | // reclaimed 10e18 126 | distributor.awardDraw(7, 510e18); // 5 tiers and 2 canary 127 | 128 | assertEq(distributor.getTierRemainingLiquidity(0), 200e18, "old tier 0 continues to accrue"); 129 | assertEq(distributor.getTierRemainingLiquidity(1), 200e18, "old tier 1 continues to accrue"); 130 | assertEq(distributor.getTierRemainingLiquidity(2), 200e18, "old tier 2 continues to accrue"); 131 | assertEq(distributor.getTierRemainingLiquidity(3), 100e18, "old tier 3 continues to accrue"); 132 | assertEq(distributor.getTierRemainingLiquidity(4), 100e18, "old canary gets reclaimed"); 133 | assertEq(distributor.getTierRemainingLiquidity(5), 5e18, "new tier who dis 1"); 134 | assertEq(distributor.getTierRemainingLiquidity(6), 5e18, "new tier who dis 2"); 135 | assertEq(distributor.reserve(), 20e18, "reserve"); 136 | assertEq(_computeLiquidity(), 830e18, "total liquidity"); 137 | } 138 | 139 | function testConstructor_numberOfTiersTooLarge() public { 140 | vm.expectRevert(abi.encodeWithSelector(NumberOfTiersGreaterThanMaximum.selector, 16)); 141 | new TieredLiquidityDistributorWrapper(tierLiquidityUtilizationRate, 16, tierShares, canaryShares, reserveShares, 365); 142 | } 143 | 144 | function testConstructor_numberOfTiersTooSmall() public { 145 | vm.expectRevert(abi.encodeWithSelector(NumberOfTiersLessThanMinimum.selector, 1)); 146 | new TieredLiquidityDistributorWrapper(tierLiquidityUtilizationRate, 1, tierShares, canaryShares, reserveShares, 365); 147 | } 148 | 149 | function testConstructor_tierLiquidityUtilizationRate_gt_1() public { 150 | vm.expectRevert(abi.encodeWithSelector(TierLiquidityUtilizationRateGreaterThanOne.selector)); 151 | new TieredLiquidityDistributorWrapper(1e18 + 1, MINIMUM_NUMBER_OF_TIERS, tierShares, canaryShares, reserveShares, 365); 152 | } 153 | 154 | function testConstructor_tierLiquidityUtilizationRate_zero() public { 155 | vm.expectRevert(abi.encodeWithSelector(TierLiquidityUtilizationRateCannotBeZero.selector)); 156 | new TieredLiquidityDistributorWrapper(0, MINIMUM_NUMBER_OF_TIERS, tierShares, canaryShares, reserveShares, 365); 157 | } 158 | 159 | function testRemainingTierLiquidity() public { 160 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, distributor.getTotalShares() * 1e18); 161 | assertEq(distributor.getTierRemainingLiquidity(0), 100e18, "tier 0"); 162 | assertEq(distributor.getTierRemainingLiquidity(1), 100e18, "tier 1"); 163 | assertEq(distributor.getTierRemainingLiquidity(2), 5e18, "tier 2"); 164 | assertEq(distributor.getTierRemainingLiquidity(3), 5e18, "tier 3"); 165 | } 166 | 167 | // regression test to see if there are any unaccounted rounding errors on consumeLiquidity 168 | function testConsumeLiquidity_roundingErrors() public { 169 | distributor = new TieredLiquidityDistributorWrapper( 170 | tierLiquidityUtilizationRate, 171 | numberOfTiers, 172 | 100, 173 | 9, 174 | 0, 175 | grandPrizePeriodDraws 176 | ); 177 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 218e18); // 100 for each tier + 9 for each canary 178 | 179 | uint256 reserveBefore = distributor.reserve(); 180 | 181 | // There is 9e18 liquidity available for tier 3. 182 | // Each time we consume 1 liquidity we will lose 0.00001 to rounding errors in 183 | // the tier.prizeTokenPerShare value. Over time, this will accumulate and lead 184 | // to the tier thinking is has more remainingLiquidity than it actually does. 185 | for (uint i = 1; i <= 10000; i++) { 186 | distributor.consumeLiquidity(3, 1); 187 | assertEq(distributor.getTierRemainingLiquidity(3) + (distributor.reserve() - reserveBefore), 9e18 - i); 188 | } 189 | 190 | // Test that we can still consume the rest of the liquidity even it if dips in the reserve 191 | assertEq(distributor.getTierRemainingLiquidity(3), 9e18 - 90000); // 10000 consumed + 10000 rounding errors, rounding up by 8 each time 192 | assertEq(distributor.reserve(), 80000); 193 | vm.expectEmit(); 194 | emit ReserveConsumed(80000); // equal to the rounding errors (8 for each one) 195 | distributor.consumeLiquidity(3, 9e18 - 10000); // we only consumed 10000, so we should still be able to consume the rest by dipping into reserve 196 | assertEq(distributor.getTierRemainingLiquidity(3), 0); 197 | } 198 | 199 | function testConsumeLiquidity_partial() public { 200 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 201 | distributor.consumeLiquidity(1, 50e18); // consume full liq for tier 1 202 | assertEq(distributor.getTierRemainingLiquidity(1), 50e18); 203 | } 204 | 205 | function testConsumeLiquidity_full() public { 206 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 207 | distributor.consumeLiquidity(1, 100e18); // consume full liq for tier 1 208 | assertEq(distributor.getTierRemainingLiquidity(1), 0); 209 | } 210 | 211 | function testConsumeLiquidity_and_empty_reserve() public { 212 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); // reserve should be 10e18 213 | uint256 reserve = distributor.reserve(); 214 | distributor.consumeLiquidity(1, 110e18); // consume full liq for tier 1 and reserve 215 | assertEq(distributor.getTierRemainingLiquidity(1), 0); 216 | assertEq(distributor.reserve(), 0); 217 | assertLt(distributor.reserve(), reserve); 218 | } 219 | 220 | function testConsumeLiquidity_and_partial_reserve() public { 221 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); // reserve should be 10e18 222 | uint256 reserve = distributor.reserve(); 223 | distributor.consumeLiquidity(1, 105e18); // consume full liq for tier 1 and reserve 224 | assertEq(distributor.getTierRemainingLiquidity(1), 0); 225 | assertEq(distributor.reserve(), 5e18); 226 | assertLt(distributor.reserve(), reserve); 227 | } 228 | 229 | function testConsumeLiquidity_insufficient() public { 230 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); // reserve should be 10e18 231 | vm.expectRevert(abi.encodeWithSelector(InsufficientLiquidity.selector, 120e18)); 232 | distributor.consumeLiquidity(1, 120e18); 233 | } 234 | 235 | function testGetTierPrizeSize_noDraw() public { 236 | assertEq(distributor.getTierPrizeSize(4), 0); 237 | } 238 | 239 | function testGetTierPrizeSize_invalid() public { 240 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 241 | assertEq(distributor.getTierPrizeSize(4), 0); 242 | } 243 | 244 | function testGetTierPrizeSize_grandPrize() public { 245 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 246 | assertEq(distributor.getTierPrizeSize(0), 100e18); 247 | } 248 | 249 | function testGetTierPrizeSize_grandPrize_utilizationLower() public { 250 | tierLiquidityUtilizationRate = 0.5e18; 251 | distributor = new TieredLiquidityDistributorWrapper(tierLiquidityUtilizationRate, numberOfTiers, tierShares, canaryShares, reserveShares, grandPrizePeriodDraws); 252 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 253 | assertEq(distributor.getTierPrizeSize(0), 50e18); 254 | assertEq(distributor.getTierRemainingLiquidity(0), 100e18); 255 | } 256 | 257 | function testGetTierPrizeSize_overflow() public { 258 | distributor = new TieredLiquidityDistributorWrapper(tierLiquidityUtilizationRate, numberOfTiers, tierShares, canaryShares, 0, 365); 259 | 260 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, type(uint104).max); 261 | distributor.awardDraw(4, type(uint104).max); 262 | distributor.awardDraw(5, type(uint104).max); 263 | distributor.awardDraw(6, type(uint104).max); 264 | 265 | assertEq(distributor.getTierPrizeSize(0), type(uint104).max); 266 | } 267 | 268 | function testGetRemainingTierLiquidity() public { 269 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 270 | assertEq(distributor.getTierRemainingLiquidity(0), 100e18); 271 | } 272 | 273 | function testGetTierRemainingLiquidity() public { 274 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 275 | assertEq(distributor.getTierRemainingLiquidity(0), 100e18); 276 | } 277 | 278 | function testGetTierRemainingLiquidity_invalid() public { 279 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, 220e18); 280 | assertEq(distributor.getTierRemainingLiquidity(5), 0); 281 | } 282 | 283 | function testIsCanaryTier() public { 284 | assertEq(distributor.isCanaryTier(0), false, "grand prize"); 285 | assertEq(distributor.isCanaryTier(1), false, "daily tier"); 286 | assertEq(distributor.isCanaryTier(2), true, "canary 1"); 287 | assertEq(distributor.isCanaryTier(3), true, "canary 2"); 288 | } 289 | 290 | function testExpansionTierLiquidity() public { 291 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, distributor.getTotalShares() * 1e18); // canary gets 100e18 292 | assertEq(distributor.getTierRemainingLiquidity(0), 100e18, "grand prize liquidity"); 293 | assertEq(distributor.getTierRemainingLiquidity(1), 100e18, "tier 1 liquidity"); 294 | assertEq(distributor.getTierRemainingLiquidity(2), 5e18, "canary 1 liquidity"); 295 | assertEq(distributor.getTierRemainingLiquidity(3), 5e18, "canary 2 liquidity"); 296 | assertEq(distributor.reserve(), 10e18, "reserve liquidity"); 297 | 298 | // canary will be reclaimed 299 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS+1, 310e18); // total will be 200e18 + 210e18 = 410e18 300 | 301 | assertEq(distributor.getTierRemainingLiquidity(0), 200e18, "grand prize liquidity"); 302 | assertEq(distributor.getTierRemainingLiquidity(1), 200e18, "tier 1 liquidity"); 303 | assertEq(distributor.getTierRemainingLiquidity(2), 100e18, "tier 2 liquidity"); 304 | assertEq(distributor.getTierRemainingLiquidity(3), 5e18, "tier 3 liquidity"); 305 | assertEq(distributor.getTierRemainingLiquidity(4), 5e18, "last tier out"); 306 | } 307 | 308 | function testExpansionTierLiquidity_max() public { 309 | uint96 amount = 79228162514264337593543950333; 310 | // uint96 amount = 100e18; 311 | distributor.awardDraw(15, amount); 312 | 313 | uint128 prizeTokenPerShare = distributor.prizeTokenPerShare(); 314 | uint256 total = ( 315 | prizeTokenPerShare * (distributor.getTotalShares() - distributor.reserveShares()) 316 | ) + distributor.reserve(); 317 | assertEq(total, amount, "prize token per share against total shares"); 318 | 319 | uint256 summed; 320 | for (uint8 t = 0; t < distributor.numberOfTiers(); t++) { 321 | summed += distributor.getTierRemainingLiquidity(t); 322 | } 323 | summed += distributor.reserve(); 324 | 325 | assertEq(summed, amount, "summed amount across prize tiers"); 326 | } 327 | 328 | function testGetTierOdds_AllAvailable() public { 329 | SD59x18 odds; 330 | grandPrizePeriodDraws = distributor.grandPrizePeriodDraws(); 331 | for ( 332 | uint8 numTiers = MINIMUM_NUMBER_OF_TIERS; 333 | numTiers <= MAXIMUM_NUMBER_OF_TIERS; 334 | numTiers++ 335 | ) { 336 | for (uint8 tier = 0; tier < numTiers; tier++) { 337 | odds = distributor.getTierOdds(tier, numTiers); 338 | } 339 | } 340 | } 341 | 342 | function testGetTierPrizeCount() public { 343 | assertEq(distributor.getTierPrizeCount(0), 1); 344 | assertEq(distributor.getTierPrizeCount(1), 4); 345 | assertEq(distributor.getTierPrizeCount(2), 16); 346 | } 347 | 348 | function testGetTierOdds_grandPrize() public { 349 | for (uint8 i = MINIMUM_NUMBER_OF_TIERS; i <= MAXIMUM_NUMBER_OF_TIERS; i++) { 350 | assertEq(distributor.getTierOdds(0, i).unwrap(), int(1e18)/int(365), string.concat("grand for num tiers ", string(abi.encode(i)))); 351 | } 352 | } 353 | 354 | function testGetTierOdds_dailyCanary() public { 355 | // 3 - 10 356 | for (uint8 i = MINIMUM_NUMBER_OF_TIERS - 1; i <= MAXIMUM_NUMBER_OF_TIERS; i++) { 357 | // last tier (canary 2) 358 | assertEq(distributor.getTierOdds(i-1, i).unwrap(), 1e18, string.concat("canary 2 for num tiers ", string(abi.encode(i)))); 359 | // third to last (daily) 360 | assertEq(distributor.getTierOdds(i-2, i).unwrap(), 1e18, string.concat("canary 1 for num tiers ", string(abi.encode(i)))); 361 | if (i > 3) { 362 | // second to last tier (canary 1) 363 | assertEq(distributor.getTierOdds(i-3, i).unwrap(), 1e18, string.concat("daily for num tiers ", string(abi.encode(i)))); 364 | } 365 | } 366 | } 367 | 368 | function testGetTierOdds_zero_when_outside_bounds() public { 369 | SD59x18 odds; 370 | for ( 371 | uint8 numTiers = MINIMUM_NUMBER_OF_TIERS - 1; 372 | numTiers <= MAXIMUM_NUMBER_OF_TIERS; 373 | numTiers++ 374 | ) { 375 | odds = distributor.getTierOdds(numTiers, numTiers); 376 | assertEq(SD59x18.unwrap(odds), 0); 377 | } 378 | } 379 | 380 | function testEstimateNumberOfTiersUsingPrizeCountPerDraw_allTiers() public { 381 | uint32 prizeCount; 382 | for ( 383 | uint8 numTiers = MINIMUM_NUMBER_OF_TIERS; 384 | numTiers <= MAXIMUM_NUMBER_OF_TIERS; 385 | numTiers++ 386 | ) { 387 | prizeCount = distributor.estimatedPrizeCount(numTiers); 388 | assertEq( 389 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(prizeCount - 1), 390 | numTiers, 391 | "slightly under" 392 | ); 393 | assertEq( 394 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(prizeCount), 395 | numTiers, 396 | "match" 397 | ); 398 | assertEq( 399 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(prizeCount + 1), 400 | numTiers, 401 | "slightly over" 402 | ); 403 | } 404 | 405 | assertEq(distributor.estimatedPrizeCount(12), 0, "exceeds bounds"); 406 | } 407 | 408 | function testEstimateNumberOfTiersUsingPrizeCountPerDraw_loose() public { 409 | // 270 prizes for num tiers = 5 410 | assertEq( 411 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(250), 412 | 6, 413 | "matches slightly under" 414 | ); 415 | assertEq( 416 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(270), 417 | 6, 418 | "matches exact" 419 | ); 420 | assertEq( 421 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(280), 422 | 6, 423 | "matches slightly over" 424 | ); 425 | assertEq( 426 | distributor.estimateNumberOfTiersUsingPrizeCountPerDraw(540), 427 | 6, 428 | "matches significantly over" 429 | ); 430 | } 431 | 432 | function testEstimatedPrizeCount_noParam() public { 433 | assertEq(distributor.estimatedPrizeCount(), 20); 434 | } 435 | 436 | function testEstimatedPrizeCount_allTiers() public { 437 | assertEq(distributor.estimatedPrizeCount(4), 20, "num tiers 4"); 438 | assertEq(distributor.estimatedPrizeCount(5), 80, "num tiers 5"); 439 | assertEq(distributor.estimatedPrizeCount(6), 320, "num tiers 6"); 440 | assertEq(distributor.estimatedPrizeCount(7), 1283, "num tiers 7"); 441 | assertEq(distributor.estimatedPrizeCount(8), 5139, "num tiers 8"); 442 | assertEq(distributor.estimatedPrizeCount(9), 20580, "num tiers 9"); 443 | assertEq(distributor.estimatedPrizeCount(10), 82408, "num tiers 10"); 444 | assertEq(distributor.estimatedPrizeCount(11), 329958, "num tiers 11"); 445 | assertEq(distributor.estimatedPrizeCount(12), 0, "num tiers 12"); 446 | } 447 | 448 | function testEstimatedPrizeCountWithBothCanaries_allTiers() public { 449 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(3), 0, "num tiers 3"); 450 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(4), 20 + 4**3, "num tiers 4"); 451 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(5), 80 + 4**4, "num tiers 5"); 452 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(6), 320 + 4**5, "num tiers 6"); 453 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(7), 1283 + 4**6, "num tiers 7"); 454 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(12), 0, "num tiers 12"); 455 | } 456 | 457 | function testEstimatedPrizeCountWithBothCanaries() public { 458 | assertEq(distributor.estimatedPrizeCountWithBothCanaries(), 20 + 4**3, "num tiers 4"); 459 | } 460 | 461 | function testSumTierPrizeCounts() public { 462 | // 16 canary 1 daily + 64 canary 2 daily = 80 463 | assertEq(distributor.sumTierPrizeCounts(5), 80, "num tiers 5"); 464 | // 64 + 256 = ~320 465 | assertEq(distributor.sumTierPrizeCounts(6), 320, "num tiers 6"); 466 | // 256 + 1024 = ~1280 467 | assertEq(distributor.sumTierPrizeCounts(7), 1283, "num tiers 7"); 468 | // 1024 + 4096 = ~5120 (plus a few prizes from non-daily tiers) 469 | assertEq(distributor.sumTierPrizeCounts(8), 5139, "num tiers 8"); 470 | assertEq(distributor.sumTierPrizeCounts(9), 20580, "num tiers 9"); 471 | assertEq(distributor.sumTierPrizeCounts(10), 82408, "num tiers 10"); 472 | assertEq(distributor.sumTierPrizeCounts(11), 329958, "num tiers 11"); 473 | assertEq(distributor.sumTierPrizeCounts(12), 0, "num tiers 12"); 474 | } 475 | 476 | function testExpansionTierLiquidity_regression() public { 477 | uint96 amount1 = 253012247290373118207; 478 | uint96 amount2 = 99152290762372054017; 479 | uint96 amount3 = 79228162514264337593543950333; 480 | uint total = amount1 + amount2 + uint(amount3); 481 | 482 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, amount1); 483 | 484 | assertEq(summedLiquidity(), amount1, "after amount1"); 485 | 486 | distributor.awardDraw(MINIMUM_NUMBER_OF_TIERS, amount2); 487 | 488 | assertEq(summedLiquidity(), amount1 + uint(amount2), "after amount2"); 489 | 490 | distributor.awardDraw(15, amount3); 491 | 492 | assertEq(summedLiquidity(), total, "after amount3"); 493 | } 494 | 495 | function summedLiquidity() public view returns (uint256) { 496 | uint256 summed; 497 | for (uint8 t = 0; t < distributor.numberOfTiers(); t++) { 498 | summed += distributor.getTierRemainingLiquidity(t); 499 | } 500 | summed += distributor.reserve(); 501 | return summed; 502 | } 503 | 504 | function _computeLiquidity() internal view returns (uint256) { 505 | // console2.log("test _computeLiquidity, distributor.numberOfTiers()", distributor.numberOfTiers()); 506 | uint256 liquidity = _getTotalTierRemainingLiquidity(distributor.numberOfTiers()); 507 | liquidity += distributor.reserve(); 508 | return liquidity; 509 | } 510 | 511 | function _getTotalTierRemainingLiquidity(uint8 _numberOfTiers) internal view returns (uint256) { 512 | uint256 liquidity = 0; 513 | for (uint8 i = 0; i < _numberOfTiers; i++) { 514 | liquidity += distributor.getTierRemainingLiquidity(i); 515 | } 516 | return liquidity; 517 | } 518 | } 519 | -------------------------------------------------------------------------------- /src/abstract/TieredLiquidityDistributor.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | 3 | pragma solidity ^0.8.19; 4 | 5 | import { SafeCast } from "openzeppelin/utils/math/SafeCast.sol"; 6 | import { SD59x18, sd } from "prb-math/SD59x18.sol"; 7 | import { UD60x18, convert } from "prb-math/UD60x18.sol"; 8 | 9 | import { TierCalculationLib } from "../libraries/TierCalculationLib.sol"; 10 | 11 | /// @notice Struct that tracks tier liquidity information. 12 | /// @param drawId The draw ID that the tier was last updated for 13 | /// @param prizeSize The size of the prize for the tier at the drawId 14 | /// @param prizeTokenPerShare The total prize tokens per share that have already been consumed for this tier. 15 | struct Tier { 16 | uint24 drawId; 17 | uint104 prizeSize; 18 | uint128 prizeTokenPerShare; 19 | } 20 | 21 | /// @notice Thrown when the number of tiers is less than the minimum number of tiers. 22 | /// @param numTiers The invalid number of tiers 23 | error NumberOfTiersLessThanMinimum(uint8 numTiers); 24 | 25 | /// @notice Thrown when the number of tiers is greater than the max tiers 26 | /// @param numTiers The invalid number of tiers 27 | error NumberOfTiersGreaterThanMaximum(uint8 numTiers); 28 | 29 | /// @notice Thrown when the tier liquidity utilization rate is greater than 1. 30 | error TierLiquidityUtilizationRateGreaterThanOne(); 31 | 32 | /// @notice Thrown when the tier liquidity utilization rate is 0. 33 | error TierLiquidityUtilizationRateCannotBeZero(); 34 | 35 | /// @notice Thrown when there is insufficient liquidity to consume. 36 | /// @param requestedLiquidity The requested amount of liquidity 37 | error InsufficientLiquidity(uint104 requestedLiquidity); 38 | 39 | uint8 constant MINIMUM_NUMBER_OF_TIERS = 4; 40 | uint8 constant MAXIMUM_NUMBER_OF_TIERS = 11; 41 | uint8 constant NUMBER_OF_CANARY_TIERS = 2; 42 | 43 | /// @title Tiered Liquidity Distributor 44 | /// @author PoolTogether Inc. 45 | /// @notice A contract that distributes liquidity according to PoolTogether V5 distribution rules. 46 | contract TieredLiquidityDistributor { 47 | /* ============ Events ============ */ 48 | 49 | /// @notice Emitted when the reserve is consumed due to insufficient prize liquidity. 50 | /// @param amount The amount to decrease by 51 | event ReserveConsumed(uint256 amount); 52 | 53 | /* ============ Constants ============ */ 54 | 55 | /// @notice The odds for each tier and number of tiers pair. For n tiers, the last three tiers are always daily. 56 | SD59x18 internal immutable TIER_ODDS_0; 57 | SD59x18 internal immutable TIER_ODDS_EVERY_DRAW; 58 | SD59x18 internal immutable TIER_ODDS_1_5; 59 | SD59x18 internal immutable TIER_ODDS_1_6; 60 | SD59x18 internal immutable TIER_ODDS_2_6; 61 | SD59x18 internal immutable TIER_ODDS_1_7; 62 | SD59x18 internal immutable TIER_ODDS_2_7; 63 | SD59x18 internal immutable TIER_ODDS_3_7; 64 | SD59x18 internal immutable TIER_ODDS_1_8; 65 | SD59x18 internal immutable TIER_ODDS_2_8; 66 | SD59x18 internal immutable TIER_ODDS_3_8; 67 | SD59x18 internal immutable TIER_ODDS_4_8; 68 | SD59x18 internal immutable TIER_ODDS_1_9; 69 | SD59x18 internal immutable TIER_ODDS_2_9; 70 | SD59x18 internal immutable TIER_ODDS_3_9; 71 | SD59x18 internal immutable TIER_ODDS_4_9; 72 | SD59x18 internal immutable TIER_ODDS_5_9; 73 | SD59x18 internal immutable TIER_ODDS_1_10; 74 | SD59x18 internal immutable TIER_ODDS_2_10; 75 | SD59x18 internal immutable TIER_ODDS_3_10; 76 | SD59x18 internal immutable TIER_ODDS_4_10; 77 | SD59x18 internal immutable TIER_ODDS_5_10; 78 | SD59x18 internal immutable TIER_ODDS_6_10; 79 | SD59x18 internal immutable TIER_ODDS_1_11; 80 | SD59x18 internal immutable TIER_ODDS_2_11; 81 | SD59x18 internal immutable TIER_ODDS_3_11; 82 | SD59x18 internal immutable TIER_ODDS_4_11; 83 | SD59x18 internal immutable TIER_ODDS_5_11; 84 | SD59x18 internal immutable TIER_ODDS_6_11; 85 | SD59x18 internal immutable TIER_ODDS_7_11; 86 | 87 | /// @notice The estimated number of prizes given X tiers. 88 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_4_TIERS; 89 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_5_TIERS; 90 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_6_TIERS; 91 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_7_TIERS; 92 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_8_TIERS; 93 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_9_TIERS; 94 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_10_TIERS; 95 | uint32 internal immutable ESTIMATED_PRIZES_PER_DRAW_FOR_11_TIERS; 96 | 97 | /// @notice The Tier liquidity data. 98 | mapping(uint8 tierId => Tier tierData) internal _tiers; 99 | 100 | /// @notice The frequency of the grand prize 101 | uint24 public immutable grandPrizePeriodDraws; 102 | 103 | /// @notice The number of shares to allocate to each prize tier. 104 | uint8 public immutable tierShares; 105 | 106 | /// @notice The number of shares to allocate to each canary tier. 107 | uint8 public immutable canaryShares; 108 | 109 | /// @notice The number of shares to allocate to the reserve. 110 | uint8 public immutable reserveShares; 111 | 112 | /// @notice The percentage of tier liquidity to target for utilization. 113 | UD60x18 public immutable tierLiquidityUtilizationRate; 114 | 115 | /// @notice The number of prize tokens that have accrued per share for all time. 116 | /// @dev This is an ever-increasing exchange rate that is used to calculate the prize liquidity for each tier. 117 | /// @dev Each tier holds a separate tierPrizeTokenPerShare; the delta between the tierPrizeTokenPerShare and 118 | /// the prizeTokenPerShare * tierShares is the available liquidity they have. 119 | uint128 public prizeTokenPerShare; 120 | 121 | /// @notice The number of tiers for the last awarded draw. The last tier is the canary tier. 122 | uint8 public numberOfTiers; 123 | 124 | /// @notice The draw id of the last awarded draw. 125 | uint24 internal _lastAwardedDrawId; 126 | 127 | /// @notice The timestamp at which the last awarded draw was awarded. 128 | uint48 public lastAwardedDrawAwardedAt; 129 | 130 | /// @notice The amount of available reserve. 131 | uint96 internal _reserve; 132 | 133 | /// @notice Constructs a new Prize Pool. 134 | /// @param _tierLiquidityUtilizationRate The target percentage of tier liquidity to utilize each draw 135 | /// @param _numberOfTiers The number of tiers to start with. Must be greater than or equal to the minimum number of tiers. 136 | /// @param _tierShares The number of shares to allocate to each tier 137 | /// @param _canaryShares The number of shares to allocate to each canary tier 138 | /// @param _reserveShares The number of shares to allocate to the reserve. 139 | /// @param _grandPrizePeriodDraws The number of draws between grand prizes 140 | constructor( 141 | uint256 _tierLiquidityUtilizationRate, 142 | uint8 _numberOfTiers, 143 | uint8 _tierShares, 144 | uint8 _canaryShares, 145 | uint8 _reserveShares, 146 | uint24 _grandPrizePeriodDraws 147 | ) { 148 | if (_numberOfTiers < MINIMUM_NUMBER_OF_TIERS) { 149 | revert NumberOfTiersLessThanMinimum(_numberOfTiers); 150 | } 151 | if (_numberOfTiers > MAXIMUM_NUMBER_OF_TIERS) { 152 | revert NumberOfTiersGreaterThanMaximum(_numberOfTiers); 153 | } 154 | if (_tierLiquidityUtilizationRate > 1e18) { 155 | revert TierLiquidityUtilizationRateGreaterThanOne(); 156 | } 157 | if (_tierLiquidityUtilizationRate == 0) { 158 | revert TierLiquidityUtilizationRateCannotBeZero(); 159 | } 160 | 161 | tierLiquidityUtilizationRate = UD60x18.wrap(_tierLiquidityUtilizationRate); 162 | 163 | numberOfTiers = _numberOfTiers; 164 | tierShares = _tierShares; 165 | canaryShares = _canaryShares; 166 | reserveShares = _reserveShares; 167 | grandPrizePeriodDraws = _grandPrizePeriodDraws; 168 | 169 | TIER_ODDS_0 = sd(1).div(sd(int24(_grandPrizePeriodDraws))); 170 | TIER_ODDS_EVERY_DRAW = SD59x18.wrap(1000000000000000000); 171 | TIER_ODDS_1_5 = TierCalculationLib.getTierOdds(1, 3, _grandPrizePeriodDraws); 172 | TIER_ODDS_1_6 = TierCalculationLib.getTierOdds(1, 4, _grandPrizePeriodDraws); 173 | TIER_ODDS_2_6 = TierCalculationLib.getTierOdds(2, 4, _grandPrizePeriodDraws); 174 | TIER_ODDS_1_7 = TierCalculationLib.getTierOdds(1, 5, _grandPrizePeriodDraws); 175 | TIER_ODDS_2_7 = TierCalculationLib.getTierOdds(2, 5, _grandPrizePeriodDraws); 176 | TIER_ODDS_3_7 = TierCalculationLib.getTierOdds(3, 5, _grandPrizePeriodDraws); 177 | TIER_ODDS_1_8 = TierCalculationLib.getTierOdds(1, 6, _grandPrizePeriodDraws); 178 | TIER_ODDS_2_8 = TierCalculationLib.getTierOdds(2, 6, _grandPrizePeriodDraws); 179 | TIER_ODDS_3_8 = TierCalculationLib.getTierOdds(3, 6, _grandPrizePeriodDraws); 180 | TIER_ODDS_4_8 = TierCalculationLib.getTierOdds(4, 6, _grandPrizePeriodDraws); 181 | TIER_ODDS_1_9 = TierCalculationLib.getTierOdds(1, 7, _grandPrizePeriodDraws); 182 | TIER_ODDS_2_9 = TierCalculationLib.getTierOdds(2, 7, _grandPrizePeriodDraws); 183 | TIER_ODDS_3_9 = TierCalculationLib.getTierOdds(3, 7, _grandPrizePeriodDraws); 184 | TIER_ODDS_4_9 = TierCalculationLib.getTierOdds(4, 7, _grandPrizePeriodDraws); 185 | TIER_ODDS_5_9 = TierCalculationLib.getTierOdds(5, 7, _grandPrizePeriodDraws); 186 | TIER_ODDS_1_10 = TierCalculationLib.getTierOdds(1, 8, _grandPrizePeriodDraws); 187 | TIER_ODDS_2_10 = TierCalculationLib.getTierOdds(2, 8, _grandPrizePeriodDraws); 188 | TIER_ODDS_3_10 = TierCalculationLib.getTierOdds(3, 8, _grandPrizePeriodDraws); 189 | TIER_ODDS_4_10 = TierCalculationLib.getTierOdds(4, 8, _grandPrizePeriodDraws); 190 | TIER_ODDS_5_10 = TierCalculationLib.getTierOdds(5, 8, _grandPrizePeriodDraws); 191 | TIER_ODDS_6_10 = TierCalculationLib.getTierOdds(6, 8, _grandPrizePeriodDraws); 192 | TIER_ODDS_1_11 = TierCalculationLib.getTierOdds(1, 9, _grandPrizePeriodDraws); 193 | TIER_ODDS_2_11 = TierCalculationLib.getTierOdds(2, 9, _grandPrizePeriodDraws); 194 | TIER_ODDS_3_11 = TierCalculationLib.getTierOdds(3, 9, _grandPrizePeriodDraws); 195 | TIER_ODDS_4_11 = TierCalculationLib.getTierOdds(4, 9, _grandPrizePeriodDraws); 196 | TIER_ODDS_5_11 = TierCalculationLib.getTierOdds(5, 9, _grandPrizePeriodDraws); 197 | TIER_ODDS_6_11 = TierCalculationLib.getTierOdds(6, 9, _grandPrizePeriodDraws); 198 | TIER_ODDS_7_11 = TierCalculationLib.getTierOdds(7, 9, _grandPrizePeriodDraws); 199 | 200 | ESTIMATED_PRIZES_PER_DRAW_FOR_4_TIERS = _sumTierPrizeCounts(4); 201 | ESTIMATED_PRIZES_PER_DRAW_FOR_5_TIERS = _sumTierPrizeCounts(5); 202 | ESTIMATED_PRIZES_PER_DRAW_FOR_6_TIERS = _sumTierPrizeCounts(6); 203 | ESTIMATED_PRIZES_PER_DRAW_FOR_7_TIERS = _sumTierPrizeCounts(7); 204 | ESTIMATED_PRIZES_PER_DRAW_FOR_8_TIERS = _sumTierPrizeCounts(8); 205 | ESTIMATED_PRIZES_PER_DRAW_FOR_9_TIERS = _sumTierPrizeCounts(9); 206 | ESTIMATED_PRIZES_PER_DRAW_FOR_10_TIERS = _sumTierPrizeCounts(10); 207 | ESTIMATED_PRIZES_PER_DRAW_FOR_11_TIERS = _sumTierPrizeCounts(11); 208 | } 209 | 210 | /// @notice Adjusts the number of tiers and distributes new liquidity. 211 | /// @param _awardingDraw The ID of the draw that is being awarded 212 | /// @param _nextNumberOfTiers The new number of tiers. Must be greater than minimum 213 | /// @param _prizeTokenLiquidity The amount of fresh liquidity to distribute across the tiers and reserve 214 | function _awardDraw( 215 | uint24 _awardingDraw, 216 | uint8 _nextNumberOfTiers, 217 | uint256 _prizeTokenLiquidity 218 | ) internal { 219 | if (_nextNumberOfTiers < MINIMUM_NUMBER_OF_TIERS) { 220 | revert NumberOfTiersLessThanMinimum(_nextNumberOfTiers); 221 | } 222 | 223 | uint8 numTiers = numberOfTiers; 224 | uint128 _prizeTokenPerShare = prizeTokenPerShare; 225 | (uint96 deltaReserve, uint128 newPrizeTokenPerShare) = _computeNewDistributions( 226 | numTiers, 227 | _nextNumberOfTiers, 228 | _prizeTokenPerShare, 229 | _prizeTokenLiquidity 230 | ); 231 | 232 | uint8 start = _computeReclamationStart(numTiers, _nextNumberOfTiers); 233 | uint8 end = _nextNumberOfTiers; 234 | for (uint8 i = start; i < end; i++) { 235 | _tiers[i] = Tier({ 236 | drawId: _awardingDraw, 237 | prizeTokenPerShare: _prizeTokenPerShare, 238 | prizeSize: _computePrizeSize( 239 | i, 240 | _nextNumberOfTiers, 241 | _prizeTokenPerShare, 242 | newPrizeTokenPerShare 243 | ) 244 | }); 245 | } 246 | 247 | prizeTokenPerShare = newPrizeTokenPerShare; 248 | numberOfTiers = _nextNumberOfTiers; 249 | _lastAwardedDrawId = _awardingDraw; 250 | lastAwardedDrawAwardedAt = uint48(block.timestamp); 251 | _reserve += deltaReserve; 252 | } 253 | 254 | /// @notice Computes the liquidity that will be distributed for the next awarded draw given the next number of tiers and prize liquidity. 255 | /// @param _numberOfTiers The current number of tiers 256 | /// @param _nextNumberOfTiers The next number of tiers to use to compute distribution 257 | /// @param _currentPrizeTokenPerShare The current prize token per share 258 | /// @param _prizeTokenLiquidity The amount of fresh liquidity to distribute across the tiers and reserve 259 | /// @return deltaReserve The amount of liquidity that will be added to the reserve 260 | /// @return newPrizeTokenPerShare The new prize token per share 261 | function _computeNewDistributions( 262 | uint8 _numberOfTiers, 263 | uint8 _nextNumberOfTiers, 264 | uint128 _currentPrizeTokenPerShare, 265 | uint256 _prizeTokenLiquidity 266 | ) internal view returns (uint96 deltaReserve, uint128 newPrizeTokenPerShare) { 267 | uint256 reclaimedLiquidity; 268 | { 269 | // need to redistribute to the canary tier and any new tiers (if expanding) 270 | uint8 start = _computeReclamationStart(_numberOfTiers, _nextNumberOfTiers); 271 | uint8 end = _numberOfTiers; 272 | for (uint8 i = start; i < end; i++) { 273 | reclaimedLiquidity = reclaimedLiquidity + ( 274 | _getTierRemainingLiquidity( 275 | _tiers[i].prizeTokenPerShare, 276 | _currentPrizeTokenPerShare, 277 | _numShares(i, _numberOfTiers) 278 | ) 279 | ); 280 | } 281 | } 282 | 283 | uint256 totalNewLiquidity = _prizeTokenLiquidity + reclaimedLiquidity; 284 | uint256 nextTotalShares = computeTotalShares(_nextNumberOfTiers); 285 | uint256 deltaPrizeTokensPerShare = totalNewLiquidity / nextTotalShares; 286 | 287 | newPrizeTokenPerShare = SafeCast.toUint128(_currentPrizeTokenPerShare + deltaPrizeTokensPerShare); 288 | 289 | deltaReserve = SafeCast.toUint96( 290 | // reserve portion of new liquidity 291 | deltaPrizeTokensPerShare * 292 | reserveShares + 293 | // remainder left over from shares 294 | totalNewLiquidity - 295 | deltaPrizeTokensPerShare * 296 | nextTotalShares 297 | ); 298 | } 299 | 300 | /// @notice Returns the prize size for the given tier. 301 | /// @param _tier The tier to retrieve 302 | /// @return The prize size for the tier 303 | function getTierPrizeSize(uint8 _tier) external view returns (uint104) { 304 | uint8 _numTiers = numberOfTiers; 305 | 306 | return 307 | !TierCalculationLib.isValidTier(_tier, _numTiers) ? 0 : _getTier(_tier, _numTiers).prizeSize; 308 | } 309 | 310 | /// @notice Returns the estimated number of prizes for the given tier. 311 | /// @param _tier The tier to retrieve 312 | /// @return The estimated number of prizes 313 | function getTierPrizeCount(uint8 _tier) external pure returns (uint32) { 314 | return uint32(TierCalculationLib.prizeCount(_tier)); 315 | } 316 | 317 | /// @notice Retrieves an up-to-date Tier struct for the given tier. 318 | /// @param _tier The tier to retrieve 319 | /// @param _numberOfTiers The number of tiers, should match the current. Passed explicitly as an optimization 320 | /// @return An up-to-date Tier struct; if the prize is outdated then it is recomputed based on available liquidity and the draw ID is updated. 321 | function _getTier(uint8 _tier, uint8 _numberOfTiers) internal view returns (Tier memory) { 322 | Tier memory tier = _tiers[_tier]; 323 | uint24 lastAwardedDrawId_ = _lastAwardedDrawId; 324 | if (tier.drawId != lastAwardedDrawId_) { 325 | tier.drawId = lastAwardedDrawId_; 326 | tier.prizeSize = _computePrizeSize( 327 | _tier, 328 | _numberOfTiers, 329 | tier.prizeTokenPerShare, 330 | prizeTokenPerShare 331 | ); 332 | } 333 | return tier; 334 | } 335 | 336 | /// @notice Computes the total shares in the system. 337 | /// @return The total shares 338 | function getTotalShares() external view returns (uint256) { 339 | return computeTotalShares(numberOfTiers); 340 | } 341 | 342 | /// @notice Computes the total shares in the system given the number of tiers. 343 | /// @param _numberOfTiers The number of tiers to calculate the total shares for 344 | /// @return The total shares 345 | function computeTotalShares(uint8 _numberOfTiers) public view returns (uint256) { 346 | return uint256(_numberOfTiers-2) * uint256(tierShares) + uint256(reserveShares) + uint256(canaryShares) * 2; 347 | } 348 | 349 | /// @notice Determines at which tier we need to start reclaiming liquidity. 350 | /// @param _numberOfTiers The current number of tiers 351 | /// @param _nextNumberOfTiers The next number of tiers 352 | /// @return The tier to start reclaiming liquidity from 353 | function _computeReclamationStart(uint8 _numberOfTiers, uint8 _nextNumberOfTiers) internal pure returns (uint8) { 354 | // We must always reset the canary tiers, both old and new. 355 | // If the next num is less than the num tiers, then the first canary tiers to reset are the last of the next tiers. 356 | // Otherwise, the canary tiers to reset are the last of the current tiers. 357 | return (_nextNumberOfTiers > _numberOfTiers ? _numberOfTiers : _nextNumberOfTiers) - NUMBER_OF_CANARY_TIERS; 358 | } 359 | 360 | /// @notice Consumes liquidity from the given tier. 361 | /// @param _tierStruct The tier to consume liquidity from 362 | /// @param _tier The tier number 363 | /// @param _liquidity The amount of liquidity to consume 364 | function _consumeLiquidity(Tier memory _tierStruct, uint8 _tier, uint104 _liquidity) internal { 365 | uint8 _tierShares = _numShares(_tier, numberOfTiers); 366 | uint104 remainingLiquidity = SafeCast.toUint104( 367 | _getTierRemainingLiquidity( 368 | _tierStruct.prizeTokenPerShare, 369 | prizeTokenPerShare, 370 | _tierShares 371 | ) 372 | ); 373 | 374 | if (_liquidity > remainingLiquidity) { 375 | uint96 excess = SafeCast.toUint96(_liquidity - remainingLiquidity); 376 | 377 | if (excess > _reserve) { 378 | revert InsufficientLiquidity(_liquidity); 379 | } 380 | 381 | unchecked { 382 | _reserve -= excess; 383 | } 384 | 385 | emit ReserveConsumed(excess); 386 | _tierStruct.prizeTokenPerShare = prizeTokenPerShare; 387 | } else { 388 | uint8 _remainder = uint8(_liquidity % _tierShares); 389 | uint8 _roundUpConsumption = _remainder == 0 ? 0 : _tierShares - _remainder; 390 | if (_roundUpConsumption > 0) { 391 | // We must round up our tier prize token per share value to ensure we don't over-award the tier's 392 | // liquidity, but any extra rounded up consumption can be contributed to the reserve so every wei 393 | // is accounted for. 394 | _reserve += _roundUpConsumption; 395 | } 396 | 397 | // We know that the rounded up `liquidity` won't exceed the `remainingLiquidity` since the `remainingLiquidity` 398 | // is an integer multiple of `_tierShares` and we check above that `_liquidity <= remainingLiquidity`. 399 | _tierStruct.prizeTokenPerShare += SafeCast.toUint104(uint256(_liquidity) + _roundUpConsumption) / _tierShares; 400 | } 401 | 402 | _tiers[_tier] = _tierStruct; 403 | } 404 | 405 | /// @notice Computes the prize size of the given tier. 406 | /// @param _tier The tier to compute the prize size of 407 | /// @param _numberOfTiers The current number of tiers 408 | /// @param _tierPrizeTokenPerShare The prizeTokenPerShare of the Tier struct 409 | /// @param _prizeTokenPerShare The global prizeTokenPerShare 410 | /// @return The prize size 411 | function _computePrizeSize( 412 | uint8 _tier, 413 | uint8 _numberOfTiers, 414 | uint128 _tierPrizeTokenPerShare, 415 | uint128 _prizeTokenPerShare 416 | ) internal view returns (uint104) { 417 | uint256 prizeCount = TierCalculationLib.prizeCount(_tier); 418 | uint256 remainingTierLiquidity = _getTierRemainingLiquidity( 419 | _tierPrizeTokenPerShare, 420 | _prizeTokenPerShare, 421 | _numShares(_tier, _numberOfTiers) 422 | ); 423 | 424 | uint256 prizeSize = convert( 425 | convert(remainingTierLiquidity).mul(tierLiquidityUtilizationRate).div(convert(prizeCount)) 426 | ); 427 | 428 | return prizeSize > type(uint104).max ? type(uint104).max : uint104(prizeSize); 429 | } 430 | 431 | /// @notice Returns whether the given tier is a canary tier 432 | /// @param _tier The tier to check 433 | /// @return True if the passed tier is a canary tier, false otherwise 434 | function isCanaryTier(uint8 _tier) public view returns (bool) { 435 | return _tier >= numberOfTiers - NUMBER_OF_CANARY_TIERS; 436 | } 437 | 438 | /// @notice Returns the number of shares for the given tier and number of tiers. 439 | /// @param _tier The tier to compute the number of shares for 440 | /// @param _numberOfTiers The number of tiers 441 | /// @return The number of shares 442 | function _numShares(uint8 _tier, uint8 _numberOfTiers) internal view returns (uint8) { 443 | uint8 result = _tier > _numberOfTiers - 3 ? canaryShares : tierShares; 444 | return result; 445 | } 446 | 447 | /// @notice Computes the remaining liquidity available to a tier. 448 | /// @param _tier The tier to compute the liquidity for 449 | /// @return The remaining liquidity 450 | function getTierRemainingLiquidity(uint8 _tier) public view returns (uint256) { 451 | uint8 _numTiers = numberOfTiers; 452 | if (TierCalculationLib.isValidTier(_tier, _numTiers)) { 453 | return _getTierRemainingLiquidity( 454 | _getTier(_tier, _numTiers).prizeTokenPerShare, 455 | prizeTokenPerShare, 456 | _numShares(_tier, _numTiers) 457 | ); 458 | } else { 459 | return 0; 460 | } 461 | } 462 | 463 | /// @notice Computes the remaining tier liquidity. 464 | /// @param _tierPrizeTokenPerShare The prizeTokenPerShare of the Tier struct 465 | /// @param _prizeTokenPerShare The global prizeTokenPerShare 466 | /// @param _tierShares The number of shares for the tier 467 | /// @return The remaining available liquidity 468 | function _getTierRemainingLiquidity( 469 | uint128 _tierPrizeTokenPerShare, 470 | uint128 _prizeTokenPerShare, 471 | uint8 _tierShares 472 | ) internal pure returns (uint256) { 473 | uint256 result = 474 | _tierPrizeTokenPerShare >= _prizeTokenPerShare 475 | ? 0 476 | : uint256(_prizeTokenPerShare - _tierPrizeTokenPerShare) * _tierShares; 477 | return result; 478 | } 479 | 480 | /// @notice Estimates the number of prizes for the current number of tiers, including the first canary tier 481 | /// @return The estimated number of prizes including the canary tier 482 | function estimatedPrizeCount() external view returns (uint32) { 483 | return estimatedPrizeCount(numberOfTiers); 484 | } 485 | 486 | /// @notice Estimates the number of prizes for the current number of tiers, including both canary tiers 487 | /// @return The estimated number of prizes including both canary tiers 488 | function estimatedPrizeCountWithBothCanaries() external view returns (uint32) { 489 | return estimatedPrizeCountWithBothCanaries(numberOfTiers); 490 | } 491 | 492 | /// @notice Returns the balance of the reserve. 493 | /// @return The amount of tokens that have been reserved. 494 | function reserve() external view returns (uint96) { 495 | return _reserve; 496 | } 497 | 498 | /// @notice Estimates the prize count for the given number of tiers, including the first canary tier. It expects no prizes are claimed for the last canary tier 499 | /// @param numTiers The number of prize tiers 500 | /// @return The estimated total number of prizes 501 | function estimatedPrizeCount( 502 | uint8 numTiers 503 | ) public view returns (uint32) { 504 | if (numTiers == 4) { 505 | return ESTIMATED_PRIZES_PER_DRAW_FOR_4_TIERS; 506 | } else if (numTiers == 5) { 507 | return ESTIMATED_PRIZES_PER_DRAW_FOR_5_TIERS; 508 | } else if (numTiers == 6) { 509 | return ESTIMATED_PRIZES_PER_DRAW_FOR_6_TIERS; 510 | } else if (numTiers == 7) { 511 | return ESTIMATED_PRIZES_PER_DRAW_FOR_7_TIERS; 512 | } else if (numTiers == 8) { 513 | return ESTIMATED_PRIZES_PER_DRAW_FOR_8_TIERS; 514 | } else if (numTiers == 9) { 515 | return ESTIMATED_PRIZES_PER_DRAW_FOR_9_TIERS; 516 | } else if (numTiers == 10) { 517 | return ESTIMATED_PRIZES_PER_DRAW_FOR_10_TIERS; 518 | } else if (numTiers == 11) { 519 | return ESTIMATED_PRIZES_PER_DRAW_FOR_11_TIERS; 520 | } 521 | return 0; 522 | } 523 | 524 | /// @notice Estimates the prize count for the given tier, including BOTH canary tiers 525 | /// @param numTiers The number of tiers 526 | /// @return The estimated prize count across all tiers, including both canary tiers. 527 | function estimatedPrizeCountWithBothCanaries( 528 | uint8 numTiers 529 | ) public view returns (uint32) { 530 | if (numTiers >= MINIMUM_NUMBER_OF_TIERS && numTiers <= MAXIMUM_NUMBER_OF_TIERS) { 531 | return estimatedPrizeCount(numTiers) + uint32(TierCalculationLib.prizeCount(numTiers - 1)); 532 | } else { 533 | return 0; 534 | } 535 | } 536 | 537 | /// @notice Estimates the number of tiers for the given prize count. 538 | /// @param _prizeCount The number of prizes that were claimed 539 | /// @return The estimated tier 540 | function _estimateNumberOfTiersUsingPrizeCountPerDraw( 541 | uint32 _prizeCount 542 | ) internal view returns (uint8) { 543 | // the prize count is slightly more than 4x for each higher tier. i.e. 16, 66, 270, 1108, etc 544 | // by doubling the measured count, we create a safe margin for error. 545 | uint32 _adjustedPrizeCount = _prizeCount * 2; 546 | if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_5_TIERS) { 547 | return 4; 548 | } else if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_6_TIERS) { 549 | return 5; 550 | } else if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_7_TIERS) { 551 | return 6; 552 | } else if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_8_TIERS) { 553 | return 7; 554 | } else if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_9_TIERS) { 555 | return 8; 556 | } else if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_10_TIERS) { 557 | return 9; 558 | } else if (_adjustedPrizeCount < ESTIMATED_PRIZES_PER_DRAW_FOR_11_TIERS) { 559 | return 10; 560 | } else { 561 | return 11; 562 | } 563 | } 564 | 565 | /// @notice Computes the expected number of prizes for a given number of tiers. 566 | /// @dev Includes the first canary tier prizes, but not the second since the first is expected to 567 | /// be claimed. 568 | /// @param _numTiers The number of tiers, including canaries 569 | /// @return The expected number of prizes, first canary included. 570 | function _sumTierPrizeCounts(uint8 _numTiers) internal view returns (uint32) { 571 | uint32 prizeCount; 572 | uint8 i = 0; 573 | do { 574 | prizeCount += TierCalculationLib.tierPrizeCountPerDraw(i, getTierOdds(i, _numTiers)); 575 | i++; 576 | } while (i < _numTiers - 1); 577 | return prizeCount; 578 | } 579 | 580 | /// @notice Computes the odds for a tier given the number of tiers. 581 | /// @param _tier The tier to compute odds for 582 | /// @param _numTiers The number of prize tiers 583 | /// @return The odds of the tier 584 | function getTierOdds(uint8 _tier, uint8 _numTiers) public view returns (SD59x18) { 585 | if (_tier == 0) return TIER_ODDS_0; 586 | if (_numTiers == 3) { 587 | if (_tier <= 2) return TIER_ODDS_EVERY_DRAW; 588 | } else if (_numTiers == 4) { 589 | if (_tier <= 3) return TIER_ODDS_EVERY_DRAW; 590 | } else if (_numTiers == 5) { 591 | if (_tier == 1) return TIER_ODDS_1_5; 592 | else if (_tier <= 4) return TIER_ODDS_EVERY_DRAW; 593 | } else if (_numTiers == 6) { 594 | if (_tier == 1) return TIER_ODDS_1_6; 595 | else if (_tier == 2) return TIER_ODDS_2_6; 596 | else if (_tier <= 5) return TIER_ODDS_EVERY_DRAW; 597 | } else if (_numTiers == 7) { 598 | if (_tier == 1) return TIER_ODDS_1_7; 599 | else if (_tier == 2) return TIER_ODDS_2_7; 600 | else if (_tier == 3) return TIER_ODDS_3_7; 601 | else if (_tier <= 6) return TIER_ODDS_EVERY_DRAW; 602 | } else if (_numTiers == 8) { 603 | if (_tier == 1) return TIER_ODDS_1_8; 604 | else if (_tier == 2) return TIER_ODDS_2_8; 605 | else if (_tier == 3) return TIER_ODDS_3_8; 606 | else if (_tier == 4) return TIER_ODDS_4_8; 607 | else if (_tier <= 7) return TIER_ODDS_EVERY_DRAW; 608 | } else if (_numTiers == 9) { 609 | if (_tier == 1) return TIER_ODDS_1_9; 610 | else if (_tier == 2) return TIER_ODDS_2_9; 611 | else if (_tier == 3) return TIER_ODDS_3_9; 612 | else if (_tier == 4) return TIER_ODDS_4_9; 613 | else if (_tier == 5) return TIER_ODDS_5_9; 614 | else if (_tier <= 8) return TIER_ODDS_EVERY_DRAW; 615 | } else if (_numTiers == 10) { 616 | if (_tier == 1) return TIER_ODDS_1_10; 617 | else if (_tier == 2) return TIER_ODDS_2_10; 618 | else if (_tier == 3) return TIER_ODDS_3_10; 619 | else if (_tier == 4) return TIER_ODDS_4_10; 620 | else if (_tier == 5) return TIER_ODDS_5_10; 621 | else if (_tier == 6) return TIER_ODDS_6_10; 622 | else if (_tier <= 9) return TIER_ODDS_EVERY_DRAW; 623 | } else if (_numTiers == 11) { 624 | if (_tier == 1) return TIER_ODDS_1_11; 625 | else if (_tier == 2) return TIER_ODDS_2_11; 626 | else if (_tier == 3) return TIER_ODDS_3_11; 627 | else if (_tier == 4) return TIER_ODDS_4_11; 628 | else if (_tier == 5) return TIER_ODDS_5_11; 629 | else if (_tier == 6) return TIER_ODDS_6_11; 630 | else if (_tier == 7) return TIER_ODDS_7_11; 631 | else if (_tier <= 10) return TIER_ODDS_EVERY_DRAW; 632 | } 633 | return sd(0); 634 | } 635 | } 636 | -------------------------------------------------------------------------------- /src/PrizePool.sol: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | pragma solidity ^0.8.24; 3 | 4 | import { SafeCast } from "openzeppelin/utils/math/SafeCast.sol"; 5 | import { IERC20 } from "openzeppelin/token/ERC20/IERC20.sol"; 6 | import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; 7 | import { SD59x18, convert, sd } from "prb-math/SD59x18.sol"; 8 | import { UD60x18, convert } from "prb-math/UD60x18.sol"; 9 | import { TwabController } from "pt-v5-twab-controller/TwabController.sol"; 10 | 11 | import { DrawAccumulatorLib, Observation, MAX_OBSERVATION_CARDINALITY } from "./libraries/DrawAccumulatorLib.sol"; 12 | import { TieredLiquidityDistributor, Tier } from "./abstract/TieredLiquidityDistributor.sol"; 13 | import { TierCalculationLib } from "./libraries/TierCalculationLib.sol"; 14 | 15 | /* ============ Constants ============ */ 16 | 17 | // The minimum draw timeout. A timeout of two is necessary to allow for enough time to close and award a draw. 18 | uint24 constant MINIMUM_DRAW_TIMEOUT = 2; 19 | 20 | /* ============ Errors ============ */ 21 | 22 | /// @notice Thrown when the prize pool is constructed with a first draw open timestamp that is in the past 23 | error FirstDrawOpensInPast(); 24 | 25 | /// @notice Thrown when the Twab Controller has an incompatible period length 26 | error IncompatibleTwabPeriodLength(); 27 | 28 | /// @notice Thrown when the Twab Controller has an incompatible period offset 29 | error IncompatibleTwabPeriodOffset(); 30 | 31 | /// @notice Thrown when someone tries to set the draw manager with the zero address 32 | error DrawManagerIsZeroAddress(); 33 | 34 | /// @notice Thrown when the passed creator is the zero address 35 | error CreatorIsZeroAddress(); 36 | 37 | /// @notice Thrown when the caller is not the deployer. 38 | error NotDeployer(); 39 | 40 | /// @notice Thrown when the range start draw id is computed with range of zero 41 | error RangeSizeZero(); 42 | 43 | /// @notice Thrown if the prize pool has shutdown 44 | error PrizePoolShutdown(); 45 | 46 | /// @notice Thrown if the prize pool is not shutdown 47 | error PrizePoolNotShutdown(); 48 | 49 | /// @notice Thrown when someone tries to withdraw too many rewards. 50 | /// @param requested The requested reward amount to withdraw 51 | /// @param available The total reward amount available for the caller to withdraw 52 | error InsufficientRewardsError(uint256 requested, uint256 available); 53 | 54 | /// @notice Thrown when an address did not win the specified prize on a vault when claiming. 55 | /// @param vault The vault address 56 | /// @param winner The address checked for the prize 57 | /// @param tier The prize tier 58 | /// @param prizeIndex The prize index 59 | error DidNotWin(address vault, address winner, uint8 tier, uint32 prizeIndex); 60 | 61 | /// @notice Thrown when the prize being claimed has already been claimed 62 | /// @param vault The vault address 63 | /// @param winner The address checked for the prize 64 | /// @param tier The prize tier 65 | /// @param prizeIndex The prize index 66 | error AlreadyClaimed(address vault, address winner, uint8 tier, uint32 prizeIndex); 67 | 68 | /// @notice Thrown when the claim reward exceeds the maximum. 69 | /// @param reward The reward being claimed 70 | /// @param maxReward The max reward that can be claimed 71 | error RewardTooLarge(uint256 reward, uint256 maxReward); 72 | 73 | /// @notice Thrown when the contributed amount is more than the available, un-accounted balance. 74 | /// @param amount The contribution amount that is being claimed 75 | /// @param available The available un-accounted balance that can be claimed as a contribution 76 | error ContributionGTDeltaBalance(uint256 amount, uint256 available); 77 | 78 | /// @notice Thrown when the withdraw amount is greater than the available reserve. 79 | /// @param amount The amount being withdrawn 80 | /// @param reserve The total reserve available for withdrawal 81 | error InsufficientReserve(uint104 amount, uint104 reserve); 82 | 83 | /// @notice Thrown when the winning random number is zero. 84 | error RandomNumberIsZero(); 85 | 86 | /// @notice Thrown when the draw cannot be awarded since it has not closed. 87 | /// @param drawClosesAt The timestamp in seconds at which the draw closes 88 | error AwardingDrawNotClosed(uint48 drawClosesAt); 89 | 90 | /// @notice Thrown when prize index is greater or equal to the max prize count for the tier. 91 | /// @param invalidPrizeIndex The invalid prize index 92 | /// @param prizeCount The prize count for the tier 93 | /// @param tier The tier number 94 | error InvalidPrizeIndex(uint32 invalidPrizeIndex, uint32 prizeCount, uint8 tier); 95 | 96 | /// @notice Thrown when there are no awarded draws when a computation requires an awarded draw. 97 | error NoDrawsAwarded(); 98 | 99 | /// @notice Thrown when the prize pool is initialized with a draw timeout lower than the minimum. 100 | /// @param drawTimeout The draw timeout that was set 101 | /// @param minimumDrawTimeout The minimum draw timeout 102 | error DrawTimeoutLtMinimum(uint24 drawTimeout, uint24 minimumDrawTimeout); 103 | 104 | /// @notice Thrown when the Prize Pool is constructed with a draw timeout greater than the grand prize period draws 105 | error DrawTimeoutGTGrandPrizePeriodDraws(); 106 | 107 | /// @notice Thrown when attempting to claim from a tier that does not exist. 108 | /// @param tier The tier number that does not exist 109 | /// @param numberOfTiers The current number of tiers 110 | error InvalidTier(uint8 tier, uint8 numberOfTiers); 111 | 112 | /// @notice Thrown when the caller is not the draw manager. 113 | /// @param caller The caller address 114 | /// @param drawManager The drawManager address 115 | error CallerNotDrawManager(address caller, address drawManager); 116 | 117 | /// @notice Thrown when someone tries to claim a prize that is zero size 118 | error PrizeIsZero(); 119 | 120 | /// @notice Thrown when someone tries to claim a prize, but sets the reward recipient address to the zero address. 121 | error RewardRecipientZeroAddress(); 122 | 123 | /// @notice Thrown when a claim is attempted after the claiming period has expired. 124 | error ClaimPeriodExpired(); 125 | 126 | /// @notice Thrown when anyone but the creator calls a privileged function 127 | error OnlyCreator(); 128 | 129 | /// @notice Thrown when the draw manager has already been set 130 | error DrawManagerAlreadySet(); 131 | 132 | /// @notice Thrown when the grand prize period is too large 133 | /// @param grandPrizePeriodDraws The set grand prize period 134 | /// @param maxGrandPrizePeriodDraws The max grand prize period 135 | error GrandPrizePeriodDrawsTooLarge(uint24 grandPrizePeriodDraws, uint24 maxGrandPrizePeriodDraws); 136 | 137 | /// @notice Constructor Parameters 138 | /// @param prizeToken The token to use for prizes 139 | /// @param twabController The Twab Controller to retrieve time-weighted average balances from 140 | /// @param creator The address that will be permitted to finish prize pool initialization after deployment 141 | /// @param tierLiquidityUtilizationRate The rate at which liquidity is utilized for prize tiers. This allows 142 | /// for deviations in prize claims; if 0.75e18 then it is 75% utilization so it can accommodate 25% deviation 143 | /// in more prize claims. 144 | /// @param drawPeriodSeconds The number of seconds between draws. 145 | /// E.g. a Prize Pool with a daily draw should have a draw period of 86400 seconds. 146 | /// @param firstDrawOpensAt The timestamp at which the first draw will open 147 | /// @param grandPrizePeriodDraws The target number of draws to pass between each grand prize 148 | /// @param numberOfTiers The number of tiers to start with. Must be greater than or equal to the minimum 149 | /// number of tiers 150 | /// @param tierShares The number of shares to allocate to each tier 151 | /// @param canaryShares The number of shares to allocate to each canary tier 152 | /// @param reserveShares The number of shares to allocate to the reserve 153 | /// @param drawTimeout The number of draws that need to be missed before the prize pool shuts down. The timeout 154 | /// resets when a draw is awarded. 155 | struct ConstructorParams { 156 | IERC20 prizeToken; 157 | TwabController twabController; 158 | address creator; 159 | uint256 tierLiquidityUtilizationRate; 160 | uint48 drawPeriodSeconds; 161 | uint48 firstDrawOpensAt; 162 | uint24 grandPrizePeriodDraws; 163 | uint8 numberOfTiers; 164 | uint8 tierShares; 165 | uint8 canaryShares; 166 | uint8 reserveShares; 167 | uint24 drawTimeout; 168 | } 169 | 170 | /// @title PoolTogether V5 Prize Pool 171 | /// @author G9 Software Inc. & PoolTogether Inc. Team 172 | /// @notice The Prize Pool holds the prize liquidity and allows vaults to claim prizes. 173 | contract PrizePool is TieredLiquidityDistributor { 174 | using SafeERC20 for IERC20; 175 | using DrawAccumulatorLib for DrawAccumulatorLib.Accumulator; 176 | 177 | /* ============ Events ============ */ 178 | 179 | /// @notice Emitted when a prize is claimed. 180 | /// @param vault The address of the vault that claimed the prize. 181 | /// @param winner The address of the winner 182 | /// @param recipient The address of the prize recipient 183 | /// @param drawId The draw ID of the draw that was claimed. 184 | /// @param tier The prize tier that was claimed. 185 | /// @param prizeIndex The index of the prize that was claimed 186 | /// @param payout The amount of prize tokens that were paid out to the winner 187 | /// @param claimReward The amount of prize tokens that were paid to the claimer 188 | /// @param claimRewardRecipient The address that the claimReward was sent to 189 | event ClaimedPrize( 190 | address indexed vault, 191 | address indexed winner, 192 | address indexed recipient, 193 | uint24 drawId, 194 | uint8 tier, 195 | uint32 prizeIndex, 196 | uint152 payout, 197 | uint96 claimReward, 198 | address claimRewardRecipient 199 | ); 200 | 201 | /// @notice Emitted when a draw is awarded. 202 | /// @param drawId The ID of the draw that was awarded 203 | /// @param winningRandomNumber The winning random number for the awarded draw 204 | /// @param lastNumTiers The previous number of prize tiers 205 | /// @param numTiers The number of prize tiers for the awarded draw 206 | /// @param reserve The resulting reserve available 207 | /// @param prizeTokensPerShare The amount of prize tokens per share for the awarded draw 208 | /// @param drawOpenedAt The start timestamp of the awarded draw 209 | event DrawAwarded( 210 | uint24 indexed drawId, 211 | uint256 winningRandomNumber, 212 | uint8 lastNumTiers, 213 | uint8 numTiers, 214 | uint104 reserve, 215 | uint128 prizeTokensPerShare, 216 | uint48 drawOpenedAt 217 | ); 218 | 219 | /// @notice Emitted when any amount of the reserve is rewarded to a recipient. 220 | /// @param to The recipient of the reward 221 | /// @param amount The amount of assets rewarded 222 | event AllocateRewardFromReserve(address indexed to, uint256 amount); 223 | 224 | /// @notice Emitted when the reserve is manually increased. 225 | /// @param user The user who increased the reserve 226 | /// @param amount The amount of assets transferred 227 | event ContributedReserve(address indexed user, uint256 amount); 228 | 229 | /// @notice Emitted when a vault contributes prize tokens to the pool. 230 | /// @param vault The address of the vault that is contributing tokens 231 | /// @param drawId The ID of the first draw that the tokens will be contributed to 232 | /// @param amount The amount of tokens contributed 233 | event ContributePrizeTokens(address indexed vault, uint24 indexed drawId, uint256 amount); 234 | 235 | /// @notice Emitted when the draw manager is set 236 | /// @param drawManager The address of the draw manager 237 | event SetDrawManager(address indexed drawManager); 238 | 239 | /// @notice Emitted when an address withdraws their prize claim rewards. 240 | /// @param account The account that is withdrawing rewards 241 | /// @param to The address the rewards are sent to 242 | /// @param amount The amount withdrawn 243 | /// @param available The total amount that was available to withdraw before the transfer 244 | event WithdrawRewards( 245 | address indexed account, 246 | address indexed to, 247 | uint256 amount, 248 | uint256 available 249 | ); 250 | 251 | /// @notice Emitted when an address receives new prize claim rewards. 252 | /// @param to The address the rewards are given to 253 | /// @param amount The amount increased 254 | event IncreaseClaimRewards(address indexed to, uint256 amount); 255 | 256 | /* ============ State ============ */ 257 | 258 | /// @notice The DrawAccumulator that tracks the exponential moving average of the contributions by a vault. 259 | mapping(address vault => DrawAccumulatorLib.Accumulator accumulator) internal _vaultAccumulator; 260 | 261 | /// @notice Records the claim record for a winner. 262 | mapping(address vault => mapping(address account => mapping(uint24 drawId => mapping(uint8 tier => mapping(uint32 prizeIndex => bool claimed))))) 263 | internal _claimedPrizes; 264 | 265 | /// @notice Tracks the total rewards accrued for a claimer or draw completer. 266 | mapping(address recipient => uint256 rewards) internal _rewards; 267 | 268 | /// @notice The special value for the donator address. Contributions from this address are excluded from the total odds. 269 | /// @dev 0x000...F2EE because it's free money! 270 | address public constant DONATOR = 0x000000000000000000000000000000000000F2EE; 271 | 272 | /// @notice The token that is being contributed and awarded as prizes. 273 | IERC20 public immutable prizeToken; 274 | 275 | /// @notice The Twab Controller to use to retrieve historic balances. 276 | TwabController public immutable twabController; 277 | 278 | /// @notice The number of seconds between draws. 279 | uint48 public immutable drawPeriodSeconds; 280 | 281 | /// @notice The timestamp at which the first draw will open. 282 | uint48 public immutable firstDrawOpensAt; 283 | 284 | /// @notice The maximum number of draws that can pass since the last awarded draw before the prize pool is considered inactive. 285 | uint24 public immutable drawTimeout; 286 | 287 | /// @notice The address that is allowed to set the draw manager 288 | address immutable creator; 289 | 290 | /// @notice The exponential weighted average of all vault contributions. 291 | DrawAccumulatorLib.Accumulator internal _totalAccumulator; 292 | 293 | /// @notice The winner random number for the last awarded draw. 294 | uint256 internal _winningRandomNumber; 295 | 296 | /// @notice The draw manager address. 297 | address public drawManager; 298 | 299 | /// @notice Tracks reserve that was contributed directly to the reserve. Always increases. 300 | uint96 internal _directlyContributedReserve; 301 | 302 | /// @notice The number of prize claims for the last awarded draw. 303 | uint24 public claimCount; 304 | 305 | /// @notice The total amount of prize tokens that have been claimed for all time. 306 | uint128 internal _totalWithdrawn; 307 | 308 | /// @notice The total amount of rewards that have yet to be claimed 309 | uint104 internal _totalRewardsToBeClaimed; 310 | 311 | /// @notice The observation at which the shutdown balance was recorded 312 | Observation shutdownObservation; 313 | 314 | /// @notice The balance available to be withdrawn at shutdown 315 | uint256 shutdownBalance; 316 | 317 | /// @notice The total contributed observation that was used for the last withdrawal for a vault and account 318 | mapping(address vault => mapping(address account => Observation lastWithdrawalTotalContributedObservation)) internal _withdrawalObservations; 319 | 320 | /// @notice The shutdown portion of liquidity for a vault and account 321 | mapping(address vault => mapping(address account => UD60x18 shutdownPortion)) internal _shutdownPortions; 322 | 323 | /* ============ Constructor ============ */ 324 | 325 | /// @notice Constructs a new Prize Pool. 326 | /// @param params A struct of constructor parameters 327 | constructor( 328 | ConstructorParams memory params 329 | ) 330 | TieredLiquidityDistributor( 331 | params.tierLiquidityUtilizationRate, 332 | params.numberOfTiers, 333 | params.tierShares, 334 | params.canaryShares, 335 | params.reserveShares, 336 | params.grandPrizePeriodDraws 337 | ) 338 | { 339 | if (params.drawTimeout < MINIMUM_DRAW_TIMEOUT) { 340 | revert DrawTimeoutLtMinimum(params.drawTimeout, MINIMUM_DRAW_TIMEOUT); 341 | } 342 | 343 | if (params.drawTimeout > params.grandPrizePeriodDraws) { 344 | revert DrawTimeoutGTGrandPrizePeriodDraws(); 345 | } 346 | 347 | if (params.firstDrawOpensAt < block.timestamp) { 348 | revert FirstDrawOpensInPast(); 349 | } 350 | 351 | if (params.grandPrizePeriodDraws >= MAX_OBSERVATION_CARDINALITY) { 352 | revert GrandPrizePeriodDrawsTooLarge(params.grandPrizePeriodDraws, MAX_OBSERVATION_CARDINALITY - 1); 353 | } 354 | 355 | uint48 twabPeriodOffset = params.twabController.PERIOD_OFFSET(); 356 | uint48 twabPeriodLength = params.twabController.PERIOD_LENGTH(); 357 | 358 | if ( 359 | params.drawPeriodSeconds < twabPeriodLength || 360 | params.drawPeriodSeconds % twabPeriodLength != 0 361 | ) { 362 | revert IncompatibleTwabPeriodLength(); 363 | } 364 | 365 | if ((params.firstDrawOpensAt - twabPeriodOffset) % twabPeriodLength != 0) { 366 | revert IncompatibleTwabPeriodOffset(); 367 | } 368 | 369 | if (params.creator == address(0)) { 370 | revert CreatorIsZeroAddress(); 371 | } 372 | 373 | creator = params.creator; 374 | drawTimeout = params.drawTimeout; 375 | prizeToken = params.prizeToken; 376 | twabController = params.twabController; 377 | drawPeriodSeconds = params.drawPeriodSeconds; 378 | firstDrawOpensAt = params.firstDrawOpensAt; 379 | } 380 | 381 | /* ============ Modifiers ============ */ 382 | 383 | /// @notice Modifier that throws if sender is not the draw manager. 384 | modifier onlyDrawManager() { 385 | if (msg.sender != drawManager) { 386 | revert CallerNotDrawManager(msg.sender, drawManager); 387 | } 388 | _; 389 | } 390 | 391 | /// @notice Sets the Draw Manager contract on the prize pool. Can only be called once by the creator. 392 | /// @param _drawManager The address of the Draw Manager contract 393 | function setDrawManager(address _drawManager) external { 394 | if (msg.sender != creator) { 395 | revert OnlyCreator(); 396 | } 397 | if (drawManager != address(0)) { 398 | revert DrawManagerAlreadySet(); 399 | } 400 | drawManager = _drawManager; 401 | 402 | emit SetDrawManager(_drawManager); 403 | } 404 | 405 | /* ============ External Write Functions ============ */ 406 | 407 | /// @notice Contributes prize tokens on behalf of the given vault. 408 | /// @dev The tokens should have already been transferred to the prize pool. 409 | /// @dev The prize pool balance will be checked to ensure there is at least the given amount to deposit. 410 | /// @param _prizeVault The address of the vault to contribute to 411 | /// @param _amount The amount of prize tokens to contribute 412 | /// @return The amount of available prize tokens prior to the contribution. 413 | function contributePrizeTokens(address _prizeVault, uint256 _amount) public returns (uint256) { 414 | uint256 _deltaBalance = prizeToken.balanceOf(address(this)) - accountedBalance(); 415 | if (_deltaBalance < _amount) { 416 | revert ContributionGTDeltaBalance(_amount, _deltaBalance); 417 | } 418 | uint24 openDrawId_ = getOpenDrawId(); 419 | _vaultAccumulator[_prizeVault].add(_amount, openDrawId_); 420 | _totalAccumulator.add(_amount, openDrawId_); 421 | emit ContributePrizeTokens(_prizeVault, openDrawId_, _amount); 422 | return _deltaBalance; 423 | } 424 | 425 | /// @notice Allows a user to donate prize tokens to the prize pool. 426 | /// @param _amount The amount of tokens to donate. The amount should already be approved for transfer. 427 | function donatePrizeTokens(uint256 _amount) external { 428 | prizeToken.safeTransferFrom(msg.sender, address(this), _amount); 429 | contributePrizeTokens(DONATOR, _amount); 430 | } 431 | 432 | /// @notice Allows the Manager to allocate a reward from the reserve to a recipient. 433 | /// @param _to The address to allocate the rewards to 434 | /// @param _amount The amount of tokens for the reward 435 | function allocateRewardFromReserve(address _to, uint96 _amount) external onlyDrawManager notShutdown { 436 | if (_to == address(0)) { 437 | revert RewardRecipientZeroAddress(); 438 | } 439 | if (_amount > _reserve) { 440 | revert InsufficientReserve(_amount, _reserve); 441 | } 442 | 443 | unchecked { 444 | _reserve -= _amount; 445 | } 446 | 447 | _rewards[_to] += _amount; 448 | _totalRewardsToBeClaimed = SafeCast.toUint104(_totalRewardsToBeClaimed + _amount); 449 | emit AllocateRewardFromReserve(_to, _amount); 450 | } 451 | 452 | /// @notice Allows the Manager to award a draw with the winning random number. 453 | /// @dev Updates the number of tiers, the winning random number and the prize pool reserve. 454 | /// @param winningRandomNumber_ The winning random number for the draw 455 | /// @return The ID of the awarded draw 456 | function awardDraw(uint256 winningRandomNumber_) external onlyDrawManager notShutdown returns (uint24) { 457 | // check winning random number 458 | if (winningRandomNumber_ == 0) { 459 | revert RandomNumberIsZero(); 460 | } 461 | uint24 awardingDrawId = getDrawIdToAward(); 462 | uint48 awardingDrawOpenedAt = drawOpensAt(awardingDrawId); 463 | uint48 awardingDrawClosedAt = awardingDrawOpenedAt + drawPeriodSeconds; 464 | if (block.timestamp < awardingDrawClosedAt) { 465 | revert AwardingDrawNotClosed(awardingDrawClosedAt); 466 | } 467 | 468 | uint24 lastAwardedDrawId_ = _lastAwardedDrawId; 469 | uint32 _claimCount = claimCount; 470 | uint8 _numTiers = numberOfTiers; 471 | uint8 _nextNumberOfTiers = _numTiers; 472 | 473 | _nextNumberOfTiers = computeNextNumberOfTiers(_claimCount); 474 | 475 | // If any draws were skipped from the last awarded draw to the one we are awarding, the contribution 476 | // from those skipped draws will be included in the new distributions. 477 | _awardDraw( 478 | awardingDrawId, 479 | _nextNumberOfTiers, 480 | getTotalContributedBetween(lastAwardedDrawId_ + 1, awardingDrawId) 481 | ); 482 | 483 | _winningRandomNumber = winningRandomNumber_; 484 | if (_claimCount != 0) { 485 | claimCount = 0; 486 | } 487 | 488 | emit DrawAwarded( 489 | awardingDrawId, 490 | winningRandomNumber_, 491 | _numTiers, 492 | _nextNumberOfTiers, 493 | _reserve, 494 | prizeTokenPerShare, 495 | awardingDrawOpenedAt 496 | ); 497 | 498 | return awardingDrawId; 499 | } 500 | 501 | /// @notice Claims a prize for a given winner and tier. 502 | /// @dev This function takes in an address _winner, a uint8 _tier, a uint96 _claimReward, and an 503 | /// address _claimRewardRecipient. It checks if _winner is actually the winner of the _tier for the calling vault. 504 | /// If so, it calculates the prize size and transfers it to the winner. If not, it reverts with an error message. 505 | /// The function then checks the claim record of _winner to see if they have already claimed the prize for the 506 | /// awarded draw. If not, it updates the claim record with the claimed tier and emits a ClaimedPrize event with 507 | /// information about the claim. 508 | /// Note that this function can modify the state of the contract by updating the claim record, changing the largest 509 | /// tier claimed and the claim count, and transferring prize tokens. The function is marked as external which 510 | /// means that it can be called from outside the contract. 511 | /// @param _winner The address of the eligible winner 512 | /// @param _tier The tier of the prize to be claimed. 513 | /// @param _prizeIndex The prize to claim for the winner. Must be less than the prize count for the tier. 514 | /// @param _prizeRecipient The recipient of the prize 515 | /// @param _claimReward The claimReward associated with claiming the prize. 516 | /// @param _claimRewardRecipient The address to receive the claimReward. 517 | /// @return Total prize amount claimed (payout and claimRewards combined). 518 | function claimPrize( 519 | address _winner, 520 | uint8 _tier, 521 | uint32 _prizeIndex, 522 | address _prizeRecipient, 523 | uint96 _claimReward, 524 | address _claimRewardRecipient 525 | ) external returns (uint256) { 526 | /// @dev Claims cannot occur after a draw has been finalized (1 period after a draw closes). This prevents 527 | /// the reserve from changing while the following draw is being awarded. 528 | uint24 lastAwardedDrawId_ = _lastAwardedDrawId; 529 | if (isDrawFinalized(lastAwardedDrawId_)) { 530 | revert ClaimPeriodExpired(); 531 | } 532 | if (_claimRewardRecipient == address(0) && _claimReward > 0) { 533 | revert RewardRecipientZeroAddress(); 534 | } 535 | 536 | uint8 _numTiers = numberOfTiers; 537 | 538 | Tier memory tierLiquidity = _getTier(_tier, _numTiers); 539 | 540 | if (_claimReward > tierLiquidity.prizeSize) { 541 | revert RewardTooLarge(_claimReward, tierLiquidity.prizeSize); 542 | } 543 | 544 | if (tierLiquidity.prizeSize == 0) { 545 | revert PrizeIsZero(); 546 | } 547 | 548 | if (!isWinner(msg.sender, _winner, _tier, _prizeIndex)) { 549 | revert DidNotWin(msg.sender, _winner, _tier, _prizeIndex); 550 | } 551 | 552 | if (_claimedPrizes[msg.sender][_winner][lastAwardedDrawId_][_tier][_prizeIndex]) { 553 | revert AlreadyClaimed(msg.sender, _winner, _tier, _prizeIndex); 554 | } 555 | 556 | _claimedPrizes[msg.sender][_winner][lastAwardedDrawId_][_tier][_prizeIndex] = true; 557 | 558 | _consumeLiquidity(tierLiquidity, _tier, tierLiquidity.prizeSize); 559 | 560 | // `amount` is the payout amount 561 | uint256 amount; 562 | if (_claimReward != 0) { 563 | emit IncreaseClaimRewards(_claimRewardRecipient, _claimReward); 564 | _rewards[_claimRewardRecipient] += _claimReward; 565 | 566 | unchecked { 567 | amount = tierLiquidity.prizeSize - _claimReward; 568 | } 569 | } else { 570 | amount = tierLiquidity.prizeSize; 571 | } 572 | 573 | // co-locate to save gas 574 | claimCount++; 575 | _totalWithdrawn = SafeCast.toUint128(_totalWithdrawn + amount); 576 | _totalRewardsToBeClaimed = SafeCast.toUint104(_totalRewardsToBeClaimed + _claimReward); 577 | 578 | emit ClaimedPrize( 579 | msg.sender, 580 | _winner, 581 | _prizeRecipient, 582 | lastAwardedDrawId_, 583 | _tier, 584 | _prizeIndex, 585 | uint152(amount), 586 | _claimReward, 587 | _claimRewardRecipient 588 | ); 589 | 590 | if (amount > 0) { 591 | prizeToken.safeTransfer(_prizeRecipient, amount); 592 | } 593 | 594 | return tierLiquidity.prizeSize; 595 | } 596 | 597 | /// @notice Withdraws earned rewards for the caller. 598 | /// @param _to The address to transfer the rewards to 599 | /// @param _amount The amount of rewards to withdraw 600 | function withdrawRewards(address _to, uint256 _amount) external { 601 | uint256 _available = _rewards[msg.sender]; 602 | 603 | if (_amount > _available) { 604 | revert InsufficientRewardsError(_amount, _available); 605 | } 606 | 607 | unchecked { 608 | _rewards[msg.sender] = _available - _amount; 609 | } 610 | 611 | _totalWithdrawn = SafeCast.toUint128(_totalWithdrawn + _amount); 612 | _totalRewardsToBeClaimed = SafeCast.toUint104(_totalRewardsToBeClaimed - _amount); 613 | 614 | // skip transfer if recipient is the prize pool (tokens stay in this contract) 615 | if (_to != address(this)) { 616 | prizeToken.safeTransfer(_to, _amount); 617 | } 618 | 619 | emit WithdrawRewards(msg.sender, _to, _amount, _available); 620 | } 621 | 622 | /// @notice Allows anyone to deposit directly into the Prize Pool reserve. 623 | /// @dev Ensure caller has sufficient balance and has approved the Prize Pool to transfer the tokens 624 | /// @param _amount The amount of tokens to increase the reserve by 625 | function contributeReserve(uint96 _amount) external notShutdown { 626 | _reserve += _amount; 627 | _directlyContributedReserve += _amount; 628 | prizeToken.safeTransferFrom(msg.sender, address(this), _amount); 629 | emit ContributedReserve(msg.sender, _amount); 630 | } 631 | 632 | /* ============ External Read Functions ============ */ 633 | 634 | /// @notice Returns the winning random number for the last awarded draw. 635 | /// @return The winning random number 636 | function getWinningRandomNumber() external view returns (uint256) { 637 | return _winningRandomNumber; 638 | } 639 | 640 | /// @notice Returns the last awarded draw id. 641 | /// @return The last awarded draw id 642 | function getLastAwardedDrawId() external view returns (uint24) { 643 | return _lastAwardedDrawId; 644 | } 645 | 646 | /// @notice Returns the total prize tokens contributed by a particular vault between the given draw ids, inclusive. 647 | /// @param _vault The address of the vault 648 | /// @param _startDrawIdInclusive Start draw id inclusive 649 | /// @param _endDrawIdInclusive End draw id inclusive 650 | /// @return The total prize tokens contributed by the given vault 651 | function getContributedBetween( 652 | address _vault, 653 | uint24 _startDrawIdInclusive, 654 | uint24 _endDrawIdInclusive 655 | ) external view returns (uint256) { 656 | return 657 | _vaultAccumulator[_vault].getDisbursedBetween( 658 | _startDrawIdInclusive, 659 | _endDrawIdInclusive 660 | ); 661 | } 662 | 663 | /// @notice Returns the total prize tokens donated to the prize pool 664 | /// @param _startDrawIdInclusive Start draw id inclusive 665 | /// @param _endDrawIdInclusive End draw id inclusive 666 | /// @return The total prize tokens donated to the prize pool 667 | function getDonatedBetween( 668 | uint24 _startDrawIdInclusive, 669 | uint24 _endDrawIdInclusive 670 | ) external view returns (uint256) { 671 | return 672 | _vaultAccumulator[DONATOR].getDisbursedBetween( 673 | _startDrawIdInclusive, 674 | _endDrawIdInclusive 675 | ); 676 | } 677 | 678 | /// @notice Returns the newest observation for the total accumulator 679 | /// @return The newest observation 680 | function getTotalAccumulatorNewestObservation() external view returns (Observation memory) { 681 | return _totalAccumulator.newestObservation(); 682 | } 683 | 684 | /// @notice Returns the newest observation for the specified vault accumulator 685 | /// @param _vault The vault to check 686 | /// @return The newest observation for the vault 687 | function getVaultAccumulatorNewestObservation(address _vault) external view returns (Observation memory) { 688 | return _vaultAccumulator[_vault].newestObservation(); 689 | } 690 | 691 | /// @notice Computes the expected duration prize accrual for a tier. 692 | /// @param _tier The tier to check 693 | /// @return The number of draws 694 | function getTierAccrualDurationInDraws(uint8 _tier) external view returns (uint24) { 695 | return 696 | TierCalculationLib.estimatePrizeFrequencyInDraws(getTierOdds(_tier, numberOfTiers), grandPrizePeriodDraws); 697 | } 698 | 699 | /// @notice The total amount of prize tokens that have been withdrawn as fees or prizes 700 | /// @return The total amount of prize tokens that have been withdrawn as fees or prizes 701 | function totalWithdrawn() external view returns (uint256) { 702 | return _totalWithdrawn; 703 | } 704 | 705 | /// @notice Returns the amount of tokens that will be added to the reserve when next draw to award is awarded. 706 | /// @dev Intended for Draw manager to use after a draw has closed but not yet been awarded. 707 | /// @return The amount of prize tokens that will be added to the reserve 708 | function pendingReserveContributions() external view returns (uint256) { 709 | uint8 _numTiers = numberOfTiers; 710 | uint24 lastAwardedDrawId_ = _lastAwardedDrawId; 711 | 712 | (uint104 newReserve, ) = _computeNewDistributions( 713 | _numTiers, 714 | lastAwardedDrawId_ == 0 ? _numTiers : computeNextNumberOfTiers(claimCount), 715 | prizeTokenPerShare, 716 | getTotalContributedBetween(lastAwardedDrawId_ + 1, getDrawIdToAward()) 717 | ); 718 | 719 | return newReserve; 720 | } 721 | 722 | /// @notice Returns whether the winner has claimed the tier for the last awarded draw 723 | /// @param _vault The vault to check 724 | /// @param _winner The account to check 725 | /// @param _tier The tier to check 726 | /// @param _prizeIndex The prize index to check 727 | /// @return True if the winner claimed the tier for the last awarded draw, false otherwise. 728 | function wasClaimed( 729 | address _vault, 730 | address _winner, 731 | uint8 _tier, 732 | uint32 _prizeIndex 733 | ) external view returns (bool) { 734 | return _claimedPrizes[_vault][_winner][_lastAwardedDrawId][_tier][_prizeIndex]; 735 | } 736 | 737 | /// @notice Returns whether the winner has claimed the tier for the specified draw 738 | /// @param _vault The vault to check 739 | /// @param _winner The account to check 740 | /// @param _drawId The draw ID to check 741 | /// @param _tier The tier to check 742 | /// @param _prizeIndex The prize index to check 743 | /// @return True if the winner claimed the tier for the specified draw, false otherwise. 744 | function wasClaimed( 745 | address _vault, 746 | address _winner, 747 | uint24 _drawId, 748 | uint8 _tier, 749 | uint32 _prizeIndex 750 | ) external view returns (bool) { 751 | return _claimedPrizes[_vault][_winner][_drawId][_tier][_prizeIndex]; 752 | } 753 | 754 | /// @notice Returns the balance of rewards earned for the given address. 755 | /// @param _recipient The recipient to retrieve the reward balance for 756 | /// @return The balance of rewards for the given recipient 757 | function rewardBalance(address _recipient) external view returns (uint256) { 758 | return _rewards[_recipient]; 759 | } 760 | 761 | /// @notice Computes and returns the next number of tiers based on the current prize claim counts. This number may change throughout the draw 762 | /// @return The next number of tiers 763 | function estimateNextNumberOfTiers() external view returns (uint8) { 764 | return computeNextNumberOfTiers(claimCount); 765 | } 766 | 767 | /* ============ Internal Functions ============ */ 768 | 769 | /// @notice Computes how many tokens have been accounted for 770 | /// @return The balance of tokens that have been accounted for 771 | function accountedBalance() public view returns (uint256) { 772 | return _accountedBalance(_totalAccumulator.newestObservation()); 773 | } 774 | 775 | /// @notice Returns the balance available at the time of shutdown, less rewards to be claimed. 776 | /// @dev This function will compute and store the current balance if it has not yet been set. 777 | /// @return balance The balance that is available for depositors to withdraw 778 | /// @return observation The observation used to compute the balance 779 | function getShutdownInfo() public returns (uint256 balance, Observation memory observation) { 780 | if (!isShutdown()) { 781 | return (balance, observation); 782 | } 783 | // if not initialized 784 | if (shutdownObservation.disbursed + shutdownObservation.available == 0) { 785 | observation = _totalAccumulator.newestObservation(); 786 | shutdownObservation = observation; 787 | balance = _accountedBalance(observation) - _totalRewardsToBeClaimed; 788 | shutdownBalance = balance; 789 | } else { 790 | observation = shutdownObservation; 791 | balance = shutdownBalance; 792 | } 793 | } 794 | 795 | /// @notice Returns the open draw ID based on the current block timestamp. 796 | /// @dev Returns `1` if the first draw hasn't opened yet. This prevents any contributions from 797 | /// going to the inaccessible draw zero. 798 | /// @dev First draw has an ID of `1`. This means that if `_lastAwardedDrawId` is zero, 799 | /// we know that no draws have been awarded yet. 800 | /// @dev Capped at the shutdown draw ID if the prize pool has shutdown. 801 | /// @return The ID of the draw period that the current block is in 802 | function getOpenDrawId() public view returns (uint24) { 803 | uint24 shutdownDrawId = getShutdownDrawId(); 804 | uint24 openDrawId = getDrawId(block.timestamp); 805 | return openDrawId > shutdownDrawId ? shutdownDrawId : openDrawId; 806 | } 807 | 808 | /// @notice Returns the open draw id for the given timestamp 809 | /// @param _timestamp The timestamp to get the draw id for 810 | /// @return The ID of the open draw that the timestamp is in 811 | function getDrawId(uint256 _timestamp) public view returns (uint24) { 812 | uint48 _firstDrawOpensAt = firstDrawOpensAt; 813 | return 814 | (_timestamp < _firstDrawOpensAt) 815 | ? 1 816 | : (uint24((_timestamp - _firstDrawOpensAt) / drawPeriodSeconds) + 1); 817 | } 818 | 819 | /// @notice Returns the next draw ID that can be awarded. 820 | /// @dev It's possible for draws to be missed, so the next draw ID to award 821 | /// may be more than one draw ahead of the last awarded draw ID. 822 | /// @return The next draw ID that can be awarded 823 | function getDrawIdToAward() public view returns (uint24) { 824 | uint24 openDrawId_ = getOpenDrawId(); 825 | return (openDrawId_ - _lastAwardedDrawId) > 1 ? openDrawId_ - 1 : openDrawId_; 826 | } 827 | 828 | /// @notice Returns the time at which a draw opens / opened at. 829 | /// @param drawId The draw to get the timestamp for 830 | /// @return The start time of the draw in seconds 831 | function drawOpensAt(uint24 drawId) public view returns (uint48) { 832 | return firstDrawOpensAt + (drawId - 1) * drawPeriodSeconds; 833 | } 834 | 835 | /// @notice Returns the time at which a draw closes / closed at. 836 | /// @param drawId The draw to get the timestamp for 837 | /// @return The end time of the draw in seconds 838 | function drawClosesAt(uint24 drawId) public view returns (uint48) { 839 | return firstDrawOpensAt + drawId * drawPeriodSeconds; 840 | } 841 | 842 | /// @notice Checks if the given draw is finalized. 843 | /// @param drawId The draw to check 844 | /// @return True if the draw is finalized, false otherwise 845 | function isDrawFinalized(uint24 drawId) public view returns (bool) { 846 | return block.timestamp >= drawClosesAt(drawId + 1); 847 | } 848 | 849 | /// @notice Calculates the number of tiers given the number of prize claims 850 | /// @dev This function will use the claim count to determine the number of tiers, then add one for the canary tier. 851 | /// @param _claimCount The number of prize claims 852 | /// @return The estimated number of tiers + the canary tier 853 | function computeNextNumberOfTiers(uint32 _claimCount) public view returns (uint8) { 854 | if (_lastAwardedDrawId != 0) { 855 | // claimCount is expected to be the estimated number of claims for the current prize tier. 856 | uint8 nextNumberOfTiers = _estimateNumberOfTiersUsingPrizeCountPerDraw(_claimCount); 857 | // limit change to 1 tier 858 | uint8 _numTiers = numberOfTiers; 859 | if (nextNumberOfTiers > _numTiers) { 860 | nextNumberOfTiers = _numTiers + 1; 861 | } else if (nextNumberOfTiers < _numTiers) { 862 | nextNumberOfTiers = _numTiers - 1; 863 | } 864 | return nextNumberOfTiers; 865 | } else { 866 | return numberOfTiers; 867 | } 868 | } 869 | 870 | /// @notice Returns the given account and vault's portion of the shutdown balance. 871 | /// @param _vault The vault whose contributions are measured 872 | /// @param _account The account whose vault twab is measured 873 | /// @return The portion of the shutdown balance that the account is entitled to. 874 | function computeShutdownPortion(address _vault, address _account) public view returns (UD60x18) { 875 | uint24 drawIdPriorToShutdown = getShutdownDrawId() - 1; 876 | uint24 startDrawIdInclusive = computeRangeStartDrawIdInclusive(drawIdPriorToShutdown, grandPrizePeriodDraws); 877 | 878 | (uint256 vaultContrib, uint256 totalContrib) = _getVaultShares( 879 | _vault, 880 | startDrawIdInclusive, 881 | drawIdPriorToShutdown 882 | ); 883 | 884 | (uint256 _userTwab, uint256 _vaultTwabTotalSupply) = getVaultUserBalanceAndTotalSupplyTwab( 885 | _vault, 886 | _account, 887 | startDrawIdInclusive, 888 | drawIdPriorToShutdown 889 | ); 890 | 891 | if (_vaultTwabTotalSupply == 0 || totalContrib == 0) { 892 | return UD60x18.wrap(0); 893 | } 894 | 895 | // first division purposely done before multiplication to avoid overflow 896 | return convert(vaultContrib) 897 | .div(convert(totalContrib)) 898 | .mul(convert(_userTwab)) 899 | .div(convert(_vaultTwabTotalSupply)); 900 | } 901 | 902 | /// @notice Returns the shutdown balance for a given vault and account. The prize pool must already be shutdown. 903 | /// @dev The shutdown balance is the amount of prize tokens that a user can claim after the prize pool has been shutdown. 904 | /// @dev The shutdown balance is calculated using the user's TWAB and the total supply TWAB, whose time ranges are the 905 | /// grand prize period prior to the shutdown timestamp. 906 | /// @param _vault The vault to check 907 | /// @param _account The account to check 908 | /// @return The shutdown balance for the given vault and account 909 | function shutdownBalanceOf(address _vault, address _account) public returns (uint256) { 910 | if (!isShutdown()) { 911 | return 0; 912 | } 913 | 914 | Observation memory withdrawalObservation = _withdrawalObservations[_vault][_account]; 915 | UD60x18 shutdownPortion; 916 | uint256 balance; 917 | 918 | // if we haven't withdrawn yet, add the portion of the shutdown balance 919 | if ((withdrawalObservation.available + withdrawalObservation.disbursed) == 0) { 920 | (balance, withdrawalObservation) = getShutdownInfo(); 921 | shutdownPortion = computeShutdownPortion(_vault, _account); 922 | _shutdownPortions[_vault][_account] = shutdownPortion; 923 | } else { 924 | shutdownPortion = _shutdownPortions[_vault][_account]; 925 | } 926 | 927 | if (shutdownPortion.unwrap() == 0) { 928 | return 0; 929 | } 930 | 931 | // if there are new rewards 932 | // current "draw id to award" observation - last withdraw observation 933 | Observation memory newestObs = _totalAccumulator.newestObservation(); 934 | balance += (newestObs.available + newestObs.disbursed) - (withdrawalObservation.available + withdrawalObservation.disbursed); 935 | 936 | return convert(convert(balance).mul(shutdownPortion)); 937 | } 938 | 939 | /// @notice Withdraws the shutdown balance for a given vault and sender 940 | /// @param _vault The eligible vault to withdraw the shutdown balance from 941 | /// @param _recipient The address to send the shutdown balance to 942 | /// @return The amount of prize tokens withdrawn 943 | function withdrawShutdownBalance(address _vault, address _recipient) external returns (uint256) { 944 | if (!isShutdown()) { 945 | revert PrizePoolNotShutdown(); 946 | } 947 | uint256 balance = shutdownBalanceOf(_vault, msg.sender); 948 | _withdrawalObservations[_vault][msg.sender] = _totalAccumulator.newestObservation(); 949 | if (balance > 0) { 950 | prizeToken.safeTransfer(_recipient, balance); 951 | _totalWithdrawn += uint128(balance); 952 | } 953 | return balance; 954 | } 955 | 956 | /// @notice Returns the open draw ID at the time of shutdown. 957 | /// @return The draw id 958 | function getShutdownDrawId() public view returns (uint24) { 959 | return getDrawId(shutdownAt()); 960 | } 961 | 962 | /// @notice Returns the timestamp at which the prize pool will be considered inactive and shutdown 963 | /// @return The timestamp at which the prize pool will be considered inactive 964 | function shutdownAt() public view returns (uint256) { 965 | uint256 twabShutdownAt = drawOpensAt(getDrawId(twabController.lastObservationAt())); 966 | uint256 drawTimeoutAt_ = drawTimeoutAt(); 967 | return drawTimeoutAt_ < twabShutdownAt ? drawTimeoutAt_ : twabShutdownAt; 968 | } 969 | 970 | /// @notice Returns whether the prize pool has been shutdown 971 | /// @return True if shutdown, false otherwise 972 | function isShutdown() public view returns (bool) { 973 | return block.timestamp >= shutdownAt(); 974 | } 975 | 976 | /// @notice Returns the timestamp at which the prize pool will be considered inactive 977 | /// @return The timestamp at which the prize pool has timed out and becomes inactive 978 | function drawTimeoutAt() public view returns (uint256) { 979 | return drawClosesAt(_lastAwardedDrawId + drawTimeout); 980 | } 981 | 982 | /// @notice Returns the total prize tokens contributed between the given draw ids, inclusive. 983 | /// @param _startDrawIdInclusive Start draw id inclusive 984 | /// @param _endDrawIdInclusive End draw id inclusive 985 | /// @return The total prize tokens contributed by all vaults 986 | function getTotalContributedBetween( 987 | uint24 _startDrawIdInclusive, 988 | uint24 _endDrawIdInclusive 989 | ) public view returns (uint256) { 990 | return 991 | _totalAccumulator.getDisbursedBetween( 992 | _startDrawIdInclusive, 993 | _endDrawIdInclusive 994 | ); 995 | } 996 | 997 | /// @notice Checks if the given user has won the prize for the specified tier in the given vault. 998 | /// @param _vault The address of the vault to check 999 | /// @param _user The address of the user to check for the prize 1000 | /// @param _tier The tier for which the prize is to be checked 1001 | /// @param _prizeIndex The prize index to check. Must be less than prize count for the tier 1002 | /// @return A boolean value indicating whether the user has won the prize or not 1003 | function isWinner( 1004 | address _vault, 1005 | address _user, 1006 | uint8 _tier, 1007 | uint32 _prizeIndex 1008 | ) public view returns (bool) { 1009 | uint24 lastAwardedDrawId_ = _lastAwardedDrawId; 1010 | 1011 | if (lastAwardedDrawId_ == 0) { 1012 | revert NoDrawsAwarded(); 1013 | } 1014 | if (_tier >= numberOfTiers) { 1015 | revert InvalidTier(_tier, numberOfTiers); 1016 | } 1017 | 1018 | SD59x18 tierOdds = getTierOdds(_tier, numberOfTiers); 1019 | uint24 startDrawIdInclusive = computeRangeStartDrawIdInclusive(lastAwardedDrawId_, TierCalculationLib.estimatePrizeFrequencyInDraws(tierOdds, grandPrizePeriodDraws)); 1020 | 1021 | uint32 tierPrizeCount = uint32(TierCalculationLib.prizeCount(_tier)); 1022 | 1023 | if (_prizeIndex >= tierPrizeCount) { 1024 | revert InvalidPrizeIndex(_prizeIndex, tierPrizeCount, _tier); 1025 | } 1026 | 1027 | uint256 userSpecificRandomNumber = TierCalculationLib.calculatePseudoRandomNumber( 1028 | lastAwardedDrawId_, 1029 | _vault, 1030 | _user, 1031 | _tier, 1032 | _prizeIndex, 1033 | _winningRandomNumber 1034 | ); 1035 | 1036 | SD59x18 vaultPortion = getVaultPortion( 1037 | _vault, 1038 | startDrawIdInclusive, 1039 | lastAwardedDrawId_ 1040 | ); 1041 | 1042 | (uint256 _userTwab, uint256 _vaultTwabTotalSupply) = getVaultUserBalanceAndTotalSupplyTwab( 1043 | _vault, 1044 | _user, 1045 | startDrawIdInclusive, 1046 | lastAwardedDrawId_ 1047 | ); 1048 | 1049 | return 1050 | TierCalculationLib.isWinner( 1051 | userSpecificRandomNumber, 1052 | _userTwab, 1053 | _vaultTwabTotalSupply, 1054 | vaultPortion, 1055 | tierOdds 1056 | ); 1057 | } 1058 | 1059 | /// @notice Compute the start draw id for a range given the end draw id and range size 1060 | /// @param _endDrawIdInclusive The end draw id (inclusive) of the range 1061 | /// @param _rangeSize The size of the range 1062 | /// @return The start draw id (inclusive) of the range 1063 | function computeRangeStartDrawIdInclusive(uint24 _endDrawIdInclusive, uint24 _rangeSize) public pure returns (uint24) { 1064 | if (_rangeSize != 0) { 1065 | return _rangeSize > _endDrawIdInclusive ? 1 : _endDrawIdInclusive - _rangeSize + 1; 1066 | } else { 1067 | revert RangeSizeZero(); 1068 | } 1069 | } 1070 | 1071 | /// @notice Returns the time-weighted average balance (TWAB) and the TWAB total supply for the specified user in 1072 | /// the given vault over a specified period. 1073 | /// @dev This function calculates the TWAB for a user by calling the getTwabBetween function of the TWAB controller 1074 | /// for a specified period of time. 1075 | /// @param _vault The address of the vault for which to get the TWAB. 1076 | /// @param _user The address of the user for which to get the TWAB. 1077 | /// @param _startDrawIdInclusive The starting draw for the range (inclusive) 1078 | /// @param _endDrawIdInclusive The end draw for the range (inclusive) 1079 | /// @return twab The TWAB for the specified user in the given vault over the specified period. 1080 | /// @return twabTotalSupply The TWAB total supply over the specified period. 1081 | function getVaultUserBalanceAndTotalSupplyTwab( 1082 | address _vault, 1083 | address _user, 1084 | uint24 _startDrawIdInclusive, 1085 | uint24 _endDrawIdInclusive 1086 | ) public view returns (uint256 twab, uint256 twabTotalSupply) { 1087 | uint48 _startTimestamp = drawOpensAt(_startDrawIdInclusive); 1088 | uint48 _endTimestamp = drawClosesAt(_endDrawIdInclusive); 1089 | twab = twabController.getTwabBetween(_vault, _user, _startTimestamp, _endTimestamp); 1090 | twabTotalSupply = twabController.getTotalSupplyTwabBetween( 1091 | _vault, 1092 | _startTimestamp, 1093 | _endTimestamp 1094 | ); 1095 | } 1096 | 1097 | /// @notice Calculates the portion of the vault's contribution to the prize pool over a specified duration in draws. 1098 | /// @param _vault The address of the vault for which to calculate the portion. 1099 | /// @param _startDrawIdInclusive The starting draw ID (inclusive) of the draw range to calculate the contribution portion for. 1100 | /// @param _endDrawIdInclusive The ending draw ID (inclusive) of the draw range to calculate the contribution portion for. 1101 | /// @return The portion of the vault's contribution to the prize pool over the specified duration in draws. 1102 | function getVaultPortion( 1103 | address _vault, 1104 | uint24 _startDrawIdInclusive, 1105 | uint24 _endDrawIdInclusive 1106 | ) public view returns (SD59x18) { 1107 | if (_vault == DONATOR) { 1108 | return sd(0); 1109 | } 1110 | 1111 | (uint256 vaultContributed, uint256 totalContributed) = _getVaultShares(_vault, _startDrawIdInclusive, _endDrawIdInclusive); 1112 | 1113 | if (totalContributed == 0) { 1114 | return sd(0); 1115 | } 1116 | 1117 | return sd( 1118 | SafeCast.toInt256( 1119 | vaultContributed 1120 | ) 1121 | ).div(sd(SafeCast.toInt256(totalContributed))); 1122 | } 1123 | 1124 | function _getVaultShares( 1125 | address _vault, 1126 | uint24 _startDrawIdInclusive, 1127 | uint24 _endDrawIdInclusive 1128 | ) internal view returns (uint256 shares, uint256 totalSupply) { 1129 | uint256 totalContributed = _totalAccumulator.getDisbursedBetween( 1130 | _startDrawIdInclusive, 1131 | _endDrawIdInclusive 1132 | ); 1133 | 1134 | uint256 totalDonated = _vaultAccumulator[DONATOR].getDisbursedBetween(_startDrawIdInclusive, _endDrawIdInclusive); 1135 | 1136 | totalSupply = totalContributed - totalDonated; 1137 | 1138 | shares = _vaultAccumulator[_vault].getDisbursedBetween( 1139 | _startDrawIdInclusive, 1140 | _endDrawIdInclusive 1141 | ); 1142 | } 1143 | 1144 | function _accountedBalance(Observation memory _observation) internal view returns (uint256) { 1145 | // obs.disbursed includes the reserve, prizes, and prize liquidity 1146 | // obs.disbursed is the total amount of tokens all-time contributed by vaults and released. These tokens may: 1147 | // - still be held for future prizes 1148 | // - have been given as prizes 1149 | // - have been captured as fees 1150 | 1151 | // obs.available is the total number of tokens that WILL be disbursed in the future. 1152 | // _directlyContributedReserve are tokens that have been contributed directly to the reserve 1153 | // totalWithdrawn represents all tokens that have been withdrawn as prizes or rewards 1154 | 1155 | return (_observation.available + _observation.disbursed) + uint256(_directlyContributedReserve) - uint256(_totalWithdrawn); 1156 | } 1157 | 1158 | /// @notice Modifier that requires the prize pool not to be shutdown 1159 | modifier notShutdown() { 1160 | if (isShutdown()) { 1161 | revert PrizePoolShutdown(); 1162 | } 1163 | _; 1164 | } 1165 | } 1166 | --------------------------------------------------------------------------------