From f0bb6603cee025860fed62afbd53d9f4c8363a4d Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Mon, 24 Jun 2024 15:49:36 +0000 Subject: [PATCH 1/7] add blast-compatible prize pool extension --- .envrc.example | 1 + foundry.toml | 1 + src/extensions/BlastPrizePool.sol | 70 ++++++++++++++++ test/extensions/BlastPrizePool.t.sol | 115 +++++++++++++++++++++++++++ 4 files changed, 187 insertions(+) create mode 100644 src/extensions/BlastPrizePool.sol create mode 100644 test/extensions/BlastPrizePool.t.sol diff --git a/.envrc.example b/.envrc.example index 765b808..885d1a1 100644 --- a/.envrc.example +++ b/.envrc.example @@ -9,6 +9,7 @@ export MAINNET_RPC_URL="" export ARBITRUM_RPC_URL="" export OPTIMISM_RPC_URL="" export POLYGON_RPC_URL="" +export BLAST_RPC_URL="" # Testnet RPC URLs export GOERLI_RPC_URL="" diff --git a/foundry.toml b/foundry.toml index 5411725..52fefd3 100644 --- a/foundry.toml +++ b/foundry.toml @@ -33,6 +33,7 @@ mainnet = "${MAINNET_RPC_URL}" arbitrum = "${ARBITRUM_RPC_URL}" optimism = "${OPTIMISM_RPC_URL}" polygon = "${POLYGON_RPC_URL}" +blast = "${BLAST_RPC_URL}" goerli = "${GOERLI_RPC_URL}" arbitrum-goerli = "${ARBITRUM_GOERLI_RPC_URL}" diff --git a/src/extensions/BlastPrizePool.sol b/src/extensions/BlastPrizePool.sol new file mode 100644 index 0000000..94a8671 --- /dev/null +++ b/src/extensions/BlastPrizePool.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import { PrizePool, ConstructorParams } from "../PrizePool.sol"; + +// The rebasing WETH token on Blast +IERC20Rebasing constant WETH = IERC20Rebasing(0x4300000000000000000000000000000000000004); + +/// @notice The Blast yield modes for WETH +enum YieldMode { + AUTOMATIC, + VOID, + CLAIMABLE +} + +/// @notice The relevant interface for rebasing WETH on Blast +interface IERC20Rebasing { + function configure(YieldMode) external returns (uint256); + function claim(address recipient, uint256 amount) external returns (uint256); + function getClaimableAmount(address account) external view returns (uint256); +} + +/// @notice Thrown if the prize token is not the expected token on Blast. +/// @param prizeToken The prize token address +/// @param expectedToken The expected token address +error PrizeTokenNotExpectedToken(address prizeToken, address expectedToken); + +/// @notice Thrown if a yield donation is triggered when there is no claimable balance. +error NoClaimableBalance(); + +/// @title PoolTogether V5 Blast Prize Pool +/// @author G9 Software Inc. +/// @notice A modified prize pool that opts in to claimable WETH yield on Blast and allows anyone to trigger +/// a donation of the accrued yield to the prize pool. +contract BlastPrizePool is PrizePool { + + /* ============ Constructor ============ */ + + /// @notice Constructs a new Blast Prize Pool. + /// @dev Reverts if the prize token is not the expected WETH token on Blast. + /// @param params A struct of constructor parameters + constructor(ConstructorParams memory params) PrizePool(params) { + if (address(params.prizeToken) != address(WETH)) { + revert PrizeTokenNotExpectedToken(address(params.prizeToken), address(WETH)); + } + + // Opt-in to claimable yield + WETH.configure(YieldMode.CLAIMABLE); + } + + /* ============ External Functions ============ */ + + /// @notice Returns the claimable WETH yield balance for this contract + function claimableYieldBalance() external view returns (uint256) { + return WETH.getClaimableAmount(address(this)); + } + + /// @notice Claims the available WETH yield balance and donates it to the prize pool. + /// @return The amount claimed and donated. + function donateClaimableYield() external returns (uint256) { + uint256 _claimableYieldBalance = WETH.getClaimableAmount(address(this)); + if (_claimableYieldBalance == 0) { + revert NoClaimableBalance(); + } + WETH.claim(address(this), _claimableYieldBalance); + contributePrizeTokens(DONATOR, _claimableYieldBalance); + return _claimableYieldBalance; + } + +} \ No newline at end of file diff --git a/test/extensions/BlastPrizePool.t.sol b/test/extensions/BlastPrizePool.t.sol new file mode 100644 index 0000000..ba35a5e --- /dev/null +++ b/test/extensions/BlastPrizePool.t.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; + +import { TwabController } from "pt-v5-twab-controller/TwabController.sol"; +import { BlastPrizePool, ConstructorParams, WETH, PrizeTokenNotExpectedToken, NoClaimableBalance } from "../../src/extensions/BlastPrizePool.sol"; +import { IERC20 } from "../../src/PrizePool.sol"; + +contract BlastPrizePoolTest is Test { + BlastPrizePool prizePool; + + address bob = makeAddr("bob"); + address alice = makeAddr("alice"); + + address wethWhale = address(0x66714DB8F3397c767d0A602458B5b4E3C0FE7dd1); + + TwabController twabController; + IERC20 prizeToken; + address drawManager; + + uint256 TIER_SHARES = 100; + uint256 CANARY_SHARES = 5; + uint256 RESERVE_SHARES = 10; + + uint24 grandPrizePeriodDraws = 365; + uint48 drawPeriodSeconds = 1 days; + uint24 drawTimeout; + uint48 firstDrawOpensAt; + uint8 initialNumberOfTiers = 4; + uint256 winningRandomNumber = 123456; + uint256 tierLiquidityUtilizationRate = 1e18; + + uint256 blockNumber = 5213491; + uint256 blockTimestamp = 1719236797; + + ConstructorParams params; + + function setUp() public { + drawTimeout = 30; + + vm.createSelectFork("blast", blockNumber); + vm.warp(blockTimestamp); + + prizeToken = IERC20(address(WETH)); + twabController = new TwabController(uint32(drawPeriodSeconds), uint32(blockTimestamp - 1 days)); + + firstDrawOpensAt = uint48(blockTimestamp + 1 days); // set draw start 1 day into future + + drawManager = address(this); + + params = ConstructorParams( + prizeToken, + twabController, + drawManager, + tierLiquidityUtilizationRate, + drawPeriodSeconds, + firstDrawOpensAt, + grandPrizePeriodDraws, + initialNumberOfTiers, + uint8(TIER_SHARES), + uint8(CANARY_SHARES), + uint8(RESERVE_SHARES), + drawTimeout + ); + + prizePool = new BlastPrizePool(params); + prizePool.setDrawManager(address(this)); + } + + function testWrongPrizeToken() public { + params.prizeToken = IERC20(address(1)); + vm.expectRevert(abi.encodeWithSelector(PrizeTokenNotExpectedToken.selector, address(1), address(WETH))); + prizePool = new BlastPrizePool(params); + } + + function testClaimableYield() public { + assertEq(IERC20(address(WETH)).balanceOf(address(prizePool)), 0); + + // check balance + assertEq(prizePool.claimableYieldBalance(), 0); + + // donate some tokens to the prize pool + vm.startPrank(wethWhale); + IERC20(address(WETH)).approve(address(prizePool), 1e18); + prizePool.donatePrizeTokens(1e18); + vm.stopPrank(); + assertEq(prizePool.getDonatedBetween(1, 1), 1e18); + + // deal some ETH to the WETH contract and call addValue + deal(address(WETH), 1e18 + address(WETH).balance); + vm.startPrank(address(0x4300000000000000000000000000000000000000)); // REPORTER + (bool success,) = address(WETH).call(abi.encodeWithSignature("addValue(uint256)", 0)); + vm.stopPrank(); + require(success, "addValue failed"); + + // check balance non-zero + uint256 claimable = prizePool.claimableYieldBalance(); + assertGt(claimable, 0); + + // trigger donation + vm.startPrank(alice); + uint256 donated = prizePool.donateClaimableYield(); + vm.stopPrank(); + + assertEq(donated, claimable); + assertEq(prizePool.getDonatedBetween(1, 1), 1e18 + donated); + assertEq(prizePool.claimableYieldBalance(), 0); + + // reverts on donation of zero balance + vm.expectRevert(abi.encodeWithSelector(NoClaimableBalance.selector)); + prizePool.donateClaimableYield(); + } + +} \ No newline at end of file From a81ed5121d9b3ae0d6607b6aed5fe3207fcf3fb8 Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Mon, 24 Jun 2024 15:58:05 +0000 Subject: [PATCH 2/7] add blast RPC to actions --- .github/workflows/coverage.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 5b7a1c5..f48a4bb 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -32,6 +32,7 @@ jobs: - name: Run Forge test env: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + BLAST_RPC_URL: ${{ secrets.BLAST_RPC_URL }} run: | forge test id: test @@ -42,6 +43,7 @@ jobs: - name: Run Forge coverage env: MAINNET_RPC_URL: ${{ secrets.MAINNET_RPC_URL }} + BLAST_RPC_URL: ${{ secrets.BLAST_RPC_URL }} run: | forge coverage --report lcov && lcov --remove lcov.info -o lcov.info 'test/*' id: coverage From e26d6a294c36fe36d25d8edb227221ffc34935dd Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Thu, 27 Jun 2024 14:20:19 +0000 Subject: [PATCH 3/7] cap prize draw range at gp period --- src/PrizePool.sol | 4 ++-- src/libraries/TierCalculationLib.sol | 7 +++++-- test/PrizePool.t.sol | 2 +- test/libraries/TierCalculationLib.t.sol | 10 +++++----- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/PrizePool.sol b/src/PrizePool.sol index 4cb2971..bad9bd0 100644 --- a/src/PrizePool.sol +++ b/src/PrizePool.sol @@ -692,7 +692,7 @@ contract PrizePool is TieredLiquidityDistributor { /// @return The number of draws function getTierAccrualDurationInDraws(uint8 _tier) external view returns (uint24) { return - uint24(TierCalculationLib.estimatePrizeFrequencyInDraws(getTierOdds(_tier, numberOfTiers))); + TierCalculationLib.estimatePrizeFrequencyInDraws(getTierOdds(_tier, numberOfTiers), grandPrizePeriodDraws); } /// @notice The total amount of prize tokens that have been withdrawn as fees or prizes @@ -1011,7 +1011,7 @@ contract PrizePool is TieredLiquidityDistributor { } SD59x18 tierOdds = getTierOdds(_tier, numberOfTiers); - uint24 startDrawIdInclusive = computeRangeStartDrawIdInclusive(lastAwardedDrawId_, uint24(TierCalculationLib.estimatePrizeFrequencyInDraws(tierOdds))); + uint24 startDrawIdInclusive = computeRangeStartDrawIdInclusive(lastAwardedDrawId_, TierCalculationLib.estimatePrizeFrequencyInDraws(tierOdds, grandPrizePeriodDraws)); uint32 tierPrizeCount = uint32(TierCalculationLib.prizeCount(_tier)); diff --git a/src/libraries/TierCalculationLib.sol b/src/libraries/TierCalculationLib.sol index 5dd7fb4..a614af3 100644 --- a/src/libraries/TierCalculationLib.sol +++ b/src/libraries/TierCalculationLib.sol @@ -27,10 +27,13 @@ library TierCalculationLib { } /// @notice Estimates the number of draws between a tier occurring. + /// @dev Limits the frequency to the grand prize period in draws. /// @param _tierOdds The odds for the tier to calculate the frequency of + /// @param _grandPrizePeriod The number of draws between grand prizes /// @return The estimated number of draws between the tier occurring - function estimatePrizeFrequencyInDraws(SD59x18 _tierOdds) internal pure returns (uint256) { - return uint256(convert(sd(1e18).div(_tierOdds).ceil())); + function estimatePrizeFrequencyInDraws(SD59x18 _tierOdds, uint24 _grandPrizePeriod) internal pure returns (uint24) { + uint256 _prizeFrequencyInDraws = uint256(convert(sd(1e18).div(_tierOdds).ceil())); + return _prizeFrequencyInDraws > _grandPrizePeriod ? _grandPrizePeriod : uint24(_prizeFrequencyInDraws); } /// @notice Computes the number of prizes for a given tier. diff --git a/test/PrizePool.t.sol b/test/PrizePool.t.sol index 100ab30..5932eac 100644 --- a/test/PrizePool.t.sol +++ b/test/PrizePool.t.sol @@ -377,7 +377,7 @@ contract PrizePoolTest is Test { } function testGetTierAccrualDurationInDraws() public { - assertEq(prizePool.getTierAccrualDurationInDraws(0), 366); + assertEq(prizePool.getTierAccrualDurationInDraws(0), 365); } function testContributePrizeTokens() public { diff --git a/test/libraries/TierCalculationLib.t.sol b/test/libraries/TierCalculationLib.t.sol index 24f9e95..ab3198e 100644 --- a/test/libraries/TierCalculationLib.t.sol +++ b/test/libraries/TierCalculationLib.t.sol @@ -32,19 +32,19 @@ contract TierCalculationLibTest is Test { function testEstimatePrizeFrequencyInDraws() public { assertEq( - TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(0, 4, 365)), - 366 + TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(0, 4, 365), 365), + 365 ); assertEq( - TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(1, 4, 365)), + TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(1, 4, 365), 365), 124 ); assertEq( - TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(2, 4, 365)), + TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(2, 4, 365), 365), 31 ); assertEq( - TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(3, 4, 365)), + TierCalculationLib.estimatePrizeFrequencyInDraws(TierCalculationLib.getTierOdds(3, 4, 365), 365), 1 ); } From 6d57310ac572086a3cda88d0a80712d20a2150f7 Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Thu, 27 Jun 2024 15:25:21 +0000 Subject: [PATCH 4/7] align shutdown timestamp to draw period --- src/PrizePool.sol | 2 +- test/PrizePool.t.sol | 30 ++++++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/PrizePool.sol b/src/PrizePool.sol index 4cb2971..ba632e7 100644 --- a/src/PrizePool.sol +++ b/src/PrizePool.sol @@ -957,7 +957,7 @@ contract PrizePool is TieredLiquidityDistributor { /// @notice Returns the timestamp at which the prize pool will be considered inactive and shutdown /// @return The timestamp at which the prize pool will be considered inactive function shutdownAt() public view returns (uint256) { - uint256 twabShutdownAt = twabController.lastObservationAt(); + uint256 twabShutdownAt = drawOpensAt(getDrawId(twabController.lastObservationAt())); uint256 drawTimeoutAt_ = drawTimeoutAt(); return drawTimeoutAt_ < twabShutdownAt ? drawTimeoutAt_ : twabShutdownAt; } diff --git a/test/PrizePool.t.sol b/test/PrizePool.t.sol index 100ab30..f7bc3a8 100644 --- a/test/PrizePool.t.sol +++ b/test/PrizePool.t.sol @@ -649,19 +649,45 @@ contract PrizePoolTest is Test { } function testAwardDraw_twabShutdown() public { + vm.warp(prizePool.drawClosesAt(1)); // warp to end of draw 1 vm.mockCall( address(twabController), abi.encodeWithSelector(twabController.lastObservationAt.selector), - abi.encode(true) + abi.encode(block.timestamp + 5) // not aligned with draw period (TWAB shutdown is 5 seconds into this draw) ); vm.expectRevert( abi.encodeWithSelector( - PrizePoolShutdown.selector + PrizePoolShutdown.selector // prize pool will truncate the TWAB shutdown with the draw period and will shutdown ) ); prizePool.awardDraw(winningRandomNumber); } + function testShutdownAt_twabShutdown() public { + vm.warp(prizePool.drawClosesAt(1)); // warp to end of draw 1 + + vm.mockCall( + address(twabController), + abi.encodeWithSelector(twabController.lastObservationAt.selector), + abi.encode(block.timestamp) // aligned with draw period + ); + assertEq(prizePool.shutdownAt(), block.timestamp); // will be aligned with current time + + vm.mockCall( + address(twabController), + abi.encodeWithSelector(twabController.lastObservationAt.selector), + abi.encode(block.timestamp - 5) // sometime in last draw period + ); + assertEq(prizePool.shutdownAt(), block.timestamp - prizePool.drawPeriodSeconds()); // will be aligned with start of last draw + + vm.mockCall( + address(twabController), + abi.encodeWithSelector(twabController.lastObservationAt.selector), + abi.encode(block.timestamp + 5) // sometime in next draw period + ); + assertEq(prizePool.shutdownAt(), block.timestamp); // will be aligned with current draw open time (last draw close time) + } + function testAwardDraw_emittedDrawIdSameAsReturnedDrawId() public { contribute(510e18); uint24 expectedDrawId = 1; From ba4b59d352c8ce74a65530d1bf84d40a38f7a094 Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Thu, 27 Jun 2024 16:15:15 +0000 Subject: [PATCH 5/7] enforce minimum draw timeout and clarify natspec --- src/PrizePool.sol | 19 ++++++++++++++----- test/PrizePool.t.sol | 16 +++++++++++++--- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/PrizePool.sol b/src/PrizePool.sol index 4cb2971..86bb460 100644 --- a/src/PrizePool.sol +++ b/src/PrizePool.sol @@ -12,6 +12,13 @@ import { DrawAccumulatorLib, Observation, MAX_OBSERVATION_CARDINALITY } from "./ import { TieredLiquidityDistributor, Tier } from "./abstract/TieredLiquidityDistributor.sol"; import { TierCalculationLib } from "./libraries/TierCalculationLib.sol"; +/* ============ Constants ============ */ + +// The minimum draw timeout. A timeout of two is necessary to allow for enough time to close and award a draw. +uint24 constant MINIMUM_DRAW_TIMEOUT = 2; + +/* ============ Errors ============ */ + /// @notice Thrown when the prize pool is constructed with a first draw open timestamp that is in the past error FirstDrawOpensInPast(); @@ -89,8 +96,10 @@ error InvalidPrizeIndex(uint32 invalidPrizeIndex, uint32 prizeCount, uint8 tier) /// @notice Thrown when there are no awarded draws when a computation requires an awarded draw. error NoDrawsAwarded(); -/// @notice Thrown when the Prize Pool is constructed with a draw timeout of zero -error DrawTimeoutIsZero(); +/// @notice Thrown when the prize pool is initialized with an invalid draw timeout. +/// @param drawTimeout The draw timeout that was set +/// @param minimumDrawTimeout The minimum draw timeout +error InvalidDrawTimeout(uint24 drawTimeout, uint24 minimumDrawTimeout); /// @notice Thrown when the Prize Pool is constructed with a draw timeout greater than the grand prize period draws error DrawTimeoutGTGrandPrizePeriodDraws(); @@ -280,7 +289,7 @@ contract PrizePool is TieredLiquidityDistributor { /// @notice The timestamp at which the first draw will open. uint48 public immutable firstDrawOpensAt; - /// @notice The maximum number of draws that can be missed before the prize pool is considered inactive. + /// @notice The maximum number of draws that can pass since the last awarded draw before the prize pool is considered inactive. uint24 public immutable drawTimeout; /// @notice The address that is allowed to set the draw manager @@ -335,8 +344,8 @@ contract PrizePool is TieredLiquidityDistributor { params.grandPrizePeriodDraws ) { - if (params.drawTimeout == 0) { - revert DrawTimeoutIsZero(); + if (params.drawTimeout < MINIMUM_DRAW_TIMEOUT) { + revert InvalidDrawTimeout(params.drawTimeout, MINIMUM_DRAW_TIMEOUT); } if (params.drawTimeout > params.grandPrizePeriodDraws) { diff --git a/test/PrizePool.t.sol b/test/PrizePool.t.sol index 100ab30..3ddd913 100644 --- a/test/PrizePool.t.sol +++ b/test/PrizePool.t.sol @@ -20,7 +20,8 @@ import { PrizeIsZero, ConstructorParams, InsufficientRewardsError, - DrawTimeoutIsZero, + InvalidDrawTimeout, + MINIMUM_DRAW_TIMEOUT, DrawTimeoutGTGrandPrizePeriodDraws, PrizePoolNotShutdown, DidNotWin, @@ -160,9 +161,18 @@ contract PrizePoolTest is Test { assertEq(prizePool.drawPeriodSeconds(), drawPeriodSeconds); } - function testDrawTimeoutIsZero() public { + function testInvalidDrawTimeout() public { params.drawTimeout = 0; - vm.expectRevert(abi.encodeWithSelector(DrawTimeoutIsZero.selector)); + vm.expectRevert(abi.encodeWithSelector(InvalidDrawTimeout.selector, 0, 2)); + new PrizePool(params); + + params.drawTimeout = 1; + vm.expectRevert(abi.encodeWithSelector(InvalidDrawTimeout.selector, 1, 2)); + new PrizePool(params); + + assertEq(MINIMUM_DRAW_TIMEOUT, 2); // validate assumptions + params.drawTimeout = 2; + // no revert new PrizePool(params); } From ddf87b32a1c0c857bb4e11e55e3b1e467e9be4d3 Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Fri, 28 Jun 2024 14:30:43 +0000 Subject: [PATCH 6/7] rename error to DrawTimeoutLtMinimum --- src/PrizePool.sol | 6 +++--- test/PrizePool.t.sol | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/PrizePool.sol b/src/PrizePool.sol index 86bb460..9d95286 100644 --- a/src/PrizePool.sol +++ b/src/PrizePool.sol @@ -96,10 +96,10 @@ error InvalidPrizeIndex(uint32 invalidPrizeIndex, uint32 prizeCount, uint8 tier) /// @notice Thrown when there are no awarded draws when a computation requires an awarded draw. error NoDrawsAwarded(); -/// @notice Thrown when the prize pool is initialized with an invalid draw timeout. +/// @notice Thrown when the prize pool is initialized with a draw timeout lower than the minimum. /// @param drawTimeout The draw timeout that was set /// @param minimumDrawTimeout The minimum draw timeout -error InvalidDrawTimeout(uint24 drawTimeout, uint24 minimumDrawTimeout); +error DrawTimeoutLtMinimum(uint24 drawTimeout, uint24 minimumDrawTimeout); /// @notice Thrown when the Prize Pool is constructed with a draw timeout greater than the grand prize period draws error DrawTimeoutGTGrandPrizePeriodDraws(); @@ -345,7 +345,7 @@ contract PrizePool is TieredLiquidityDistributor { ) { if (params.drawTimeout < MINIMUM_DRAW_TIMEOUT) { - revert InvalidDrawTimeout(params.drawTimeout, MINIMUM_DRAW_TIMEOUT); + revert DrawTimeoutLtMinimum(params.drawTimeout, MINIMUM_DRAW_TIMEOUT); } if (params.drawTimeout > params.grandPrizePeriodDraws) { diff --git a/test/PrizePool.t.sol b/test/PrizePool.t.sol index 3ddd913..10799a2 100644 --- a/test/PrizePool.t.sol +++ b/test/PrizePool.t.sol @@ -20,7 +20,7 @@ import { PrizeIsZero, ConstructorParams, InsufficientRewardsError, - InvalidDrawTimeout, + DrawTimeoutLtMinimum, MINIMUM_DRAW_TIMEOUT, DrawTimeoutGTGrandPrizePeriodDraws, PrizePoolNotShutdown, @@ -161,13 +161,13 @@ contract PrizePoolTest is Test { assertEq(prizePool.drawPeriodSeconds(), drawPeriodSeconds); } - function testInvalidDrawTimeout() public { + function testDrawTimeoutLtMinimum() public { params.drawTimeout = 0; - vm.expectRevert(abi.encodeWithSelector(InvalidDrawTimeout.selector, 0, 2)); + vm.expectRevert(abi.encodeWithSelector(DrawTimeoutLtMinimum.selector, 0, 2)); new PrizePool(params); params.drawTimeout = 1; - vm.expectRevert(abi.encodeWithSelector(InvalidDrawTimeout.selector, 1, 2)); + vm.expectRevert(abi.encodeWithSelector(DrawTimeoutLtMinimum.selector, 1, 2)); new PrizePool(params); assertEq(MINIMUM_DRAW_TIMEOUT, 2); // validate assumptions From 2fc70f5b90180383ce1dc1339ec213df960d9ef0 Mon Sep 17 00:00:00 2001 From: Trevor Richard Date: Wed, 3 Jul 2024 15:50:44 +0000 Subject: [PATCH 7/7] improve overflow protection for shutdown portion calculation --- src/PrizePool.sol | 30 +++++++++++++----------------- test/PrizePool.t.sol | 36 +++++++++++++++++++++++------------- 2 files changed, 36 insertions(+), 30 deletions(-) diff --git a/src/PrizePool.sol b/src/PrizePool.sol index 2bffd6c..4f632ec 100644 --- a/src/PrizePool.sol +++ b/src/PrizePool.sol @@ -5,7 +5,7 @@ import { SafeCast } from "openzeppelin/utils/math/SafeCast.sol"; import { IERC20 } from "openzeppelin/token/ERC20/IERC20.sol"; import { SafeERC20 } from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; import { SD59x18, convert, sd } from "prb-math/SD59x18.sol"; -import { SD1x18, unwrap, UNIT } from "prb-math/SD1x18.sol"; +import { UD60x18, convert } from "prb-math/UD60x18.sol"; import { TwabController } from "pt-v5-twab-controller/TwabController.sol"; import { DrawAccumulatorLib, Observation, MAX_OBSERVATION_CARDINALITY } from "./libraries/DrawAccumulatorLib.sol"; @@ -167,14 +167,6 @@ struct ConstructorParams { uint24 drawTimeout; } -/// @notice A struct to represent a shutdown portion of liquidity for a vault and account -/// @param numerator The numerator of the portion -/// @param denominator The denominator of the portion -struct ShutdownPortion { - uint256 numerator; - uint256 denominator; -} - /// @title PoolTogether V5 Prize Pool /// @author G9 Software Inc. & PoolTogether Inc. Team /// @notice The Prize Pool holds the prize liquidity and allows vaults to claim prizes. @@ -326,7 +318,7 @@ contract PrizePool is TieredLiquidityDistributor { mapping(address vault => mapping(address account => Observation lastWithdrawalTotalContributedObservation)) internal _withdrawalObservations; /// @notice The shutdown portion of liquidity for a vault and account - mapping(address vault => mapping(address account => ShutdownPortion shutdownPortion)) internal _shutdownPortions; + mapping(address vault => mapping(address account => UD60x18 shutdownPortion)) internal _shutdownPortions; /* ============ Constructor ============ */ @@ -879,7 +871,7 @@ contract PrizePool is TieredLiquidityDistributor { /// @param _vault The vault whose contributions are measured /// @param _account The account whose vault twab is measured /// @return The portion of the shutdown balance that the account is entitled to. - function computeShutdownPortion(address _vault, address _account) public view returns (ShutdownPortion memory) { + function computeShutdownPortion(address _vault, address _account) public view returns (UD60x18) { uint24 drawIdPriorToShutdown = getShutdownDrawId() - 1; uint24 startDrawIdInclusive = computeRangeStartDrawIdInclusive(drawIdPriorToShutdown, grandPrizePeriodDraws); @@ -896,11 +888,15 @@ contract PrizePool is TieredLiquidityDistributor { drawIdPriorToShutdown ); - if (_vaultTwabTotalSupply == 0) { - return ShutdownPortion(0, 0); + if (_vaultTwabTotalSupply == 0 || totalContrib == 0) { + return UD60x18.wrap(0); } - return ShutdownPortion(vaultContrib * _userTwab, totalContrib * _vaultTwabTotalSupply); + // first division purposely done before multiplication to avoid overflow + return convert(vaultContrib) + .div(convert(totalContrib)) + .mul(convert(_userTwab)) + .div(convert(_vaultTwabTotalSupply)); } /// @notice Returns the shutdown balance for a given vault and account. The prize pool must already be shutdown. @@ -916,7 +912,7 @@ contract PrizePool is TieredLiquidityDistributor { } Observation memory withdrawalObservation = _withdrawalObservations[_vault][_account]; - ShutdownPortion memory shutdownPortion; + UD60x18 shutdownPortion; uint256 balance; // if we haven't withdrawn yet, add the portion of the shutdown balance @@ -928,7 +924,7 @@ contract PrizePool is TieredLiquidityDistributor { shutdownPortion = _shutdownPortions[_vault][_account]; } - if (shutdownPortion.denominator == 0) { + if (shutdownPortion.unwrap() == 0) { return 0; } @@ -937,7 +933,7 @@ contract PrizePool is TieredLiquidityDistributor { Observation memory newestObs = _totalAccumulator.newestObservation(); balance += (newestObs.available + newestObs.disbursed) - (withdrawalObservation.available + withdrawalObservation.disbursed); - return (shutdownPortion.numerator * balance) / shutdownPortion.denominator; + return convert(convert(balance).mul(shutdownPortion)); } /// @notice Withdraws the shutdown balance for a given vault and sender diff --git a/test/PrizePool.t.sol b/test/PrizePool.t.sol index 9c1d74a..70d7d37 100644 --- a/test/PrizePool.t.sol +++ b/test/PrizePool.t.sol @@ -6,6 +6,7 @@ import "forge-std/Test.sol"; import { ERC20 } from "openzeppelin/token/ERC20/ERC20.sol"; import { IERC20 } from "openzeppelin/token/ERC20/IERC20.sol"; import { sd, SD59x18 } from "prb-math/SD59x18.sol"; +import { UD60x18, convert } from "prb-math/UD60x18.sol"; import { UD2x18, ud2x18 } from "prb-math/UD2x18.sol"; import { SD1x18, sd1x18 } from "prb-math/SD1x18.sol"; import { TwabController } from "pt-v5-twab-controller/TwabController.sol"; @@ -44,8 +45,7 @@ import { IncompatibleTwabPeriodOffset, ClaimPeriodExpired, PrizePoolShutdown, - Observation, - ShutdownPortion + Observation } from "../src/PrizePool.sol"; import { ERC20Mintable } from "./mocks/ERC20Mintable.sol"; @@ -918,8 +918,8 @@ contract PrizePoolTest is Test { uint bobShutdownBalance = 630e18/6; uint aliceShutdownBalance = 630e18/3; - assertEq(prizePool.shutdownBalanceOf(vault, bob), bobShutdownBalance, "bob balance"); - assertEq(prizePool.shutdownBalanceOf(vault2, alice), aliceShutdownBalance, "alice balance"); + assertApproxEqAbs(prizePool.shutdownBalanceOf(vault, bob), bobShutdownBalance, 1000, "bob balance"); + assertApproxEqAbs(prizePool.shutdownBalanceOf(vault2, alice), aliceShutdownBalance, 1000, "alice balance"); assertEq(prizePool.rewardBalance(bob), 0.1e18, "bob rewards"); assertEq(prizePool.rewardBalance(alice), remainder, "alice rewards"); @@ -927,22 +927,33 @@ contract PrizePoolTest is Test { prizePool.withdrawRewards(bob, 0.1e18); vm.prank(bob); prizePool.withdrawShutdownBalance(vault, bob); - assertEq(prizeToken.balanceOf(bob), bobShutdownBalance + 0.1e18, "bob token balance"); + assertApproxEqAbs(prizeToken.balanceOf(bob), bobShutdownBalance + 0.1e18, 1000, "bob token balance"); vm.prank(alice); prizePool.withdrawShutdownBalance(vault2, alice); vm.prank(alice); prizePool.withdrawRewards(alice, remainder); - assertEq(prizeToken.balanceOf(alice), aliceShutdownBalance + remainder, "alice token balance"); + assertApproxEqAbs(prizeToken.balanceOf(alice), aliceShutdownBalance + remainder, 1000, "alice token balance"); - assertEq(prizePool.accountedBalance(), 660e18 - (630e18/6 + 630e18/3) - 0.1e18 - remainder, "final balance"); + assertApproxEqAbs(prizePool.accountedBalance(), 660e18 - (630e18/6 + 630e18/3) - 0.1e18 - remainder, 1000, "final balance"); + } + + function test_shutdownBalanceOf_noOverflow() public { + // The contribution, TWAB, and available prize token balance would overflow if multiplied together, + // but we should not see this overflow happen in the shutdown logic. + contribute(type(uint96).max, vault); + uint newTime = prizePool.shutdownAt(); + vm.warp(newTime); + mockShutdownTwab(type(uint96).max, type(uint96).max, bob, vault); + UD60x18 portion = prizePool.computeShutdownPortion(vault, bob); + assertEq(portion.unwrap(), 1e18); + assertEq(prizePool.shutdownBalanceOf(vault, bob), type(uint96).max); } function test_computeShutdownPortion_empty() public { vm.warp(prizePool.shutdownAt()); - ShutdownPortion memory portion = prizePool.computeShutdownPortion(address(this), bob); - assertEq(portion.numerator, 0); - assertEq(portion.denominator, 0); + UD60x18 portion = prizePool.computeShutdownPortion(address(this), bob); + assertEq(portion.unwrap(), 0); } function test_computeShutdownPortion_nonZero() public { @@ -950,9 +961,8 @@ contract PrizePoolTest is Test { uint newTime = prizePool.shutdownAt(); vm.warp(newTime); mockShutdownTwab(0.5e18, 1e18, bob, vault); - ShutdownPortion memory portion = prizePool.computeShutdownPortion(vault, bob); - assertEq(portion.numerator, 220e18 * 0.5e18); - assertEq(portion.denominator, 220e18 * 1e18); + UD60x18 portion = prizePool.computeShutdownPortion(vault, bob); + assertEq(portion.unwrap(), 0.5e18); } function test_withdrawShutdownBalance_notShutdown() public {