fix: Don't PLE-encode for Ekubo

Our encoding uses PLE for subsequent swaps in a swap group. The EkuboExecutor assumes hard-coded hop length, which did not match the encoding side - leading to corrupted calldata in any swap after the first swap.
This commit is contained in:
TAMARA LIPOWSKI
2025-10-07 12:08:49 +02:00
parent 13563a6fa9
commit ae1b1f8850
6 changed files with 968 additions and 724 deletions

1511
Cargo.lock generated

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -208,7 +208,11 @@ contract EkuboExecutorTest is Constants, TestUtils {
}
}
contract TychoRouterForBalancerV3Test is TychoRouterTestSetup {
contract TychoRouterForEkuboTest is TychoRouterTestSetup {
function getForkBlock() public view virtual override returns (uint256) {
return 23518049;
}
function testSingleEkuboIntegration() public {
vm.stopPrank();
@@ -227,4 +231,28 @@ contract TychoRouterForBalancerV3Test is TychoRouterTestSetup {
assertGe(balanceAfter - balanceBefore, 26173932);
assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0);
}
function testTwoEkuboIntegration() public {
// Test multi-hop Ekubo swaps (grouped swap)
//
// USDE ──(EKUBO)──> USDC ──(EKUBO)──> USDT
//
deal(USDE_ADDR, ALICE, 1 ether);
uint256 balanceBefore = IERC20(USDT_ADDR).balanceOf(ALICE);
// Approve permit2
vm.startPrank(ALICE);
IERC20(USDE_ADDR).approve(tychoRouterAddr, type(uint256).max);
bytes memory callData =
loadCallDataFromFile("test_single_ekubo_multi_hop");
(bool success,) = tychoRouterAddr.call(callData);
vm.stopPrank();
uint256 balanceAfter = IERC20(USDT_ADDR).balanceOf(ALICE);
assertTrue(success, "Call Failed");
assertEq(balanceAfter - balanceBefore, 999804);
assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0);
}
}

View File

@@ -53,3 +53,11 @@ pub static CALLBACK_CONSTRAINED_PROTOCOLS: LazyLock<HashSet<&'static str>> = Laz
set.insert("vm:balancer_v3");
set
});
/// These groupable protocols use simple concatenation when forming swap groups instead of PLE
/// encoding for grouped protocol data.
pub static NON_PLE_ENCODED_PROTOCOLS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
let mut set = HashSet::new();
set.insert("ekubo_v2");
set
});

View File

@@ -6,6 +6,7 @@ use tycho_common::{models::Chain, Bytes};
use crate::encoding::{
errors::EncodingError,
evm::{
constants::NON_PLE_ENCODED_PROTOCOLS,
group_swaps::group_swaps,
strategy_encoder::{
strategy_validators::{SequentialSwapValidator, SplitSwapValidator, SwapValidator},
@@ -139,8 +140,14 @@ impl StrategyEncoder for SingleSwapStrategyEncoder {
}
if !grouped_protocol_data.is_empty() {
if NON_PLE_ENCODED_PROTOCOLS.contains(grouped_swap.protocol_system.as_str()) {
for protocol_data in grouped_protocol_data {
initial_protocol_data.extend(protocol_data);
}
} else {
initial_protocol_data.extend(ple_encode(grouped_protocol_data));
}
}
let swap_data = self.encode_swap_header(
Bytes::from_str(swap_encoder.executor_address())
@@ -305,8 +312,14 @@ impl StrategyEncoder for SequentialSwapStrategyEncoder {
}
if !grouped_protocol_data.is_empty() {
if NON_PLE_ENCODED_PROTOCOLS.contains(grouped_swap.protocol_system.as_str()) {
for protocol_data in grouped_protocol_data {
initial_protocol_data.extend(protocol_data);
}
} else {
initial_protocol_data.extend(ple_encode(grouped_protocol_data));
}
}
let swap_data = self.encode_swap_header(
Bytes::from_str(swap_encoder.executor_address()).map_err(|_| {
@@ -511,8 +524,14 @@ impl StrategyEncoder for SplitSwapStrategyEncoder {
}
if !grouped_protocol_data.is_empty() {
if NON_PLE_ENCODED_PROTOCOLS.contains(grouped_swap.protocol_system.as_str()) {
for protocol_data in grouped_protocol_data {
initial_protocol_data.extend(protocol_data);
}
} else {
initial_protocol_data.extend(ple_encode(grouped_protocol_data));
}
}
let swap_data = self.encode_swap_header(
get_token_position(&tokens, &grouped_swap.token_in)?,

View File

@@ -406,6 +406,96 @@ fn test_single_encoding_strategy_usv4_grouped_swap() {
);
}
#[test]
fn test_single_encoding_strategy_ekubo_grouped_swap() {
// Test multi-hop Ekubo swap (grouped swaps)
//
// USDE ──(EKUBO)──> USDC ──(EKUBO)──> USDT
let usde = Bytes::from_str("0x4c9edd5852cd905f086c759e8383e09bff1e68b3").unwrap();
let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
let usdt = Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap();
// First swap: USDE -> USDC
let swap1 = Swap {
component: ProtocolComponent {
id: "a419f0ebb019eb85fdccd0200843752dd9cc31d0cb3127f3adb4ba37a092788f".to_string(),
protocol_system: "ekubo_v2".to_string(),
static_attributes: HashMap::from([
("fee".to_string(), Bytes::from(922337203685478_u64)),
("tick_spacing".to_string(), Bytes::from(100_u32)),
(
"extension".to_string(),
Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
),
]),
..Default::default()
},
token_in: usde.clone(),
token_out: usdc.clone(),
split: 0f64,
user_data: None,
protocol_state: None,
estimated_amount_in: None,
};
// Second swap: USDC -> USDT
let swap2 = Swap {
component: ProtocolComponent {
id: "ca5b3ef9770bb95940bd4e0bff5ead70a5973d904a8b370b52147820e61a2ff6".to_string(),
protocol_system: "ekubo_v2".to_string(),
static_attributes: HashMap::from([
("fee".to_string(), Bytes::from(92233720368547_u64)),
("tick_spacing".to_string(), Bytes::from(50_u32)),
(
"extension".to_string(),
Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
),
]),
..Default::default()
},
token_in: usdc.clone(),
token_out: usdt.clone(),
split: 0f64,
user_data: None,
protocol_state: None,
estimated_amount_in: None,
};
let encoder = get_tycho_router_encoder(UserTransferType::TransferFrom);
let solution = Solution {
exact_out: false,
given_token: usde,
given_amount: BigUint::from_str("1_000000000000000000").unwrap(),
checked_token: usdt,
checked_amount: BigUint::from_str("1000").unwrap(),
sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(),
receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(),
swaps: vec![swap1, swap2],
..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,
&eth(),
None,
)
.unwrap()
.data;
let hex_calldata = encode(&calldata);
write_calldata_to_file("test_single_ekubo_multi_hop", hex_calldata.as_str());
}
#[test]
fn test_single_encoding_strategy_curve() {
// UWU ──(curve 2 crypto pool)──> WETH