From f32210bb1f6103a1775975604415295260de9107 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 13 Feb 2025 01:58:34 -0500 Subject: [PATCH 1/9] feat: (WIP) UniswapV4 encoding - To keep any knowledge of USV4 separate from regular splits, I've made a new USV4 encoding strategy that will be used only if we detect sequential USV4 swaps. - For single USV4 swaps without necessary optimizations, the regular split swap strategy can be used - No need to change the swap struct interface to take multiple swaps - this concatenation can be done at the swap strategy level. TODO: - test - deduplicate code from split strategy - UniswapV4SwapEncoder --- .../evm/strategy_encoder/strategy_encoders.rs | 393 ++++++++++++++++-- src/encoding/evm/utils.rs | 43 +- 2 files changed, 399 insertions(+), 37 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 4e8193f..356b84c 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -1,12 +1,10 @@ use std::{ - cmp::max, collections::{HashMap, HashSet, VecDeque}, str::FromStr, }; use alloy_primitives::{aliases::U24, FixedBytes, U256, U8}; use alloy_sol_types::SolValue; -use num_bigint::BigUint; use tycho_core::{keccak256, Bytes}; use crate::encoding::{ @@ -14,7 +12,10 @@ use crate::encoding::{ evm::{ approvals::permit2::Permit2, swap_encoder::swap_encoder_registry::SwapEncoderRegistry, - utils::{biguint_to_u256, bytes_to_address, encode_input, percentage_to_uint24}, + utils::{ + biguint_to_u256, bytes_to_address, encode_input, get_min_amount_for_solution, + get_token_position, percentage_to_uint24, + }, }, models::{Chain, EncodingContext, NativeAction, Solution, Swap}, strategy_encoder::StrategyEncoder, @@ -258,6 +259,357 @@ impl SplitSwapStrategyEncoder { } } +/// To be used if there are two or more UniswapV4 swaps consecutively. They can be combined as a +/// gas optimization. +#[derive(Clone)] +pub struct UniswapV4StrategyEncoder { + swap_encoder_registry: SwapEncoderRegistry, + permit2: Permit2, + selector: String, + native_address: Bytes, + wrapped_address: Bytes, +} + +impl EVMStrategyEncoder for UniswapV4StrategyEncoder {} + +impl StrategyEncoder for UniswapV4StrategyEncoder { + fn encode_strategy( + &self, + solution: Solution, + ) -> Result<(Vec, Bytes, Option), EncodingError> { + self.validate_split_percentages(&solution.swaps)?; + self.validate_swap_path( + &solution.swaps, + &solution.given_token, + &solution.checked_token, + &solution.native_action, + )?; + let (permit, signature) = self.permit2.get_permit( + &solution.router_address, + &solution.sender, + &solution.given_token, + &solution.given_amount, + )?; + let min_amount_out = get_min_amount_for_solution(solution.clone()); + + // The tokens array is composed of the given token, the checked token and all the + // intermediary tokens in between. The contract expects the tokens to be in this order. + let solution_tokens: HashSet = + vec![solution.given_token.clone(), solution.checked_token.clone()] + .into_iter() + .collect(); + + let intermediary_tokens: HashSet = solution + .swaps + .iter() + .flat_map(|swap| vec![swap.token_in.clone(), swap.token_out.clone()]) + .collect(); + let mut intermediary_tokens: Vec = intermediary_tokens + .difference(&solution_tokens) + .cloned() + .collect(); + // this is only to make the test deterministic (same index for the same token for different + // runs) + intermediary_tokens.sort(); + + 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 tokens = Vec::with_capacity(2 + intermediary_tokens.len()); + if wrap { + tokens.push(self.wrapped_address.clone()); + } else { + tokens.push(solution.given_token.clone()); + } + tokens.extend(intermediary_tokens); + + if unwrap { + tokens.push(self.wrapped_address.clone()); + } else { + tokens.push(solution.checked_token.clone()); + } + + let mut swaps = vec![]; + + let mut previous_protocol_data: Vec = vec![]; + let mut first_usv4_in_token: Bytes = Bytes::default(); + let mut last_swap_was_usv4 = false; + + for swap in solution.swaps.iter() { + let swap_encoder = self + .get_swap_encoder(&swap.component.protocol_system) + .ok_or_else(|| { + EncodingError::InvalidInput(format!( + "Swap encoder not found for protocol: {}", + swap.component.protocol_system + )) + })?; + + let current_swap_is_usv4 = swap.component.protocol_system == "uniswap_v4"; + let encoding_context = EncodingContext { + receiver: solution.router_address.clone(), + exact_out: solution.exact_out, + router_address: solution.router_address.clone(), + }; + let mut protocol_data = swap_encoder.encode_swap(swap.clone(), encoding_context)?; + let in_token; + + if current_swap_is_usv4 { + if !last_swap_was_usv4 { + // This is the first usv4 swap of a potential sequence. Store the input token + first_usv4_in_token = swap.clone().token_in; + } else { + // This is the second or later usv4 swap of a sequence. Concatenate the protocol + // data with the previous swap's protocol data + protocol_data = + [previous_protocol_data.clone(), protocol_data.clone()].concat(); + } + in_token = first_usv4_in_token.clone(); + previous_protocol_data = protocol_data.clone(); + } else { + in_token = swap.clone().token_in; + // This is not a USV4 swap. Clear previous USV4 protocol data. + previous_protocol_data = vec![]; + } + + // This is the hardest part - we will need to have the input token be the first of the + // USV4 sequence, and the output token be the last, essentially removing + // intermediate tokens and pretending they don't exist... I think? + let swap_data = self.encode_swap_header( + get_token_position(tokens.clone(), in_token)?, + get_token_position(tokens.clone(), swap.clone().token_out)?, + percentage_to_uint24(swap.split), + Bytes::from_str(swap_encoder.executor_address()).map_err(|_| { + EncodingError::FatalError("Invalid executor address".to_string()) + })?, + self.encode_executor_selector(swap_encoder.executor_selector()), + protocol_data, + ); + + // If the last swap was usv4, and this swap is also usv4, replace the last swap_data + // with the updated swap_data, which will contain both swaps, along with the + // proper input and output tokens + if last_swap_was_usv4 && current_swap_is_usv4 { + let swaps_len = swaps.len() - 1; + swaps[swaps_len] = swap_data; + } else { + swaps.push(swap_data); + } + last_swap_was_usv4 = current_swap_is_usv4; + } + + let encoded_swaps = self.ple_encode(swaps); + let method_calldata = ( + 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, + U256::from(tokens.len()), + bytes_to_address(&solution.receiver)?, + permit, + signature.as_bytes().to_vec(), + encoded_swaps, + ) + .abi_encode(); + + let contract_interaction = encode_input(&self.selector, method_calldata); + Ok((contract_interaction, solution.router_address, None)) + } + + 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()) + } +} + +impl UniswapV4StrategyEncoder { + #[allow(dead_code)] + pub fn new( + signer_pk: String, + chain: Chain, + swap_encoder_registry: SwapEncoderRegistry, + ) -> Result { + let selector = "swap(uint256,address,address,uint256,bool,bool,uint256,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string(); + Ok(Self { + permit2: Permit2::new(signer_pk, chain.clone())?, + selector, + swap_encoder_registry, + native_address: chain.native_token()?, + wrapped_address: chain.wrapped_token()?, + }) + } + + /// 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 + 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(()) + } + + /// Raises an error if swaps do not represent a valid path from the given token to the checked + /// token. + /// + /// A path is considered valid if all the following conditions are met: + /// * The checked token is reachable from the given token through the swap path + /// * There are no tokens which are unconnected from the main path + /// + /// 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. + fn validate_swap_path( + &self, + swaps: &[Swap], + given_token: &Bytes, + checked_token: &Bytes, + native_action: &Option, + ) -> Result<(), EncodingError> { + // Convert ETH to WETH only if there's a corresponding wrap/unwrap action + let given_token = if *given_token == *self.native_address { + match native_action { + Some(NativeAction::Wrap) => &self.wrapped_address, + _ => given_token, + } + } else { + given_token + }; + + let checked_token = if *checked_token == *self.native_address { + match native_action { + Some(NativeAction::Unwrap) => &self.wrapped_address, + _ => checked_token, + } + } else { + checked_token + }; + + // Build directed graph of token flows + let mut graph: HashMap<&Bytes, HashSet<&Bytes>> = HashMap::new(); + for swap in swaps { + graph + .entry(&swap.token_in) + .or_default() + .insert(&swap.token_out); + } + + // BFS from validation_given + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(given_token); + + while let Some(token) = queue.pop_front() { + if !visited.insert(token) { + continue; + } + + // Early success check + if token == checked_token && visited.len() == graph.len() + 1 { + return Ok(()); + } + + if let Some(next_tokens) = graph.get(token) { + for &next_token in next_tokens { + if !visited.contains(next_token) { + queue.push_back(next_token); + } + } + } + } + + // If we get here, either checked_token wasn't reached or not all tokens were visited + if !visited.contains(checked_token) { + Err(EncodingError::InvalidInput( + "Checked token is not reachable through swap path".to_string(), + )) + } else { + Err(EncodingError::InvalidInput( + "Some tokens are not connected to the main path".to_string(), + )) + } + } +} + impl EVMStrategyEncoder for SplitSwapStrategyEncoder {} impl StrategyEncoder for SplitSwapStrategyEncoder { @@ -278,19 +630,8 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { &solution.given_token, &solution.given_amount, )?; - let mut min_amount_out = solution - .checked_amount - .unwrap_or(BigUint::ZERO); + let min_amount_out = get_min_amount_for_solution(solution.clone()); - if let (Some(expected_amount), Some(slippage)) = - (solution.expected_amount.as_ref(), solution.slippage) - { - let one_hundred = BigUint::from(100u32); - let slippage_percent = BigUint::from((slippage * 100.0) as u32); - let multiplier = &one_hundred - slippage_percent; - let expected_amount_with_slippage = (expected_amount * &multiplier) / &one_hundred; - min_amount_out = max(min_amount_out, expected_amount_with_slippage); - } // The tokens array is composed of the given token, the checked token and all the // intermediary tokens in between. The contract expects the tokens to be in this order. let solution_tokens: HashSet = @@ -351,26 +692,8 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { }; let protocol_data = swap_encoder.encode_swap(swap.clone(), encoding_context)?; let swap_data = self.encode_swap_header( - U8::from( - tokens - .iter() - .position(|t| *t == swap.token_in) - .ok_or_else(|| { - EncodingError::InvalidInput( - "In token not found in tokens array".to_string(), - ) - })?, - ), - U8::from( - tokens - .iter() - .position(|t| *t == swap.token_out) - .ok_or_else(|| { - EncodingError::InvalidInput( - "Out token not found in tokens array".to_string(), - ) - })?, - ), + get_token_position(tokens.clone(), swap.token_in.clone())?, + get_token_position(tokens.clone(), swap.token_out.clone())?, percentage_to_uint24(swap.split), Bytes::from_str(swap_encoder.executor_address()).map_err(|_| { EncodingError::FatalError("Invalid executor address".to_string()) diff --git a/src/encoding/evm/utils.rs b/src/encoding/evm/utils.rs index 5d3a70a..d4017f6 100644 --- a/src/encoding/evm/utils.rs +++ b/src/encoding/evm/utils.rs @@ -1,8 +1,10 @@ -use alloy_primitives::{aliases::U24, Address, Keccak256, U256}; +use std::cmp::max; + +use alloy_primitives::{aliases::U24, Address, Keccak256, U256, U8}; use num_bigint::BigUint; use tycho_core::Bytes; -use crate::encoding::errors::EncodingError; +use crate::encoding::{errors::EncodingError, models::Solution}; /// Safely converts a `Bytes` object to an `Address` object. /// @@ -52,3 +54,40 @@ pub fn percentage_to_uint24(decimal: f64) -> U24 { let scaled = (decimal / 1.0) * (MAX_UINT24 as f64); U24::from(scaled.round()) } + +/// Gets the minimum amount out for a solution to pass when executed on-chain. +/// +/// The minimum amount is calculated based on the expected amount and the slippage percentage, if +/// passed. If this information is not passed, the user-passed checked amount will be used. +/// If both the slippage and minimum user-passed checked amount are passed, the maximum of the two +/// will be used. +/// If neither are passed, the minimum amount will be zero. +pub fn get_min_amount_for_solution(solution: Solution) -> BigUint { + let mut min_amount_out = solution + .checked_amount + .unwrap_or(BigUint::ZERO); + + if let (Some(expected_amount), Some(slippage)) = + (solution.expected_amount.as_ref(), solution.slippage) + { + let one_hundred = BigUint::from(100u32); + let slippage_percent = BigUint::from((slippage * 100.0) as u32); + let multiplier = &one_hundred - slippage_percent; + let expected_amount_with_slippage = (expected_amount * &multiplier) / &one_hundred; + min_amount_out = max(min_amount_out, expected_amount_with_slippage); + } + min_amount_out +} + +/// Gets the position of a token in a list of tokens. +pub fn get_token_position(tokens: Vec, token: Bytes) -> Result { + let position = U8::from( + tokens + .iter() + .position(|t| *t == token) + .ok_or_else(|| { + EncodingError::InvalidInput(format!("Token {:?} not found in tokens array", token)) + })?, + ); + Ok(position) +} From b452372714adb70a6b9cf940327a337cb55d01f2 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 13 Feb 2025 15:30:09 -0500 Subject: [PATCH 2/9] refactor: deduplicate validation for Split & USV4 strategy We use the same validation for both SplitSwapStrategy and UniswapV4Strategy - so, instead, put the validation into a Validator trait that gets initialized in both swap strategies in order to avoid duplication. --- .../evm/strategy_encoder/strategy_encoders.rs | 323 ++++++------------ 1 file changed, 98 insertions(+), 225 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 356b84c..3023177 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -81,24 +81,14 @@ pub struct SplitSwapStrategyEncoder { selector: String, native_address: Bytes, wrapped_address: Bytes, + split_swap_validator: SplitSwapValidator, } -impl SplitSwapStrategyEncoder { - pub fn new( - signer_pk: String, - chain: Chain, - swap_encoder_registry: SwapEncoderRegistry, - ) -> Result { - let selector = "swap(uint256,address,address,uint256,bool,bool,uint256,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string(); - Ok(Self { - permit2: Permit2::new(signer_pk, chain.clone())?, - selector, - swap_encoder_registry, - native_address: chain.native_token()?, - wrapped_address: chain.wrapped_token()?, - }) - } +/// 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: @@ -193,20 +183,22 @@ impl SplitSwapStrategyEncoder { given_token: &Bytes, checked_token: &Bytes, native_action: &Option, + native_address: &Bytes, + wrapped_address: &Bytes, ) -> Result<(), EncodingError> { // Convert ETH to WETH only if there's a corresponding wrap/unwrap action - let given_token = if *given_token == *self.native_address { + let given_token = if *given_token == *native_address { match native_action { - Some(NativeAction::Wrap) => &self.wrapped_address, + Some(NativeAction::Wrap) => wrapped_address, _ => given_token, } } else { given_token }; - let checked_token = if *checked_token == *self.native_address { + let checked_token = if *checked_token == *native_address { match native_action { - Some(NativeAction::Unwrap) => &self.wrapped_address, + Some(NativeAction::Unwrap) => wrapped_address, _ => checked_token, } } else { @@ -259,6 +251,24 @@ impl SplitSwapStrategyEncoder { } } +impl SplitSwapStrategyEncoder { + pub fn new( + signer_pk: String, + chain: Chain, + swap_encoder_registry: SwapEncoderRegistry, + ) -> Result { + let selector = "swap(uint256,address,address,uint256,bool,bool,uint256,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string(); + Ok(Self { + permit2: Permit2::new(signer_pk, chain.clone())?, + selector, + swap_encoder_registry, + native_address: chain.native_token()?, + wrapped_address: chain.wrapped_token()?, + split_swap_validator: SplitSwapValidator, + }) + } +} + /// To be used if there are two or more UniswapV4 swaps consecutively. They can be combined as a /// gas optimization. #[derive(Clone)] @@ -268,6 +278,7 @@ pub struct UniswapV4StrategyEncoder { selector: String, native_address: Bytes, wrapped_address: Bytes, + split_swap_validator: SplitSwapValidator, } impl EVMStrategyEncoder for UniswapV4StrategyEncoder {} @@ -277,13 +288,17 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { &self, solution: Solution, ) -> Result<(Vec, Bytes, Option), EncodingError> { - self.validate_split_percentages(&solution.swaps)?; - self.validate_swap_path( - &solution.swaps, - &solution.given_token, - &solution.checked_token, - &solution.native_action, - )?; + self.split_swap_validator + .validate_split_percentages(&solution.swaps)?; + self.split_swap_validator + .validate_swap_path( + &solution.swaps, + &solution.given_token, + &solution.checked_token, + &solution.native_action, + &self.native_address, + &self.wrapped_address, + )?; let (permit, signature) = self.permit2.get_permit( &solution.router_address, &solution.sender, @@ -447,167 +462,9 @@ impl UniswapV4StrategyEncoder { swap_encoder_registry, native_address: chain.native_token()?, wrapped_address: chain.wrapped_token()?, + split_swap_validator: 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 - 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(()) - } - - /// Raises an error if swaps do not represent a valid path from the given token to the checked - /// token. - /// - /// A path is considered valid if all the following conditions are met: - /// * The checked token is reachable from the given token through the swap path - /// * There are no tokens which are unconnected from the main path - /// - /// 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. - fn validate_swap_path( - &self, - swaps: &[Swap], - given_token: &Bytes, - checked_token: &Bytes, - native_action: &Option, - ) -> Result<(), EncodingError> { - // Convert ETH to WETH only if there's a corresponding wrap/unwrap action - let given_token = if *given_token == *self.native_address { - match native_action { - Some(NativeAction::Wrap) => &self.wrapped_address, - _ => given_token, - } - } else { - given_token - }; - - let checked_token = if *checked_token == *self.native_address { - match native_action { - Some(NativeAction::Unwrap) => &self.wrapped_address, - _ => checked_token, - } - } else { - checked_token - }; - - // Build directed graph of token flows - let mut graph: HashMap<&Bytes, HashSet<&Bytes>> = HashMap::new(); - for swap in swaps { - graph - .entry(&swap.token_in) - .or_default() - .insert(&swap.token_out); - } - - // BFS from validation_given - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - queue.push_back(given_token); - - while let Some(token) = queue.pop_front() { - if !visited.insert(token) { - continue; - } - - // Early success check - if token == checked_token && visited.len() == graph.len() + 1 { - return Ok(()); - } - - if let Some(next_tokens) = graph.get(token) { - for &next_token in next_tokens { - if !visited.contains(next_token) { - queue.push_back(next_token); - } - } - } - } - - // If we get here, either checked_token wasn't reached or not all tokens were visited - if !visited.contains(checked_token) { - Err(EncodingError::InvalidInput( - "Checked token is not reachable through swap path".to_string(), - )) - } else { - Err(EncodingError::InvalidInput( - "Some tokens are not connected to the main path".to_string(), - )) - } - } } impl EVMStrategyEncoder for SplitSwapStrategyEncoder {} @@ -617,13 +474,17 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { &self, solution: Solution, ) -> Result<(Vec, Bytes, Option), EncodingError> { - self.validate_split_percentages(&solution.swaps)?; - self.validate_swap_path( - &solution.swaps, - &solution.given_token, - &solution.checked_token, - &solution.native_action, - )?; + self.split_swap_validator + .validate_split_percentages(&solution.swaps)?; + self.split_swap_validator + .validate_swap_path( + &solution.swaps, + &solution.given_token, + &solution.checked_token, + &solution.native_action, + &self.native_address, + &self.wrapped_address, + )?; let (permit, signature) = self.permit2.get_permit( &solution.router_address, &solution.sender, @@ -1204,16 +1065,10 @@ mod tests { println!("{}", _hex_calldata); } - fn get_mock_split_swap_strategy_encoder() -> SplitSwapStrategyEncoder { - let private_key = - "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); - let swap_encoder_registry = get_swap_encoder_registry(); - SplitSwapStrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap() - } - #[test] fn test_validate_path_single_swap() { - let encoder = get_mock_split_swap_strategy_encoder(); + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); let swaps = vec![Swap { @@ -1226,13 +1081,14 @@ mod tests { token_out: dai.clone(), split: 0f64, }]; - let result = encoder.validate_swap_path(&swaps, &weth, &dai, &None); + let result = validator.validate_swap_path(&swaps, &weth, &dai, &None, ð, &weth); assert_eq!(result, Ok(())); } #[test] fn test_validate_path_multiple_swaps() { - let encoder = get_mock_split_swap_strategy_encoder(); + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); @@ -1258,13 +1114,14 @@ mod tests { split: 0f64, }, ]; - let result = encoder.validate_swap_path(&swaps, &weth, &usdc, &None); + let result = validator.validate_swap_path(&swaps, &weth, &usdc, &None, ð, &weth); assert_eq!(result, Ok(())); } #[test] fn test_validate_path_disconnected() { - let encoder = get_mock_split_swap_strategy_encoder(); + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); @@ -1293,7 +1150,8 @@ mod tests { split: 0.0, }, ]; - let result = encoder.validate_swap_path(&disconnected_swaps, &weth, &usdc, &None); + let result = + validator.validate_swap_path(&disconnected_swaps, &weth, &usdc, &None, ð, &weth); assert!(matches!( result, Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") @@ -1302,7 +1160,8 @@ mod tests { #[test] fn test_validate_path_unreachable_checked_token() { - let encoder = get_mock_split_swap_strategy_encoder(); + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); @@ -1317,7 +1176,8 @@ mod tests { token_out: dai.clone(), split: 1.0, }]; - let result = encoder.validate_swap_path(&unreachable_swaps, &weth, &usdc, &None); + let result = + validator.validate_swap_path(&unreachable_swaps, &weth, &usdc, &None, ð, &weth); assert!(matches!( result, Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") @@ -1326,12 +1186,13 @@ mod tests { #[test] fn test_validate_path_empty_swaps() { - let encoder = get_mock_split_swap_strategy_encoder(); + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); let empty_swaps: Vec = vec![]; - let result = encoder.validate_swap_path(&empty_swaps, &weth, &usdc, &None); + let result = validator.validate_swap_path(&empty_swaps, &weth, &usdc, &None, ð, &weth); assert!(matches!( result, Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") @@ -1340,7 +1201,7 @@ mod tests { #[test] fn test_validate_swap_single() { - let encoder = get_mock_split_swap_strategy_encoder(); + let validator = SplitSwapValidator; let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); let swaps = vec![Swap { @@ -1353,12 +1214,13 @@ mod tests { token_out: dai.clone(), split: 0f64, }]; - let result = encoder.validate_split_percentages(&swaps); + let result = validator.validate_split_percentages(&swaps); assert_eq!(result, Ok(())); } #[test] fn test_validate_swaps_multiple() { + let validator = SplitSwapValidator; let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); @@ -1395,14 +1257,14 @@ mod tests { split: 0.0, // Remainder (20%) }, ]; - let encoder = get_mock_split_swap_strategy_encoder(); - assert!(encoder + assert!(validator .validate_split_percentages(&valid_swaps) .is_ok()); } #[test] fn test_validate_swaps_no_remainder_split() { + let validator = SplitSwapValidator; let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); @@ -1428,15 +1290,15 @@ mod tests { split: 0.3, }, ]; - let encoder = get_mock_split_swap_strategy_encoder(); assert!(matches!( - encoder.validate_split_percentages(&invalid_total_swaps), + validator.validate_split_percentages(&invalid_total_swaps), Err(EncodingError::InvalidInput(msg)) if msg.contains("must have exactly one 0% split") )); } #[test] fn test_validate_swaps_zero_split_not_at_end() { + let validator = SplitSwapValidator; let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); @@ -1462,15 +1324,15 @@ mod tests { split: 0.5, }, ]; - let encoder = get_mock_split_swap_strategy_encoder(); assert!(matches!( - encoder.validate_split_percentages(&invalid_zero_position_swaps), + validator.validate_split_percentages(&invalid_zero_position_swaps), Err(EncodingError::InvalidInput(msg)) if msg.contains("must be the last swap") )); } #[test] fn test_validate_swaps_splits_exceed_hundred_percent() { + let validator = SplitSwapValidator; let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); @@ -1506,17 +1368,15 @@ mod tests { split: 0.0, }, ]; - let encoder = get_mock_split_swap_strategy_encoder(); assert!(matches!( - encoder.validate_split_percentages(&invalid_overflow_swaps), + validator.validate_split_percentages(&invalid_overflow_swaps), Err(EncodingError::InvalidInput(msg)) if msg.contains("must be <100%") )); } #[test] fn test_validate_path_wrap_eth_given_token() { - let encoder = get_mock_split_swap_strategy_encoder(); - + let validator = SplitSwapValidator; let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(); @@ -1532,14 +1392,20 @@ mod tests { split: 0f64, }]; - let result = encoder.validate_swap_path(&swaps, ð, &usdc, &Some(NativeAction::Wrap)); + let result = validator.validate_swap_path( + &swaps, + ð, + &usdc, + &Some(NativeAction::Wrap), + ð, + &weth, + ); assert_eq!(result, Ok(())); } #[test] fn test_validate_token_path_connectivity_wrap_eth_checked_token() { - let encoder = get_mock_split_swap_strategy_encoder(); - + let validator = SplitSwapValidator; let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(); @@ -1555,7 +1421,14 @@ mod tests { split: 0f64, }]; - let result = encoder.validate_swap_path(&swaps, &usdc, ð, &Some(NativeAction::Unwrap)); + let result = validator.validate_swap_path( + &swaps, + &usdc, + ð, + &Some(NativeAction::Unwrap), + ð, + &weth, + ); assert_eq!(result, Ok(())); } } From 556af0253d9df8ad853e1b8ea1cd286e11a62409 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Thu, 13 Feb 2025 17:23:05 -0500 Subject: [PATCH 3/9] test: Add test for USV4 sequential swap --- src/encoding/config/executor_addresses.json | 1 + .../evm/strategy_encoder/strategy_encoders.rs | 125 +++++++++++++++++- src/encoding/evm/swap_encoder/builder.rs | 2 + 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/src/encoding/config/executor_addresses.json b/src/encoding/config/executor_addresses.json index 32c9e3e..03430bf 100644 --- a/src/encoding/config/executor_addresses.json +++ b/src/encoding/config/executor_addresses.json @@ -2,6 +2,7 @@ "ethereum": { "uniswap_v2": "0x5C2F5a71f67c01775180ADc06909288B4C329308", "uniswap_v3": "0x5C2F5a71f67c01775180ADc06909288B4C329308", + "uniswap_v4": "0x5C2F5a71f67c01775180ADc06909288B4C329308", "vm:balancer_v2": "0x543778987b293C7E8Cf0722BB2e935ba6f4068D4" } } \ No newline at end of file diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 3023177..c5b3b80 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -354,6 +354,7 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { let mut previous_protocol_data: Vec = vec![]; let mut first_usv4_in_token: Bytes = Bytes::default(); let mut last_swap_was_usv4 = false; + println!("Hello?"); for swap in solution.swaps.iter() { let swap_encoder = self @@ -373,7 +374,7 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { }; let mut protocol_data = swap_encoder.encode_swap(swap.clone(), encoding_context)?; let in_token; - + println!("Hello?"); if current_swap_is_usv4 { if !last_swap_was_usv4 { // This is the first usv4 swap of a potential sequence. Store the input token @@ -383,6 +384,8 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { // data with the previous swap's protocol data protocol_data = [previous_protocol_data.clone(), protocol_data.clone()].concat(); + println!("Previous protocol data{}", hex::encode(&previous_protocol_data)); + println!("Current protocol data{}", hex::encode(&protocol_data)); } in_token = first_usv4_in_token.clone(); previous_protocol_data = protocol_data.clone(); @@ -1065,6 +1068,126 @@ mod tests { println!("{}", _hex_calldata); } + #[test] + fn test_usv4_encoding_strategy() { + // Performs a split swap from WETH to USDC though WBTC using two consecutive USV4 pools + // + // WETH ──(USV2)──> WBTC ───(USV4)──> 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_v4".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: "0xAE461cA67B15dc8dc81CE7615e0320dA1A9aB8D5".to_string(), + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = + UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: usdc, + expected_amount: Some(BigUint::from_str("3_000_000000").unwrap()), + checked_amount: None, + slippage: None, + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + ..Default::default() + }; + + let (calldata, _, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let expected_input = [ + "4860f9ed", // Function selector + "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out + "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token out + "0000000000000000000000000000000000000000000000000000000000000000", // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap + "0000000000000000000000000000000000000000000000000000000000000003", // tokens length + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + ] + .join(""); + + // after this there is the permit and because of the deadlines (that depend on block time) + // it's hard to assert + // "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + // "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount in + // "0000000000000000000000000000000000000000000000000000000067c205fe", // expiration + // "0000000000000000000000000000000000000000000000000000000000000000", // nonce + // "0000000000000000000000002c6a3cd97c6283b95ac8c5a4459ebb0d5fd404f4", // spender + // "00000000000000000000000000000000000000000000000000000000679a8006", // deadline + // offset of signature (from start of call data to beginning of length indication) + // "0000000000000000000000000000000000000000000000000000000000000200", + // offset of ple encoded swaps (from start of call data to beginning of length indication) + // "0000000000000000000000000000000000000000000000000000000000000280", + // length of signature without padding + // "0000000000000000000000000000000000000000000000000000000000000041", + // signature + padding + // "a031b63a01ef5d25975663e5d6c420ef498e3a5968b593cdf846c6729a788186", + // "1ddaf79c51453cd501d321ee541d13593e3a266be44103eefdf6e76a032d2870", + // "1b00000000000000000000000000000000000000000000000000000000000000" + + let expected_swaps = String::from(concat!( + // length of ple encoded swaps without padding + "0000000000000000000000000000000000000000000000000000000000000099", + // ple encoded swaps + "0097", // Swap header + "00", // token in index + "02", // token out index + "000000", // split + // Swap data header + "5c2f5a71f67c01775180adc06909288b4c329308", // executor address + "bd0625ab", // selector + // First swap protocol data + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "bb2b8038a1640196fbe3e38816f3e67cba72d940", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "00", // zero2one + // Second swap protocol data + "2260fac5e5542a773aa44fbcfedf7c193bc2c599", // token in + "ae461ca67b15dc8dc81ce7615e0320da1a9ab8d5", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "01", // zero2one + "00000000000000", // padding + )); + let hex_calldata = encode(&calldata); + + assert_eq!(hex_calldata[..520], expected_input); + assert_eq!(hex_calldata[1288..], expected_swaps); + } + #[test] fn test_validate_path_single_swap() { let validator = SplitSwapValidator; diff --git a/src/encoding/evm/swap_encoder/builder.rs b/src/encoding/evm/swap_encoder/builder.rs index 430dcb2..94d7fff 100644 --- a/src/encoding/evm/swap_encoder/builder.rs +++ b/src/encoding/evm/swap_encoder/builder.rs @@ -25,6 +25,8 @@ impl SwapEncoderBuilder { "uniswap_v2" => Ok(Box::new(UniswapV2SwapEncoder::new(self.executor_address))), "vm:balancer_v2" => Ok(Box::new(BalancerV2SwapEncoder::new(self.executor_address))), "uniswap_v3" => Ok(Box::new(UniswapV3SwapEncoder::new(self.executor_address))), + // TODO replace this with V4 encoder once implemented + "uniswap_v4" => Ok(Box::new(UniswapV2SwapEncoder::new(self.executor_address))), _ => Err(EncodingError::FatalError(format!( "Unknown protocol system: {}", self.protocol_system From e94154bc2d72c312d07eb1aa73a0fd96214ad288 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Fri, 14 Feb 2025 18:48:39 -0500 Subject: [PATCH 4/9] fix: Do not count intermediary tokens in indices To achieve this, a small refactor was necessary with the introduction of a SwapGroup object, and a separate method to create a vec of these objects. This then separates all logic related to combining protocol data nicely and cleanly into its own method with its own tests. TODO: - Deal with split swaps and add test for split swap scenario - Clean up all existing PR comments --- .../evm/strategy_encoder/strategy_encoders.rs | 328 +++++++++++++++--- src/encoding/models.rs | 3 +- 2 files changed, 274 insertions(+), 57 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index c5b3b80..d91c023 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -283,6 +283,15 @@ pub struct UniswapV4StrategyEncoder { impl EVMStrategyEncoder for UniswapV4StrategyEncoder {} +#[derive(Clone, PartialEq, Debug)] +pub struct SwapGroup { + input_token: Bytes, + output_token: Bytes, + protocol_system: String, + swaps: Vec, + split: f64, +} + impl StrategyEncoder for UniswapV4StrategyEncoder { fn encode_strategy( &self, @@ -314,10 +323,13 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { .into_iter() .collect(); - let intermediary_tokens: HashSet = solution - .swaps + let grouped_swaps = self.group_swaps(solution.swaps); + + let intermediary_tokens: HashSet = grouped_swaps .iter() - .flat_map(|swap| vec![swap.token_in.clone(), swap.token_out.clone()]) + .flat_map(|grouped_swap| { + vec![grouped_swap.input_token.clone(), grouped_swap.output_token.clone()] + }) .collect(); let mut intermediary_tokens: Vec = intermediary_tokens .difference(&solution_tokens) @@ -350,75 +362,39 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { } let mut swaps = vec![]; - - let mut previous_protocol_data: Vec = vec![]; - let mut first_usv4_in_token: Bytes = Bytes::default(); - let mut last_swap_was_usv4 = false; - println!("Hello?"); - - for swap in solution.swaps.iter() { + for grouped_swap in grouped_swaps.iter() { let swap_encoder = self - .get_swap_encoder(&swap.component.protocol_system) + .get_swap_encoder(&grouped_swap.protocol_system) .ok_or_else(|| { EncodingError::InvalidInput(format!( "Swap encoder not found for protocol: {}", - swap.component.protocol_system + grouped_swap.protocol_system )) })?; - let current_swap_is_usv4 = swap.component.protocol_system == "uniswap_v4"; let encoding_context = EncodingContext { receiver: solution.router_address.clone(), exact_out: solution.exact_out, router_address: solution.router_address.clone(), }; - let mut protocol_data = swap_encoder.encode_swap(swap.clone(), encoding_context)?; - let in_token; - println!("Hello?"); - if current_swap_is_usv4 { - if !last_swap_was_usv4 { - // This is the first usv4 swap of a potential sequence. Store the input token - first_usv4_in_token = swap.clone().token_in; - } else { - // This is the second or later usv4 swap of a sequence. Concatenate the protocol - // data with the previous swap's protocol data - protocol_data = - [previous_protocol_data.clone(), protocol_data.clone()].concat(); - println!("Previous protocol data{}", hex::encode(&previous_protocol_data)); - println!("Current protocol data{}", hex::encode(&protocol_data)); - } - in_token = first_usv4_in_token.clone(); - previous_protocol_data = protocol_data.clone(); - } else { - in_token = swap.clone().token_in; - // This is not a USV4 swap. Clear previous USV4 protocol data. - previous_protocol_data = vec![]; + let mut grouped_protocol_data: Vec> = vec![]; + for swap in grouped_swap.swaps.iter() { + let protocol_data = + swap_encoder.encode_swap(swap.clone(), encoding_context.clone())?; + grouped_protocol_data.push(protocol_data); } - // This is the hardest part - we will need to have the input token be the first of the - // USV4 sequence, and the output token be the last, essentially removing - // intermediate tokens and pretending they don't exist... I think? let swap_data = self.encode_swap_header( - get_token_position(tokens.clone(), in_token)?, - get_token_position(tokens.clone(), swap.clone().token_out)?, - percentage_to_uint24(swap.split), + get_token_position(tokens.clone(), grouped_swap.input_token.clone())?, + get_token_position(tokens.clone(), grouped_swap.output_token.clone())?, + percentage_to_uint24(grouped_swap.split), Bytes::from_str(swap_encoder.executor_address()).map_err(|_| { EncodingError::FatalError("Invalid executor address".to_string()) })?, self.encode_executor_selector(swap_encoder.executor_selector()), - protocol_data, + grouped_protocol_data.abi_encode_packed(), ); - - // If the last swap was usv4, and this swap is also usv4, replace the last swap_data - // with the updated swap_data, which will contain both swaps, along with the - // proper input and output tokens - if last_swap_was_usv4 && current_swap_is_usv4 { - let swaps_len = swaps.len() - 1; - swaps[swaps_len] = swap_data; - } else { - swaps.push(swap_data); - } - last_swap_was_usv4 = current_swap_is_usv4; + swaps.push(swap_data); } let encoded_swaps = self.ple_encode(swaps); @@ -468,6 +444,45 @@ impl UniswapV4StrategyEncoder { split_swap_validator: SplitSwapValidator, }) } + + /// Group consecutive swaps which can be encoded into one swap execution for gas optimization. + /// + /// An example where this applies is the case of USV4, which uses a PoolManager contract + /// to save token transfers on consecutive swaps. + fn group_swaps(&self, swaps: Vec) -> Vec { + let mut grouped_swaps: Vec = Vec::new(); + let mut current_group: Option = None; + let mut last_swap_was_usv4 = false; + for swap in swaps { + let current_swap_is_usv4 = swap.component.protocol_system == "uniswap_v4"; + if current_swap_is_usv4 && last_swap_was_usv4 { + // Second or later USV4 pool in a sequence of USV4 pools. Merge to the current group + if let Some(group) = current_group.as_mut() { + group.swaps.push(swap.clone()); + // Update the output token of the current group. + group.output_token = swap.token_out.clone(); + } + } else { + // Not second or later USV4 pool. Push the current group (if it exists) and then + // create a new group. + if let Some(group) = current_group.as_mut() { + grouped_swaps.push(group.clone()); + } + current_group = Some(SwapGroup { + input_token: swap.token_in.clone(), + output_token: swap.token_out.clone(), + protocol_system: swap.component.protocol_system.clone(), + swaps: vec![swap.clone()], + split: swap.split, + }); + } + last_swap_was_usv4 = current_swap_is_usv4; + } + if let Some(group) = current_group.as_mut() { + grouped_swaps.push(group.clone()); + } + grouped_swaps + } } impl EVMStrategyEncoder for SplitSwapStrategyEncoder {} @@ -1068,11 +1083,82 @@ mod tests { println!("{}", _hex_calldata); } + #[test] + fn test_group_swaps() { + // 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 dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let swap_weth_wbtc = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".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 { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_usdc_dai = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: usdc.clone(), + token_out: dai.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = + UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + + let grouped_swaps = encoder.group_swaps(vec![ + swap_weth_wbtc.clone(), + swap_wbtc_usdc.clone(), + swap_usdc_dai.clone(), + ]); + + assert_eq!( + grouped_swaps, + vec![ + SwapGroup { + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + input_token: weth, + output_token: usdc.clone(), + protocol_system: "uniswap_v4".to_string(), + split: 0f64, + }, + SwapGroup { + swaps: vec![swap_usdc_dai], + input_token: usdc, + output_token: dai, + protocol_system: "uniswap_v2".to_string(), + split: 0f64, + } + ] + ); + } + #[test] fn test_usv4_encoding_strategy() { // Performs a split swap from WETH to USDC though WBTC using two consecutive USV4 pools // - // WETH ──(USV2)──> WBTC ───(USV4)──> USDC + // WETH ──(USV4)──> WBTC ───(USV4)──> USDC // // Set up a mock private key for signing @@ -1135,7 +1221,8 @@ mod tests { "0000000000000000000000000000000000000000000000000000000000000000", // min amount out "0000000000000000000000000000000000000000000000000000000000000000", // wrap "0000000000000000000000000000000000000000000000000000000000000000", // unwrap - "0000000000000000000000000000000000000000000000000000000000000003", // tokens length + // tokens length (not including intermediary tokens of USV4-optimized swaps) + "0000000000000000000000000000000000000000000000000000000000000002", "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver ] .join(""); @@ -1163,9 +1250,9 @@ mod tests { // length of ple encoded swaps without padding "0000000000000000000000000000000000000000000000000000000000000099", // ple encoded swaps - "0097", // Swap header + "0097", // Swap length "00", // token in index - "02", // token out index + "01", // token out index "000000", // split // Swap data header "5c2f5a71f67c01775180adc06909288b4c329308", // executor address @@ -1187,6 +1274,135 @@ mod tests { assert_eq!(hex_calldata[..520], expected_input); assert_eq!(hex_calldata[1288..], expected_swaps); } + #[test] + fn test_usv4_encoding_strategy_no_optimization() { + // Performs a split swap from WETH to USDC though WBTC using one USV4 pool after a USV2 + // pool. No swaps are optimizable here. Check that this doesn't break anything. + // + // WETH ──(USV2)──> WBTC ───(USV4)──> 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: "0xAE461cA67B15dc8dc81CE7615e0320dA1A9aB8D5".to_string(), + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = + UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: usdc, + expected_amount: Some(BigUint::from_str("3_000_000000").unwrap()), + checked_amount: None, + slippage: None, + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + ..Default::default() + }; + + let (calldata, _, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let expected_input = [ + "4860f9ed", // Function selector + "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out + "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token out + "0000000000000000000000000000000000000000000000000000000000000000", // min amount out + "0000000000000000000000000000000000000000000000000000000000000000", // wrap + "0000000000000000000000000000000000000000000000000000000000000000", // unwrap + // tokens length (not including intermediary tokens of USV4-optimized swaps) + "0000000000000000000000000000000000000000000000000000000000000003", + "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver + ] + .join(""); + + // after this there is the permit and because of the deadlines (that depend on block time) + // it's hard to assert + // "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + // "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount in + // "0000000000000000000000000000000000000000000000000000000067c205fe", // expiration + // "0000000000000000000000000000000000000000000000000000000000000000", // nonce + // "0000000000000000000000002c6a3cd97c6283b95ac8c5a4459ebb0d5fd404f4", // spender + // "00000000000000000000000000000000000000000000000000000000679a8006", // deadline + // offset of signature (from start of call data to beginning of length indication) + // "0000000000000000000000000000000000000000000000000000000000000200", + // offset of ple encoded swaps (from start of call data to beginning of length indication) + // "0000000000000000000000000000000000000000000000000000000000000280", + // length of signature without padding + // "0000000000000000000000000000000000000000000000000000000000000041", + // signature + padding + // "a031b63a01ef5d25975663e5d6c420ef498e3a5968b593cdf846c6729a788186", + // "1ddaf79c51453cd501d321ee541d13593e3a266be44103eefdf6e76a032d2870", + // "1b00000000000000000000000000000000000000000000000000000000000000" + + let expected_swaps = String::from(concat!( + // length of ple encoded swaps without padding + "00000000000000000000000000000000000000000000000000000000000000b8", + // ple encoded swaps + "005a", // Swap length + "00", // token in index + "01", // token out index + "000000", // split + // Swap data header + "5c2f5a71f67c01775180adc06909288b4c329308", // executor address + "bd0625ab", // selector + // First swap protocol data + "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in + "bb2b8038a1640196fbe3e38816f3e67cba72d940", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "00", // zero2one + // ple encoded swaps + "005a", // Swap length + "01", // token in index + "02", // token out index + "000000", // split + // Swap data header + "5c2f5a71f67c01775180adc06909288b4c329308", // executor address + "bd0625ab", // selector + // Second swap protocol data + "2260fac5e5542a773aa44fbcfedf7c193bc2c599", // token in + "ae461ca67b15dc8dc81ce7615e0320da1a9ab8d5", // component id + "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver + "01", // zero2one + "0000000000000000", // padding + )); + let hex_calldata = encode(&calldata); + + assert_eq!(hex_calldata[..520], expected_input); + assert_eq!(hex_calldata[1288..], expected_swaps); + } #[test] fn test_validate_path_single_swap() { diff --git a/src/encoding/models.rs b/src/encoding/models.rs index 3bf1151..78a4a3f 100644 --- a/src/encoding/models.rs +++ b/src/encoding/models.rs @@ -65,7 +65,7 @@ pub enum NativeAction { } /// Represents a swap operation to be performed on a pool. -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct Swap { /// Protocol component from tycho indexer pub component: ProtocolComponent, @@ -111,6 +111,7 @@ pub struct Transaction { /// * `receiver`: Address of the receiver of the out token after the swaps are completed. /// * `exact_out`: true if the solution is a buy order, false if it is a sell order. /// * `router_address`: Address of the router contract to be used for the swaps. +#[derive(Clone, Debug)] pub struct EncodingContext { pub receiver: Bytes, pub exact_out: bool, From 957bf898f28a23dd451d8a0ab07eb221dec1dc11 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Feb 2025 16:50:59 -0500 Subject: [PATCH 5/9] fix: Do not group split swaps As far as we know, Uniswap V4 has no support for optimizing split swaps - only sequential swaps. We must make sure we don't accidentally group split swaps too - as this will likely revert due to mismatched input and output values. --- .../evm/strategy_encoder/strategy_encoders.rs | 109 +++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index d91c023..583d19f 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -453,9 +453,14 @@ impl UniswapV4StrategyEncoder { let mut grouped_swaps: Vec = Vec::new(); let mut current_group: Option = None; let mut last_swap_was_usv4 = false; + let mut last_swap_out_token = Bytes::default(); for swap in swaps { let current_swap_is_usv4 = swap.component.protocol_system == "uniswap_v4"; - if current_swap_is_usv4 && last_swap_was_usv4 { + // Split 0 can also mean that the swap is the remaining part of a branch of splits, + // so we need to check the last swap's out token as well + let is_split = swap.split != 0.0 || swap.token_in != last_swap_out_token; + + if current_swap_is_usv4 && last_swap_was_usv4 && !is_split { // Second or later USV4 pool in a sequence of USV4 pools. Merge to the current group if let Some(group) = current_group.as_mut() { group.swaps.push(swap.clone()); @@ -477,6 +482,7 @@ impl UniswapV4StrategyEncoder { }); } last_swap_was_usv4 = current_swap_is_usv4; + last_swap_out_token = swap.token_out.clone(); } if let Some(group) = current_group.as_mut() { grouped_swaps.push(group.clone()); @@ -1084,7 +1090,12 @@ mod tests { } #[test] - fn test_group_swaps() { + fn test_group_swaps_simple() { + // The first and second swaps can be grouped since there is no split, and they are + // both USV4. + // + // WETH ──(USV4)──> WBTC ───(USV4)──> USDC ───(USV2)──> DAI + // // Set up a mock private key for signing let private_key = "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); @@ -1154,6 +1165,100 @@ mod tests { ); } + #[test] + fn test_group_swaps_complex_split() { + // There is a split in the solution, but it's possible to combine two of the USV4 splits. + // The WETH -> USDC swap cannot get grouped with anything, but the WETH -> DAI and + // DAI -> USDC swaps can be grouped. + // + // ┌──(USV4)──> USDC + // WBTC ──> (USV4)──> WETH ─┤ + // └──(USV4)──> DAI ───(USV4)──> USDC + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = weth(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let swap_wbtc_weth = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: weth.clone(), + split: 0f64, + }; + let swap_weth_usdc = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0.5f64, + }; + let swap_weth_dai = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.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_dai_usdc = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: dai.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = + UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + + let grouped_swaps = encoder.group_swaps(vec![ + swap_wbtc_weth.clone(), + swap_weth_usdc.clone(), + swap_weth_dai.clone(), + swap_dai_usdc.clone(), + ]); + + assert_eq!( + grouped_swaps, + vec![ + SwapGroup { + swaps: vec![swap_wbtc_weth], + input_token: wbtc.clone(), + output_token: weth.clone(), + protocol_system: "uniswap_v4".to_string(), + split: 0f64, + }, + SwapGroup { + swaps: vec![swap_weth_usdc], + input_token: weth.clone(), + output_token: usdc.clone(), + protocol_system: "uniswap_v4".to_string(), + split: 0.5f64, + }, + SwapGroup { + swaps: vec![swap_weth_dai, swap_dai_usdc], + input_token: weth, + output_token: usdc, + protocol_system: "uniswap_v4".to_string(), + split: 0f64, + } + ] + ); + } + #[test] fn test_usv4_encoding_strategy() { // Performs a split swap from WETH to USDC though WBTC using two consecutive USV4 pools From 47b61802eef58068d1188c25db86974c7e03f3a8 Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Feb 2025 17:23:29 -0500 Subject: [PATCH 6/9] feat: Generalize group_swaps method - This can now be used for any groupable protocols - not just USV4. --- src/encoding/evm/constants.rs | 14 +++ .../evm/strategy_encoder/strategy_encoders.rs | 105 ++++++++++++++++-- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/src/encoding/evm/constants.rs b/src/encoding/evm/constants.rs index 1426dd4..62f9800 100644 --- a/src/encoding/evm/constants.rs +++ b/src/encoding/evm/constants.rs @@ -1,2 +1,16 @@ +use std::{collections::HashSet, sync::LazyLock}; + pub const DEFAULT_EXECUTORS_JSON: &str = include_str!("../../../src/encoding/config/executor_addresses.json"); + +/// These protocols support the optimization of grouping swaps. +/// +/// This requires special encoding to send call data of multiple swaps to a single executor, +/// as if it were a single swap. The protocol likely uses flash accounting to save gas on token +/// transfers. +pub static GROUPABLE_PROTOCOLS: LazyLock> = LazyLock::new(|| { + let mut set = HashSet::new(); + set.insert("uniswap_v4"); + set.insert("balancer_v3"); + set +}); diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index 583d19f..e906a01 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -11,6 +11,7 @@ use crate::encoding::{ errors::EncodingError, evm::{ approvals::permit2::Permit2, + constants::GROUPABLE_PROTOCOLS, swap_encoder::swap_encoder_registry::SwapEncoderRegistry, utils::{ biguint_to_u256, bytes_to_address, encode_input, get_min_amount_for_solution, @@ -452,16 +453,20 @@ impl UniswapV4StrategyEncoder { fn group_swaps(&self, swaps: Vec) -> Vec { let mut grouped_swaps: Vec = Vec::new(); let mut current_group: Option = None; - let mut last_swap_was_usv4 = false; + let mut last_swap_protocol = "".to_string(); + let mut groupable_protocol; let mut last_swap_out_token = Bytes::default(); for swap in swaps { - let current_swap_is_usv4 = swap.component.protocol_system == "uniswap_v4"; + let current_swap_protocol = swap.component.protocol_system.clone(); + groupable_protocol = GROUPABLE_PROTOCOLS.contains(¤t_swap_protocol.as_str()); + // Split 0 can also mean that the swap is the remaining part of a branch of splits, // so we need to check the last swap's out token as well - let is_split = swap.split != 0.0 || swap.token_in != last_swap_out_token; + let no_split = swap.split == 0.0 && swap.token_in == last_swap_out_token; - if current_swap_is_usv4 && last_swap_was_usv4 && !is_split { - // Second or later USV4 pool in a sequence of USV4 pools. Merge to the current group + if current_swap_protocol == last_swap_protocol && groupable_protocol && no_split { + // Second or later groupable pool in a sequence of groupable pools. Merge to the + // current group. if let Some(group) = current_group.as_mut() { group.swaps.push(swap.clone()); // Update the output token of the current group. @@ -476,12 +481,12 @@ impl UniswapV4StrategyEncoder { current_group = Some(SwapGroup { input_token: swap.token_in.clone(), output_token: swap.token_out.clone(), - protocol_system: swap.component.protocol_system.clone(), + protocol_system: current_swap_protocol.clone(), swaps: vec![swap.clone()], split: swap.split, }); } - last_swap_was_usv4 = current_swap_is_usv4; + last_swap_protocol = current_swap_protocol; last_swap_out_token = swap.token_out.clone(); } if let Some(group) = current_group.as_mut() { @@ -1259,6 +1264,92 @@ mod tests { ); } + #[test] + fn test_group_swaps_complex_split_multi_protocol() { + // There is a split in the solution, but it's possible to group the USV4 splits with each + // other and the Balancer V3 swaps with each other. + // + // ┌──(BalancerV3)──> WBTC ──(BalancerV3)──> USDC + // WETH ─┤ + // └──(USV4)──> DAI ───(USV4)──> USDC + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = weth(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let swap_weth_wbtc = Swap { + component: ProtocolComponent { + protocol_system: "balancer_v3".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: wbtc.clone(), + split: 0.5f64, + }; + let swap_wbtc_usdc = Swap { + component: ProtocolComponent { + protocol_system: "balancer_v3".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_weth_dai = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.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_dai_usdc = Swap { + component: ProtocolComponent { + protocol_system: "uniswap_v4".to_string(), + ..Default::default() + }, + token_in: dai.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = + UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + + let grouped_swaps = encoder.group_swaps(vec![ + swap_weth_wbtc.clone(), + swap_wbtc_usdc.clone(), + swap_weth_dai.clone(), + swap_dai_usdc.clone(), + ]); + + assert_eq!( + grouped_swaps, + vec![ + SwapGroup { + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + input_token: weth.clone(), + output_token: usdc.clone(), + protocol_system: "balancer_v3".to_string(), + split: 0.5f64, + }, + SwapGroup { + swaps: vec![swap_weth_dai, swap_dai_usdc], + input_token: weth, + output_token: usdc, + protocol_system: "uniswap_v4".to_string(), + split: 0f64, + } + ] + ); + } + #[test] fn test_usv4_encoding_strategy() { // Performs a split swap from WETH to USDC though WBTC using two consecutive USV4 pools From 44aabf17612994fa126cfdfa4ef7f043b825aeee Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Feb 2025 23:44:58 -0500 Subject: [PATCH 7/9] feat: Merge USV4 strategy back into split strategy - Since the group_swaps method is now generalized, there is no need to have an entirely separate method here. --- .../evm/strategy_encoder/strategy_encoders.rs | 430 +++--------------- 1 file changed, 75 insertions(+), 355 deletions(-) diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index e906a01..bdd9b50 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -68,6 +68,24 @@ pub trait EVMStrategyEncoder: StrategyEncoder { } } +/// Represents a group of swaps that can be encoded into a single swap execution for gas +/// optimization. +/// +/// # Fields +/// * `input_token`: Bytes, the input token of the first swap +/// * `output_token`: Bytes, the output token of the final swap +/// * `protocol_system`: String, the protocol system of the swaps +/// * `swaps`: Vec, the sequence of swaps to be executed as a group +/// * `split`: f64, the split percentage of the first swap in the group +#[derive(Clone, PartialEq, Debug)] +pub struct SwapGroup { + input_token: Bytes, + output_token: Bytes, + protocol_system: String, + swaps: Vec, + split: f64, +} + /// Represents the encoder for a swap strategy which supports single, sequential and split swaps. /// /// # Fields @@ -268,32 +286,59 @@ impl SplitSwapStrategyEncoder { split_swap_validator: SplitSwapValidator, }) } + + /// Group consecutive swaps which can be encoded into one swap execution for gas optimization. + /// + /// An example where this applies is the case of USV4, which uses a PoolManager contract + /// to save token transfers on consecutive swaps. + fn group_swaps(&self, swaps: Vec) -> Vec { + let mut grouped_swaps: Vec = Vec::new(); + let mut current_group: Option = None; + let mut last_swap_protocol = "".to_string(); + let mut groupable_protocol; + let mut last_swap_out_token = Bytes::default(); + for swap in swaps { + let current_swap_protocol = swap.component.protocol_system.clone(); + groupable_protocol = GROUPABLE_PROTOCOLS.contains(¤t_swap_protocol.as_str()); + + // Split 0 can also mean that the swap is the remaining part of a branch of splits, + // so we need to check the last swap's out token as well + let no_split = swap.split == 0.0 && swap.token_in == last_swap_out_token; + + if current_swap_protocol == last_swap_protocol && groupable_protocol && no_split { + // Second or later groupable pool in a sequence of groupable pools. Merge to the + // current group. + if let Some(group) = current_group.as_mut() { + group.swaps.push(swap.clone()); + // Update the output token of the current group. + group.output_token = swap.token_out.clone(); + } + } else { + // Not second or later USV4 pool. Push the current group (if it exists) and then + // create a new group. + if let Some(group) = current_group.as_mut() { + grouped_swaps.push(group.clone()); + } + current_group = Some(SwapGroup { + input_token: swap.token_in.clone(), + output_token: swap.token_out.clone(), + protocol_system: current_swap_protocol.clone(), + swaps: vec![swap.clone()], + split: swap.split, + }); + } + last_swap_protocol = current_swap_protocol; + last_swap_out_token = swap.token_out.clone(); + } + if let Some(group) = current_group.as_mut() { + grouped_swaps.push(group.clone()); + } + grouped_swaps + } } +impl EVMStrategyEncoder for SplitSwapStrategyEncoder {} -/// To be used if there are two or more UniswapV4 swaps consecutively. They can be combined as a -/// gas optimization. -#[derive(Clone)] -pub struct UniswapV4StrategyEncoder { - swap_encoder_registry: SwapEncoderRegistry, - permit2: Permit2, - selector: String, - native_address: Bytes, - wrapped_address: Bytes, - split_swap_validator: SplitSwapValidator, -} - -impl EVMStrategyEncoder for UniswapV4StrategyEncoder {} - -#[derive(Clone, PartialEq, Debug)] -pub struct SwapGroup { - input_token: Bytes, - output_token: Bytes, - protocol_system: String, - swaps: Vec, - split: f64, -} - -impl StrategyEncoder for UniswapV4StrategyEncoder { +impl StrategyEncoder for SplitSwapStrategyEncoder { fn encode_strategy( &self, solution: Solution, @@ -428,202 +473,6 @@ impl StrategyEncoder for UniswapV4StrategyEncoder { } } -impl UniswapV4StrategyEncoder { - #[allow(dead_code)] - pub fn new( - signer_pk: String, - chain: Chain, - swap_encoder_registry: SwapEncoderRegistry, - ) -> Result { - let selector = "swap(uint256,address,address,uint256,bool,bool,uint256,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string(); - Ok(Self { - permit2: Permit2::new(signer_pk, chain.clone())?, - selector, - swap_encoder_registry, - native_address: chain.native_token()?, - wrapped_address: chain.wrapped_token()?, - split_swap_validator: SplitSwapValidator, - }) - } - - /// Group consecutive swaps which can be encoded into one swap execution for gas optimization. - /// - /// An example where this applies is the case of USV4, which uses a PoolManager contract - /// to save token transfers on consecutive swaps. - fn group_swaps(&self, swaps: Vec) -> Vec { - let mut grouped_swaps: Vec = Vec::new(); - let mut current_group: Option = None; - let mut last_swap_protocol = "".to_string(); - let mut groupable_protocol; - let mut last_swap_out_token = Bytes::default(); - for swap in swaps { - let current_swap_protocol = swap.component.protocol_system.clone(); - groupable_protocol = GROUPABLE_PROTOCOLS.contains(¤t_swap_protocol.as_str()); - - // Split 0 can also mean that the swap is the remaining part of a branch of splits, - // so we need to check the last swap's out token as well - let no_split = swap.split == 0.0 && swap.token_in == last_swap_out_token; - - if current_swap_protocol == last_swap_protocol && groupable_protocol && no_split { - // Second or later groupable pool in a sequence of groupable pools. Merge to the - // current group. - if let Some(group) = current_group.as_mut() { - group.swaps.push(swap.clone()); - // Update the output token of the current group. - group.output_token = swap.token_out.clone(); - } - } else { - // Not second or later USV4 pool. Push the current group (if it exists) and then - // create a new group. - if let Some(group) = current_group.as_mut() { - grouped_swaps.push(group.clone()); - } - current_group = Some(SwapGroup { - input_token: swap.token_in.clone(), - output_token: swap.token_out.clone(), - protocol_system: current_swap_protocol.clone(), - swaps: vec![swap.clone()], - split: swap.split, - }); - } - last_swap_protocol = current_swap_protocol; - last_swap_out_token = swap.token_out.clone(); - } - if let Some(group) = current_group.as_mut() { - grouped_swaps.push(group.clone()); - } - grouped_swaps - } -} - -impl EVMStrategyEncoder for SplitSwapStrategyEncoder {} - -impl StrategyEncoder for SplitSwapStrategyEncoder { - fn encode_strategy( - &self, - solution: Solution, - ) -> Result<(Vec, Bytes, Option), EncodingError> { - self.split_swap_validator - .validate_split_percentages(&solution.swaps)?; - self.split_swap_validator - .validate_swap_path( - &solution.swaps, - &solution.given_token, - &solution.checked_token, - &solution.native_action, - &self.native_address, - &self.wrapped_address, - )?; - let (permit, signature) = self.permit2.get_permit( - &solution.router_address, - &solution.sender, - &solution.given_token, - &solution.given_amount, - )?; - let min_amount_out = get_min_amount_for_solution(solution.clone()); - - // The tokens array is composed of the given token, the checked token and all the - // intermediary tokens in between. The contract expects the tokens to be in this order. - let solution_tokens: HashSet = - vec![solution.given_token.clone(), solution.checked_token.clone()] - .into_iter() - .collect(); - - let intermediary_tokens: HashSet = solution - .swaps - .iter() - .flat_map(|swap| vec![swap.token_in.clone(), swap.token_out.clone()]) - .collect(); - let mut intermediary_tokens: Vec = intermediary_tokens - .difference(&solution_tokens) - .cloned() - .collect(); - // this is only to make the test deterministic (same index for the same token for different - // runs) - intermediary_tokens.sort(); - - 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 tokens = Vec::with_capacity(2 + intermediary_tokens.len()); - if wrap { - tokens.push(self.wrapped_address.clone()); - } else { - tokens.push(solution.given_token.clone()); - } - tokens.extend(intermediary_tokens); - - if unwrap { - tokens.push(self.wrapped_address.clone()); - } else { - tokens.push(solution.checked_token.clone()); - } - - let mut swaps = vec![]; - for swap in solution.swaps.iter() { - let swap_encoder = self - .get_swap_encoder(&swap.component.protocol_system) - .ok_or_else(|| { - EncodingError::InvalidInput(format!( - "Swap encoder not found for protocol: {}", - swap.component.protocol_system - )) - })?; - - let encoding_context = EncodingContext { - receiver: solution.router_address.clone(), - exact_out: solution.exact_out, - router_address: solution.router_address.clone(), - }; - let protocol_data = swap_encoder.encode_swap(swap.clone(), encoding_context)?; - let swap_data = self.encode_swap_header( - get_token_position(tokens.clone(), swap.token_in.clone())?, - get_token_position(tokens.clone(), swap.token_out.clone())?, - percentage_to_uint24(swap.split), - Bytes::from_str(swap_encoder.executor_address()).map_err(|_| { - EncodingError::FatalError("Invalid executor address".to_string()) - })?, - self.encode_executor_selector(swap_encoder.executor_selector()), - protocol_data, - ); - swaps.push(swap_data); - } - - let encoded_swaps = self.ple_encode(swaps); - let method_calldata = ( - 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, - U256::from(tokens.len()), - bytes_to_address(&solution.receiver)?, - permit, - signature.as_bytes().to_vec(), - encoded_swaps, - ) - .abi_encode(); - - let contract_interaction = encode_input(&self.selector, method_calldata); - Ok((contract_interaction, solution.router_address, None)) - } - - 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()) - } -} - /// This strategy encoder is used for solutions that are sent directly to the executor, bypassing /// the router. Only one solution with one swap is supported. /// @@ -812,7 +661,7 @@ mod tests { #[case] checked_amount: Option, #[case] expected_min_amount: U256, ) { - // Performs a single swap from WETH to DAI on a USV2 pool + // 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 = @@ -1141,7 +990,7 @@ mod tests { }; let swap_encoder_registry = get_swap_encoder_registry(); let encoder = - UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + SplitSwapStrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); let grouped_swaps = encoder.group_swaps(vec![ swap_weth_wbtc.clone(), @@ -1227,7 +1076,7 @@ mod tests { }; let swap_encoder_registry = get_swap_encoder_registry(); let encoder = - UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + SplitSwapStrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); let grouped_swaps = encoder.group_swaps(vec![ swap_wbtc_weth.clone(), @@ -1320,7 +1169,7 @@ mod tests { }; let swap_encoder_registry = get_swap_encoder_registry(); let encoder = - UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + SplitSwapStrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); let grouped_swaps = encoder.group_swaps(vec![ swap_weth_wbtc.clone(), @@ -1351,7 +1200,7 @@ mod tests { } #[test] - fn test_usv4_encoding_strategy() { + fn test_split_encoding_strategy_usv4() { // Performs a split swap from WETH to USDC though WBTC using two consecutive USV4 pools // // WETH ──(USV4)──> WBTC ───(USV4)──> USDC @@ -1389,7 +1238,7 @@ mod tests { }; let swap_encoder_registry = get_swap_encoder_registry(); let encoder = - UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); + SplitSwapStrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); let solution = Solution { exact_out: false, given_token: weth, @@ -1470,135 +1319,6 @@ mod tests { assert_eq!(hex_calldata[..520], expected_input); assert_eq!(hex_calldata[1288..], expected_swaps); } - #[test] - fn test_usv4_encoding_strategy_no_optimization() { - // Performs a split swap from WETH to USDC though WBTC using one USV4 pool after a USV2 - // pool. No swaps are optimizable here. Check that this doesn't break anything. - // - // WETH ──(USV2)──> WBTC ───(USV4)──> 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: "0xAE461cA67B15dc8dc81CE7615e0320dA1A9aB8D5".to_string(), - protocol_system: "uniswap_v4".to_string(), - ..Default::default() - }, - token_in: wbtc.clone(), - token_out: usdc.clone(), - split: 0f64, - }; - let swap_encoder_registry = get_swap_encoder_registry(); - let encoder = - UniswapV4StrategyEncoder::new(private_key, eth_chain(), swap_encoder_registry).unwrap(); - let solution = Solution { - exact_out: false, - given_token: weth, - given_amount: BigUint::from_str("1_000000000000000000").unwrap(), - checked_token: usdc, - expected_amount: Some(BigUint::from_str("3_000_000000").unwrap()), - checked_amount: None, - slippage: None, - sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), - router_address: Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), - swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], - ..Default::default() - }; - - let (calldata, _, _) = encoder - .encode_strategy(solution) - .unwrap(); - - let expected_input = [ - "4860f9ed", // Function selector - "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount out - "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", // token out - "0000000000000000000000000000000000000000000000000000000000000000", // min amount out - "0000000000000000000000000000000000000000000000000000000000000000", // wrap - "0000000000000000000000000000000000000000000000000000000000000000", // unwrap - // tokens length (not including intermediary tokens of USV4-optimized swaps) - "0000000000000000000000000000000000000000000000000000000000000003", - "000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2", // receiver - ] - .join(""); - - // after this there is the permit and because of the deadlines (that depend on block time) - // it's hard to assert - // "000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - // "0000000000000000000000000000000000000000000000000de0b6b3a7640000", // amount in - // "0000000000000000000000000000000000000000000000000000000067c205fe", // expiration - // "0000000000000000000000000000000000000000000000000000000000000000", // nonce - // "0000000000000000000000002c6a3cd97c6283b95ac8c5a4459ebb0d5fd404f4", // spender - // "00000000000000000000000000000000000000000000000000000000679a8006", // deadline - // offset of signature (from start of call data to beginning of length indication) - // "0000000000000000000000000000000000000000000000000000000000000200", - // offset of ple encoded swaps (from start of call data to beginning of length indication) - // "0000000000000000000000000000000000000000000000000000000000000280", - // length of signature without padding - // "0000000000000000000000000000000000000000000000000000000000000041", - // signature + padding - // "a031b63a01ef5d25975663e5d6c420ef498e3a5968b593cdf846c6729a788186", - // "1ddaf79c51453cd501d321ee541d13593e3a266be44103eefdf6e76a032d2870", - // "1b00000000000000000000000000000000000000000000000000000000000000" - - let expected_swaps = String::from(concat!( - // length of ple encoded swaps without padding - "00000000000000000000000000000000000000000000000000000000000000b8", - // ple encoded swaps - "005a", // Swap length - "00", // token in index - "01", // token out index - "000000", // split - // Swap data header - "5c2f5a71f67c01775180adc06909288b4c329308", // executor address - "bd0625ab", // selector - // First swap protocol data - "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in - "bb2b8038a1640196fbe3e38816f3e67cba72d940", // component id - "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver - "00", // zero2one - // ple encoded swaps - "005a", // Swap length - "01", // token in index - "02", // token out index - "000000", // split - // Swap data header - "5c2f5a71f67c01775180adc06909288b4c329308", // executor address - "bd0625ab", // selector - // Second swap protocol data - "2260fac5e5542a773aa44fbcfedf7c193bc2c599", // token in - "ae461ca67b15dc8dc81ce7615e0320da1a9ab8d5", // component id - "3ede3eca2a72b3aecc820e955b36f38437d01395", // receiver - "01", // zero2one - "0000000000000000", // padding - )); - let hex_calldata = encode(&calldata); - - assert_eq!(hex_calldata[..520], expected_input); - assert_eq!(hex_calldata[1288..], expected_swaps); - } #[test] fn test_validate_path_single_swap() { From 8f1e372c90de4602132ef8eeeb808c8c16a32afa Mon Sep 17 00:00:00 2001 From: TAMARA LIPOWSKI Date: Mon, 17 Feb 2025 23:57:01 -0500 Subject: [PATCH 8/9] refactor: PR comments (moving things around/docs)... - Move the validator into its own file, for organization purposes. - Add missing elements to `SplitSwapStrategyEncoder` docstring --- src/encoding/evm/strategy_encoder/mod.rs | 1 + .../evm/strategy_encoder/strategy_encoders.rs | 544 +---------------- .../strategy_encoder/strategy_validators.rs | 552 ++++++++++++++++++ 3 files changed, 559 insertions(+), 538 deletions(-) create mode 100644 src/encoding/evm/strategy_encoder/strategy_validators.rs diff --git a/src/encoding/evm/strategy_encoder/mod.rs b/src/encoding/evm/strategy_encoder/mod.rs index a0cf652..8067b75 100644 --- a/src/encoding/evm/strategy_encoder/mod.rs +++ b/src/encoding/evm/strategy_encoder/mod.rs @@ -1,2 +1,3 @@ pub mod strategy_encoder_registry; mod strategy_encoders; +mod strategy_validators; diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index bdd9b50..efb79e4 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{HashMap, HashSet, VecDeque}, - str::FromStr, -}; +use std::{collections::HashSet, str::FromStr}; use alloy_primitives::{aliases::U24, FixedBytes, U256, U8}; use alloy_sol_types::SolValue; @@ -12,6 +9,7 @@ use crate::encoding::{ evm::{ approvals::permit2::Permit2, constants::GROUPABLE_PROTOCOLS, + strategy_encoder::strategy_validators::SplitSwapValidator, swap_encoder::swap_encoder_registry::SwapEncoderRegistry, utils::{ biguint_to_u256, bytes_to_address, encode_input, get_min_amount_for_solution, @@ -93,6 +91,10 @@ pub struct SwapGroup { /// * `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 +/// * `split_swap_validator`: SplitSwapValidator, responsible for checking validity of split swap +/// solutions #[derive(Clone)] pub struct SplitSwapStrategyEncoder { swap_encoder_registry: SwapEncoderRegistry, @@ -103,173 +105,6 @@ pub struct SplitSwapStrategyEncoder { split_swap_validator: SplitSwapValidator, } -/// 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 - 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(()) - } - - /// Raises an error if swaps do not represent a valid path from the given token to the checked - /// token. - /// - /// A path is considered valid if all the following conditions are met: - /// * The checked token is reachable from the given token through the swap path - /// * There are no tokens which are unconnected from the main path - /// - /// 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. - fn validate_swap_path( - &self, - swaps: &[Swap], - given_token: &Bytes, - checked_token: &Bytes, - native_action: &Option, - native_address: &Bytes, - wrapped_address: &Bytes, - ) -> Result<(), EncodingError> { - // Convert ETH to WETH only if there's a corresponding wrap/unwrap action - let given_token = if *given_token == *native_address { - match native_action { - Some(NativeAction::Wrap) => wrapped_address, - _ => given_token, - } - } else { - given_token - }; - - let checked_token = if *checked_token == *native_address { - match native_action { - Some(NativeAction::Unwrap) => wrapped_address, - _ => checked_token, - } - } else { - checked_token - }; - - // Build directed graph of token flows - let mut graph: HashMap<&Bytes, HashSet<&Bytes>> = HashMap::new(); - for swap in swaps { - graph - .entry(&swap.token_in) - .or_default() - .insert(&swap.token_out); - } - - // BFS from validation_given - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - queue.push_back(given_token); - - while let Some(token) = queue.pop_front() { - if !visited.insert(token) { - continue; - } - - // Early success check - if token == checked_token && visited.len() == graph.len() + 1 { - return Ok(()); - } - - if let Some(next_tokens) = graph.get(token) { - for &next_token in next_tokens { - if !visited.contains(next_token) { - queue.push_back(next_token); - } - } - } - } - - // If we get here, either checked_token wasn't reached or not all tokens were visited - if !visited.contains(checked_token) { - Err(EncodingError::InvalidInput( - "Checked token is not reachable through swap path".to_string(), - )) - } else { - Err(EncodingError::InvalidInput( - "Some tokens are not connected to the main path".to_string(), - )) - } - } -} - impl SplitSwapStrategyEncoder { pub fn new( signer_pk: String, @@ -1319,371 +1154,4 @@ mod tests { assert_eq!(hex_calldata[..520], expected_input); assert_eq!(hex_calldata[1288..], expected_swaps); } - - #[test] - fn test_validate_path_single_swap() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - let swaps = vec![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 result = validator.validate_swap_path(&swaps, &weth, &dai, &None, ð, &weth); - assert_eq!(result, Ok(())); - } - - #[test] - fn test_validate_path_multiple_swaps() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); - let swaps = vec![ - Swap { - component: ProtocolComponent { - id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.5f64, - }, - Swap { - component: ProtocolComponent { - id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: dai.clone(), - token_out: usdc.clone(), - split: 0f64, - }, - ]; - let result = validator.validate_swap_path(&swaps, &weth, &usdc, &None, ð, &weth); - assert_eq!(result, Ok(())); - } - - #[test] - fn test_validate_path_disconnected() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); - let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); - - let disconnected_swaps = vec![ - Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.5, - }, - // This swap is disconnected from the WETH->DAI path - Swap { - component: ProtocolComponent { - id: "pool2".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: wbtc.clone(), - token_out: usdc.clone(), - split: 0.0, - }, - ]; - let result = - validator.validate_swap_path(&disconnected_swaps, &weth, &usdc, &None, ð, &weth); - assert!(matches!( - result, - Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") - )); - } - - #[test] - fn test_validate_path_unreachable_checked_token() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); - - let unreachable_swaps = vec![Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 1.0, - }]; - let result = - validator.validate_swap_path(&unreachable_swaps, &weth, &usdc, &None, ð, &weth); - assert!(matches!( - result, - Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") - )); - } - - #[test] - fn test_validate_path_empty_swaps() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); - - let empty_swaps: Vec = vec![]; - let result = validator.validate_swap_path(&empty_swaps, &weth, &usdc, &None, ð, &weth); - assert!(matches!( - result, - Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") - )); - } - - #[test] - fn test_validate_swap_single() { - let validator = SplitSwapValidator; - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - let swaps = vec![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 result = validator.validate_split_percentages(&swaps); - assert_eq!(result, Ok(())); - } - - #[test] - fn test_validate_swaps_multiple() { - let validator = SplitSwapValidator; - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - - // Valid case: Multiple swaps with proper splits (50%, 30%, remainder) - let valid_swaps = vec![ - Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.5, - }, - Swap { - component: ProtocolComponent { - id: "pool2".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.3, - }, - Swap { - component: ProtocolComponent { - id: "pool3".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.0, // Remainder (20%) - }, - ]; - assert!(validator - .validate_split_percentages(&valid_swaps) - .is_ok()); - } - - #[test] - fn test_validate_swaps_no_remainder_split() { - let validator = SplitSwapValidator; - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - - let invalid_total_swaps = vec![ - Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.7, - }, - Swap { - component: ProtocolComponent { - id: "pool2".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.3, - }, - ]; - assert!(matches!( - validator.validate_split_percentages(&invalid_total_swaps), - Err(EncodingError::InvalidInput(msg)) if msg.contains("must have exactly one 0% split") - )); - } - - #[test] - fn test_validate_swaps_zero_split_not_at_end() { - let validator = SplitSwapValidator; - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - - let invalid_zero_position_swaps = vec![ - Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.0, - }, - Swap { - component: ProtocolComponent { - id: "pool2".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.5, - }, - ]; - assert!(matches!( - validator.validate_split_percentages(&invalid_zero_position_swaps), - Err(EncodingError::InvalidInput(msg)) if msg.contains("must be the last swap") - )); - } - - #[test] - fn test_validate_swaps_splits_exceed_hundred_percent() { - let validator = SplitSwapValidator; - let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); - let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); - - let invalid_overflow_swaps = vec![ - Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.6, - }, - Swap { - component: ProtocolComponent { - id: "pool2".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.5, - }, - Swap { - component: ProtocolComponent { - id: "pool3".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: dai.clone(), - split: 0.0, - }, - ]; - assert!(matches!( - validator.validate_split_percentages(&invalid_overflow_swaps), - Err(EncodingError::InvalidInput(msg)) if msg.contains("must be <100%") - )); - } - - #[test] - fn test_validate_path_wrap_eth_given_token() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); - let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(); - - let swaps = vec![Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: weth.clone(), - token_out: usdc.clone(), - split: 0f64, - }]; - - let result = validator.validate_swap_path( - &swaps, - ð, - &usdc, - &Some(NativeAction::Wrap), - ð, - &weth, - ); - assert_eq!(result, Ok(())); - } - - #[test] - fn test_validate_token_path_connectivity_wrap_eth_checked_token() { - let validator = SplitSwapValidator; - let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); - let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); - let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(); - - let swaps = vec![Swap { - component: ProtocolComponent { - id: "pool1".to_string(), - protocol_system: "uniswap_v2".to_string(), - ..Default::default() - }, - token_in: usdc.clone(), - token_out: weth.clone(), - split: 0f64, - }]; - - let result = validator.validate_swap_path( - &swaps, - &usdc, - ð, - &Some(NativeAction::Unwrap), - ð, - &weth, - ); - assert_eq!(result, Ok(())); - } } diff --git a/src/encoding/evm/strategy_encoder/strategy_validators.rs b/src/encoding/evm/strategy_encoder/strategy_validators.rs new file mode 100644 index 0000000..26eb368 --- /dev/null +++ b/src/encoding/evm/strategy_encoder/strategy_validators.rs @@ -0,0 +1,552 @@ +use std::collections::{HashMap, HashSet, VecDeque}; + +use tycho_core::Bytes; + +use crate::encoding::{ + errors::EncodingError, + models::{NativeAction, 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(()) + } + + /// Raises an error if swaps do not represent a valid path from the given token to the checked + /// token. + /// + /// A path is considered valid if all the following conditions are met: + /// * The checked token is reachable from the given token through the swap path + /// * There are no tokens which are unconnected from the main path + /// + /// 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( + &self, + swaps: &[Swap], + given_token: &Bytes, + checked_token: &Bytes, + native_action: &Option, + native_address: &Bytes, + wrapped_address: &Bytes, + ) -> Result<(), EncodingError> { + // Convert ETH to WETH only if there's a corresponding wrap/unwrap action + let given_token = if *given_token == *native_address { + match native_action { + Some(NativeAction::Wrap) => wrapped_address, + _ => given_token, + } + } else { + given_token + }; + + let checked_token = if *checked_token == *native_address { + match native_action { + Some(NativeAction::Unwrap) => wrapped_address, + _ => checked_token, + } + } else { + checked_token + }; + + // Build directed graph of token flows + let mut graph: HashMap<&Bytes, HashSet<&Bytes>> = HashMap::new(); + for swap in swaps { + graph + .entry(&swap.token_in) + .or_default() + .insert(&swap.token_out); + } + + // BFS from validation_given + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(given_token); + + while let Some(token) = queue.pop_front() { + if !visited.insert(token) { + continue; + } + + // Early success check + if token == checked_token && visited.len() == graph.len() + 1 { + return Ok(()); + } + + if let Some(next_tokens) = graph.get(token) { + for &next_token in next_tokens { + if !visited.contains(next_token) { + queue.push_back(next_token); + } + } + } + } + + // If we get here, either checked_token wasn't reached or not all tokens were visited + if !visited.contains(checked_token) { + Err(EncodingError::InvalidInput( + "Checked token is not reachable through swap path".to_string(), + )) + } else { + Err(EncodingError::InvalidInput( + "Some tokens are not connected to the main path".to_string(), + )) + } + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use tycho_core::{dto::ProtocolComponent, Bytes}; + + use super::*; + use crate::encoding::models::Swap; + + #[test] + fn test_validate_path_single_swap() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + let swaps = vec![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 result = validator.validate_swap_path(&swaps, &weth, &dai, &None, ð, &weth); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_path_multiple_swaps() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let swaps = vec![ + Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.5f64, + }, + Swap { + component: ProtocolComponent { + id: "0xA478c2975Ab1Ea89e8196811F51A7B7Ade33eB11".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: dai.clone(), + token_out: usdc.clone(), + split: 0f64, + }, + ]; + let result = validator.validate_swap_path(&swaps, &weth, &usdc, &None, ð, &weth); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_path_disconnected() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + + let disconnected_swaps = vec![ + Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.5, + }, + // This swap is disconnected from the WETH->DAI path + Swap { + component: ProtocolComponent { + id: "pool2".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0.0, + }, + ]; + let result = + validator.validate_swap_path(&disconnected_swaps, &weth, &usdc, &None, ð, &weth); + assert!(matches!( + result, + Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") + )); + } + + #[test] + fn test_validate_path_unreachable_checked_token() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let unreachable_swaps = vec![Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 1.0, + }]; + let result = + validator.validate_swap_path(&unreachable_swaps, &weth, &usdc, &None, ð, &weth); + assert!(matches!( + result, + Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") + )); + } + + #[test] + fn test_validate_path_empty_swaps() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let empty_swaps: Vec = vec![]; + let result = validator.validate_swap_path(&empty_swaps, &weth, &usdc, &None, ð, &weth); + assert!(matches!( + result, + Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") + )); + } + + #[test] + fn test_validate_swap_single() { + let validator = SplitSwapValidator; + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + let swaps = vec![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 result = validator.validate_split_percentages(&swaps); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_swaps_multiple() { + let validator = SplitSwapValidator; + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + // Valid case: Multiple swaps with proper splits (50%, 30%, remainder) + let valid_swaps = vec![ + Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.5, + }, + Swap { + component: ProtocolComponent { + id: "pool2".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.3, + }, + Swap { + component: ProtocolComponent { + id: "pool3".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.0, // Remainder (20%) + }, + ]; + assert!(validator + .validate_split_percentages(&valid_swaps) + .is_ok()); + } + + #[test] + fn test_validate_swaps_no_remainder_split() { + let validator = SplitSwapValidator; + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let invalid_total_swaps = vec![ + Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.7, + }, + Swap { + component: ProtocolComponent { + id: "pool2".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.3, + }, + ]; + assert!(matches!( + validator.validate_split_percentages(&invalid_total_swaps), + Err(EncodingError::InvalidInput(msg)) if msg.contains("must have exactly one 0% split") + )); + } + + #[test] + fn test_validate_swaps_zero_split_not_at_end() { + let validator = SplitSwapValidator; + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let invalid_zero_position_swaps = vec![ + Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.0, + }, + Swap { + component: ProtocolComponent { + id: "pool2".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.5, + }, + ]; + assert!(matches!( + validator.validate_split_percentages(&invalid_zero_position_swaps), + Err(EncodingError::InvalidInput(msg)) if msg.contains("must be the last swap") + )); + } + + #[test] + fn test_validate_swaps_splits_exceed_hundred_percent() { + let validator = SplitSwapValidator; + let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(); + let dai = Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(); + + let invalid_overflow_swaps = vec![ + Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.6, + }, + Swap { + component: ProtocolComponent { + id: "pool2".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.5, + }, + Swap { + component: ProtocolComponent { + id: "pool3".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: dai.clone(), + split: 0.0, + }, + ]; + assert!(matches!( + validator.validate_split_percentages(&invalid_overflow_swaps), + Err(EncodingError::InvalidInput(msg)) if msg.contains("must be <100%") + )); + } + + #[test] + fn test_validate_path_wrap_eth_given_token() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(); + + let swaps = vec![Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: usdc.clone(), + split: 0f64, + }]; + + let result = validator.validate_swap_path( + &swaps, + ð, + &usdc, + &Some(NativeAction::Wrap), + ð, + &weth, + ); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_token_path_connectivity_wrap_eth_checked_token() { + let validator = SplitSwapValidator; + let eth = Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(); + + let swaps = vec![Swap { + component: ProtocolComponent { + id: "pool1".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: usdc.clone(), + token_out: weth.clone(), + split: 0f64, + }]; + + let result = validator.validate_swap_path( + &swaps, + &usdc, + ð, + &Some(NativeAction::Unwrap), + ð, + &weth, + ); + assert_eq!(result, Ok(())); + } +} From fde870f57a66633f443ee576c1b5a3ddf1a2f9d1 Mon Sep 17 00:00:00 2001 From: semantic-release-bot Date: Tue, 18 Feb 2025 14:21:36 +0000 Subject: [PATCH 9/9] chore(release): 0.40.0 [skip ci] ## [0.40.0](https://github.com/propeller-heads/tycho-execution/compare/0.39.0...0.40.0) (2025-02-18) ### Features * (WIP) UniswapV4 encoding ([f32210b](https://github.com/propeller-heads/tycho-execution/commit/f32210bb1f6103a1775975604415295260de9107)) * Generalize group_swaps method ([47b6180](https://github.com/propeller-heads/tycho-execution/commit/47b61802eef58068d1188c25db86974c7e03f3a8)) * Merge USV4 strategy back into split strategy ([44aabf1](https://github.com/propeller-heads/tycho-execution/commit/44aabf17612994fa126cfdfa4ef7f043b825aeee)) ### Bug Fixes * Do not count intermediary tokens in indices ([e94154b](https://github.com/propeller-heads/tycho-execution/commit/e94154bc2d72c312d07eb1aa73a0fd96214ad288)) * Do not group split swaps ([957bf89](https://github.com/propeller-heads/tycho-execution/commit/957bf898f28a23dd451d8a0ab07eb221dec1dc11)) --- CHANGELOG.md | 15 +++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 011c20b..462fdcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## [0.40.0](https://github.com/propeller-heads/tycho-execution/compare/0.39.0...0.40.0) (2025-02-18) + + +### Features + +* (WIP) UniswapV4 encoding ([f32210b](https://github.com/propeller-heads/tycho-execution/commit/f32210bb1f6103a1775975604415295260de9107)) +* Generalize group_swaps method ([47b6180](https://github.com/propeller-heads/tycho-execution/commit/47b61802eef58068d1188c25db86974c7e03f3a8)) +* Merge USV4 strategy back into split strategy ([44aabf1](https://github.com/propeller-heads/tycho-execution/commit/44aabf17612994fa126cfdfa4ef7f043b825aeee)) + + +### Bug Fixes + +* Do not count intermediary tokens in indices ([e94154b](https://github.com/propeller-heads/tycho-execution/commit/e94154bc2d72c312d07eb1aa73a0fd96214ad288)) +* Do not group split swaps ([957bf89](https://github.com/propeller-heads/tycho-execution/commit/957bf898f28a23dd451d8a0ab07eb221dec1dc11)) + ## [0.39.0](https://github.com/propeller-heads/tycho-execution/compare/0.38.0...0.39.0) (2025-02-14) diff --git a/Cargo.lock b/Cargo.lock index 56600d5..3c585c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4340,7 +4340,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.39.0" +version = "0.40.0" dependencies = [ "alloy", "alloy-primitives", diff --git a/Cargo.toml b/Cargo.toml index c07ebf0..b839a16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycho-execution" -version = "0.39.0" +version = "0.40.0" edition = "2021" [[bin]]