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/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..b934f8c --- /dev/null +++ b/foundry/src/executors/EkuboExecutor.sol @@ -0,0 +1,210 @@ +// 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; + + 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 == 0xb45a3c0e) { // Selector of locked(uint256) + _locked(stripped); + } else if (selector == 0x599d0714) { // Selector of payCallback(uint256,address) + _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 { + uint128 amount = uint128(bytes16(LibBytes.loadCalldata(msg.data, 68))); + + SafeTransferLib.safeTransfer(token, address(core), amount); + } + + 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); + + 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; + + 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 { + 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[0:20])); + uint128 amount = uint128(bytes16(payData[20:36])); + + SafeTransferLib.safeTransfer(address(core), token, 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..c323bdb --- /dev/null +++ b/foundry/test/executors/EkuboExecutor.t.sol @@ -0,0 +1,121 @@ +// 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 { + 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 { + uint256 forkBlock = 22082754; + vm.createSelectFork(vm.rpcUrl("mainnet"), forkBlock); + executor = new EkuboExecutor(ICore(payable(CORE_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); + } + + function testMultiHopSwap() public { + 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)); + + 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) + ); + + 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); + } +} 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/swap_encoder/swap_encoders.rs b/src/encoding/evm/swap_encoder/swap_encoders.rs index 473b250..f5c2752 100644 --- a/src/encoding/evm/swap_encoder/swap_encoders.rs +++ b/src/encoding/evm/swap_encoder/swap_encoders.rs @@ -1,4 +1,4 @@ -use std::str::FromStr; +use std::{ops::Add, str::FromStr}; use alloy_primitives::{Address, Bytes as AlloyBytes}; use alloy_sol_types::SolValue; @@ -258,6 +258,64 @@ 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 EkuboEncoder { + executor_address: String, +} + +impl SwapEncoder for EkuboEncoder { + fn new(executor_address: String) -> Self { + Self { executor_address } + } + + // TODO Exact out + fn encode_swap( + &self, + swap: Swap, + encoding_context: EncodingContext, + ) -> Result, EncodingError> { + 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, "fee")? + .as_slice() + .try_into() + .map_err(|_| EncodingError::FatalError("extension should be an address".to_string()))?; + + let mut encoded = vec![]; + + // TODO What if the token_in appears at the start of a route and later on again? + 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;