diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6c0a19..823bfd7 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 day TTL + run: > + echo "FOUNDRY_FUZZ_SEED=$( + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400)) + )" >> $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 day TTL + run: > + echo "FOUNDRY_FUZZ_SEED=$( + echo $(($EPOCHSECONDS - $EPOCHSECONDS % 86400)) + )" >> $GITHUB_ENV + - name: Run coverage run: forge coverage --report summary --report lcov @@ -85,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/.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..4c0cee8 100644 --- a/test/UniStaker.integration.t.sol +++ b/test/UniStaker.integration.t.sol @@ -1,15 +1,18 @@ // 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"; 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 {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 +37,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 +71,267 @@ 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)); + + 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(newTickSpacing, _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)); + + _passQueueAndExecuteProposals(); + + 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)); + + _passQueueAndExecuteProposals(); + + 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)); + + _passQueueAndExecuteProposals(); + + 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); + + _passQueueAndExecuteProposals(); + _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 + { + 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; + + 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); + + _passQueueAndExecuteProposals(); + _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); + + _passQueueAndExecuteProposals(); + _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); + _passQueueAndExecuteProposals(); + _swapAndClaimFees(_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); + // Make sure depositor is not UniStaker + vm.assume(_depositor != 0xE2307e3710d108ceC7a4722a020a050681c835b3); + _passQueueAndExecuteProposals(); + _swapAndClaimFees(_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); + _passQueueAndExecuteProposals(); + _swapAndClaimFees(_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)); + _passQueueAndExecuteProposals(); + _swapAndClaimFees(_swapAmount); + _initialAmount = _dealStakingToken(_depositor, _initialAmount); + _percentDuration = bound(_percentDuration, 0, 100); + + vm.prank(_depositor); + UniStaker.DepositIdentifier _depositId = uniStaker.stake(_initialAmount, _delegatee); + + _jumpAheadByPercentOfRewardDuration(_percentDuration); + + _additionalAmount = _dealStakingToken(_depositor, _additionalAmount); + vm.prank(_depositor); + uniStaker.stakeMore(_depositId, _additionalAmount); + + _jumpAheadByPercentOfRewardDuration(100 - _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..1b5a22a 100644 --- a/test/helpers/Constants.sol +++ b/test/helpers/Constants.sol @@ -1,9 +1,18 @@ // 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); + address constant STAKING_TOKEN_MINTER = 0x1a9C8182C09F50C8318d769245beA52c32BE35BC; } diff --git a/test/helpers/IntegrationTest.sol b/test/helpers/IntegrationTest.sol new file mode 100644 index 0000000..abc0d07 --- /dev/null +++ b/test/helpers/IntegrationTest.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.23; + +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"; +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, + 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 _dealPayoutTokenAndApproveFactoryOwner() 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 _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); + deal(address(dai), address(this), _amountDai, true); + return _amountDai; + } + + function _generateFeesWithSwap(uint128 _amount) internal { + uint256 _amountDai = _dealDaiAndApproveRouter(_amount); + _swapTokens(DAI_ADDRESS, WETH_ADDRESS, _amountDai); + } + + function _swapAndClaimFees(uint128 _amount) internal { + _dealPayoutTokenAndApproveFactoryOwner(); + _generateFeesWithSwap(_amount); + v3FactoryOwner.claimFees(IUniswapV3PoolOwnerActions(DAI_WETH_3000_POOL), address(this), 1, 0); + } + + 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); + + 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); + } +} 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/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; +} 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; }