From 58ce241c632feb6cc2014a511de9beca3cd6e471 Mon Sep 17 00:00:00 2001 From: die-herdplatte <173669014+die-herdplatte@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:02:37 +0200 Subject: [PATCH] Add support for Ekubo MEV-resist extension --- foundry/lib/ekubo/interfaces/ICore.sol | 6 +- .../lib/ekubo/interfaces/IFlashAccountant.sol | 12 +- foundry/lib/ekubo/types/poolKey.sol | 10 +- foundry/src/executors/EkuboExecutor.sol | 153 +++++++++++++----- foundry/test/TychoRouterTestSetup.sol | 9 +- foundry/test/executors/EkuboExecutor.t.sol | 86 ++++++++-- 6 files changed, 214 insertions(+), 62 deletions(-) diff --git a/foundry/lib/ekubo/interfaces/ICore.sol b/foundry/lib/ekubo/interfaces/ICore.sol index d2388e8..cb43b8d 100644 --- a/foundry/lib/ekubo/interfaces/ICore.sol +++ b/foundry/lib/ekubo/interfaces/ICore.sol @@ -2,15 +2,15 @@ pragma solidity ^0.8.26; import {IFlashAccountant} from "./IFlashAccountant.sol"; -import {EkuboPoolKey} from "../types/poolKey.sol"; +import {PoolKey} from "../types/poolKey.sol"; import {SqrtRatio} from "../types/sqrtRatio.sol"; interface ICore is IFlashAccountant { function swap_611415377( - EkuboPoolKey memory poolKey, + PoolKey memory poolKey, int128 amount, bool isToken1, SqrtRatio sqrtRatioLimit, uint256 skipAhead ) external payable returns (int128 delta0, int128 delta1); -} \ No newline at end of file +} diff --git a/foundry/lib/ekubo/interfaces/IFlashAccountant.sol b/foundry/lib/ekubo/interfaces/IFlashAccountant.sol index 2524078..5682e25 100644 --- a/foundry/lib/ekubo/interfaces/IFlashAccountant.sol +++ b/foundry/lib/ekubo/interfaces/IFlashAccountant.sol @@ -10,7 +10,17 @@ interface IPayer { } interface IFlashAccountant { + // Forward the lock from the current locker to the given address + // Any additional calldata is also passed through to the forwardee, with no additional encoding + // In addition, any data returned from IForwardee#forwarded is also returned from this function exactly as is, i.e. with no additional encoding or decoding + // Reverts are also bubbled up + function forward(address to) external; + // Withdraws a token amount from the accountant to the given recipient. // The contract must be locked, as it tracks the withdrawn amount against the current locker's delta. - function withdraw(address token, address recipient, uint128 amount) external; + function withdraw( + address token, + address recipient, + uint128 amount + ) external; } diff --git a/foundry/lib/ekubo/types/poolKey.sol b/foundry/lib/ekubo/types/poolKey.sol index ad20db8..ad375a4 100644 --- a/foundry/lib/ekubo/types/poolKey.sol +++ b/foundry/lib/ekubo/types/poolKey.sol @@ -1,12 +1,20 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.26; +using {extension} for Config global; + // address (20 bytes) | fee (8 bytes) | tickSpacing (4 bytes) type Config is bytes32; // Each pool has its own state associated with this key -struct EkuboPoolKey { +struct PoolKey { address token0; address token1; Config config; } + +function extension(Config config) pure returns (address e) { + assembly ("memory-safe") { + e := shr(96, config) + } +} diff --git a/foundry/src/executors/EkuboExecutor.sol b/foundry/src/executors/EkuboExecutor.sol index 63a57b3..f149234 100644 --- a/foundry/src/executors/EkuboExecutor.sol +++ b/foundry/src/executors/EkuboExecutor.sol @@ -9,8 +9,8 @@ import {ILocker, IPayer} from "@ekubo/interfaces/IFlashAccountant.sol"; import {NATIVE_TOKEN_ADDRESS} from "@ekubo/math/constants.sol"; import {SafeTransferLib} from "@solady/utils/SafeTransferLib.sol"; import {LibBytes} from "@solady/utils/LibBytes.sol"; -import {Config, EkuboPoolKey} from "@ekubo/types/poolKey.sol"; -import {MAX_SQRT_RATIO, MIN_SQRT_RATIO} from "@ekubo/types/sqrtRatio.sol"; +import {Config, PoolKey} from "@ekubo/types/poolKey.sol"; +import {MAX_SQRT_RATIO, MIN_SQRT_RATIO, SqrtRatio} from "@ekubo/types/sqrtRatio.sol"; import {RestrictTransferFrom} from "../RestrictTransferFrom.sol"; import "@openzeppelin/contracts/utils/Address.sol"; @@ -26,6 +26,7 @@ contract EkuboExecutor is error EkuboExecutor__UnknownCallback(); ICore immutable core; + address immutable mevResist; uint256 constant POOL_DATA_OFFSET = 57; uint256 constant HOP_BYTE_LEN = 52; @@ -33,30 +34,34 @@ contract EkuboExecutor is bytes4 constant LOCKED_SELECTOR = 0xb45a3c0e; // locked(uint256) bytes4 constant PAY_CALLBACK_SELECTOR = 0x599d0714; // payCallback(uint256,address) + uint256 constant SKIP_AHEAD = 0; + using SafeERC20 for IERC20; - constructor(address _core, address _permit2) - RestrictTransferFrom(_permit2) - { + constructor( + address _core, + address _mevResist, + address _permit2 + ) RestrictTransferFrom(_permit2) { core = ICore(_core); + mevResist = _mevResist; } - function swap(uint256 amountIn, bytes calldata data) - external - payable - returns (uint256 calculatedAmount) - { + function swap( + uint256 amountIn, + bytes calldata data + ) external payable returns (uint256 calculatedAmount) { if (data.length < 92) revert EkuboExecutor__InvalidDataLength(); // amountIn must be at most type(int128).MAX - calculatedAmount = - uint256(_lock(bytes.concat(bytes16(uint128(amountIn)), data))); + calculatedAmount = uint256( + _lock(bytes.concat(bytes16(uint128(amountIn)), data)) + ); } - function handleCallback(bytes calldata raw) - external - returns (bytes memory) - { + function handleCallback( + bytes calldata raw + ) external returns (bytes memory) { verifyCallback(raw); // Without selector and locker id @@ -89,15 +94,12 @@ contract EkuboExecutor is } } - function payCallback(uint256, address /*token*/ ) external coreOnly { + function payCallback(uint256, address /*token*/) external coreOnly { // Without selector and locker id _payCallback(msg.data[36:]); } - function _lock(bytes memory data) - internal - returns (uint128 swappedAmount) - { + function _lock(bytes memory data) internal returns (uint128 swappedAmount) { address target = address(core); // slither-disable-next-line assembly @@ -131,29 +133,56 @@ contract EkuboExecutor is address nextTokenIn = tokenIn; - uint256 hopsLength = (swapData.length - POOL_DATA_OFFSET) / HOP_BYTE_LEN; + uint256 hopsLength = (swapData.length - POOL_DATA_OFFSET) / + HOP_BYTE_LEN; uint256 offset = POOL_DATA_OFFSET; for (uint256 i = 0; i < hopsLength; i++) { - address nextTokenOut = - address(bytes20(LibBytes.loadCalldata(swapData, offset))); - Config poolConfig = - Config.wrap(LibBytes.loadCalldata(swapData, offset + 20)); - - (address token0, address token1, bool isToken1) = nextTokenIn - > nextTokenOut - ? (nextTokenOut, nextTokenIn, true) - : (nextTokenIn, nextTokenOut, false); - - // slither-disable-next-line calls-loop - (int128 delta0, int128 delta1) = core.swap_611415377( - EkuboPoolKey(token0, token1, poolConfig), - nextAmountIn, - isToken1, - isToken1 ? MAX_SQRT_RATIO : MIN_SQRT_RATIO, - 0 + address nextTokenOut = address( + bytes20(LibBytes.loadCalldata(swapData, offset)) ); + Config poolConfig = Config.wrap( + LibBytes.loadCalldata(swapData, offset + 20) + ); + + ( + address token0, + address token1, + bool isToken1, + SqrtRatio sqrtRatioLimit + ) = nextTokenIn > nextTokenOut + ? (nextTokenOut, nextTokenIn, true, MAX_SQRT_RATIO) + : (nextTokenIn, nextTokenOut, false, MIN_SQRT_RATIO); + + PoolKey memory pk = PoolKey(token0, token1, poolConfig); + + int128 delta0; + int128 delta1; + + if (poolConfig.extension() == mevResist) { + (delta0, delta1) = abi.decode( + _forward( + mevResist, + abi.encode( + pk, + nextAmountIn, + isToken1, + sqrtRatioLimit, + SKIP_AHEAD + ) + ), + (int128, int128) + ); + } else { + (delta0, delta1) = core.swap_611415377( + pk, + nextAmountIn, + isToken1, + sqrtRatioLimit, + SKIP_AHEAD + ); + } nextTokenIn = nextTokenOut; nextAmountIn = -(isToken1 ? delta0 : delta1); @@ -166,9 +195,49 @@ contract EkuboExecutor is return nextAmountIn; } - function _pay(address token, uint128 amount, TransferType transferType) - internal - { + function _forward( + address to, + bytes memory data + ) internal returns (bytes memory result) { + address target = address(core); + + assembly ("memory-safe") { + // We will store result where the free memory pointer is now, ... + result := mload(0x40) + + // But first use it to store the calldata + + // Selector of forward(address) + mstore(result, shl(224, 0x101e8952)) + mstore(add(result, 4), to) + + // We only copy the data, not the length, because the length is read from the calldata size + let len := mload(data) + mcopy(add(result, 36), add(data, 32), len) + + // If the call failed, pass through the revert + if iszero(call(gas(), target, 0, result, add(36, len), 0, 0)) { + returndatacopy(result, 0, returndatasize()) + revert(result, returndatasize()) + } + + // Copy the entire return data into the space where the result is pointing + mstore(result, returndatasize()) + returndatacopy(add(result, 32), 0, returndatasize()) + + // Update the free memory pointer to be after the end of the data, aligned to the next 32 byte word + mstore( + 0x40, + and(add(add(result, add(32, returndatasize())), 31), not(31)) + ) + } + } + + function _pay( + address token, + uint128 amount, + TransferType transferType + ) internal { address target = address(core); if (token == NATIVE_TOKEN_ADDRESS) { diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index 925fafa..da1291c 100644 --- a/foundry/test/TychoRouterTestSetup.sol +++ b/foundry/test/TychoRouterTestSetup.sol @@ -3,7 +3,7 @@ pragma solidity ^0.8.26; import "../src/executors/BalancerV2Executor.sol"; import "../src/executors/CurveExecutor.sol"; -import "../src/executors/EkuboExecutor.sol"; +import {EkuboExecutor} from "../src/executors/EkuboExecutor.sol"; import "../src/executors/UniswapV2Executor.sol"; import "../src/executors/UniswapV3Executor.sol"; import "../src/executors/UniswapV4Executor.sol"; @@ -116,6 +116,7 @@ contract TychoRouterTestSetup is Constants, Permit2TestHelper, TestUtils { bytes32 initCodePancakeV3 = PANCAKEV3_POOL_CODE_INIT_HASH; address poolManagerAddress = 0x000000000004444c5dc75cB358380D2e3dE08A90; address ekuboCore = 0xe0e0e08A6A4b9Dc7bD67BCB7aadE5cF48157d444; + address ekuboMevResist = 0x553a2EFc570c9e104942cEC6aC1c18118e54C091; IPoolManager poolManager = IPoolManager(poolManagerAddress); usv2Executor = @@ -127,7 +128,11 @@ contract TychoRouterTestSetup is Constants, Permit2TestHelper, TestUtils { factoryPancakeV3, initCodePancakeV3, PERMIT2_ADDRESS ); balancerv2Executor = new BalancerV2Executor(PERMIT2_ADDRESS); - ekuboExecutor = new EkuboExecutor(ekuboCore, PERMIT2_ADDRESS); + ekuboExecutor = new EkuboExecutor( + ekuboCore, + ekuboMevResist, + PERMIT2_ADDRESS + ); curveExecutor = new CurveExecutor(ETH_ADDR_FOR_CURVE, PERMIT2_ADDRESS); maverickv2Executor = new MaverickV2Executor(MAVERICK_V2_FACTORY, PERMIT2_ADDRESS); diff --git a/foundry/test/executors/EkuboExecutor.t.sol b/foundry/test/executors/EkuboExecutor.t.sol index 7fa3eb1..8168016 100644 --- a/foundry/test/executors/EkuboExecutor.t.sol +++ b/foundry/test/executors/EkuboExecutor.t.sol @@ -18,22 +18,29 @@ contract EkuboExecutorTest is Constants, TestUtils { IERC20 USDT = IERC20(USDT_ADDR); address constant CORE_ADDRESS = 0xe0e0e08A6A4b9Dc7bD67BCB7aadE5cF48157d444; + address constant MEV_RESIST_ADDRESS = + 0x553a2EFc570c9e104942cEC6aC1c18118e54C091; bytes32 constant ORACLE_CONFIG = 0x51d02a5948496a67827242eabc5725531342527c000000000000000000000000; - function setUp() public { - vm.createSelectFork(vm.rpcUrl("mainnet"), 22082754); + // 0.01% fee and 0.02% tick spacing + bytes32 constant MEV_RESIST_POOL_CONFIG = + 0x553a2EFc570c9e104942cEC6aC1c18118e54C09100068db8bac710cb000000c8; + + modifier setUpFork(uint256 blockNumber) { + vm.createSelectFork(vm.rpcUrl("mainnet"), blockNumber); deployCodeTo( "executors/EkuboExecutor.sol", - abi.encode(CORE_ADDRESS, PERMIT2_ADDRESS), + abi.encode(CORE_ADDRESS, MEV_RESIST_ADDRESS, PERMIT2_ADDRESS), EXECUTOR_ADDRESS ); executor = EkuboExecutor(payable(EXECUTOR_ADDRESS)); + _; } - function testSingleSwapEth() public { + function testSingleSwapEth() public setUpFork(22722989) { uint256 amountIn = 1 ether; deal(address(executor), amountIn); @@ -59,10 +66,14 @@ contract EkuboExecutorTest is Constants, TestUtils { console.log(amountOut); assertEq(CORE_ADDRESS.balance, ethBalanceBeforeCore + amountIn); - assertEq(address(executor).balance, ethBalanceBeforeExecutor - amountIn); + assertEq( + address(executor).balance, + ethBalanceBeforeExecutor - amountIn + ); assertEq( - USDC.balanceOf(CORE_ADDRESS), usdcBalanceBeforeCore - amountOut + USDC.balanceOf(CORE_ADDRESS), + usdcBalanceBeforeCore - amountOut ); assertEq( USDC.balanceOf(address(executor)), @@ -70,7 +81,7 @@ contract EkuboExecutorTest is Constants, TestUtils { ); } - function testSingleSwapERC20() public { + function testSingleSwapERC20() public setUpFork(22722989) { uint256 amountIn = 1_000_000_000; deal(USDC_ADDR, address(executor), amountIn); @@ -95,7 +106,10 @@ contract EkuboExecutorTest is Constants, TestUtils { console.log(amountOut); - assertEq(USDC.balanceOf(CORE_ADDRESS), usdcBalanceBeforeCore + amountIn); + assertEq( + USDC.balanceOf(CORE_ADDRESS), + usdcBalanceBeforeCore + amountIn + ); assertEq( USDC.balanceOf(address(executor)), usdcBalanceBeforeExecutor - amountIn @@ -103,7 +117,49 @@ contract EkuboExecutorTest is Constants, TestUtils { assertEq(CORE_ADDRESS.balance, ethBalanceBeforeCore - amountOut); assertEq( - address(executor).balance, ethBalanceBeforeExecutor + amountOut + address(executor).balance, + ethBalanceBeforeExecutor + amountOut + ); + } + + function testMevResist() public setUpFork(22722989) { + uint256 amountIn = 1_000_000_000; + + deal(USDC_ADDR, address(executor), amountIn); + + uint256 usdcBalanceBeforeCore = USDC.balanceOf(CORE_ADDRESS); + uint256 usdcBalanceBeforeExecutor = USDC.balanceOf(address(executor)); + + uint256 ethBalanceBeforeCore = CORE_ADDRESS.balance; + uint256 ethBalanceBeforeExecutor = address(executor).balance; + + bytes memory data = abi.encodePacked( + uint8(RestrictTransferFrom.TransferType.Transfer), // transferNeeded (transfer from executor to core) + address(executor), // receiver + USDC_ADDR, // tokenIn + NATIVE_TOKEN_ADDRESS, // tokenOut + MEV_RESIST_POOL_CONFIG // config + ); + + uint256 gasBefore = gasleft(); + uint256 amountOut = executor.swap(amountIn, data); + console.log(gasBefore - gasleft()); + + console.log(amountOut); + + assertEq( + USDC.balanceOf(CORE_ADDRESS), + usdcBalanceBeforeCore + amountIn + ); + assertEq( + USDC.balanceOf(address(executor)), + usdcBalanceBeforeExecutor - amountIn + ); + + assertEq(CORE_ADDRESS.balance, ethBalanceBeforeCore - amountOut); + assertEq( + address(executor).balance, + ethBalanceBeforeExecutor + amountOut ); } @@ -126,10 +182,14 @@ contract EkuboExecutorTest is Constants, TestUtils { console.log(amountOut); assertEq(CORE_ADDRESS.balance, ethBalanceBeforeCore + amountIn); - assertEq(address(executor).balance, ethBalanceBeforeExecutor - amountIn); + assertEq( + address(executor).balance, + ethBalanceBeforeExecutor - amountIn + ); assertEq( - USDT.balanceOf(CORE_ADDRESS), usdtBalanceBeforeCore - amountOut + USDT.balanceOf(CORE_ADDRESS), + usdtBalanceBeforeCore - amountOut ); assertEq( USDT.balanceOf(address(executor)), @@ -138,7 +198,7 @@ contract EkuboExecutorTest is Constants, TestUtils { } // Same test case as in swap_encoder::tests::ekubo::test_encode_swap_multi - function testMultiHopSwap() public { + function testMultiHopSwap() public setUpFork(22082754) { bytes memory data = abi.encodePacked( uint8(RestrictTransferFrom.TransferType.Transfer), // transferNeeded (transfer from executor to core) address(executor), // receiver @@ -154,7 +214,7 @@ contract EkuboExecutorTest is Constants, TestUtils { } // Data is generated by test case in swap_encoder::tests::ekubo::test_encode_swap_multi - function testMultiHopSwapIntegration() public { + function testMultiHopSwapIntegration() public setUpFork(22082754) { multiHopSwap(loadCallDataFromFile("test_ekubo_encode_swap_multi")); } }