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: add universal router executor #316

Merged
merged 6 commits into from
Feb 11, 2025
Merged
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
32 changes: 32 additions & 0 deletions script/DeployUniversalRouterExecutor.s.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.13;

import "forge-std/console2.sol";
import "forge-std/Script.sol";
import {UniversalRouterExecutor} from "../src/sample-executors/UniversalRouterExecutor.sol";
import {IReactor} from "../src/interfaces/IReactor.sol";
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";

contract DeployUniversalRouterExecutor is Script {
function setUp() public {}

function run() public returns (UniversalRouterExecutor executor) {
uint256 privateKey = vm.envUint("FOUNDRY_PRIVATE_KEY");
IReactor reactor = IReactor(vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_REACTOR"));
// can encode with cast abi-encode "foo(address[])" "[addr1, addr2, ...]"
bytes memory encodedAddresses =
vm.envBytes("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_WHITELISTED_CALLERS_ENCODED");
address owner = vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_OWNER");
address universalRouter = vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_UNIVERSALROUTER");
IPermit2 permit2 = IPermit2(vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_PERMIT2"));

address[] memory decodedAddresses = abi.decode(encodedAddresses, (address[]));

vm.startBroadcast(privateKey);
executor = new UniversalRouterExecutor{salt: 0x00}(decodedAddresses, reactor, owner, universalRouter, permit2);
vm.stopBroadcast();

console2.log("UniversalRouterExecutor", address(executor));
console2.log("owner", executor.owner());
}
}
25 changes: 25 additions & 0 deletions src/external/IUniversalRouter.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.24;

interface IUniversalRouter {
/// @notice Thrown when a required command has failed
error ExecutionFailed(uint256 commandIndex, bytes message);

/// @notice Thrown when attempting to send ETH directly to the contract
error ETHNotAccepted();

/// @notice Thrown when executing commands with an expired deadline
error TransactionDeadlinePassed();

/// @notice Thrown when attempting to execute commands and an incorrect number of inputs are provided
error LengthMismatch();

// @notice Thrown when an address that isn't WETH tries to send ETH to the router without calldata
error InvalidEthSender();

/// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired.
/// @param commands A set of concatenated commands, each 1 byte in length
/// @param inputs An array of byte strings containing abi encoded inputs for each command
/// @param deadline The deadline by which the transaction must be executed
function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable;
}
123 changes: 123 additions & 0 deletions src/sample-executors/UniversalRouterExecutor.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {Owned} from "solmate/src/auth/Owned.sol";
import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
import {ERC20} from "solmate/src/tokens/ERC20.sol";
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
import {IReactorCallback} from "../interfaces/IReactorCallback.sol";
import {IReactor} from "../interfaces/IReactor.sol";
import {CurrencyLibrary} from "../lib/CurrencyLibrary.sol";
import {ResolvedOrder, SignedOrder} from "../base/ReactorStructs.sol";

/// @notice A fill contract that uses UniversalRouter to execute trades
contract UniversalRouterExecutor is IReactorCallback, Owned {
using SafeTransferLib for ERC20;
using CurrencyLibrary for address;

/// @notice thrown if reactorCallback is called with a non-whitelisted filler
error CallerNotWhitelisted();
/// @notice thrown if reactorCallback is called by an address other than the reactor
error MsgSenderNotReactor();

address public immutable universalRouter;
mapping(address => bool) whitelistedCallers;
IReactor public immutable reactor;
IPermit2 public immutable permit2;

modifier onlyWhitelistedCaller() {
if (whitelistedCallers[msg.sender] == false) {
revert CallerNotWhitelisted();
}
_;
}

modifier onlyReactor() {
if (msg.sender != address(reactor)) {
revert MsgSenderNotReactor();
}
_;
}

constructor(
address[] memory _whitelistedCallers,
IReactor _reactor,
address _owner,
address _universalRouter,
IPermit2 _permit2
) Owned(_owner) {
for (uint256 i = 0; i < _whitelistedCallers.length; i++) {
whitelistedCallers[_whitelistedCallers[i]] = true;
}
reactor = _reactor;
universalRouter = _universalRouter;
permit2 = _permit2;
}

/// @notice assume that we already have all output tokens
function execute(SignedOrder calldata order, bytes calldata callbackData) external onlyWhitelistedCaller {
reactor.executeWithCallback(order, callbackData);
}

/// @notice assume that we already have all output tokens
function executeBatch(SignedOrder[] calldata orders, bytes calldata callbackData) external onlyWhitelistedCaller {
reactor.executeBatchWithCallback(orders, callbackData);
}

/// @notice fill UniswapX orders using UniversalRouter
/// @param callbackData It has the below encoded:
/// address[] memory tokensToApproveForUniversalRouter: Max approve these tokens to permit2 and universalRouter
/// address[] memory tokensToApproveForReactor: Max approve these tokens to reactor
/// bytes memory data: execution data
function reactorCallback(ResolvedOrder[] calldata, bytes calldata callbackData) external onlyReactor {
(
address[] memory tokensToApproveForUniversalRouter,
address[] memory tokensToApproveForReactor,
bytes memory data
) = abi.decode(callbackData, (address[], address[], bytes));

unchecked {
for (uint256 i = 0; i < tokensToApproveForUniversalRouter.length; i++) {
// Max approve token to permit2
ERC20(tokensToApproveForUniversalRouter[i]).safeApprove(address(permit2), type(uint256).max);
// Max approve token to universalRouter via permit2
permit2.approve(
tokensToApproveForUniversalRouter[i], address(universalRouter), type(uint160).max, type(uint48).max
);
}

for (uint256 i = 0; i < tokensToApproveForReactor.length; i++) {
ERC20(tokensToApproveForReactor[i]).safeApprove(address(reactor), type(uint256).max);
}
}

(bool success, bytes memory returnData) = universalRouter.call(data);
if (!success) {
assembly {
revert(add(returnData, 32), mload(returnData))
}
}

// transfer any native balance to the reactor
// it will refund any excess
if (address(this).balance > 0) {
CurrencyLibrary.transferNative(address(reactor), address(this).balance);
}
}

/// @notice Transfer all ETH in this contract to the recipient. Can only be called by owner.
/// @param recipient The recipient of the ETH
function withdrawETH(address recipient) external onlyOwner {
SafeTransferLib.safeTransferETH(recipient, address(this).balance);
}

/// @notice Transfer the entire balance of an ERC20 token in this contract to a recipient. Can only be called by owner.
/// @param token The ERC20 token to withdraw
/// @param to The recipient of the tokens
function withdrawERC20(ERC20 token, address to) external onlyOwner {
token.safeTransfer(to, token.balanceOf(address(this)));
}

/// @notice Necessary for this contract to receive ETH
receive() external payable {}
}
156 changes: 156 additions & 0 deletions test/integration/UniversalRouterExecutorIntegration.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;

import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
import {Test} from "forge-std/Test.sol";
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
import {ERC20} from "solmate/src/tokens/ERC20.sol";
import {UniversalRouterExecutor} from "../../src/sample-executors/UniversalRouterExecutor.sol";
import {InputToken, OrderInfo, SignedOrder} from "../../src/base/ReactorStructs.sol";
import {OrderInfoBuilder} from "../util/OrderInfoBuilder.sol";
import {DutchOrderReactor, DutchOrder, DutchInput, DutchOutput} from "../../src/reactors/DutchOrderReactor.sol";
import {OutputsBuilder} from "../util/OutputsBuilder.sol";
import {PermitSignature} from "../util/PermitSignature.sol";
import {IReactor} from "../../src/interfaces/IReactor.sol";
import {IUniversalRouter} from "../../src/external/IUniversalRouter.sol";

contract UniversalRouterExecutorIntegrationTest is Test, PermitSignature {
using OrderInfoBuilder for OrderInfo;
using SafeTransferLib for ERC20;

ERC20 constant USDC = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
ERC20 constant USDT = ERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);

uint256 constant USDC_ONE = 1e6;

// UniversalRouter with V4 support
IUniversalRouter universalRouter = IUniversalRouter(0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af);
IPermit2 permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);

address swapper;
uint256 swapperPrivateKey;
address whitelistedCaller;
address owner;
UniversalRouterExecutor universalRouterExecutor;
DutchOrderReactor reactor;

// UniversalRouter commands
uint256 constant V3_SWAP_EXACT_IN = 0x00;

function setUp() public {
swapperPrivateKey = 0xbeef;
swapper = vm.addr(swapperPrivateKey);
vm.label(swapper, "swapper");
whitelistedCaller = makeAddr("whitelistedCaller");
owner = makeAddr("owner");
// 02-10-2025
vm.createSelectFork(vm.envString("FOUNDRY_RPC_URL"), 21818802);
reactor = new DutchOrderReactor(permit2, address(0));
address[] memory whitelistedCallers = new address[](1);
whitelistedCallers[0] = whitelistedCaller;
universalRouterExecutor = new UniversalRouterExecutor(
whitelistedCallers, IReactor(address(reactor)), owner, address(universalRouter), permit2
);

vm.prank(swapper);
USDC.approve(address(permit2), type(uint256).max);

deal(address(USDC), swapper, 100 * 1e6);
}

function baseTest(DutchOrder memory order) internal {
_baseTest(order, false, "");
}

function _baseTest(DutchOrder memory order, bool expectRevert, bytes memory revertData) internal {
address[] memory tokensToApproveForPermit2AndUniversalRouter = new address[](1);
tokensToApproveForPermit2AndUniversalRouter[0] = address(USDC);

address[] memory tokensToApproveForReactor = new address[](1);
tokensToApproveForReactor[0] = address(USDT);

bytes memory commands = hex"00";
bytes[] memory inputs = new bytes[](1);
// V3 swap USDC -> USDT, with recipient as universalRouterExecutor
inputs[0] =
hex"0000000000000000000000002e234DAe75C793f67A35089C9d99245E1C58470b0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000090972200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000064dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000";

bytes memory data =
abi.encodeWithSelector(IUniversalRouter.execute.selector, commands, inputs, uint256(block.timestamp + 1000));

vm.prank(whitelistedCaller);
if (expectRevert) {
vm.expectRevert(revertData);
}
universalRouterExecutor.execute(
SignedOrder(abi.encode(order), signOrder(swapperPrivateKey, address(permit2), order)),
abi.encode(tokensToApproveForPermit2AndUniversalRouter, tokensToApproveForReactor, data)
);
}

function test_universalRouterExecutor() public {
DutchOrder memory order = DutchOrder({
info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100),
decayStartTime: block.timestamp - 100,
decayEndTime: block.timestamp + 100,
input: DutchInput(USDC, 10 * USDC_ONE, 10 * USDC_ONE),
outputs: OutputsBuilder.singleDutch(address(USDT), 9 * USDC_ONE, 9 * USDC_ONE, address(swapper))
});

address[] memory tokensToApproveForPermit2AndUniversalRouter = new address[](1);
tokensToApproveForPermit2AndUniversalRouter[0] = address(USDC);

address[] memory tokensToApproveForReactor = new address[](1);
tokensToApproveForReactor[0] = address(USDT);

uint256 swapperInputBalanceBefore = USDC.balanceOf(swapper);
uint256 swapperOutputBalanceBefore = USDT.balanceOf(swapper);

baseTest(order);

assertEq(USDC.balanceOf(swapper), swapperInputBalanceBefore - 10 * USDC_ONE);
assertEq(USDT.balanceOf(swapper), swapperOutputBalanceBefore + 9 * USDC_ONE);
// Expect some USDT to be left in the executor from the swap
assertGe(USDT.balanceOf(address(universalRouterExecutor)), 0);
}

function test_universalRouterExecutor_TooLittleReceived() public {
DutchOrder memory order = DutchOrder({
info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100),
decayStartTime: block.timestamp - 100,
decayEndTime: block.timestamp + 100,
input: DutchInput(USDC, 10 * USDC_ONE, 10 * USDC_ONE),
// Too much output
outputs: OutputsBuilder.singleDutch(address(USDT), 11 * USDC_ONE, 11 * USDC_ONE, address(swapper))
});

_baseTest(order, true, bytes("TRANSFER_FROM_FAILED"));
}

function test_universalRouterExecutor_onlyOwner() public {
address nonOwner = makeAddr("nonOwner");
address recipient = makeAddr("recipient");
uint256 recipientBalanceBefore = recipient.balance;
uint256 recipientUSDCBalanceBefore = USDC.balanceOf(recipient);

vm.deal(address(universalRouterExecutor), 1 ether);
deal(address(USDC), address(universalRouterExecutor), 100 * USDC_ONE);

vm.prank(nonOwner);
vm.expectRevert("UNAUTHORIZED");
universalRouterExecutor.withdrawETH(recipient);

vm.prank(nonOwner);
vm.expectRevert("UNAUTHORIZED");
universalRouterExecutor.withdrawERC20(USDC, recipient);

vm.prank(owner);
universalRouterExecutor.withdrawETH(recipient);
assertEq(address(recipient).balance, recipientBalanceBefore + 1 ether);

vm.prank(owner);
universalRouterExecutor.withdrawERC20(USDC, recipient);
assertEq(USDC.balanceOf(recipient), recipientUSDCBalanceBefore + 100 * USDC_ONE);
assertEq(USDC.balanceOf(address(universalRouterExecutor)), 0);
}
}
Loading