diff --git a/foundry/test/TychoRouterIntegration.t.sol b/foundry/test/TychoRouterIntegration.t.sol index a473d9d..85ade39 100644 --- a/foundry/test/TychoRouterIntegration.t.sol +++ b/foundry/test/TychoRouterIntegration.t.sol @@ -5,9 +5,6 @@ import "./TychoRouterTestSetup.sol"; contract TychoRouterTestIntegration is TychoRouterTestSetup { function testSplitSwapSingleIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping WETH -> DAI on a USV2 pool deal(WETH_ADDR, ALICE, 1 ether); uint256 balancerBefore = IERC20(DAI_ADDR).balanceOf(ALICE); @@ -29,9 +26,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { } function testSplitSwapSingleWithoutPermit2Integration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping WETH -> DAI on a USV2 pool without permit2 deal(WETH_ADDR, ALICE, 1 ether); vm.startPrank(ALICE); @@ -127,9 +121,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { } function testSplitSwapSingleWithWrapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping WETH -> DAI on a USV2 pool, but ETH is received from the user // and wrapped before the swap deal(ALICE, 1 ether); @@ -151,9 +142,6 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { } function testSplitSwapSingleWithUnwrapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Tests swapping DAI -> WETH on a USV2 pool, and WETH is unwrapped to ETH // before sending back to the user deal(DAI_ADDR, ALICE, 3000 ether); @@ -206,16 +194,10 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertTrue(success, "Call Failed"); assertGe(balancerAfter - balancerBefore, 26173932); - - // All input tokens are transferred to the router at first. Make sure we used - // all of it (and thus our splits are correct). assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } function testSplitSwapIntegration() public { - // Test created with calldata from our router encoder, replacing the executor - // address with the USV2 executor address. - // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools // // ┌──(USV2)──> WBTC ───(USV2)──> USDC @@ -244,6 +226,54 @@ contract TychoRouterTestIntegration is TychoRouterTestSetup { assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); } + function testSequentialSwapIntegrationPermit2() public { + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ──(USV2)──> WBTC ───(USV2)──> USDC + deal(WETH_ADDR, ALICE, 1 ether); + uint256 balancerBefore = IERC20(USDC_ADDR).balanceOf(ALICE); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(PERMIT2_ADDRESS, type(uint256).max); + // Encoded solution generated using `test_sequential_swap_strategy_encoder_complex_route` + (bool success,) = tychoRouterAddr.call( + hex"51bcc7b60000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000000000068168aea00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003ede3eca2a72b3aecc820e955b36f38437d013950000000000000000000000000000000000000000000000000000000067ef04f200000000000000000000000000000000000000000000000000000000000001e00000000000000000000000000000000000000000000000000000000000000260000000000000000000000000000000000000000000000000000000000000004154956683effd126a9182e2d82ebd3d778e5283b93d571b13cdbc9dfbf3d9f655057a2332ed566f79bed7514a22ef1c52969132bc71a5a2ef125d78e39ec264511c0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a600515615deb798bb3e4dfa0139dfa1b3d433cc23b72fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d013950000515615deb798bb3e4dfa0139dfa1b3d433cc23b72f2260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" + ); + + vm.stopPrank(); + + uint256 balancerAfter = IERC20(USDC_ADDR).balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertEq(balancerAfter - balancerBefore, 2552915143); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + + function testSequentialSwapIntegration() public { + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ──(USV2)──> WBTC ───(USV2)──> USDC + deal(WETH_ADDR, ALICE, 1 ether); + uint256 balancerBefore = IERC20(USDC_ADDR).balanceOf(ALICE); + + // Approve permit2 + vm.startPrank(ALICE); + IERC20(WETH_ADDR).approve(tychoRouterAddr, type(uint256).max); + // Encoded solution generated using `test_sequential_swap_strategy_encoder_no_permit2` + (bool success,) = tychoRouterAddr.call( + hex"e8a980d70000000000000000000000000000000000000000000000000de0b6b3a7640000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000000000000000000000000000000000000018f61ec00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000cd09f75e2bf2a4d11f3ab23f1389fcc1621c0cc2000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000a600515615deb798bb3e4dfa0139dfa1b3d433cc23b72fc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2bb2b8038a1640196fbe3e38816f3e67cba72d9403ede3eca2a72b3aecc820e955b36f38437d013950000515615deb798bb3e4dfa0139dfa1b3d433cc23b72f2260fac5e5542a773aa44fbcfedf7c193bc2c599004375dff511095cc5a197a54140a24efef3a4163ede3eca2a72b3aecc820e955b36f38437d01395010000000000000000000000000000000000000000000000000000" + ); + + vm.stopPrank(); + + uint256 balancerAfter = IERC20(USDC_ADDR).balanceOf(ALICE); + + assertTrue(success, "Call Failed"); + assertEq(balancerAfter - balancerBefore, 2552915143); + assertEq(IERC20(WETH_ADDR).balanceOf(tychoRouterAddr), 0); + } + function testCyclicSequentialSwapIntegration() public { deal(USDC_ADDR, ALICE, 100 * 10 ** 6); diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index f2192bb..54fc5de 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -12,7 +12,10 @@ use crate::encoding::{ evm::{ approvals::permit2::Permit2, constants::DEFAULT_ROUTERS_JSON, - strategy_encoder::{group_swaps::group_swaps, strategy_validators::SplitSwapValidator}, + strategy_encoder::{ + group_swaps::group_swaps, + strategy_validators::{SequentialSwapValidator, SplitSwapValidator, SwapValidator}, + }, swap_encoder::swap_encoder_registry::SwapEncoderRegistry, utils::{ biguint_to_u256, bytes_to_address, encode_input, get_min_amount_for_solution, @@ -62,7 +65,7 @@ impl SingleSwapStrategyEncoder { Ok(Self { permit2, selector, swap_encoder_registry, router_address }) } - /// Encodes information necessary for performing a single swap against a given executor for + /// Encodes information necessary for performing a single hop against a given executor for /// a protocol. fn encode_swap_header(&self, executor_address: Bytes, protocol_data: Vec) -> Vec { let mut encoded = Vec::new(); @@ -179,7 +182,178 @@ impl StrategyEncoder for SingleSwapStrategyEncoder { } } -/// Represents the encoder for a swap strategy which supports single, sequential and split swaps. +/// Represents the encoder for a swap strategy which supports sequential swaps. +/// +/// # Fields +/// * `swap_encoder_registry`: SwapEncoderRegistry, containing all possible swap encoders +/// * `permit2`: Permit2, responsible for managing permit2 operations and providing necessary +/// signatures and permit2 objects for calling the router +/// * `selector`: String, the selector for the swap function in the router contract +/// * `native_address`: Address of the chain's native token +/// * `wrapped_address`: Address of the chain's wrapped token +/// * `router_address`: Address of the router to be used to execute swaps +/// * `sequential_swap_validator`: SequentialSwapValidator, responsible for checking validity of +/// sequential swap solutions +#[derive(Clone)] +pub struct SequentialSwapStrategyEncoder { + swap_encoder_registry: SwapEncoderRegistry, + permit2: Option, + selector: String, + router_address: Bytes, + native_address: Bytes, + wrapped_address: Bytes, + sequential_swap_validator: SequentialSwapValidator, +} + +impl SequentialSwapStrategyEncoder { + pub fn new( + blockchain: tycho_common::models::Chain, + swap_encoder_registry: SwapEncoderRegistry, + swapper_pk: Option, + router_address: Bytes, + ) -> Result { + let chain = Chain::from(blockchain); + let (permit2, selector) = if let Some(swapper_pk) = swapper_pk { + (Some(Permit2::new(swapper_pk, chain.clone())?), "sequentialSwapPermit2(uint256,address,address,uint256,bool,bool,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string()) + } else { + ( + None, + "sequentialSwap(uint256,address,address,uint256,bool,bool,address,bytes)" + .to_string(), + ) + }; + Ok(Self { + permit2, + selector, + swap_encoder_registry, + router_address, + native_address: chain.native_token()?, + wrapped_address: chain.wrapped_token()?, + sequential_swap_validator: SequentialSwapValidator, + }) + } + + /// Encodes information necessary for performing a single hop against a given executor for + /// a protocol. + fn encode_swap_header(&self, executor_address: Bytes, protocol_data: Vec) -> Vec { + let mut encoded = Vec::new(); + encoded.extend(executor_address.to_vec()); + encoded.extend(protocol_data); + encoded + } +} + +impl EVMStrategyEncoder for SequentialSwapStrategyEncoder {} + +impl StrategyEncoder for SequentialSwapStrategyEncoder { + fn encode_strategy(&self, solution: Solution) -> Result<(Vec, Bytes), EncodingError> { + self.sequential_swap_validator + .validate_solution_min_amounts(&solution)?; + self.sequential_swap_validator + .validate_swap_path( + &solution.swaps, + &solution.given_token, + &solution.checked_token, + &solution.native_action, + &self.native_address, + &self.wrapped_address, + )?; + + let min_amount_out = get_min_amount_for_solution(solution.clone()); + let grouped_swaps = group_swaps(solution.swaps); + + let (mut unwrap, mut wrap) = (false, false); + if let Some(action) = solution.native_action.clone() { + match action { + NativeAction::Wrap => wrap = true, + NativeAction::Unwrap => unwrap = true, + } + } + + let mut swaps = vec![]; + for grouped_swap in grouped_swaps.iter() { + let swap_encoder = self + .get_swap_encoder(&grouped_swap.protocol_system) + .ok_or_else(|| { + EncodingError::InvalidInput(format!( + "Swap encoder not found for protocol: {}", + grouped_swap.protocol_system + )) + })?; + + let mut grouped_protocol_data: Vec = vec![]; + for swap in grouped_swap.swaps.iter() { + let encoding_context = EncodingContext { + receiver: self.router_address.clone(), + exact_out: solution.exact_out, + router_address: Some(self.router_address.clone()), + group_token_in: grouped_swap.input_token.clone(), + group_token_out: grouped_swap.output_token.clone(), + }; + let protocol_data = + swap_encoder.encode_swap(swap.clone(), encoding_context.clone())?; + grouped_protocol_data.extend(protocol_data); + } + + let swap_data = self.encode_swap_header( + Bytes::from_str(swap_encoder.executor_address()).map_err(|_| { + EncodingError::FatalError("Invalid executor address".to_string()) + })?, + grouped_protocol_data, + ); + swaps.push(swap_data); + } + + let encoded_swaps = self.ple_encode(swaps); + let method_calldata = if let Some(permit2) = self.permit2.clone() { + let (permit, signature) = permit2.get_permit( + &self.router_address, + &solution.sender, + &solution.given_token, + &solution.given_amount, + )?; + ( + biguint_to_u256(&solution.given_amount), + bytes_to_address(&solution.given_token)?, + bytes_to_address(&solution.checked_token)?, + biguint_to_u256(&min_amount_out), + wrap, + unwrap, + bytes_to_address(&solution.receiver)?, + permit, + signature.as_bytes().to_vec(), + encoded_swaps, + ) + .abi_encode() + } else { + ( + biguint_to_u256(&solution.given_amount), + bytes_to_address(&solution.given_token)?, + bytes_to_address(&solution.checked_token)?, + biguint_to_u256(&min_amount_out), + wrap, + unwrap, + bytes_to_address(&solution.receiver)?, + encoded_swaps, + ) + .abi_encode() + }; + + let contract_interaction = encode_input(&self.selector, method_calldata); + Ok((contract_interaction, self.router_address.clone())) + } + + fn get_swap_encoder(&self, protocol_system: &str) -> Option<&Box> { + self.swap_encoder_registry + .get_encoder(protocol_system) + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +/// Represents the encoder for a swap strategy which supports split swaps. /// /// # Fields /// * `swap_encoder_registry`: SwapEncoderRegistry, containing all possible swap encoders @@ -245,7 +419,7 @@ impl SplitSwapStrategyEncoder { }) } - /// Encodes information necessary for performing a single swap against a given executor for + /// Encodes information necessary for performing a single hop against a given executor for /// a protocol as part of a split swap solution. fn encode_swap_header( &self, @@ -870,6 +1044,7 @@ mod tests { #[case] expected_min_amount: U256, ) { // Performs a single swap from WETH to DAI on a USV2 pool, with no grouping optimizations. + // Set up a mock private key for signing let private_key = "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); @@ -892,7 +1067,7 @@ mod tests { eth_chain(), swap_encoder_registry, Some(private_key), - Bytes::from("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395"), + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), ) .unwrap(); let solution = Solution { @@ -929,8 +1104,9 @@ mod tests { // it's hard to assert let expected_swap = String::from(concat!( - // length of swap bytes + // length of ple encoded swaps without padding "0000000000000000000000000000000000000000000000000000000000000051", + // Swap data "5615deb798bb3e4dfa0139dfa1b3d433cc23b72f", // executor address "c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2", // token in "a478c2975ab1ea89e8196811f51a7b7ade33eb11", // component id @@ -1193,6 +1369,134 @@ mod tests { println!("{}", _hex_calldata); } + #[test] + fn test_sequential_swap_strategy_encoder_complex_route() { + // Note: This test does not assert anything. It is only used to obtain integration test + // data for our router solidity test. + // + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ───(USV2)──> WBTC ───(USV2)──> USDC + + // Set up a mock private key for signing + let private_key = + "0x123456789abcdef123456789abcdef123456789abcdef123456789abcdef1234".to_string(); + + let weth = weth(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let swap_weth_wbtc = Swap { + component: ProtocolComponent { + id: "0xBb2b8038a1640196FbE3e38816F3e67Cba72D940".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: wbtc.clone(), + split: 0f64, + }; + let swap_wbtc_usdc = Swap { + component: ProtocolComponent { + id: "0x004375Dff511095CC5A197A54140a24eFEF3A416".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SequentialSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + Some(private_key), + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + ) + .unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: usdc, + expected_amount: None, + checked_amount: Some(BigUint::from_str("26173932").unwrap()), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let _hex_calldata = encode(&calldata); + println!("{}", _hex_calldata); + } + + #[test] + fn test_sequential_swap_strategy_encoder_no_permit2() { + // Note: This test does not assert anything. It is only used to obtain integration test + // data for our router solidity test. + // + // Performs a split swap from WETH to USDC though WBTC and DAI using USV2 pools + // + // WETH ───(USV2)──> WBTC ───(USV2)──> USDC + + let weth = weth(); + let wbtc = Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(); + let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(); + + let swap_weth_wbtc = Swap { + component: ProtocolComponent { + id: "0xBb2b8038a1640196FbE3e38816F3e67Cba72D940".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: weth.clone(), + token_out: wbtc.clone(), + split: 0f64, + }; + let swap_wbtc_usdc = Swap { + component: ProtocolComponent { + id: "0x004375Dff511095CC5A197A54140a24eFEF3A416".to_string(), + protocol_system: "uniswap_v2".to_string(), + ..Default::default() + }, + token_in: wbtc.clone(), + token_out: usdc.clone(), + split: 0f64, + }; + let swap_encoder_registry = get_swap_encoder_registry(); + let encoder = SequentialSwapStrategyEncoder::new( + eth_chain(), + swap_encoder_registry, + None, + Bytes::from_str("0x3Ede3eCa2a72B3aeCC820E955B36f38437D01395").unwrap(), + ) + .unwrap(); + let solution = Solution { + exact_out: false, + given_token: weth, + given_amount: BigUint::from_str("1_000000000000000000").unwrap(), + checked_token: usdc, + expected_amount: None, + checked_amount: Some(BigUint::from_str("26173932").unwrap()), + sender: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + receiver: Bytes::from_str("0xcd09f75E2BF2A4d11F3AB23f1389FcC1621c0cc2").unwrap(), + swaps: vec![swap_weth_wbtc, swap_wbtc_usdc], + ..Default::default() + }; + + let (calldata, _) = encoder + .encode_strategy(solution) + .unwrap(); + + let _hex_calldata = encode(&calldata); + println!("{}", _hex_calldata); + } + #[test] fn test_split_encoding_strategy_usv4() { // Performs a sequential swap from USDC to PEPE though ETH using two consecutive USV4 pools diff --git a/src/encoding/evm/strategy_encoder/strategy_validators.rs b/src/encoding/evm/strategy_encoder/strategy_validators.rs index e7ae722..04396de 100644 --- a/src/encoding/evm/strategy_encoder/strategy_validators.rs +++ b/src/encoding/evm/strategy_encoder/strategy_validators.rs @@ -7,92 +7,10 @@ use crate::encoding::{ models::{NativeAction, Solution, Swap}, }; -/// Validates whether a sequence of split swaps represents a valid solution. -#[derive(Clone)] -pub struct SplitSwapValidator; - -impl SplitSwapValidator { - /// Raises an error if the split percentages are invalid. - /// - /// Split percentages are considered valid if all the following conditions are met: - /// * Each split amount is < 1 (100%) - /// * There is exactly one 0% split for each token, and it's the last swap specified, signifying - /// to the router to send the remainder of the token to the designated protocol - /// * The sum of all non-remainder splits for each token is < 1 (100%) - /// * There are no negative split amounts - pub fn validate_split_percentages(&self, swaps: &[Swap]) -> Result<(), EncodingError> { - let mut swaps_by_token: HashMap> = HashMap::new(); - for swap in swaps { - if swap.split >= 1.0 { - return Err(EncodingError::InvalidInput(format!( - "Split percentage must be less than 1 (100%), got {}", - swap.split - ))); - } - swaps_by_token - .entry(swap.token_in.clone()) - .or_default() - .push(swap); - } - - for (token, token_swaps) in swaps_by_token { - // Single swaps don't need remainder handling - if token_swaps.len() == 1 { - if token_swaps[0].split != 0.0 { - return Err(EncodingError::InvalidInput(format!( - "Single swap must have 0% split for token {:?}", - token - ))); - } - continue; - } - - let mut found_zero_split = false; - let mut total_percentage = 0.0; - for (i, swap) in token_swaps.iter().enumerate() { - match (swap.split == 0.0, i == token_swaps.len() - 1) { - (true, false) => { - return Err(EncodingError::InvalidInput(format!( - "The 0% split for token {:?} must be the last swap", - token - ))) - } - (true, true) => found_zero_split = true, - (false, _) => { - if swap.split < 0.0 { - return Err(EncodingError::InvalidInput(format!( - "All splits must be >= 0% for token {:?}", - token - ))); - } - total_percentage += swap.split; - } - } - } - - if !found_zero_split { - return Err(EncodingError::InvalidInput(format!( - "Token {:?} must have exactly one 0% split for remainder handling", - token - ))); - } - - // Total must be <100% to leave room for remainder - if total_percentage >= 1.0 { - return Err(EncodingError::InvalidInput(format!( - "Total of non-remainder splits for token {:?} must be <100%, got {}%", - token, - total_percentage * 100.0 - ))); - } - } - - Ok(()) - } - +pub trait SwapValidator { /// Raises an error if the solution does not have checked amount set or slippage with checked /// amount set. - pub fn validate_solution_min_amounts(&self, solution: &Solution) -> Result<(), EncodingError> { + fn validate_solution_min_amounts(&self, solution: &Solution) -> Result<(), EncodingError> { if solution.checked_amount.is_none() && (solution.slippage.is_none() || solution.expected_amount.is_none()) { @@ -113,7 +31,7 @@ impl SplitSwapValidator { /// If the given token is the native token and the native action is WRAP, it will be converted /// to the wrapped token before validating the swap path. The same principle applies for the /// checked token and the UNWRAP action. - pub fn validate_swap_path( + fn validate_swap_path( &self, swaps: &[Swap], given_token: &Bytes, @@ -197,6 +115,98 @@ impl SplitSwapValidator { } } +/// Validates whether a sequence of split swaps represents a valid solution. +#[derive(Clone)] +pub struct SplitSwapValidator; + +impl SwapValidator for SplitSwapValidator {} + +impl SplitSwapValidator { + /// Raises an error if the split percentages are invalid. + /// + /// Split percentages are considered valid if all the following conditions are met: + /// * Each split amount is < 1 (100%) + /// * There is exactly one 0% split for each token, and it's the last swap specified, signifying + /// to the router to send the remainder of the token to the designated protocol + /// * The sum of all non-remainder splits for each token is < 1 (100%) + /// * There are no negative split amounts + pub fn validate_split_percentages(&self, swaps: &[Swap]) -> Result<(), EncodingError> { + let mut swaps_by_token: HashMap> = HashMap::new(); + for swap in swaps { + if swap.split >= 1.0 { + return Err(EncodingError::InvalidInput(format!( + "Split percentage must be less than 1 (100%), got {}", + swap.split + ))); + } + swaps_by_token + .entry(swap.token_in.clone()) + .or_default() + .push(swap); + } + + for (token, token_swaps) in swaps_by_token { + // Single swaps don't need remainder handling + if token_swaps.len() == 1 { + if token_swaps[0].split != 0.0 { + return Err(EncodingError::InvalidInput(format!( + "Single swap must have 0% split for token {:?}", + token + ))); + } + continue; + } + + let mut found_zero_split = false; + let mut total_percentage = 0.0; + for (i, swap) in token_swaps.iter().enumerate() { + match (swap.split == 0.0, i == token_swaps.len() - 1) { + (true, false) => { + return Err(EncodingError::InvalidInput(format!( + "The 0% split for token {:?} must be the last swap", + token + ))) + } + (true, true) => found_zero_split = true, + (false, _) => { + if swap.split < 0.0 { + return Err(EncodingError::InvalidInput(format!( + "All splits must be >= 0% for token {:?}", + token + ))); + } + total_percentage += swap.split; + } + } + } + + if !found_zero_split { + return Err(EncodingError::InvalidInput(format!( + "Token {:?} must have exactly one 0% split for remainder handling", + token + ))); + } + + // Total must be <100% to leave room for remainder + if total_percentage >= 1.0 { + return Err(EncodingError::InvalidInput(format!( + "Total of non-remainder splits for token {:?} must be <100%, got {}%", + token, + total_percentage * 100.0 + ))); + } + } + + Ok(()) + } +} + +/// Validates whether a sequence of sequential swaps represents a valid solution. +#[derive(Clone)] +pub struct SequentialSwapValidator; + +impl SwapValidator for SequentialSwapValidator {} + #[cfg(test)] mod tests { use std::str::FromStr;