diff --git a/config/protocol_specific_addresses.json b/config/protocol_specific_addresses.json index de1aef00..0047e3c8 100644 --- a/config/protocol_specific_addresses.json +++ b/config/protocol_specific_addresses.json @@ -16,6 +16,9 @@ "rfq:hashflow": { "hashflow_router_address": "0x55084eE0fEf03f14a305cd24286359A35D735151" }, + "rfq:liquorice": { + "balance_manager_address": "0xb87bAE43a665EB5943A5642F81B26666bC9E5C95" + }, "etherfi": { "eeth_address": "0x35fA164735182de50811E8e2E824cFb9B6118ac2", "weeth_address": "0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee", diff --git a/config/test_executor_addresses.json b/config/test_executor_addresses.json index a0c0e3cb..5769a9ec 100644 --- a/config/test_executor_addresses.json +++ b/config/test_executor_addresses.json @@ -13,6 +13,7 @@ "vm:balancer_v3": "0x03A6a84cD762D9707A21605b548aaaB891562aAb", "rfq:bebop": "0xD6BbDE9174b1CdAa358d2Cf4D57D1a9F7178FBfF", "rfq:hashflow": "0x15cF58144EF33af1e14b5208015d11F9143E27b9", + "rfq:liquorice": "0xDB25A7b768311dE128BBDa7B8426c3f9C74f3240", "fluid_v1": "0x212224D2F2d262cd093eE13240ca4873fcCBbA3C", "rocketpool": "0x3D7Ebc40AF7092E3F1C81F2e996cbA5Cae2090d7", "erc4626": "0xD16d567549A2a2a2005aEACf7fB193851603dd70", diff --git a/foundry/src/executors/LiquoriceExecutor.sol b/foundry/src/executors/LiquoriceExecutor.sol new file mode 100644 index 00000000..328f525f --- /dev/null +++ b/foundry/src/executors/LiquoriceExecutor.sol @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "@interfaces/IExecutor.sol"; +import "../RestrictTransferFrom.sol"; +import "@openzeppelin/contracts/utils/math/Math.sol"; +import { + IERC20, + SafeERC20 +} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/Address.sol"; + +/// @title LiquoriceExecutor +/// @notice Executor for Liquorice RFQ (Request for Quote) swaps +/// @dev Handles RFQ swaps through Liquorice settlement contracts with support for +/// partial fills and dynamic allowance management +contract LiquoriceExecutor is IExecutor, RestrictTransferFrom { + using SafeERC20 for IERC20; + using Address for address; + + /// @notice Liquorice-specific errors + error LiquoriceExecutor__InvalidDataLength(); + error LiquoriceExecutor__ZeroAddress(); + error LiquoriceExecutor__AmountBelowMinimum(); + + /// @notice The Liquorice settlement contract address + address public immutable liquoriceSettlement; + + /// @notice The Liquorice balance manager contract address + address public immutable liquoriceBalanceManager; + + constructor( + address _liquoriceSettlement, + address _liquoriceliquoriceBalanceManager, + address _permit2 + ) RestrictTransferFrom(_permit2) { + if ( + _liquoriceSettlement == address(0) + || _liquoriceliquoriceBalanceManager == address(0) + ) { + revert LiquoriceExecutor__ZeroAddress(); + } + liquoriceSettlement = _liquoriceSettlement; + liquoriceBalanceManager = _liquoriceliquoriceBalanceManager; + } + + /// @notice Executes a swap through Liquorice's RFQ system + /// @param givenAmount The amount of input token to swap + /// @param data Encoded swap data containing tokens and liquorice calldata + /// @return calculatedAmount The amount of output token received + function swap(uint256 givenAmount, bytes calldata data) + external + payable + virtual + override + returns (uint256 calculatedAmount) + { + ( + address tokenIn, + address tokenOut, + TransferType transferType, + uint32 partialFillOffset, + uint256 originalBaseTokenAmount, + uint256 minBaseTokenAmount, + bool approvalNeeded, + address receiver, + bytes memory liquoriceCalldata + ) = _decodeData(data); + + // Grant approval to Liquorice balance manager if needed + if (approvalNeeded && tokenIn != address(0)) { + // slither-disable-next-line unused-return + IERC20(tokenIn) + .forceApprove(liquoriceBalanceManager, type(uint256).max); + } + + givenAmount = _clampAmount( + givenAmount, originalBaseTokenAmount, minBaseTokenAmount + ); + + _transfer(address(this), transferType, tokenIn, givenAmount); + + // Modify the fill amount in the calldata if partial fill is supported + // If partialFillOffset is 0, partial fill is not supported + bytes memory finalCalldata = liquoriceCalldata; + if (partialFillOffset > 0 && originalBaseTokenAmount > givenAmount) { + finalCalldata = _modifyFilledTakerAmount( + liquoriceCalldata, givenAmount, partialFillOffset + ); + } + + uint256 balanceBefore = _balanceOf(tokenOut, receiver); + uint256 ethValue = tokenIn == address(0) ? givenAmount : 0; + + // Execute the swap by forwarding calldata to settlement contract + // slither-disable-next-line unused-return + liquoriceSettlement.functionCallWithValue(finalCalldata, ethValue); + + uint256 balanceAfter = _balanceOf(tokenOut, receiver); + calculatedAmount = balanceAfter - balanceBefore; + } + + /// @dev Decodes the packed calldata + function _decodeData(bytes calldata data) + internal + pure + returns ( + address tokenIn, + address tokenOut, + TransferType transferType, + uint32 partialFillOffset, + uint256 originalBaseTokenAmount, + uint256 minBaseTokenAmount, + bool approvalNeeded, + address receiver, + bytes memory liquoriceCalldata + ) + { + // Minimum fixed fields: + // tokenIn (20) + tokenOut (20) + transferType (1) + partialFillOffset (4) + + // originalBaseTokenAmount (32) + minBaseTokenAmount (32) + + // approvalNeeded (1) + receiver (20) = 130 bytes + if (data.length < 130) revert LiquoriceExecutor__InvalidDataLength(); + + tokenIn = address(bytes20(data[0:20])); + tokenOut = address(bytes20(data[20:40])); + transferType = TransferType(uint8(data[40])); + partialFillOffset = uint32(bytes4(data[41:45])); + originalBaseTokenAmount = uint256(bytes32(data[45:77])); + minBaseTokenAmount = uint256(bytes32(data[77:109])); + approvalNeeded = data[109] != 0; + receiver = address(bytes20(data[110:130])); + liquoriceCalldata = data[130:]; + } + + /// @dev Clamps the given amount to be within the valid range for the quote + /// @param givenAmount The amount provided by the router + /// @param originalBaseTokenAmount The maximum amount the quote supports + /// @param minBaseTokenAmount The minimum amount required for partial fills + /// @return The clamped amount + function _clampAmount( + uint256 givenAmount, + uint256 originalBaseTokenAmount, + uint256 minBaseTokenAmount + ) internal pure returns (uint256) { + // For partially filled quotes, revert if below minimum amount requirement + if (givenAmount < minBaseTokenAmount) { + revert LiquoriceExecutor__AmountBelowMinimum(); + } + // It is possible to have a quote with a smaller amount than was requested + if (givenAmount > originalBaseTokenAmount) { + return originalBaseTokenAmount; + } + return givenAmount; + } + + /// @dev Modifies the filledTakerAmount in the liquorice calldata to handle slippage + /// @param liquoriceCalldata The original calldata for the liquorice settlement + /// @param givenAmount The actual amount available from the router + /// @param partialFillOffset The offset from Liquorice API indicating where the fill amount is located + /// @return The modified calldata with updated fill amount + function _modifyFilledTakerAmount( + bytes memory liquoriceCalldata, + uint256 givenAmount, + uint32 partialFillOffset + ) internal pure returns (bytes memory) { + // Use the offset from Liquorice API to locate the fill amount + // Position = 4 bytes (selector) + offset bytes + uint256 fillAmountPos = 4 + uint256(partialFillOffset); + + // Use assembly to modify the fill amount at the correct position + // slither-disable-next-line assembly + assembly { + // Get pointer to the data portion of the bytes array + let dataPtr := add(liquoriceCalldata, 0x20) + + // Calculate the actual position and store the new value + let actualPos := add(dataPtr, fillAmountPos) + mstore(actualPos, givenAmount) + } + + return liquoriceCalldata; + } + + /// @dev Returns the balance of a token or ETH for an account + /// @param token The token address, or address(0) for ETH + /// @param account The account to get the balance of + /// @return The balance of the token or ETH for the account + function _balanceOf(address token, address account) + internal + view + returns (uint256) + { + return token == address(0) + ? account.balance + : IERC20(token).balanceOf(account); + } + + /// @dev Allow receiving ETH for settlement calls that require ETH + receive() external payable {} +} diff --git a/foundry/test/Constants.sol b/foundry/test/Constants.sol index 0efb9091..d55913db 100644 --- a/foundry/test/Constants.sol +++ b/foundry/test/Constants.sol @@ -156,6 +156,11 @@ contract Constants is Test, BaseConstants { // Hashflow Router address HASHFLOW_ROUTER = 0x55084eE0fEf03f14a305cd24286359A35D735151; + // Liquorice Settlement + address LIQUORICE_SETTLEMENT = 0x0448633eb8B0A42EfED924C42069E0DcF08fb552; + address LIQUORICE_BALANCE_MANAGER = + 0xb87bAE43a665EB5943A5642F81B26666bC9E5C95; + // Pool Code Init Hashes bytes32 USV2_POOL_CODE_INIT_HASH = 0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f; diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index 83c9996b..527ff411 100644 --- a/foundry/test/TychoRouterTestSetup.sol +++ b/foundry/test/TychoRouterTestSetup.sol @@ -9,6 +9,7 @@ import {CurveExecutor} from "../src/executors/CurveExecutor.sol"; import {EkuboExecutor} from "../src/executors/EkuboExecutor.sol"; import {EkuboV3Executor} from "../src/executors/EkuboV3Executor.sol"; import {HashflowExecutor} from "../src/executors/HashflowExecutor.sol"; +import {LiquoriceExecutor} from "../src/executors/LiquoriceExecutor.sol"; import {MaverickV2Executor} from "../src/executors/MaverickV2Executor.sol"; import {UniswapV2Executor} from "../src/executors/UniswapV2Executor.sol"; import { @@ -84,6 +85,7 @@ contract TychoRouterTestSetup is Constants, Permit2TestHelper, TestUtils { BalancerV3Executor public balancerV3Executor; BebopExecutor public bebopExecutor; HashflowExecutor public hashflowExecutor; + LiquoriceExecutor public liquoriceExecutor; FluidV1Executor public fluidV1Executor; SlipstreamsExecutor public slipstreamsExecutor; RocketpoolExecutor public rocketpoolExecutor; @@ -178,8 +180,11 @@ contract TychoRouterTestSetup is Constants, Permit2TestHelper, TestUtils { 0xCd5fE23C85820F7B72D0926FC9b05b43E359b7ee, 0xDadEf1fFBFeaAB4f68A9fD181395F68b4e4E7Ae0 ); + liquoriceExecutor = new LiquoriceExecutor( + LIQUORICE_SETTLEMENT, LIQUORICE_BALANCE_MANAGER, PERMIT2_ADDRESS + ); - address[] memory executors = new address[](17); + address[] memory executors = new address[](18); executors[0] = address(usv2Executor); executors[1] = address(usv3Executor); executors[2] = address(pancakev3Executor); @@ -197,6 +202,7 @@ contract TychoRouterTestSetup is Constants, Permit2TestHelper, TestUtils { executors[14] = address(erc4626Executor); executors[15] = address(ekuboV3Executor); executors[16] = address(etherfiExecutor); + executors[17] = address(liquoriceExecutor); return executors; } diff --git a/foundry/test/assets/calldata.txt b/foundry/test/assets/calldata.txt index 6627e1c6..d2eda7b5 100644 --- a/foundry/test/assets/calldata.txt +++ b/foundry/test/assets/calldata.txt @@ -60,3 +60,5 @@ test_single_encoding_strategy_ekubo_v3:5c4b639c000000000000000000000000000000000 test_single_ekubo_v3_grouped_swap:5c4b639c00000000000000000000000000000000000000000000000000000002540be400000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc20000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000012000000000000000000000000000000000000000000000000000000000000000a596d3f6c20eed2697647f543fe6c08bc2fbf3975800cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2dac17f958d2ee523a2206206994597c13d831ec7a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000a7c5ac471b48800000320000000000000000000000000000000000000000517e506700271aea091b02f42756f5e174af5230000000000000000000000000000000000000000000000000000000000000000000000000000000 test_sequential_encoding_strategy_etherfi_unwrap_weeth:e21dd0d30000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000cd5fe23c85820f7b72d0926fc9b05b43e359b7ee00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009964bff29baa37b47604f3f3f51f3b3c5149d6de00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000005a002b13aa49bac059d709dd0a18d6bb63290076a702d76bc529dc7b81a031828ddce2bc419d01ff268c66000300002b13aa49bac059d709dd0a18d6bb63290076a702d79964bff29baa37b47604f3f3f51f3b3c5149d6de020001000000000000 test_sequential_encoding_strategy_etherfi_wrap_eeth:e21dd0d30000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd5fe23c85820f7b72d0926fc9b05b43e359b7ee0000000000000000000000000000000000000000000000000c7d713b49da0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009964bff29baa37b47604f3f3f51f3b3c5149d6de00000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000005a002b13aa49bac059d709dd0a18d6bb63290076a702d76bc529dc7b81a031828ddce2bc419d01ff268c66020100002b13aa49bac059d709dd0a18d6bb63290076a702d79964bff29baa37b47604f3f3f51f3b3c5149d6de020201000000000000 +test_single_encoding_strategy_liquorice_settle_single:5c4b639c00000000000000000000000000000000000000000000000000000000b2d05e00000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000045adb25a7b768311de128bbda7b8426c3f9c74f3240a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000006000000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000b2d05e0001d2068e04cf586f76eece7ba5beb779d7bb1474a19935c86800000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000b2d05e00000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a10000000000000000000000006bc529dc7b81a031828ddce2bc419d01ff268c66000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f6870580000000000000000000000000000000000000000000000000000000000000001310000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000419ab2af25941edb594c4c33388649c35f7bbc545ce0a745f9e6272c0ea0e8b2517939f2747b980419ca5f22129742f65755464928ac9592aff9d16ac4446b18df1c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000 +test_single_encoding_strategy_liquorice_settle:5c4b639c00000000000000000000000000000000000000000000000000000000b2d05e00000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a100000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000063adb25a7b768311de128bbda7b8426c3f9c74f3240a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000002000000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000b2d05e0001d2068e04cf586f76eece7ba5beb779d7bb1474a1cba673a700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000448633eb8b0a42efed924c42069e0dcf08fb552000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a10000000000000000000000006bc529dc7b81a031828ddce2bc419d01ff268c66000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f6870580000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000416e4084d38e2d4e334057a124ce1ed667947b4fe6a0b44d6cbd62baa5dd384ff93eba5c03f8ea1f4fec128c1c76ce49eb1aad71f053adf3a4d860ef9fe973374d1b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000 diff --git a/foundry/test/protocols/Liquorice.t.sol b/foundry/test/protocols/Liquorice.t.sol new file mode 100644 index 00000000..fc7a0c87 --- /dev/null +++ b/foundry/test/protocols/Liquorice.t.sol @@ -0,0 +1,496 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import "../TestUtils.sol"; +import "../TychoRouterTestSetup.sol"; +import "@src/executors/LiquoriceExecutor.sol"; +import {Constants} from "../Constants.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {Permit2TestHelper} from "../Permit2TestHelper.sol"; +import { + SafeERC20 +} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +interface ILiquoriceSettlement { + function BALANCE_MANAGER() external view returns (address); + function AUTHENTICATOR() external view returns (address); +} + +interface IAllowListAuthentication { + function addSolver(address _solver) external; + function addMaker(address _maker) external; +} + +contract LiquoriceExecutorExposed is LiquoriceExecutor { + constructor( + address _liquoriceSettlement, + address _liquoriceBalanceManager, + address _permit2 + ) + LiquoriceExecutor( + _liquoriceSettlement, _liquoriceBalanceManager, _permit2 + ) + {} + + function decodeData(bytes calldata data) + external + pure + returns ( + address tokenIn, + address tokenOut, + TransferType transferType, + uint32 partialFillOffset, + uint256 originalBaseTokenAmount, + uint256 minBaseTokenAmount, + bool approvalNeeded, + address receiver, + bytes memory liquoriceCalldata + ) + { + return _decodeData(data); + } + + function clampAmount( + uint256 givenAmount, + uint256 originalBaseTokenAmount, + uint256 minBaseTokenAmount + ) external pure returns (uint256) { + return _clampAmount( + givenAmount, originalBaseTokenAmount, minBaseTokenAmount + ); + } +} + +contract LiquoriceExecutorTest is Constants, Permit2TestHelper, TestUtils { + using SafeERC20 for IERC20; + + ILiquoriceSettlement liquoriceSettlement; + IAllowListAuthentication authenticator; + LiquoriceExecutorExposed liquoriceExecutor; + + address constant AUTH_MANAGER = 0x000438801500c89E225E8D6CB69D9c14dD05e000; + + IERC20 WETH = IERC20(WETH_ADDR); + IERC20 USDC = IERC20(USDC_ADDR); + IERC20 WBTC = IERC20(WBTC_ADDR); + + address constant MAKER = 0x06465bcEEaef280Bb7340A58D75dfc5E1F687058; + uint256 constant FORK_BLOCK = 24_392_845; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK); + + liquoriceSettlement = ILiquoriceSettlement(LIQUORICE_SETTLEMENT); + + liquoriceExecutor = new LiquoriceExecutorExposed( + LIQUORICE_SETTLEMENT, LIQUORICE_BALANCE_MANAGER, PERMIT2_ADDRESS + ); + authenticator = + IAllowListAuthentication(liquoriceSettlement.AUTHENTICATOR()); + + vm.prank(AUTH_MANAGER); + authenticator.addSolver(address(liquoriceExecutor)); + vm.prank(AUTH_MANAGER); + authenticator.addMaker(MAKER); + + vm.prank(MAKER); + WETH.approve(LIQUORICE_BALANCE_MANAGER, type(uint256).max); + } + + function testSettleSingle() public { + // 3000 USDC -> 1 WETH + bytes memory liquoriceCalldata = + hex"9935c86800000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000010000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f0000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f687058000000000000000000000000000000000000000000000000000000000000000131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000041883a6506193307eebda0f3adf2cb81f84a073e030749055ebb18cbf98704eef100a03c307266527d706f9a5c3e08ed0988f5b130bc5327e0ad62dde6f3709d251b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"; + + address tokenIn = USDC_ADDR; + address tokenOut = WETH_ADDR; + RestrictTransferFrom.TransferType transferType = + RestrictTransferFrom.TransferType.None; + uint32 partialFillOffset = 96; // PARTIAL_FILL_OFFSET_SETTLE_SINGLE_ORDER + uint256 amountIn = 3000e6; // 3000 USDC + bool approvalNeeded = true; + uint256 expectedAmountOut = 1 ether; + + // Fund maker with WETH (what trader receives) + deal(WETH_ADDR, MAKER, expectedAmountOut); + // Fund executor with USDC (what trader sells) + deal(tokenIn, address(liquoriceExecutor), amountIn); + + bytes memory params = abi.encodePacked( + tokenIn, + tokenOut, + uint8(transferType), + partialFillOffset, + amountIn, // originalBaseTokenAmount + amountIn, // minBaseTokenAmount (same for full fill) + uint8(approvalNeeded ? 1 : 0), + address(liquoriceExecutor), + liquoriceCalldata + ); + + uint256 initialTokenOutBalance = + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)); + + uint256 amountOut = liquoriceExecutor.swap(amountIn, params); + + assertEq(amountOut, expectedAmountOut, "Incorrect amount out"); + assertEq( + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)) + - initialTokenOutBalance, + expectedAmountOut, + "WETH should be at receiver" + ); + assertEq( + IERC20(tokenIn).balanceOf(address(liquoriceExecutor)), + 0, + "USDC left in executor" + ); + } + + function testSettleSingle_PartialFill() public { + // 1500 USDC -> 0.5 WETH (50% partial fill of 3000 USDC -> 1 WETH quote) + bytes memory liquoriceCalldata = + hex"9935c86800000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000000000000000320000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000010000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f0000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f687058000000000000000000000000000000000000000000000000000000000000000131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000041883a6506193307eebda0f3adf2cb81f84a073e030749055ebb18cbf98704eef100a03c307266527d706f9a5c3e08ed0988f5b130bc5327e0ad62dde6f3709d251b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"; + + address tokenIn = USDC_ADDR; + address tokenOut = WETH_ADDR; + RestrictTransferFrom.TransferType transferType = + RestrictTransferFrom.TransferType.None; + uint32 partialFillOffset = 96; // PARTIAL_FILL_OFFSET_SETTLE_SINGLE_ORDER + uint256 originalAmountIn = 3000e6; // Original quote: 3000 USDC + uint256 amountIn = 1500e6; // Partial fill: 1500 USDC (50%) + uint256 minAmountIn = 1500e6; // Minimum allowed + bool approvalNeeded = true; + uint256 expectedAmountOut = 0.5 ether; // 50% of 1 WETH + + // Fund maker with WETH (only need 0.5 for partial fill) + deal(WETH_ADDR, MAKER, expectedAmountOut); + // Fund executor with partial USDC amount + deal(tokenIn, address(liquoriceExecutor), amountIn); + + bytes memory params = abi.encodePacked( + tokenIn, + tokenOut, + uint8(transferType), + partialFillOffset, + originalAmountIn, // originalBaseTokenAmount from quote + minAmountIn, // minBaseTokenAmount for partial fill + uint8(approvalNeeded ? 1 : 0), + address(liquoriceExecutor), + liquoriceCalldata + ); + + uint256 initialTokenOutBalance = + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)); + + uint256 amountOut = liquoriceExecutor.swap(amountIn, params); + + assertEq(amountOut, expectedAmountOut, "Incorrect partial amount out"); + assertEq( + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)) + - initialTokenOutBalance, + expectedAmountOut, + "WETH should be at receiver" + ); + assertEq( + IERC20(tokenIn).balanceOf(address(liquoriceExecutor)), + 0, + "USDC left in executor" + ); + } + + function testSettle() public { + // 3000 USDC -> 1 WETH using settle() function + bytes memory liquoriceCalldata = + hex"cba673a700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000448633eb8b0a42efed924c42069e0dcf08fb5520000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000000010000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f0000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f6870580000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000411d85c337d0e071eb601d8a90e2e8dd0afb61db200a1614c4afe5d26ff0c11bd402e018ab5fdbf8386437d7594af4383cf020a75c96e02c0a208f0b06e86115401b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"; + + address tokenIn = USDC_ADDR; + address tokenOut = WETH_ADDR; + RestrictTransferFrom.TransferType transferType = + RestrictTransferFrom.TransferType.None; + uint32 partialFillOffset = 32; // PARTIAL_FILL_OFFSET_SETTLE_ORDER + uint256 amountIn = 3000e6; // 3000 USDC + bool approvalNeeded = true; + uint256 expectedAmountOut = 1 ether; + + // Fund maker with WETH + deal(WETH_ADDR, MAKER, expectedAmountOut); + // Fund executor with USDC + deal(tokenIn, address(liquoriceExecutor), amountIn); + + bytes memory params = abi.encodePacked( + tokenIn, + tokenOut, + uint8(transferType), + partialFillOffset, + amountIn, // originalBaseTokenAmount + amountIn, // minBaseTokenAmount (same for full fill) + uint8(approvalNeeded ? 1 : 0), + address(liquoriceExecutor), + liquoriceCalldata + ); + + uint256 initialTokenOutBalance = + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)); + + uint256 amountOut = liquoriceExecutor.swap(amountIn, params); + + assertEq(amountOut, expectedAmountOut, "Incorrect amount out"); + assertEq( + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)) + - initialTokenOutBalance, + expectedAmountOut, + "WETH should be at receiver" + ); + assertEq( + IERC20(tokenIn).balanceOf(address(liquoriceExecutor)), + 0, + "USDC left in executor" + ); + } + + function testSettle_PartialFill() public { + // 1500 USDC -> 0.5 WETH (50% partial fill) using settle() function + bytes memory liquoriceCalldata = + hex"cba673a700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f687058000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000448633eb8b0a42efed924c42069e0dcf08fb5520000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000026000000000000000000000000000000000000000000000000000000000000000010000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f0000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f6870580000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000013100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000060000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000041e89ad636a6d749213b9339ac5218229adaa53bdf96d457ee2cebfd4fd02909bf678953c976ceca7307ef2e73c5687c33738a6a1e4130e378d220b68d9c59e18b1b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000"; + + address tokenIn = USDC_ADDR; + address tokenOut = WETH_ADDR; + RestrictTransferFrom.TransferType transferType = + RestrictTransferFrom.TransferType.None; + uint32 partialFillOffset = 32; // PARTIAL_FILL_OFFSET_SETTLE_ORDER + uint256 originalAmountIn = 3000e6; // Original quote: 3000 USDC + uint256 amountIn = 1500e6; // Partial fill: 1500 USDC (50%) + uint256 minAmountIn = 1500e6; // Minimum allowed + bool approvalNeeded = true; + uint256 expectedAmountOut = 0.5 ether; // 50% of 1 WETH + + // Fund maker with WETH (only need 0.5 for partial fill) + deal(WETH_ADDR, MAKER, expectedAmountOut); + // Fund executor with partial USDC amount + deal(tokenIn, address(liquoriceExecutor), amountIn); + + bytes memory params = abi.encodePacked( + tokenIn, + tokenOut, + uint8(transferType), + partialFillOffset, + originalAmountIn, // originalBaseTokenAmount from quote + minAmountIn, // minBaseTokenAmount for partial fill + uint8(approvalNeeded ? 1 : 0), + address(liquoriceExecutor), + liquoriceCalldata + ); + + uint256 initialTokenOutBalance = + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)); + + uint256 amountOut = liquoriceExecutor.swap(amountIn, params); + + assertEq(amountOut, expectedAmountOut, "Incorrect partial amount out"); + assertEq( + IERC20(tokenOut).balanceOf(address(liquoriceExecutor)) + - initialTokenOutBalance, + expectedAmountOut, + "WETH should be at receiver" + ); + assertEq( + IERC20(tokenIn).balanceOf(address(liquoriceExecutor)), + 0, + "USDC left in executor" + ); + } + + function testDecodeData() public view { + bytes memory liquoriceCalldata = abi.encodePacked( + bytes4(0xdeadbeef), // mock selector + hex"1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + ); + + uint256 originalAmount = 1000000000; // 1000 USDC + uint256 minAmount = 800000000; // 800 USDC + address receiver = address(0xABcdEFABcdEFabcdEfAbCdefabcdeFABcDEFabCD); + + bytes memory params = abi.encodePacked( + USDC_ADDR, // tokenIn (20 bytes) + WETH_ADDR, // tokenOut (20 bytes) + uint8(RestrictTransferFrom.TransferType.Transfer), // transferType (1 byte) + uint32(5), // partialFillOffset (4 bytes) + originalAmount, // originalBaseTokenAmount (32 bytes) + minAmount, // minBaseTokenAmount (32 bytes) + uint8(0), // approvalNeeded (1 byte) - false + receiver, // receiver (20 bytes) + liquoriceCalldata // variable length + ); + + ( + address decodedTokenIn, + address decodedTokenOut, + RestrictTransferFrom.TransferType decodedTransferType, + uint32 decodedPartialFillOffset, + uint256 decodedOriginalAmount, + uint256 decodedMinAmount, + bool decodedApprovalNeeded, + address decodedReceiver, + bytes memory decodedCalldata + ) = liquoriceExecutor.decodeData(params); + + assertEq(decodedTokenIn, USDC_ADDR, "tokenIn mismatch"); + assertEq(decodedTokenOut, WETH_ADDR, "tokenOut mismatch"); + assertEq( + uint8(decodedTransferType), + uint8(RestrictTransferFrom.TransferType.Transfer), + "transferType mismatch" + ); + assertEq(decodedPartialFillOffset, 5, "partialFillOffset mismatch"); + assertEq( + decodedOriginalAmount, originalAmount, "originalAmount mismatch" + ); + assertEq(decodedMinAmount, minAmount, "minAmount mismatch"); + assertFalse(decodedApprovalNeeded, "approvalNeeded should be false"); + assertEq(decodedReceiver, receiver, "receiver mismatch"); + assertEq( + keccak256(decodedCalldata), + keccak256(liquoriceCalldata), + "calldata mismatch" + ); + } + + function testDecodeData_InvalidDataLength() public { + // Too short - missing required fields + bytes memory tooShort = abi.encodePacked( + USDC_ADDR, // tokenIn (20 bytes) + WETH_ADDR, // tokenOut (20 bytes) + uint8(RestrictTransferFrom.TransferType.Transfer) // transferType (1 byte) + // missing: partialFillOffset, originalAmount, approvalNeeded, receiver + ); + + vm.expectRevert( + LiquoriceExecutor.LiquoriceExecutor__InvalidDataLength.selector + ); + liquoriceExecutor.decodeData(tooShort); + } + + function testClampAmount_WithinRange() public view { + // givenAmount is within [minBaseTokenAmount, originalBaseTokenAmount] + uint256 result = liquoriceExecutor.clampAmount( + 500, // givenAmount + 1000, // originalBaseTokenAmount + 100 // minBaseTokenAmount + ); + assertEq(result, 500, "Should return givenAmount when within range"); + } + + function testClampAmount_ExceedsMax() public view { + // givenAmount exceeds originalBaseTokenAmount + uint256 result = liquoriceExecutor.clampAmount( + 1500, // givenAmount + 1000, // originalBaseTokenAmount + 100 // minBaseTokenAmount + ); + assertEq( + result, + 1000, + "Should clamp to originalBaseTokenAmount when exceeded" + ); + } + + function testClampAmount_BelowMin_Reverts() public { + // givenAmount is below minBaseTokenAmount - should revert + vm.expectRevert( + LiquoriceExecutor.LiquoriceExecutor__AmountBelowMinimum.selector + ); + liquoriceExecutor.clampAmount( + 50, // givenAmount + 1000, // originalBaseTokenAmount + 100 // minBaseTokenAmount + ); + } +} + +contract TychoRouterForLiquoriceTest is TychoRouterTestSetup { + using SafeERC20 for IERC20; + + address constant AUTH_MANAGER = 0x000438801500c89E225E8D6CB69D9c14dD05e000; + address constant MAKER = 0x06465bcEEaef280Bb7340A58D75dfc5E1F687058; + + // Override the fork block for Liquorice tests + function getForkBlock() public pure override returns (uint256) { + return 24392845; + } + + function setUp() public override { + super.setUp(); + + // Whitelist executor as solver and MAKER + ILiquoriceSettlement settlement = + ILiquoriceSettlement(LIQUORICE_SETTLEMENT); + IAllowListAuthentication authenticator = + IAllowListAuthentication(settlement.AUTHENTICATOR()); + + vm.prank(AUTH_MANAGER); + authenticator.addSolver(address(tychoRouter)); + vm.prank(AUTH_MANAGER); + authenticator.addMaker(MAKER); + + // MAKER approves balance manager + vm.prank(MAKER); + IERC20(WETH_ADDR).approve(LIQUORICE_BALANCE_MANAGER, type(uint256).max); + } + + function testSettleSingleLiquoriceIntegration() public { + // 3000 USDC -> 1 WETH using settleSingleOrder + address user = 0xd2068e04Cf586f76EEcE7BA5bEB779D7bB1474A1; + deal(USDC_ADDR, user, 3000e6); + deal(WETH_ADDR, MAKER, 1 ether); + uint256 expAmountOut = 1 ether; + + uint256 wethBefore = IERC20(WETH_ADDR).balanceOf(user); + vm.startPrank(user); + IERC20(USDC_ADDR).approve(tychoRouterAddr, type(uint256).max); + + bytes memory callData = loadCallDataFromFile( + "test_single_encoding_strategy_liquorice_settle_single" + ); + + (bool success,) = tychoRouterAddr.call(callData); + + assertTrue(success, "Call Failed"); + uint256 wethReceived = IERC20(WETH_ADDR).balanceOf(user) - wethBefore; + assertEq(wethReceived, expAmountOut, "Incorrect WETH received"); + assertEq( + IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), + 0, + "USDC left in router" + ); + vm.stopPrank(); + } + + function testSettleLiquoriceIntegration() public { + // 3000 USDC -> 1 WETH using settle + address user = 0xd2068e04Cf586f76EEcE7BA5bEB779D7bB1474A1; + deal(USDC_ADDR, user, 3000e6); + deal(WETH_ADDR, MAKER, 1 ether); + uint256 expAmountOut = 1 ether; + + uint256 wethBefore = IERC20(WETH_ADDR).balanceOf(user); + vm.startPrank(user); + IERC20(USDC_ADDR).approve(tychoRouterAddr, type(uint256).max); + + bytes memory callData = loadCallDataFromFile( + "test_single_encoding_strategy_liquorice_settle" + ); + + (bool success,) = tychoRouterAddr.call(callData); + + assertTrue(success, "Call Failed"); + uint256 wethReceived = IERC20(WETH_ADDR).balanceOf(user) - wethBefore; + assertEq(wethReceived, expAmountOut, "Incorrect WETH received"); + assertEq( + IERC20(USDC_ADDR).balanceOf(tychoRouterAddr), + 0, + "USDC left in router" + ); + vm.stopPrank(); + } +} diff --git a/src/encoding/evm/constants.rs b/src/encoding/evm/constants.rs index cfe77414..3cbba90d 100644 --- a/src/encoding/evm/constants.rs +++ b/src/encoding/evm/constants.rs @@ -37,6 +37,7 @@ pub static FUNDS_IN_ROUTER_PROTOCOLS: LazyLock> = LazyLock set.insert("vm:curve"); set.insert("rfq:bebop"); set.insert("rfq:hashflow"); + set.insert("rfq:liquorice"); set.insert("rocketpool"); set.insert("erc4626"); set.insert("etherfi"); diff --git a/src/encoding/evm/swap_encoder/liquorice.rs b/src/encoding/evm/swap_encoder/liquorice.rs new file mode 100644 index 00000000..38c81a37 --- /dev/null +++ b/src/encoding/evm/swap_encoder/liquorice.rs @@ -0,0 +1,325 @@ +use std::{collections::HashMap, str::FromStr, sync::Arc}; + +use alloy::primitives::Address; +use tokio::{ + runtime::{Handle, Runtime}, + task::block_in_place, +}; +use tycho_common::{ + models::{protocol::GetAmountOutParams, Chain}, + Bytes, +}; + +use crate::encoding::{ + errors::EncodingError, + evm::{ + approvals::protocol_approvals_manager::ProtocolApprovalsManager, + utils::{bytes_to_address, get_runtime}, + }, + models::{EncodingContext, Swap}, + swap_encoder::SwapEncoder, +}; + +/// Encodes a swap on Liquorice (RFQ) through the given executor address. +/// +/// Liquorice uses a Request-for-Quote model where quotes are obtained off-chain +/// and settled on-chain. The executor receives pre-encoded calldata from the API. +/// +/// # Fields +/// * `executor_address` - The address of the executor contract that will perform the swap. +/// * `balance_manager_address` - The address of the Liquorice balance manager contract. +#[derive(Clone)] +pub struct LiquoriceSwapEncoder { + executor_address: Bytes, + balance_manager_address: Bytes, + runtime_handle: Handle, + #[allow(dead_code)] + runtime: Option>, +} + +impl SwapEncoder for LiquoriceSwapEncoder { + fn new( + executor_address: Bytes, + _chain: Chain, + config: Option>, + ) -> Result { + let config = config.ok_or(EncodingError::FatalError( + "Missing liquorice specific addresses in config".to_string(), + ))?; + let balance_manager_address = config + .get("balance_manager_address") + .ok_or_else(|| { + EncodingError::FatalError( + "Missing liquorice balance manager address in config".to_string(), + ) + }) + .and_then(|s| { + Bytes::from_str(s).map_err(|_| { + EncodingError::FatalError( + "Invalid liquorice balance manager address".to_string(), + ) + }) + })?; + + let (runtime_handle, runtime) = get_runtime()?; + Ok(Self { executor_address, balance_manager_address, runtime_handle, runtime }) + } + + fn encode_swap( + &self, + swap: &Swap, + encoding_context: &EncodingContext, + ) -> Result, EncodingError> { + let token_in = bytes_to_address(swap.token_in())?; + let token_out = bytes_to_address(swap.token_out())?; + + // Get protocol state and request signed quote + let protocol_state = swap + .get_protocol_state() + .as_ref() + .ok_or_else(|| { + EncodingError::FatalError("protocol_state is required for Liquorice".to_string()) + })?; + + let estimated_amount_in = swap + .get_estimated_amount_in() + .clone() + .ok_or(EncodingError::FatalError( + "Estimated amount in is mandatory for a Liquorice swap".to_string(), + ))?; + + let router_address = encoding_context + .router_address + .clone() + .ok_or(EncodingError::FatalError( + "The router address is needed to perform a Liquorice swap".to_string(), + ))?; + + let params = GetAmountOutParams { + amount_in: estimated_amount_in.clone(), + token_in: swap.token_in().clone(), + token_out: swap.token_out().clone(), + sender: router_address.clone(), + receiver: encoding_context.receiver.clone(), + }; + + let signed_quote = block_in_place(|| { + self.runtime_handle.block_on(async { + protocol_state + .as_indicatively_priced()? + .request_signed_quote(params) + .await + }) + })?; + + // Extract required fields from quote + let liquorice_calldata = signed_quote + .quote_attributes + .get("calldata") + .ok_or(EncodingError::FatalError( + "Liquorice quote must have a calldata attribute".to_string(), + ))?; + + let base_token_amount = signed_quote + .quote_attributes + .get("base_token_amount") + .ok_or(EncodingError::FatalError( + "Liquorice quote must have a base_token_amount attribute".to_string(), + ))?; + + // Get partial fill offset (defaults to 0 if not present, meaning partial fill is not + // available for the quote) + let partial_fill_offset: Vec = signed_quote + .quote_attributes + .get("partial_fill_offset") + .map(|b| { + if b.len() == 4 { + b.to_vec() + } else { + // Pad to 4 bytes if needed + let mut padded = vec![0u8; 4]; + if b.len() < 4 { + let start = 4 - b.len(); + padded[start..].copy_from_slice(b); + } + padded + } + }) + .unwrap_or(vec![0u8; 4]); + + // Get min base token amount (defaults to original base token amount if partial fill is not + // available for the quote) + let min_base_token_amount = signed_quote + .quote_attributes + .get("min_base_token_amount") + .unwrap_or(base_token_amount); + + // Parse original base token amount (U256 encoded as 32 bytes) + let original_base_token_amount = if base_token_amount.len() == 32 { + base_token_amount.to_vec() + } else { + // Pad to 32 bytes if needed + let mut padded = vec![0u8; 32]; + let start = 32 - base_token_amount.len(); + padded[start..].copy_from_slice(base_token_amount); + padded + }; + + // Parse min base token amount (U256 encoded as 32 bytes) + let min_base_token_amount = if min_base_token_amount.len() == 32 { + min_base_token_amount.to_vec() + } else { + let mut padded = vec![0u8; 32]; + let start = 32 - min_base_token_amount.len(); + padded[start..].copy_from_slice(min_base_token_amount); + padded + }; + + // Check if approval is needed from Router to balance manager + let router_address = bytes_to_address(&router_address)?; + let balance_manager_address = Address::from_slice(&self.balance_manager_address); + let approval_needed = ProtocolApprovalsManager::new()?.approval_needed( + token_in, + router_address, + balance_manager_address, + )?; + + let receiver = bytes_to_address(&encoding_context.receiver)?; + + // Encode packed data for the executor + // Format: token_in | token_out | transfer_type | partial_fill_offset | + // original_base_token_amount | min_base_token_amount | + // approval_needed | receiver | liquorice_calldata + let mut encoded = Vec::new(); + + encoded.extend_from_slice(token_in.as_slice()); // 20 bytes + encoded.extend_from_slice(token_out.as_slice()); // 20 bytes + encoded.push(encoding_context.transfer_type as u8); // 1 byte + encoded.extend_from_slice(&partial_fill_offset); // 4 bytes + encoded.extend_from_slice(&original_base_token_amount); // 32 bytes + encoded.extend_from_slice(&min_base_token_amount); // 32 bytes + encoded.push(approval_needed as u8); // 1 byte + encoded.extend_from_slice(receiver.as_slice()); // 20 bytes + + // Calldata (variable length) + encoded.extend_from_slice(liquorice_calldata); + + Ok(encoded) + } + + fn executor_address(&self) -> &Bytes { + &self.executor_address + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use alloy::hex::encode; + use num_bigint::BigUint; + use tycho_common::models::protocol::ProtocolComponent; + + use super::*; + use crate::encoding::{ + evm::{ + swap_encoder::liquorice::LiquoriceSwapEncoder, testing_utils::MockRFQState, + utils::biguint_to_u256, + }, + models::TransferType, + }; + + fn liquorice_config() -> Option> { + Some(HashMap::from([( + "balance_manager_address".to_string(), + "0xb87bAE43a665EB5943A5642F81B26666bC9E5C95".to_string(), + )])) + } + + #[test] + fn test_encode_liquorice_single_with_protocol_state() { + // 3000 USDC -> 1 WETH using a mocked RFQ state to get a quote + let quote_amount_out = BigUint::from_str("1000000000000000000").unwrap(); + let liquorice_calldata = Bytes::from_str("0xdeadbeef1234567890").unwrap(); + let base_token_amount = biguint_to_u256(&BigUint::from(3000000000_u64)) + .to_be_bytes::<32>() + .to_vec(); + + let liquorice_component = ProtocolComponent { + id: String::from("liquorice-rfq"), + protocol_system: String::from("rfq:liquorice"), + ..Default::default() + }; + + let min_base_token_amount = biguint_to_u256(&BigUint::from(2500000000_u64)) + .to_be_bytes::<32>() + .to_vec(); + + let liquorice_state = MockRFQState { + quote_amount_out, + quote_data: HashMap::from([ + ("calldata".to_string(), liquorice_calldata.clone()), + ("base_token_amount".to_string(), Bytes::from(base_token_amount.clone())), + ("min_base_token_amount".to_string(), Bytes::from(min_base_token_amount.clone())), + ("partial_fill_offset".to_string(), Bytes::from(vec![12u8])), + ]), + }; + + let token_in = Bytes::from("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); // USDC + let token_out = Bytes::from("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"); // WETH + + let swap = Swap::new(liquorice_component, token_in.clone(), token_out.clone()) + .estimated_amount_in(BigUint::from_str("3000000000").unwrap()) + .protocol_state(Arc::new(liquorice_state)); + + let encoding_context = EncodingContext { + receiver: Bytes::from("0xc5564C13A157E6240659fb81882A28091add8670"), + exact_out: false, + router_address: Some(Bytes::zero(20)), + group_token_in: token_in.clone(), + group_token_out: token_out.clone(), + transfer_type: TransferType::Transfer, + historical_trade: false, + }; + + let encoder = LiquoriceSwapEncoder::new( + Bytes::from("0x543778987b293C7E8Cf0722BB2e935ba6f4068D4"), + Chain::Ethereum, + liquorice_config(), + ) + .unwrap(); + + let encoded_swap = encoder + .encode_swap(&swap, &encoding_context) + .unwrap(); + let hex_swap = encode(&encoded_swap); + + // Expected format: + // token_in (20) | token_out (20) | transfer_type (1) | partial_fill_offset (4) | + // original_base_token_amount (32) | min_base_token_amount (32) | + // approval_needed (1) | receiver (20) | calldata (variable) + let expected_swap = String::from(concat!( + // token_in (USDC) + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + // token_out (WETH) + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", + // transfer_type + "01", + // partial_fill_offset + "0000000c", + // original_base_token_amount (3000000000 as U256) + "00000000000000000000000000000000000000000000000000000000b2d05e00", + // min_base_token_amount (2500000000 as U256) + "000000000000000000000000000000000000000000000000000000009502f900", + // approval_needed + "01", + // receiver + "c5564c13a157e6240659fb81882a28091add8670", + )); + assert_eq!(hex_swap, expected_swap + &liquorice_calldata.to_string()[2..]); + } +} diff --git a/src/encoding/evm/swap_encoder/mod.rs b/src/encoding/evm/swap_encoder/mod.rs index 67748bfc..ce5ad29f 100644 --- a/src/encoding/evm/swap_encoder/mod.rs +++ b/src/encoding/evm/swap_encoder/mod.rs @@ -8,6 +8,7 @@ mod erc_4626; mod etherfi; mod fluid_v1; mod hashflow; +mod liquorice; mod maverick_v2; mod rocketpool; mod slipstreams; diff --git a/src/encoding/evm/swap_encoder/swap_encoder_registry.rs b/src/encoding/evm/swap_encoder/swap_encoder_registry.rs index b3625fb5..dce60793 100644 --- a/src/encoding/evm/swap_encoder/swap_encoder_registry.rs +++ b/src/encoding/evm/swap_encoder/swap_encoder_registry.rs @@ -11,10 +11,10 @@ use crate::encoding::{ bebop::BebopSwapEncoder, curve::CurveSwapEncoder, ekubo::EkuboSwapEncoder, ekubo_v3::EkuboV3SwapEncoder, erc_4626::ERC4626SwapEncoder, etherfi::EtherfiSwapEncoder, fluid_v1::FluidV1SwapEncoder, - hashflow::HashflowSwapEncoder, maverick_v2::MaverickV2SwapEncoder, - rocketpool::RocketpoolSwapEncoder, slipstreams::SlipstreamsSwapEncoder, - uniswap_v2::UniswapV2SwapEncoder, uniswap_v3::UniswapV3SwapEncoder, - uniswap_v4::UniswapV4SwapEncoder, + hashflow::HashflowSwapEncoder, liquorice::LiquoriceSwapEncoder, + maverick_v2::MaverickV2SwapEncoder, rocketpool::RocketpoolSwapEncoder, + slipstreams::SlipstreamsSwapEncoder, uniswap_v2::UniswapV2SwapEncoder, + uniswap_v3::UniswapV3SwapEncoder, uniswap_v4::UniswapV4SwapEncoder, }, }, swap_encoder::SwapEncoder, @@ -136,6 +136,9 @@ impl SwapEncoderRegistry { "rfq:hashflow" => { Ok(Box::new(HashflowSwapEncoder::new(executor_address, self.chain, config)?)) } + "rfq:liquorice" => { + Ok(Box::new(LiquoriceSwapEncoder::new(executor_address, self.chain, config)?)) + } "fluid_v1" => { Ok(Box::new(FluidV1SwapEncoder::new(executor_address, self.chain, config)?)) } diff --git a/tests/protocol_integration_tests.rs b/tests/protocol_integration_tests.rs index 9fea1179..5e0993f7 100644 --- a/tests/protocol_integration_tests.rs +++ b/tests/protocol_integration_tests.rs @@ -1715,3 +1715,197 @@ fn test_sequential_encoding_strategy_etherfi_wrap_eeth() { hex_calldata.as_str(), ); } + +#[test] +fn test_single_encoding_strategy_liquorice_settle_single() { + // Note: This test generates calldata for the TychoRouterForLiquoriceTest Solidity integration + // test. + // + // Performs a swap from USDC to WETH using Liquorice RFQ settleSingleOrder + // Uses real calldata captured at block 24,392,845 + // + // USDC ───(Liquorice RFQ)──> WETH + let user = Bytes::from_str("0xd2068e04cf586f76eece7ba5beb779d7bb1474a1").unwrap(); + + let usdc = usdc(); + let weth = weth(); + + // 3000 USDC -> 1 WETH via Liquorice RFQ + let quote_amount_out = BigUint::from_str("1000000000000000000").unwrap(); // 1 WETH + + // Real calldata for Liquorice settleSingleOrder (selector 0x9935c868) + // Captured from testSettleSingle() in Liquorice.t.sol at block 24,392,845 + let liquorice_calldata = Bytes::from( + hex::decode("9935c86800000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000024000000000000000000000000000000000000000000000000000000000b2d05e00000000000000000000000000000000000000000000000000000000000000032000000000000000000000000000000000000000000000000000000000000001600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a10000000000000000000000006bc529dc7b81a031828ddce2bc419d01ff268c66000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000000000000000000000000000000000000b2d05e000000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f6870580000000000000000000000000000000000000000000000000000000000000001310000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000419ab2af25941edb594c4c33388649c35f7bbc545ce0a745f9e6272c0ea0e8b2517939f2747b980419ca5f22129742f65755464928ac9592aff9d16ac4446b18df1c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000") + .unwrap(), + ); + + let liquorice_state = MockRFQState { + quote_amount_out, + quote_data: HashMap::from([ + ("calldata".to_string(), liquorice_calldata), + ( + "base_token_amount".to_string(), + Bytes::from( + biguint_to_u256(&BigUint::from(3000000000_u64)) + .to_be_bytes::<32>() + .to_vec(), + ), + ), + ( + "partial_fill_offset".to_string(), + Bytes::from(vec![96u8]), // offset = 96 for settleSingleOrder + ), + ( + "min_base_token_amount".to_string(), + Bytes::from( + biguint_to_u256(&BigUint::from(3000000000_u64)) + .to_be_bytes::<32>() + .to_vec(), + ), + ), + ]), + }; + + let liquorice_component = ProtocolComponent { + id: String::from("liquorice-rfq"), + protocol_system: String::from("rfq:liquorice"), + ..Default::default() + }; + + let swap_usdc_weth = Swap::new(liquorice_component, usdc.clone(), weth.clone()) + .estimated_amount_in(BigUint::from_str("3000000000").unwrap()) + .protocol_state(Arc::new(liquorice_state)); + + let encoder = get_tycho_router_encoder(UserTransferType::TransferFrom); + + let solution = Solution { + exact_out: false, + given_token: usdc, + given_amount: BigUint::from_str("3000000000").unwrap(), + checked_token: weth, + checked_amount: BigUint::from_str("1000000000000000000").unwrap(), + sender: user.clone(), + receiver: user, + swaps: vec![swap_usdc_weth], + ..Default::default() + }; + + let encoded_solution = encoder + .encode_solutions(vec![solution.clone()]) + .unwrap()[0] + .clone(); + + let calldata = encode_tycho_router_call( + eth_chain().id(), + encoded_solution, + &solution, + &UserTransferType::TransferFrom, + ð(), + None, + ) + .unwrap() + .data; + + let hex_calldata = encode(&calldata); + write_calldata_to_file( + "test_single_encoding_strategy_liquorice_settle_single", + hex_calldata.as_str(), + ); +} + +#[test] +fn test_single_encoding_strategy_liquorice_settle() { + // Note: This test generates calldata for the TychoRouterForLiquoriceTest Solidity integration + // test. + // + // Performs a swap from USDC to WETH using Liquorice RFQ settle + // Uses real calldata captured at block 24,392,845 + // + // USDC ───(Liquorice RFQ)──> WETH + + let user = Bytes::from_str("0xd2068e04cf586f76eece7ba5beb779d7bb1474a1").unwrap(); + + let usdc = usdc(); + let weth = weth(); + + // 3000 USDC -> 1 WETH via Liquorice RFQ + let quote_amount_out = BigUint::from_str("1000000000000000000").unwrap(); // 1 WETH + + // Real calldata for Liquorice settle (selector 0xcba673a7) + // Captured from testSettle() in Liquorice.t.sol at block 24,392,845 + let liquorice_calldata = Bytes::from( + hex::decode("cba673a700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f68705800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000003a0000000000000000000000000000000000000000000000000000000000000042000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000448633eb8b0a42efed924c42069e0dcf08fb552000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000001000000000000000000000000d2068e04cf586f76eece7ba5beb779d7bb1474a10000000000000000000000006bc529dc7b81a031828ddce2bc419d01ff268c66000000000000000000000000000000000000000000000000000000006985036700000000000000000000000006465bceeaef280bb7340a58d75dfc5e1f6870580000000000000000000000000000000000000000000000000000000000000001000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000b2d05e0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000131000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000416e4084d38e2d4e334057a124ce1ed667947b4fe6a0b44d6cbd62baa5dd384ff93eba5c03f8ea1f4fec128c1c76ce49eb1aad71f053adf3a4d860ef9fe973374d1b0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000") + .unwrap(), + ); + + let liquorice_state = MockRFQState { + quote_amount_out, + quote_data: HashMap::from([ + ("calldata".to_string(), liquorice_calldata), + ( + "base_token_amount".to_string(), + Bytes::from( + biguint_to_u256(&BigUint::from(3000000000_u64)) + .to_be_bytes::<32>() + .to_vec(), + ), + ), + ( + "partial_fill_offset".to_string(), + Bytes::from(vec![32u8]), // offset = 32 for settle + ), + ( + "min_base_token_amount".to_string(), + Bytes::from( + biguint_to_u256(&BigUint::from(3000000000_u64)) + .to_be_bytes::<32>() + .to_vec(), + ), + ), + ]), + }; + + let liquorice_component = ProtocolComponent { + id: String::from("liquorice-rfq"), + protocol_system: String::from("rfq:liquorice"), + ..Default::default() + }; + + let swap_usdc_weth = Swap::new(liquorice_component, usdc.clone(), weth.clone()) + .estimated_amount_in(BigUint::from_str("3000000000").unwrap()) + .protocol_state(Arc::new(liquorice_state)); + + let encoder = get_tycho_router_encoder(UserTransferType::TransferFrom); + + let solution = Solution { + exact_out: false, + given_token: usdc, + given_amount: BigUint::from_str("3000000000").unwrap(), + checked_token: weth, + checked_amount: BigUint::from_str("1000000000000000000").unwrap(), + sender: user.clone(), + receiver: user, + swaps: vec![swap_usdc_weth], + ..Default::default() + }; + + let encoded_solution = encoder + .encode_solutions(vec![solution.clone()]) + .unwrap()[0] + .clone(); + + let calldata = encode_tycho_router_call( + eth_chain().id(), + encoded_solution, + &solution, + &UserTransferType::TransferFrom, + ð(), + None, + ) + .unwrap() + .data; + + let hex_calldata = encode(&calldata); + write_calldata_to_file("test_single_encoding_strategy_liquorice_settle", hex_calldata.as_str()); +}