diff --git a/script/DeployUniversalRouterExecutor.s.sol b/script/DeployUniversalRouterExecutor.s.sol new file mode 100644 index 00000000..a406f8ea --- /dev/null +++ b/script/DeployUniversalRouterExecutor.s.sol @@ -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()); + } +} diff --git a/src/external/IUniversalRouter.sol b/src/external/IUniversalRouter.sol new file mode 100644 index 00000000..6c70134e --- /dev/null +++ b/src/external/IUniversalRouter.sol @@ -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; +} diff --git a/src/sample-executors/UniversalRouterExecutor.sol b/src/sample-executors/UniversalRouterExecutor.sol new file mode 100644 index 00000000..39e254b4 --- /dev/null +++ b/src/sample-executors/UniversalRouterExecutor.sol @@ -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 {} +} diff --git a/test/integration/UniversalRouterExecutorIntegration.t.sol b/test/integration/UniversalRouterExecutorIntegration.t.sol new file mode 100644 index 00000000..3011c583 --- /dev/null +++ b/test/integration/UniversalRouterExecutorIntegration.t.sol @@ -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); + } +}