diff --git a/README.md b/README.md index 937078d..5129093 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic - `Reactor.sol` validates the signed order, pulls approved input from the swapper directly into factory-registered LiquidLane adapters, and enforces output delivery. - `Executor.sol` is an example role-gated execution surface that calls the Reactor, performs adapter swaps, runs any post-swap execution payload, and approves output transfers back to the Reactor. +- `LiquidLaneUniswapXExecutor.sol` is a transparent-proxy, caller-gated UniswapX fill contract. It routes Reactor-supplied ERC-20 input through direct and discount LiquidLane adapters, grants the immutable Reactor output allowances, and forwards native output. The Reactor authoritatively resolves and settles orders; adapters enforce swap and discount terms. Direct and discount route arrays remain separate, and unspent input or output surplus remains on the executor. Because the Reactor has maximum ERC-20 allowances, retained balances can participate in subsequent Reactor settlement; there is no sweep entrypoint. > [!NOTE] > @@ -15,6 +16,7 @@ This directory contains the core RFQ settlement contracts used by the Symbiotic - [Reactor.sol](src/Reactor.sol) - [Executor.sol](src/Executor.sol) +- [LiquidLaneUniswapXExecutor.sol](src/uniswapx/LiquidLaneUniswapXExecutor.sol) ## Flow diff --git a/script/DeployUniswapXExecutor.s.sol b/script/DeployUniswapXExecutor.s.sol new file mode 100644 index 0000000..739fd67 --- /dev/null +++ b/script/DeployUniswapXExecutor.s.sol @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {DeployUniswapXExecutorBaseScript} from "./deploy/base/DeployUniswapXExecutorBase.s.sol"; + +// forge script script/DeployUniswapXExecutor.s.sol:DeployUniswapXExecutorScript --rpc-url=RPC --broadcast + +contract DeployUniswapXExecutorScript is DeployUniswapXExecutorBaseScript { + // Configurations - UPDATE THESE BEFORE DEPLOYMENT + + // Deployed UniswapX Reactor address this Executor forwards fills to. + address public constant REACTOR = 0x00000011F84B9aa48e5f8aA8B9897600006289Be; + // Executor owner. + address public constant ADMIN = 0x0000000000000000000000000000000000000000; + // Proxy admin owner allowed to upgrade the executor proxy. + address public constant PROXY_ADMIN_OWNER = 0x0000000000000000000000000000000000000000; + // Initial caller allowed to invoke fill entrypoints. + address public constant CALLER = 0x0000000000000000000000000000000000000000; + + function run() public returns (DeploymentData memory data) { + data = + runBase(DeployParams({reactor: REACTOR, admin: ADMIN, proxyAdminOwner: PROXY_ADMIN_OWNER, caller: CALLER})); + } +} diff --git a/script/deploy/base/DeployUniswapXExecutorBase.s.sol b/script/deploy/base/DeployUniswapXExecutorBase.s.sol new file mode 100644 index 0000000..1bd0439 --- /dev/null +++ b/script/deploy/base/DeployUniswapXExecutorBase.s.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {LiquidLaneUniswapXExecutor} from "../../../src/uniswapx/LiquidLaneUniswapXExecutor.sol"; + +abstract contract DeployUniswapXExecutorBaseScript is Script { + struct DeployParams { + address reactor; + address admin; + address proxyAdminOwner; + address caller; + } + + struct DeploymentData { + LiquidLaneUniswapXExecutor executor; + address implementation; + address reactor; + address admin; + address proxyAdminOwner; + address caller; + } + + function runBase(DeployParams memory params) public virtual returns (DeploymentData memory data) { + _validateParams(params); + + address[] memory callers = new address[](1); + callers[0] = params.caller; + + _startBroadcast(); + LiquidLaneUniswapXExecutor implementation = new LiquidLaneUniswapXExecutor(params.reactor); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + params.proxyAdminOwner, + abi.encodeCall(LiquidLaneUniswapXExecutor.initialize, (params.admin, callers)) + ); + _stopBroadcast(); + + data.executor = LiquidLaneUniswapXExecutor(payable(address(proxy))); + data.implementation = address(implementation); + data.reactor = params.reactor; + data.admin = params.admin; + data.proxyAdminOwner = params.proxyAdminOwner; + data.caller = params.caller; + + _validateDeployment(data); + _logDeployment(data); + } + + function _startBroadcast() internal virtual { + vm.startBroadcast(); + } + + function _stopBroadcast() internal virtual { + vm.stopBroadcast(); + } + + function _validateParams(DeployParams memory params) internal pure { + require(params.reactor != address(0), "invalid reactor"); + require(params.admin != address(0), "invalid admin"); + require(params.proxyAdminOwner != address(0), "invalid proxy admin owner"); + require(params.caller != address(0), "invalid caller"); + } + + function _validateDeployment(DeploymentData memory data) internal view { + assert(address(data.executor) != data.implementation); + assert(data.executor.owner() == data.admin); + assert(data.executor.callers(0) == data.caller); + } + + function _logDeployment(DeploymentData memory data) internal view { + console2.log("Deployed UniswapX Executor"); + console2.log(" executor: ", address(data.executor)); + console2.log(" implementation: ", data.implementation); + console2.log(" reactor: ", data.reactor); + console2.log(" admin: ", data.admin); + console2.log(" proxyAdminOwner: ", data.proxyAdminOwner); + console2.log(" caller: ", data.caller); + } +} diff --git a/src/uniswapx/LiquidLaneUniswapXExecutor.sol b/src/uniswapx/LiquidLaneUniswapXExecutor.sol new file mode 100644 index 0000000..722c193 --- /dev/null +++ b/src/uniswapx/LiquidLaneUniswapXExecutor.sol @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {ILiquidLaneAdapter} from "../interfaces/ILiquidLaneAdapter.sol"; +import {ILiquidLaneUniswapXExecutor} from "./interfaces/ILiquidLaneUniswapXExecutor.sol"; +import {IUniswapXReactor, UniswapXResolvedOrder, UniswapXSignedOrder} from "./interfaces/IUniswapXReactor.sol"; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; + +/// @title LiquidLaneUniswapXExecutor +/// @notice UniswapX Reactor callback that atomically sources outputs from LiquidLane adapters. +contract LiquidLaneUniswapXExecutor is Initializable, OwnableUpgradeable, ILiquidLaneUniswapXExecutor { + using Address for address payable; + using SafeERC20 for IERC20; + + address internal immutable REACTOR; + address[] public callers; + + constructor(address reactor) { + REACTOR = reactor; + _disableInitializers(); + } + + function initialize(address owner, address[] calldata initCallers) external initializer { + __Ownable_init(owner); + callers = initCallers; + } + + modifier onlyCaller() { + if (!_isCaller(msg.sender)) revert NotCaller(); + _; + } + + function execute(UniswapXSignedOrder calldata order, FillCall calldata fillCall) public onlyCaller { + IUniswapXReactor(REACTOR).executeWithCallback(order, abi.encode(fillCall)); + } + + function reactorCallback(UniswapXResolvedOrder[] memory resolvedOrders, bytes memory callbackData) external { + if (msg.sender != REACTOR) revert NotReactor(); + + FillCall memory fillCall = abi.decode(callbackData, (FillCall)); + address tokenIn = resolvedOrders[0].input.token; + + uint256 routesLength = fillCall.routes.length; + for (uint256 i; i < routesLength; ++i) { + FillRoute memory route = fillCall.routes[i]; + IERC20(tokenIn).safeTransfer(route.adapter, route.amountIn); + ILiquidLaneAdapter(route.adapter) + .swap( + ILiquidLaneAdapter.Swap({ + recipient: address(this), tokenIn: tokenIn, amountIn: route.amountIn, amountOut: route.amountOut + }) + ); + } + uint256 discountRoutesLength = fillCall.discountRoutes.length; + for (uint256 i; i < discountRoutesLength; ++i) { + DiscountRoute memory route = fillCall.discountRoutes[i]; + IERC20(tokenIn).safeTransfer(route.adapter, route.amountIn); + ILiquidLaneAdapter(route.adapter) + .swap(route.discountSwap, route.protocolSignature, address(this), route.amountIn); + } + + uint256 outputsLength = resolvedOrders[0].outputs.length; + for (uint256 i; i < outputsLength; ++i) { + address token = resolvedOrders[0].outputs[i].token; + // Native output (address(0)) is forwarded below; only ERC-20 outputs need a Reactor allowance. + if (token != address(0) && IERC20(token).allowance(address(this), REACTOR) < type(uint256).max) { + IERC20(token).forceApprove(REACTOR, type(uint256).max); + } + } + + uint256 balance = address(this).balance; + if (balance > 0) payable(REACTOR).sendValue(balance); + } + + function setCallers(address[] calldata newCallers) public onlyOwner { + callers = newCallers; + emit SetCallers(newCallers); + } + + function _isCaller(address caller) internal view returns (bool) { + uint256 callersLength = callers.length; + for (uint256 i; i < callersLength; ++i) { + if (callers[i] == caller) return true; + } + return false; + } + + receive() external payable {} +} diff --git a/src/uniswapx/interfaces/ILiquidLaneUniswapXExecutor.sol b/src/uniswapx/interfaces/ILiquidLaneUniswapXExecutor.sol new file mode 100644 index 0000000..38667e2 --- /dev/null +++ b/src/uniswapx/interfaces/ILiquidLaneUniswapXExecutor.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity ^0.8.0; + +import {ILiquidLaneAdapter} from "../../interfaces/ILiquidLaneAdapter.sol"; +import {IUniswapXReactorCallback, UniswapXSignedOrder} from "./IUniswapXReactor.sol"; + +interface ILiquidLaneUniswapXExecutor is IUniswapXReactorCallback { + error NotCaller(); + error NotReactor(); + + struct FillRoute { + address adapter; + uint256 amountIn; + uint256 amountOut; + } + + struct FillCall { + FillRoute[] routes; + DiscountRoute[] discountRoutes; + } + + struct DiscountRoute { + address adapter; + uint256 amountIn; + ILiquidLaneAdapter.DiscountSwap discountSwap; + bytes protocolSignature; + } + + event SetCallers(address[] newCallers); + + function initialize(address owner, address[] calldata initCallers) external; + function callers(uint256 index) external view returns (address caller); + function execute(UniswapXSignedOrder calldata order, FillCall calldata fillCall) external; + function setCallers(address[] calldata newCallers) external; +} diff --git a/src/uniswapx/interfaces/IUniswapXReactor.sol b/src/uniswapx/interfaces/IUniswapXReactor.sol new file mode 100644 index 0000000..91994e5 --- /dev/null +++ b/src/uniswapx/interfaces/IUniswapXReactor.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity ^0.8.0; + +struct UniswapXOrderInfo { + address reactor; + address swapper; + uint256 nonce; + uint256 deadline; + address additionalValidationContract; + bytes additionalValidationData; +} + +struct UniswapXInputToken { + address token; + uint256 amount; + uint256 maxAmount; +} + +struct UniswapXOutputToken { + address token; + uint256 amount; + address recipient; +} + +struct UniswapXResolvedOrder { + UniswapXOrderInfo info; + UniswapXInputToken input; + UniswapXOutputToken[] outputs; + bytes sig; + bytes32 hash; +} + +struct UniswapXSignedOrder { + bytes order; + bytes sig; +} + +interface IUniswapXReactor { + function executeWithCallback(UniswapXSignedOrder calldata order, bytes calldata callbackData) external payable; +} + +interface IUniswapXReactorCallback { + function reactorCallback(UniswapXResolvedOrder[] memory resolvedOrders, bytes memory callbackData) external; +} diff --git a/test/deploy/DeployUniswapXExecutor.t.sol b/test/deploy/DeployUniswapXExecutor.t.sol new file mode 100644 index 0000000..423122d --- /dev/null +++ b/test/deploy/DeployUniswapXExecutor.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {DeployUniswapXExecutorBaseScript} from "../../script/deploy/base/DeployUniswapXExecutorBase.s.sol"; +import {LiquidLaneUniswapXExecutor} from "../../src/uniswapx/LiquidLaneUniswapXExecutor.sol"; +import {ILiquidLaneUniswapXExecutor} from "../../src/uniswapx/interfaces/ILiquidLaneUniswapXExecutor.sol"; +import {IUniswapXReactor, UniswapXSignedOrder} from "../../src/uniswapx/interfaces/IUniswapXReactor.sol"; + +import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; + +contract DeployUniswapXExecutorTest is Test { + bytes32 internal constant ERC1967_ADMIN_SLOT = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; + bytes32 internal constant ERC1967_IMPLEMENTATION_SLOT = + 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + + RecordingUniswapXReactor internal reactor; + address internal admin = makeAddr("admin"); + address internal proxyAdminOwner = makeAddr("proxyAdminOwner"); + address internal caller = makeAddr("caller"); + + function setUp() public { + reactor = new RecordingUniswapXReactor(); + } + + function testDeploysProxyWithConfigurationAndInitializerLocks() public { + DeployUniswapXExecutorBaseHarness harness = new DeployUniswapXExecutorBaseHarness(); + DeployUniswapXExecutorBaseScript.DeploymentData memory data = harness.runBase( + DeployUniswapXExecutorBaseScript.DeployParams({ + reactor: address(reactor), admin: admin, proxyAdminOwner: proxyAdminOwner, caller: caller + }) + ); + + assertTrue(address(data.executor) != data.implementation); + assertEq(data.executor.owner(), admin); + assertEq(data.executor.callers(0), caller); + assertEq(data.reactor, address(reactor)); + + address implementation = address(uint160(uint256(vm.load(address(data.executor), ERC1967_IMPLEMENTATION_SLOT)))); + assertEq(implementation, data.implementation); + + address proxyAdmin = address(uint160(uint256(vm.load(address(data.executor), ERC1967_ADMIN_SLOT)))); + assertEq(ProxyAdmin(proxyAdmin).owner(), proxyAdminOwner); + + ILiquidLaneUniswapXExecutor.FillCall memory fillCall = ILiquidLaneUniswapXExecutor.FillCall({ + routes: new ILiquidLaneUniswapXExecutor.FillRoute[](0), + discountRoutes: new ILiquidLaneUniswapXExecutor.DiscountRoute[](0) + }); + vm.prank(caller); + data.executor.execute(UniswapXSignedOrder({order: bytes(""), sig: bytes("")}), fillCall); + assertTrue(reactor.executeWithCallbackCalled()); + assertEq(reactor.executor(), address(data.executor)); + + address[] memory callers = new address[](1); + callers[0] = caller; + vm.expectRevert(Initializable.InvalidInitialization.selector); + LiquidLaneUniswapXExecutor(payable(data.implementation)).initialize(admin, callers); + vm.expectRevert(Initializable.InvalidInitialization.selector); + data.executor.initialize(admin, callers); + } +} + +contract DeployUniswapXExecutorBaseHarness is DeployUniswapXExecutorBaseScript { + function _startBroadcast() internal override {} + + function _stopBroadcast() internal override {} +} + +contract RecordingUniswapXReactor is IUniswapXReactor { + bool public executeWithCallbackCalled; + address public executor; + + function executeWithCallback(UniswapXSignedOrder calldata, bytes calldata) external payable override { + executeWithCallbackCalled = true; + executor = msg.sender; + } +} diff --git a/test/uniswapx/LiquidLaneUniswapXExecutor.t.sol b/test/uniswapx/LiquidLaneUniswapXExecutor.t.sol new file mode 100644 index 0000000..f6800d9 --- /dev/null +++ b/test/uniswapx/LiquidLaneUniswapXExecutor.t.sol @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {ILiquidLaneAdapter} from "../../src/interfaces/ILiquidLaneAdapter.sol"; +import {LiquidLaneUniswapXExecutor} from "../../src/uniswapx/LiquidLaneUniswapXExecutor.sol"; +import {ILiquidLaneUniswapXExecutor} from "../../src/uniswapx/interfaces/ILiquidLaneUniswapXExecutor.sol"; +import { + IUniswapXReactor, + IUniswapXReactorCallback, + UniswapXInputToken, + UniswapXOrderInfo, + UniswapXOutputToken, + UniswapXResolvedOrder, + UniswapXSignedOrder +} from "../../src/uniswapx/interfaces/IUniswapXReactor.sol"; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Address} from "@openzeppelin/contracts/utils/Address.sol"; +import {Initializable} from "@openzeppelin/contracts/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; + +contract LiquidLaneUniswapXExecutorTest is Test { + using Address for address payable; + + address internal caller = makeAddr("caller"); + address internal owner = makeAddr("owner"); + address internal proxyAdminOwner = makeAddr("proxyAdminOwner"); + address internal recipient = makeAddr("recipient"); + + TestToken internal inputToken; + TestToken internal outputToken; + MockUniswapXAdapter internal adapter; + MockUniswapXReactor internal reactor; + LiquidLaneUniswapXExecutor internal implementation; + LiquidLaneUniswapXExecutor internal executor; + + function setUp() public { + inputToken = new TestToken("Input", "IN"); + outputToken = new TestToken("Output", "OUT"); + adapter = new MockUniswapXAdapter(address(outputToken)); + reactor = new MockUniswapXReactor(); + + implementation = new LiquidLaneUniswapXExecutor(address(reactor)); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + proxyAdminOwner, + abi.encodeCall(LiquidLaneUniswapXExecutor.initialize, (owner, _callers(caller))) + ); + executor = LiquidLaneUniswapXExecutor(payable(address(proxy))); + + inputToken.mint(address(reactor), 10 ether); + outputToken.mint(address(adapter), 100 ether); + reactor.setOrder(_resolvedOrder(10 ether, _erc20Outputs(address(outputToken), 9 ether, recipient))); + } + + function testInitializeSetsOwnerAndCallers() public view { + assertEq(executor.owner(), owner); + assertEq(executor.callers(0), caller); + } + + function testInitializeCannotBeCalledTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + executor.initialize(makeAddr("intruder"), _callers(makeAddr("intruderCaller"))); + } + + function testImplementationInitializerIsDisabled() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + implementation.initialize(owner, _callers(caller)); + } + + function testExecuteRejectsOwnerWhenOwnerIsNotCaller() public { + vm.prank(owner); + vm.expectRevert(ILiquidLaneUniswapXExecutor.NotCaller.selector); + executor.execute(_signedOrder(), _emptyFillCall()); + } + + function testSetCallersAllowsNewCallerAndRevokesOldCaller() public { + address newCaller = makeAddr("newCaller"); + reactor.setOrder(_resolvedOrder(0, _emptyOutputs())); + vm.prank(owner); + executor.setCallers(_callers(newCaller)); + + vm.prank(caller); + vm.expectRevert(ILiquidLaneUniswapXExecutor.NotCaller.selector); + executor.execute(_signedOrder(), _emptyFillCall()); + + vm.prank(newCaller); + executor.execute(_signedOrder(), _emptyFillCall()); + } + + function testSetCallersRejectsNonOwner() public { + vm.prank(caller); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, caller)); + executor.setCallers(_callers(caller)); + } + + function testReactorCallbackRejectsNonReactor() public { + UniswapXResolvedOrder[] memory orders = + _resolvedOrders(_resolvedOrder(10 ether, _erc20Outputs(address(outputToken), 9 ether, recipient))); + vm.expectRevert(ILiquidLaneUniswapXExecutor.NotReactor.selector); + executor.reactorCallback(orders, abi.encode(_emptyFillCall())); + } + + function testReactorCallbackRejectsMalformedData() public { + UniswapXResolvedOrder[] memory orders = + _resolvedOrders(_resolvedOrder(10 ether, _erc20Outputs(address(outputToken), 9 ether, recipient))); + vm.prank(address(reactor)); + vm.expectRevert(); + executor.reactorCallback(orders, hex"01"); + } + + function testExecuteRoutesDirectFillAndKeepsInputAndOutputSurplus() public { + adapter.setDirectOutput(10 ether); + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](1); + routes[0] = _directRoute(address(adapter), 9 ether, 10 ether); + reactor.setOrder(_resolvedOrder(10 ether, _erc20Outputs(address(outputToken), 9 ether, recipient))); + + vm.prank(caller); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + + assertEq(inputToken.balanceOf(address(executor)), 1 ether); + assertEq(outputToken.balanceOf(address(executor)), 1 ether); + assertEq(outputToken.allowance(address(executor), address(reactor)), type(uint256).max); + } + + function testExecuteRoutesMultipleDirectFillsAndSettlesMixedErc20Outputs() public { + TestToken secondOutput = new TestToken("Second Output", "OUT2"); + MockUniswapXAdapter secondAdapter = new MockUniswapXAdapter(address(secondOutput)); + address secondRecipient = makeAddr("secondRecipient"); + adapter.setDirectOutput(4 ether); + secondAdapter.setDirectOutput(6 ether); + outputToken.mint(address(adapter), 4 ether); + secondOutput.mint(address(secondAdapter), 6 ether); + + UniswapXOutputToken[] memory outputs = new UniswapXOutputToken[](2); + outputs[0] = UniswapXOutputToken({token: address(outputToken), amount: 4 ether, recipient: recipient}); + outputs[1] = UniswapXOutputToken({token: address(secondOutput), amount: 5 ether, recipient: secondRecipient}); + reactor.setOrder(_resolvedOrder(10 ether, outputs)); + + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](2); + routes[0] = _directRoute(address(adapter), 4 ether, 4 ether); + routes[1] = _directRoute(address(secondAdapter), 6 ether, 6 ether); + vm.prank(caller); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + + assertEq(outputToken.balanceOf(recipient), 4 ether); + assertEq(secondOutput.balanceOf(secondRecipient), 5 ether); + assertEq(outputToken.balanceOf(address(executor)), 0); + assertEq(secondOutput.balanceOf(address(executor)), 1 ether); + assertEq(outputToken.allowance(address(executor), address(reactor)), type(uint256).max); + assertEq(secondOutput.allowance(address(executor), address(reactor)), type(uint256).max); + } + + function testExecuteRoutesDiscountFill() public { + adapter.setDiscountOutput(9 ether); + ILiquidLaneUniswapXExecutor.DiscountRoute[] memory discountRoutes = + new ILiquidLaneUniswapXExecutor.DiscountRoute[](1); + discountRoutes[0] = _discountRoute(address(adapter), 10 ether); + + vm.prank(caller); + executor.execute(_signedOrder(), _fillCall(new ILiquidLaneUniswapXExecutor.FillRoute[](0), discountRoutes)); + + assertEq(adapter.discountCalls(), 1); + assertEq(inputToken.balanceOf(address(adapter)), 10 ether); + assertEq(outputToken.balanceOf(recipient), 9 ether); + } + + function testExecuteRoutesDirectAndDiscountFillsTogether() public { + MockUniswapXAdapter discountAdapter = new MockUniswapXAdapter(address(outputToken)); + adapter.setDirectOutput(4 ether); + discountAdapter.setDiscountOutput(6 ether); + outputToken.mint(address(discountAdapter), 6 ether); + reactor.setOrder(_resolvedOrder(10 ether, _erc20Outputs(address(outputToken), 10 ether, recipient))); + + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](1); + routes[0] = _directRoute(address(adapter), 4 ether, 4 ether); + ILiquidLaneUniswapXExecutor.DiscountRoute[] memory discountRoutes = + new ILiquidLaneUniswapXExecutor.DiscountRoute[](1); + discountRoutes[0] = _discountRoute(address(discountAdapter), 6 ether); + vm.prank(caller); + executor.execute(_signedOrder(), _fillCall(routes, discountRoutes)); + + assertEq(adapter.directCalls(), 1); + assertEq(discountAdapter.discountCalls(), 1); + assertEq(inputToken.balanceOf(address(adapter)), 4 ether); + assertEq(inputToken.balanceOf(address(discountAdapter)), 6 ether); + } + + function testExecuteKeepsMaxApprovalWithoutReapproving() public { + ApprovalCountingToken countingOutput = new ApprovalCountingToken("Counting Output", "COUNT"); + MockUniswapXAdapter countingAdapter = new MockUniswapXAdapter(address(countingOutput)); + countingAdapter.setDirectOutput(9 ether); + countingOutput.mint(address(countingAdapter), 18 ether); + inputToken.mint(address(reactor), 10 ether); + reactor.setOrder(_resolvedOrder(10 ether, _erc20Outputs(address(countingOutput), 9 ether, recipient))); + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](1); + routes[0] = _directRoute(address(countingAdapter), 10 ether, 9 ether); + + vm.startPrank(caller); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + vm.stopPrank(); + + assertEq(countingOutput.allowance(address(executor), address(reactor)), type(uint256).max); + assertEq(countingOutput.approveCalls(), 1); + } + + function testExecuteForwardsNativeOutputAndReceivesReactorRefund() public { + MockUniswapXAdapter nativeAdapter = new MockUniswapXAdapter(address(0)); + reactor.setOrder(_resolvedOrder(10 ether, _nativeOutputs(2 ether, recipient))); + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](1); + routes[0] = _directRoute(address(nativeAdapter), 10 ether, 0); + vm.deal(address(this), 3 ether); + payable(address(executor)).sendValue(3 ether); + uint256 recipientBalanceBefore = recipient.balance; + + vm.prank(caller); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + + assertEq(recipient.balance, recipientBalanceBefore + 2 ether); + assertEq(address(executor).balance, 1 ether); + assertEq(address(reactor).balance, 0); + } + + function testExecuteBubblesAdapterRevertAndRollsBack() public { + adapter.setShouldRevert(true); + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](1); + routes[0] = _directRoute(address(adapter), 10 ether, 9 ether); + + vm.prank(caller); + vm.expectRevert(MockUniswapXAdapter.AdapterFailed.selector); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + + assertEq(inputToken.balanceOf(address(reactor)), 10 ether); + assertEq(inputToken.balanceOf(address(adapter)), 0); + assertEq(outputToken.balanceOf(recipient), 0); + assertEq(outputToken.allowance(address(executor), address(reactor)), 0); + } + + function testExecuteBubblesReactorOutputShortfallAndRollsBack() public { + adapter.setDirectOutput(8 ether); + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes = new ILiquidLaneUniswapXExecutor.FillRoute[](1); + routes[0] = _directRoute(address(adapter), 10 ether, 9 ether); + + vm.prank(caller); + vm.expectRevert( + abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, address(executor), 8 ether, 9 ether) + ); + executor.execute(_signedOrder(), _fillCall(routes, new ILiquidLaneUniswapXExecutor.DiscountRoute[](0))); + + assertEq(inputToken.balanceOf(address(reactor)), 10 ether); + assertEq(inputToken.balanceOf(address(adapter)), 0); + assertEq(outputToken.balanceOf(recipient), 0); + assertEq(outputToken.allowance(address(executor), address(reactor)), 0); + } + + function _signedOrder() internal pure returns (UniswapXSignedOrder memory) { + return UniswapXSignedOrder({order: hex"01", sig: hex"02"}); + } + + function _resolvedOrder(uint256 amountIn, UniswapXOutputToken[] memory outputs) + internal + returns (UniswapXResolvedOrder memory order) + { + order = UniswapXResolvedOrder({ + info: UniswapXOrderInfo({ + reactor: address(reactor), + swapper: makeAddr("swapper"), + nonce: 1, + deadline: block.timestamp + 1 hours, + additionalValidationContract: address(0), + additionalValidationData: "" + }), + input: UniswapXInputToken({token: address(inputToken), amount: amountIn, maxAmount: amountIn}), + outputs: outputs, + sig: "", + hash: keccak256("order") + }); + } + + function _resolvedOrders(UniswapXResolvedOrder memory order) + internal + pure + returns (UniswapXResolvedOrder[] memory orders) + { + orders = new UniswapXResolvedOrder[](1); + orders[0] = order; + } + + function _erc20Outputs(address token, uint256 amount, address outputRecipient) + internal + pure + returns (UniswapXOutputToken[] memory outputs) + { + outputs = new UniswapXOutputToken[](1); + outputs[0] = UniswapXOutputToken({token: token, amount: amount, recipient: outputRecipient}); + } + + function _nativeOutputs(uint256 amount, address outputRecipient) + internal + pure + returns (UniswapXOutputToken[] memory outputs) + { + outputs = new UniswapXOutputToken[](1); + outputs[0] = UniswapXOutputToken({token: address(0), amount: amount, recipient: outputRecipient}); + } + + function _emptyOutputs() internal pure returns (UniswapXOutputToken[] memory outputs) { + outputs = new UniswapXOutputToken[](0); + } + + function _callers(address caller_) internal pure returns (address[] memory callers_) { + callers_ = new address[](1); + callers_[0] = caller_; + } + + function _emptyFillCall() internal pure returns (ILiquidLaneUniswapXExecutor.FillCall memory fillCall) { + fillCall = _fillCall( + new ILiquidLaneUniswapXExecutor.FillRoute[](0), new ILiquidLaneUniswapXExecutor.DiscountRoute[](0) + ); + } + + function _fillCall( + ILiquidLaneUniswapXExecutor.FillRoute[] memory routes, + ILiquidLaneUniswapXExecutor.DiscountRoute[] memory discountRoutes + ) internal pure returns (ILiquidLaneUniswapXExecutor.FillCall memory fillCall) { + fillCall = ILiquidLaneUniswapXExecutor.FillCall({routes: routes, discountRoutes: discountRoutes}); + } + + function _directRoute(address routeAdapter, uint256 amountIn, uint256 amountOut) + internal + pure + returns (ILiquidLaneUniswapXExecutor.FillRoute memory) + { + return ILiquidLaneUniswapXExecutor.FillRoute({adapter: routeAdapter, amountIn: amountIn, amountOut: amountOut}); + } + + function _discountRoute(address routeAdapter, uint256 amountIn) + internal + view + returns (ILiquidLaneUniswapXExecutor.DiscountRoute memory) + { + return ILiquidLaneUniswapXExecutor.DiscountRoute({ + adapter: routeAdapter, + amountIn: amountIn, + discountSwap: ILiquidLaneAdapter.DiscountSwap({ + discount: ILiquidLaneAdapter.Discount({ + tokenToRedeem: address(inputToken), + discount: 0, + signer: address(1), + protocol: address(2), + nonce: 1, + deadline: uint48(block.timestamp + 1 hours) + }), + signerSignature: hex"01", + protocolDeadline: uint48(block.timestamp + 1 hours) + }), + protocolSignature: hex"02" + }); + } +} + +contract MockUniswapXReactor is IUniswapXReactor { + using Address for address payable; + using SafeERC20 for IERC20; + + address internal tokenIn; + uint256 internal amountIn; + UniswapXOutputToken[] internal storedOutputs; + + receive() external payable {} + + function setOrder(UniswapXResolvedOrder memory newOrder) external { + tokenIn = newOrder.input.token; + amountIn = newOrder.input.amount; + delete storedOutputs; + for (uint256 i; i < newOrder.outputs.length; ++i) { + storedOutputs.push(newOrder.outputs[i]); + } + } + + function executeWithCallback(UniswapXSignedOrder calldata, bytes calldata callbackData) external payable { + IERC20(tokenIn).safeTransfer(msg.sender, amountIn); + + UniswapXResolvedOrder[] memory orders = new UniswapXResolvedOrder[](1); + UniswapXOutputToken[] memory outputs = new UniswapXOutputToken[](storedOutputs.length); + for (uint256 i; i < storedOutputs.length; ++i) { + outputs[i] = storedOutputs[i]; + } + orders[0] = UniswapXResolvedOrder({ + info: UniswapXOrderInfo({ + reactor: address(this), + swapper: address(1), + nonce: 1, + deadline: block.timestamp + 1 hours, + additionalValidationContract: address(0), + additionalValidationData: "" + }), + input: UniswapXInputToken({token: tokenIn, amount: amountIn, maxAmount: amountIn}), + outputs: outputs, + sig: "", + hash: keccak256("order") + }); + IUniswapXReactorCallback(msg.sender).reactorCallback(orders, callbackData); + for (uint256 i; i < outputs.length; ++i) { + if (outputs[i].token == address(0)) { + payable(outputs[i].recipient).sendValue(outputs[i].amount); + } else { + IERC20(outputs[i].token).safeTransferFrom(msg.sender, outputs[i].recipient, outputs[i].amount); + } + } + uint256 nativeRefund = address(this).balance; + if (nativeRefund > 0) payable(msg.sender).sendValue(nativeRefund); + } +} + +contract MockUniswapXAdapter is ILiquidLaneAdapter { + using Address for address payable; + using SafeERC20 for IERC20; + + error AdapterFailed(); + + address internal immutable outputToken; + uint256 internal directOutput; + uint256 internal discountOutput; + uint256 public directCalls; + uint256 public discountCalls; + bool internal shouldRevert; + + constructor(address outputToken_) { + outputToken = outputToken_; + } + + receive() external payable {} + + function setDirectOutput(uint256 newDirectOutput) external { + directOutput = newDirectOutput; + } + + function setDiscountOutput(uint256 newDiscountOutput) external { + discountOutput = newDiscountOutput; + } + + function setShouldRevert(bool newShouldRevert) external { + shouldRevert = newShouldRevert; + } + + function getAmountOut(address, uint256) external view returns (uint256) { + return directOutput; + } + + function getMaxAssets(address) external view returns (uint256) { + return outputToken == address(0) ? address(this).balance : IERC20(outputToken).balanceOf(address(this)); + } + + function minDiscount(address) external pure returns (uint256) { + return 0; + } + + function swap(Swap calldata swap_) external { + ++directCalls; + if (shouldRevert) revert AdapterFailed(); + _transferOutput(swap_.recipient, directOutput); + } + + function swap(SignedSwap calldata, bytes calldata) external pure { + revert("unsupported"); + } + + function swap(DiscountSwap calldata, bytes calldata, address recipient_, uint256) + external + returns (uint256 amountOut) + { + ++discountCalls; + if (shouldRevert) revert AdapterFailed(); + amountOut = discountOutput; + _transferOutput(recipient_, amountOut); + } + + function _transferOutput(address recipient_, uint256 amount) internal { + if (outputToken == address(0)) { + payable(recipient_).sendValue(amount); + } else { + IERC20(outputToken).safeTransfer(recipient_, amount); + } + } +} + +contract TestToken is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +contract ApprovalCountingToken is TestToken { + uint256 public approveCalls; + + constructor(string memory name_, string memory symbol_) TestToken(name_, symbol_) {} + + function _approve(address owner_, address spender, uint256 value, bool emitEvent) internal override { + if (emitEvent) ++approveCalls; + super._approve(owner_, spender, value, emitEvent); + } +}