From a5f07a25ef3edc099f45ab5d4da5acce694b155a Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Wed, 26 Mar 2025 16:05:54 +0100 Subject: [PATCH 1/7] feat: SingleSwapStrategyEncoder - Had to take the implementation of the swap header encoding out of the main EVMStrategyEncoder trait, since it will now be difference for single and split swap strategies. - Integration tests will be added in separate task/PR. --- .../evm/strategy_encoder/strategy_encoders.rs | 103 ++++++++++++++++-- 1 file changed, 91 insertions(+), 12 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 4498c3b..cadeb30 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -916,7 +916,7 @@ mod tests { Some(private_key), Bytes::from("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395"), ) - .unwrap(); + .unwrap(); let solution = Solution { exact_out: false, given_token: weth, @@ -951,15 +951,15 @@ mod tests { // it's hard to assert let expected_swap = String::from(concat!( - // length of swap bytes - "0000000000000000000000000000000000000000000000000000000000000051", - "5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", // executor address - "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id - "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver - "00", // zero2one - "00", // exact out - "0000000000000000000000000000", // padding + // length of swap bytes + "0000000000000000000000000000000000000000000000000000000000000051", + "5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "00", // zero2one + "00", // exact out + "0000000000000000000000000000", // padding )); let hex_calldata = encode(&calldata); @@ -1362,6 +1362,85 @@ mod tests { println!("{}", hex_calldata); } + #[test] + fn test_single_swap_strategy_encoder_no_permit2() { + // Performs a single swap from WETH to DAI on a USV2 pool, without permit2 and no grouping + // optimizations. + + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let expected_amount = Some(BigUint::from_str("2_650_000000000000000000").unwrap()); + let slippage = Some(0.01f64); + let checked_amount = Some(BigUint::from_str("2_640_000000000000000000").unwrap()); + let expected_min_amount = U256::from_str("2_640_000000000000000000").unwrap(); + + let swap = Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SingleSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + None, + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + ) + .unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: dai, + expected_amount, + slippage, + checked_amount, + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + swaps: vec![swap], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + let expected_min_amount_encoded = hex::encode(U256::abi_encode(&expected_min_amount)); + let expected_input = [ + "0f6cbbe8", // Function selector + "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out + "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f", // token out + &expected_min_amount_encoded, // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + "0000000000000000000000000000000000000000000000000000000000000100", // offset of swap bytes + "0000000000000000000000000000000000000000000000000000000000000051", // length of swap bytes without padding + + // Swap data + "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "00", // zero2one + "00", // exact out + "0000000000000000000000000000", // padding + ] + .join(""); + + let hex_calldata = encode(&calldata); + + assert_eq!(hex_calldata, expected_input); + println!("{}", hex_calldata); + } + #[test] fn test_split_encoding_strategy_ekubo() { // ETH ──(EKUBO)──> USDC @@ -1397,7 +1476,7 @@ mod tests { None, Some(Bytes::from_str("0x1d1499e622D69689cdf9004d05Ec547d650Ff211").unwrap()), ) - .unwrap(); + .unwrap(); let solution = Solution { exact_out: false, @@ -1423,7 +1502,7 @@ mod tests { } #[test] - fn test_single_swap_strategy_encoder_no_permit2() { + fn test_split_swap_strategy_encoder_simple_route_no_permit2() { // Performs a single swap from WETH to DAI on a USV2 pool, without permit2 and no grouping // optimizations. From 5d586c25e3e084511b34677684cd1bb71e249fcc Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Wed, 26 Mar 2025 17:06:56 +0100 Subject: [PATCH 2/7] feat: SingleSwapStrategyEncoder - Had to take the implementation of the swap header encoding out of the main EVMStrategyEncoder trait, since it will now be difference for single and split swap strategies. - Integration tests will be added in separate task/PR. --- .../evm/strategy_encoder/strategy_encoders.rs | 236 +++++++++++++++++- 1 file changed, 235 insertions(+), 1 deletion(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index cadeb30..6821e61 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -199,6 +199,154 @@ impl StrategyEncoder for SingleSwapStrategyEncoder { } } +/// Represents the encoder for a swap strategy which supports single swaps. +/// +/// # Fields +/// * `swap_encoder_registry`: SwapEncoderRegistry, containing all possible swap encoders +/// * `permit2`: Permit2, responsible for managing permit2 operations and providing necessary +/// signatures and permit2 objects for calling the router +/// * `selector`: String, the selector for the swap function in the router contract +/// * `native_address`: Address of the chain's native token +/// * `wrapped_address`: Address of the chain's wrapped token +/// * `router_address`: Address of the router to be used to execute swaps +#[derive(Clone)] +pub struct SequentialSwapStrategyEncoder { + swap_encoder_registry: SwapEncoderRegistry, + permit2: Option, + selector: String, + router_address: Bytes, +} + +impl SequentialSwapStrategyEncoder { + pub fn new( + blockchain: tycho_core::models::Chain, + swap_encoder_registry: SwapEncoderRegistry, + swapper_pk: Option, + router_address: Bytes, + ) -> Result { + let chain = Chain::from(blockchain); + let (permit2, selector) = if let Some(swapper_pk) = swapper_pk { + (Some(Permit2::new(swapper_pk, chain.clone())?), "sequentialSwapPermit2(uint256,address,address,uint256,bool,bool,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string()) + } else { + ( + None, + "sequentialSwap(uint256,address,address,uint256,bool,bool,address,bytes)" + .to_string(), + ) + }; + Ok(Self { permit2, selector, swap_encoder_registry, router_address }) + } + + /// Encodes information necessary for performing a single swap against a given executor for + /// a protocol. + fn encode_swap_header(&self, executor_address: Bytes, protocol_data: Vec) -> Vec { + let mut encoded = Vec::new(); + encoded.extend(executor_address.to_vec()); + encoded.extend(protocol_data); + encoded + } +} + +impl EVMStrategyEncoder for SequentialSwapStrategyEncoder {} + +impl StrategyEncoder for SequentialSwapStrategyEncoder { + fn encode_strategy(&self, solution: Solution) -> Result<(Vec, Bytes), EncodingError> { + // TODO validate sequential swaps: check valid cycles, empty swaps, etc. + + let min_amount_out = get_min_amount_for_solution(solution.clone()); + let grouped_swaps = group_swaps(solution.swaps); + + let (mut unwrap, mut wrap) = (false, false); + if let Some(action) = solution.native_action.clone() { + match action { + NativeAction::Wrap => wrap = true, + NativeAction::Unwrap => unwrap = true, + } + } + + let mut swaps = vec![]; + for grouped_swap in grouped_swaps.iter() { + let swap_encoder = self + .get_swap_encoder(&grouped_swap.protocol_system) + .ok_or_else(|| { + EncodingError::InvalidInput(format!( + "Swap encoder not found for protocol: {}", + grouped_swap.protocol_system + )) + })?; + + let mut grouped_protocol_data: Vec = vec![]; + for swap in grouped_swap.swaps.iter() { + let encoding_context = EncodingContext { + receiver: solution.router_address.clone(), + exact_out: solution.exact_out, + router_address: self.router_address.clone(), + group_token_in: grouped_swap.input_token.clone(), + group_token_out: grouped_swap.output_token.clone(), + }; + let protocol_data = + swap_encoder.encode_swap(swap.clone(), encoding_context.clone())?; + grouped_protocol_data.extend(protocol_data); + } + + let swap_data = self.encode_swap_header( + Bytes::from_str(swap_encoder.executor_address()).map_err(|_| { + EncodingError::FatalError("Invalid executor address".to_string()) + })?, + grouped_protocol_data, + ); + swaps.push(swap_data); + } + + let encoded_swaps = self.ple_encode(swaps); + let method_calldata = if let Some(permit2) = self.permit2.clone() { + let (permit, signature) = permit2.get_permit( + &self.router_address, + &solution.sender, + &solution.given_token, + &solution.given_amount, + )?; + ( + biguint_to_u256(&solution.given_amount), + bytes_to_address(&solution.given_token)?, + bytes_to_address(&solution.checked_token)?, + biguint_to_u256(&min_amount_out), + wrap, + unwrap, + bytes_to_address(&solution.receiver)?, + permit, + signature.as_bytes().to_vec(), + encoded_swaps, + ) + .abi_encode() + } else { + ( + biguint_to_u256(&solution.given_amount), + bytes_to_address(&solution.given_token)?, + bytes_to_address(&solution.checked_token)?, + biguint_to_u256(&min_amount_out), + wrap, + unwrap, + bytes_to_address(&solution.receiver)?, + encoded_swaps, + ) + .abi_encode() + }; + + let contract_interaction = encode_input(&self.selector, method_calldata); + Ok((contract_interaction, solution.router_address)) + } + + fn get_swap_encoder(&self, protocol_system: &str) -> Option<&Box> { + self.swap_encoder_registry + .get_encoder(protocol_system) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + /// Represents the encoder for a swap strategy which supports single, sequential and split swaps. /// /// # Fields @@ -885,13 +1033,99 @@ mod tests { Some(BigUint::from_str("2_999_000000000000000000").unwrap()), U256::from_str("2_999_000000000000000000").unwrap(), )] - fn test_single_swap_strategy_encoder( + fn test_sequential_swap_strategy_encoder_simple_route( #[case] expected_amount: Option, #[case] slippage: Option, #[case] checked_amount: Option, #[case] expected_min_amount: U256, ) { // Performs a single swap from WETH to DAI on a USV2 pool, with no grouping optimizations. + + // Set up a mock private key for signing + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let swap = Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SequentialSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + Some(private_key), + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + ) + .unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: dai, + expected_amount, + slippage, + checked_amount, + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + swaps: vec![swap], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + let expected_min_amount_encoded = hex::encode(U256::abi_encode(&expected_min_amount)); + let expected_input = [ + "51bcc7b6", // Function selector + "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out + "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f", // token out + &expected_min_amount_encoded, // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + ] + .join(""); + + // after this there is the permit and because of the deadlines (that depend on block time) + // it's hard to assert + + let expected_swaps = String::from(concat!( + // length of ple encoded swaps without padding + "0000000000000000000000000000000000000000000000000000000000000053", + // ple encoded swaps + "0051", + // Swap data + "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "00", // zero2one + "00", // exact out + "000000000000000000000000", // padding + )); + let hex_calldata = encode(&calldata); + + assert_eq!(hex_calldata[..456], expected_input); + assert_eq!(hex_calldata[1224..], expected_swaps); + } + + #[test] + fn test_single_swap_strategy_encoder_wrap() { + // Performs a single swap from WETH to DAI on a USV2 pool, wrapping ETH + // Note: This test does not assert anything. It is only used to obtain integration test + // data for our router solidity test. + // Set up a mock private key for signing let private_key = "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); From 0d8150e22f2a354a41583a722bd4eba60ab1de59 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 27 Mar 2025 11:19:00 +0100 Subject: [PATCH 3/7] feat: sequential swap solution validation - Basically reuse all methods of split swap validation, minus those specific to splits. --- .../evm/strategy_encoder/strategy_encoders.rs | 32 +++- .../strategy_encoder/strategy_validators.rs | 180 +++++++++--------- 2 files changed, 124 insertions(+), 88 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 6821e61..4f49eee 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -12,7 +12,10 @@ use crate::encoding::{ evm::{ approvals::permit2::Permit2, constants::DEFAULT_ROUTERS_JSON, - strategy_encoder::{group_swaps::group_swaps, strategy_validators::SplitSwapValidator}, + strategy_encoder::{ + group_swaps::group_swaps, + strategy_validators::{SequentialSwapValidator, SplitSwapValidator, SwapValidator}, + }, swap_encoder::swap_encoder_registry::SwapEncoderRegistry, utils::{ biguint_to_u256, bytes_to_address, encode_input, get_min_amount_for_solution, @@ -209,12 +212,17 @@ impl StrategyEncoder for SingleSwapStrategyEncoder { /// * `native_address`: Address of the chain's native token /// * `wrapped_address`: Address of the chain's wrapped token /// * `router_address`: Address of the router to be used to execute swaps +/// * `sequential_swap_validator`: SequentialSwapValidator, responsible for checking validity of +/// sequential swap solutions #[derive(Clone)] pub struct SequentialSwapStrategyEncoder { swap_encoder_registry: SwapEncoderRegistry, permit2: Option, selector: String, router_address: Bytes, + native_address: Bytes, + wrapped_address: Bytes, + sequential_swap_validator: SequentialSwapValidator, } impl SequentialSwapStrategyEncoder { @@ -234,7 +242,15 @@ impl SequentialSwapStrategyEncoder { .to_string(), ) }; - Ok(Self { permit2, selector, swap_encoder_registry, router_address }) + Ok(Self { + permit2, + selector, + swap_encoder_registry, + router_address, + native_address: chain.native_token()?, + wrapped_address: chain.wrapped_token()?, + sequential_swap_validator: SequentialSwapValidator, + }) } /// Encodes information necessary for performing a single swap against a given executor for @@ -251,7 +267,17 @@ impl EVMStrategyEncoder for SequentialSwapStrategyEncoder {} impl StrategyEncoder for SequentialSwapStrategyEncoder { fn encode_strategy(&self, solution: Solution) -> Result<(Vec, Bytes), EncodingError> { - // TODO validate sequential swaps: check valid cycles, empty swaps, etc. + self.sequential_swap_validator + .validate_solution_min_amounts(&solution)?; + self.sequential_swap_validator + .validate_swap_path( + &solution.swaps, + &solution.given_token, + &solution.checked_token, + &solution.native_action, + &self.native_address, + &self.wrapped_address, + )?; let min_amount_out = get_min_amount_for_solution(solution.clone()); let grouped_swaps = group_swaps(solution.swaps); diff --git a/src/encoding/evm/strategy_encoder/strategy_validators.rs b/src/encoding/evm/strategy_encoder/strategy_validators.rs index e7ae722..04396de 100644 --- a/src/encoding/evm/strategy_encoder/strategy_validators.rs +++ b/src/encoding/evm/strategy_encoder/strategy_validators.rs @@ -7,92 +7,10 @@ use crate::encoding::{ models::{NativeAction, Solution, Swap}, }; -/// Validates whether a sequence of split swaps represents a valid solution. -#[derive(Clone)] -pub struct SplitSwapValidator; - -impl SplitSwapValidator { - /// Raises an error if the split percentages are invalid. - /// - /// Split percentages are considered valid if all the following conditions are met: - /// * Each split amount is < 1 (100%) - /// * There is exactly one 0% split for each token, and it's the last swap specified, signifying - /// to the router to send the remainder of the token to the designated protocol - /// * The sum of all non-remainder splits for each token is < 1 (100%) - /// * There are no negative split amounts - pub fn validate_split_percentages(&self, swaps: &[Swap]) -> Result<(), EncodingError> { - let mut swaps_by_token: HashMap> = HashMap::new(); - for swap in swaps { - if swap.split >= 1.0 { - return Err(EncodingError::InvalidInput(format!( - "Split percentage must be less than 1 (100%), got {}", - swap.split - ))); - } - swaps_by_token - .entry(swap.token_in.clone()) - .or_default() - .push(swap); - } - - for (token, token_swaps) in swaps_by_token { - // Single swaps don't need remainder handling - if token_swaps.len() == 1 { - if token_swaps[0].split != 0.0 { - return Err(EncodingError::InvalidInput(format!( - "Single swap must have 0% split for token {:?}", - token - ))); - } - continue; - } - - let mut found_zero_split = false; - let mut total_percentage = 0.0; - for (i, swap) in token_swaps.iter().enumerate() { - match (swap.split == 0.0, i == token_swaps.len() - 1) { - (true, false) => { - return Err(EncodingError::InvalidInput(format!( - "The 0% split for token {:?} must be the last swap", - token - ))) - } - (true, true) => found_zero_split = true, - (false, _) => { - if swap.split < 0.0 { - return Err(EncodingError::InvalidInput(format!( - "All splits must be >= 0% for token {:?}", - token - ))); - } - total_percentage += swap.split; - } - } - } - - if !found_zero_split { - return Err(EncodingError::InvalidInput(format!( - "Token {:?} must have exactly one 0% split for remainder handling", - token - ))); - } - - // Total must be <100% to leave room for remainder - if total_percentage >= 1.0 { - return Err(EncodingError::InvalidInput(format!( - "Total of non-remainder splits for token {:?} must be <100%, got {}%", - token, - total_percentage * 100.0 - ))); - } - } - - Ok(()) - } - +pub trait SwapValidator { /// Raises an error if the solution does not have checked amount set or slippage with checked /// amount set. - pub fn validate_solution_min_amounts(&self, solution: &Solution) -> Result<(), EncodingError> { + fn validate_solution_min_amounts(&self, solution: &Solution) -> Result<(), EncodingError> { if solution.checked_amount.is_none() && (solution.slippage.is_none() || solution.expected_amount.is_none()) { @@ -113,7 +31,7 @@ impl SplitSwapValidator { /// If the given token is the native token and the native action is WRAP, it will be converted /// to the wrapped token before validating the swap path. The same principle applies for the /// checked token and the UNWRAP action. - pub fn validate_swap_path( + fn validate_swap_path( &self, swaps: &[Swap], given_token: &Bytes, @@ -197,6 +115,98 @@ impl SplitSwapValidator { } } +/// Validates whether a sequence of split swaps represents a valid solution. +#[derive(Clone)] +pub struct SplitSwapValidator; + +impl SwapValidator for SplitSwapValidator {} + +impl SplitSwapValidator { + /// Raises an error if the split percentages are invalid. + /// + /// Split percentages are considered valid if all the following conditions are met: + /// * Each split amount is < 1 (100%) + /// * There is exactly one 0% split for each token, and it's the last swap specified, signifying + /// to the router to send the remainder of the token to the designated protocol + /// * The sum of all non-remainder splits for each token is < 1 (100%) + /// * There are no negative split amounts + pub fn validate_split_percentages(&self, swaps: &[Swap]) -> Result<(), EncodingError> { + let mut swaps_by_token: HashMap> = HashMap::new(); + for swap in swaps { + if swap.split >= 1.0 { + return Err(EncodingError::InvalidInput(format!( + "Split percentage must be less than 1 (100%), got {}", + swap.split + ))); + } + swaps_by_token + .entry(swap.token_in.clone()) + .or_default() + .push(swap); + } + + for (token, token_swaps) in swaps_by_token { + // Single swaps don't need remainder handling + if token_swaps.len() == 1 { + if token_swaps[0].split != 0.0 { + return Err(EncodingError::InvalidInput(format!( + "Single swap must have 0% split for token {:?}", + token + ))); + } + continue; + } + + let mut found_zero_split = false; + let mut total_percentage = 0.0; + for (i, swap) in token_swaps.iter().enumerate() { + match (swap.split == 0.0, i == token_swaps.len() - 1) { + (true, false) => { + return Err(EncodingError::InvalidInput(format!( + "The 0% split for token {:?} must be the last swap", + token + ))) + } + (true, true) => found_zero_split = true, + (false, _) => { + if swap.split < 0.0 { + return Err(EncodingError::InvalidInput(format!( + "All splits must be >= 0% for token {:?}", + token + ))); + } + total_percentage += swap.split; + } + } + } + + if !found_zero_split { + return Err(EncodingError::InvalidInput(format!( + "Token {:?} must have exactly one 0% split for remainder handling", + token + ))); + } + + // Total must be <100% to leave room for remainder + if total_percentage >= 1.0 { + return Err(EncodingError::InvalidInput(format!( + "Total of non-remainder splits for token {:?} must be <100%, got {}%", + token, + total_percentage * 100.0 + ))); + } + } + + Ok(()) + } +} + +/// Validates whether a sequence of sequential swaps represents a valid solution. +#[derive(Clone)] +pub struct SequentialSwapValidator; + +impl SwapValidator for SequentialSwapValidator {} + #[cfg(test)] mod tests { use std::str::FromStr; From c67c9acac45d780d61493b83e0ed6384f4d6777a Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 3 Apr 2025 23:35:48 +0200 Subject: [PATCH 4/7] test: Sequential swap integration test - And other small fixes after merge --- foundry/test/TychoRouterIntegration.t.sol | 63 ++++++ .../evm/strategy_encoder/strategy_encoders.rs | 190 ++++++++---------- 2 files changed, 151 insertions(+), 102 deletions(-) diff --git a/foundry/test/TychoRouterIntegration.t.sol b/foundry/test/TychoRouterIntegration.t.sol index a473d9d..b87c322 100644 --- a/foundry/test/TychoRouterIntegration.t.sol +++ b/foundry/test/TychoRouterIntegration.t.sol @@ -28,6 +28,39 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertEq(balancerAfter - balancerBefore, 2659881924818443699787); } + function testSequentialIntegration() public { + // Test created with calldata from our router encoder, replacing the executor + // address with the USV2 executor address. + + // Note: This test does not assert anything. It is only used to obtain integration test + // data for our router solidity test. + // + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ───(USV2)──> WBTC ───(USV2)──> USDC + + deal(WETH_ADDR, ALICE, 1 ether); + uint256 balancerBefore = IERC20(DAI_ADDR).balanceOf(ALICE); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); + // Encoded solution generated using `test_split_swap_strategy_encoder_simple` + // but manually replacing the executor address + // `5c2f5a71f67c01775180adc06909288b4c329308` with the one in this test + // `5615deb798bb3e4dfa0139dfa1b3d433cc23b72f` + (bool success,) = tychoRouterAddr.call( + hex"51bcc7b60000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000681632fc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067eead0400000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000041e396cbca5c5cfa3ea8a9ed8b63a4ac3a8b079e4de510d79f656cd660303e69482f89ed071d177b3ac73030619cc15e5ca42a41543551e47d67089437166978a01c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a60051f6c5be66fff9dc69962d73da0a617a827c382329c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d01395000051f6c5be66fff9dc69962d73da0a617a827c3823292260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" + ); + + vm.stopPrank(); + + uint256 balancerAfter = IERC20(DAI_ADDR).balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertEq(balancerAfter - balancerBefore, 2659881924818443699787); + } + function testSplitSwapSingleWithoutPermit2Integration() public { // Test created with calldata from our router encoder, replacing the executor // address with the USV2 executor address. @@ -244,6 +277,36 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } + function testSequentialSwapIntegration() public { + // Test created with calldata from our router encoder, replacing the executor + // address with the USV2 executor address. + + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ──(USV2)──> WBTC ───(USV2)──> USDC + deal(WETH_ADDR, ALICE, 1 ether); + uint256 balancerBefore = IERC20(USDC_ADDR).balanceOf(ALICE); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); + // Encoded solution generated using `test_split_swap_strategy_encoder_complex` + (bool success,) = tychoRouterAddr.call( + hex"51bcc7b60000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000068168aea00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067ef04f200000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000260000000000000000000000000000000000000000000000000000000000000004154956683effd126a9182e2d82ebd3d778e5283b93d571b13cdbc9dfbf3d9f655057a2332ed566f79bed7514a22ef1c52969132bc71a5a2ef125d78e39ec264511c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a600515615deb798bb3e4dfa0139dfa1b3d433cc23b72fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d013950000515615deb798bb3e4dfa0139dfa1b3d433cc23b72f2260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" + ); + + vm.stopPrank(); + + uint256 balancerAfter = IERC20(USDC_ADDR).balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertEq(balancerAfter - balancerBefore, 2552915143); + + // All input tokens are transferred to the router at first. Make sure we used + // all of it (and thus our splits are correct). + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + function testCyclicSequentialSwapIntegration() public { deal(USDC_ADDR, ALICE, 100 * 10 ** 6); diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 4f49eee..b3ed871 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -227,7 +227,7 @@ pub struct SequentialSwapStrategyEncoder { impl SequentialSwapStrategyEncoder { pub fn new( - blockchain: tycho_core::models::Chain, + blockchain: tycho_common::models::Chain, swap_encoder_registry: SwapEncoderRegistry, swapper_pk: Option, router_address: Bytes, @@ -304,9 +304,9 @@ impl StrategyEncoder for SequentialSwapStrategyEncoder { let mut grouped_protocol_data: Vec = vec![]; for swap in grouped_swap.swaps.iter() { let encoding_context = EncodingContext { - receiver: solution.router_address.clone(), + receiver: self.router_address.clone(), exact_out: solution.exact_out, - router_address: self.router_address.clone(), + router_address: Some(self.router_address.clone()), group_token_in: grouped_swap.input_token.clone(), group_token_out: grouped_swap.output_token.clone(), }; @@ -360,7 +360,7 @@ impl StrategyEncoder for SequentialSwapStrategyEncoder { }; let contract_interaction = encode_input(&self.selector, method_calldata); - Ok((contract_interaction, solution.router_address)) + Ok((contract_interaction, self.router_address.clone())) } fn get_swap_encoder(&self, protocol_system: &str) -> Option<&Box> { @@ -1091,7 +1091,7 @@ mod tests { Some(private_key), Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), ) - .unwrap(); + .unwrap(); let solution = Solution { exact_out: false, given_token: weth, @@ -1102,7 +1102,6 @@ mod tests { checked_amount, sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), swaps: vec![swap], ..Default::default() }; @@ -1127,18 +1126,18 @@ mod tests { // it's hard to assert let expected_swaps = String::from(concat!( - // length of ple encoded swaps without padding - "0000000000000000000000000000000000000000000000000000000000000053", - // ple encoded swaps - "0051", - // Swap data - "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address - "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id - "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver - "00", // zero2one - "00", // exact out - "000000000000000000000000", // padding + // length of ple encoded swaps without padding + "0000000000000000000000000000000000000000000000000000000000000053", + // ple encoded swaps + "0051", + // Swap data + "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "00", // zero2one + "00", // exact out + "000000000000000000000000", // padding )); let hex_calldata = encode(&calldata); @@ -1146,87 +1145,6 @@ mod tests { assert_eq!(hex_calldata[1224..], expected_swaps); } - #[test] - fn test_single_swap_strategy_encoder_wrap() { - // Performs a single swap from WETH to DAI on a USV2 pool, wrapping ETH - // Note: This test does not assert anything. It is only used to obtain integration test - // data for our router solidity test. - - // Set up a mock private key for signing - let private_key = - "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); - - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - - let swap = Swap { - component: ProtocolComponent { - id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0f64, - }; - let swap_encoder_registry = get_swap_encoder_registry(); - let encoder = SingleSwapStrategyEncoder::new( - eth_chain(), - swap_encoder_registry, - Some(private_key), - Bytes::from("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395"), - ) - .unwrap(); - let solution = Solution { - exact_out: false, - given_token: weth, - given_amount: BigUint::from_str("1_000000000000000000").unwrap(), - checked_token: dai, - expected_amount, - slippage, - checked_amount, - sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - swaps: vec![swap], - ..Default::default() - }; - - let (calldata, _) = encoder - .encode_strategy(solution) - .unwrap(); - let expected_min_amount_encoded = hex::encode(U256::abi_encode(&expected_min_amount)); - let expected_input = [ - "c378044e", // Function selector - "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out - "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f", // token out - &expected_min_amount_encoded, // min amount out - "0000000000000000000000000000000000000000000000000000000000000000", // wrap - "0000000000000000000000000000000000000000000000000000000000000000", // unwrap - "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver - ] - .join(""); - - // after this there is the permit and because of the deadlines (that depend on block time) - // it's hard to assert - - let expected_swap = String::from(concat!( - // length of swap bytes - "0000000000000000000000000000000000000000000000000000000000000051", - "5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", // executor address - "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id - "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver - "00", // zero2one - "00", // exact out - "0000000000000000000000000000", // padding - )); - let hex_calldata = encode(&calldata); - - assert_eq!(hex_calldata[..456], expected_input); - assert_eq!(hex_calldata[1224..], expected_swap); - } - #[test] fn test_single_swap_strategy_encoder_wrap() { // Performs a single swap from WETH to DAI on a USV2 pool, wrapping ETH @@ -1475,6 +1393,74 @@ mod tests { println!("{}", _hex_calldata); } + #[test] + fn test_sequential_swap_strategy_encoder_complex_route() { + // Note: This test does not assert anything. It is only used to obtain integration test + // data for our router solidity test. + // + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ───(USV2)──> WBTC ───(USV2)──> USDC + + // Set up a mock private key for signing + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = weth(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let swap_weth_wbtc = Swap { + component: ProtocolComponent { + id: "0xBb2b8038a1640196FbE3e38816F3e67Cba72D940".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: wbtc.clone(), + // This represents the remaining 50%, but to avoid any rounding errors we set this to + // 0 to signify "the remainder of the WETH value". It should still be very close to 50% + split: 0f64, + }; + let swap_wbtc_usdc = Swap { + component: ProtocolComponent { + id: "0x004375Dff511095CC5A197A54140a24eFEF3A416".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SequentialSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + Some(private_key), + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + ) + .unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: usdc, + expected_amount: None, + checked_amount: Some(BigUint::from_str("26173932").unwrap()), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let _hex_calldata = encode(&calldata); + println!("{}", _hex_calldata); + } + #[test] fn test_split_encoding_strategy_usv4() { // Performs a sequential swap from USDC to PEPE though ETH using two consecutive USV4 pools @@ -1650,9 +1636,10 @@ mod tests { eth_chain(), swap_encoder_registry, None, + // TODO this should be OPTION Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), ) - .unwrap(); + .unwrap(); let solution = Solution { exact_out: false, given_token: weth, @@ -1663,7 +1650,6 @@ mod tests { checked_amount, sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), swaps: vec![swap], ..Default::default() }; @@ -1736,7 +1722,7 @@ mod tests { None, Some(Bytes::from_str("0x1d1499e622D69689cdf9004d05Ec547d650Ff211").unwrap()), ) - .unwrap(); + .unwrap(); let solution = Solution { exact_out: false, From e77a7bc3e1c2734d5b211bd6237ee6ded8958716 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 3 Apr 2025 23:58:00 +0200 Subject: [PATCH 5/7] test: Fix duplicate test --- foundry/test/TychoRouterIntegration.t.sol | 35 +---------------------- 1 file changed, 1 insertion(+), 34 deletions(-) diff --git a/foundry/test/TychoRouterIntegration.t.sol b/foundry/test/TychoRouterIntegration.t.sol index b87c322..64d77f5 100644 --- a/foundry/test/TychoRouterIntegration.t.sol +++ b/foundry/test/TychoRouterIntegration.t.sol @@ -28,39 +28,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertEq(balancerAfter - balancerBefore, 2659881924818443699787); } - function testSequentialIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - - // Note: This test does not assert anything. It is only used to obtain integration test - // data for our router solidity test. - // - // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools - // - // WETH ───(USV2)──> WBTC ───(USV2)──> USDC - - deal(WETH_ADDR, ALICE, 1 ether); - uint256 balancerBefore = IERC20(DAI_ADDR).balanceOf(ALICE); - - // Approve permit2 - vm.startPrank(ALICE); - IERC20(WETH_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); - // Encoded solution generated using `test_split_swap_strategy_encoder_simple` - // but manually replacing the executor address - // `5c2f5a71f67c01775180adc06909288b4c329308` with the one in this test - // `5615deb798bb3e4dfa0139dfa1b3d433cc23b72f` - (bool success,) = tychoRouterAddr.call( - hex"51bcc7b60000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000681632fc00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067eead0400000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000002600000000000000000000000000000000000000000000000000000000000000041e396cbca5c5cfa3ea8a9ed8b63a4ac3a8b079e4de510d79f656cd660303e69482f89ed071d177b3ac73030619cc15e5ca42a41543551e47d67089437166978a01c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a60051f6c5be66fff9dc69962d73da0a617a827c382329c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d01395000051f6c5be66fff9dc69962d73da0a617a827c3823292260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" - ); - - vm.stopPrank(); - - uint256 balancerAfter = IERC20(DAI_ADDR).balanceOf(ALICE); - - assertTrue(success, "Call Failed"); - assertEq(balancerAfter - balancerBefore, 2659881924818443699787); - } - function testSplitSwapSingleWithoutPermit2Integration() public { // Test created with calldata from our router encoder, replacing the executor // address with the USV2 executor address. @@ -290,7 +257,7 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { // Approve permit2 vm.startPrank(ALICE); IERC20(WETH_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); - // Encoded solution generated using `test_split_swap_strategy_encoder_complex` + // Encoded solution generated using `test_sequential_swap_strategy_encoder_complex_route` (bool success,) = tychoRouterAddr.call( hex"51bcc7b60000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000068168aea00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067ef04f200000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000260000000000000000000000000000000000000000000000000000000000000004154956683effd126a9182e2d82ebd3d778e5283b93d571b13cdbc9dfbf3d9f655057a2332ed566f79bed7514a22ef1c52969132bc71a5a2ef125d78e39ec264511c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a600515615deb798bb3e4dfa0139dfa1b3d433cc23b72fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d013950000515615deb798bb3e4dfa0139dfa1b3d433cc23b72f2260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" ); From 2f81b167d428c8947b795929db2a96010efa9817 Mon Sep 17 00:00:00 2001 From: Diana Carvalho Date: Fri, 4 Apr 2025 15:37:43 +0100 Subject: [PATCH 6/7] fix: Fix executor address in test and remove duplicated test --- don't change below this line --- ENG-4306 Took 12 minutes Took 18 seconds --- .../evm/strategy_encoder/strategy_encoders.rs | 99 ++----------------- 1 file changed, 9 insertions(+), 90 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index b3ed871..14ba38c 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -1059,7 +1059,7 @@ mod tests { Some(BigUint::from_str("2_999_000000000000000000").unwrap()), U256::from_str("2_999_000000000000000000").unwrap(), )] - fn test_sequential_swap_strategy_encoder_simple_route( + fn test_single_swap_strategy_encoder( #[case] expected_amount: Option, #[case] slippage: Option, #[case] checked_amount: Option, @@ -1085,7 +1085,7 @@ mod tests { split: 0f64, }; let swap_encoder_registry = get_swap_encoder_registry(); - let encoder = SequentialSwapStrategyEncoder::new( + let encoder = SingleSwapStrategyEncoder::new( eth_chain(), swap_encoder_registry, Some(private_key), @@ -1111,7 +1111,7 @@ mod tests { .unwrap(); let expected_min_amount_encoded = hex::encode(U256::abi_encode(&expected_min_amount)); let expected_input = [ - "51bcc7b6", // Function selector + "c378044e", // Function selector "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in "0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f", // token out @@ -1125,24 +1125,22 @@ mod tests { // after this there is the permit and because of the deadlines (that depend on block time) // it's hard to assert - let expected_swaps = String::from(concat!( + let expected_swap = String::from(concat!( // length of ple encoded swaps without padding - "0000000000000000000000000000000000000000000000000000000000000053", - // ple encoded swaps - "0051", + "0000000000000000000000000000000000000000000000000000000000000051", // Swap data - "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address + "5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", // executor address "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver "00", // zero2one "00", // exact out - "000000000000000000000000", // padding + "0000000000000000000000000000", // padding )); let hex_calldata = encode(&calldata); assert_eq!(hex_calldata[..456], expected_input); - assert_eq!(hex_calldata[1224..], expected_swaps); + assert_eq!(hex_calldata[1224..], expected_swap); } #[test] @@ -1608,85 +1606,6 @@ mod tests { println!("{}", hex_calldata); } - #[test] - fn test_single_swap_strategy_encoder_no_permit2() { - // Performs a single swap from WETH to DAI on a USV2 pool, without permit2 and no grouping - // optimizations. - - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - - let expected_amount = Some(BigUint::from_str("2_650_000000000000000000").unwrap()); - let slippage = Some(0.01f64); - let checked_amount = Some(BigUint::from_str("2_640_000000000000000000").unwrap()); - let expected_min_amount = U256::from_str("2_640_000000000000000000").unwrap(); - - let swap = Swap { - component: ProtocolComponent { - id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0f64, - }; - let swap_encoder_registry = get_swap_encoder_registry(); - let encoder = SingleSwapStrategyEncoder::new( - eth_chain(), - swap_encoder_registry, - None, - // TODO this should be OPTION - Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), - ) - .unwrap(); - let solution = Solution { - exact_out: false, - given_token: weth, - given_amount: BigUint::from_str("1_000000000000000000").unwrap(), - checked_token: dai, - expected_amount, - slippage, - checked_amount, - sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - swaps: vec![swap], - ..Default::default() - }; - - let (calldata, _) = encoder - .encode_strategy(solution) - .unwrap(); - let expected_min_amount_encoded = hex::encode(U256::abi_encode(&expected_min_amount)); - let expected_input = [ - "0f6cbbe8", // Function selector - "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out - "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f", // token out - &expected_min_amount_encoded, // min amount out - "0000000000000000000000000000000000000000000000000000000000000000", // wrap - "0000000000000000000000000000000000000000000000000000000000000000", // unwrap - "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver - "0000000000000000000000000000000000000000000000000000000000000100", // offset of swap bytes - "0000000000000000000000000000000000000000000000000000000000000051", // length of swap bytes without padding - - // Swap data - "f6c5be66fff9dc69962d73da0a617a827c382329", // executor address - "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id - "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver - "00", // zero2one - "00", // exact out - "0000000000000000000000000000", // padding - ] - .join(""); - - let hex_calldata = encode(&calldata); - - assert_eq!(hex_calldata, expected_input); - println!("{}", hex_calldata); - } - #[test] fn test_split_encoding_strategy_ekubo() { // ETH ──(EKUBO)──> USDC @@ -1748,7 +1667,7 @@ mod tests { } #[test] - fn test_split_swap_strategy_encoder_simple_route_no_permit2() { + fn test_single_swap_strategy_encoder_no_permit2() { // Performs a single swap from WETH to DAI on a USV2 pool, without permit2 and no grouping // optimizations. From 6e7bf3c019c32d586d406bf420daa0080dcda1fd Mon Sep 17 00:00:00 2001 From: Diana Carvalho Date: Fri, 4 Apr 2025 16:48:20 +0100 Subject: [PATCH 7/7] feat: Add SequentialSwap integration test with regular approvals Improve docstrings --- don't change below this line --- ENG-4306 Took 20 minutes --- foundry/test/TychoRouterIntegration.t.sol | 48 ++++++------ .../evm/strategy_encoder/strategy_encoders.rs | 74 +++++++++++++++++-- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/foundry/test/TychoRouterIntegration.t.sol b/foundry/test/TychoRouterIntegration.t.sol index 64d77f5..85ade39 100644 --- a/foundry/test/TychoRouterIntegration.t.sol +++ b/foundry/test/TychoRouterIntegration.t.sol @@ -5,9 +5,6 @@ import "./TychoRouterTestSetup.sol"; contract TychoRouterTestIntegration is TychoRouterTestSetup { function testSplitSwapSingleIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping WETH -> DAI on a USV2 pool deal(WETH_ADDR, ALICE, 1 ether); uint256 balancerBefore = IERC20(DAI_ADDR).balanceOf(ALICE); @@ -29,9 +26,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { } function testSplitSwapSingleWithoutPermit2Integration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping WETH -> DAI on a USV2 pool without permit2 deal(WETH_ADDR, ALICE, 1 ether); vm.startPrank(ALICE); @@ -127,9 +121,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { } function testSplitSwapSingleWithWrapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping WETH -> DAI on a USV2 pool, but ETH is received from the user // and wrapped before the swap deal(ALICE, 1 ether); @@ -151,9 +142,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { } function testSplitSwapSingleWithUnwrapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping DAI -> WETH on a USV2 pool, and WETH is unwrapped to ETH // before sending back to the user deal(DAI_ADDR, ALICE, 3000 ether); @@ -206,16 +194,10 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertTrue(success, "Call Failed"); assertGe(balancerAfter - balancerBefore, 26173932); - - // All input tokens are transferred to the router at first. Make sure we used - // all of it (and thus our splits are correct). assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } function testSplitSwapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools // // ┌──(USV2)──> WBTC ───(USV2)──> USDC @@ -244,10 +226,7 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } - function testSequentialSwapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - + function testSequentialSwapIntegrationPermit2() public { // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools // // WETH ──(USV2)──> WBTC ───(USV2)──> USDC @@ -268,9 +247,30 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertTrue(success, "Call Failed"); assertEq(balancerAfter - balancerBefore, 2552915143); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } - // All input tokens are transferred to the router at first. Make sure we used - // all of it (and thus our splits are correct). + function testSequentialSwapIntegration() public { + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ──(USV2)──> WBTC ───(USV2)──> USDC + deal(WETH_ADDR, ALICE, 1 ether); + uint256 balancerBefore = IERC20(USDC_ADDR).balanceOf(ALICE); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(tychoRouterAddr, type(uint256).max); + // Encoded solution generated using `test_sequential_swap_strategy_encoder_no_permit2` + (bool success,) = tychoRouterAddr.call( + hex"e8a980d70000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000a600515615deb798bb3e4dfa0139dfa1b3d433cc23b72fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d013950000515615deb798bb3e4dfa0139dfa1b3d433cc23b72f2260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" + ); + + vm.stopPrank(); + + uint256 balancerAfter = IERC20(USDC_ADDR).balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertEq(balancerAfter - balancerBefore, 2552915143); assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 14ba38c..adffe4d 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -83,7 +83,7 @@ impl SingleSwapStrategyEncoder { Ok(Self { permit2, selector, swap_encoder_registry, router_address }) } - /// Encodes information necessary for performing a single swap against a given executor for + /// Encodes information necessary for performing a single hop against a given executor for /// a protocol. fn encode_swap_header(&self, executor_address: Bytes, protocol_data: Vec) -> Vec { let mut encoded = Vec::new(); @@ -202,7 +202,7 @@ impl StrategyEncoder for SingleSwapStrategyEncoder { } } -/// Represents the encoder for a swap strategy which supports single swaps. +/// Represents the encoder for a swap strategy which supports sequential swaps. /// /// # Fields /// * `swap_encoder_registry`: SwapEncoderRegistry, containing all possible swap encoders @@ -253,7 +253,7 @@ impl SequentialSwapStrategyEncoder { }) } - /// Encodes information necessary for performing a single swap against a given executor for + /// Encodes information necessary for performing a single hop against a given executor for /// a protocol. fn encode_swap_header(&self, executor_address: Bytes, protocol_data: Vec) -> Vec { let mut encoded = Vec::new(); @@ -373,7 +373,7 @@ impl StrategyEncoder for SequentialSwapStrategyEncoder { } } -/// Represents the encoder for a swap strategy which supports single, sequential and split swaps. +/// Represents the encoder for a swap strategy which supports split swaps. /// /// # Fields /// * `swap_encoder_registry`: SwapEncoderRegistry, containing all possible swap encoders @@ -439,7 +439,7 @@ impl SplitSwapStrategyEncoder { }) } - /// Encodes information necessary for performing a single swap against a given executor for + /// Encodes information necessary for performing a single hop against a given executor for /// a protocol as part of a split swap solution. fn encode_swap_header( &self, @@ -1416,8 +1416,6 @@ mod tests { }, token_in: weth.clone(), token_out: wbtc.clone(), - // This represents the remaining 50%, but to avoid any rounding errors we set this to - // 0 to signify "the remainder of the WETH value". It should still be very close to 50% split: 0f64, }; let swap_wbtc_usdc = Swap { @@ -1459,6 +1457,68 @@ mod tests { println!("{}", _hex_calldata); } + #[test] + fn test_sequential_swap_strategy_encoder_no_permit2() { + // Note: This test does not assert anything. It is only used to obtain integration test + // data for our router solidity test. + // + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ───(USV2)──> WBTC ───(USV2)──> USDC + + let weth = weth(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let swap_weth_wbtc = Swap { + component: ProtocolComponent { + id: "0xBb2b8038a1640196FbE3e38816F3e67Cba72D940".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: wbtc.clone(), + split: 0f64, + }; + let swap_wbtc_usdc = Swap { + component: ProtocolComponent { + id: "0x004375Dff511095CC5A197A54140a24eFEF3A416".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SequentialSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + None, + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + ) + .unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: usdc, + expected_amount: None, + checked_amount: Some(BigUint::from_str("26173932").unwrap()), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let _hex_calldata = encode(&calldata); + println!("{}", _hex_calldata); + } + #[test] fn test_split_encoding_strategy_usv4() { // Performs a sequential swap from USDC to PEPE though ETH using two consecutive USV4 pools