Skip to content

Commit 1fd0d1c

Browse files
authored
feat: add universal router executor (#316)
* add universal router executor * Add tests * fix import * nit comments fix import * nit: style
1 parent 5016e3d commit 1fd0d1c

File tree

4 files changed

+336
-0
lines changed

4 files changed

+336
-0
lines changed
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
pragma solidity ^0.8.13;
3+
4+
import "forge-std/console2.sol";
5+
import "forge-std/Script.sol";
6+
import {UniversalRouterExecutor} from "../src/sample-executors/UniversalRouterExecutor.sol";
7+
import {IReactor} from "../src/interfaces/IReactor.sol";
8+
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
9+
10+
contract DeployUniversalRouterExecutor is Script {
11+
function setUp() public {}
12+
13+
function run() public returns (UniversalRouterExecutor executor) {
14+
uint256 privateKey = vm.envUint("FOUNDRY_PRIVATE_KEY");
15+
IReactor reactor = IReactor(vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_REACTOR"));
16+
// can encode with cast abi-encode "foo(address[])" "[addr1, addr2, ...]"
17+
bytes memory encodedAddresses =
18+
vm.envBytes("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_WHITELISTED_CALLERS_ENCODED");
19+
address owner = vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_OWNER");
20+
address universalRouter = vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_UNIVERSALROUTER");
21+
IPermit2 permit2 = IPermit2(vm.envAddress("FOUNDRY_UNIVERSALROUTEREXECUTOR_DEPLOY_PERMIT2"));
22+
23+
address[] memory decodedAddresses = abi.decode(encodedAddresses, (address[]));
24+
25+
vm.startBroadcast(privateKey);
26+
executor = new UniversalRouterExecutor{salt: 0x00}(decodedAddresses, reactor, owner, universalRouter, permit2);
27+
vm.stopBroadcast();
28+
29+
console2.log("UniversalRouterExecutor", address(executor));
30+
console2.log("owner", executor.owner());
31+
}
32+
}

src/external/IUniversalRouter.sol

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
pragma solidity ^0.8.24;
3+
4+
interface IUniversalRouter {
5+
/// @notice Thrown when a required command has failed
6+
error ExecutionFailed(uint256 commandIndex, bytes message);
7+
8+
/// @notice Thrown when attempting to send ETH directly to the contract
9+
error ETHNotAccepted();
10+
11+
/// @notice Thrown when executing commands with an expired deadline
12+
error TransactionDeadlinePassed();
13+
14+
/// @notice Thrown when attempting to execute commands and an incorrect number of inputs are provided
15+
error LengthMismatch();
16+
17+
// @notice Thrown when an address that isn't WETH tries to send ETH to the router without calldata
18+
error InvalidEthSender();
19+
20+
/// @notice Executes encoded commands along with provided inputs. Reverts if deadline has expired.
21+
/// @param commands A set of concatenated commands, each 1 byte in length
22+
/// @param inputs An array of byte strings containing abi encoded inputs for each command
23+
/// @param deadline The deadline by which the transaction must be executed
24+
function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable;
25+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
pragma solidity ^0.8.0;
3+
4+
import {Owned} from "solmate/src/auth/Owned.sol";
5+
import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
6+
import {ERC20} from "solmate/src/tokens/ERC20.sol";
7+
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
8+
import {IReactorCallback} from "../interfaces/IReactorCallback.sol";
9+
import {IReactor} from "../interfaces/IReactor.sol";
10+
import {CurrencyLibrary} from "../lib/CurrencyLibrary.sol";
11+
import {ResolvedOrder, SignedOrder} from "../base/ReactorStructs.sol";
12+
13+
/// @notice A fill contract that uses UniversalRouter to execute trades
14+
contract UniversalRouterExecutor is IReactorCallback, Owned {
15+
using SafeTransferLib for ERC20;
16+
using CurrencyLibrary for address;
17+
18+
/// @notice thrown if reactorCallback is called with a non-whitelisted filler
19+
error CallerNotWhitelisted();
20+
/// @notice thrown if reactorCallback is called by an address other than the reactor
21+
error MsgSenderNotReactor();
22+
23+
address public immutable universalRouter;
24+
mapping(address => bool) whitelistedCallers;
25+
IReactor public immutable reactor;
26+
IPermit2 public immutable permit2;
27+
28+
modifier onlyWhitelistedCaller() {
29+
if (whitelistedCallers[msg.sender] == false) {
30+
revert CallerNotWhitelisted();
31+
}
32+
_;
33+
}
34+
35+
modifier onlyReactor() {
36+
if (msg.sender != address(reactor)) {
37+
revert MsgSenderNotReactor();
38+
}
39+
_;
40+
}
41+
42+
constructor(
43+
address[] memory _whitelistedCallers,
44+
IReactor _reactor,
45+
address _owner,
46+
address _universalRouter,
47+
IPermit2 _permit2
48+
) Owned(_owner) {
49+
for (uint256 i = 0; i < _whitelistedCallers.length; i++) {
50+
whitelistedCallers[_whitelistedCallers[i]] = true;
51+
}
52+
reactor = _reactor;
53+
universalRouter = _universalRouter;
54+
permit2 = _permit2;
55+
}
56+
57+
/// @notice assume that we already have all output tokens
58+
function execute(SignedOrder calldata order, bytes calldata callbackData) external onlyWhitelistedCaller {
59+
reactor.executeWithCallback(order, callbackData);
60+
}
61+
62+
/// @notice assume that we already have all output tokens
63+
function executeBatch(SignedOrder[] calldata orders, bytes calldata callbackData) external onlyWhitelistedCaller {
64+
reactor.executeBatchWithCallback(orders, callbackData);
65+
}
66+
67+
/// @notice fill UniswapX orders using UniversalRouter
68+
/// @param callbackData It has the below encoded:
69+
/// address[] memory tokensToApproveForUniversalRouter: Max approve these tokens to permit2 and universalRouter
70+
/// address[] memory tokensToApproveForReactor: Max approve these tokens to reactor
71+
/// bytes memory data: execution data
72+
function reactorCallback(ResolvedOrder[] calldata, bytes calldata callbackData) external onlyReactor {
73+
(
74+
address[] memory tokensToApproveForUniversalRouter,
75+
address[] memory tokensToApproveForReactor,
76+
bytes memory data
77+
) = abi.decode(callbackData, (address[], address[], bytes));
78+
79+
unchecked {
80+
for (uint256 i = 0; i < tokensToApproveForUniversalRouter.length; i++) {
81+
// Max approve token to permit2
82+
ERC20(tokensToApproveForUniversalRouter[i]).safeApprove(address(permit2), type(uint256).max);
83+
// Max approve token to universalRouter via permit2
84+
permit2.approve(
85+
tokensToApproveForUniversalRouter[i], address(universalRouter), type(uint160).max, type(uint48).max
86+
);
87+
}
88+
89+
for (uint256 i = 0; i < tokensToApproveForReactor.length; i++) {
90+
ERC20(tokensToApproveForReactor[i]).safeApprove(address(reactor), type(uint256).max);
91+
}
92+
}
93+
94+
(bool success, bytes memory returnData) = universalRouter.call(data);
95+
if (!success) {
96+
assembly {
97+
revert(add(returnData, 32), mload(returnData))
98+
}
99+
}
100+
101+
// transfer any native balance to the reactor
102+
// it will refund any excess
103+
if (address(this).balance > 0) {
104+
CurrencyLibrary.transferNative(address(reactor), address(this).balance);
105+
}
106+
}
107+
108+
/// @notice Transfer all ETH in this contract to the recipient. Can only be called by owner.
109+
/// @param recipient The recipient of the ETH
110+
function withdrawETH(address recipient) external onlyOwner {
111+
SafeTransferLib.safeTransferETH(recipient, address(this).balance);
112+
}
113+
114+
/// @notice Transfer the entire balance of an ERC20 token in this contract to a recipient. Can only be called by owner.
115+
/// @param token The ERC20 token to withdraw
116+
/// @param to The recipient of the tokens
117+
function withdrawERC20(ERC20 token, address to) external onlyOwner {
118+
token.safeTransfer(to, token.balanceOf(address(this)));
119+
}
120+
121+
/// @notice Necessary for this contract to receive ETH
122+
receive() external payable {}
123+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// SPDX-License-Identifier: GPL-2.0-or-later
2+
pragma solidity ^0.8.0;
3+
4+
import {SafeTransferLib} from "solmate/src/utils/SafeTransferLib.sol";
5+
import {Test} from "forge-std/Test.sol";
6+
import {IPermit2} from "permit2/src/interfaces/IPermit2.sol";
7+
import {ERC20} from "solmate/src/tokens/ERC20.sol";
8+
import {UniversalRouterExecutor} from "../../src/sample-executors/UniversalRouterExecutor.sol";
9+
import {InputToken, OrderInfo, SignedOrder} from "../../src/base/ReactorStructs.sol";
10+
import {OrderInfoBuilder} from "../util/OrderInfoBuilder.sol";
11+
import {DutchOrderReactor, DutchOrder, DutchInput, DutchOutput} from "../../src/reactors/DutchOrderReactor.sol";
12+
import {OutputsBuilder} from "../util/OutputsBuilder.sol";
13+
import {PermitSignature} from "../util/PermitSignature.sol";
14+
import {IReactor} from "../../src/interfaces/IReactor.sol";
15+
import {IUniversalRouter} from "../../src/external/IUniversalRouter.sol";
16+
17+
contract UniversalRouterExecutorIntegrationTest is Test, PermitSignature {
18+
using OrderInfoBuilder for OrderInfo;
19+
using SafeTransferLib for ERC20;
20+
21+
ERC20 constant USDC = ERC20(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
22+
ERC20 constant USDT = ERC20(0xdAC17F958D2ee523a2206206994597C13D831ec7);
23+
24+
uint256 constant USDC_ONE = 1e6;
25+
26+
// UniversalRouter with V4 support
27+
IUniversalRouter universalRouter = IUniversalRouter(0x66a9893cC07D91D95644AEDD05D03f95e1dBA8Af);
28+
IPermit2 permit2 = IPermit2(0x000000000022D473030F116dDEE9F6B43aC78BA3);
29+
30+
address swapper;
31+
uint256 swapperPrivateKey;
32+
address whitelistedCaller;
33+
address owner;
34+
UniversalRouterExecutor universalRouterExecutor;
35+
DutchOrderReactor reactor;
36+
37+
// UniversalRouter commands
38+
uint256 constant V3_SWAP_EXACT_IN = 0x00;
39+
40+
function setUp() public {
41+
swapperPrivateKey = 0xbeef;
42+
swapper = vm.addr(swapperPrivateKey);
43+
vm.label(swapper, "swapper");
44+
whitelistedCaller = makeAddr("whitelistedCaller");
45+
owner = makeAddr("owner");
46+
// 02-10-2025
47+
vm.createSelectFork(vm.envString("FOUNDRY_RPC_URL"), 21818802);
48+
reactor = new DutchOrderReactor(permit2, address(0));
49+
address[] memory whitelistedCallers = new address[](1);
50+
whitelistedCallers[0] = whitelistedCaller;
51+
universalRouterExecutor = new UniversalRouterExecutor(
52+
whitelistedCallers, IReactor(address(reactor)), owner, address(universalRouter), permit2
53+
);
54+
55+
vm.prank(swapper);
56+
USDC.approve(address(permit2), type(uint256).max);
57+
58+
deal(address(USDC), swapper, 100 * 1e6);
59+
}
60+
61+
function baseTest(DutchOrder memory order) internal {
62+
_baseTest(order, false, "");
63+
}
64+
65+
function _baseTest(DutchOrder memory order, bool expectRevert, bytes memory revertData) internal {
66+
address[] memory tokensToApproveForPermit2AndUniversalRouter = new address[](1);
67+
tokensToApproveForPermit2AndUniversalRouter[0] = address(USDC);
68+
69+
address[] memory tokensToApproveForReactor = new address[](1);
70+
tokensToApproveForReactor[0] = address(USDT);
71+
72+
bytes memory commands = hex"00";
73+
bytes[] memory inputs = new bytes[](1);
74+
// V3 swap USDC -> USDT, with recipient as universalRouterExecutor
75+
inputs[0] =
76+
hex"0000000000000000000000002e234DAe75C793f67A35089C9d99245E1C58470b0000000000000000000000000000000000000000000000000000000000989680000000000000000000000000000000000000000000000000000000000090972200000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000002ba0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000064dac17f958d2ee523a2206206994597c13d831ec7000000000000000000000000000000000000000000";
77+
78+
bytes memory data =
79+
abi.encodeWithSelector(IUniversalRouter.execute.selector, commands, inputs, uint256(block.timestamp + 1000));
80+
81+
vm.prank(whitelistedCaller);
82+
if (expectRevert) {
83+
vm.expectRevert(revertData);
84+
}
85+
universalRouterExecutor.execute(
86+
SignedOrder(abi.encode(order), signOrder(swapperPrivateKey, address(permit2), order)),
87+
abi.encode(tokensToApproveForPermit2AndUniversalRouter, tokensToApproveForReactor, data)
88+
);
89+
}
90+
91+
function test_universalRouterExecutor() public {
92+
DutchOrder memory order = DutchOrder({
93+
info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100),
94+
decayStartTime: block.timestamp - 100,
95+
decayEndTime: block.timestamp + 100,
96+
input: DutchInput(USDC, 10 * USDC_ONE, 10 * USDC_ONE),
97+
outputs: OutputsBuilder.singleDutch(address(USDT), 9 * USDC_ONE, 9 * USDC_ONE, address(swapper))
98+
});
99+
100+
address[] memory tokensToApproveForPermit2AndUniversalRouter = new address[](1);
101+
tokensToApproveForPermit2AndUniversalRouter[0] = address(USDC);
102+
103+
address[] memory tokensToApproveForReactor = new address[](1);
104+
tokensToApproveForReactor[0] = address(USDT);
105+
106+
uint256 swapperInputBalanceBefore = USDC.balanceOf(swapper);
107+
uint256 swapperOutputBalanceBefore = USDT.balanceOf(swapper);
108+
109+
baseTest(order);
110+
111+
assertEq(USDC.balanceOf(swapper), swapperInputBalanceBefore - 10 * USDC_ONE);
112+
assertEq(USDT.balanceOf(swapper), swapperOutputBalanceBefore + 9 * USDC_ONE);
113+
// Expect some USDT to be left in the executor from the swap
114+
assertGe(USDT.balanceOf(address(universalRouterExecutor)), 0);
115+
}
116+
117+
function test_universalRouterExecutor_TooLittleReceived() public {
118+
DutchOrder memory order = DutchOrder({
119+
info: OrderInfoBuilder.init(address(reactor)).withSwapper(swapper).withDeadline(block.timestamp + 100),
120+
decayStartTime: block.timestamp - 100,
121+
decayEndTime: block.timestamp + 100,
122+
input: DutchInput(USDC, 10 * USDC_ONE, 10 * USDC_ONE),
123+
// Too much output
124+
outputs: OutputsBuilder.singleDutch(address(USDT), 11 * USDC_ONE, 11 * USDC_ONE, address(swapper))
125+
});
126+
127+
_baseTest(order, true, bytes("TRANSFER_FROM_FAILED"));
128+
}
129+
130+
function test_universalRouterExecutor_onlyOwner() public {
131+
address nonOwner = makeAddr("nonOwner");
132+
address recipient = makeAddr("recipient");
133+
uint256 recipientBalanceBefore = recipient.balance;
134+
uint256 recipientUSDCBalanceBefore = USDC.balanceOf(recipient);
135+
136+
vm.deal(address(universalRouterExecutor), 1 ether);
137+
deal(address(USDC), address(universalRouterExecutor), 100 * USDC_ONE);
138+
139+
vm.prank(nonOwner);
140+
vm.expectRevert("UNAUTHORIZED");
141+
universalRouterExecutor.withdrawETH(recipient);
142+
143+
vm.prank(nonOwner);
144+
vm.expectRevert("UNAUTHORIZED");
145+
universalRouterExecutor.withdrawERC20(USDC, recipient);
146+
147+
vm.prank(owner);
148+
universalRouterExecutor.withdrawETH(recipient);
149+
assertEq(address(recipient).balance, recipientBalanceBefore + 1 ether);
150+
151+
vm.prank(owner);
152+
universalRouterExecutor.withdrawERC20(USDC, recipient);
153+
assertEq(USDC.balanceOf(recipient), recipientUSDCBalanceBefore + 100 * USDC_ONE);
154+
assertEq(USDC.balanceOf(address(universalRouterExecutor)), 0);
155+
}
156+
}

0 commit comments

Comments
 (0)