Skip to content

Commit

Permalink
Implement and unit test the claimFees method on the factory owner
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
apbendi committed Jan 13, 2024
1 parent d2488b7 commit 9205c6a
Show file tree
Hide file tree
Showing 6 changed files with 318 additions and 8 deletions.
41 changes: 40 additions & 1 deletion src/V3FactoryOwner.sol
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
}
Expand Down
6 changes: 6 additions & 0 deletions src/interfaces/INotifiableRewardReceiver.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity ^0.8.23;

interface INotifiableRewardReceiver {
function notifyRewardsAmount(uint256 _amount) external;
}
25 changes: 25 additions & 0 deletions src/interfaces/IUniswapV3PoolOwnerActions.sol
Original file line number Diff line number Diff line change
@@ -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);
}
213 changes: 206 additions & 7 deletions test/V3FactoryOwner.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
}
}
12 changes: 12 additions & 0 deletions test/mocks/MockRewardReceiver.sol
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit 9205c6a

Please sign in to comment.