Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: make fee collector open and competitive #12

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions remappings.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ds-test/=lib/forge-std/lib/ds-test/src/
forge-std/=lib/forge-std/src/
solmate/=lib/solmate/src/
v2-core/=lib/v2-core/contracts/
5 changes: 2 additions & 3 deletions script/DeployFeeCollector.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,11 @@ contract DeployFeeCollector is Script {
function run() public returns (FeeCollector collector) {
uint256 privateKey = vm.envUint("FOUNDRY_FEE_COLLECTOR_PRIVATE_KEY");
address owner = vm.envAddress("FOUNDRY_FEE_COLLECTOR_OWNER_ADDRESS");
address universalRouter = vm.envAddress("FOUNDRY_FEE_COLLECTOR_UNIVERSAL_ROUTER_ADDRESS");
address permit2 = vm.envAddress("FOUNDRY_FEE_COLLECTOR_PERMIT2_ADDRESS");
address feeRecipient = vm.envAddress("FOUNDRY_FEE_COLLECTOR_FEE_RECIPIENT_ADDRESS");
address feeToken = vm.envAddress("FOUNDRY_FEE_COLLECTOR_FEE_TOKEN_ADDRESS");

vm.startBroadcast(privateKey);
collector = new FeeCollector{salt: 0x00}(owner, universalRouter, permit2, feeToken);
collector = new FeeCollector{salt: 0x00}(owner, feeRecipient, feeToken, 100 ether);
vm.stopBroadcast();

console2.log("Successfully deployed FeeCollector", address(collector));
Expand Down
68 changes: 29 additions & 39 deletions src/FeeCollector.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,52 @@ import {Owned} from "solmate/auth/Owned.sol";
import {ERC20} from "solmate/tokens/ERC20.sol";
import {SafeTransferLib} from "solmate/utils/SafeTransferLib.sol";
import {IFeeCollector} from "./interfaces/IFeeCollector.sol";
import {IPermit2} from "./external/IPermit2.sol";

/// @notice The collector of protocol fees that will be used to swap and send to a fee recipient address.
contract FeeCollector is Owned, IFeeCollector {
using SafeTransferLib for ERC20;

error UniversalRouterCallFailed();
error CallFailed();

address private immutable universalRouter;
address public feeRecipient;
/// @notice the amount of fee token that must be paid per token
uint256 public feeTokenAmount;
/// @notice the token to receive fees in
ERC20 public immutable feeToken;

ERC20 private immutable feeToken;
IPermit2 private immutable permit2;

uint256 private constant MAX_APPROVAL_AMOUNT = type(uint256).max;
uint160 private constant MAX_PERMIT2_APPROVAL_AMOUNT = type(uint160).max;
uint48 private constant MAX_PERMIT2_DEADLINE = type(uint48).max;

constructor(address _owner, address _universalRouter, address _permit2, address _feeToken) Owned(_owner) {
universalRouter = _universalRouter;
constructor(address _owner, address _feeRecipient, address _feeToken, uint256 _feeTokenAmount) Owned(_owner) {
feeRecipient = _feeRecipient;
feeToken = ERC20(_feeToken);
permit2 = IPermit2(_permit2);
feeTokenAmount = _feeTokenAmount;
}

/// @inheritdoc IFeeCollector
function swapBalance(bytes calldata swapData, uint256 nativeValue) external onlyOwner {
_execute(swapData, nativeValue);
/// @notice allow anyone to take the full balance of any arbitrary tokens
/// @dev as long as they pay `feeTokenAmount` per token taken to the `feeRecipient`
/// this creates a competitive auction as the balances of this contract increase
/// to find the optimal path for the swap
function swapBalances(ERC20[] memory tokens, bytes calldata call) external {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

seems unnecessary to pass through this call info, why not just have a shared interface for the callback?

for (uint256 i = 0; i < tokens.length; i++) {
tokens[i].safeTransfer(msg.sender, tokens[i].balanceOf(address(this)));
}
(bool success,) = msg.sender.call(call);
if (!success) {
revert CallFailed();
}

feeToken.safeTransferFrom(msg.sender, feeRecipient, feeTokenAmount * tokens.length);
}

/// @inheritdoc IFeeCollector
function swapBalance(bytes calldata swapData, uint256 nativeValue, ERC20[] calldata tokensToApprove)
external
onlyOwner
{
unchecked {
for (uint256 i = 0; i < tokensToApprove.length; i++) {
tokensToApprove[i].safeApprove(address(permit2), MAX_APPROVAL_AMOUNT);
permit2.approve(
address(tokensToApprove[i]), universalRouter, MAX_PERMIT2_APPROVAL_AMOUNT, MAX_PERMIT2_DEADLINE
);
}
}

_execute(swapData, nativeValue);
function withdrawToken(ERC20 token, address to, uint256 amount) external onlyOwner {
token.safeTransfer(to, amount);
}

/// @notice Helper function to call UniversalRouter.
/// @param swapData The bytes call data to be forwarded to UniversalRouter.
/// @param nativeValue The amount of native currency to send to UniversalRouter.
function _execute(bytes calldata swapData, uint256 nativeValue) internal {
(bool success,) = universalRouter.call{value: nativeValue}(swapData);
if (!success) revert UniversalRouterCallFailed();
function setFeeRecipient(address _feeRecipient) external onlyOwner {
feeRecipient = _feeRecipient;
}

/// @inheritdoc IFeeCollector
function withdrawFeeToken(address feeRecipient, uint256 amount) external onlyOwner {
feeToken.safeTransfer(feeRecipient, amount);
function setFeeTokenAmount(uint256 _feeTokenAmount) external onlyOwner {
feeTokenAmount = _feeTokenAmount;
}

receive() external payable {}
Expand Down
19 changes: 6 additions & 13 deletions src/interfaces/IFeeCollector.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,12 @@ import {ERC20} from "solmate/tokens/ERC20.sol";

/// @notice The collector of protocol fees that will be used to swap and send to a fee recipient address.
interface IFeeCollector {
/// @notice Swaps the contract balance.
/// @param swapData The bytes call data to be forwarded to UniversalRouter.
/// @param nativeValue The amount of native currency to send to UniversalRouter.
function swapBalance(bytes calldata swapData, uint256 nativeValue) external;
/// @notice
function swapBalances(ERC20[] memory tokens, bytes calldata call) external;

/// @notice Approves tokens for swapping and then swaps the contract balance.
/// @param swapData The bytes call data to be forwarded to UniversalRouter.
/// @param nativeValue The amount of native currency to send to UniversalRouter.
/// @param tokensToApprove An array of ERC20 tokens to approve for spending.
function swapBalance(bytes calldata swapData, uint256 nativeValue, ERC20[] calldata tokensToApprove) external;

/// @notice Transfers the fee token balance from this contract to the fee recipient.
/// @param feeRecipient The address to send the fee token balance to.
/// @notice Transfers amount of token to a caller specified recipient.
/// @param token The token to withdraw.
/// @param to The address to send to
/// @param amount The amount to withdraw.
function withdrawFeeToken(address feeRecipient, uint256 amount) external;
function withdrawToken(ERC20 token, address to, uint256 amount) external;
}
119 changes: 15 additions & 104 deletions test/FeeCollector.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,141 +5,52 @@ import {Test} from "forge-std/Test.sol";
import {Vm} from "forge-std/Vm.sol";
import {ERC20} from "solmate/tokens/ERC20.sol";
import {MockToken} from "./mock/MockToken.sol";
import {MockUniversalRouter} from "./mock/MockUniversalRouter.sol";
import {IMockUniversalRouter} from "./mock/MockUniversalRouter.sol";
import {MockSearcher} from "./mock/MockSearcher.sol";
import {FeeCollector} from "../src/FeeCollector.sol";

contract FeeCollectorTest is Test {
FeeCollector public collector;

address caller;
address feeRecipient;
address permit2;

MockToken mockFeeToken;
MockToken tokenIn;
MockToken tokenOut;
MockUniversalRouter router;

MockSearcher searcherContract;

function setUp() public {
// Mock caller and fee recipient
caller = makeAddr("caller");
feeRecipient = makeAddr("feeRecipient");
permit2 = 0x000000000022D473030F116dDEE9F6B43aC78BA3;
mockFeeToken = new MockToken();
tokenIn = new MockToken();
tokenOut = new MockToken();
router = new MockUniversalRouter();

collector = new FeeCollector(caller, address(router), permit2, address(mockFeeToken));
collector = new FeeCollector(caller, feeRecipient, address(mockFeeToken), 100 ether);
searcherContract = new MockSearcher(payable(address(collector)));
}

function testSwapBalance() public {
tokenIn.mint(address(collector), 100 ether);
tokenOut.mint(address(router), 100 ether);
assertEq(tokenIn.balanceOf(address(collector)), 100 ether);
assertEq(tokenOut.balanceOf(address(router)), 100 ether);

bytes memory swapData = abi.encodeWithSelector(
IMockUniversalRouter.execute.selector, abi.encode(address(tokenIn), address(tokenOut), 100 ether, 100 ether)
);

vm.prank(address(collector));
tokenIn.approve(address(router), 100 ether);
vm.prank(caller);
collector.swapBalance(swapData, 0);

assertEq(tokenIn.balanceOf(address(collector)), 0 ether);
assertEq(tokenOut.balanceOf(address(collector)), 100 ether);
assertEq(tokenIn.balanceOf(address(router)), 100 ether);
assertEq(tokenOut.balanceOf(address(router)), 0 ether);
}

function testSwapBalanceNative() public {
vm.deal(address(collector), 100 ether);
tokenOut.mint(address(router), 100 ether);
assertEq(address(collector).balance, 100 ether);
assertEq(tokenOut.balanceOf(address(router)), 100 ether);

bytes memory swapData = abi.encodeWithSelector(
IMockUniversalRouter.execute.selector, abi.encode(address(0), address(tokenOut), 100 ether, 100 ether)
);

vm.prank(caller);
collector.swapBalance(swapData, 100 ether);

assertEq(address(collector).balance, 0 ether);
assertEq(tokenOut.balanceOf(address(collector)), 100 ether);
assertEq(address(router).balance, 100 ether);
assertEq(tokenOut.balanceOf(address(router)), 0 ether);
}

function testSwapBalanceNativeError() public {
tokenIn.mint(address(collector), 100 ether);
tokenOut.mint(address(router), 100 ether);
assertEq(tokenIn.balanceOf(address(collector)), 100 ether);
assertEq(tokenOut.balanceOf(address(router)), 100 ether);

bytes memory badSwapCallData = abi.encodeWithSelector(
IMockUniversalRouter.execute.selector, abi.encode(address(tokenIn), address(tokenOut))
);

vm.prank(address(collector));
tokenIn.approve(address(router), 100 ether);
vm.expectRevert(FeeCollector.UniversalRouterCallFailed.selector);
vm.prank(caller);
collector.swapBalance(badSwapCallData, 0);

assertEq(tokenIn.balanceOf(address(collector)), 100 ether);
assertEq(tokenOut.balanceOf(address(collector)), 0 ether);
assertEq(tokenIn.balanceOf(address(router)), 0 ether);
assertEq(tokenOut.balanceOf(address(router)), 100 ether);
}

function testSwapBalanceUnauthorized() public {
tokenIn.mint(address(collector), 100 ether);
tokenOut.mint(address(router), 100 ether);
assertEq(tokenIn.balanceOf(address(collector)), 100 ether);
assertEq(tokenOut.balanceOf(address(router)), 100 ether);

bytes memory swapData = abi.encodeWithSelector(
IMockUniversalRouter.execute.selector, abi.encode(address(tokenIn), address(tokenOut), 100 ether, 100 ether)
);
// For the test we just assume the filler has the required fee tokens
mockFeeToken.mint(address(searcherContract), 100 ether);

vm.prank(address(collector));
tokenIn.approve(address(router), 100 ether);
vm.expectRevert("UNAUTHORIZED");
vm.prank(address(0xbeef));
collector.swapBalance(swapData, 0);

assertEq(tokenIn.balanceOf(address(collector)), 100 ether);
assertEq(tokenOut.balanceOf(address(collector)), 0 ether);
assertEq(tokenIn.balanceOf(address(router)), 0 ether);
assertEq(tokenOut.balanceOf(address(router)), 100 ether);
}
ERC20[] memory tokens = new ERC20[](1);
tokens[0] = tokenIn;
bytes memory call = abi.encodeWithSignature("doMev()");
searcherContract.swapBalances(tokens, call);

function testWithdrawFeeToken() public {
assertEq(mockFeeToken.balanceOf(address(collector)), 0);
assertEq(mockFeeToken.balanceOf(address(feeRecipient)), 0);
mockFeeToken.mint(address(collector), 100 ether);
assertEq(mockFeeToken.balanceOf(address(collector)), 100 ether);
vm.prank(caller);
collector.withdrawFeeToken(feeRecipient, 100 ether);
assertEq(mockFeeToken.balanceOf(address(collector)), 0);
// Expect that the full tokenIn balance of the collector was sent to the searcher contract
assertEq(tokenIn.balanceOf(address(collector)), 0);
assertEq(tokenIn.balanceOf(address(searcherContract)), 100 ether);
// Expect that the fee tokens were transferred to the fee recipient
assertEq(mockFeeToken.balanceOf(address(feeRecipient)), 100 ether);
}

function testWithdrawFeeTokenUnauthorized() public {
assertEq(mockFeeToken.balanceOf(address(collector)), 0);
assertEq(mockFeeToken.balanceOf(address(feeRecipient)), 0);
mockFeeToken.mint(address(collector), 100 ether);
assertEq(mockFeeToken.balanceOf(address(collector)), 100 ether);
vm.expectRevert("UNAUTHORIZED");
vm.prank(address(0xbeef));
collector.withdrawFeeToken(feeRecipient, 100 ether);
assertEq(mockFeeToken.balanceOf(address(collector)), 100 ether);
}

function testTransferOwnership() public {
address newOwner = makeAddr("newOwner");
assertEq(collector.owner(), caller);
Expand Down
Loading
Loading