From 99a6547d4167b999fae9159d91a9d6db3efeded7 Mon Sep 17 00:00:00 2001 From: keating Date: Fri, 2 Feb 2024 16:16:29 -0500 Subject: [PATCH 01/16] 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 6bf743b..9632ac7 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; } From ae8b4ecd8018b434d984adce54280f3569394faf Mon Sep 17 00:00:00 2001 From: Keating Date: Thu, 15 Feb 2024 12:27:59 -0500 Subject: [PATCH 02/16] Change lines --- test/UniStaker.integration.t.sol | 36 ++++++++++++++++---------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index 9632ac7..f747a7a 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -8,9 +8,7 @@ import {DeployInput} from "script/DeployInput.sol"; import {V3FactoryOwner} from "src/V3FactoryOwner.sol"; 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"; @@ -83,15 +81,17 @@ contract Propose is IntegrationTest { _fee = uint24(bound(_fee, 0, 999_999)); _tickSpacing = int24(bound(_tickSpacing, 1, 16_383)); - _setupProposals(); + _passQueueAndExecuteProposals(); + IUniswapV3FactoryOwnerActions factory = + IUniswapV3FactoryOwnerActions(UNISWAP_V3_FACTORY_ADDRESS); + int24 oldTickSpacing = factory.feeAmountTickSpacing(_fee); 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"); + int24 newTickSpacing = factory.feeAmountTickSpacing(_fee); + assertEq(oldTickSpacing, 0, "Original tick spacing is incorrect for the set fee"); + assertEq(newTickSpacing, _tickSpacing, "Tick spacing is incorrect for the set fee"); } function testForkFuzz_RevertIf_EnableFeeAmountFeeIsTooHighAfterProposalIsExecuted( @@ -100,7 +100,7 @@ contract Propose is IntegrationTest { ) public { _fee = uint24(bound(_fee, 1_000_000, type(uint24).max)); - _setupProposals(); + _passQueueAndExecuteProposals(); vm.prank(UNISWAP_GOVERNOR_TIMELOCK); vm.expectRevert(bytes("")); @@ -113,7 +113,7 @@ contract Propose is IntegrationTest { ) public { _tickSpacing = int24(bound(_tickSpacing, 16_383, type(int24).max)); - _setupProposals(); + _passQueueAndExecuteProposals(); vm.prank(UNISWAP_GOVERNOR_TIMELOCK); vm.expectRevert(bytes("")); @@ -126,7 +126,7 @@ contract Propose is IntegrationTest { ) public { _tickSpacing = int24(bound(_tickSpacing, type(int24).min, 0)); - _setupProposals(); + _passQueueAndExecuteProposals(); vm.prank(UNISWAP_GOVERNOR_TIMELOCK); vm.expectRevert(bytes("")); @@ -148,7 +148,7 @@ contract Propose is IntegrationTest { weth.approve(address(UNISWAP_V3_SWAP_ROUTER), totalWETH); weth.approve(address(v3FactoryOwner), totalWETH); - _setupProposals(); + _passQueueAndExecuteProposals(); _swapTokens(WETH_ADDRESS, DAI_ADDRESS, _amount); (uint128 token0Fees, uint128 token1Fees) = daiWethPool.protocolFees(); @@ -166,11 +166,11 @@ contract Propose is IntegrationTest { 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 should be high enough to generate fees _amount = uint128(bound(_amount, 1e18, 1_000_000e18)); uint256 totalDai = _amount; @@ -181,7 +181,7 @@ contract Propose is IntegrationTest { dai.approve(address(UNISWAP_V3_SWAP_ROUTER), totalDai); weth.approve(address(v3FactoryOwner), PAYOUT_AMOUNT); - _setupProposals(); + _passQueueAndExecuteProposals(); _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amount); (uint128 token0Fees, uint128 token1Fees) = daiWethPool.protocolFees(); @@ -217,7 +217,7 @@ contract Propose is IntegrationTest { weth.approve(address(UNISWAP_V3_SWAP_ROUTER), totalWeth); weth.approve(address(v3FactoryOwner), PAYOUT_AMOUNT); - _setupProposals(); + _passQueueAndExecuteProposals(); _swapTokens(DAI_ADDRESS, WETH_ADDRESS, totalDai); _swapTokens(WETH_ADDRESS, DAI_ADDRESS, _amountWeth); @@ -245,7 +245,7 @@ contract Stake is IntegrationTest, PercentAssertions { uint128 _swapAmount ) public { vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); - _setupProposals(); + _passQueueAndExecuteProposals(); _notifyRewards(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); @@ -263,7 +263,7 @@ contract Stake is IntegrationTest, PercentAssertions { uint128 _swapAmount ) public { vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); - _setupProposals(); + _passQueueAndExecuteProposals(); _notifyRewards(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); @@ -291,7 +291,7 @@ contract Stake is IntegrationTest, PercentAssertions { ) public { vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); _percentDuration = bound(_percentDuration, 0, 100); - _setupProposals(); + _passQueueAndExecuteProposals(); _notifyRewards(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); @@ -314,7 +314,7 @@ contract Stake is IntegrationTest, PercentAssertions { uint256 _percentDuration ) public { vm.assume(_depositor != address(0) && _delegatee != address(0)); - _setupProposals(); + _passQueueAndExecuteProposals(); _notifyRewards(_swapAmount); _initialAmount = _dealStakingToken(_depositor, _initialAmount); _percentDuration = bound(_percentDuration, 0, 100); From 0e234c75acce296a12d07d51c7d9b1c2a590c901 Mon Sep 17 00:00:00 2001 From: Keating Date: Thu, 15 Feb 2024 13:05:04 -0500 Subject: [PATCH 03/16] Remove unnecessary utils --- test/helpers/IntegrationTest.sol | 85 -------------------------------- 1 file changed, 85 deletions(-) diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index 4b6405b..bc5bd93 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -8,10 +8,6 @@ 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, @@ -71,85 +67,4 @@ contract IntegrationTest is ProposalTest { 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("-----------------------------------------------"); - } } From aee6bae68c2ef9203efbde9760ce34b59e56995a Mon Sep 17 00:00:00 2001 From: Keating Date: Fri, 16 Feb 2024 10:39:55 -0500 Subject: [PATCH 04/16] Fix tests --- test/UniStaker.integration.t.sol | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index f747a7a..ad012dc 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -81,16 +81,17 @@ contract Propose is IntegrationTest { _fee = uint24(bound(_fee, 0, 999_999)); _tickSpacing = int24(bound(_tickSpacing, 1, 16_383)); - _passQueueAndExecuteProposals(); IUniswapV3FactoryOwnerActions factory = IUniswapV3FactoryOwnerActions(UNISWAP_V3_FACTORY_ADDRESS); int24 oldTickSpacing = factory.feeAmountTickSpacing(_fee); + // If the tick spacing is above 0 it will revert + vm.assume(oldTickSpacing == 0); + _passQueueAndExecuteProposals(); vm.prank(UNISWAP_GOVERNOR_TIMELOCK); v3FactoryOwner.enableFeeAmount(_fee, _tickSpacing); int24 newTickSpacing = factory.feeAmountTickSpacing(_fee); - assertEq(oldTickSpacing, 0, "Original tick spacing is incorrect for the set fee"); assertEq(newTickSpacing, _tickSpacing, "Tick spacing is incorrect for the set fee"); } @@ -263,6 +264,8 @@ contract Stake is IntegrationTest, PercentAssertions { uint128 _swapAmount ) public { vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); + // Make sure depositor is not UniStaker + vm.assume(_depositor != 0xE2307e3710d108ceC7a4722a020a050681c835b3); _passQueueAndExecuteProposals(); _notifyRewards(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); From 904b37a6c29011567c1d7dce528f76362dd8050c Mon Sep 17 00:00:00 2001 From: keating Date: Mon, 19 Feb 2024 16:32:52 -0500 Subject: [PATCH 05/16] Mint for real --- test/helpers/Constants.sol | 1 + test/helpers/IntegrationTest.sol | 7 +++++-- test/helpers/interfaces/IERC20Mint.sol | 6 ++++++ 3 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 test/helpers/interfaces/IERC20Mint.sol diff --git a/test/helpers/Constants.sol b/test/helpers/Constants.sol index b81bea1..1b5a22a 100644 --- a/test/helpers/Constants.sol +++ b/test/helpers/Constants.sol @@ -14,4 +14,5 @@ contract Constants { address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; // mint only minters ISwapRouter constant UNISWAP_V3_SWAP_ROUTER = ISwapRouter(0xE592427A0AEce92De3Edee1F18E0157C05861564); + address constant STAKING_TOKEN_MINTER = 0x1a9C8182C09F50C8318d769245beA52c32BE35BC; } diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index bc5bd93..19c8feb 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -6,6 +6,7 @@ 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"; +import {IERC20Mint} from "test/helpers/interfaces/IERC20Mint.sol"; contract IntegrationTest is ProposalTest { function _swapTokens(address tokenIn, address tokenOut, uint256 _amount) internal { @@ -51,8 +52,10 @@ contract IntegrationTest is ProposalTest { } function _dealStakingToken(address _depositor, uint256 _amount) internal returns (uint256) { - _amount = bound(_amount, 1, 10_000_000_000e18); - deal(STAKE_TOKEN_ADDRESS, _depositor, _amount); + _amount = bound(_amount, 1, 2e25); // max mint cap + IERC20Mint stakeToken = IERC20Mint(STAKE_TOKEN_ADDRESS); + vm.prank(STAKING_TOKEN_MINTER); + stakeToken.mint(_depositor, _amount); vm.prank(_depositor); IERC20(STAKE_TOKEN_ADDRESS).approve(address(uniStaker), _amount); diff --git a/test/helpers/interfaces/IERC20Mint.sol b/test/helpers/interfaces/IERC20Mint.sol new file mode 100644 index 0000000..efada59 --- /dev/null +++ b/test/helpers/interfaces/IERC20Mint.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +interface IERC20Mint { + function mint(address dst, uint256 rawAmount) external; +} From 33abd224a414e0a532ad6fad0a9db750d5c0b7cd Mon Sep 17 00:00:00 2001 From: keating Date: Mon, 19 Feb 2024 16:37:23 -0500 Subject: [PATCH 06/16] Add fuzz seed --- .github/workflows/ci.yml | 14 ++++++++++++++ foundry.toml | 1 + 2 files changed, 15 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6c0a19..e7d77ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,13 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + # https://twitter.com/PaulRBerg/status/1611116650664796166 + - name: Generate fuzz seed with 1 week TTL + run: > + echo "FOUNDRY_FUZZ_SEED=$( + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) + )" >> $GITHUB_ENV + - name: Run tests run: forge test @@ -44,6 +51,13 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + # https://twitter.com/PaulRBerg/status/1611116650664796166 + - name: Generate fuzz seed with 1 week TTL + run: > + echo "FOUNDRY_FUZZ_SEED=$( + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) + )" >> $GITHUB_ENV + - name: Run coverage run: forge coverage --report summary --report lcov diff --git a/foundry.toml b/foundry.toml index 97b7b1d..1b4bc0d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,5 +1,6 @@ [profile.default] evm_version = "paris" + fuzz = { seed = "1" } optimizer = true optimizer_runs = 10_000_000 remappings = [ From d44fabbc00dd5faf753207582ddf7e877d5a83c7 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 09:45:26 -0500 Subject: [PATCH 07/16] Fix test? --- test/UniStaker.integration.t.sol | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index ad012dc..e2d4be1 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -317,17 +317,22 @@ contract Stake is IntegrationTest, PercentAssertions { uint256 _percentDuration ) public { vm.assume(_depositor != address(0) && _delegatee != address(0)); + + _additionalAmount = _dealStakingToken(_depositor, _additionalAmount); + vm.warp(block.timestamp + 1 days * 365); + _initialAmount = _dealStakingToken(_depositor, _initialAmount); + _passQueueAndExecuteProposals(); _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); + IERC20(STAKE_TOKEN_ADDRESS).approve(address(uniStaker), _additionalAmount); + vm.prank(_depositor); uniStaker.stakeMore(_depositId, _additionalAmount); From 3fa7bf213b5eeffe6ccb09d42c688c24289f1519 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 15:06:16 -0500 Subject: [PATCH 08/16] Lower the minting allowed after cap --- test/UniStaker.integration.t.sol | 11 +++-------- test/helpers/IntegrationTest.sol | 6 +++++- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index e2d4be1..ad012dc 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -317,22 +317,17 @@ contract Stake is IntegrationTest, PercentAssertions { uint256 _percentDuration ) public { vm.assume(_depositor != address(0) && _delegatee != address(0)); - - _additionalAmount = _dealStakingToken(_depositor, _additionalAmount); - vm.warp(block.timestamp + 1 days * 365); - _initialAmount = _dealStakingToken(_depositor, _initialAmount); - _passQueueAndExecuteProposals(); _notifyRewards(_swapAmount); + _initialAmount = _dealStakingToken(_depositor, _initialAmount); _percentDuration = bound(_percentDuration, 0, 100); + vm.prank(_depositor); UniStaker.DepositIdentifier _depositId = uniStaker.stake(_initialAmount, _delegatee); _jumpAheadByPercentOfRewardDuration(100 - _percentDuration); - vm.prank(_depositor); - IERC20(STAKE_TOKEN_ADDRESS).approve(address(uniStaker), _additionalAmount); - + _additionalAmount = _dealStakingToken(_depositor, _additionalAmount); vm.prank(_depositor); uniStaker.stakeMore(_depositId, _additionalAmount); diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index 19c8feb..ae75ea5 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.23; -import {console2} from "forge-std/Test.sol"; +import {console2, stdStorage, StdStorage} 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"; @@ -9,6 +9,8 @@ import {ProposalTest} from "test/helpers/ProposalTest.sol"; import {IERC20Mint} from "test/helpers/interfaces/IERC20Mint.sol"; contract IntegrationTest is ProposalTest { + using stdStorage for StdStorage; + function _swapTokens(address tokenIn, address tokenOut, uint256 _amount) internal { ISwapRouter.ExactInputSingleParams memory params = ISwapRouter.ExactInputSingleParams({ tokenIn: tokenIn, @@ -52,8 +54,10 @@ contract IntegrationTest is ProposalTest { } function _dealStakingToken(address _depositor, uint256 _amount) internal returns (uint256) { + stdstore.target(STAKE_TOKEN_ADDRESS).sig("mintingAllowedAfter()").checked_write(uint256(0)); _amount = bound(_amount, 1, 2e25); // max mint cap IERC20Mint stakeToken = IERC20Mint(STAKE_TOKEN_ADDRESS); + vm.prank(STAKING_TOKEN_MINTER); stakeToken.mint(_depositor, _amount); From 76d5f218c33d3f7472249be94e55b34e1fb32f10 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 15:08:07 -0500 Subject: [PATCH 09/16] Rename _setupPayouttoken --- test/helpers/IntegrationTest.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index ae75ea5..0bb9fab 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -27,7 +27,7 @@ contract IntegrationTest is ProposalTest { UNISWAP_V3_SWAP_ROUTER.exactInputSingle(params); } - function _setupPayoutToken() internal { + function _dealPayoutTokenAndApproveFactoryOwner() internal { IERC20 weth = IERC20(payable(WETH_ADDRESS)); weth.approve(address(v3FactoryOwner), PAYOUT_AMOUNT); vm.deal(address(this), PAYOUT_AMOUNT); @@ -48,7 +48,7 @@ contract IntegrationTest is ProposalTest { } function _notifyRewards(uint128 _amount) internal { - _setupPayoutToken(); + _dealPayoutTokenAndApproveFactoryOwner(); _generateFees(_amount); v3FactoryOwner.claimFees(IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), 1, 0); } From 0aa3c8da4130c62c011f9676ccf8eec39993a452 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 15:08:49 -0500 Subject: [PATCH 10/16] Rename setupSwapToken --- test/helpers/IntegrationTest.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index 0bb9fab..b6242d5 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -34,7 +34,7 @@ contract IntegrationTest is ProposalTest { deal(address(weth), address(this), PAYOUT_AMOUNT); } - function _setupSwapToken(uint128 _amountDai) internal returns (uint256) { + function _dealDaiAndApproveRouter(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); @@ -43,7 +43,7 @@ contract IntegrationTest is ProposalTest { } function _generateFees(uint128 _amount) internal { - uint256 _amountDai = _setupSwapToken(_amount); + uint256 _amountDai = _dealDaiAndApproveRouter(_amount); _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amountDai); } From 1082a961212aa901188d8bad84aa41edaee7b7cc Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 15:09:51 -0500 Subject: [PATCH 11/16] Rename _generateFees --- test/helpers/IntegrationTest.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index b6242d5..a01d61a 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -42,14 +42,14 @@ contract IntegrationTest is ProposalTest { return _amountDai; } - function _generateFees(uint128 _amount) internal { + function _generateFeesWithSwap(uint128 _amount) internal { uint256 _amountDai = _dealDaiAndApproveRouter(_amount); _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amountDai); } function _notifyRewards(uint128 _amount) internal { _dealPayoutTokenAndApproveFactoryOwner(); - _generateFees(_amount); + _generateFeesWithSwap(_amount); v3FactoryOwner.claimFees(IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), 1, 0); } From 087091257aa598ba26ce0535f4a065c6bb3394db Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 15:11:08 -0500 Subject: [PATCH 12/16] Rename _notifyRewards helper --- test/UniStaker.integration.t.sol | 8 ++++---- test/helpers/IntegrationTest.sol | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index ad012dc..50771b7 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -247,7 +247,7 @@ contract Stake is IntegrationTest, PercentAssertions { ) public { vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); _passQueueAndExecuteProposals(); - _notifyRewards(_swapAmount); + _swapAndClaimFees(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); vm.prank(_depositor); @@ -267,7 +267,7 @@ contract Stake is IntegrationTest, PercentAssertions { // Make sure depositor is not UniStaker vm.assume(_depositor != 0xE2307e3710d108ceC7a4722a020a050681c835b3); _passQueueAndExecuteProposals(); - _notifyRewards(_swapAmount); + _swapAndClaimFees(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); vm.prank(_depositor); @@ -295,7 +295,7 @@ contract Stake is IntegrationTest, PercentAssertions { vm.assume(_depositor != address(0) && _delegatee != address(0) && _amount != 0); _percentDuration = bound(_percentDuration, 0, 100); _passQueueAndExecuteProposals(); - _notifyRewards(_swapAmount); + _swapAndClaimFees(_swapAmount); _amount = _dealStakingToken(_depositor, _amount); vm.prank(_depositor); @@ -318,7 +318,7 @@ contract Stake is IntegrationTest, PercentAssertions { ) public { vm.assume(_depositor != address(0) && _delegatee != address(0)); _passQueueAndExecuteProposals(); - _notifyRewards(_swapAmount); + _swapAndClaimFees(_swapAmount); _initialAmount = _dealStakingToken(_depositor, _initialAmount); _percentDuration = bound(_percentDuration, 0, 100); diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol index a01d61a..abc0d07 100644 --- a/test/helpers/IntegrationTest.sol +++ b/test/helpers/IntegrationTest.sol @@ -47,7 +47,7 @@ contract IntegrationTest is ProposalTest { _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amountDai); } - function _notifyRewards(uint128 _amount) internal { + function _swapAndClaimFees(uint128 _amount) internal { _dealPayoutTokenAndApproveFactoryOwner(); _generateFeesWithSwap(_amount); v3FactoryOwner.claimFees(IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), 1, 0); From 9c16b867890bbbd4168949711fa7df962f490f40 Mon Sep 17 00:00:00 2001 From: keating Date: Tue, 20 Feb 2024 15:12:17 -0500 Subject: [PATCH 13/16] Reorder jumps --- test/UniStaker.integration.t.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/UniStaker.integration.t.sol b/test/UniStaker.integration.t.sol index 50771b7..4c0cee8 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -325,13 +325,13 @@ contract Stake is IntegrationTest, PercentAssertions { vm.prank(_depositor); UniStaker.DepositIdentifier _depositId = uniStaker.stake(_initialAmount, _delegatee); - _jumpAheadByPercentOfRewardDuration(100 - _percentDuration); + _jumpAheadByPercentOfRewardDuration(_percentDuration); _additionalAmount = _dealStakingToken(_depositor, _additionalAmount); vm.prank(_depositor); uniStaker.stakeMore(_depositId, _additionalAmount); - _jumpAheadByPercentOfRewardDuration(_percentDuration); + _jumpAheadByPercentOfRewardDuration(100 - _percentDuration); assertLteWithinOnePercent(uniStaker.unclaimedReward(address(_depositor)), PAYOUT_AMOUNT); } } From 535081ee5f8477cf8a2e2508a748022a4d7b0588 Mon Sep 17 00:00:00 2001 From: keating Date: Wed, 21 Feb 2024 10:56:50 -0500 Subject: [PATCH 14/16] Rename ci step --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7d77ed..4edf330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 # https://twitter.com/PaulRBerg/status/1611116650664796166 - - name: Generate fuzz seed with 1 week TTL + - name: Recycle the fuzz seed from the test run run: > echo "FOUNDRY_FUZZ_SEED=$( echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) From ba83d8ee65521a0cba3f06bb12b1aaaf87b9d1cc Mon Sep 17 00:00:00 2001 From: Keating Date: Thu, 22 Feb 2024 11:20:49 -0500 Subject: [PATCH 15/16] Make changes based on feedback --- .github/workflows/ci.yml | 6 ++++-- foundry.toml | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4edf330..0f94163 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Generate fuzz seed with 1 week TTL run: > echo "FOUNDRY_FUZZ_SEED=$( - echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400)) )" >> $GITHUB_ENV - name: Run tests @@ -55,7 +55,7 @@ jobs: - name: Recycle the fuzz seed from the test run run: > echo "FOUNDRY_FUZZ_SEED=$( - echo $(($EPOCHSECONDS - $EPOCHSECONDS % 604800)) + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400)) )" >> $GITHUB_ENV - name: Run coverage @@ -99,6 +99,8 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 + with: + cache: false - name: Install scopelint uses: engineerd/configurator@v0.0.8 diff --git a/foundry.toml b/foundry.toml index 1b4bc0d..97b7b1d 100644 --- a/foundry.toml +++ b/foundry.toml @@ -1,6 +1,5 @@ [profile.default] evm_version = "paris" - fuzz = { seed = "1" } optimizer = true optimizer_runs = 10_000_000 remappings = [ From e75e451a99fef7d92023b079fe52a23890679b6c Mon Sep 17 00:00:00 2001 From: Keating Date: Thu, 22 Feb 2024 11:31:47 -0500 Subject: [PATCH 16/16] Rename step --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f94163..823bfd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 # https://twitter.com/PaulRBerg/status/1611116650664796166 - - name: Generate fuzz seed with 1 week TTL + - name: Generate fuzz seed with 1 day TTL run: > echo "FOUNDRY_FUZZ_SEED=$( echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400)) @@ -52,7 +52,7 @@ jobs: uses: foundry-rs/foundry-toolchain@v1 # https://twitter.com/PaulRBerg/status/1611116650664796166 - - name: Recycle the fuzz seed from the test run + - name: Generate fuzz seed with 1 day TTL run: > echo "FOUNDRY_FUZZ_SEED=$( echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400))