diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d38cc..98553bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +## [0.31.0](https://github.com/propeller-heads/tycho-execution/compare/0.30.1...0.31.0) (2025-02-05) + + +### Features + +* add tests for split swap validations ([b69aef9](https://github.com/propeller-heads/tycho-execution/commit/b69aef9b8f1d253bb465a39669bd18aa5f355aa5)) +* add tests for wrap unwrap case ([4d97c3f](https://github.com/propeller-heads/tycho-execution/commit/4d97c3f16d263c975f1f42bebae9666af789eb10)) +* add validation for split swap ([f80ffa9](https://github.com/propeller-heads/tycho-execution/commit/f80ffa924f1da626bef0751c92c09fb133d2ba85)) + + +### Bug Fixes + +* checks in validations ([95edd5b](https://github.com/propeller-heads/tycho-execution/commit/95edd5b1fe99fd96163dcf74c2a570a7c8a480a1)) +* get_mock_split_swap_strategy_encoder ([7b72263](https://github.com/propeller-heads/tycho-execution/commit/7b7226356d3bde61da987946dbc10ae3eec33722)) +* use native action to validate path ([c787f5e](https://github.com/propeller-heads/tycho-execution/commit/c787f5e722ad8a9f9a24e6ea09f59dfcf5f82239)) + +## [0.30.1](https://github.com/propeller-heads/tycho-execution/compare/0.30.0...0.30.1) (2025-02-04) + + +### Bug Fixes + +* deprecated signature ([576f89d](https://github.com/propeller-heads/tycho-execution/commit/576f89d24ca25ab37ae59b4db97cbff946d6da58)) + ## [0.30.0](https://github.com/propeller-heads/tycho-execution/compare/0.29.1...0.30.0) (2025-02-04) diff --git a/Cargo.lock b/Cargo.lock index e678f46..21b6199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea8ebf106e84a1c37f86244df7da0c7587e697b71a0d565cce079449b85ac6f8" +checksum = "bbcc41e8a11a4975b18ec6afba2cc48d591fa63336a4c526dacb50479a8d6b35" dependencies = [ "alloy-consensus", "alloy-core", @@ -82,20 +82,35 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ed961a48297c732a5d97ee321aa8bb5009ecadbcb077d8bec90cb54e651629" +checksum = "f4138dc275554afa6f18c4217262ac9388790b2fc393c2dfe03c51d357abf013" dependencies = [ "alloy-eips", "alloy-primitives", "alloy-rlp", "alloy-serde", + "alloy-trie", "auto_impl", "c-kzg", "derive_more", "serde", ] +[[package]] +name = "alloy-consensus-any" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa04e1882c31288ce1028fdf31b6ea94cfa9eafa2e497f903ded631c8c6a42c" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + [[package]] name = "alloy-core" version = "0.8.19" @@ -140,9 +155,9 @@ dependencies = [ [[package]] name = "alloy-eip7702" -version = "0.3.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64ffc577390ce50234e02d841214b3dc0bea6aaaae8e04bbf3cb82e9a45da9eb" +checksum = "cabf647eb4650c91a9d38cb6f972bb320009e7e9d61765fb688a86f1563b33e8" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -152,9 +167,9 @@ dependencies = [ [[package]] name = "alloy-eips" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b69e06cf9c37be824b9d26d6d101114fdde6af0c87de2828b414c05c4b3daa71" +checksum = "52dd5869ed09e399003e0e0ec6903d981b2a92e74c5d37e6b40890bad2517526" dependencies = [ "alloy-eip2930", "alloy-eip7702", @@ -170,12 +185,14 @@ dependencies = [ [[package]] name = "alloy-genesis" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dde15e14944a88bd6a57d325e9a49b75558746fe16aaccc79713ae50a6a9574c" +checksum = "e7d2a7fe5c1a9bd6793829ea21a636f30fc2b3f5d2e7418ba86d96e41dd1f460" dependencies = [ + "alloy-eips", "alloy-primitives", "alloy-serde", + "alloy-trie", "serde", ] @@ -193,29 +210,31 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af5979e0d5a7bf9c7eb79749121e8256e59021af611322aee56e77e20776b4b3" +checksum = "2008bedb8159a255b46b7c8614516eda06679ea82f620913679afbd8031fea72" dependencies = [ "alloy-primitives", "alloy-sol-types", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.11", "tracing", ] [[package]] name = "alloy-network" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "204237129086ce5dc17a58025e93739b01b45313841f98fa339eb1d780511e57" +checksum = "4556f01fe41d0677495df10a648ddcf7ce118b0e8aa9642a0e2b6dd1fb7259de" dependencies = [ "alloy-consensus", + "alloy-consensus-any", "alloy-eips", "alloy-json-rpc", "alloy-network-primitives", "alloy-primitives", + "alloy-rpc-types-any", "alloy-rpc-types-eth", "alloy-serde", "alloy-signer", @@ -223,14 +242,16 @@ dependencies = [ "async-trait", "auto_impl", "futures-utils-wasm", - "thiserror 1.0.69", + "serde", + "serde_json", + "thiserror 2.0.11", ] [[package]] name = "alloy-network-primitives" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514f70ee2a953db21631cd817b13a1571474ec77ddc03d47616d5e8203489fde" +checksum = "f31c3c6b71340a1d076831823f09cb6e02de01de5c6630a9631bdb36f947ff80" dependencies = [ "alloy-consensus", "alloy-eips", @@ -268,9 +289,9 @@ dependencies = [ [[package]] name = "alloy-provider" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4814d141ede360bb6cd1b4b064f1aab9de391e7c4d0d4d50ac89ea4bc1e25fbd" +checksum = "5a22c4441b3ebe2d77fa9cf629ba68c3f713eb91779cff84275393db97eddd82" dependencies = [ "alloy-chains", "alloy-consensus", @@ -296,7 +317,7 @@ dependencies = [ "schnellru", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.11", "tokio", "tracing", "url", @@ -327,9 +348,9 @@ dependencies = [ [[package]] name = "alloy-rpc-client" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fc2bd1e7403463a5f2c61e955bcc9d3072b63aa177442b0f9aa6a6d22a941e3" +checksum = "d06a292b37e182e514903ede6e623b9de96420e8109ce300da288a96d88b7e4b" dependencies = [ "alloy-json-rpc", "alloy-primitives", @@ -350,9 +371,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eea9bf1abdd506f985a53533f5ac01296bcd6102c5e139bbc5d40bc468d2c916" +checksum = "9383845dd924939e7ab0298bbfe231505e20928907d7905aa3bf112287305e06" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -361,29 +382,41 @@ dependencies = [ ] [[package]] -name = "alloy-rpc-types-eth" -version = "0.5.4" +name = "alloy-rpc-types-any" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b034779a4850b4b03f5be5ea674a1cf7d746b2da762b34d1860ab45e48ca27" +checksum = "ca445cef0eb6c2cf51cfb4e214fbf1ebd00893ae2e6f3b944c8101b07990f988" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0938bc615c02421bd86c1733ca7205cc3d99a122d9f9bff05726bd604b76a5c2" dependencies = [ "alloy-consensus", + "alloy-consensus-any", "alloy-eips", "alloy-network-primitives", "alloy-primitives", "alloy-rlp", "alloy-serde", "alloy-sol-types", - "derive_more", "itertools 0.13.0", "serde", "serde_json", + "thiserror 2.0.11", ] [[package]] name = "alloy-serde" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "028e72eaa9703e4882344983cfe7636ce06d8cce104a78ea62fd19b46659efc4" +checksum = "ae0465c71d4dced7525f408d84873aeebb71faf807d22d74c4a426430ccd9b55" dependencies = [ "alloy-primitives", "serde", @@ -392,9 +425,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "592c185d7100258c041afac51877660c7bf6213447999787197db4842f0e938e" +checksum = "9bfa395ad5cc952c82358d31e4c68b27bf4a89a5456d9b27e226e77dac50e4ff" dependencies = [ "alloy-dyn-abi", "alloy-primitives", @@ -403,14 +436,14 @@ dependencies = [ "auto_impl", "elliptic-curve", "k256", - "thiserror 1.0.69", + "thiserror 2.0.11", ] [[package]] name = "alloy-signer-aws" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a406102908a4e51834f32c4e5c1b29aa2c407b3fd23a5cad129c28b56d85e1b8" +checksum = "0eb06810c34427d499863817eb506acf57cb9ded9224b374116cae4e22dbd4e9" dependencies = [ "alloy-consensus", "alloy-network", @@ -420,15 +453,15 @@ dependencies = [ "aws-sdk-kms", "k256", "spki", - "thiserror 1.0.69", + "thiserror 2.0.11", "tracing", ] [[package]] name = "alloy-signer-gcp" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8d363e12280cb43747d3b62a1e6f00d595bc1a56464bb20200c6b6ca5d68185" +checksum = "d629e63fec8802ad53706d46e8eceeeae2b135c6648d0de41669a523bf17df4a" dependencies = [ "alloy-consensus", "alloy-network", @@ -438,15 +471,15 @@ dependencies = [ "gcloud-sdk", "k256", "spki", - "thiserror 1.0.69", + "thiserror 2.0.11", "tracing", ] [[package]] name = "alloy-signer-ledger" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a642c9f66ac73ae0d5398ce7ce3ce5bdfad5658d549abd48ea48962e585dca" +checksum = "b426789566a19252cb46b757d91543a6f8e70330c72f312b86c5878595d092ef" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -458,15 +491,15 @@ dependencies = [ "coins-ledger", "futures-util", "semver 1.0.24", - "thiserror 1.0.69", + "thiserror 2.0.11", "tracing", ] [[package]] name = "alloy-signer-local" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6614f02fc1d5b079b2a4a5320018317b506fd0a6d67c1fd5542a71201724986c" +checksum = "fbdc63ce9eda1283fcbaca66ba4a414b841c0e3edbeef9c86a71242fc9e84ccc" dependencies = [ "alloy-consensus", "alloy-network", @@ -475,7 +508,7 @@ dependencies = [ "async-trait", "k256", "rand", - "thiserror 1.0.69", + "thiserror 2.0.11", ] [[package]] @@ -550,9 +583,9 @@ dependencies = [ [[package]] name = "alloy-transport" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be77579633ebbc1266ae6fd7694f75c408beb1aeb6865d0b18f22893c265a061" +checksum = "d17722a198f33bbd25337660787aea8b8f57814febb7c746bc30407bdfc39448" dependencies = [ "alloy-json-rpc", "base64 0.22.1", @@ -560,7 +593,7 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.11", "tokio", "tower 0.5.2", "tracing", @@ -570,9 +603,9 @@ dependencies = [ [[package]] name = "alloy-transport-http" -version = "0.5.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fd1a5d0827939847983b46f2f79510361f901dc82f8e3c38ac7397af142c6e" +checksum = "6e1509599021330a31c4a6816b655e34bf67acb1cc03c564e09fd8754ff6c5de" dependencies = [ "alloy-json-rpc", "alloy-transport", @@ -583,6 +616,22 @@ dependencies = [ "url", ] +[[package]] +name = "alloy-trie" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6917c79e837aa7b77b7a6dae9f89cbe15313ac161c4d3cfaf8909ef21f3d22d8" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "arrayvec", + "derive_more", + "nybbles", + "serde", + "smallvec", + "tracing", +] + [[package]] name = "android-tzdata" version = "0.1.1" @@ -733,6 +782,9 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] [[package]] name = "async-compression" @@ -2590,6 +2642,19 @@ dependencies = [ "syn 2.0.96", ] +[[package]] +name = "nybbles" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983bb634df7248924ee0c4c3a749609b5abcb082c28fffe3254b3eb3602b307" +dependencies = [ + "alloy-rlp", + "const-hex", + "proptest", + "serde", + "smallvec", +] + [[package]] name = "object" version = "0.36.7" @@ -3644,6 +3709,9 @@ name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -4163,7 +4231,7 @@ dependencies = [ [[package]] name = "tycho-execution" -version = "0.30.0" +version = "0.31.0" dependencies = [ "alloy", "alloy-primitives", @@ -4470,9 +4538,9 @@ dependencies = [ [[package]] name = "wasmtimer" -version = "0.2.1" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7ed9d8b15c7fb594d72bfb4b5a276f3d2029333cd93a932f376f5937f6f80ee" +checksum = "0048ad49a55b9deb3953841fa1fc5858f0efbcb7a18868c899a360269fac1b23" dependencies = [ "futures", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index d980b9f..8a363ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tycho-execution" -version = "0.30.0" +version = "0.31.0" edition = "2021" [dependencies] @@ -15,7 +15,7 @@ thiserror = "1.0.69" tokio = { version = "1.38.0", features = ["full"] } chrono = "0.4.39" -alloy = { version = "0.5.4", features = ["providers", "rpc-types-eth", "eip712", "signer-local"], optional = true } +alloy = { version = "0.9.2", features = ["providers", "rpc-types-eth", "eip712", "signer-local"], optional = true } alloy-sol-types = { version = "0.8.14", optional = true } alloy-primitives = { version = "0.8.9", optional = true } tycho-core = { git = "https://github.com/propeller-heads/tycho-indexer.git", package = "tycho-core", tag = "0.46.0" } diff --git a/examples/quickstart/main.rs b/examples/quickstart/main.rs index f01b8e9..a86be5b 100644 --- a/examples/quickstart/main.rs +++ b/examples/quickstart/main.rs @@ -1,7 +1,10 @@ use std::str::FromStr; use num_bigint::BigUint; -use tycho_core::{dto::ProtocolComponent, models::Chain, Bytes}; +use tycho_core::{ + dto::{Chain, ProtocolComponent}, + Bytes, +}; use tycho_execution::encoding::{ evm::{ strategy_encoder::strategy_encoder_registry::EVMStrategyEncoderRegistry, diff --git a/src/encoding/evm/approvals/permit2.rs b/src/encoding/evm/approvals/permit2.rs index 1423ce0..b189d08 100644 --- a/src/encoding/evm/approvals/permit2.rs +++ b/src/encoding/evm/approvals/permit2.rs @@ -7,14 +7,12 @@ use alloy::{ signers::{local::PrivateKeySigner, SignerSync}, transports::BoxTransport, }; -#[allow(deprecated)] -use alloy_primitives::Signature; -use alloy_primitives::B256; +use alloy_primitives::{PrimitiveSignature as Signature, B256}; use alloy_sol_types::{eip712_domain, sol, SolStruct, SolValue}; use chrono::Utc; use num_bigint::BigUint; use tokio::runtime::Runtime; -use tycho_core::{models::Chain, Bytes}; +use tycho_core::{dto::Chain, Bytes}; use crate::encoding::{ errors::EncodingError, @@ -117,7 +115,6 @@ impl Permit2 { } } /// Creates permit single and signature - #[allow(deprecated)] pub fn get_permit( &self, spender: &Bytes, diff --git a/src/encoding/evm/constants.rs b/src/encoding/evm/constants.rs index af1c5d9..f73f6a5 100644 --- a/src/encoding/evm/constants.rs +++ b/src/encoding/evm/constants.rs @@ -1,5 +1,5 @@ use alloy_primitives::hex; -use tycho_core::{models::Chain, Bytes}; +use tycho_core::{dto::Chain, Bytes}; pub fn native_address(chain: Chain) -> Bytes { match chain { diff --git a/src/encoding/evm/models.rs b/src/encoding/evm/models.rs index 68121bf..706bc46 100644 --- a/src/encoding/evm/models.rs +++ b/src/encoding/evm/models.rs @@ -1,4 +1,4 @@ -use tycho_core::models::Chain; +use tycho_core::dto::Chain; pub struct ChainId(u64); diff --git a/src/encoding/evm/strategy_encoder/strategy_encoder_registry.rs b/src/encoding/evm/strategy_encoder/strategy_encoder_registry.rs index 5554c9d..94a8253 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoder_registry.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoder_registry.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use tycho_core::models::Chain; +use tycho_core::dto::Chain; use crate::encoding::{ errors::EncodingError, diff --git a/src/encoding/evm/strategy_encoder/strategy_encoders.rs b/src/encoding/evm/strategy_encoder/strategy_encoders.rs index a6f37f6..cfc3f71 100644 --- a/src/encoding/evm/strategy_encoder/strategy_encoders.rs +++ b/src/encoding/evm/strategy_encoder/strategy_encoders.rs @@ -1,19 +1,23 @@ -use std::{cmp::max, collections::HashSet, str::FromStr}; +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, models::Chain, Bytes}; +use tycho_core::{dto::Chain, keccak256, Bytes}; use crate::encoding::{ errors::EncodingError, evm::{ approvals::permit2::Permit2, - constants::wrapped_address, + constants::{native_address, wrapped_address}, swap_encoder::swap_encoder_registry::SwapEncoderRegistry, utils::{biguint_to_u256, bytes_to_address, encode_input, percentage_to_uint24}, }, - models::{EncodingContext, NativeAction, Solution}, + models::{EncodingContext, NativeAction, Solution, Swap}, strategy_encoder::StrategyEncoder, swap_encoder::SwapEncoder, }; @@ -59,6 +63,7 @@ pub struct SplitSwapStrategyEncoder { permit2: Permit2, selector: String, wrapped_address: Bytes, + native_address: Bytes, } impl SplitSwapStrategyEncoder { @@ -69,13 +74,156 @@ impl SplitSwapStrategyEncoder { ) -> Result { let selector = "swap(uint256,address,address,uint256,bool,bool,uint256,address,((address,uint160,uint48,uint48),address,uint256),bytes,bytes)".to_string(); let wrapped_address = wrapped_address(chain); + let native_address = native_address(chain); Ok(Self { permit2: Permit2::new(signer_pk, chain)?, selector, swap_encoder_registry, wrapped_address, + native_address, }) } + + 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(()) + } + + 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 { @@ -84,6 +232,13 @@ impl StrategyEncoder for SplitSwapStrategyEncoder { solution: Solution, router_address: Bytes, ) -> Result<(Vec, Bytes), 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( &router_address, &solution.sender, @@ -676,4 +831,359 @@ mod tests { let _hex_calldata = encode(&calldata); 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, Chain::Ethereum, swap_encoder_registry).unwrap() + } + + #[test] + fn test_validate_path_single_swap() { + let encoder = get_mock_split_swap_strategy_encoder(); + 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 = encoder.validate_swap_path(&swaps, &weth, &dai, &None); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_path_multiple_swaps() { + let encoder = get_mock_split_swap_strategy_encoder(); + 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 = encoder.validate_swap_path(&swaps, &weth, &usdc, &None); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_path_disconnected() { + let encoder = get_mock_split_swap_strategy_encoder(); + 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 = encoder.validate_swap_path(&disconnected_swaps, &weth, &usdc, &None); + assert!(matches!( + result, + Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") + )); + } + + #[test] + fn test_validate_path_unreachable_checked_token() { + let encoder = get_mock_split_swap_strategy_encoder(); + 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 = encoder.validate_swap_path(&unreachable_swaps, &weth, &usdc, &None); + assert!(matches!( + result, + Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") + )); + } + + #[test] + fn test_validate_path_empty_swaps() { + let encoder = get_mock_split_swap_strategy_encoder(); + 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); + assert!(matches!( + result, + Err(EncodingError::InvalidInput(msg)) if msg.contains("not reachable through swap path") + )); + } + + #[test] + fn test_validate_swap_single() { + let encoder = get_mock_split_swap_strategy_encoder(); + 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 = encoder.validate_split_percentages(&swaps); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_swaps_multiple() { + 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%) + }, + ]; + let encoder = get_mock_split_swap_strategy_encoder(); + assert!(encoder + .validate_split_percentages(&valid_swaps) + .is_ok()); + } + + #[test] + fn test_validate_swaps_no_remainder_split() { + 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, + }, + ]; + let encoder = get_mock_split_swap_strategy_encoder(); + assert!(matches!( + encoder.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 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, + }, + ]; + let encoder = get_mock_split_swap_strategy_encoder(); + assert!(matches!( + encoder.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 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, + }, + ]; + let encoder = get_mock_split_swap_strategy_encoder(); + assert!(matches!( + encoder.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 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 = encoder.validate_swap_path(&swaps, ð, &usdc, &Some(NativeAction::Wrap)); + assert_eq!(result, Ok(())); + } + + #[test] + fn test_validate_token_path_connectivity_wrap_eth_checked_token() { + let encoder = get_mock_split_swap_strategy_encoder(); + + 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 = encoder.validate_swap_path(&swaps, &usdc, ð, &Some(NativeAction::Unwrap)); + assert_eq!(result, Ok(())); + } } diff --git a/src/encoding/evm/swap_encoder/swap_encoder_registry.rs b/src/encoding/evm/swap_encoder/swap_encoder_registry.rs index 639dfc0..b04e580 100644 --- a/src/encoding/evm/swap_encoder/swap_encoder_registry.rs +++ b/src/encoding/evm/swap_encoder/swap_encoder_registry.rs @@ -1,6 +1,6 @@ use std::{collections::HashMap, fs}; -use tycho_core::models::Chain; +use tycho_core::dto::Chain; use crate::encoding::{ errors::EncodingError, evm::swap_encoder::builder::SwapEncoderBuilder, diff --git a/src/encoding/evm/tycho_encoder.rs b/src/encoding/evm/tycho_encoder.rs index e03576b..a29779a 100644 --- a/src/encoding/evm/tycho_encoder.rs +++ b/src/encoding/evm/tycho_encoder.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use num_bigint::BigUint; -use tycho_core::{models::Chain, Bytes}; +use tycho_core::{dto::Chain, Bytes}; use crate::encoding::{ errors::EncodingError, @@ -119,7 +119,7 @@ impl TychoEncoder for EVMTychoEncoder { #[cfg(test)] mod tests { - use tycho_core::{dto::ProtocolComponent, models::Chain}; + use tycho_core::dto::{Chain, ProtocolComponent}; use super::*; use crate::encoding::{ diff --git a/src/encoding/strategy_encoder.rs b/src/encoding/strategy_encoder.rs index 04d23e7..1b5013b 100644 --- a/src/encoding/strategy_encoder.rs +++ b/src/encoding/strategy_encoder.rs @@ -1,4 +1,4 @@ -use tycho_core::{models::Chain, Bytes}; +use tycho_core::{dto::Chain, Bytes}; use crate::encoding::{errors::EncodingError, models::Solution, swap_encoder::SwapEncoder};