From ce1fe1dd94a4cc68e4695902c80ee30d30d7fd5e Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 7 Jul 2025 18:00:53 -0400 Subject: [PATCH] feat: UniswapXFiller skeleton --- foundry/src/uniswap_x/IReactor.sol | 31 +++++ foundry/src/uniswap_x/IReactorCallback.sol | 16 +++ foundry/src/uniswap_x/IStructs.sol | 115 ++++++++++++++++++ foundry/src/uniswap_x/UniswapXFiller.sol | 109 +++++++++++++++++ foundry/test/TychoRouter.t.sol | 1 - foundry/test/uniswap_x/UniswapXFiller.t.sol | 126 ++++++++++++++++++++ 6 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 foundry/src/uniswap_x/IReactor.sol create mode 100644 foundry/src/uniswap_x/IReactorCallback.sol create mode 100644 foundry/src/uniswap_x/IStructs.sol create mode 100644 foundry/src/uniswap_x/UniswapXFiller.sol create mode 100644 foundry/test/uniswap_x/UniswapXFiller.t.sol diff --git a/foundry/src/uniswap_x/IReactor.sol b/foundry/src/uniswap_x/IReactor.sol new file mode 100644 index 0000000..5ca5414 --- /dev/null +++ b/foundry/src/uniswap_x/IReactor.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import {SignedOrder} from "./IStructs.sol"; +import {IReactorCallback} from "./IReactorCallback.sol"; + +/// @notice Interface for order execution reactors +interface IReactor { + /// @notice Execute a single order + /// @param order The order definition and valid signature to execute + function execute(SignedOrder calldata order) external payable; + + /// @notice Execute a single order using the given callback data + /// @param order The order definition and valid signature to execute + function executeWithCallback( + SignedOrder calldata order, + bytes calldata callbackData + ) external payable; + + /// @notice Execute the given orders at once + /// @param orders The order definitions and valid signatures to execute + function executeBatch(SignedOrder[] calldata orders) external payable; + + /// @notice Execute the given orders at once using a callback with the given callback data + /// @param orders The order definitions and valid signatures to execute + /// @param callbackData The callbackData to pass to the callback + function executeBatchWithCallback( + SignedOrder[] calldata orders, + bytes calldata callbackData + ) external payable; +} diff --git a/foundry/src/uniswap_x/IReactorCallback.sol b/foundry/src/uniswap_x/IReactorCallback.sol new file mode 100644 index 0000000..12af565 --- /dev/null +++ b/foundry/src/uniswap_x/IReactorCallback.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "./IStructs.sol"; + +/// @notice Callback for executing orders through a reactor. +interface IReactorCallback { + /// @notice Called by the reactor during the execution of an order + /// @param resolvedOrders Has inputs and outputs + /// @param fillData The fillData specified for an order execution + /// @dev Must have approved each token and amount in outputs to the msg.sender + function reactorCallback( + ResolvedOrder[] memory resolvedOrders, + bytes memory fillData + ) external; +} diff --git a/foundry/src/uniswap_x/IStructs.sol b/foundry/src/uniswap_x/IStructs.sol new file mode 100644 index 0000000..3781266 --- /dev/null +++ b/foundry/src/uniswap_x/IStructs.sol @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +/// @dev external struct including a generic encoded order and swapper signature +/// The order bytes will be parsed and mapped to a ResolvedOrder in the concrete reactor contract + +struct SignedOrder { + bytes order; + bytes sig; +} + +struct OrderInfo { + // The address of the reactor that this order is targeting + // Note that this must be included in every order so the swapper + // signature commits to the specific reactor that they trust to fill their order properly + address reactor; + // The address of the user which created the order + // Note that this must be included so that order hashes are unique by swapper + address swapper; + // The nonce of the order, allowing for signature replay protection and cancellation + uint256 nonce; + // The timestamp after which this order is no longer valid + uint256 deadline; + // Custom validation contract + address additionalValidationContract; + // Encoded validation params for additionalValidationContract + bytes additionalValidationData; +} + +/// @dev tokens that need to be sent from the swapper in order to satisfy an order +struct InputToken { + address token; + uint256 amount; + // Needed for dutch decaying inputs + uint256 maxAmount; +} + +/// @dev tokens that need to be received by the recipient in order to satisfy an order +struct OutputToken { + address token; + uint256 amount; + address recipient; +} +/// @dev generic concrete order that specifies exact tokens which need to be sent and received + +struct ResolvedOrder { + OrderInfo info; + InputToken input; + OutputToken[] outputs; + bytes sig; + bytes32 hash; +} + +struct DutchOutput { + address token; + uint256 startAmount; + uint256 endAmount; + address recipient; +} + +struct DutchInput { + address token; + uint256 startAmount; + uint256 endAmount; +} + +struct ExclusiveDutchOrder { + OrderInfo info; + uint256 decayStartTime; + uint256 decayEndTime; + address exclusiveFiller; + uint256 exclusivityOverrideBps; + DutchInput input; + DutchOutput[] outputs; +} + +struct DutchOrder { + OrderInfo info; + uint256 decayStartTime; + uint256 decayEndTime; + address exclusiveFiller; + uint256 exclusivityOverrideBps; + DutchInput input; + DutchOutput[] outputs; +} + +struct CosignerData { + // The time at which the DutchOutputs start decaying + uint256 decayStartTime; + // The time at which price becomes static + uint256 decayEndTime; + // The address who has exclusive rights to the order until decayStartTime + address exclusiveFiller; + // The amount in bps that a non-exclusive filler needs to improve the outputs by to be able to fill the order + uint256 exclusivityOverrideBps; + // The tokens that the swapper will provide when settling the order + uint256 inputAmount; + // The tokens that must be received to satisfy the order + uint256[] outputAmounts; +} + +struct V2DutchOrder { + // generic order information + OrderInfo info; + // The address which must cosign the full order + address cosigner; + // The tokens that the swapper will provide when settling the order + DutchInput baseInput; + // The tokens that must be received to satisfy the order + DutchOutput[] baseOutputs; + // signed over by the cosigner + CosignerData cosignerData; + // signature from the cosigner over (orderHash || cosignerData) + bytes cosignature; +} diff --git a/foundry/src/uniswap_x/UniswapXFiller.sol b/foundry/src/uniswap_x/UniswapXFiller.sol new file mode 100644 index 0000000..bbb8af3 --- /dev/null +++ b/foundry/src/uniswap_x/UniswapXFiller.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "./IReactor.sol"; +import "./IReactorCallback.sol"; +import { + SafeERC20, + IERC20 +} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/AccessControl.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; + +error UniswapXFiller__AddressZero(); + +contract UniswapXFiller is AccessControl, IReactorCallback { + using SafeERC20 for IERC20; + + // UniswapX V2DutchOrder Reactor + IReactor public constant USXEDAReactor = + IReactor(0x00000011F84B9aa48e5f8aA8B9897600006289Be); + address public immutable tychoRouter; + + // keccak256("NAME_OF_ROLE") : save gas on deployment + bytes32 public constant REACTOR_ROLE = + 0x39dd1d7269516fc1f719706a5e9b05cdcb1644978808b171257d9a8eab55dd57; + bytes32 public constant EXECUTOR_ROLE = + 0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63; + + event Withdrawal( + address indexed token, uint256 amount, address indexed receiver + ); + + constructor(address _tychoRouter) { + if (_tychoRouter == address(0)) revert UniswapXFiller__AddressZero(); + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(REACTOR_ROLE, address(USXEDAReactor)); + tychoRouter = _tychoRouter; + } + + function execute(SignedOrder calldata order, bytes calldata callbackData) + external + onlyRole(EXECUTOR_ROLE) + { + USXEDAReactor.executeWithCallback(order, callbackData); + } + + function reactorCallback( + ResolvedOrder[] calldata resolvedOrders, + bytes calldata callbackData + ) external onlyRole(REACTOR_ROLE) { + // TODO + } + + /** + * @dev Allows granting roles to multiple accounts in a single call. + */ + function batchGrantRole(bytes32 role, address[] memory accounts) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + for (uint256 i = 0; i < accounts.length; i++) { + _grantRole(role, accounts[i]); + } + } + + /** + * @dev Allows withdrawing any ERC20 funds if funds get stuck in case of a bug. + */ + function withdraw(IERC20[] memory tokens, address receiver) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + if (receiver == address(0)) revert UniswapXFiller__AddressZero(); + + for (uint256 i = 0; i < tokens.length; i++) { + // slither-disable-next-line calls-loop + uint256 tokenBalance = tokens[i].balanceOf(address(this)); + if (tokenBalance > 0) { + emit Withdrawal(address(tokens[i]), tokenBalance, receiver); + tokens[i].safeTransfer(receiver, tokenBalance); + } + } + } + + /** + * @dev Allows withdrawing any NATIVE funds if funds get stuck in case of a bug. + * The contract should never hold any NATIVE tokens for security reasons. + */ + function withdrawNative(address receiver) + external + onlyRole(DEFAULT_ADMIN_ROLE) + { + if (receiver == address(0)) revert UniswapXFiller__AddressZero(); + + uint256 amount = address(this).balance; + if (amount > 0) { + emit Withdrawal(address(0), amount, receiver); + Address.sendValue(payable(receiver), amount); + } + } + + /** + * @dev Allows this contract to receive native token with empty msg.data from contracts + */ + receive() external payable { + require(msg.sender.code.length != 0); + } +} diff --git a/foundry/test/TychoRouter.t.sol b/foundry/test/TychoRouter.t.sol index fb53058..651461d 100644 --- a/foundry/test/TychoRouter.t.sol +++ b/foundry/test/TychoRouter.t.sol @@ -96,7 +96,6 @@ contract TychoRouterTest is TychoRouterTestSetup { } vm.startPrank(FUND_RESCUER); - tychoRouter.withdraw(tokens, FUND_RESCUER); // Check balances after withdrawing diff --git a/foundry/test/uniswap_x/UniswapXFiller.t.sol b/foundry/test/uniswap_x/UniswapXFiller.t.sol new file mode 100644 index 0000000..c267938 --- /dev/null +++ b/foundry/test/uniswap_x/UniswapXFiller.t.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@src/executors/UniswapV4Executor.sol"; +import "forge-std/Test.sol"; +import "@src/uniswap_x/UniswapXFiller.sol"; +import "../TychoRouterTestSetup.sol"; + +contract UniswapXFillerTest is Test, TychoRouterTestSetup { + address EXECUTOR = makeAddr("executor"); + address REACTOR = address(0x00000011F84B9aa48e5f8aA8B9897600006289Be); + + UniswapXFiller filler; + address fillerAddr; + + bytes32 public constant EXECUTOR_ROLE = + 0xd8aa0f3194971a2a116679f7c2090f6939c8d4e01a2a8d7e41d55e5351469e63; + + event CallbackVerifierSet(address indexed callbackVerifier); + event Withdrawal( + address indexed token, uint256 amount, address indexed receiver + ); + + function fillerSetup() public { + vm.startPrank(ADMIN); + filler = new UniswapXFiller(tychoRouterAddr); + fillerAddr = address(filler); + filler.grantRole(keccak256("EXECUTOR_ROLE"), EXECUTOR); + vm.stopPrank(); + } + + function testTychoAddressZero() public { + vm.expectRevert(UniswapXFiller__AddressZero.selector); + filler = new UniswapXFiller(address(0)); + } + + function testWithdrawNative() public { + fillerSetup(); + vm.startPrank(ADMIN); + // Send 100 ether to filler + assertEq(fillerAddr.balance, 0); + assertEq(ADMIN.balance, 0); + vm.deal(fillerAddr, 100 ether); + vm.expectEmit(); + emit Withdrawal(address(0), 100 ether, ADMIN); + filler.withdrawNative(ADMIN); + assertEq(fillerAddr.balance, 0); + assertEq(ADMIN.balance, 100 ether); + vm.stopPrank(); + } + + function testWithdrawNativeAddressZero() public { + fillerSetup(); + vm.deal(fillerAddr, 100 ether); + vm.startPrank(ADMIN); + vm.expectRevert(UniswapXFiller__AddressZero.selector); + filler.withdrawNative(address(0)); + vm.stopPrank(); + } + + function testWithdrawNativeMissingRole() public { + fillerSetup(); + vm.deal(fillerAddr, 100 ether); + // Not role ADMIN + vm.startPrank(BOB); + vm.expectRevert(); + filler.withdrawNative(ADMIN); + vm.stopPrank(); + } + + function testWithdrawERC20Tokens() public { + fillerSetup(); + + IERC20[] memory tokens = new IERC20[](2); + tokens[0] = IERC20(WETH_ADDR); + tokens[1] = IERC20(USDC_ADDR); + for (uint256 i = 0; i < tokens.length; i++) { + deal(address(tokens[i]), fillerAddr, 100 ether); + } + + vm.startPrank(ADMIN); + filler.withdraw(tokens, ADMIN); + + // Check balances after withdrawing + for (uint256 i = 0; i < tokens.length; i++) { + // slither-disable-next-line calls-loop + assertEq(tokens[i].balanceOf(fillerAddr), 0); + // slither-disable-next-line calls-loop + assertEq(tokens[i].balanceOf(ADMIN), 100 ether); + } + vm.stopPrank(); + } + + function testWithdrawERC20TokensAddressZero() public { + fillerSetup(); + + IERC20[] memory tokens = new IERC20[](2); + tokens[0] = IERC20(WETH_ADDR); + tokens[1] = IERC20(USDC_ADDR); + for (uint256 i = 0; i < tokens.length; i++) { + deal(address(tokens[i]), fillerAddr, 100 ether); + } + + vm.startPrank(ADMIN); + vm.expectRevert(UniswapXFiller__AddressZero.selector); + filler.withdraw(tokens, address(0)); + vm.stopPrank(); + } + + function testWithdrawERC20TokensAddressMissingRole() public { + fillerSetup(); + + IERC20[] memory tokens = new IERC20[](2); + tokens[0] = IERC20(WETH_ADDR); + tokens[1] = IERC20(USDC_ADDR); + for (uint256 i = 0; i < tokens.length; i++) { + deal(address(tokens[i]), fillerAddr, 100 ether); + } + + // Not role ADMIN + vm.startPrank(BOB); + vm.expectRevert(); + filler.withdraw(tokens, ADMIN); + vm.stopPrank(); + } +}