diff --git a/CHANGELOG.md b/CHANGELOG.md index 4004d25..aa89aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## [0.102.0](https://github.com/propeller-heads/tycho-execution/compare/0.101.5...0.102.0) (2025-07-01) + + +### Features + +* Deploy EkuboExecutor with MEV-resist ([3fe9906](https://github.com/propeller-heads/tycho-execution/commit/3fe9906e5babb4a31af45c1d48fda3a096802fe8)) + +## [0.101.5](https://github.com/propeller-heads/tycho-execution/compare/0.101.4...0.101.5) (2025-06-27) + + +### Bug Fixes + +* Use native token curve address and not regular zero address ([0f679d6](https://github.com/propeller-heads/tycho-execution/commit/0f679d6e0663aba881babb09319815723cdf68e5)) + +## [0.101.4](https://github.com/propeller-heads/tycho-execution/compare/0.101.3...0.101.4) (2025-06-27) + + +### Bug Fixes + +* Fix wrong log in CurveEncoder ([312269d](https://github.com/propeller-heads/tycho-execution/commit/312269dabba5b0d8f303f9aba47d9456e5cd5db7)) + ## [0.101.3](https://github.com/propeller-heads/tycho-execution/compare/0.101.2...0.101.3) (2025-06-23) diff --git a/Cargo.lock b/Cargo.lock index 4d53c90..4e65e5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4658,7 +4658,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.101.3" +version = "0.102.0" dependencies = [ "alloy", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 13b398d..d304d85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycho-execution" -version = "0.101.3" +version = "0.102.0" edition = "2021" description = "Provides tools for encoding and executing swaps against Tycho router and protocol executors." repository = "https://github.com/propeller-heads/tycho-execution" diff --git a/config/executor_addresses.json b/config/executor_addresses.json index 5881fdc..72f95f7 100644 --- a/config/executor_addresses.json +++ b/config/executor_addresses.json @@ -7,7 +7,7 @@ "pancakeswap_v3": "0x9D32e9F569B22Ae8d8C6f788037C1CD53632A059", "uniswap_v4": "0xD11496EAb53A9521f0bC1e5c1098Ecb467103Ad9", "vm:balancer_v2": "0xB5b8dc3F0a1Be99685a0DEd015Af93bFBB55C411", - "ekubo_v2": "0xcCF8e1E39e9ddfa88282fA6a7B31eBFB41a1ED7B", + "ekubo_v2": "0x263DD7AD20983b5E0392bf1F09C4493500EDb333", "vm:curve": "0x879F3008D96EBea0fc584aD684c7Df31777F3165", "vm:maverick_v2": "0xF35e3F5F205769B41508A18787b62A21bC80200B", "vm:balancer_v3": "0xec5cE4bF6FbcB7bB0148652c92a4AEC8c1d474Ec" 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..a3f2bc3 100644 --- a/foundry/lib/ekubo/types/poolKey.sol +++ b/foundry/lib/ekubo/types/poolKey.sol @@ -1,12 +1,21 @@ // 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) { + // slither-disable-next-line assembly + assembly ("memory-safe") { + e := shr(96, config) + } +} diff --git a/foundry/scripts/deploy-executors.js b/foundry/scripts/deploy-executors.js index ca4adb1..e50a4fe 100644 --- a/foundry/scripts/deploy-executors.js +++ b/foundry/scripts/deploy-executors.js @@ -57,10 +57,11 @@ const executors_to_deploy = { }, // Args: Permit2 {exchange: "BalancerV2Executor", args: ["0x000000000022D473030F116dDEE9F6B43aC78BA3"]}, - // Args: Ekubo core contract, Permit2 + // Args: Ekubo core contract, mev resist, Permit2 { exchange: "EkuboExecutor", args: [ "0xe0e0e08A6A4b9Dc7bD67BCB7aadE5cF48157d444", + "0x553a2EFc570c9e104942cEC6aC1c18118e54C091", "0x000000000022D473030F116dDEE9F6B43aC78BA3" ] }, diff --git a/foundry/src/executors/EkuboExecutor.sol b/foundry/src/executors/EkuboExecutor.sol index 63a57b3..2718841 100644 --- a/foundry/src/executors/EkuboExecutor.sol +++ b/foundry/src/executors/EkuboExecutor.sol @@ -9,8 +9,12 @@ 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"; @@ -21,11 +25,13 @@ contract EkuboExecutor is ICallback, RestrictTransferFrom { + error EkuboExecutor__AddressZero(); error EkuboExecutor__InvalidDataLength(); error EkuboExecutor__CoreOnly(); error EkuboExecutor__UnknownCallback(); ICore immutable core; + address immutable mevResist; uint256 constant POOL_DATA_OFFSET = 57; uint256 constant HOP_BYTE_LEN = 52; @@ -33,12 +39,19 @@ 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) + constructor(address _core, address _mevResist, address _permit2) RestrictTransferFrom(_permit2) { core = ICore(_core); + + if (_mevResist == address(0)) { + revert EkuboExecutor__AddressZero(); + } + mevResist = _mevResist; } function swap(uint256 amountIn, bytes calldata data) @@ -141,19 +154,40 @@ contract EkuboExecutor is Config poolConfig = Config.wrap(LibBytes.loadCalldata(swapData, offset + 20)); - (address token0, address token1, bool isToken1) = nextTokenIn - > nextTokenOut - ? (nextTokenOut, nextTokenIn, true) - : (nextTokenIn, nextTokenOut, false); + ( + address token0, + address token1, + bool isToken1, + SqrtRatio sqrtRatioLimit + ) = nextTokenIn > nextTokenOut + ? (nextTokenOut, nextTokenIn, true, MAX_SQRT_RATIO) + : (nextTokenIn, nextTokenOut, false, MIN_SQRT_RATIO); - // 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 - ); + 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 { + // slither-disable-next-line calls-loop + (delta0, delta1) = core.swap_611415377( + pk, nextAmountIn, isToken1, sqrtRatioLimit, SKIP_AHEAD + ); + } nextTokenIn = nextTokenOut; nextAmountIn = -(isToken1 ? delta0 : delta1); @@ -166,6 +200,45 @@ contract EkuboExecutor is return nextAmountIn; } + function _forward(address to, bytes memory data) + internal + returns (bytes memory result) + { + address target = address(core); + + // slither-disable-next-line assembly + 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 { diff --git a/foundry/test/TychoRouterTestSetup.sol b/foundry/test/TychoRouterTestSetup.sol index 951148a..6f1193d 100644 --- a/foundry/test/TychoRouterTestSetup.sol +++ b/foundry/test/TychoRouterTestSetup.sol @@ -114,6 +114,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 = @@ -125,7 +126,8 @@ 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/protocols/Ekubo.t.sol b/foundry/test/protocols/Ekubo.t.sol index 8832af4..f1e5da9 100644 --- a/foundry/test/protocols/Ekubo.t.sol +++ b/foundry/test/protocols/Ekubo.t.sol @@ -19,22 +19,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); @@ -71,7 +78,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); @@ -108,6 +115,43 @@ contract EkuboExecutorTest is Constants, TestUtils { ); } + 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 + ); + } + // Expects input that encodes the same test case as swap_encoder::tests::ekubo::test_encode_swap_multi function multiHopSwap(bytes memory data) internal { uint256 amountIn = 1 ether; @@ -139,7 +183,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 @@ -155,7 +199,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")); } } diff --git a/src/encoding/evm/swap_encoder/swap_encoders.rs b/src/encoding/evm/swap_encoder/swap_encoders.rs index cbf33e9..c7e30e6 100644 --- a/src/encoding/evm/swap_encoder/swap_encoders.rs +++ b/src/encoding/evm/swap_encoder/swap_encoders.rs @@ -407,7 +407,10 @@ impl CurveSwapEncoder { // Some curve pools support both ETH and WETH as tokens. // They do the wrapping/unwrapping inside the pool fn normalize_token(&self, token: Address, coins: &[Address]) -> Result { - let native_token_address = bytes_to_address(&self.native_token_address)?; + let native_token_address = + Address::from_str(&self.native_token_curve_address).map_err(|_| { + EncodingError::FatalError("Invalid native token curve address".to_string()) + })?; let wrapped_native_token_address = bytes_to_address(&self.wrapped_native_token_address)?; if token == native_token_address && !coins.contains(&token) { Ok(wrapped_native_token_address) @@ -440,7 +443,7 @@ impl CurveSwapEncoder { .iter() .position(|&addr| addr == token_out) .ok_or(EncodingError::FatalError(format!( - "Token in address {token_in} not found in curve pool coins" + "Token in address {token_out} not found in curve pool coins" )))?; Ok((U8::from(i), U8::from(j))) }