From fbb0a8247ac01b516ced3093a259efc4f36eaf09 Mon Sep 17 00:00:00 2001 From: keating Date: Fri, 2 Feb 2024 16:16:29 -0500 Subject: [PATCH] Happy path integration tests --- .gitmodules | 6 + foundry.toml | 6 +- lib/forge-std | 2 +- lib/v3-core | 1 + lib/v3-periphery | 1 + .../IUniswapV3FactoryOwnerActions.sol | 8 + test/UniStaker.integration.t.sol | 265 +++++++++++++++++- test/UniStaker.t.sol | 56 +--- test/helpers/Constants.sol | 8 + test/helpers/IntegrationTest.sol | 155 ++++++++++ test/helpers/PercentAssertions.sol | 59 ++++ test/helpers/ProposalTest.sol | 3 + test/helpers/interfaces/IUniswapPool.sol | 33 ++- test/mocks/MockUniswapV3Factory.sol | 4 + 14 files changed, 549 insertions(+), 58 deletions(-) create mode 160000 lib/v3-core create mode 160000 lib/v3-periphery create mode 100644 test/helpers/IntegrationTest.sol create mode 100644 test/helpers/PercentAssertions.sol diff --git a/.gitmodules b/.gitmodules index e80ffd8..68d2d65 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,9 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/openzeppelin/openzeppelin-contracts +[submodule "lib/v3-core"] + path = lib/v3-core + url = https://github.com/uniswap/v3-core +[submodule "lib/v3-periphery"] + path = lib/v3-periphery + url = https://github.com/uniswap/v3-periphery diff --git a/foundry.toml b/foundry.toml index a3031f2..97b7b1d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -2,7 +2,11 @@ evm_version = "paris" optimizer = true optimizer_runs = 10_000_000 - remappings = ["openzeppelin/=lib/openzeppelin-contracts/contracts"] + remappings = [ + "openzeppelin/=lib/openzeppelin-contracts/contracts", + "uniswap-periphery/=lib/v3-periphery/contracts", + "@uniswap/v3-core=lib/v3-core", + ] solc_version = "0.8.23" verbosity = 3 diff --git a/lib/forge-std b/lib/forge-std index 4513bc2..155d547 160000 --- a/lib/forge-std +++ b/lib/forge-std @@ -1 +1 @@ -Subproject commit 4513bc2063f23c57bee6558799584b518d387a39 +Subproject commit 155d547c449afa8715f538d69454b83944117811 diff --git a/lib/v3-core b/lib/v3-core new file mode 160000 index 0000000..e3589b1 --- /dev/null +++ b/lib/v3-core @@ -0,0 +1 @@ +Subproject commit e3589b192d0be27e100cd0daaf6c97204fdb1899 diff --git a/lib/v3-periphery b/lib/v3-periphery new file mode 160000 index 0000000..697c247 --- /dev/null +++ b/lib/v3-periphery @@ -0,0 +1 @@ +Subproject commit 697c2474757ea89fec12a4e6db16a574fe259610 diff --git a/src/interfaces/IUniswapV3FactoryOwnerActions.sol b/src/interfaces/IUniswapV3FactoryOwnerActions.sol index 756812f..c32ebd2 100644 --- a/src/interfaces/IUniswapV3FactoryOwnerActions.sol +++ b/src/interfaces/IUniswapV3FactoryOwnerActions.sol @@ -23,4 +23,12 @@ interface IUniswapV3FactoryOwnerActions { /// @param tickSpacing The spacing between ticks to be enforced for all pools created with the /// given fee amount function enableFeeAmount(uint24 fee, int24 tickSpacing) external; + + /// @notice Returns the tick spacing for a given fee amount, if enabled, or 0 if not enabled + /// @dev A fee amount can never be removed, so this value should be hard coded or cached in the + /// calling context + /// @param fee The enabled fee, denominated in hundredths of a bip. Returns 0 in case of unenabled + /// fee + /// @return The tick spacing + function feeAmountTickSpacing(uint24 fee) external view returns (int24); } diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index 0adf739..8317282 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.23; +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; import {Test, console2} from "forge-std/Test.sol"; import {Deploy} from "script/Deploy.s.sol"; import {DeployInput} from "script/DeployInput.sol"; @@ -10,6 +11,10 @@ import {UniStaker} from "src/UniStaker.sol"; import {ProposalTest} from "test/helpers/ProposalTest.sol"; import {IUniswapV3FactoryOwnerActions} from "src/interfaces/IUniswapV3FactoryOwnerActions.sol"; import {IUniswapPool} from "test/helpers/interfaces/IUniswapPool.sol"; +import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; +import {IUniswapPool} from "test/helpers/interfaces/IUniswapPool.sol"; +import {PercentAssertions} from "test/helpers/PercentAssertions.sol"; +import {IntegrationTest} from "test/helpers/IntegrationTest.sol"; contract DeployScriptTest is Test, DeployInput { function setUp() public { @@ -34,7 +39,7 @@ contract DeployScriptTest is Test, DeployInput { } } -contract Propose is ProposalTest { +contract Propose is IntegrationTest { function testFork_CorrectlyPassAndExecuteProposal() public { IUniswapV3FactoryOwnerActions factory = IUniswapV3FactoryOwnerActions(UNISWAP_V3_FACTORY_ADDRESS); @@ -68,4 +73,262 @@ contract Propose is ProposalTest { assertEq(newDaiWethFeeProtocol, 10 + (10 << 4)); assertEq(newDaiUsdcFeeProtocol, 10 + (10 << 4)); } + + function testForkFuzz_CorrectlyEnableFeeAmountAfterProposalIsExecuted( + uint24 _fee, + int24 _tickSpacing + ) public { + // These bounds are specified in the pool contract + // https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/contracts/UniswapV3Factory.sol#L61 + _fee = uint24(bound(_fee, 0, 999_999)); + _tickSpacing = int24(bound(_tickSpacing, 1, 16_383)); + + _setupProposals(); + + vm.prank(UNISWAP_GOVERNOR_TIMELOCK); + v3FactoryOwner.enableFeeAmount(_fee, _tickSpacing); + IUniswapV3FactoryOwnerActions factory = + IUniswapV3FactoryOwnerActions(UNISWAP_V3_FACTORY_ADDRESS); + + int24 tickSpacing = factory.feeAmountTickSpacing(_fee); + assertEq(tickSpacing, _tickSpacing, "Tick spacing is incorrect for the set fee"); + } + + function testForkFuzz_RevertIf_EnableFeeAmountFeeIsTooHighAfterProposalIsExecuted( + uint24 _fee, + int24 _tickSpacing + ) public { + _fee = uint24(bound(_fee, 1_000_000, type(uint24).max)); + + _setupProposals(); + + vm.prank(UNISWAP_GOVERNOR_TIMELOCK); + vm.expectRevert(bytes("")); + v3FactoryOwner.enableFeeAmount(_fee, _tickSpacing); + } + + function testForkFuzz_RevertIf_EnableFeeAmountTickSpacingIsTooHighAfterProposalIsExecuted( + uint24 _fee, + int24 _tickSpacing + ) public { + _tickSpacing = int24(bound(_tickSpacing, 16_383, type(int24).max)); + + _setupProposals(); + + vm.prank(UNISWAP_GOVERNOR_TIMELOCK); + vm.expectRevert(bytes("")); + v3FactoryOwner.enableFeeAmount(_fee, _tickSpacing); + } + + function testForkFuzz_RevertIf_EnableFeeAmountTickSpacingIsTooLowAfterProposalIsExecuted( + uint24 _fee, + int24 _tickSpacing + ) public { + _tickSpacing = int24(bound(_tickSpacing, type(int24).min, 0)); + + _setupProposals(); + + vm.prank(UNISWAP_GOVERNOR_TIMELOCK); + vm.expectRevert(bytes("")); + v3FactoryOwner.enableFeeAmount(_fee, _tickSpacing); + } + + function testForkFuzz_CorrectlySwapWethAndNotifyRewardAfterProposalIsExecuted(uint128 _amount) + public + { + IERC20 weth = IERC20(payable(WETH_ADDRESS)); + IUniswapPool daiWethPool = IUniswapPool(DAI_WETH_3000_POOL); + + // Amount should be high enough to generate fees + _amount = uint128(bound(_amount, 1e18, 1_000_000e18)); + uint256 totalWETH = _amount + PAYOUT_AMOUNT; + vm.deal(address(this), totalWETH); + deal(address(weth), address(this), totalWETH); + + weth.approve(address(UNISWAP_V3_SWAP_ROUTER), totalWETH); + weth.approve(address(v3FactoryOwner), totalWETH); + + _setupProposals(); + _swapTokens(WETH_ADDRESS, DAI_ADDRESS, _amount); + + (uint128 token0Fees, uint128 token1Fees) = daiWethPool.protocolFees(); + + // We subtract 1 to make sure the requested amount is less then the actual fees + v3FactoryOwner.claimFees( + IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), token0Fees, token1Fees - 1 + ); + + uint256 balance = IERC20(WETH_ADDRESS).balanceOf(address(this)); + assertEq(balance, uint256(token1Fees - 1)); + assertEq(0, token0Fees); + } + + function testForkFuzz_CorrectlySwapDaiAndNotifyRewardAfterProposalIsExecuted(uint128 _amount) + public + { + // Amount should be high enough to generate fees + IERC20 weth = IERC20(payable(WETH_ADDRESS)); + IERC20 dai = IERC20(payable(DAI_ADDRESS)); + IUniswapPool daiWethPool = IUniswapPool(DAI_WETH_3000_POOL); + + _amount = uint128(bound(_amount, 1e18, 1_000_000e18)); + uint256 totalDai = _amount; + + vm.deal(address(this), PAYOUT_AMOUNT); + deal(address(dai), address(this), totalDai, true); + deal(address(weth), address(this), PAYOUT_AMOUNT); + + dai.approve(address(UNISWAP_V3_SWAP_ROUTER), totalDai); + weth.approve(address(v3FactoryOwner), PAYOUT_AMOUNT); + + _setupProposals(); + _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amount); + + (uint128 token0Fees, uint128 token1Fees) = daiWethPool.protocolFees(); + // We subtract 1 to make sure the requested amount is less then the actual fees + v3FactoryOwner.claimFees( + IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), token0Fees - 1, token1Fees + ); + + uint256 balance = IERC20(DAI_ADDRESS).balanceOf(address(this)); + assertEq(0, token1Fees); + assertEq(balance, uint256(token0Fees - 1)); + } + + function testForkFuzz_CorrectlySwapDaiAndWETHThenNotifyRewardAfterProposalIsExecuted( + uint128 _amountDai, + uint128 _amountWeth + ) public { + IERC20 weth = IERC20(payable(WETH_ADDRESS)); + IERC20 dai = IERC20(payable(DAI_ADDRESS)); + IUniswapPool daiWethPool = IUniswapPool(DAI_WETH_3000_POOL); + + // Amount should be high enough to generate fees + _amountDai = uint128(bound(_amountDai, 1e18, 1_000_000e18)); + _amountWeth = uint128(bound(_amountWeth, 1e18, 1_000_000e18)); + uint256 totalDai = _amountDai; + uint256 totalWeth = _amountWeth + PAYOUT_AMOUNT; + + vm.deal(address(this), totalWeth); + deal(address(dai), address(this), totalDai, true); + deal(address(weth), address(this), totalWeth); + + dai.approve(address(UNISWAP_V3_SWAP_ROUTER), totalDai); + weth.approve(address(UNISWAP_V3_SWAP_ROUTER), totalWeth); + weth.approve(address(v3FactoryOwner), PAYOUT_AMOUNT); + + _setupProposals(); + _swapTokens(DAI_ADDRESS, WETH_ADDRESS, totalDai); + _swapTokens(WETH_ADDRESS, DAI_ADDRESS, _amountWeth); + + uint256 originalDaiBalance = IERC20(DAI_ADDRESS).balanceOf(address(this)); + uint256 originalWethBalance = IERC20(WETH_ADDRESS).balanceOf(address(this)) - PAYOUT_AMOUNT; + + (uint128 token0Fees, uint128 token1Fees) = daiWethPool.protocolFees(); + // We subtract 1 to make sure the requested amount is less then the actual fees + v3FactoryOwner.claimFees( + IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), token0Fees - 1, token1Fees - 1 + ); + + uint256 daiBalance = IERC20(DAI_ADDRESS).balanceOf(address(this)); + uint256 wethBalance = IERC20(WETH_ADDRESS).balanceOf(address(this)); + assertEq(wethBalance - originalWethBalance, token1Fees - 1); + assertEq(daiBalance - originalDaiBalance, uint256(token0Fees - 1)); + } +} + +contract Stake is IntegrationTest, PercentAssertions { + function testForkFuzz_CorrectlyStakeAndEarnRewardsAfterFullDuration( + address _depositor, + uint256 _amount, + address _delegatee, + uint128 _swapAmount + ) public { + vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); + _setupProposals(); + _notifyRewards(_swapAmount); + _amount = _dealStakingToken(_depositor, _amount); + + vm.prank(_depositor); + uniStaker.stake(_amount, _delegatee); + + _jumpAheadByPercentOfRewardDuration(101); + assertLteWithinOnePercent(uniStaker.unclaimedReward(address(_depositor)), PAYOUT_AMOUNT); + } + + function testForkFuzz_CorrectlyStakeAndClaimRewardsAfterFullDuration( + address _depositor, + uint256 _amount, + address _delegatee, + uint128 _swapAmount + ) public { + vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); + _setupProposals(); + _notifyRewards(_swapAmount); + _amount = _dealStakingToken(_depositor, _amount); + + vm.prank(_depositor); + uniStaker.stake(_amount, _delegatee); + + _jumpAheadByPercentOfRewardDuration(101); + IERC20 weth = IERC20(WETH_ADDRESS); + uint256 oldBalance = weth.balanceOf(_depositor); + + vm.prank(_depositor); + uniStaker.claimReward(); + + uint256 newBalance = weth.balanceOf(_depositor); + assertLteWithinOnePercent(newBalance - oldBalance, PAYOUT_AMOUNT); + assertEq(uniStaker.unclaimedReward(address(_depositor)), 0); + } + + function testForkFuzz_CorrectlyStakeAndEarnRewardsAfterPartialDuration( + address _depositor, + uint256 _amount, + address _delegatee, + uint128 _swapAmount, + uint256 _percentDuration + ) public { + vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); + _percentDuration = bound(_percentDuration, 0, 100); + _setupProposals(); + _notifyRewards(_swapAmount); + _amount = _dealStakingToken(_depositor, _amount); + + vm.prank(_depositor); + uniStaker.stake(_amount, _delegatee); + + _jumpAheadByPercentOfRewardDuration(100 - _percentDuration); + assertLteWithinOnePercent( + uniStaker.unclaimedReward(address(_depositor)), + _percentOf(PAYOUT_AMOUNT, 100 - _percentDuration) + ); + } + + function testForkFuzz_CorrectlyStakeMoreAndEarnRewardsAfterFullDuration( + address _depositor, + uint256 _initialAmount, + uint256 _additionalAmount, + address _delegatee, + uint128 _swapAmount, + uint256 _percentDuration + ) public { + vm.assume(_depositor != address(0) && _delegatee != address(0)); + _setupProposals(); + _notifyRewards(_swapAmount); + _initialAmount = _dealStakingToken(_depositor, _initialAmount); + _percentDuration = bound(_percentDuration, 0, 100); + + vm.prank(_depositor); + UniStaker.DepositIdentifier _depositId = uniStaker.stake(_initialAmount, _delegatee); + + _jumpAheadByPercentOfRewardDuration(100 - _percentDuration); + + _additionalAmount = _dealStakingToken(_depositor, _additionalAmount); + vm.prank(_depositor); + uniStaker.stakeMore(_depositId, _additionalAmount); + + _jumpAheadByPercentOfRewardDuration(_percentDuration); + assertLteWithinOnePercent(uniStaker.unclaimedReward(address(_depositor)), PAYOUT_AMOUNT); + } } diff --git a/test/UniStaker.t.sol b/test/UniStaker.t.sol index 51d79e9..89124f8 100644 --- a/test/UniStaker.t.sol +++ b/test/UniStaker.t.sol @@ -6,8 +6,9 @@ import {UniStaker, DelegationSurrogate, IERC20, IERC20Delegates} from "src/UniSt import {UniStakerHarness} from "test/harnesses/UniStakerHarness.sol"; import {ERC20VotesMock, ERC20Permit} from "test/mocks/MockERC20Votes.sol"; import {ERC20Fake} from "test/fakes/ERC20Fake.sol"; +import {PercentAssertions} from "test/helpers/PercentAssertions.sol"; -contract UniStakerTest is Test { +contract UniStakerTest is Test, PercentAssertions { ERC20Fake rewardToken; ERC20VotesMock govToken; address admin; @@ -2625,59 +2626,6 @@ contract SetAdmin is UniStakerTest { } contract UniStakerRewardsTest is UniStakerTest { - // Because there will be (expected) rounding errors in the amount of rewards earned, this helper - // checks that the truncated number is lesser and within 1% of the expected number. - function assertLteWithinOnePercent(uint256 a, uint256 b) public { - if (a > b) { - emit log("Error: a <= b not satisfied"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - - fail(); - } - - uint256 minBound = (b * 9900) / 10_000; - - if (a < minBound) { - emit log("Error: a >= 0.99 * b not satisfied"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - emit log_named_uint(" minBound", minBound); - - fail(); - } - } - - // This helper is for normal rounding errors, i.e. if the number might be truncated down by 1 - function assertLteWithinOneUnit(uint256 a, uint256 b) public { - if (a > b) { - emit log("Error: a <= b not satisfied"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - - fail(); - } - - uint256 minBound = b - 1; - - if (!((a == b) || (a == minBound))) { - emit log("Error: a == b || a == b-1"); - emit log_named_uint(" Expected", b); - emit log_named_uint(" Actual", a); - - fail(); - } - } - - function _percentOf(uint256 _amount, uint256 _percent) public pure returns (uint256) { - // For cases where the percentage is less than 100, we calculate the percentage by - // taking the inverse percentage and subtracting it. This effectively rounds _up_ the - // value by putting the truncation on the opposite side. For example, 92% of 555 is 510.6. - // Calculating it in this way would yield (555 - 44) = 511, instead of 510. - if (_percent < 100) return _amount - ((100 - _percent) * _amount) / 100; - else return (_percent * _amount) / 100; - } - // Helper methods for dumping contract state related to rewards calculation for debugging function __dumpDebugGlobalRewards() public view { console2.log("reward balance"); diff --git a/test/helpers/Constants.sol b/test/helpers/Constants.sol index f1ff366..b81bea1 100644 --- a/test/helpers/Constants.sol +++ b/test/helpers/Constants.sol @@ -1,9 +1,17 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.23; +import {ISwapRouter} from "v3-periphery/interfaces/ISwapRouter.sol"; + contract Constants { address constant UNISWAP_GOVERNOR_ADDRESS = 0x408ED6354d4973f66138C91495F2f2FCbd8724C3; address constant WBTC_WETH_3000_POOL = 0xCBCdF9626bC03E24f779434178A73a0B4bad62eD; address constant DAI_WETH_3000_POOL = 0xC2e9F25Be6257c210d7Adf0D4Cd6E3E881ba25f8; address constant DAI_USDC_100_POOL = 0x5777d92f208679DB4b9778590Fa3CAB3aC9e2168; + + address constant WETH_ADDRESS = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; // use deposit + address constant DAI_ADDRESS = 0x6B175474E89094C44Da98b954EedeAC495271d0F; // mint with auth + address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // mint only minters + ISwapRouter constant UNISWAP_V3_SWAP_ROUTER = + ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); } diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol new file mode 100644 index 0000000..4b6405b --- /dev/null +++ b/test/helpers/IntegrationTest.sol @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.23; + +import {console2} from "forge-std/Test.sol"; +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; +import {ISwapRouter} from "v3-periphery/interfaces/ISwapRouter.sol"; +import {ProposalTest} from "test/helpers/ProposalTest.sol"; + +contract IntegrationTest is ProposalTest { + function _setupProposals() internal { + _passQueueAndExecuteProposals(); + } + + function _swapTokens(address tokenIn, address tokenOut, uint256 _amount) internal { + ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ + tokenIn: tokenIn, + tokenOut: tokenOut, + fee: 3000, + recipient: address(this), + deadline: block.timestamp + 1000, + amountIn: _amount, + amountOutMinimum: 0, + sqrtPriceLimitX96: 0 + }); + + // The call to `exactInputSingle` executes the swap. + UNISWAP_V3_SWAP_ROUTER.exactInputSingle(params); + } + + function _setupPayoutToken() internal { + IERC20 weth = IERC20(payable(WETH_ADDRESS)); + weth.approve(address(v3FactoryOwner), PAYOUT_AMOUNT); + vm.deal(address(this), PAYOUT_AMOUNT); + deal(address(weth), address(this), PAYOUT_AMOUNT); + } + + function _setupSwapToken(uint128 _amountDai) internal returns (uint256) { + _amountDai = uint128(bound(_amountDai, 1e18, 1_000_000e18)); + IERC20 dai = IERC20(payable(DAI_ADDRESS)); + dai.approve(address(UNISWAP_V3_SWAP_ROUTER), _amountDai); + deal(address(dai), address(this), _amountDai, true); + return _amountDai; + } + + function _generateFees(uint128 _amount) internal { + uint256 _amountDai = _setupSwapToken(_amount); + _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amountDai); + } + + function _notifyRewards(uint128 _amount) internal { + _setupPayoutToken(); + _generateFees(_amount); + v3FactoryOwner.claimFees(IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), 1, 0); + } + + function _dealStakingToken(address _depositor, uint256 _amount) internal returns (uint256) { + _amount = bound(_amount, 1, 10_000_000_000e18); + deal(STAKE_TOKEN_ADDRESS, _depositor, _amount); + + vm.prank(_depositor); + IERC20(STAKE_TOKEN_ADDRESS).approve(address(uniStaker), _amount); + return _amount; + } + + function _jumpAhead(uint256 _seconds) public { + vm.warp(block.timestamp + _seconds); + } + + function _jumpAheadByPercentOfRewardDuration(uint256 _percent) public { + uint256 _seconds = (_percent * uniStaker.REWARD_DURATION()) / 100; + _jumpAhead(_seconds); + } + + function _boundToRealisticReward(uint256 _rewardAmount) + public + pure + returns (uint256 _boundedRewardAmount) + { + _boundedRewardAmount = bound(_rewardAmount, 200e6, 10_000_000e18); + } + + function _boundToRealisticStakeAndReward(uint256 _stakeAmount, uint256 _rewardAmount) + public + pure + returns (uint256 _boundedStakeAmount, uint256 _boundedRewardAmount) + { + _boundedStakeAmount = _boundToRealisticStake(_stakeAmount); + _boundedRewardAmount = _boundToRealisticReward(_rewardAmount); + } + + function _mintTransferAndNotifyReward(uint256 _amount) public { + deal(address(rewardToken), rewardNotifier, _amount); + + vm.startPrank(rewardNotifier); + rewardToken.transfer(address(uniStaker), _amount); + uniStaker.notifyRewardAmount(_amount); + vm.stopPrank(); + } + + function _mintTransferAndNotifyReward(address _rewardNotifier, uint256 _amount) public { + vm.assume(_rewardNotifier != address(0)); + deal(address(rewardToken), rewardNotifier, _amount); + + vm.startPrank(_rewardNotifier); + rewardToken.transfer(address(uniStaker), _amount); + uniStaker.notifyRewardAmount(_amount); + vm.stopPrank(); + } + + function _boundToRealisticStake(uint256 _stakeAmount) + public + pure + returns (uint256 _boundedStakeAmount) + { + _boundedStakeAmount = bound(_stakeAmount, 0.1e18, 25_000_000e18); + } + + // Helper methods for dumping contract state related to rewards calculation for debugging + function __dumpDebugGlobalRewards() public view { + console2.log("reward balance"); + console2.log(rewardToken.balanceOf(address(uniStaker))); + console2.log("rewardDuration"); + console2.log(uniStaker.REWARD_DURATION()); + console2.log("rewardEndTime"); + console2.log(uniStaker.rewardEndTime()); + console2.log("lastCheckpointTime"); + console2.log(uniStaker.lastCheckpointTime()); + console2.log("totalStake"); + console2.log(uniStaker.totalStaked()); + console2.log("scaledRewardRate"); + console2.log(uniStaker.scaledRewardRate()); + console2.log("block.timestamp"); + console2.log(block.timestamp); + console2.log("rewardPerTokenAccumulatedCheckpoint"); + console2.log(uniStaker.rewardPerTokenAccumulatedCheckpoint()); + console2.log("lastTimeRewardDistributed()"); + console2.log(uniStaker.lastTimeRewardDistributed()); + console2.log("rewardPerTokenAccumulated()"); + console2.log(uniStaker.rewardPerTokenAccumulated()); + console2.log("-----------------------------------------------"); + } + + function __dumpDebugDepositorRewards(address _depositor) public view { + console2.log("earningPower[_depositor]"); + console2.log(uniStaker.earningPower(_depositor)); + console2.log("beneficiaryRewardPerTokenCheckpoint[_depositor]"); + console2.log(uniStaker.beneficiaryRewardPerTokenCheckpoint(_depositor)); + console2.log("unclaimedRewardCheckpoint[_depositor]"); + console2.log(uniStaker.unclaimedRewardCheckpoint(_depositor)); + console2.log("unclaimedReward(_depositor)"); + console2.log(uniStaker.unclaimedReward(_depositor)); + console2.log("-----------------------------------------------"); + } +} diff --git a/test/helpers/PercentAssertions.sol b/test/helpers/PercentAssertions.sol new file mode 100644 index 0000000..8b40b36 --- /dev/null +++ b/test/helpers/PercentAssertions.sol @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.23; + +import {Test} from "forge-std/Test.sol"; + +contract PercentAssertions is Test { + // Because there will be (expected) rounding errors in the amount of rewards earned, this helper + // checks that the truncated number is lesser and within 1% of the expected number. + function assertLteWithinOnePercent(uint256 a, uint256 b) public { + if (a > b) { + emit log("Error: a <= b not satisfied"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + + fail(); + } + + uint256 minBound = (b * 9900) / 10_000; + + if (a < minBound) { + emit log("Error: a >= 0.99 * b not satisfied"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + emit log_named_uint(" minBound", minBound); + + fail(); + } + } + + function _percentOf(uint256 _amount, uint256 _percent) public pure returns (uint256) { + // For cases where the percentage is less than 100, we calculate the percentage by + // taking the inverse percentage and subtracting it. This effectively rounds _up_ the + // value by putting the truncation on the opposite side. For example, 92% of 555 is 510.6. + // Calculating it in this way would yield (555 - 44) = 511, instead of 510. + if (_percent < 100) return _amount - ((100 - _percent) * _amount) / 100; + else return (_percent * _amount) / 100; + } + + // This helper is for normal rounding errors, i.e. if the number might be truncated down by 1 + function assertLteWithinOneUnit(uint256 a, uint256 b) public { + if (a > b) { + emit log("Error: a <= b not satisfied"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + + fail(); + } + + uint256 minBound = b - 1; + + if (!((a == b) || (a == minBound))) { + emit log("Error: a == b || a == b-1"); + emit log_named_uint(" Expected", b); + emit log_named_uint(" Actual", a); + + fail(); + } + } +} diff --git a/test/helpers/ProposalTest.sol b/test/helpers/ProposalTest.sol index 811761c..0f76f27 100644 --- a/test/helpers/ProposalTest.sol +++ b/test/helpers/ProposalTest.sol @@ -2,6 +2,7 @@ pragma solidity ^0.8.23; import {Test} from "forge-std/Test.sol"; +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; import {Deploy} from "script/Deploy.s.sol"; import {DeployInput} from "script/DeployInput.sol"; @@ -20,6 +21,8 @@ abstract contract ProposalTest is Test, DeployInput, Constants { UniStaker uniStaker; V3FactoryOwner v3FactoryOwner; GovernorBravoDelegate governor = GovernorBravoDelegate(UNISWAP_GOVERNOR_ADDRESS); + address rewardNotifier; + IERC20 rewardToken = IERC20(PAYOUT_TOKEN_ADDRESS); enum VoteType { Against, diff --git a/test/helpers/interfaces/IUniswapPool.sol b/test/helpers/interfaces/IUniswapPool.sol index 26e863b..26fbfd5 100644 --- a/test/helpers/interfaces/IUniswapPool.sol +++ b/test/helpers/interfaces/IUniswapPool.sol @@ -1,7 +1,9 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity ^0.8.23; -interface IUniswapPool { +import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; + +interface IUniswapPool is IUniswapV3PoolOwnerActions { function slot0() external view @@ -14,4 +16,33 @@ interface IUniswapPool { uint8 feeProtocol, bool unlocked ); + + /// @notice Swap token0 for token1, or token1 for token0 + /// @dev The caller of this method receives a callback in the form of + /// IUniswapV3SwapCallback#uniswapV3SwapCallback + /// @param recipient The address to receive the output of the swap + /// @param zeroForOne The direction of the swap, true for token0 to token1, false for token1 to + /// token0 + /// @param amountSpecified The amount of the swap, which implicitly configures the swap as exact + /// input (positive), or exact output (negative) + /// @param sqrtPriceLimitX96 The Q64.96 sqrt price limit. If zero for one, the price cannot be + /// less than this + /// value after the swap. If one for zero, the price cannot be greater than this value after the + /// swap + /// @param data Any data to be passed through to the callback + /// @return amount0 The delta of the balance of token0 of the pool, exact when negative, minimum + /// when positive + /// @return amount1 The delta of the balance of token1 of the pool, exact when negative, minimum + /// when positive + function swap( + address recipient, + bool zeroForOne, + int256 amountSpecified, + uint160 sqrtPriceLimitX96, + bytes calldata data + ) external returns (int256 amount0, int256 amount1); + + /// @notice The amounts of token0 and token1 that are owed to the protocol + /// @dev Protocol fees will never exceed uint128 max in either token + function protocolFees() external view returns (uint128 token0, uint128 token1); } diff --git a/test/mocks/MockUniswapV3Factory.sol b/test/mocks/MockUniswapV3Factory.sol index a456b00..696295d 100644 --- a/test/mocks/MockUniswapV3Factory.sol +++ b/test/mocks/MockUniswapV3Factory.sol @@ -9,6 +9,10 @@ contract MockUniswapV3Factory is IUniswapV3FactoryOwnerActions { uint24 public lastParam__enableFeeAmount_fee; int24 public lastParam__enableFeeAmount_tickSpacing; + function feeAmountTickSpacing(uint24) external view returns (int24) { + return lastParam__enableFeeAmount_tickSpacing; + } + function owner() external view returns (address) { return lastParam__setOwner_owner; }