diff --git a/lib/core b/lib/core index 3f0e820..8e5c033 160000 --- a/lib/core +++ b/lib/core @@ -1 +1 @@ -Subproject commit 3f0e820c1b5a529724b3d4c378db60dcf7e3e2f7 +Subproject commit 8e5c03316c8bf7420b71abb3abb4a78c9dd9b6bd diff --git a/script/DeployExecutor.s.sol b/script/DeployExecutor.s.sol index 7d1f35b..99a9ea8 100644 --- a/script/DeployExecutor.s.sol +++ b/script/DeployExecutor.s.sol @@ -12,6 +12,8 @@ contract DeployExecutorScript is DeployExecutorBaseScript { address public constant REACTOR = 0xC323B898d7E4105E3980082B74CC5D4602996B10; // Executor owner. Defaults to the sender when left zero. address public constant ADMIN = 0x0000000000000000000000000000000000000000; + // Owner of the proxy's ProxyAdmin, authorized to upgrade. Defaults to the sender when left zero. + address public constant PROXY_ADMIN_OWNER = 0x0000000000000000000000000000000000000000; // Initial caller allowed to invoke fill entrypoints. Defaults to the sender when left zero. address public constant CALLER = 0x0000000000000000000000000000000000000000; @@ -21,6 +23,7 @@ contract DeployExecutorScript is DeployExecutorBaseScript { DeployParams({ reactor: REACTOR, admin: ADMIN == address(0) ? owner : ADMIN, + proxyAdminOwner: PROXY_ADMIN_OWNER == address(0) ? owner : PROXY_ADMIN_OWNER, caller: CALLER == address(0) ? owner : CALLER }) ); diff --git a/script/DeployLifiExecutor.s.sol b/script/DeployLifiExecutor.s.sol new file mode 100644 index 0000000..77f7c40 --- /dev/null +++ b/script/DeployLifiExecutor.s.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {DeployLifiExecutorBaseScript} from "./deploy/base/DeployLifiExecutorBase.s.sol"; + +// forge script script/DeployLifiExecutor.s.sol:DeployLifiExecutorScript --rpc-url=RPC --broadcast + +contract DeployLifiExecutorScript is DeployLifiExecutorBaseScript { + // Configurations - UPDATE THESE BEFORE DEPLOYMENT + + // LI.FI OIF InputSettlerEscrow this executor finalises orders through. + address public constant INPUT_SETTLER = 0x000025c3226C00B2Cdc200005a1600509f4e00C0; + // LI.FI OIF OutputSettler this executor fills and attests outputs through. + address public constant OUTPUT_SETTLER = 0x0000000000eC36B683C2E6AC89e9A75989C22a2e; + // Executor owner. Defaults to the sender when left zero. + address public constant ADMIN = 0x0000000000000000000000000000000000000000; + // Owner of the proxy's ProxyAdmin, authorized to upgrade. Defaults to the sender when left zero. + address public constant PROXY_ADMIN_OWNER = 0x0000000000000000000000000000000000000000; + // Initial caller allowed to invoke finalise and register with LI.FI. Defaults to the sender when left zero. + address public constant CALLER = 0x0000000000000000000000000000000000000000; + + function run() public returns (DeploymentData memory data) { + address owner = _scriptOwner(); + data = runBase( + DeployParams({ + inputSettler: INPUT_SETTLER, + outputSettler: OUTPUT_SETTLER, + admin: ADMIN == address(0) ? owner : ADMIN, + proxyAdminOwner: PROXY_ADMIN_OWNER == address(0) ? owner : PROXY_ADMIN_OWNER, + caller: CALLER == address(0) ? owner : CALLER + }) + ); + } +} diff --git a/script/deploy/base/DeployExecutorBase.s.sol b/script/deploy/base/DeployExecutorBase.s.sol index 47ff65f..c0e919c 100644 --- a/script/deploy/base/DeployExecutorBase.s.sol +++ b/script/deploy/base/DeployExecutorBase.s.sol @@ -3,19 +3,24 @@ pragma solidity ^0.8.28; import {Script, console2} from "forge-std/Script.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + import {Executor} from "../../../src/Executor.sol"; contract DeployExecutorBaseScript is Script { struct DeployParams { address reactor; address admin; + address proxyAdminOwner; address caller; } struct DeploymentData { Executor executor; + address implementation; address reactor; address admin; + address proxyAdminOwner; address caller; } @@ -26,11 +31,19 @@ contract DeployExecutorBaseScript is Script { callers[0] = params.caller; _startBroadcast(); - data.executor = new Executor(params.reactor, params.admin, callers); + Executor implementation = new Executor(params.reactor); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + params.proxyAdminOwner, + abi.encodeCall(Executor.initialize, (params.admin, callers)) + ); _stopBroadcast(); + data.executor = Executor(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); @@ -53,6 +66,7 @@ contract DeployExecutorBaseScript is Script { 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"); } @@ -63,9 +77,11 @@ contract DeployExecutorBaseScript is Script { function _logDeployment(DeploymentData memory data) internal view { console2.log("Deployed RFQ Executor"); - console2.log(" executor: ", address(data.executor)); - console2.log(" reactor: ", data.reactor); - console2.log(" admin: ", data.admin); - console2.log(" caller: ", data.caller); + 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/script/deploy/base/DeployLifiExecutorBase.s.sol b/script/deploy/base/DeployLifiExecutorBase.s.sol new file mode 100644 index 0000000..9753883 --- /dev/null +++ b/script/deploy/base/DeployLifiExecutorBase.s.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.28; + +import {Script, console2} from "forge-std/Script.sol"; + +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; + +import {LiquidLaneLifiExecutor} from "../../../src/lifi/LiquidLaneLifiExecutor.sol"; + +contract DeployLifiExecutorBaseScript is Script { + struct DeployParams { + address inputSettler; + address outputSettler; + address admin; + address proxyAdminOwner; + address caller; + } + + struct DeploymentData { + LiquidLaneLifiExecutor executor; + address implementation; + address inputSettler; + address outputSettler; + 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(); + LiquidLaneLifiExecutor implementation = new LiquidLaneLifiExecutor(params.inputSettler, params.outputSettler); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(implementation), + params.proxyAdminOwner, + abi.encodeCall(LiquidLaneLifiExecutor.initialize, (params.admin, callers)) + ); + _stopBroadcast(); + + data.executor = LiquidLaneLifiExecutor(address(proxy)); + data.implementation = address(implementation); + data.inputSettler = params.inputSettler; + data.outputSettler = params.outputSettler; + 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 _scriptOwner() internal view virtual returns (address owner_) { + (,, address origin) = vm.readCallers(); + owner_ = origin == address(0) ? msg.sender : origin; + } + + function _validateParams(DeployParams memory params) internal pure { + require(params.inputSettler != address(0), "invalid input settler"); + require(params.outputSettler != address(0), "invalid output settler"); + 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(data.executor.owner() == data.admin); + assert(data.executor.INPUT_SETTLER() == data.inputSettler); + assert(data.executor.OUTPUT_SETTLER() == data.outputSettler); + assert(data.executor.isCaller(data.caller)); + } + + function _logDeployment(DeploymentData memory data) internal view { + console2.log("Deployed LI.FI Executor"); + console2.log(" executor: ", address(data.executor)); + console2.log(" implementation: ", data.implementation); + console2.log(" inputSettler: ", data.inputSettler); + console2.log(" outputSettler: ", data.outputSettler); + console2.log(" admin: ", data.admin); + console2.log(" proxyAdminOwner: ", data.proxyAdminOwner); + console2.log(" caller: ", data.caller); + } +} diff --git a/src/Executor.sol b/src/Executor.sol index 0dd2d02..21028a9 100644 --- a/src/Executor.sol +++ b/src/Executor.sol @@ -8,12 +8,15 @@ import {IReactor, NATIVE} from "./interfaces/IReactor.sol"; import {Address} from "@openzeppelin/contracts/utils/Address.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; /// @title Executor /// @notice Caller-gated executor that forwards fills into Reactor and handles execution callbacks. -contract Executor is Ownable, IExecutor { +/// @dev Deployed behind a transparent proxy; the Reactor address is immutable in the implementation +/// while ownership and the caller list live in proxy storage set by {initialize}. +contract Executor is Initializable, OwnableUpgradeable, IExecutor { using Address for address payable; using SafeERC20 for IERC20; using Address for address; @@ -30,8 +33,14 @@ contract Executor is Ownable, IExecutor { /* CONSTRUCTOR */ - constructor(address reactor, address owner, address[] memory initCallers) Ownable(owner) { + constructor(address reactor) { REACTOR = reactor; + _disableInitializers(); + } + + /// @inheritdoc IExecutor + function initialize(address owner, address[] calldata initCallers) external initializer { + __Ownable_init(owner); callers = initCallers; } diff --git a/src/interfaces/IExecutor.sol b/src/interfaces/IExecutor.sol index 31ad6e4..0a0a234 100644 --- a/src/interfaces/IExecutor.sol +++ b/src/interfaces/IExecutor.sol @@ -45,6 +45,13 @@ interface IExecutor { /* FUNCTIONS */ + /** + * @notice Initializes the proxy with its owner and allowed caller list. + * @param owner Owner authorized to manage the caller list. + * @param initCallers Initial addresses allowed to call the fill entrypoints. + */ + function initialize(address owner, address[] calldata initCallers) external; + /** * @notice Returns an allowed caller by index. * @param index Caller index. diff --git a/src/lifi/LiquidLaneLifiExecutor.sol b/src/lifi/LiquidLaneLifiExecutor.sol new file mode 100644 index 0000000..5027ee5 --- /dev/null +++ b/src/lifi/LiquidLaneLifiExecutor.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {ILiquidLaneAdapter} from "../interfaces/ILiquidLaneAdapter.sol"; +import {IInputSettler} from "./interfaces/IInputSettler.sol"; +import {ILiquidLaneLifiExecutor} from "./interfaces/ILiquidLaneLifiExecutor.sol"; +import {IOutputSettler} from "./interfaces/IOutputSettler.sol"; + +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {SignatureChecker} from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {EIP712Upgradeable} from "@openzeppelin/contracts-upgradeable/utils/cryptography/EIP712Upgradeable.sol"; + +/// @title LiquidLaneLifiExecutor +/// @notice LI.FI same-chain solver that redeems released inputs and fills the order output atomically. +/// @dev Order lifecycle, fill deadlines, auction pricing, exclusivity, and discount terms are delegated +/// to the input settler, the output settler, and the LiquidLane adapters, which enforce them +/// authoritatively; the executor only routes the received inputs and settles the generated output. +/// @dev Deployed behind a transparent proxy; the settler addresses are immutable in the implementation +/// while ownership, the caller list, and the EIP-712 domain live in proxy storage set by {initialize}. +contract LiquidLaneLifiExecutor is Initializable, OwnableUpgradeable, EIP712Upgradeable, ILiquidLaneLifiExecutor { + using SafeERC20 for IERC20; + + /// @notice EIP-712 typehash wrapping a LI.FI registration message so signatures are domain-bound. + bytes32 public constant LIFI_REGISTRATION_TYPEHASH = keccak256("LifiRegistration(bytes32 messageHash)"); + + /* IMMUTABLES */ + + /// @inheritdoc ILiquidLaneLifiExecutor + address public immutable INPUT_SETTLER; + /// @inheritdoc ILiquidLaneLifiExecutor + address public immutable OUTPUT_SETTLER; + + /* STATE VARIABLES */ + + /// @inheritdoc ILiquidLaneLifiExecutor + address[] public callers; + + /* CONSTRUCTOR */ + + constructor(address inputSettler, address outputSettler) { + INPUT_SETTLER = inputSettler; + OUTPUT_SETTLER = outputSettler; + _disableInitializers(); + } + + /// @inheritdoc ILiquidLaneLifiExecutor + function initialize(address owner_, address[] calldata initCallers) external initializer { + __Ownable_init(owner_); + __EIP712_init("LiquidLaneLifiExecutor", "1"); + callers = initCallers; + } + + /* MODIFIERS */ + + /// @dev Reverts unless the caller is in the allowed caller list. + modifier onlyCaller() { + if (!_isCaller(msg.sender)) revert NotCaller(); + _; + } + + /* FINALISE WRAPPER */ + + /// @inheritdoc ILiquidLaneLifiExecutor + function isCaller(address caller) external view returns (bool) { + return _isCaller(caller); + } + + /// @inheritdoc ILiquidLaneLifiExecutor + function finaliseWithCurrentTimestamp( + IInputSettler.StandardOrder calldata order, + FillRoute[] calldata routes, + DiscountRoute[] calldata discountRoutes + ) external onlyCaller { + bytes32 executorId = bytes32(uint256(uint160(address(this)))); + IInputSettler.SolveParams[] memory solveParams = new IInputSettler.SolveParams[](1); + solveParams[0] = IInputSettler.SolveParams({timestamp: uint32(block.timestamp), solver: executorId}); + IInputSettler(INPUT_SETTLER) + .finalise( + order, + solveParams, + executorId, + abi.encode( + FillCall({ + orderId: IInputSettler(INPUT_SETTLER).orderIdentifier(order), + output: order.outputs[0], + fillDeadline: order.fillDeadline, + routes: routes, + discountRoutes: discountRoutes + }) + ) + ); + } + + /* IINPUTCALLBACK */ + + /// @notice Called by the LI.FI input settler during finalise, after inputs are transferred here. + function orderFinalised(uint256[2][] calldata inputs, bytes calldata executionData) external { + if (INPUT_SETTLER != msg.sender) revert NotInputSettler(); + + FillCall memory fillCall = abi.decode(executionData, (FillCall)); + + // Adapters assume their input has already been transferred to them before the swap call. + // forge-lint: disable-next-line(unsafe-typecast) + address tokenIn = address(uint160(inputs[0][0])); + 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); + } + + // The output settler resolves the context-dependent amount it is owed and pulls it, + // reverting on shortfall. + address outputToken = address(uint160(uint256(fillCall.output.token))); + if (IERC20(outputToken).allowance(address(this), OUTPUT_SETTLER) < type(uint256).max) { + IERC20(outputToken).forceApprove(OUTPUT_SETTLER, type(uint256).max); + } + bytes32 solver = bytes32(uint256(uint160(address(this)))); + IOutputSettler(OUTPUT_SETTLER) + .fill(fillCall.orderId, fillCall.output, fillCall.fillDeadline, abi.encode(solver)); + IOutputSettler(OUTPUT_SETTLER) + .setAttestation(fillCall.orderId, solver, uint32(block.timestamp), fillCall.output); + } + + /* OWNER */ + + /// @inheritdoc ILiquidLaneLifiExecutor + function setCallers(address[] calldata newCallers) external onlyOwner { + callers = newCallers; + emit SetCallers(newCallers); + } + + /* EIP-1271 */ + + /// @inheritdoc ILiquidLaneLifiExecutor + function lifiRegistrationDigest(bytes32 messageHash) public view returns (bytes32) { + return _hashTypedDataV4(keccak256(abi.encode(LIFI_REGISTRATION_TYPEHASH, messageHash))); + } + + /// @inheritdoc IERC1271 + /// @dev The LI.FI registration message is wrapped in this executor's EIP-712 domain and accepted + /// only if signed by an authorized caller, binding the signature to both the signer and this proxy. + function isValidSignature(bytes32 hash, bytes calldata signature) external view returns (bytes4) { + bytes32 digest = lifiRegistrationDigest(hash); + uint256 callersLength = callers.length; + for (uint256 i; i < callersLength; ++i) { + if (SignatureChecker.isValidSignatureNowCalldata(callers[i], digest, signature)) { + return IERC1271.isValidSignature.selector; + } + } + return 0xffffffff; + } + + /* INTERNAL */ + + /// @dev Returns whether `caller` can invoke the finalise entrypoint. + 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; + } +} diff --git a/src/lifi/interfaces/IInputCallback.sol b/src/lifi/interfaces/IInputCallback.sol new file mode 100644 index 0000000..186a8fa --- /dev/null +++ b/src/lifi/interfaces/IInputCallback.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @title IInputCallback + * @notice OIF callback invoked after an input settler transfers order inputs to the destination. + */ +interface IInputCallback { + /** + * @notice Handles order inputs delivered to the callback destination. + * @param inputs Order input token ids and amounts. + * @param executionData Callback-specific execution data. + */ + function orderFinalised(uint256[2][] calldata inputs, bytes calldata executionData) external; +} diff --git a/src/lifi/interfaces/IInputSettler.sol b/src/lifi/interfaces/IInputSettler.sol new file mode 100644 index 0000000..591cad5 --- /dev/null +++ b/src/lifi/interfaces/IInputSettler.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {MandateOutput} from "./IOutputSettler.sol"; + +/** + * @title IInputSettler + * @notice Minimal OIF InputSettler surface used by the LI.FI executor. + */ +interface IInputSettler { + struct StandardOrder { + address user; + uint256 nonce; + uint256 originChainId; + uint32 expires; + uint32 fillDeadline; + address inputOracle; + uint256[2][] inputs; + MandateOutput[] outputs; + } + + struct SolveParams { + uint32 timestamp; + bytes32 solver; + } + + function orderIdentifier(StandardOrder calldata order) external view returns (bytes32 orderId); + + function finalise( + StandardOrder calldata order, + SolveParams[] calldata solveParams, + bytes32 destination, + bytes calldata call + ) external; +} diff --git a/src/lifi/interfaces/ILiquidLaneLifiExecutor.sol b/src/lifi/interfaces/ILiquidLaneLifiExecutor.sol new file mode 100644 index 0000000..97b32ea --- /dev/null +++ b/src/lifi/interfaces/ILiquidLaneLifiExecutor.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity ^0.8.0; + +import {IInputCallback} from "./IInputCallback.sol"; +import {IInputSettler} from "./IInputSettler.sol"; +import {MandateOutput} from "./IOutputSettler.sol"; +import {ILiquidLaneAdapter} from "../../interfaces/ILiquidLaneAdapter.sol"; + +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; + +/** + * @title ILiquidLaneLifiExecutor + * @notice LI.FI same-chain solver and executor callback for on-chain orders. + */ +interface ILiquidLaneLifiExecutor is IInputCallback, IERC1271 { + /* ERRORS */ + + error NotCaller(); + error NotInputSettler(); + + /* EVENTS */ + + event SetCallers(address[] newCallers); + + /* STRUCTS */ + + /** + * @notice One atomic direct-swap LiquidLane redemption leg. + * @param adapter LiquidLane adapter selected by the solver. + * @param amountIn Order-input amount routed to the adapter. + * @param amountOut Output amount requested from the adapter. + */ + struct FillRoute { + address adapter; + uint256 amountIn; + uint256 amountOut; + } + + /** + * @notice One atomic discount-backed LiquidLane redemption leg. + * @param adapter LiquidLane adapter selected by the solver. + * @param amountIn Order-input amount routed to the adapter. + * @param discountSwap Reusable signer policy plus the fresh protocol deadline. + * @param protocolSignature Fresh protocol cosign verified by the LiquidLane adapter. + */ + struct DiscountRoute { + address adapter; + uint256 amountIn; + ILiquidLaneAdapter.DiscountSwap discountSwap; + bytes protocolSignature; + } + + /** + * @notice Callback payload constructed by `finaliseWithCurrentTimestamp` from the order itself. + * @param orderId OIF order id. + * @param output Single output to fill and attest. + * @param fillDeadline Fill deadline carried by the order. + * @param routes Direct-swap LiquidLane legs selected by the solver. + * @param discountRoutes Discount-backed LiquidLane legs selected by the solver. + */ + struct FillCall { + bytes32 orderId; + MandateOutput output; + uint32 fillDeadline; + FillRoute[] routes; + DiscountRoute[] discountRoutes; + } + + /* FUNCTIONS */ + + function INPUT_SETTLER() external view returns (address inputSettler); + function OUTPUT_SETTLER() external view returns (address outputSettler); + function callers(uint256 index) external view returns (address caller); + function initialize(address owner, address[] calldata initCallers) external; + function finaliseWithCurrentTimestamp( + IInputSettler.StandardOrder calldata order, + FillRoute[] calldata routes, + DiscountRoute[] calldata discountRoutes + ) external; + function isCaller(address caller) external view returns (bool allowed); + function lifiRegistrationDigest(bytes32 messageHash) external view returns (bytes32 digest); + function setCallers(address[] calldata newCallers) external; +} diff --git a/src/lifi/interfaces/IOutputSettler.sol b/src/lifi/interfaces/IOutputSettler.sol new file mode 100644 index 0000000..5aa21d0 --- /dev/null +++ b/src/lifi/interfaces/IOutputSettler.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/** + * @notice OIF output description used by LI.FI same-chain intents. + */ +struct MandateOutput { + bytes32 oracle; + bytes32 settler; + uint256 chainId; + bytes32 token; + uint256 amount; + bytes32 recipient; + bytes callbackData; + bytes context; +} + +/** + * @title IOutputSettler + * @notice Minimal OIF OutputSettler surface used by the LI.FI callback. + */ +interface IOutputSettler { + /** + * @notice Fills one output and transfers the output token from the caller. + * @param orderId OIF order id. + * @param output Output to satisfy. + * @param fillDeadline Fill deadline carried by the order. + * @param fillerData Solver identifier. + * @return fillRecordHash Output settler fill record hash. + */ + function fill(bytes32 orderId, MandateOutput calldata output, uint48 fillDeadline, bytes calldata fillerData) + external + payable + returns (bytes32 fillRecordHash); + + /** + * @notice Stores a same-chain attestation for a filled output. + * @param orderId OIF order id. + * @param solver Solver identifier. + * @param timestamp Fill timestamp. + * @param output Filled output. + */ + function setAttestation(bytes32 orderId, bytes32 solver, uint32 timestamp, MandateOutput calldata output) external; +} diff --git a/test/CoreIntegration.t.sol b/test/CoreIntegration.t.sol index bf1ec78..e7fe737 100644 --- a/test/CoreIntegration.t.sol +++ b/test/CoreIntegration.t.sol @@ -16,6 +16,7 @@ import {IReactor, ORDER_TYPEHASH, OUTPUT_TYPEHASH, REQUEST_TYPEHASH} from "../sr import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {Test} from "forge-std/Test.sol"; @@ -46,7 +47,14 @@ contract LiquidLaneIntegrationTest is Test { adapterFactory.setEntity(address(adapter), true); adapterFactory.setEntity(address(secondaryAdapter), true); reactor = new Reactor(address(adapterFactory)); - executor = new Executor(address(reactor), address(this), _callers(filler)); + Executor executorImpl = new Executor(address(reactor)); + executor = Executor( + payable(new TransparentUpgradeableProxy( + address(executorImpl), + makeAddr("proxyAdminOwner"), + abi.encodeCall(Executor.initialize, (address(this), _callers(filler))) + )) + ); rwa = new IntegrationERC20("RWA", "RWA"); outputToken = new IntegrationERC20("USD", "USD"); diff --git a/test/Reactor.t.sol b/test/Reactor.t.sol index 15cafdd..b95e6f0 100644 --- a/test/Reactor.t.sol +++ b/test/Reactor.t.sol @@ -11,6 +11,8 @@ import {IReactor, NATIVE, ORDER_TYPEHASH, OUTPUT_TYPEHASH, REQUEST_TYPEHASH} fro import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {ReentrancyGuardTransient} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; import {Test} from "forge-std/Test.sol"; @@ -30,6 +32,7 @@ contract ReactorTest is Test { address internal vault1 = makeAddr("vault1"); address internal vault0Account = makeAddr("vault0Account"); address internal vault1Account = makeAddr("vault1Account"); + address internal proxyAdminOwner = makeAddr("proxyAdminOwner"); MockAdapter internal adapter; MockAdapter internal secondaryAdapter; @@ -48,7 +51,7 @@ contract ReactorTest is Test { adapterFactory.setEntity(address(secondaryAdapter), true); callTarget = new MockCallTarget(); reactor = new Reactor(address(adapterFactory)); - executor = new Executor(address(reactor), address(this), _callers(filler)); + executor = _deployExecutor(address(reactor), address(this), _callers(filler)); rwa = new MockERC20("RWA", "RWA"); outputToken = new MockERC20("USD", "USD"); @@ -246,7 +249,7 @@ contract ReactorTest is Test { function testExecutorRequiresCaller() public { Reactor customReactor = new Reactor(address(adapterFactory)); - Executor lockedExecutor = new Executor(address(customReactor), address(this), new address[](0)); + Executor lockedExecutor = _deployExecutor(address(customReactor), address(this), new address[](0)); outputToken.mint(address(lockedExecutor), 5 ether); @@ -276,8 +279,25 @@ contract ReactorTest is Test { assertEq(adapter.signedSwapCount(), 0); } + function testExecutorInitializeSetsOwnerAndCallers() public view { + assertEq(executor.owner(), address(this)); + assertEq(executor.callers(0), filler); + } + + function testExecutorInitializeCannotBeCalledTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + executor.initialize(makeAddr("intruder"), _callers(filler)); + } + + function testExecutorImplementationInitializerIsDisabled() public { + Executor impl = new Executor(address(reactor)); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + impl.initialize(makeAddr("intruder"), _callers(filler)); + } + function testFillRevertsIfExecutorDoesNotMatchOrderFiller() public { - Executor otherExecutor = new Executor(address(reactor), address(this), _callers(filler)); + Executor otherExecutor = _deployExecutor(address(reactor), address(this), _callers(filler)); IReactor.Output[] memory outputs = new IReactor.Output[](1); outputs[0] = IReactor.Output({token: address(outputToken), amount: 5 ether, recipient: swapper}); @@ -887,6 +907,17 @@ contract ReactorTest is Test { callers_[0] = caller; } + function _deployExecutor(address reactor_, address owner_, address[] memory initCallers) + internal + returns (Executor) + { + Executor impl = new Executor(reactor_); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), proxyAdminOwner, abi.encodeCall(Executor.initialize, (owner_, initCallers)) + ); + return Executor(payable(address(proxy))); + } + function _order(IReactor.Output[] memory outputs) internal view returns (IReactor.Order memory) { return _order(outputs, 10 ether, address(executor)); } @@ -1228,6 +1259,8 @@ contract ReentrantNativeRecipient is IExecutor { bytes calldata ) external {} + function initialize(address, address[] calldata) external {} + function setCallers(address[] calldata) external {} function callers(uint256) external pure returns (address) { diff --git a/test/ReactorFork.t.sol b/test/ReactorFork.t.sol index 34162d3..5e7e6fa 100644 --- a/test/ReactorFork.t.sol +++ b/test/ReactorFork.t.sol @@ -9,6 +9,7 @@ import {ILiquidLaneAdapter} from "../src/interfaces/ILiquidLaneAdapter.sol"; import {IReactor, ORDER_TYPEHASH, OUTPUT_TYPEHASH, REQUEST_TYPEHASH} from "../src/interfaces/IReactor.sol"; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; import {Test} from "forge-std/Test.sol"; @@ -47,7 +48,14 @@ contract ReactorMainnetForkTest is Test { adapterFactory = new ForkAdapterFactory(); adapterFactory.setEntity(address(adapter), true); reactor = new Reactor(address(adapterFactory)); - executor = new Executor(address(reactor), address(this), _callers(filler)); + Executor executorImpl = new Executor(address(reactor)); + executor = Executor( + payable(new TransparentUpgradeableProxy( + address(executorImpl), + makeAddr("proxyAdminOwner"), + abi.encodeCall(Executor.initialize, (address(this), _callers(filler))) + )) + ); adapter.setAccount(vault, DAI, vaultAccount); diff --git a/test/deploy/DeployExecutor.t.sol b/test/deploy/DeployExecutor.t.sol new file mode 100644 index 0000000..b926a4c --- /dev/null +++ b/test/deploy/DeployExecutor.t.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {Executor} from "../../src/Executor.sol"; +import {LiquidLaneLifiExecutor} from "../../src/lifi/LiquidLaneLifiExecutor.sol"; +import {DeployExecutorBaseScript} from "../../script/deploy/base/DeployExecutorBase.s.sol"; +import {DeployLifiExecutorBaseScript} from "../../script/deploy/base/DeployLifiExecutorBase.s.sol"; + +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; + +contract DeployExecutorTest is Test { + address internal reactor = makeAddr("reactor"); + address internal admin = makeAddr("admin"); + address internal proxyAdminOwner = makeAddr("proxyAdminOwner"); + address internal caller = makeAddr("caller"); + address internal inputSettler = makeAddr("inputSettler"); + address internal outputSettler = makeAddr("outputSettler"); + + function testDeployExecutorBehindProxyInitializesOwnerAndCaller() public { + DeployExecutorBaseHarness harness = new DeployExecutorBaseHarness(); + DeployExecutorBaseScript.DeploymentData memory data = harness.runBase( + DeployExecutorBaseScript.DeployParams({ + reactor: reactor, admin: admin, proxyAdminOwner: proxyAdminOwner, caller: caller + }) + ); + + assertTrue(data.implementation != address(data.executor), "proxy distinct from impl"); + assertEq(data.executor.owner(), admin); + assertEq(data.executor.callers(0), caller); + + address[] memory callers = new address[](1); + callers[0] = caller; + vm.expectRevert(Initializable.InvalidInitialization.selector); + Executor(payable(data.implementation)).initialize(admin, callers); + } + + function testDeployLifiExecutorBehindProxyInitializesOwnerAndImmutables() public { + DeployLifiExecutorBaseHarness harness = new DeployLifiExecutorBaseHarness(); + DeployLifiExecutorBaseScript.DeploymentData memory data = harness.runBase( + DeployLifiExecutorBaseScript.DeployParams({ + inputSettler: inputSettler, + outputSettler: outputSettler, + admin: admin, + proxyAdminOwner: proxyAdminOwner, + caller: caller + }) + ); + + assertTrue(data.implementation != address(data.executor), "proxy distinct from impl"); + assertEq(data.executor.owner(), admin); + assertEq(data.executor.INPUT_SETTLER(), inputSettler); + assertEq(data.executor.OUTPUT_SETTLER(), outputSettler); + assertTrue(data.executor.isCaller(caller)); + + address[] memory callers = new address[](1); + callers[0] = caller; + vm.expectRevert(Initializable.InvalidInitialization.selector); + LiquidLaneLifiExecutor(data.implementation).initialize(admin, callers); + } +} + +contract DeployExecutorBaseHarness is DeployExecutorBaseScript { + function _startBroadcast() internal override {} + + function _stopBroadcast() internal override {} +} + +contract DeployLifiExecutorBaseHarness is DeployLifiExecutorBaseScript { + function _startBroadcast() internal override {} + + function _stopBroadcast() internal override {} +} diff --git a/test/lifi/LifiExecutorFork.t.sol b/test/lifi/LifiExecutorFork.t.sol new file mode 100644 index 0000000..82677ba --- /dev/null +++ b/test/lifi/LifiExecutorFork.t.sol @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {ILiquidLaneAdapter} from "../../src/interfaces/ILiquidLaneAdapter.sol"; +import {LiquidLaneLifiExecutor} from "../../src/lifi/LiquidLaneLifiExecutor.sol"; +import {IInputSettler} from "../../src/lifi/interfaces/IInputSettler.sol"; +import {ILiquidLaneLifiExecutor} from "../../src/lifi/interfaces/ILiquidLaneLifiExecutor.sol"; +import {MandateOutput} from "../../src/lifi/interfaces/IOutputSettler.sol"; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {Test} from "forge-std/Test.sol"; + +interface IInputSettlerEscrowLike { + function open(IInputSettler.StandardOrder calldata order) external; + function orderIdentifier(IInputSettler.StandardOrder calldata order) external view returns (bytes32); + function orderStatus(bytes32 orderId) external view returns (uint8); +} + +contract LifiExecutorForkTest is Test { + address internal constant INPUT_SETTLER = 0x000025c3226C00B2Cdc200005a1600509f4e00C0; + address internal constant OUTPUT_SETTLER = 0x0000000000eC36B683C2E6AC89e9A75989C22a2e; + + address internal user = makeAddr("user"); + address internal owner = makeAddr("owner"); + address internal recipient = makeAddr("recipient"); + address internal proxyAdminOwner = makeAddr("proxyAdminOwner"); + + ForkTestToken internal inputToken; + ForkTestToken internal outputToken; + ForkMintingAdapter internal adapter; + LiquidLaneLifiExecutor internal executor; + + function setUp() external { + string memory rpcUrl = vm.envOr("ETH_RPC_URL_SEPOLIA", string("")); + if (bytes(rpcUrl).length == 0) { + vm.skip(true, "ETH_RPC_URL_SEPOLIA not set"); + } + + vm.createSelectFork(rpcUrl); + inputToken = new ForkTestToken("Fork RWA", "FRWA"); + outputToken = new ForkTestToken("Fork USD", "FUSD"); + adapter = new ForkMintingAdapter(outputToken); + + address[] memory callers = new address[](1); + callers[0] = owner; + LiquidLaneLifiExecutor impl = new LiquidLaneLifiExecutor(INPUT_SETTLER, OUTPUT_SETTLER); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), proxyAdminOwner, abi.encodeCall(LiquidLaneLifiExecutor.initialize, (owner, callers)) + ); + executor = LiquidLaneLifiExecutor(address(proxy)); + } + + function testExecutorFinalisesOpenedOrderOnRealSettler() external { + IInputSettler.StandardOrder memory order = _openOrder(10 ether, 9 ether, "executor"); + bytes32 orderId = IInputSettlerEscrowLike(INPUT_SETTLER).orderIdentifier(order); + + vm.prank(owner); + executor.finaliseWithCurrentTimestamp(order, _routes(order), new ILiquidLaneLifiExecutor.DiscountRoute[](0)); + + assertEq(IInputSettlerEscrowLike(INPUT_SETTLER).orderStatus(orderId), 2, "claimed"); + assertEq(outputToken.balanceOf(recipient), 9 ether, "recipient output"); + assertEq(outputToken.balanceOf(address(executor)), 1 ether, "executor surplus"); + } + + function _openOrder(uint256 amountIn, uint256 amountOut, string memory salt) + internal + returns (IInputSettler.StandardOrder memory order) + { + order = _order(amountIn, amountOut, salt); + + inputToken.mint(user, amountIn); + vm.startPrank(user); + inputToken.approve(INPUT_SETTLER, amountIn); + IInputSettlerEscrowLike(INPUT_SETTLER).open(order); + vm.stopPrank(); + + bytes32 orderId = IInputSettlerEscrowLike(INPUT_SETTLER).orderIdentifier(order); + assertEq(IInputSettlerEscrowLike(INPUT_SETTLER).orderStatus(orderId), 1, "deposited"); + } + + function _order(uint256 amountIn, uint256 amountOut, string memory salt) + internal + view + returns (IInputSettler.StandardOrder memory order) + { + uint256[2][] memory inputs = new uint256[2][](1); + inputs[0] = [uint256(uint160(address(inputToken))), amountIn]; + + MandateOutput[] memory outputs = new MandateOutput[](1); + outputs[0] = MandateOutput({ + oracle: _id(OUTPUT_SETTLER), + settler: _id(OUTPUT_SETTLER), + chainId: block.chainid, + token: _id(address(outputToken)), + amount: amountOut, + recipient: _id(recipient), + callbackData: hex"", + context: hex"" + }); + + order = IInputSettler.StandardOrder({ + user: user, + nonce: uint256(keccak256(abi.encodePacked("lifi-executor", salt))), + originChainId: block.chainid, + expires: uint32(block.timestamp + 1 hours), + fillDeadline: uint32(block.timestamp + 30 minutes), + inputOracle: OUTPUT_SETTLER, + inputs: inputs, + outputs: outputs + }); + } + + function _routes(IInputSettler.StandardOrder memory order) + internal + view + returns (ILiquidLaneLifiExecutor.FillRoute[] memory routes) + { + routes = new ILiquidLaneLifiExecutor.FillRoute[](1); + routes[0] = ILiquidLaneLifiExecutor.FillRoute({ + adapter: address(adapter), amountIn: order.inputs[0][1], amountOut: 10 ether + }); + } + + function _id(address addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addr))); + } +} + +contract ForkMintingAdapter is ILiquidLaneAdapter { + ForkTestToken public immutable outputToken; + + constructor(ForkTestToken outputToken_) { + outputToken = outputToken_; + } + + function swap(Swap calldata swap_) external { + require(ForkTestToken(swap_.tokenIn).balanceOf(address(this)) >= swap_.amountIn, "missing input"); + outputToken.mint(swap_.recipient, swap_.amountOut); + } + + function swap(SignedSwap calldata, bytes calldata) external {} + + function swap(DiscountSwap calldata, bytes calldata, address, uint256) external pure returns (uint256) { + return 0; + } +} + +contract ForkTestToken is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/lifi/LiquidLaneLifiExecutor.t.sol b/test/lifi/LiquidLaneLifiExecutor.t.sol new file mode 100644 index 0000000..6d09c8d --- /dev/null +++ b/test/lifi/LiquidLaneLifiExecutor.t.sol @@ -0,0 +1,981 @@ +// SPDX-License-Identifier: BUSL-1.1 +// Copyright (c) 2026 Symbiotic +pragma solidity 0.8.28; + +import {ILiquidLaneAdapter} from "../../src/interfaces/ILiquidLaneAdapter.sol"; +import {IInputCallback} from "../../src/lifi/interfaces/IInputCallback.sol"; +import {IInputSettler} from "../../src/lifi/interfaces/IInputSettler.sol"; +import {LiquidLaneLifiExecutor} from "../../src/lifi/LiquidLaneLifiExecutor.sol"; +import {ILiquidLaneLifiExecutor} from "../../src/lifi/interfaces/ILiquidLaneLifiExecutor.sol"; +import {IOutputSettler, MandateOutput} from "../../src/lifi/interfaces/IOutputSettler.sol"; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol"; +import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import {Test} from "forge-std/Test.sol"; + +interface IOutputCallbackLike { + function outputFilled(bytes32 token, uint256 amount, bytes calldata callbackData) external; +} + +contract LiquidLaneLifiExecutorTest is Test { + bytes32 internal constant ORDER_ID = keccak256("order"); + address internal constant DISCOUNT_SIGNER = address(0x515011); + bytes32 internal constant MOCK_SOLVER = bytes32(uint256(uint160(address(0x515012)))); + uint8 internal constant ORDER_STATUS_DEPOSITED = 1; + uint8 internal constant ORDER_STATUS_CLAIMED = 2; + + address internal owner; + address internal recipient = makeAddr("recipient"); + address internal proxyAdminOwner = makeAddr("proxyAdminOwner"); + + TestToken internal rwa; + TestToken internal outputToken; + MockLifiAdapter internal adapter; + MockInputSettler internal inputSettler; + MockOutputSettler internal outputSettler; + LiquidLaneLifiExecutor internal executor; + + function setUp() public { + owner = address(this); + rwa = new TestToken("RWA", "RWA"); + outputToken = new TestToken("USD", "USD"); + adapter = new MockLifiAdapter(outputToken); + inputSettler = new MockInputSettler(); + outputSettler = new MockOutputSettler(); + + executor = _deployExecutor(address(inputSettler), address(outputSettler), owner); + + outputToken.mint(address(adapter), 100 ether); + } + + function _deployExecutor(address inputSettler_, address outputSettler_, address owner_) + internal + returns (LiquidLaneLifiExecutor) + { + return _deployExecutor(inputSettler_, outputSettler_, owner_, _callers(owner_)); + } + + function _deployExecutor(address inputSettler_, address outputSettler_, address owner_, address[] memory callers_) + internal + returns (LiquidLaneLifiExecutor) + { + LiquidLaneLifiExecutor impl = new LiquidLaneLifiExecutor(inputSettler_, outputSettler_); + TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy( + address(impl), proxyAdminOwner, abi.encodeCall(LiquidLaneLifiExecutor.initialize, (owner_, callers_)) + ); + return LiquidLaneLifiExecutor(address(proxy)); + } + + function _callers(address caller) internal pure returns (address[] memory callers_) { + callers_ = new address[](1); + callers_[0] = caller; + } + + /* FINALISE HAPPY PATHS */ + + function testFinaliseWithCurrentTimestampFillsAndAttestsDirectRoute() public { + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + bytes32 orderId = _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + + assertEq(rwa.balanceOf(address(adapter)), 10 ether); + assertEq(outputToken.balanceOf(recipient), 9 ether); + assertEq(outputToken.balanceOf(address(executor)), 1 ether); + assertEq(outputToken.allowance(address(executor), address(outputSettler)), type(uint256).max); + assertEq(inputSettler.orderStatus(orderId), ORDER_STATUS_CLAIMED); + assertEq(inputSettler.lastTimestamp(), uint32(block.timestamp)); + assertEq(inputSettler.lastSolver(), _id(address(executor))); + assertEq(inputSettler.lastDestination(), _id(address(executor))); + assertEq(outputSettler.lastOrderId(), orderId); + assertEq(outputSettler.lastSolver(), _id(address(executor))); + assertTrue(outputSettler.attested()); + } + + function testFinaliseWithCurrentTimestampExecutesMultipleRoutesAndKeepsSurplus() public { + MockLifiAdapter secondAdapter = new MockLifiAdapter(outputToken); + outputToken.mint(address(secondAdapter), 100 ether); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + bytes32 orderId = _openOrder(order); + + ILiquidLaneLifiExecutor.FillRoute[] memory routes = new ILiquidLaneLifiExecutor.FillRoute[](2); + routes[0] = _directRoute(address(adapter), 4 ether, 4 ether); + routes[1] = _directRoute(address(secondAdapter), 6 ether, 6 ether); + + executor.finaliseWithCurrentTimestamp(order, routes, _noDiscountRoutes()); + + assertEq(rwa.balanceOf(address(adapter)), 4 ether); + assertEq(rwa.balanceOf(address(secondAdapter)), 6 ether); + assertEq(outputToken.balanceOf(recipient), 9 ether); + assertEq(outputToken.balanceOf(address(executor)), 1 ether); + } + + function testFinaliseWithCurrentTimestampExecutesDiscountRoute() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 8 ether); + _openOrder(order); + + ILiquidLaneLifiExecutor.DiscountRoute[] memory discountRoutes = new ILiquidLaneLifiExecutor.DiscountRoute[](1); + discountRoutes[0] = _discountRoute(address(adapter), 10 ether, 100_000); + + executor.finaliseWithCurrentTimestamp(order, _noRoutes(), discountRoutes); + + assertEq(rwa.balanceOf(address(adapter)), 10 ether); + assertEq(outputToken.balanceOf(recipient), 8 ether); + assertEq(outputToken.balanceOf(address(executor)), 1 ether); + } + + function testFinaliseWithCurrentTimestampExecutesDirectAndDiscountRoutes() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 8 ether); + _openOrder(order); + + ILiquidLaneLifiExecutor.DiscountRoute[] memory discountRoutes = new ILiquidLaneLifiExecutor.DiscountRoute[](1); + discountRoutes[0] = _discountRoute(address(adapter), 6 ether, 100_000); + + executor.finaliseWithCurrentTimestamp(order, _directRoutes(address(adapter), 4 ether, 4 ether), discountRoutes); + + assertEq(rwa.balanceOf(address(adapter)), 10 ether); + // 4 ether direct output plus 5.4 ether discounted output; 8 ether fill leaves 1.4 ether surplus. + assertEq(outputToken.balanceOf(recipient), 8 ether); + assertEq(outputToken.balanceOf(address(executor)), 1.4 ether); + } + + function testFinaliseWithCurrentTimestampSupportsSameInputAndOutputToken() public { + MockLifiAdapter sameTokenAdapter = new MockLifiAdapter(rwa); + rwa.mint(address(sameTokenAdapter), 100 ether); + rwa.mint(address(executor), 3 ether); + IInputSettler.StandardOrder memory order = _order(10 ether, 9.5 ether, address(rwa)); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(sameTokenAdapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + + assertEq(rwa.balanceOf(recipient), 9.5 ether); + // Pre-existing 3 ether plus 0.5 ether fill surplus stay with the executor. + assertEq(rwa.balanceOf(address(executor)), 3.5 ether); + assertEq(rwa.balanceOf(address(sameTokenAdapter)), 100 ether); + } + + function testFinaliseWithCurrentTimestampFillsDutchOutputAtResolvedAmount() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].context = _dutchContext(900, 1100, 0.01 ether); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10.5 ether), _noDiscountRoutes() + ); + + assertEq(outputToken.balanceOf(recipient), 10 ether); + assertEq(outputSettler.lastOutputAmount(), 10 ether); + assertEq(outputToken.balanceOf(address(executor)), 0.5 ether); + } + + function testFinaliseWithCurrentTimestampFillsExclusiveDutchOutputAtResolvedAmount() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].context = _exclusiveDutchContext(_id(makeAddr("otherSolver")), 900, 1100, 0.01 ether); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10.5 ether), _noDiscountRoutes() + ); + + assertEq(outputToken.balanceOf(recipient), 10 ether); + assertEq(outputSettler.lastOutputAmount(), 10 ether); + } + + function testFinaliseWithCurrentTimestampFillsExclusiveOutputAfterStartTime() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].context = _exclusiveContext(_id(makeAddr("otherSolver")), 1000); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + + assertEq(outputToken.balanceOf(recipient), 9 ether); + } + + function testFinaliseWithCurrentTimestampMatchesRoutesToReceivedAmountWhenSettlerTakesFee() public { + inputSettler.setInputFee(1 ether); + IInputSettler.StandardOrder memory order = _order(10 ether, 8.5 ether); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 9 ether, 9 ether), _noDiscountRoutes() + ); + + assertEq(rwa.balanceOf(address(adapter)), 9 ether); + assertEq(rwa.balanceOf(address(inputSettler)), 1 ether); + assertEq(outputToken.balanceOf(recipient), 8.5 ether); + assertEq(outputToken.balanceOf(address(executor)), 0.5 ether); + } + + function testFinaliseWithCurrentTimestampPreservesExistingInputBalance() public { + rwa.mint(address(executor), 5 ether); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + + assertEq(rwa.balanceOf(address(executor)), 5 ether); + assertEq(outputToken.balanceOf(recipient), 9 ether); + } + + function testFinaliseWithCurrentTimestampKeepsOutputWhenExecutorIsRecipient() public { + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].recipient = _id(address(executor)); + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + + assertEq(outputToken.balanceOf(address(executor)), 10 ether); + } + + function testFinaliseWithCurrentTimestampKeepsCallbackRefundSeparateFromSurplus() public { + RefundingOutputRecipient outputRecipient = new RefundingOutputRecipient(outputToken, address(executor), 1 ether); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].recipient = _id(address(outputRecipient)); + order.outputs[0].callbackData = hex"01"; + _openOrder(order); + + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + + assertEq(outputToken.balanceOf(address(outputRecipient)), 8 ether); + assertEq(outputToken.balanceOf(address(executor)), 2 ether); + } + + /* FINALISE DELEGATED REVERTS */ + + function testFinaliseWithCurrentTimestampBubblesExclusivityBeforeStartTime() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].context = _exclusiveContext(_id(makeAddr("otherSolver")), 1001); + _openOrder(order); + + vm.expectRevert(bytes("exclusive")); + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + } + + function testFinaliseWithCurrentTimestampBubblesInsufficientOutputForResolvedDutchAmount() public { + vm.warp(1000); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.outputs[0].context = _dutchContext(900, 1100, 0.01 ether); + _openOrder(order); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientBalance.selector, address(executor), 9.5 ether, 10 ether + ) + ); + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 9.5 ether), _noDiscountRoutes() + ); + } + + function testFinaliseWithCurrentTimestampBubblesAlreadyClaimedOrder() public { + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + inputSettler.setOrderStatus(_orderId(order), ORDER_STATUS_CLAIMED); + + vm.expectRevert(MockInputSettler.InvalidOrderStatus.selector); + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + } + + function testFinaliseWithCurrentTimestampBubblesExpiredFillDeadline() public { + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + order.fillDeadline = uint32(block.timestamp - 1); + _openOrder(order); + + vm.expectRevert(bytes("deadline")); + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + } + + /* CALLER AUTHORIZATION */ + + function testFinaliseWithCurrentTimestampRejectsUnauthorizedCaller() public { + address caller = makeAddr("caller"); + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + + vm.expectRevert(ILiquidLaneLifiExecutor.NotCaller.selector); + vm.prank(caller); + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + } + + function testSetCallersAllowsNonOwnerToFinaliseAndRevokesOldCaller() public { + address caller = makeAddr("caller"); + executor.setCallers(_callers(caller)); + + assertTrue(executor.isCaller(caller)); + assertFalse(executor.isCaller(owner)); + + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + _openOrder(order); + + vm.prank(caller); + executor.finaliseWithCurrentTimestamp( + order, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + assertEq(outputToken.balanceOf(recipient), 9 ether); + + IInputSettler.StandardOrder memory secondOrder = _order(10 ether, 8 ether); + vm.expectRevert(ILiquidLaneLifiExecutor.NotCaller.selector); + executor.finaliseWithCurrentTimestamp( + secondOrder, _directRoutes(address(adapter), 10 ether, 10 ether), _noDiscountRoutes() + ); + } + + function testSetCallersRejectsNonOwner() public { + address caller = makeAddr("caller"); + + vm.expectRevert(abi.encodeWithSignature("OwnableUnauthorizedAccount(address)", caller)); + vm.prank(caller); + executor.setCallers(_callers(caller)); + } + + /* CALLBACK AUTHENTICATION */ + + function testOrderFinalisedRejectsNonInputSettler() public { + vm.expectRevert(ILiquidLaneLifiExecutor.NotInputSettler.selector); + executor.orderFinalised(_inputs(10 ether), abi.encode(_unsolicitedFillCall())); + } + + /* EIP-1271 REGISTRATION */ + + function testIsValidSignatureAcceptsCallerWithRegistrationDomain() public { + uint256 callerKey = 0xA11CE; + LiquidLaneLifiExecutor callerExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), owner, _callers(vm.addr(callerKey))); + bytes32 messageHash = keccak256("lifi registration"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(callerKey, callerExecutor.lifiRegistrationDigest(messageHash)); + + assertEq( + callerExecutor.isValidSignature(messageHash, abi.encodePacked(r, s, v)), IERC1271.isValidSignature.selector + ); + } + + function testIsValidSignatureAcceptsAnyCaller() public { + uint256 firstCallerKey = 0xA11CE; + uint256 secondCallerKey = 0xB0B; + address[] memory allowedCallers = new address[](2); + allowedCallers[0] = vm.addr(firstCallerKey); + allowedCallers[1] = vm.addr(secondCallerKey); + LiquidLaneLifiExecutor callerExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), owner, allowedCallers); + bytes32 messageHash = keccak256("lifi registration"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(secondCallerKey, callerExecutor.lifiRegistrationDigest(messageHash)); + + assertEq( + callerExecutor.isValidSignature(messageHash, abi.encodePacked(r, s, v)), IERC1271.isValidSignature.selector + ); + } + + function testIsValidSignatureRejectsOwnerWhenNotCaller() public { + uint256 ownerKey = 0xA11CE; + LiquidLaneLifiExecutor callerExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), vm.addr(ownerKey), _callers(vm.addr(0xB0B))); + bytes32 messageHash = keccak256("lifi registration"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerKey, callerExecutor.lifiRegistrationDigest(messageHash)); + + assertEq(callerExecutor.isValidSignature(messageHash, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function testIsValidSignatureRejectsRawMessageHashSignature() public { + uint256 callerKey = 0xA11CE; + LiquidLaneLifiExecutor callerExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), owner, _callers(vm.addr(callerKey))); + bytes32 messageHash = keccak256("lifi registration"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(callerKey, messageHash); + + assertEq(callerExecutor.isValidSignature(messageHash, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function testIsValidSignatureRejectsSignatureForAnotherExecutor() public { + uint256 callerKey = 0xA11CE; + address caller = vm.addr(callerKey); + LiquidLaneLifiExecutor firstExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), owner, _callers(caller)); + LiquidLaneLifiExecutor secondExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), owner, _callers(caller)); + bytes32 messageHash = keccak256("lifi registration"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(callerKey, firstExecutor.lifiRegistrationDigest(messageHash)); + + assertEq(secondExecutor.isValidSignature(messageHash, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function testIsValidSignatureRejectsRemovedCaller() public { + uint256 callerKey = 0xA11CE; + LiquidLaneLifiExecutor callerExecutor = + _deployExecutor(address(inputSettler), address(outputSettler), owner, _callers(vm.addr(callerKey))); + bytes32 messageHash = keccak256("lifi registration"); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(callerKey, callerExecutor.lifiRegistrationDigest(messageHash)); + + callerExecutor.setCallers(new address[](0)); + + assertEq(callerExecutor.isValidSignature(messageHash, abi.encodePacked(r, s, v)), bytes4(0xffffffff)); + } + + function testIsValidSignatureRejectsMalformedSignature() public { + assertEq(executor.isValidSignature(keccak256("lifi registration"), hex"deadbeef"), bytes4(0xffffffff)); + } + + /* UPGRADEABILITY */ + + function testInitializeSetsOwnerAndCallers() public view { + assertEq(executor.owner(), owner); + assertEq(executor.callers(0), owner); + assertTrue(executor.isCaller(owner)); + } + + function testInitializeCannotBeCalledTwice() public { + vm.expectRevert(Initializable.InvalidInitialization.selector); + executor.initialize(makeAddr("intruder"), _callers(makeAddr("intruder"))); + } + + function testImplementationInitializerIsDisabled() public { + LiquidLaneLifiExecutor impl = new LiquidLaneLifiExecutor(address(inputSettler), address(outputSettler)); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + impl.initialize(makeAddr("intruder"), _callers(makeAddr("intruder"))); + } + + /* MOCK SELF-TESTS */ + + function testMockFinaliseRejectsStaleOrFutureTimestamp() public { + IInputSettler.StandardOrder memory order = _order(10 ether, 9 ether); + IInputSettler.SolveParams[] memory solveParams = new IInputSettler.SolveParams[](1); + solveParams[0].solver = _id(address(executor)); + + vm.warp(100); + solveParams[0].timestamp = 99; + vm.expectRevert(MockInputSettler.TimestampPassed.selector); + inputSettler.finalise(order, solveParams, _id(address(executor)), hex""); + + solveParams[0].timestamp = 101; + vm.expectRevert(MockInputSettler.TimestampNotPassed.selector); + inputSettler.finalise(order, solveParams, _id(address(executor)), hex""); + } + + function testMockOutputSettlerFillIsIdempotent() public { + MandateOutput memory output = _output(9 ether); + outputToken.mint(address(this), 18 ether); + outputToken.approve(address(outputSettler), 18 ether); + + bytes32 firstRecord = outputSettler.fill(ORDER_ID, output, uint48(block.timestamp), abi.encode(MOCK_SOLVER)); + bytes32 secondRecord = outputSettler.fill(ORDER_ID, output, uint48(block.timestamp), abi.encode(MOCK_SOLVER)); + + assertEq(secondRecord, firstRecord); + assertEq(outputToken.balanceOf(recipient), 9 ether); + assertEq(outputToken.balanceOf(address(this)), 9 ether); + } + + function testMockOutputSettlerInvokesOutputCallback() public { + MockOutputRecipient outputRecipient = new MockOutputRecipient(); + MandateOutput memory output = _output(9 ether); + output.recipient = _id(address(outputRecipient)); + output.callbackData = hex"1234"; + outputToken.mint(address(this), 9 ether); + outputToken.approve(address(outputSettler), 9 ether); + + outputSettler.fill(ORDER_ID, output, uint48(block.timestamp), abi.encode(MOCK_SOLVER)); + + assertEq(outputRecipient.token(), output.token); + assertEq(outputRecipient.amount(), 9 ether); + assertEq(outputRecipient.callbackData(), hex"1234"); + } + + /* HELPERS */ + + function _order(uint256 amountIn, uint256 amountOut) internal view returns (IInputSettler.StandardOrder memory) { + return _order(amountIn, amountOut, address(outputToken)); + } + + function _order(uint256 amountIn, uint256 amountOut, address tokenOut) + internal + view + returns (IInputSettler.StandardOrder memory) + { + MandateOutput[] memory outputs = new MandateOutput[](1); + outputs[0] = _output(amountOut, tokenOut); + + return IInputSettler.StandardOrder({ + user: address(0xA11CE), + nonce: uint256(ORDER_ID), + originChainId: block.chainid, + expires: uint32(block.timestamp + 1 hours), + fillDeadline: uint32(block.timestamp + 1 hours), + inputOracle: address(outputSettler), + inputs: _inputs(amountIn), + outputs: outputs + }); + } + + function _inputs(uint256 amount) internal view returns (uint256[2][] memory inputs) { + inputs = new uint256[2][](1); + inputs[0][0] = uint256(uint160(address(rwa))); + inputs[0][1] = amount; + } + + function _openOrder(IInputSettler.StandardOrder memory order) internal returns (bytes32 orderId) { + orderId = _orderId(order); + inputSettler.setOrderStatus(orderId, ORDER_STATUS_DEPOSITED); + rwa.mint(address(inputSettler), order.inputs[0][1]); + } + + function _directRoutes(address fillAdapter, uint256 amountIn, uint256 amountOut) + internal + pure + returns (ILiquidLaneLifiExecutor.FillRoute[] memory routes) + { + routes = new ILiquidLaneLifiExecutor.FillRoute[](1); + routes[0] = _directRoute(fillAdapter, amountIn, amountOut); + } + + function _directRoute(address fillAdapter, uint256 amountIn, uint256 amountOut) + internal + pure + returns (ILiquidLaneLifiExecutor.FillRoute memory) + { + return ILiquidLaneLifiExecutor.FillRoute({adapter: fillAdapter, amountIn: amountIn, amountOut: amountOut}); + } + + function _discountRoute(address fillAdapter, uint256 amountIn, uint256 discount) + internal + view + returns (ILiquidLaneLifiExecutor.DiscountRoute memory) + { + return ILiquidLaneLifiExecutor.DiscountRoute({ + adapter: fillAdapter, + amountIn: amountIn, + discountSwap: ILiquidLaneAdapter.DiscountSwap({ + discount: ILiquidLaneAdapter.Discount({ + tokenToRedeem: address(rwa), + discount: discount, + signer: DISCOUNT_SIGNER, + protocol: address(0xBEEF), + nonce: 1, + deadline: uint48(block.timestamp + 100) + }), + signerSignature: hex"1234", + protocolDeadline: uint48(block.timestamp + 100) + }), + protocolSignature: hex"5678" + }); + } + + function _noRoutes() internal pure returns (ILiquidLaneLifiExecutor.FillRoute[] memory routes) {} + + function _noDiscountRoutes() + internal + pure + returns (ILiquidLaneLifiExecutor.DiscountRoute[] memory discountRoutes) + {} + + function _unsolicitedFillCall() internal view returns (ILiquidLaneLifiExecutor.FillCall memory) { + return ILiquidLaneLifiExecutor.FillCall({ + orderId: ORDER_ID, + output: _output(9 ether), + fillDeadline: uint32(block.timestamp + 1 hours), + routes: _directRoutes(address(adapter), 10 ether, 10 ether), + discountRoutes: _noDiscountRoutes() + }); + } + + function _output(uint256 amount) internal view returns (MandateOutput memory) { + return _output(amount, address(outputToken)); + } + + function _output(uint256 amount, address token) internal view returns (MandateOutput memory) { + return MandateOutput({ + oracle: _id(address(outputSettler)), + settler: _id(address(outputSettler)), + chainId: block.chainid, + token: _id(token), + amount: amount, + recipient: _id(recipient), + callbackData: bytes(""), + context: bytes("") + }); + } + + function _id(address addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addr))); + } + + function _dutchContext(uint32 startTime, uint32 stopTime, uint256 slope) internal pure returns (bytes memory) { + return abi.encodePacked(bytes1(0x01), startTime, stopTime, slope); + } + + function _exclusiveContext(bytes32 exclusiveFor, uint32 startTime) internal pure returns (bytes memory) { + return abi.encodePacked(bytes1(0xe0), exclusiveFor, startTime); + } + + function _exclusiveDutchContext(bytes32 exclusiveFor, uint32 startTime, uint32 stopTime, uint256 slope) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(bytes1(0xe1), exclusiveFor, startTime, stopTime, slope); + } + + function _orderId(IInputSettler.StandardOrder memory order) internal pure returns (bytes32) { + return keccak256( + abi.encode( + order.user, + order.nonce, + order.originChainId, + order.expires, + order.fillDeadline, + order.inputOracle, + keccak256(abi.encode(order.inputs)), + _outputsHash(order.outputs) + ) + ); + } + + function _outputsHash(MandateOutput[] memory outputs) internal pure returns (bytes32) { + bytes32[] memory outputHashes = new bytes32[](outputs.length); + for (uint256 i; i < outputs.length; ++i) { + outputHashes[i] = _outputHash(outputs[i]); + } + return keccak256(abi.encode(outputHashes)); + } + + function _outputHash(MandateOutput memory output) internal pure returns (bytes32) { + return keccak256( + abi.encode( + output.oracle, + output.settler, + output.chainId, + output.token, + output.amount, + output.recipient, + keccak256(output.callbackData), + keccak256(output.context) + ) + ); + } +} + +contract MockInputSettler is IInputSettler { + using SafeERC20 for IERC20; + + uint8 internal constant ORDER_STATUS_DEPOSITED = 1; + uint8 internal constant ORDER_STATUS_CLAIMED = 2; + + error InvalidOrderStatus(); + error InvalidTimestampLength(); + error TimestampNotPassed(); + error TimestampPassed(); + + mapping(bytes32 orderId => uint8 status) public orderStatus; + uint256 public inputFee; + uint32 public lastTimestamp; + bytes32 public lastSolver; + bytes32 public lastDestination; + + function setOrderStatus(bytes32 orderId, uint8 status) public { + orderStatus[orderId] = status; + } + + function setInputFee(uint256 fee) public { + inputFee = fee; + } + + function orderIdentifier(StandardOrder calldata order) external pure returns (bytes32 orderId) { + return _orderId(order); + } + + function finalise( + StandardOrder calldata order, + SolveParams[] calldata solveParams, + bytes32 destination, + bytes calldata call + ) external { + if (solveParams.length != 1) revert InvalidTimestampLength(); + if (solveParams[0].timestamp < block.timestamp) revert TimestampPassed(); + if (solveParams[0].timestamp > block.timestamp) revert TimestampNotPassed(); + + bytes32 orderId = _orderId(order); + if (orderStatus[orderId] != ORDER_STATUS_DEPOSITED) revert InvalidOrderStatus(); + + lastTimestamp = solveParams[0].timestamp; + lastSolver = solveParams[0].solver; + lastDestination = destination; + + address destinationAddress = address(uint160(uint256(destination))); + // The claimed input amounts are forwarded verbatim even when a fee is retained. + for (uint256 i; i < order.inputs.length; ++i) { + IERC20(address(uint160(order.inputs[i][0]))).safeTransfer(destinationAddress, order.inputs[i][1] - inputFee); + } + + orderStatus[orderId] = ORDER_STATUS_CLAIMED; + IInputCallback(destinationAddress).orderFinalised(order.inputs, call); + + ILiquidLaneLifiExecutor.FillCall memory fillCall = abi.decode(call, (ILiquidLaneLifiExecutor.FillCall)); + require(fillCall.orderId == orderId, "order mismatch"); + require(MockOutputSettler(address(uint160(uint256(fillCall.output.settler)))).attested(), "not attested"); + } + + function _orderId(StandardOrder calldata order) internal pure returns (bytes32) { + return keccak256( + abi.encode( + order.user, + order.nonce, + order.originChainId, + order.expires, + order.fillDeadline, + order.inputOracle, + keccak256(abi.encode(order.inputs)), + _outputsHash(order.outputs) + ) + ); + } + + function _outputsHash(MandateOutput[] calldata outputs) internal pure returns (bytes32) { + bytes32[] memory outputHashes = new bytes32[](outputs.length); + for (uint256 i; i < outputs.length; ++i) { + outputHashes[i] = _outputHash(outputs[i]); + } + return keccak256(abi.encode(outputHashes)); + } + + function _outputHash(MandateOutput calldata output) internal pure returns (bytes32) { + return keccak256( + abi.encode( + output.oracle, + output.settler, + output.chainId, + output.token, + output.amount, + output.recipient, + keccak256(output.callbackData), + keccak256(output.context) + ) + ); + } +} + +contract MockOutputSettler is IOutputSettler { + using SafeERC20 for IERC20; + + bool public attested; + bytes32 public lastOrderId; + bytes32 public lastSolver; + uint256 public lastOutputAmount; + mapping(bytes32 orderId => mapping(bytes32 outputHash => bytes32 fillRecord)) public fillRecords; + + function fill(bytes32 orderId, MandateOutput calldata output, uint48 fillDeadline, bytes calldata fillerData) + external + payable + returns (bytes32 fillRecordHash) + { + require(fillDeadline >= block.timestamp, "deadline"); + bytes32 solver = abi.decode(fillerData, (bytes32)); + address token = _identifierAddress(output.token); + address recipient = _identifierAddress(output.recipient); + require(output.chainId == block.chainid, "chain"); + require(output.settler == _id(address(this)), "settler"); + require(output.oracle == _id(address(this)), "oracle"); + + uint256 resolvedAmount = _resolveOutputAmount(output, solver); + bytes32 outputHash = _outputHash(output); + fillRecordHash = fillRecords[orderId][outputHash]; + if (fillRecordHash != bytes32(0)) return fillRecordHash; + + fillRecordHash = keccak256(abi.encodePacked(solver, uint32(block.timestamp))); + fillRecords[orderId][outputHash] = fillRecordHash; + lastOrderId = orderId; + lastSolver = solver; + lastOutputAmount = resolvedAmount; + + IERC20(token).safeTransferFrom(msg.sender, recipient, resolvedAmount); + if (output.callbackData.length != 0) { + IOutputCallbackLike(recipient).outputFilled(output.token, resolvedAmount, output.callbackData); + } + } + + function setAttestation(bytes32 orderId, bytes32 solver, uint32 timestamp, MandateOutput calldata output) external { + bytes32 expected = keccak256(abi.encodePacked(solver, timestamp)); + require(fillRecords[orderId][_outputHash(output)] == expected, "invalid attestation"); + attested = true; + } + + function _outputHash(MandateOutput calldata output) internal pure returns (bytes32) { + return keccak256( + abi.encode( + output.oracle, + output.settler, + output.chainId, + output.token, + output.amount, + output.recipient, + keccak256(output.callbackData), + keccak256(output.context) + ) + ); + } + + function _identifierAddress(bytes32 identifier) internal pure returns (address addr) { + addr = address(uint160(uint256(identifier))); + require(addr != address(0) && identifier == _id(addr), "invalid identifier"); + } + + function _resolveOutputAmount(MandateOutput calldata output, bytes32 solver) internal view returns (uint256) { + bytes calldata context = output.context; + if (context.length == 0) return output.amount; + + uint8 contextType = uint8(context[0]); + if (contextType == 0x00) { + require(context.length == 1, "bad length"); + return output.amount; + } + if (contextType == 0x01) { + require(context.length == 41, "bad length"); + return _dutchOutputAmount(output.amount, context, 1); + } + if (contextType == 0xe0) { + require(context.length == 37, "bad length"); + _validateExclusiveSolver(context, solver, 1, 33); + return output.amount; + } + if (contextType == 0xe1) { + require(context.length == 73, "bad length"); + _validateExclusiveSolver(context, solver, 1, 33); + return _dutchOutputAmount(output.amount, context, 33); + } + + revert("unknown context"); + } + + function _dutchOutputAmount(uint256 amount, bytes calldata context, uint256 startTimeOffset) + internal + view + returns (uint256) + { + uint256 startTime = _readUint32(context, startTimeOffset); + uint256 stopTime = _readUint32(context, startTimeOffset + 4); + uint256 currentTime = block.timestamp > startTime ? block.timestamp : startTime; + if (stopTime < currentTime) return amount; + + return amount + _readUint256(context, startTimeOffset + 8) * (stopTime - currentTime); + } + + function _validateExclusiveSolver( + bytes calldata context, + bytes32 solver, + uint256 exclusiveForOffset, + uint256 startTimeOffset + ) internal view { + bytes32 exclusiveFor = _readBytes32(context, exclusiveForOffset); + require(block.timestamp >= _readUint32(context, startTimeOffset) || exclusiveFor == solver, "exclusive"); + } + + function _readUint32(bytes calldata data, uint256 offset) internal pure returns (uint32 value) { + bytes32 word = _readBytes32(data, offset); + value = uint32(uint256(word >> 224)); + } + + function _readUint256(bytes calldata data, uint256 offset) internal pure returns (uint256 value) { + value = uint256(_readBytes32(data, offset)); + } + + function _readBytes32(bytes calldata data, uint256 offset) internal pure returns (bytes32 value) { + assembly ("memory-safe") { + value := calldataload(add(data.offset, offset)) + } + } + + function _id(address addr) internal pure returns (bytes32) { + return bytes32(uint256(uint160(addr))); + } +} + +contract MockLifiAdapter is ILiquidLaneAdapter { + TestToken public immutable outputToken; + + constructor(TestToken outputToken_) { + outputToken = outputToken_; + } + + function swap(ILiquidLaneAdapter.Swap calldata swap_) public { + require(IERC20(swap_.tokenIn).balanceOf(address(this)) >= swap_.amountIn, "missing input"); + outputToken.transfer(swap_.recipient, swap_.amountOut); + } + + function swap(ILiquidLaneAdapter.SignedSwap calldata, bytes calldata) public {} + + function swap( + ILiquidLaneAdapter.DiscountSwap calldata discountSwap, + bytes calldata, + address recipient, + uint256 amountIn + ) public returns (uint256) { + require(IERC20(discountSwap.discount.tokenToRedeem).balanceOf(address(this)) >= amountIn, "missing input"); + uint256 amountOut = amountIn * (1_000_000 - discountSwap.discount.discount) / 1_000_000; + outputToken.transfer(recipient, amountOut); + return amountOut; + } +} + +contract MockOutputRecipient is IOutputCallbackLike { + bytes32 public token; + uint256 public amount; + bytes public callbackData; + + function outputFilled(bytes32 token_, uint256 amount_, bytes calldata callbackData_) external { + token = token_; + amount = amount_; + callbackData = callbackData_; + } +} + +contract RefundingOutputRecipient is IOutputCallbackLike { + using SafeERC20 for IERC20; + + IERC20 public immutable token; + address public immutable recipient; + uint256 public immutable refundAmount; + + constructor(IERC20 token_, address recipient_, uint256 refundAmount_) { + token = token_; + recipient = recipient_; + refundAmount = refundAmount_; + } + + function outputFilled(bytes32, uint256, bytes calldata) external { + token.safeTransfer(recipient, refundAmount); + } +} + +contract TestToken is ERC20 { + constructor(string memory name_, string memory symbol_) ERC20(name_, symbol_) {} + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +}