From 9205c6afa68df8555e14848fb570f2e1271993f7 Mon Sep 17 00:00:00 2001 From: Ben DiFrancesco Date: Thu, 4 Jan 2024 13:57:16 -0500 Subject: [PATCH] Implement and unit test the claimFees method on the factory owner Allows anyone to call claim fees, pay the payout amount in the payout token, and receive the fees earned by the protocol on a given pool. --- src/V3FactoryOwner.sol | 41 +++- src/interfaces/INotifiableRewardReceiver.sol | 6 + src/interfaces/IUniswapV3PoolOwnerActions.sol | 25 ++ test/V3FactoryOwner.t.sol | 213 +++++++++++++++++- test/mocks/MockRewardReceiver.sol | 12 + test/mocks/MockUniswapV3Pool.sol | 29 +++ 6 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 src/interfaces/INotifiableRewardReceiver.sol create mode 100644 src/interfaces/IUniswapV3PoolOwnerActions.sol create mode 100644 test/mocks/MockRewardReceiver.sol create mode 100644 test/mocks/MockUniswapV3Pool.sol diff --git a/src/V3FactoryOwner.sol b/src/V3FactoryOwner.sol index 1d78825..4bbca53 100644 --- a/src/V3FactoryOwner.sol +++ b/src/V3FactoryOwner.sol @@ -1,17 +1,42 @@ // SPDX-License-Identifier: AGPL-3.0-only pragma solidity 0.8.23; +import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; +import {INotifiableRewardReceiver} from "src/interfaces/INotifiableRewardReceiver.sol"; +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {SafeERC20} from "openzeppelin/token/ERC20/utils/SafeERC20.sol"; + contract V3FactoryOwner { + using SafeERC20 for IERC20; + address public admin; + IERC20 public immutable PAYOUT_TOKEN; + uint256 public immutable PAYOUT_AMOUNT; + INotifiableRewardReceiver public immutable REWARD_RECEIVER; event AdminUpdated(address indexed oldAmin, address indexed newAdmin); + event FeesClaimed( + address indexed pool, + address indexed caller, + address indexed recipient, + uint256 amount0, + uint256 amount1 + ); error V3FactoryOwner__Unauthorized(); error V3FactoryOwner__InvalidAddress(); - constructor(address _admin) { + constructor( + address _admin, + IERC20 _payoutToken, + uint256 _payoutAmount, + INotifiableRewardReceiver _rewardReceiver + ) { if (_admin == address(0)) revert V3FactoryOwner__InvalidAddress(); admin = _admin; + PAYOUT_TOKEN = _payoutToken; + PAYOUT_AMOUNT = _payoutAmount; + REWARD_RECEIVER = _rewardReceiver; } function setAdmin(address _newAdmin) external { @@ -21,6 +46,20 @@ contract V3FactoryOwner { admin = _newAdmin; } + function claimFees( + IUniswapV3PoolOwnerActions _pool, + address _recipient, + uint128 _amount0Requested, + uint128 _amount1Requested + ) external { + PAYOUT_TOKEN.safeTransferFrom(msg.sender, address(REWARD_RECEIVER), PAYOUT_AMOUNT); + REWARD_RECEIVER.notifyRewardsAmount(PAYOUT_AMOUNT); + (uint128 _amount0, uint128 _amount1) = + _pool.collectProtocol(_recipient, _amount0Requested, _amount1Requested); + + emit FeesClaimed(address(_pool), msg.sender, _recipient, _amount0, _amount1); + } + function _revertIfNotAdmin() internal view { if (msg.sender != admin) revert V3FactoryOwner__Unauthorized(); } diff --git a/src/interfaces/INotifiableRewardReceiver.sol b/src/interfaces/INotifiableRewardReceiver.sol new file mode 100644 index 0000000..f4fe2ac --- /dev/null +++ b/src/interfaces/INotifiableRewardReceiver.sol @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity ^0.8.23; + +interface INotifiableRewardReceiver { + function notifyRewardsAmount(uint256 _amount) external; +} diff --git a/src/interfaces/IUniswapV3PoolOwnerActions.sol b/src/interfaces/IUniswapV3PoolOwnerActions.sol new file mode 100644 index 0000000..a2b8277 --- /dev/null +++ b/src/interfaces/IUniswapV3PoolOwnerActions.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +pragma solidity ^0.8.23; + +/// @title Permissioned pool actions +/// @notice Contains pool methods that may only be called by the factory owner +/// @dev Vendored from +/// https://github.com/Uniswap/v3-core/blob/d8b1c635c275d2a9450bd6a78f3fa2484fef73eb/contracts/interfaces/pool/IUniswapV3PoolOwnerActions.sol +interface IUniswapV3PoolOwnerActions { + /// @notice Set the denominator of the protocol's % share of the fees + /// @param feeProtocol0 new protocol fee for token0 of the pool + /// @param feeProtocol1 new protocol fee for token1 of the pool + function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external; + + /// @notice Collect the protocol fee accrued to the pool + /// @param recipient The address to which collected protocol fees should be sent + /// @param amount0Requested The maximum amount of token0 to send, can be 0 to collect fees in only + /// token1 + /// @param amount1Requested The maximum amount of token1 to send, can be 0 to collect fees in only + /// token0 + /// @return amount0 The protocol fee collected in token0 + /// @return amount1 The protocol fee collected in token1 + function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) + external + returns (uint128 amount0, uint128 amount1); +} diff --git a/test/V3FactoryOwner.t.sol b/test/V3FactoryOwner.t.sol index f82b92d..cd55011 100644 --- a/test/V3FactoryOwner.t.sol +++ b/test/V3FactoryOwner.t.sol @@ -3,39 +3,93 @@ pragma solidity 0.8.23; import {Test, console2} from "forge-std/Test.sol"; import {V3FactoryOwner} from "src/V3FactoryOwner.sol"; +import {INotifiableRewardReceiver} from "src/interfaces/INotifiableRewardReceiver.sol"; +import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; +import {ERC20Fake} from "test/fakes/ERC20Fake.sol"; +import {IERC20} from "openzeppelin/token/ERC20/IERC20.sol"; +import {IERC20Errors} from "openzeppelin/interfaces/draft-IERC6093.sol"; +import {MockRewardReceiver} from "test/mocks/MockRewardReceiver.sol"; +import {MockUniswapV3Pool} from "test/mocks/MockUniswapV3Pool.sol"; contract V3FactoryOwnerTest is Test { V3FactoryOwner factoryOwner; address admin = makeAddr("Admin"); + ERC20Fake payoutToken; + MockRewardReceiver rewardReceiver; + MockUniswapV3Pool pool; event AdminUpdated(address indexed oldAmin, address indexed newAdmin); + event FeesClaimed( + address indexed pool, + address indexed caller, + address indexed recipient, + uint256 amount0, + uint256 amount1 + ); function setUp() public { - factoryOwner = new V3FactoryOwner(admin); + vm.label(admin, "Admin"); + + payoutToken = new ERC20Fake(); + vm.label(address(payoutToken), "Payout Token"); + + rewardReceiver = new MockRewardReceiver(); + vm.label(address(rewardReceiver), "Reward Receiver"); + + pool = new MockUniswapV3Pool(); + vm.label(address(pool), "Pool"); + } + + // In order to fuzz over the payout amount, we require each test to call this method to deploy + // the factory owner before doing anything else. + function _deployFactoryOwnerWithPayoutAmount(uint256 _payoutAmount) public { + factoryOwner = new V3FactoryOwner(admin, payoutToken, _payoutAmount, rewardReceiver); vm.label(address(factoryOwner), "Factory Owner"); } } contract Constructor is V3FactoryOwnerTest { - function test_SetsTheAdmin() public { + function testFuzz_SetsTheAdminPayoutTokenAndPayoutAmount(uint256 _payoutAmount) public { + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + assertEq(factoryOwner.admin(), admin); + assertEq(address(factoryOwner.PAYOUT_TOKEN()), address(payoutToken)); + assertEq(factoryOwner.PAYOUT_AMOUNT(), _payoutAmount); + assertEq(address(factoryOwner.REWARD_RECEIVER()), address(rewardReceiver)); } - function testFuzz_SetTheAdminToAnArbitraryAddress(address _admin) public { + function testFuzz_SetsAllParametersToArbitraryValues( + address _admin, + address _payoutToken, + uint256 _payoutAmount, + address _rewardReceiver + ) public { vm.assume(_admin != address(0)); - V3FactoryOwner _factoryOwner = new V3FactoryOwner(_admin); + V3FactoryOwner _factoryOwner = new V3FactoryOwner( + _admin, IERC20(_payoutToken), _payoutAmount, INotifiableRewardReceiver(_rewardReceiver) + ); assertEq(_factoryOwner.admin(), _admin); + assertEq(address(_factoryOwner.PAYOUT_TOKEN()), address(_payoutToken)); + assertEq(_factoryOwner.PAYOUT_AMOUNT(), _payoutAmount); + assertEq(address(_factoryOwner.REWARD_RECEIVER()), _rewardReceiver); } - function test_RevertIf_TheAdminIsAddressZero() public { + function testFuzz_RevertIf_TheAdminIsAddressZero( + address _payoutToken, + uint256 _payoutAmount, + address _rewardReceiver + ) public { vm.expectRevert(V3FactoryOwner.V3FactoryOwner__InvalidAddress.selector); - new V3FactoryOwner(address(0)); + new V3FactoryOwner( + address(0), IERC20(_payoutToken), _payoutAmount, INotifiableRewardReceiver(_rewardReceiver) + ); } } contract SetAdmin is V3FactoryOwnerTest { function testFuzz_UpdatesTheAdminWhenCalledByTheCurrentAdmin(address _newAdmin) public { vm.assume(_newAdmin != address(0)); + _deployFactoryOwnerWithPayoutAmount(0); vm.prank(admin); factoryOwner.setAdmin(_newAdmin); @@ -45,6 +99,7 @@ contract SetAdmin is V3FactoryOwnerTest { function testFuzz_EmitsAnEventWhenUpdatingTheAdmin(address _newAdmin) public { vm.assume(_newAdmin != address(0)); + _deployFactoryOwnerWithPayoutAmount(0); vm.expectEmit(true, true, true, true); vm.prank(admin); @@ -55,6 +110,8 @@ contract SetAdmin is V3FactoryOwnerTest { function testFuzz_RevertIf_TheCallerIsNotTheCurrentAdmin(address _notAdmin, address _newAdmin) public { + _deployFactoryOwnerWithPayoutAmount(0); + vm.assume(_notAdmin != admin); vm.expectRevert(V3FactoryOwner.V3FactoryOwner__Unauthorized.selector); @@ -63,9 +120,151 @@ contract SetAdmin is V3FactoryOwnerTest { } function test_RevertIf_TheNewAdminIsAddressZero() public { - vm.prank(admin); + _deployFactoryOwnerWithPayoutAmount(0); vm.expectRevert(V3FactoryOwner.V3FactoryOwner__InvalidAddress.selector); + vm.prank(admin); factoryOwner.setAdmin(address(0)); } } + +contract ClaimFees is V3FactoryOwnerTest { + function testFuzz_TransfersThePayoutFromTheCallerToTheRewardReceiver( + uint256 _payoutAmount, + address _caller, + address _recipient, + uint128 _amount0, + uint128 _amount1 + ) public { + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + + vm.assume(_caller != address(0) && _recipient != address(0)); + payoutToken.mint(_caller, _payoutAmount); + + vm.startPrank(_caller); + payoutToken.approve(address(factoryOwner), _payoutAmount); + factoryOwner.claimFees(pool, _recipient, _amount0, _amount1); + vm.stopPrank(); + + assertEq(payoutToken.balanceOf(address(rewardReceiver)), _payoutAmount); + } + + function testFuzz_NotifiesTheRewardReceiverOfTheReward( + uint256 _payoutAmount, + address _caller, + address _recipient, + uint128 _amount0, + uint128 _amount1 + ) public { + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + + vm.assume(_caller != address(0) && _recipient != address(0)); + payoutToken.mint(_caller, _payoutAmount); + + vm.startPrank(_caller); + payoutToken.approve(address(factoryOwner), _payoutAmount); + factoryOwner.claimFees(pool, _recipient, _amount0, _amount1); + vm.stopPrank(); + + assertEq(rewardReceiver.lastParam__notifyRewardsAmount_amount(), _payoutAmount); + } + + function testFuzz_CallsPoolCollectProtocolMethodWithRecipientAndAmountsRequested( + uint256 _payoutAmount, + address _caller, + address _recipient, + uint128 _amount0, + uint128 _amount1 + ) public { + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + + vm.assume(_caller != address(0) && _recipient != address(0)); + payoutToken.mint(_caller, _payoutAmount); + + vm.startPrank(_caller); + payoutToken.approve(address(factoryOwner), _payoutAmount); + factoryOwner.claimFees(pool, _recipient, _amount0, _amount1); + vm.stopPrank(); + + assertEq(pool.lastParam__collectProtocol_recipient(), _recipient); + assertEq(pool.lastParam__collectProtocol_amount0Requested(), _amount0); + assertEq(pool.lastParam__collectProtocol_amount1Requested(), _amount1); + } + + function testFuzz_EmitsAnEventWithFeeClaimParameters( + uint256 _payoutAmount, + address _caller, + address _recipient, + uint128 _amount0, + uint128 _amount1 + ) public { + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + + vm.assume(_caller != address(0) && _recipient != address(0)); + payoutToken.mint(_caller, _payoutAmount); + + vm.startPrank(_caller); + payoutToken.approve(address(factoryOwner), _payoutAmount); + vm.expectEmit(true, true, true, true); + emit FeesClaimed(address(pool), _caller, _recipient, _amount0, _amount1); + factoryOwner.claimFees(pool, _recipient, _amount0, _amount1); + vm.stopPrank(); + } + + function testFuzz_RevertIf_CallerHasInsufficientBalanceOfPayoutToken( + uint256 _payoutAmount, + address _caller, + address _recipient, + uint128 _amount0, + uint128 _amount1, + uint256 _mintAmount + ) public { + _payoutAmount = bound(_payoutAmount, 1, type(uint256).max); + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + + vm.assume(_caller != address(0) && _recipient != address(0)); + _mintAmount = bound(_mintAmount, 0, _payoutAmount - 1); + payoutToken.mint(_caller, _mintAmount); + + vm.startPrank(_caller); + payoutToken.approve(address(factoryOwner), _payoutAmount); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, _caller, _mintAmount, _payoutAmount + ) + ); + factoryOwner.claimFees(pool, _recipient, _amount0, _amount1); + vm.stopPrank(); + } + + function testFuzz_RevertIf_CallerHasInsufficientApprovalForPayoutToken( + uint256 _payoutAmount, + address _caller, + address _recipient, + uint128 _amount0, + uint128 _amount1, + uint256 _approveAmount + ) public { + _payoutAmount = bound(_payoutAmount, 1, type(uint256).max); + _deployFactoryOwnerWithPayoutAmount(_payoutAmount); + + vm.assume(_caller != address(0) && _recipient != address(0)); + _approveAmount = bound(_approveAmount, 0, _payoutAmount - 1); + payoutToken.mint(_caller, _payoutAmount); + + vm.startPrank(_caller); + payoutToken.approve(address(factoryOwner), _approveAmount); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(factoryOwner), + _approveAmount, + _payoutAmount + ) + ); + factoryOwner.claimFees(pool, _recipient, _amount0, _amount1); + vm.stopPrank(); + } +} diff --git a/test/mocks/MockRewardReceiver.sol b/test/mocks/MockRewardReceiver.sol new file mode 100644 index 0000000..fb2fdb7 --- /dev/null +++ b/test/mocks/MockRewardReceiver.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.23; + +import {INotifiableRewardReceiver} from "src/interfaces/INotifiableRewardReceiver.sol"; + +contract MockRewardReceiver is INotifiableRewardReceiver { + uint256 public lastParam__notifyRewardsAmount_amount; + + function notifyRewardsAmount(uint256 _amount) external { + lastParam__notifyRewardsAmount_amount = _amount; + } +} diff --git a/test/mocks/MockUniswapV3Pool.sol b/test/mocks/MockUniswapV3Pool.sol new file mode 100644 index 0000000..1c4a75e --- /dev/null +++ b/test/mocks/MockUniswapV3Pool.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-only +pragma solidity 0.8.23; + +import {IUniswapV3PoolOwnerActions} from "src/interfaces/IUniswapV3PoolOwnerActions.sol"; + +contract MockUniswapV3Pool is IUniswapV3PoolOwnerActions { + uint8 public lastParam__setFeeProtocol_feeProtocol0; + uint8 public lastParam__setFeeProtocol_feeProtocol1; + + address public lastParam__collectProtocol_recipient; + uint128 public lastParam__collectProtocol_amount0Requested; + uint128 public lastParam__collectProtocol_amount1Requested; + + function setFeeProtocol(uint8 feeProtocol0, uint8 feeProtocol1) external { + lastParam__setFeeProtocol_feeProtocol0 = feeProtocol0; + lastParam__setFeeProtocol_feeProtocol1 = feeProtocol1; + } + + function collectProtocol(address recipient, uint128 amount0Requested, uint128 amount1Requested) + external + returns (uint128 amount0, uint128 amount1) + { + lastParam__collectProtocol_recipient = recipient; + lastParam__collectProtocol_amount0Requested = amount0Requested; + lastParam__collectProtocol_amount1Requested = amount1Requested; + + return (amount0Requested, amount1Requested); + } +}