-
Notifications
You must be signed in to change notification settings - Fork 98
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add universal router executor (#316)
* add universal router executor * Add tests * fix import * nit comments fix import * nit: style
- Loading branch information
Showing
4 changed files
with
336 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
156
test/integration/UniversalRouterExecutorIntegration.t.sol
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
This comment has been minimized.
Sorry, something went wrong.
Alucar08Feb 21, 2025