diff --git a/.github/workflows/evm-foundry-ci.yml b/.github/workflows/evm-foundry-ci.yml index ee56fa0..8c4a98e 100644 --- a/.github/workflows/evm-foundry-ci.yml +++ b/.github/workflows/evm-foundry-ci.yml @@ -4,7 +4,7 @@ on: push: branches: - main - pull_request: + pull_request_target: jobs: check: @@ -13,12 +13,12 @@ jobs: env: RPC_URL: ${{ secrets.ETH_RPC_URL }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: submodules: recursive - name: Install Foundry - uses: foundry-rs/foundry-toolchain@v1 + uses: foundry-rs/foundry-toolchain@de808b1eea699e761c404bda44ba8f21aba30b2c - name: Check formatting run: forge fmt --check diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..9e0ce55 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,30 @@ +name: Release + +on: + workflow_dispatch: + inputs: { } + release: + types: [ created, prereleased ] + +permissions: + id-token: write + contents: write + +jobs: + publish-crate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + - name: Push to crates.io + run: | + cargo publish --locked --verbose --token ${{ secrets.CRATESIO_REGISTRY_TOKEN }} +# we can't use the action because it errors on github dependencies in any workspace crate +# - uses: katyo/publish-crates@v2 +# with: +# path: "./tycho-common" +# registry-token: ${{ secrets.CRATESIO_REGISTRY_TOKEN }} + diff --git a/.github/workflows/slither.yml b/.github/workflows/slither.yml index 4134b29..dc18ce8 100644 --- a/.github/workflows/slither.yml +++ b/.github/workflows/slither.yml @@ -10,8 +10,8 @@ jobs: analyze: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: crytic/slither-action@v0.4.0 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: crytic/slither-action@f197989dea5b53e986d0f88c60a034ddd77ec9a8 with: target: 'foundry/' - slither-args: '--filter-paths foundry/lib/' \ No newline at end of file + slither-args: '--filter-paths foundry/lib/' diff --git a/.github/workflows/tests-and-lints-template.yaml b/.github/workflows/tests-and-lints-template.yaml index 12cd28a..2c9ffcc 100644 --- a/.github/workflows/tests-and-lints-template.yaml +++ b/.github/workflows/tests-and-lints-template.yaml @@ -92,7 +92,7 @@ jobs: - name: Checkout uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: - ref: ${{ github.head_ref }} + ref: ${{ github.event.pull_request.head.sha }} - name: Setup git to use https run: | diff --git a/.gitmodules b/.gitmodules index eacb977..d9919b8 100644 --- a/.gitmodules +++ b/.gitmodules @@ -19,3 +19,6 @@ [submodule "foundry/lib/v4-periphery"] path = foundry/lib/v4-periphery url = https://github.com/Uniswap/v4-periphery +[submodule "foundry/lib/solady"] + path = foundry/lib/solady + url = https://github.com/vectorized/solady diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0bd10..0f38b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,48 @@ +## [0.68.2](https://github.com/propeller-heads/tycho-execution/compare/0.68.1...0.68.2) (2025-03-28) + + +### Bug Fixes + +* fix for foundry tests external contributors ([a9ddb0e](https://github.com/propeller-heads/tycho-execution/commit/a9ddb0e6e9ef546f6e851c3056df5a55ee4dfa76)) + +## [0.68.1](https://github.com/propeller-heads/tycho-execution/compare/0.68.0...0.68.1) (2025-03-27) + + +### Bug Fixes + +* Add crate metadata ([7e7fabf](https://github.com/propeller-heads/tycho-execution/commit/7e7fabf51bff842ab20c2f512e4f3a609a266e79)) + +## [0.68.0](https://github.com/propeller-heads/tycho-execution/compare/0.67.2...0.68.0) (2025-03-27) + + +### Features + +* switch to tycho_commons ([0836bf7](https://github.com/propeller-heads/tycho-execution/commit/0836bf7d530f18a6c0f112542bcad16050e88afa)) + + +### Bug Fixes + +* Handle unichain chain id ([379858b](https://github.com/propeller-heads/tycho-execution/commit/379858bfca27eb5e8180a32351337779e625e0b5)) + +## [0.67.2](https://github.com/propeller-heads/tycho-execution/compare/0.67.1...0.67.2) (2025-03-27) + + +### Bug Fixes + +* prepared lint workflow for external contributors ([9896f48](https://github.com/propeller-heads/tycho-execution/commit/9896f4882940517d61852300420c7c580138406f)) +* prepared lint workflow for external contributors ([5162b9e](https://github.com/propeller-heads/tycho-execution/commit/5162b9e19efcaa5a2137f71a94f6f9e7f7d14da0)) + +## [0.67.1](https://github.com/propeller-heads/tycho-execution/compare/0.67.0...0.67.1) (2025-03-27) + + +### Bug Fixes + +* added empty line ([b3c4dbc](https://github.com/propeller-heads/tycho-execution/commit/b3c4dbc293df758ff4cff949298a819436d83c38)) +* fixed git checkout for codelint ([58e2ddd](https://github.com/propeller-heads/tycho-execution/commit/58e2ddd50e131c484ad53a6dca0b09e1d221d0e5)) +* prepared lint workflow for external contributors ([9f7d605](https://github.com/propeller-heads/tycho-execution/commit/9f7d605ea5e76d230b5946c618ece76365fb4f02)) +* removed empty line ([ae5d7de](https://github.com/propeller-heads/tycho-execution/commit/ae5d7deaccfc1ac527f88371bb3f055b01689801)) +* test run outside a PR ([af01972](https://github.com/propeller-heads/tycho-execution/commit/af0197205adb3220673022b690f1d8aa6f6734aa)) + ## [0.67.0](https://github.com/propeller-heads/tycho-execution/compare/0.66.1...0.67.0) (2025-03-20) diff --git a/Cargo.lock b/Cargo.lock index bdbcf8f..54ec35b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4316,9 +4316,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "tycho-core" -version = "0.61.1" -source = "git+https://github.com/propeller-heads/tycho-indexer.git?tag=0.61.1#aae2c11bffe9ae5e436adc019b769438fa038272" +name = "tycho-common" +version = "0.63.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c64749b24bdb5de6050c37ea91420a67a5fe606247fdb29f2e812dac30b42e" dependencies = [ "anyhow", "async-trait", @@ -4340,7 +4341,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.67.0" +version = "0.68.2" dependencies = [ "alloy", "alloy-primitives", @@ -4358,7 +4359,7 @@ dependencies = [ "serde_json", "thiserror 1.0.69", "tokio", - "tycho-core", + "tycho-common", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b3cd474..00c7abd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,15 @@ [package] name = "tycho-execution" -version = "0.67.0" +version = "0.68.2" edition = "2021" +description = "Provides tools for encoding and executing swaps against Tycho router and protocol executors." +repository = "https://github.com/propeller-heads/tycho-execution" +homepage = "https://www.propellerheads.xyz/tycho" +documentation = "https://docs.propellerheads.xyz/tycho" +keywords = ["propellerheads", "solver", "defi", "dex", "mev"] +license = "MIT" +categories = ["finance", "cryptography::cryptocurrencies"] +readme = "README.md" [[bin]] name = "tycho-encode" @@ -24,7 +32,7 @@ clap = { version = "4.5.3", features = ["derive"] } alloy = { version = "0.9.2", features = ["providers", "rpc-types-eth", "eip712", "signer-local"], optional = true } alloy-sol-types = { version = "0.8.14", optional = true } alloy-primitives = { version = "0.8.9", optional = true } -tycho-core = { git = "https://github.com/propeller-heads/tycho-indexer.git", package = "tycho-core", tag = "0.61.1" } +tycho-common = "0.63.1" once_cell = "1.20.2" [dev-dependencies] diff --git a/examples/encoding-example/main.rs b/examples/encoding-example/main.rs index 48ee12b..70b46d9 100644 --- a/examples/encoding-example/main.rs +++ b/examples/encoding-example/main.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use num_bigint::BigUint; -use tycho_core::{ +use tycho_common::{ models::{protocol::ProtocolComponent, Chain}, Bytes, }; diff --git a/foundry/lib/ekubo/interfaces/ICore.sol b/foundry/lib/ekubo/interfaces/ICore.sol new file mode 100644 index 0000000..2bc6b3d --- /dev/null +++ b/foundry/lib/ekubo/interfaces/ICore.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import {IFlashAccountant} from "./IFlashAccountant.sol"; +import {PoolKey} from "../types/poolKey.sol"; +import {SqrtRatio} from "../types/sqrtRatio.sol"; + +interface ICore is IFlashAccountant { + function swap_611415377( + 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 new file mode 100644 index 0000000..2524078 --- /dev/null +++ b/foundry/lib/ekubo/interfaces/IFlashAccountant.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +interface ILocker { + function locked(uint256 id) external; +} + +interface IPayer { + function payCallback(uint256 id, address token) external; +} + +interface IFlashAccountant { + // 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; +} diff --git a/foundry/lib/ekubo/math/constants.sol b/foundry/lib/ekubo/math/constants.sol new file mode 100644 index 0000000..cd08e3b --- /dev/null +++ b/foundry/lib/ekubo/math/constants.sol @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +// We use this address to represent the native token within the protocol +address constant NATIVE_TOKEN_ADDRESS = address(0); diff --git a/foundry/lib/ekubo/types/poolKey.sol b/foundry/lib/ekubo/types/poolKey.sol new file mode 100644 index 0000000..b0301e2 --- /dev/null +++ b/foundry/lib/ekubo/types/poolKey.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +// address (20 bytes) | fee (8 bytes) | tickSpacing (4 bytes) +type Config is bytes32; + +// Each pool has its own state associated with this key +struct PoolKey { + address token0; + address token1; + Config config; +} diff --git a/foundry/lib/ekubo/types/sqrtRatio.sol b/foundry/lib/ekubo/types/sqrtRatio.sol new file mode 100644 index 0000000..27e5856 --- /dev/null +++ b/foundry/lib/ekubo/types/sqrtRatio.sol @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +type SqrtRatio is uint96; + +uint96 constant MIN_SQRT_RATIO_RAW = 4611797791050542631; +SqrtRatio constant MIN_SQRT_RATIO = SqrtRatio.wrap(MIN_SQRT_RATIO_RAW); +uint96 constant MAX_SQRT_RATIO_RAW = 79227682466138141934206691491; +SqrtRatio constant MAX_SQRT_RATIO = SqrtRatio.wrap(MAX_SQRT_RATIO_RAW); diff --git a/foundry/lib/solady b/foundry/lib/solady new file mode 160000 index 0000000..c9e079c --- /dev/null +++ b/foundry/lib/solady @@ -0,0 +1 @@ +Subproject commit c9e079c0ca836dcc52777a1fa7227ef28e3537b3 diff --git a/foundry/remappings.txt b/foundry/remappings.txt index 04240ad..d10f28f 100644 --- a/foundry/remappings.txt +++ b/foundry/remappings.txt @@ -7,4 +7,6 @@ @uniswap/v3-updated/=lib/v3-updated/ @uniswap/v3-core/=lib/v3-core/ @uniswap/v4-core/=lib/v4-core/ -@uniswap/v4-periphery/=lib/v4-periphery/ \ No newline at end of file +@uniswap/v4-periphery/=lib/v4-periphery/ +@solady=lib/solady/src/ +@ekubo=lib/ekubo/ diff --git a/foundry/src/executors/EkuboExecutor.sol b/foundry/src/executors/EkuboExecutor.sol new file mode 100644 index 0000000..4f291fe --- /dev/null +++ b/foundry/src/executors/EkuboExecutor.sol @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IExecutor} from "@interfaces/IExecutor.sol"; +import {ICallback} from "@interfaces/ICallback.sol"; +import {ICore} from "@ekubo/interfaces/ICore.sol"; +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, PoolKey} from "@ekubo/types/poolKey.sol"; +import {MAX_SQRT_RATIO, MIN_SQRT_RATIO} from "@ekubo/types/sqrtRatio.sol"; + +contract EkuboExecutor is IExecutor, ICallback, ILocker, IPayer { + error EkuboExecutor__InvalidDataLength(); + error EkuboExecutor__CoreOnly(); + error EkuboExecutor__UnknownCallback(); + + ICore immutable core; + + bytes4 constant LOCKED_SELECTOR = 0xb45a3c0e; // locked(uint256) + bytes4 constant PAY_CALLBACK_SELECTOR = 0x599d0714; // payCallback(uint256,address) + + uint256 constant POOL_DATA_OFFSET = 56; + uint256 constant HOP_BYTE_LEN = 52; + + constructor(ICore _core) { + core = _core; + } + + function swap(uint256 amountIn, bytes calldata data) + external + payable + returns (uint256 calculatedAmount) + { + if (data.length < 92) revert EkuboExecutor__InvalidDataLength(); + + uint256 tokenOutOffset = data.length - HOP_BYTE_LEN; + address tokenOut = + address(bytes20(LibBytes.loadCalldata(data, tokenOutOffset))); + + uint256 tokenOutBalanceBefore = _balanceOf(tokenOut); + + // amountIn must be at most type(int128).MAX + _lock(bytes.concat(bytes16(uint128(amountIn)), data)); + + uint256 tokenOutBalanceAfter = _balanceOf(tokenOut); + + // It would be better if we could somehow pass back the swapped amount from the lock but the interface doesn't offer that capability. + // Note that the current approach also prevents arbs that return less than their input because of arithmetic underflow. + calculatedAmount = tokenOutBalanceAfter - tokenOutBalanceBefore; + } + + // We can't use the return value here since it won't get propagated (see Dispatcher.sol:_handleCallback) + function handleCallback(bytes calldata raw) + external + returns (bytes memory) + { + verifyCallback(raw); + + // Without selector and locker id + bytes calldata stripped = raw[36:]; + + bytes4 selector = bytes4(raw[:4]); + + if (selector == LOCKED_SELECTOR) { + _locked(stripped); + } else if (selector == PAY_CALLBACK_SELECTOR) { + _payCallback(stripped); + } else { + revert EkuboExecutor__UnknownCallback(); + } + + return ""; + } + + function verifyCallback(bytes calldata) public view coreOnly {} + + function locked(uint256) external coreOnly { + // Without selector and locker id + _locked(msg.data[36:]); + } + + function payCallback(uint256, address /*token*/ ) external coreOnly { + // Without selector and locker id + _payCallback(msg.data[36:]); + } + + function _balanceOf(address token) + internal + view + returns (uint256 balance) + { + balance = token == NATIVE_TOKEN_ADDRESS + ? address(this).balance + : IERC20(token).balanceOf(address(this)); + } + + function _lock(bytes memory data) internal { + address target = address(core); + + // slither-disable-next-line assembly + assembly ("memory-safe") { + let args := mload(0x40) + + // Selector of lock() + mstore(args, shl(224, 0xf83d08ba)) + + // We only copy the data, not the length, because the length is read from the calldata size + let len := mload(data) + mcopy(add(args, 4), add(data, 32), len) + + // If the call failed, pass through the revert + if iszero(call(gas(), target, 0, args, add(len, 36), 0, 0)) { + returndatacopy(0, 0, returndatasize()) + revert(0, returndatasize()) + } + } + } + + function _locked(bytes calldata swapData) internal { + // For partial swaps this is not equivalent to the given input amount + uint128 tokenInDebtAmount = 0; + + int128 nextAmountIn = int128(uint128(bytes16(swapData[0:16]))); + + address receiver = address(bytes20(swapData[16:36])); + address tokenIn = address(bytes20(swapData[36:POOL_DATA_OFFSET])); + + address nextTokenIn = tokenIn; + + 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); + + (int128 delta0, int128 delta1) = core.swap_611415377( + PoolKey(token0, token1, poolConfig), + nextAmountIn, + isToken1, + isToken1 ? MAX_SQRT_RATIO : MIN_SQRT_RATIO, + 0 + ); + + if (tokenInDebtAmount == 0) { + tokenInDebtAmount = uint128(isToken1 ? delta1 : delta0); + } + + nextTokenIn = nextTokenOut; + nextAmountIn = -(isToken1 ? delta0 : delta1); + + offset += HOP_BYTE_LEN; + } + + _pay(tokenIn, tokenInDebtAmount); + + core.withdraw(nextTokenIn, receiver, uint128(nextAmountIn)); + } + + function _pay(address token, uint128 amount) internal { + address target = address(core); + + if (token == NATIVE_TOKEN_ADDRESS) { + SafeTransferLib.safeTransferETH(target, amount); + } else { + // slither-disable-next-line assembly + assembly ("memory-safe") { + let free := mload(0x40) + // selector of pay(address) + mstore(free, shl(224, 0x0c11dedd)) + mstore(add(free, 4), token) + mstore(add(free, 36), shl(128, amount)) + + // if it failed, pass through revert + if iszero(call(gas(), target, 0, free, 52, 0, 0)) { + returndatacopy(0, 0, returndatasize()) + revert(0, returndatasize()) + } + } + } + } + + function _payCallback(bytes calldata payData) internal { + address token = address(bytes20(payData[12:32])); // This arg is abi-encoded + uint128 amount = uint128(bytes16(payData[32:48])); + + SafeTransferLib.safeTransfer(token, address(core), amount); + } + + // To receive withdrawals from Core + receive() external payable {} + + modifier coreOnly() { + if (msg.sender != address(core)) revert EkuboExecutor__CoreOnly(); + _; + } +} diff --git a/foundry/test/executors/EkuboExecutor.t.sol b/foundry/test/executors/EkuboExecutor.t.sol new file mode 100644 index 0000000..0971b40 --- /dev/null +++ b/foundry/test/executors/EkuboExecutor.t.sol @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity ^0.8.26; + +import {EkuboExecutor} from "@src/executors/EkuboExecutor.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {Constants} from "../Constants.sol"; +import {Test, console} from "forge-std/Test.sol"; +import {NATIVE_TOKEN_ADDRESS} from "@ekubo/math/constants.sol"; +import {ICore} from "@ekubo/interfaces/ICore.sol"; + +contract EkuboExecutorTest is Test, Constants { + address constant EXECUTOR_ADDRESS = + 0xcA4F73Fe97D0B987a0D12B39BBD562c779BAb6f6; // Same address as in swap_encoder.rs tests + EkuboExecutor executor; + + IERC20 USDC = IERC20(USDC_ADDR); + IERC20 USDT = IERC20(USDT_ADDR); + + address constant CORE_ADDRESS = 0xe0e0e08A6A4b9Dc7bD67BCB7aadE5cF48157d444; + + bytes32 constant ORACLE_CONFIG = + 0x51d02a5948496a67827242eabc5725531342527c000000000000000000000000; + + function setUp() public { + vm.createSelectFork(vm.rpcUrl("mainnet"), 22082754); + + deployCodeTo( + "executors/EkuboExecutor.sol", + abi.encode(CORE_ADDRESS), + EXECUTOR_ADDRESS + ); + executor = EkuboExecutor(payable(EXECUTOR_ADDRESS)); + } + + function testSingleSwapEth() public { + uint256 amountIn = 1 ether; + + deal(address(executor), amountIn); + + uint256 ethBalanceBeforeCore = CORE_ADDRESS.balance; + uint256 ethBalanceBeforeExecutor = address(executor).balance; + + uint256 usdcBalanceBeforeCore = USDC.balanceOf(CORE_ADDRESS); + uint256 usdcBalanceBeforeExecutor = USDC.balanceOf(address(executor)); + + bytes memory data = abi.encodePacked( + address(executor), // receiver + NATIVE_TOKEN_ADDRESS, // tokenIn + USDC_ADDR, // tokenOut + ORACLE_CONFIG // poolConfig + ); + + uint256 gasBefore = gasleft(); + uint256 amountOut = executor.swap(amountIn, data); + console.log(gasBefore - gasleft()); + + console.log(amountOut); + + assertEq(CORE_ADDRESS.balance, ethBalanceBeforeCore + amountIn); + assertEq(address(executor).balance, ethBalanceBeforeExecutor - amountIn); + + assertEq( + USDC.balanceOf(CORE_ADDRESS), usdcBalanceBeforeCore - amountOut + ); + assertEq( + USDC.balanceOf(address(executor)), + usdcBalanceBeforeExecutor + amountOut + ); + } + + function testSingleSwapERC20() public { + 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( + address(executor), // receiver + USDC_ADDR, // tokenIn + NATIVE_TOKEN_ADDRESS, // tokenOut + ORACLE_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; + + deal(address(executor), amountIn); + + uint256 ethBalanceBeforeCore = CORE_ADDRESS.balance; + uint256 ethBalanceBeforeExecutor = address(executor).balance; + + uint256 usdtBalanceBeforeCore = USDT.balanceOf(CORE_ADDRESS); + uint256 usdtBalanceBeforeExecutor = USDT.balanceOf(address(executor)); + + uint256 gasBefore = gasleft(); + uint256 amountOut = executor.swap(amountIn, data); + console.log(gasBefore - gasleft()); + + console.log(amountOut); + + assertEq(CORE_ADDRESS.balance, ethBalanceBeforeCore + amountIn); + assertEq(address(executor).balance, ethBalanceBeforeExecutor - amountIn); + + assertEq( + USDT.balanceOf(CORE_ADDRESS), usdtBalanceBeforeCore - amountOut + ); + assertEq( + USDT.balanceOf(address(executor)), + usdtBalanceBeforeExecutor + amountOut + ); + } + + // Same test case as in swap_encoder::tests::ekubo::test_encode_swap_multi + function testMultiHopSwap() public { + bytes memory data = abi.encodePacked( + address(executor), // receiver + NATIVE_TOKEN_ADDRESS, // tokenIn + USDC_ADDR, // tokenOut of 1st swap + ORACLE_CONFIG, // config of 1st swap + USDT_ADDR, // tokenOut of 2nd swap + bytes32( + 0x00000000000000000000000000000000000000000001a36e2eb1c43200000032 + ) // config of 2nd swap (0.0025% fee & 0.005% base pool) + ); + multiHopSwap(data); + } + + // Data is generated by test case in swap_encoder::tests::ekubo::test_encode_swap_multi + function testMultiHopSwapIntegration() public { + multiHopSwap( + hex"ca4f73fe97d0b987a0d12b39bbd562c779bab6f60000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4851d02a5948496a67827242eabc5725531342527c000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000001a36e2eb1c43200000032" + ); + } +} diff --git a/src/bin/tycho-encode.rs b/src/bin/tycho-encode.rs index 06cbe7b..08d157c 100644 --- a/src/bin/tycho-encode.rs +++ b/src/bin/tycho-encode.rs @@ -1,7 +1,7 @@ use std::io::{self, Read}; use clap::{Parser, Subcommand}; -use tycho_core::models::Chain; +use tycho_common::models::Chain; use tycho_execution::encoding::{ evm::encoder_builder::EVMEncoderBuilder, models::Solution, tycho_encoder::TychoEncoder, }; diff --git a/src/encoding/evm/approvals/permit2.rs b/src/encoding/evm/approvals/permit2.rs index f1a5153..549197e 100644 --- a/src/encoding/evm/approvals/permit2.rs +++ b/src/encoding/evm/approvals/permit2.rs @@ -15,7 +15,7 @@ use tokio::{ runtime::{Handle, Runtime}, task::block_in_place, }; -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{ errors::EncodingError, @@ -175,7 +175,7 @@ mod tests { use alloy_primitives::Uint; use num_bigint::BigUint; - use tycho_core::models::Chain as TychoCoreChain; + use tycho_common::models::Chain as TychoCoreChain; use super::*; diff --git a/src/encoding/evm/constants.rs b/src/encoding/evm/constants.rs index 8f93215..d742100 100644 --- a/src/encoding/evm/constants.rs +++ b/src/encoding/evm/constants.rs @@ -11,5 +11,6 @@ pub static GROUPABLE_PROTOCOLS: LazyLock> = LazyLock::new( let mut set = HashSet::new(); set.insert("uniswap_v4"); set.insert("balancer_v3"); + set.insert("ekubo"); set }); diff --git a/src/encoding/evm/encoder_builder.rs b/src/encoding/evm/encoder_builder.rs index 11a5f2e..7a13804 100644 --- a/src/encoding/evm/encoder_builder.rs +++ b/src/encoding/evm/encoder_builder.rs @@ -1,4 +1,4 @@ -use tycho_core::models::Chain; +use tycho_common::models::Chain; use crate::encoding::{ errors::EncodingError, diff --git a/src/encoding/evm/strategy_encoder/group_swaps.rs b/src/encoding/evm/strategy_encoder/group_swaps.rs index 48cb86b..0e4206c 100644 --- a/src/encoding/evm/strategy_encoder/group_swaps.rs +++ b/src/encoding/evm/strategy_encoder/group_swaps.rs @@ -1,4 +1,4 @@ -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{evm::constants::GROUPABLE_PROTOCOLS, models::Swap}; @@ -74,7 +74,7 @@ mod tests { use std::str::FromStr; use alloy_primitives::hex; - use tycho_core::{models::protocol::ProtocolComponent, Bytes}; + use tycho_common::{models::protocol::ProtocolComponent, Bytes}; use super::*; use crate::encoding::models::Swap; diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 92d1af8..a306b27 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -2,7 +2,7 @@ use std::{collections::HashSet, str::FromStr}; use alloy_primitives::{aliases::U24, U256, U8}; use alloy_sol_types::SolValue; -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{ errors::EncodingError, @@ -80,7 +80,7 @@ pub struct SplitSwapStrategyEncoder { impl SplitSwapStrategyEncoder { pub fn new( - blockchain: tycho_core::models::Chain, + blockchain: tycho_common::models::Chain, swap_encoder_registry: SwapEncoderRegistry, swapper_pk: Option, ) -> Result { @@ -341,7 +341,7 @@ mod tests { use alloy_primitives::hex; use num_bigint::{BigInt, BigUint}; use rstest::rstest; - use tycho_core::{ + use tycho_common::{ models::{protocol::ProtocolComponent, Chain as TychoCoreChain}, Bytes, }; diff --git a/src/encoding/evm/strategy_encoder/strategy_validators.rs b/src/encoding/evm/strategy_encoder/strategy_validators.rs index d4bf22f..e7ae722 100644 --- a/src/encoding/evm/strategy_encoder/strategy_validators.rs +++ b/src/encoding/evm/strategy_encoder/strategy_validators.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet, VecDeque}; -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{ errors::EncodingError, @@ -203,7 +203,7 @@ mod tests { use num_bigint::BigUint; use rstest::rstest; - use tycho_core::{models::protocol::ProtocolComponent, Bytes}; + use tycho_common::{models::protocol::ProtocolComponent, Bytes}; use super::*; use crate::encoding::models::Swap; diff --git a/src/encoding/evm/swap_encoder/swap_encoder_registry.rs b/src/encoding/evm/swap_encoder/swap_encoder_registry.rs index 72ab48a..8a5b9e4 100644 --- a/src/encoding/evm/swap_encoder/swap_encoder_registry.rs +++ b/src/encoding/evm/swap_encoder/swap_encoder_registry.rs @@ -19,7 +19,7 @@ impl SwapEncoderRegistry { /// executors' addresses in the file at the given path. pub fn new( executors_file_path: Option, - blockchain: tycho_core::models::Chain, + blockchain: tycho_common::models::Chain, ) -> Result { let chain = Chain::from(blockchain); let config_str = if let Some(ref path) = executors_file_path { diff --git a/src/encoding/evm/swap_encoder/swap_encoders.rs b/src/encoding/evm/swap_encoder/swap_encoders.rs index 473b250..f119681 100644 --- a/src/encoding/evm/swap_encoder/swap_encoders.rs +++ b/src/encoding/evm/swap_encoder/swap_encoders.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use alloy_primitives::{Address, Bytes as AlloyBytes}; use alloy_sol_types::SolValue; -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{ errors::EncodingError, @@ -258,13 +258,77 @@ impl SwapEncoder for BalancerV2SwapEncoder { } } +/// Encodes a swap on an Ekubo pool through the given executor address. +/// +/// # Fields +/// * `executor_address` - The address of the executor contract that will perform the swap. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EkuboSwapEncoder { + executor_address: String, +} + +impl SwapEncoder for EkuboSwapEncoder { + fn new(executor_address: String) -> Self { + Self { executor_address } + } + + fn encode_swap( + &self, + swap: Swap, + encoding_context: EncodingContext, + ) -> Result, EncodingError> { + if encoding_context.exact_out { + return Err(EncodingError::InvalidInput("exact out swaps not implemented".to_string())); + } + + let fee = u64::from_be_bytes( + get_static_attribute(&swap, "fee")? + .try_into() + .map_err(|_| EncodingError::FatalError("fee should be an u64".to_string()))?, + ); + + let tick_spacing = u32::from_be_bytes( + get_static_attribute(&swap, "tick_spacing")? + .try_into() + .map_err(|_| { + EncodingError::FatalError("tick_spacing should be an u32".to_string()) + })?, + ); + + let extension: Address = get_static_attribute(&swap, "extension")? + .as_slice() + .try_into() + .map_err(|_| EncodingError::FatalError("extension should be an address".to_string()))?; + + let mut encoded = vec![]; + + if encoding_context.group_token_in == swap.token_in { + encoded.extend(bytes_to_address(&encoding_context.receiver)?); + encoded.extend(bytes_to_address(&swap.token_in)?); + } + + encoded.extend(bytes_to_address(&swap.token_out)?); + encoded.extend((extension, fee, tick_spacing).abi_encode_packed()); + + Ok(encoded) + } + + fn executor_address(&self) -> &str { + &self.executor_address + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; use alloy::hex::encode; use num_bigint::BigInt; - use tycho_core::{models::protocol::ProtocolComponent, Bytes}; + use tycho_common::{models::protocol::ProtocolComponent, Bytes}; use super::*; @@ -636,4 +700,142 @@ mod tests { )) ); } + + mod ekubo { + use super::*; + + const RECEIVER: &str = "ca4f73fe97d0b987a0d12b39bbd562c779bab6f6"; // Random address + + #[test] + fn test_encode_swap_simple() { + let token_in = Bytes::from(Address::ZERO.as_slice()); + let token_out = Bytes::from("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); // USDC + + let static_attributes = HashMap::from([ + ("fee".to_string(), Bytes::from(0_u64)), + ("tick_spacing".to_string(), Bytes::from(0_u32)), + ( + "extension".to_string(), + Bytes::from("0x51d02a5948496a67827242eabc5725531342527c"), + ), // Oracle + ]); + + let component = ProtocolComponent { static_attributes, ..Default::default() }; + + let swap = Swap { + component, + token_in: token_in.clone(), + token_out: token_out.clone(), + split: 0f64, + }; + + let encoding_context = EncodingContext { + receiver: RECEIVER.into(), + group_token_in: token_in.clone(), + group_token_out: token_out.clone(), + exact_out: false, + router_address: Bytes::default(), + }; + + let encoder = EkuboSwapEncoder::new(String::default()); + + let encoded_swap = encoder + .encode_swap(swap, encoding_context) + .unwrap(); + + let hex_swap = encode(&encoded_swap); + + assert_eq!( + hex_swap, + RECEIVER.to_string() + + concat!( + // group token in + "0000000000000000000000000000000000000000", + // token out 1st swap + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + // pool config 1st swap + "51d02a5948496a67827242eabc5725531342527c000000000000000000000000", + ), + ); + } + + #[test] + fn test_encode_swap_multi() { + let group_token_in = Bytes::from(Address::ZERO.as_slice()); + let group_token_out = Bytes::from("0xdAC17F958D2ee523a2206206994597C13D831ec7"); // USDT + let intermediary_token = Bytes::from("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"); // USDC + + let encoder = EkuboSwapEncoder::new(String::default()); + + let encoding_context = EncodingContext { + receiver: RECEIVER.into(), + group_token_in: group_token_in.clone(), + group_token_out: group_token_out.clone(), + exact_out: false, + router_address: Bytes::default(), + }; + + let first_swap = Swap { + component: ProtocolComponent { + static_attributes: HashMap::from([ + ("fee".to_string(), Bytes::from(0_u64)), + ("tick_spacing".to_string(), Bytes::from(0_u32)), + ( + "extension".to_string(), + Bytes::from("0x51d02a5948496a67827242eabc5725531342527c"), + ), // Oracle + ]), + ..Default::default() + }, + token_in: group_token_in.clone(), + token_out: intermediary_token.clone(), + split: 0f64, + }; + + let second_swap = Swap { + component: ProtocolComponent { + // 0.0025% fee & 0.005% base pool + static_attributes: HashMap::from([ + ("fee".to_string(), Bytes::from(461168601842738_u64)), + ("tick_spacing".to_string(), Bytes::from(50_u32)), + ("extension".to_string(), Bytes::zero(20)), + ]), + ..Default::default() + }, + token_in: intermediary_token.clone(), + token_out: group_token_out.clone(), + split: 0f64, + }; + + let first_encoded_swap = encoder + .encode_swap(first_swap, encoding_context.clone()) + .unwrap(); + + let second_encoded_swap = encoder + .encode_swap(second_swap, encoding_context) + .unwrap(); + + let combined_hex = + format!("{}{}", encode(first_encoded_swap), encode(second_encoded_swap)); + + println!("{}", combined_hex); + + assert_eq!( + combined_hex, + RECEIVER.to_string() + + concat!( + // group token in + "0000000000000000000000000000000000000000", + // token out 1st swap + "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + // pool config 1st swap + "51d02a5948496a67827242eabc5725531342527c000000000000000000000000", + // token out 2nd swap + "dac17f958d2ee523a2206206994597c13d831ec7", + // pool config 2nd swap + "00000000000000000000000000000000000000000001a36e2eb1c43200000032", + ), + ); + } + } } diff --git a/src/encoding/evm/tycho_encoder.rs b/src/encoding/evm/tycho_encoder.rs index 183e215..5f20b2e 100644 --- a/src/encoding/evm/tycho_encoder.rs +++ b/src/encoding/evm/tycho_encoder.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use num_bigint::BigUint; -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{ errors::EncodingError, @@ -34,7 +34,7 @@ impl Clone for EVMTychoEncoder { impl EVMTychoEncoder { pub fn new( - chain: tycho_core::models::Chain, + chain: tycho_common::models::Chain, strategy_encoder: Box, ) -> Result { let chain: Chain = Chain::from(chain); @@ -54,6 +54,8 @@ impl EVMTychoEncoder { /// swap's input is the chain's wrapped token. /// * If the solution is unwrapping, the checked token is the chain's native token and the last /// swap's output is the chain's wrapped token. + /// * The token cannot appear more than once in the solution unless it is the first and last + /// token (i.e. a true cyclical swap). fn validate_solution(&self, solution: &Solution) -> Result<(), EncodingError> { if solution.exact_out { return Err(EncodingError::FatalError( @@ -175,7 +177,7 @@ impl TychoEncoder for EVMTychoEncoder { mod tests { use std::str::FromStr; - use tycho_core::models::{protocol::ProtocolComponent, Chain as TychoCoreChain}; + use tycho_common::models::{protocol::ProtocolComponent, Chain as TychoCoreChain}; use super::*; use crate::encoding::{ diff --git a/src/encoding/evm/utils.rs b/src/encoding/evm/utils.rs index f284ee4..b8af4f5 100644 --- a/src/encoding/evm/utils.rs +++ b/src/encoding/evm/utils.rs @@ -3,7 +3,7 @@ use std::{cmp::max, sync::Arc}; use alloy_primitives::{aliases::U24, keccak256, Address, FixedBytes, Keccak256, U256, U8}; use num_bigint::BigUint; use tokio::runtime::{Handle, Runtime}; -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{ errors::EncodingError, diff --git a/src/encoding/models.rs b/src/encoding/models.rs index 0abc963..af8a780 100644 --- a/src/encoding/models.rs +++ b/src/encoding/models.rs @@ -1,7 +1,7 @@ use hex; use num_bigint::BigUint; use serde::{Deserialize, Serialize}; -use tycho_core::{ +use tycho_common::{ models::{protocol::ProtocolComponent, Chain as TychoCoreChain}, Bytes, }; @@ -130,6 +130,7 @@ impl From for Chain { TychoCoreChain::Arbitrum => Chain { id: 42161, name: chain.to_string() }, TychoCoreChain::Starknet => Chain { id: 0, name: chain.to_string() }, TychoCoreChain::Base => Chain { id: 8453, name: chain.to_string() }, + TychoCoreChain::Unichain => Chain { id: 130, name: chain.to_string() }, } } } diff --git a/src/encoding/strategy_encoder.rs b/src/encoding/strategy_encoder.rs index b0e42fb..57630a0 100644 --- a/src/encoding/strategy_encoder.rs +++ b/src/encoding/strategy_encoder.rs @@ -1,4 +1,4 @@ -use tycho_core::Bytes; +use tycho_common::Bytes; use crate::encoding::{errors::EncodingError, models::Solution, swap_encoder::SwapEncoder};