diff --git a/CHANGELOG.md b/CHANGELOG.md index 34d27c2..ab74142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +## [0.104.0](https://github.com/propeller-heads/tycho-execution/compare/0.103.0...0.104.0) (2025-07-08) + + +### Features + +* Take reactor address as input to UniswapXFiller ([00f22d6](https://github.com/propeller-heads/tycho-execution/commit/00f22d62c1f978f27eb5a922bc7b36af2a0b806b)) +* UniswapXFiller skeleton ([ce1fe1d](https://github.com/propeller-heads/tycho-execution/commit/ce1fe1dd94a4cc68e4695902c80ee30d30d7fd5e)) + + +### Bug Fixes + +* Make slither happy ([d61469e](https://github.com/propeller-heads/tycho-execution/commit/d61469ea67f2c8d0f28e5185da4099260c2b69ea)) + ## [0.103.0](https://github.com/propeller-heads/tycho-execution/compare/0.102.0...0.103.0) (2025-07-07) diff --git a/Cargo.lock b/Cargo.lock index df435e3..05e0314 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4658,7 +4658,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.103.0" +version = "0.104.0" dependencies = [ "alloy", "chrono", diff --git a/Cargo.toml b/Cargo.toml index df96172..136586e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycho-execution" -version = "0.103.0" +version = "0.104.0" edition = "2021" description = "Provides tools for encoding and executing swaps against Tycho router and protocol executors." repository = "https://github.com/propeller-heads/tycho-execution" 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..9672fd2 --- /dev/null +++ b/foundry/src/uniswap_x/IStructs.sol @@ -0,0 +1,114 @@ +// 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..de52edd --- /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 immutable reactor; + 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, address _reactor) { + if (_tychoRouter == address(0)) revert UniswapXFiller__AddressZero(); + if (_reactor == address(0)) revert UniswapXFiller__AddressZero(); + + _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); + _grantRole(REACTOR_ROLE, address(reactor)); + tychoRouter = _tychoRouter; + reactor = IReactor(_reactor); + } + + function execute(SignedOrder calldata order, bytes calldata callbackData) + external + onlyRole(EXECUTOR_ROLE) + { + reactor.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. + */ + 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. + */ + 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..1dc11df --- /dev/null +++ b/foundry/test/uniswap_x/UniswapXFiller.t.sol @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +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; + + 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, REACTOR); + fillerAddr = address(filler); + filler.grantRole(keccak256("EXECUTOR_ROLE"), EXECUTOR); + vm.stopPrank(); + } + + function testTychoAddressZeroTychoRouter() public { + vm.expectRevert(UniswapXFiller__AddressZero.selector); + filler = new UniswapXFiller(address(0), REACTOR); + } + + function testTychoAddressZeroReactor() public { + vm.expectRevert(UniswapXFiller__AddressZero.selector); + filler = new UniswapXFiller(tychoRouterAddr, 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(); + } +}