diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index eb1b7743..181ac574 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -118,6 +118,8 @@ jobs: include: - test: happy_e2e_open_fill_settle timeout_minutes: 10 + - test: happy_e2e_native_fill_settle + timeout_minutes: 10 - test: submit_e2e_hyperlane timeout_minutes: 10 - test: permit2_e2e diff --git a/config/demo.json b/config/demo.json index 456577a3..d9e6a731 100644 --- a/config/demo.json +++ b/config/demo.json @@ -102,6 +102,11 @@ ], "the_compact_address": "0x00000000000000000000000000000000000003ea", "tokens": [ + { + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18, + "symbol": "ETH" + }, { "address": "0x00000000000000000000000000000000000003ed", "decimals": 18, @@ -127,6 +132,11 @@ ], "the_compact_address": "0x00000000000000000000000000000000000003f3", "tokens": [ + { + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18, + "symbol": "ETH" + }, { "address": "0x00000000000000000000000000000000000003f6", "decimals": 18, diff --git a/config/example-broadcaster.json b/config/example-broadcaster.json index 0b83ae04..eb9d754c 100644 --- a/config/example-broadcaster.json +++ b/config/example-broadcaster.json @@ -17,6 +17,12 @@ "the_compact_address": "0x00000000000000171ede64904551eedf3c6c9788", "allocator_address": "0x565466528d126141ddb5c7d558803f79d9b66a6d", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", @@ -36,6 +42,12 @@ "the_compact_address": "0x00000000000000171ede64904551eedf3c6c9788", "allocator_address": "0x04bb6e565f0067e0411528e2d3a55a712d9a8b32", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", diff --git a/config/example-hyperlane.json b/config/example-hyperlane.json index d4d7338f..ee7f3a4d 100644 --- a/config/example-hyperlane.json +++ b/config/example-hyperlane.json @@ -16,6 +16,12 @@ "the_compact_address": "0x00000000000000171ede64904551eedf3c6c9788", "allocator_address": "0x565466528d126141ddb5c7d558803f79d9b66a6d", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", @@ -35,6 +41,12 @@ "the_compact_address": "0x00000000000000171ede64904551eedf3c6c9788", "allocator_address": "0x04bb6e565f0067e0411528e2d3a55a712d9a8b32", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", diff --git a/config/non-seeded-networks-example.json b/config/non-seeded-networks-example.json index 4b9d42c3..4fad85b8 100644 --- a/config/non-seeded-networks-example.json +++ b/config/non-seeded-networks-example.json @@ -12,7 +12,14 @@ "input_settler_compact_address": "0x3333333333333333333333333333333333333333", "the_compact_address": "0x4444444444444444444444444444444444444444", "allocator_address": "0x5555555555555555555555555555555555555555", - "tokens": [] + "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + } + ] }, { "chain_id": 1235, @@ -24,7 +31,14 @@ "input_settler_compact_address": "0x8888888888888888888888888888888888888888", "the_compact_address": "0x9999999999999999999999999999999999999999", "allocator_address": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokens": [] + "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + } + ] } ], "admin": { diff --git a/config/seed-overrides-kms-example.json b/config/seed-overrides-kms-example.json index 6197e933..f242e561 100644 --- a/config/seed-overrides-kms-example.json +++ b/config/seed-overrides-kms-example.json @@ -16,6 +16,12 @@ "name": "optimism-sepolia", "type": "parent", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", @@ -29,6 +35,12 @@ "name": "base-sepolia", "type": "hub", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", diff --git a/config/seed-overrides-mainnet.json b/config/seed-overrides-mainnet.json index ee630a2b..29f1c9b1 100644 --- a/config/seed-overrides-mainnet.json +++ b/config/seed-overrides-mainnet.json @@ -7,6 +7,12 @@ "name": "optimism", "type": "parent", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", @@ -20,6 +26,12 @@ "name": "base", "type": "hub", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", @@ -33,6 +45,12 @@ "name": "arbitrum", "type": "new", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", diff --git a/config/seed-overrides-testnet.json b/config/seed-overrides-testnet.json index fc2296b4..59733fea 100644 --- a/config/seed-overrides-testnet.json +++ b/config/seed-overrides-testnet.json @@ -6,6 +6,12 @@ "name": "optimism-sepolia", "type": "parent", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", @@ -19,6 +25,12 @@ "name": "base-sepolia", "type": "hub", "tokens": [ + { + "symbol": "ETH", + "name": "Ether", + "address": "0x0000000000000000000000000000000000000000", + "decimals": 18 + }, { "symbol": "USDC", "name": "USD Coin", diff --git a/crates/solver-core/src/engine/context.rs b/crates/solver-core/src/engine/context.rs index d19d11f3..d62cbcc6 100644 --- a/crates/solver-core/src/engine/context.rs +++ b/crates/solver-core/src/engine/context.rs @@ -9,7 +9,7 @@ use crate::SolverError; use alloy_primitives::hex; use solver_config::Config; use solver_delivery::DeliveryService; -use solver_types::{Address, ExecutionContext, Intent}; +use solver_types::{is_native_address, Address, ExecutionContext, Intent}; use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -250,6 +250,7 @@ impl ContextBuilder { .get_tokens_for_chain(chain_id) .await .into_iter() + .filter(|token| !is_native_address(&token.address)) .map(|token| hex::encode(&token.address.0)) .collect() } @@ -339,6 +340,11 @@ mod tests { .symbol("WETH") .decimals(18) .build(); + let native_token = TokenConfigBuilder::new() + .address(Address(vec![0u8; 20])) + .symbol("ETH") + .decimals(18) + .build(); // Use NetworkConfigBuilder but replace tokens completely using .tokens() // Note: This will extend the default tokens, so we need to account for that @@ -351,7 +357,7 @@ mod tests { .build(); // Manually set the tokens to exactly what we want (overriding defaults) - network_config.tokens = vec![usdc_token, weth_token]; + network_config.tokens = vec![native_token, usdc_token, weth_token]; NetworksConfigBuilder::new() .add_network(1, network_config) diff --git a/crates/solver-core/src/engine/cost_profit.rs b/crates/solver-core/src/engine/cost_profit.rs index 42f0242f..544eeeca 100644 --- a/crates/solver-core/src/engine/cost_profit.rs +++ b/crates/solver-core/src/engine/cost_profit.rs @@ -36,7 +36,7 @@ use solver_pricing::PricingService; use solver_storage::StorageService; use solver_types::{ costs::{CostBreakdown, CostContext, TokenAmountInfo}, - current_timestamp, + current_timestamp, is_native_token_id, standards::eip7683::{ interfaces::{IOutputSettlerSimple, SolMandateOutput}, MAX_CALLBACK_DATA_BYTES, @@ -337,6 +337,44 @@ struct GasLegCostsWei { claim: U256, } +/// Built-in `chain_id -> native gas-token symbol` fallback for well-known +/// chains. Used by [`CostProfitService::native_base_units_to_usd`] only when +/// the chain has no explicit zero-address `TokenConfig`, so gas legs still +/// price on configs that predate the native-token entry. Config always takes +/// precedence over this map. +/// +/// This fallback is intentionally GAS-ONLY. Gas is priced for EVERY order, +/// including pure-ERC20 flows, so a missing native `TokenConfig` must not +/// hard-fail gas pricing — hence the fallback. Native OUTPUT (and INPUT) +/// valuation is the opposite case: it is opt-in and rare, so it deliberately +/// does NOT consult this map (see `calculate_outputs_usd_value`) and instead +/// requires an explicit native `TokenConfig` (symbol + decimals). Extending +/// this fallback to valuation would silently misprice orders whose native +/// symbol/decimals were never actually configured. +/// +/// Returned symbols must exist in `solver_pricing::DEFAULT_TOKEN_MAPPINGS`. +/// All listed chains use an 18-decimal native token, so callers assume 18. +/// Unknown chains return `None`, keeping the caller's fail-closed behaviour. +fn native_symbol_fallback(chain_id: u64) -> Option<&'static str> { + match chain_id { + // Ethereum L1, its major rollups, and their testnets all meter gas in ETH. + 1 // Ethereum mainnet + | 10 // OP Mainnet + | 8453 // Base + | 42161 // Arbitrum One + | 747474 // Katana + | 11155111 // Sepolia + | 84532 // Base Sepolia + | 421614 // Arbitrum Sepolia + | 11155420 // OP Sepolia + => Some("ETH"), + 137 => Some("POL"), // Polygon PoS + 56 => Some("BNB"), // BNB Smart Chain + 43114 => Some("AVAX"), // Avalanche C-Chain + _ => None, + } +} + /// Compute per-leg wei costs from gas units and resolved per-chain /// `cost_per_gas` values. Origin legs (open / pre_claim / claim) use /// `origin_cost_per_gas`; destination legs (fill / post_fill) use @@ -1309,11 +1347,11 @@ impl CostProfitService { // Price conversions are independent as well. let (gas_open, gas_fill, gas_post_fill, gas_pre_claim, gas_claim) = tokio::try_join!( - self.wei_to_usd(&open_cost_wei), - self.wei_to_usd(&fill_cost_wei), - self.wei_to_usd(&post_fill_cost_wei), - self.wei_to_usd(&pre_claim_cost_wei), - self.wei_to_usd(&claim_cost_wei), + self.native_base_units_to_usd(origin_chain_id, &open_cost_wei), + self.native_base_units_to_usd(dest_chain_id, &fill_cost_wei), + self.native_base_units_to_usd(dest_chain_id, &post_fill_cost_wei), + self.native_base_units_to_usd(origin_chain_id, &pre_claim_cost_wei), + self.native_base_units_to_usd(origin_chain_id, &claim_cost_wei), )?; // Calculate gas buffer using config value (hot-reloadable) @@ -1325,14 +1363,16 @@ impl CostProfitService { // so keep this component at zero to avoid double-charging. let rate_buffer = Decimal::ZERO; - let settlement_fee = self.wei_to_usd(&settlement_fee_wei).await?; + let settlement_fee = self + .native_base_units_to_usd(dest_chain_id, &settlement_fee_wei) + .await?; let settlement_fee_buffer_bps = Decimal::new(config.solver.settlement_fee_buffer_bps as i64, 0); let settlement_fee_buffer = (settlement_fee * settlement_fee_buffer_bps) / Decimal::from(10000); let (l1_data_fee, l1_data_fee_buffer) = tokio::try_join!( - self.wei_to_usd(&l1_data_fee_wei), - self.wei_to_usd(&l1_data_fee_buffer_wei), + self.native_base_units_to_usd(dest_chain_id, &l1_data_fee_wei), + self.native_base_units_to_usd(dest_chain_id, &l1_data_fee_buffer_wei), )?; // Input and output valuations do not depend on each other. @@ -2305,6 +2345,15 @@ impl CostProfitService { let token_bytes32 = AlloyAddress::from_slice(token_addr.as_slice()).into_word(); let recipient_bytes32 = AlloyAddress::from_slice(recipient_addr.as_slice()).into_word(); let settler_bytes32 = AlloyAddress::from_slice(&settler_address.0).into_word(); + // Detect native output via the shared 32-byte identifier helper so this + // matches `build_post_fill_tx_for_quote`/production fill builders. + // `token_bytes32` is the clean 20-byte address left-padded into a word, + // so an all-zero word is exactly the native (zero-address) case. + let tx_value = if is_native_token_id(&token_bytes32.0) { + amount + } else { + U256::ZERO + }; // Hex-decode calldata, mirroring // `crates/solver-service/src/apis/quote/generation.rs:938-951`. @@ -2389,7 +2438,7 @@ impl CostProfitService { Ok(Transaction { to: Some(settler_address), data, - value: U256::ZERO, + value: tx_value, chain_id, nonce: None, gas_limit: None, @@ -2827,17 +2876,74 @@ impl CostProfitService { Ok((raw, buffer)) } - async fn wei_to_usd(&self, value_wei: &U256) -> Result { - let usd_value = self - .pricing_service - .wei_to_currency(&value_wei.to_string(), "USD") + /// Converts a native (gas-token) amount in base units to USD. + /// + /// Symbol/decimals resolution is fail-open for gas: config first, then the + /// gas-only [`native_symbol_fallback`] for well-known chains, so gas pricing + /// — which runs for EVERY order, including pure-ERC20 flows — never + /// hard-fails on a config lacking a zero-address native `TokenConfig`. This + /// fallback is deliberately NOT used for output/input valuation (see + /// [`CostProfitService::calculate_outputs_usd_value`]); native OUTPUT is + /// opt-in and must be configured explicitly. + /// + /// Assumes an 18-decimal native token (true for all supported EVM chains): + /// gas/native amounts are 1e18-base wei. A configured native `decimals != d` + /// where `d != 18` would misprice by `10^(18 - d)`, so this method fails + /// closed and returns an error at runtime (in all builds) for any non-18 + /// native decimals. Revisit for non-EVM chains whose native token is not 18 + /// decimals. + async fn native_base_units_to_usd( + &self, + chain_id: u64, + value: &U256, + ) -> Result { + if value.is_zero() { + return Ok(Decimal::ZERO); + } + + let native_token = Address(vec![0u8; 20]); + + // Resolve the native gas-token symbol/decimals for this chain. + // + // Config always wins: an explicit zero-address `TokenConfig` lets an + // operator override the symbol (or non-18 decimals). Only when the chain + // has no such entry (`TokenNotSupported`) do we consult the built-in + // `chain_id -> native symbol` fallback, so pure-ERC20 orders don't hard- + // require a zero-address token on every chain. Unknown chains keep the + // original error and fail closed. + let (symbol, decimals) = match self + .token_manager + .get_token_info(chain_id, &native_token) .await - .map_err(|e| { - CostProfitError::Calculation(format!("Failed to convert wei to USD: {e}")) - })?; + { + Ok(info) => (info.symbol, info.decimals), + Err(e @ TokenManagerError::TokenNotSupported(..)) => { + match native_symbol_fallback(chain_id) { + // Well-known native tokens are all 18-decimal. + Some(symbol) => (symbol.to_string(), 18u8), + None => return Err(e.into()), + } + }, + Err(e) => return Err(e.into()), + }; + + // Fail closed: gas is denominated in 1e18-base wei, so a config that sets a + // non-18 native `decimals` would misprice by 10^(18 - decimals). debug_assert + // is stripped in release builds, so enforce this at runtime for all builds. + if decimals != 18 { + return Err(CostProfitError::Calculation(format!( + "Native gas pricing assumes 18-decimal base units; chain {chain_id} \ + configured native token {symbol} with {decimals} decimals" + ))); + } - Decimal::from_str(&usd_value) - .map_err(|e| CostProfitError::Calculation(format!("Failed to parse USD value: {e}"))) + Self::convert_raw_token_to_usd(value, &symbol, decimals, self.pricing_service.as_ref()) + .await + .map_err(|e| { + CostProfitError::Calculation(format!( + "Failed to convert native units for chain {chain_id} to USD: {e}" + )) + }) } /// Validates callback safety and simulates fill transaction gas for an order with callbackData. @@ -3039,7 +3145,11 @@ impl CostProfitService { let normalized_amount = match token_decimals { 0 => raw_amount_decimal, decimals => { - let divisor = Decimal::new(10_i64.pow(decimals as u32), 0); + // `decimals <= 28` is enforced above. Compute the power in + // `i128` (not `i64`, which overflows at 10^19) so tokens with + // 19..=28 decimals no longer panic (debug) / wrap (release). + // 10^28 fits both `i128` and rust_decimal's 96-bit mantissa. + let divisor = Decimal::from_i128_with_scale(10_i128.pow(decimals as u32), 0); raw_amount_decimal / divisor }, }; @@ -3071,10 +3181,30 @@ impl CostProfitService { .map_err(|e| CostProfitError::Calculation(format!("Failed to get address: {e}")))?; let token_address = Address(ethereum_addr.0.to_vec()); - let token_info = self + let token_info = match self .token_manager .get_token_info(chain_id, &token_address) - .await?; + .await + { + Ok(info) => info, + Err(e @ TokenManagerError::TokenNotSupported(..)) => { + // Defensive: native inputs are rejected upstream (order + // validation), so this arm is not expected to fire in + // practice — but if a native (zero-address) input ever + // reaches valuation it needs an explicit native `TokenConfig` + // (symbol + decimals). The `native_symbol_fallback` map is + // GAS-ONLY (see its doc) and deliberately does not apply here. + let mut token_id = [0u8; 32]; + token_id[12..].copy_from_slice(&token_address.0); + if is_native_token_id(&token_id) { + return Err(CostProfitError::Calculation(format!( + "Native (zero-address) input on chain {chain_id} requires an explicit native TokenConfig (symbol + decimals) for USD valuation; the chain native-symbol fallback applies to gas pricing only" + ))); + } + return Err(e.into()); + }, + Err(e) => return Err(e.into()), + }; let usd_amount = Self::convert_raw_token_to_usd( &input.amount, @@ -3108,10 +3238,29 @@ impl CostProfitService { .map_err(|e| CostProfitError::Calculation(format!("Failed to get address: {e}")))?; let token_address = Address(ethereum_addr.0.to_vec()); - let token_info = self + let token_info = match self .token_manager .get_token_info(chain_id, &token_address) - .await?; + .await + { + Ok(info) => info, + Err(e @ TokenManagerError::TokenNotSupported(..)) => { + // A native (zero-address) output needs an explicit native + // `TokenConfig` (symbol + decimals) to be valued in USD. The + // chain native-symbol fallback in `native_symbol_fallback` is + // GAS-ONLY (see its doc), so it deliberately does not rescue + // valuation here. + let mut token_id = [0u8; 32]; + token_id[12..].copy_from_slice(&token_address.0); + if is_native_token_id(&token_id) { + return Err(CostProfitError::Calculation(format!( + "Native (zero-address) output on chain {chain_id} requires an explicit native TokenConfig (symbol + decimals) for USD valuation; the chain native-symbol fallback applies to gas pricing only" + ))); + } + return Err(e.into()); + }, + Err(e) => return Err(e.into()), + }; let usd_amount = Self::convert_raw_token_to_usd( &output.amount, @@ -3165,8 +3314,30 @@ impl CostProfitService { })?; // Convert to smallest unit (apply decimals), rounding up to ensure we collect enough - // This protects our margin when costs are deducted from outputs or added to inputs - let multiplier = Decimal::new(10_i64.pow(token_info.decimals as u32), 0); + // This protects our margin when costs are deducted from outputs or added to inputs. + // + // Compute the power in `i128` (not `i64`, which overflows at 10^19) and + // build the multiplier via `try_from_i128_with_scale`, which fails + // cleanly once 10^decimals exceeds rust_decimal's 96-bit mantissa + // (>28 decimals). This both fixes the >=19-decimal overflow and adds the + // missing <=28 guard as a clear error instead of a panic/wrap. + let multiplier = Decimal::try_from_i128_with_scale( + 10_i128 + .checked_pow(token_info.decimals as u32) + .ok_or_else(|| { + CostProfitError::Calculation(format!( + "Token decimals {} exceeds maximum supported precision", + token_info.decimals + )) + })?, + 0, + ) + .map_err(|e| { + CostProfitError::Calculation(format!( + "Token decimals {} exceeds maximum supported precision: {e}", + token_info.decimals + )) + })?; let token_amount_in_smallest = token_amount_decimal * multiplier; // Ceil to ensure we always collect enough to cover costs after USD->token->USD round-trip @@ -3431,8 +3602,19 @@ mod tests { .symbol("INPUT".to_string()) .decimals(18) .build(); + let native_token = solver_types::utils::tests::builders::TokenConfigBuilder::new() + .address(solver_types::Address(vec![0u8; 20])) + .symbol("ETH".to_string()) + .decimals(18) + .build(); let output_token = solver_types::utils::tests::builders::TokenConfigBuilder::new() - .address(solver_types::Address(vec![0u8; 20])) // Zero address for output + .address(solver_types::Address( + [ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, 0x1C, + 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + )) .symbol("OUTPUT".to_string()) .decimals(18) .build(); @@ -3440,18 +3622,36 @@ mod tests { .add_network( 1, NetworkConfigBuilder::new() - .tokens(vec![input_token]) + .tokens(vec![native_token.clone(), input_token]) .build(), ) .add_network( 137, NetworkConfigBuilder::new() - .tokens(vec![output_token]) + .tokens(vec![native_token, output_token]) .build(), ) .build() } + fn native_eth_token() -> solver_types::TokenConfig { + solver_types::TokenConfig { + address: solver_types::Address(vec![0u8; 20]), + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + decimals: 18, + } + } + + fn output_token_bytes32() -> [u8; 32] { + let mut token = [0u8; 32]; + token[12..].copy_from_slice(&[ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, 0x1C, 0x5C, + 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ]); + token + } + // Helper functions for creating test data fn create_test_config() -> Config { let mut config = ConfigBuilder::new() @@ -3904,18 +4104,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 18, - symbol: "ETH".to_string(), - name: Some("Ether".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, + 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 18, + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -3929,18 +4132,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 6, - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, + 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 6, + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -3983,8 +4189,8 @@ mod tests { // Verify cost breakdown exists and has reasonable values assert!(cost_context.cost_breakdown.total >= Decimal::ZERO); assert!(cost_context.cost_breakdown.operational_cost >= Decimal::ZERO); - assert!(cost_context.cost_breakdown.settlement_fee > Decimal::ZERO); - assert!(cost_context.cost_breakdown.settlement_fee_buffer > Decimal::ZERO); + assert!(cost_context.cost_breakdown.settlement_fee >= Decimal::ZERO); + assert!(cost_context.cost_breakdown.settlement_fee_buffer >= Decimal::ZERO); // Verify execution costs by chain assert!(!cost_context.execution_costs_by_chain.is_empty()); @@ -4019,7 +4225,9 @@ mod tests { let amount_f64: f64 = amount.parse().unwrap_or(0.0); Box::pin(async move { match (from.as_str(), to.as_str()) { - ("ETH", "USD") => Ok((amount_f64 * ETH_USD_PRICE).to_string()), + ("ETH", "USD") => Err(solver_types::PricingError::PriceNotAvailable( + "ETH/USD".to_string(), + )), ("USD", "ETH") => Ok((amount_f64 / ETH_USD_PRICE).to_string()), ("USDC", "USD") => Ok((amount_f64 * USDC_USD_PRICE).to_string()), ("USD", "USDC") => Ok((amount_f64 / USDC_USD_PRICE).to_string()), @@ -4080,18 +4288,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 18, - symbol: "ETH".to_string(), - name: Some("Ether".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, + 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 18, + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -4104,18 +4315,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 6, - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, + 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 6, + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -4250,18 +4464,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 18, - symbol: "ETH".to_string(), - name: Some("Ether".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, + 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 18, + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -4275,18 +4492,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 6, - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, + 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 6, + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -4501,19 +4721,27 @@ mod tests { #[tokio::test] async fn test_calculate_total_cost_includes_optional_settlement_and_l1_data_fees() { + let one_native = U256::from(1_000_000_000_000_000_000u128); let mut mock_pricing = MockPricingInterface::new(); + mock_pricing.expect_wei_to_currency().times(0); mock_pricing - .expect_wei_to_currency() + .expect_convert_asset() .times(8) - .returning(|wei, _| { - let wei = wei.to_string(); - Box::pin(async move { Ok(wei) }) + .returning(|from, to, amount| { + assert_eq!(from, "ETH"); + assert_eq!(to, "USD"); + let amount = amount.to_string(); + Box::pin(async move { Ok(amount) }) }); let mut origin_delivery = MockDeliveryInterface::new(); origin_delivery .expect_get_fee_params() - .returning(|chain_id| Box::pin(async move { Ok(FeeParams::legacy(chain_id, 10u128)) })); + .returning(|chain_id| { + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 10_000_000_000_000_000_000u128)) }, + ) + }); origin_delivery.expect_config_schema().returning(|| { Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) }); @@ -4522,7 +4750,9 @@ mod tests { destination_delivery .expect_get_fee_params() .returning(|chain_id| { - Box::pin(async move { Ok(FeeParams::legacy(chain_id, 100u128)) }) + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 100_000_000_000_000_000_000u128)) }, + ) }); destination_delivery.expect_config_schema().returning(|| { Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) @@ -4543,8 +4773,31 @@ mod tests { 3600, 60, )); + let native_token = solver_types::TokenConfig { + address: solver_types::Address(vec![0u8; 20]), + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + decimals: 18, + }; + let mut networks = NetworksConfig::new(); + for chain_id in [1, 2] { + networks.insert( + chain_id, + solver_types::NetworkConfig { + name: Some(format!("chain-{chain_id}")), + network_type: solver_types::networks::NetworkType::New, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x11; 20].to_vec()), + output_settler_address: solver_types::Address([0x22; 20].to_vec()), + tokens: vec![native_token.clone()], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + } let token_manager = Arc::new(TokenManager::new( - create_test_networks_config(), + networks, delivery.clone(), create_mock_account_service(), )); @@ -4571,9 +4824,9 @@ mod tests { pre_claim_units: 4, claim_units: 5, }, - U256::from(10u64), - U256::from(7u64), - U256::from(8u64), + one_native * U256::from(10u64), + one_native * U256::from(7u64), + one_native * U256::from(8u64), ) .await .unwrap(); @@ -4591,74 +4844,719 @@ mod tests { assert_eq!(breakdown.operational_cost, Decimal::from(686)); } - // ============================================================================ - // Profitability Validation Tests - // ============================================================================ - - // Helpers for profitability validation - fn create_test_order_with_amounts(input_amount: U256, output_amount: U256) -> Order { - // Create EIP-7683 order data - let eip7683_data = Eip7683OrderData { - user: "0x1111111111111111111111111111111111111111".to_string(), - nonce: U256::from(1), - origin_chain_id: U256::from(1), - expires: (current_timestamp() + 3600) as u32, - fill_deadline: (current_timestamp() + 300) as u32, - input_oracle: "0x0000000000000000000000000000000000000000".to_string(), - inputs: vec![[ - // Use the INPUT token address from create_test_networks_config (0x3e8 = 1000) - U256::from(1000), - input_amount, // amount - ]], - order_id: [1u8; 32], - gas_limit_overrides: GasLimitOverrides::default(), - outputs: vec![MandateOutput { - oracle: [0u8; 32], // Zero oracle for test - settler: [0u8; 32], // Zero settler for test - token: [0u8; 32], // Use zero address for OUTPUT token - amount: output_amount, - recipient: U256::from_str("0x2222222222222222222222222222222222222222") - .unwrap() - .to_be_bytes(), - chain_id: U256::from(137), - call: vec![], - context: vec![], // Empty context for test - }], - raw_order_data: None, - signature: None, - sponsor: None, - lock_type: None, - }; + #[tokio::test] + async fn test_calculate_total_cost_prices_native_gas_by_chain_symbol() { + let one_native = U256::from(1_000_000_000_000_000_000u128); + let mut mock_pricing = MockPricingInterface::new(); + mock_pricing.expect_wei_to_currency().times(0); + mock_pricing + .expect_convert_asset() + .returning(|from, to, amount| { + let from = from.to_string(); + let to = to.to_string(); + let amount = Decimal::from_str(amount).unwrap(); + Box::pin(async move { + assert_eq!(to, "USD"); + let price = match from.as_str() { + "POL" => Decimal::from(1), + "ETH" => Decimal::from(100), + "BNB" => Decimal::from(3), + "AVAX" => Decimal::from(7), + other => panic!("unexpected native asset conversion from {other}"), + }; + Ok((amount * price).to_string()) + }) + }); - Order { - id: "test_order_id".to_string(), - standard: "eip7683".to_string(), - created_at: current_timestamp(), - updated_at: current_timestamp(), - status: OrderStatus::Created, - data: serde_json::to_value(eip7683_data).unwrap(), - solver_address: solver_types::Address([0xAB; 20].to_vec()), - quote_id: Some("test_quote_id".to_string()), - input_chains: vec![ChainSettlerInfo { - chain_id: 1, - settler_address: solver_types::Address([0x11; 20].to_vec()), - }], - output_chains: vec![ChainSettlerInfo { - chain_id: 137, - settler_address: solver_types::Address([0x22; 20].to_vec()), - }], - execution_params: None, - prepare_tx_hash: None, - fill_tx_hash: None, - post_fill_tx_hash: None, - pre_claim_tx_hash: None, - claim_tx_hash: None, - fill_proof: None, - settlement_name: None, - } - } + let mut polygon_delivery = MockDeliveryInterface::new(); + polygon_delivery + .expect_get_fee_params() + .returning(|chain_id| { + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }, + ) + }); + polygon_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); - fn create_profitable_order() -> Order { + let mut ethereum_delivery = MockDeliveryInterface::new(); + ethereum_delivery + .expect_get_fee_params() + .returning(|chain_id| { + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }, + ) + }); + ethereum_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let mut bnb_delivery = MockDeliveryInterface::new(); + bnb_delivery.expect_get_fee_params().returning(|chain_id| { + Box::pin(async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }) + }); + bnb_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let mut avax_delivery = MockDeliveryInterface::new(); + avax_delivery.expect_get_fee_params().returning(|chain_id| { + Box::pin(async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }) + }); + avax_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let delivery = Arc::new(DeliveryService::new( + HashMap::from([ + ( + 137, + Arc::new(polygon_delivery) as Arc, + ), + ( + 1, + Arc::new(ethereum_delivery) as Arc, + ), + ( + 56, + Arc::new(bnb_delivery) as Arc, + ), + ( + 43114, + Arc::new(avax_delivery) as Arc, + ), + ]), + 1, + 3600, + 60, + )); + + let native_token = |symbol: &'static std::primitive::str| solver_types::TokenConfig { + address: solver_types::Address(vec![0u8; 20]), + symbol: symbol.to_string(), + name: Some(symbol.to_string()), + decimals: 18, + }; + let mut networks = NetworksConfig::new(); + networks.insert( + 137, + solver_types::NetworkConfig { + name: Some("polygon".to_string()), + network_type: solver_types::networks::NetworkType::Hub, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x11; 20].to_vec()), + output_settler_address: solver_types::Address([0x22; 20].to_vec()), + tokens: vec![native_token("POL")], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + networks.insert( + 1, + solver_types::NetworkConfig { + name: Some("ethereum".to_string()), + network_type: solver_types::networks::NetworkType::Parent, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x33; 20].to_vec()), + output_settler_address: solver_types::Address([0x44; 20].to_vec()), + tokens: vec![native_token("ETH")], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + networks.insert( + 56, + solver_types::NetworkConfig { + name: Some("bnb".to_string()), + network_type: solver_types::networks::NetworkType::Hub, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x55; 20].to_vec()), + output_settler_address: solver_types::Address([0x66; 20].to_vec()), + tokens: vec![native_token("BNB")], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + networks.insert( + 43114, + solver_types::NetworkConfig { + name: Some("avalanche".to_string()), + network_type: solver_types::networks::NetworkType::Hub, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x77; 20].to_vec()), + output_settler_address: solver_types::Address([0x88; 20].to_vec()), + tokens: vec![native_token("AVAX")], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + let token_manager = Arc::new(TokenManager::new( + networks, + delivery.clone(), + create_mock_account_service(), + )); + let service = CostProfitService::new( + Arc::new(PricingService::new(Box::new(mock_pricing), Vec::new())), + delivery, + token_manager, + Arc::new(StorageService::new(Box::new(MockStorageInterface::new()))), + no_fee_settlement_service(), + ); + + let breakdown = service + .calculate_total_cost( + &[], + &[], + &create_test_config(), + 137, + 1, + &GasUnits { + open_units: 1, + fill_units: 4, + post_fill_units: 5, + pre_claim_units: 2, + claim_units: 3, + }, + one_native * U256::from(6), + one_native * U256::from(7), + one_native * U256::from(8), + ) + .await + .unwrap(); + + assert_eq!(breakdown.gas_open, Decimal::from(1)); + assert_eq!(breakdown.gas_pre_claim, Decimal::from(2)); + assert_eq!(breakdown.gas_claim, Decimal::from(3)); + assert_eq!(breakdown.gas_fill, Decimal::from(400)); + assert_eq!(breakdown.gas_post_fill, Decimal::from(500)); + assert_eq!(breakdown.settlement_fee, Decimal::from(600)); + assert_eq!(breakdown.l1_data_fee, Decimal::from(700)); + assert_eq!(breakdown.l1_data_fee_buffer, Decimal::from(800)); + + // BNB (56) and AVAX (43114) resolve their native gas symbol from config + // the same way, proving chain-aware pricing is not ETH/POL-specific. + let breakdown_bnb_avax = service + .calculate_total_cost( + &[], + &[], + &create_test_config(), + 56, + 43114, + &GasUnits { + open_units: 1, + fill_units: 1, + post_fill_units: 0, + pre_claim_units: 0, + claim_units: 0, + }, + U256::ZERO, + U256::ZERO, + U256::ZERO, + ) + .await + .unwrap(); + + // origin leg (chain 56, 1 gas * 1e18) priced in BNB @ 3; + // destination leg (chain 43114, 1 gas * 1e18) priced in AVAX @ 7. + assert_eq!(breakdown_bnb_avax.gas_open, Decimal::from(3)); + assert_eq!(breakdown_bnb_avax.gas_fill, Decimal::from(7)); + } + + /// A chain whose config has NO zero-address `TokenConfig` must still price + /// native gas via the built-in chain -> native symbol fallback, instead of + /// failing the whole quote. Regression guard for the config-first fallback. + #[tokio::test] + async fn test_native_gas_pricing_falls_back_when_chain_not_configured() { + let mut mock_pricing = MockPricingInterface::new(); + mock_pricing.expect_wei_to_currency().times(0); + mock_pricing + .expect_convert_asset() + .returning(|from, to, amount| { + let from = from.to_string(); + let to = to.to_string(); + let amount = Decimal::from_str(amount).unwrap(); + Box::pin(async move { + assert_eq!(to, "USD"); + let price = match from.as_str() { + // Polygon's native token, resolved via the fallback map + // because the config has no zero-address TokenConfig. + "POL" => Decimal::from(5), + other => panic!("unexpected native asset conversion from {other}"), + }; + Ok((amount * price).to_string()) + }) + }); + + let mut polygon_delivery = MockDeliveryInterface::new(); + polygon_delivery + .expect_get_fee_params() + .returning(|chain_id| { + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }, + ) + }); + polygon_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let delivery = Arc::new(DeliveryService::new( + HashMap::from([( + 137, + Arc::new(polygon_delivery) as Arc, + )]), + 1, + 3600, + 60, + )); + + // Polygon (137) is present in config but WITHOUT a zero-address native + // TokenConfig — the exact "unmigrated config" case the fallback fixes. + let mut networks = NetworksConfig::new(); + networks.insert( + 137, + solver_types::NetworkConfig { + name: Some("polygon".to_string()), + network_type: solver_types::networks::NetworkType::Hub, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x11; 20].to_vec()), + output_settler_address: solver_types::Address([0x22; 20].to_vec()), + tokens: vec![], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + let token_manager = Arc::new(TokenManager::new( + networks, + delivery.clone(), + create_mock_account_service(), + )); + let service = CostProfitService::new( + Arc::new(PricingService::new(Box::new(mock_pricing), Vec::new())), + delivery, + token_manager, + Arc::new(StorageService::new(Box::new(MockStorageInterface::new()))), + no_fee_settlement_service(), + ); + + let breakdown = service + .calculate_total_cost( + &[], + &[], + &create_test_config(), + 137, + 137, + &GasUnits { + open_units: 1, + fill_units: 0, + post_fill_units: 0, + pre_claim_units: 0, + claim_units: 0, + }, + U256::ZERO, + U256::ZERO, + U256::ZERO, + ) + .await + .expect("native gas pricing must fall back instead of erroring"); + + // 1 gas unit * 1e18 wei/gas = 1 POL, priced at 5 USD via the fallback. + assert_eq!(breakdown.gas_open, Decimal::from(5)); + } + + /// Gas is denominated in 1e18-base wei, so a chain whose explicit + /// zero-address native `TokenConfig` is misconfigured with non-18 decimals + /// must FAIL CLOSED at runtime (all builds), not silently misprice by + /// `10^(18 - decimals)`. Regression guard for the release-safe decimals check + /// that replaced a debug-only `debug_assert`. + #[tokio::test] + async fn test_native_gas_pricing_rejects_non_18_decimal_native() { + let mut mock_pricing = MockPricingInterface::new(); + // We must fail on the decimals guard BEFORE any pricing call is made. + mock_pricing.expect_wei_to_currency().times(0); + mock_pricing.expect_convert_asset().times(0); + + let mut polygon_delivery = MockDeliveryInterface::new(); + polygon_delivery + .expect_get_fee_params() + .returning(|chain_id| { + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }, + ) + }); + polygon_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let delivery = Arc::new(DeliveryService::new( + HashMap::from([( + 137, + Arc::new(polygon_delivery) as Arc, + )]), + 1, + 3600, + 60, + )); + + // Explicit zero-address native TokenConfig with NON-18 decimals (6) — the + // exact misconfiguration the runtime guard must reject. + let mut networks = NetworksConfig::new(); + networks.insert( + 137, + solver_types::NetworkConfig { + name: Some("polygon".to_string()), + network_type: solver_types::networks::NetworkType::Hub, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x11; 20].to_vec()), + output_settler_address: solver_types::Address([0x22; 20].to_vec()), + tokens: vec![solver_types::TokenConfig { + address: solver_types::Address(vec![0u8; 20]), + symbol: "POL".to_string(), + name: Some("POL".to_string()), + decimals: 6, + }], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + let token_manager = Arc::new(TokenManager::new( + networks, + delivery.clone(), + create_mock_account_service(), + )); + let service = CostProfitService::new( + Arc::new(PricingService::new(Box::new(mock_pricing), Vec::new())), + delivery, + token_manager, + Arc::new(StorageService::new(Box::new(MockStorageInterface::new()))), + no_fee_settlement_service(), + ); + + // One non-zero origin gas leg on the misconfigured chain forces a native + // base-units -> USD conversion; every other leg is zero (early-returns). + let result = service + .calculate_total_cost( + &[], + &[], + &create_test_config(), + 137, + 137, + &GasUnits { + open_units: 1, + fill_units: 0, + post_fill_units: 0, + pre_claim_units: 0, + claim_units: 0, + }, + U256::ZERO, + U256::ZERO, + U256::ZERO, + ) + .await; + + let err = result + .expect_err("native gas pricing must fail closed for a non-18-decimal native config"); + let msg = err.to_string(); + assert!( + msg.contains("18-decimal") && msg.contains("decimals"), + "error must explain the non-18 native decimals rejection, got: {msg}" + ); + } + + /// An unknown chain id that is neither in config nor in the fallback map + /// must fail closed rather than silently mispricing native gas. + #[tokio::test] + async fn test_native_gas_pricing_errors_on_unknown_unconfigured_chain() { + const UNKNOWN_CHAIN: u64 = 999_999; + + let mut mock_pricing = MockPricingInterface::new(); + // Neither pricing path should be reached: the symbol never resolves. + mock_pricing.expect_wei_to_currency().times(0); + mock_pricing.expect_convert_asset().times(0); + + let mut unknown_delivery = MockDeliveryInterface::new(); + unknown_delivery + .expect_get_fee_params() + .returning(|chain_id| { + Box::pin( + async move { Ok(FeeParams::legacy(chain_id, 1_000_000_000_000_000_000u128)) }, + ) + }); + unknown_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let delivery = Arc::new(DeliveryService::new( + HashMap::from([( + UNKNOWN_CHAIN, + Arc::new(unknown_delivery) as Arc, + )]), + 1, + 3600, + 60, + )); + + // Chain present for fee params, but has no zero-address native token and + // is not a well-known chain, so no native symbol can be resolved. + let mut networks = NetworksConfig::new(); + networks.insert( + UNKNOWN_CHAIN, + solver_types::NetworkConfig { + name: Some("unknown".to_string()), + network_type: solver_types::networks::NetworkType::Hub, + rpc_urls: vec![], + input_settler_address: solver_types::Address([0x11; 20].to_vec()), + output_settler_address: solver_types::Address([0x22; 20].to_vec()), + tokens: vec![], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + let token_manager = Arc::new(TokenManager::new( + networks, + delivery.clone(), + create_mock_account_service(), + )); + let service = CostProfitService::new( + Arc::new(PricingService::new(Box::new(mock_pricing), Vec::new())), + delivery, + token_manager, + Arc::new(StorageService::new(Box::new(MockStorageInterface::new()))), + no_fee_settlement_service(), + ); + + let result = service + .calculate_total_cost( + &[], + &[], + &create_test_config(), + UNKNOWN_CHAIN, + UNKNOWN_CHAIN, + &GasUnits { + open_units: 1, + fill_units: 0, + post_fill_units: 0, + pre_claim_units: 0, + claim_units: 0, + }, + U256::ZERO, + U256::ZERO, + U256::ZERO, + ) + .await; + + assert!( + result.is_err(), + "unknown, unconfigured chain must fail closed, got: {result:?}" + ); + } + + /// A native (zero-address) OUTPUT on a chain that is CONFIGURED but has no + /// zero-address native `TokenConfig` must fail valuation with a CLEAR + /// message that mentions `native` and `config` — not a bare + /// `TokenNotSupported`. The gas-only native-symbol fallback must not rescue + /// valuation here. A native output WITH an explicit native config still + /// values fine. + #[tokio::test] + async fn test_native_output_valuation_errors_clearly_without_config() { + let mut mock_pricing = MockPricingInterface::new(); + mock_pricing + .expect_convert_asset() + .returning(|from, to, amount| { + let from = from.to_string(); + let to = to.to_string(); + let amount = Decimal::from_str(amount).unwrap(); + Box::pin(async move { + assert_eq!(to, "USD"); + let price = match from.as_str() { + "ETH" => Decimal::from(4000), + other => panic!("unexpected asset conversion from {other}"), + }; + Ok((amount * price).to_string()) + }) + }); + + // Chain 1: WITH a zero-address native ETH TokenConfig. + // Chain 137: configured, but NO zero-address native TokenConfig. + let native_eth = solver_types::utils::tests::builders::TokenConfigBuilder::new() + .address(solver_types::Address(vec![0u8; 20])) + .symbol("ETH".to_string()) + .decimals(18) + .build(); + let some_erc20 = solver_types::utils::tests::builders::TokenConfigBuilder::new() + .address(solver_types::Address([0x11u8; 20].to_vec())) + .symbol("OUTPUT".to_string()) + .decimals(18) + .build(); + let networks = NetworksConfigBuilder::new() + .add_network( + 1, + NetworkConfigBuilder::new().tokens(vec![native_eth]).build(), + ) + .add_network( + 137, + NetworkConfigBuilder::new().tokens(vec![some_erc20]).build(), + ) + .build(); + let token_manager = Arc::new(TokenManager::new( + networks, + create_mock_delivery_service(), + create_mock_account_service(), + )); + let service = CostProfitService::new( + Arc::new(PricingService::new(Box::new(mock_pricing), Vec::new())), + create_mock_delivery_service(), + token_manager, + Arc::new(StorageService::new(Box::new(MockStorageInterface::new()))), + no_fee_settlement_service(), + ); + + // Native output on chain 137 (no native config) -> clear error. + let native_no_config = OrderOutput { + receiver: InteropAddress::new_ethereum( + 137, + address!("0000000000000000000000000000000000000000"), + ), + asset: InteropAddress::new_ethereum( + 137, + address!("0000000000000000000000000000000000000000"), + ), + amount: U256::from(1_000_000_000_000_000_000u128), + calldata: None, + }; + let err = service + .calculate_outputs_usd_value(&[native_no_config]) + .await + .expect_err("native output without native TokenConfig must error"); + let msg = err.to_string().to_lowercase(); + assert!(msg.contains("native"), "error must mention native: {msg}"); + assert!(msg.contains("config"), "error must mention config: {msg}"); + + // Native output on chain 1 (WITH native config) -> values fine. + let native_with_config = OrderOutput { + receiver: InteropAddress::new_ethereum( + 1, + address!("0000000000000000000000000000000000000000"), + ), + asset: InteropAddress::new_ethereum( + 1, + address!("0000000000000000000000000000000000000000"), + ), + amount: U256::from(1_000_000_000_000_000_000u128), + calldata: None, + }; + let usd = service + .calculate_outputs_usd_value(&[native_with_config]) + .await + .expect("native output with native TokenConfig must value fine"); + assert_eq!(usd, Decimal::from(4000)); + } + + /// Regression: `10_i64.pow(20)` overflowed `i64` (panics in debug, wraps in + /// release) for tokens with >=19 decimals, despite the <=28 decimals guard. + /// Computing the divisor in `i128` fixes it. + #[tokio::test] + async fn test_convert_raw_token_to_usd_handles_20_decimal_token() { + let mut mock_pricing = MockPricingInterface::new(); + mock_pricing + .expect_convert_asset() + .returning(|from, to, amount| { + let from = from.to_string(); + let to = to.to_string(); + let amount = Decimal::from_str(amount).unwrap(); + Box::pin(async move { + assert_eq!(from, "TKN20"); + assert_eq!(to, "USD"); + // TKN20 priced at 3 USD each. + Ok((amount * Decimal::from(3)).to_string()) + }) + }); + let pricing = PricingService::new(Box::new(mock_pricing), Vec::new()); + + // 2 whole tokens with 20 decimals = 2 * 10^20 base units. + let raw = U256::from_str("200000000000000000000").unwrap(); + let usd = CostProfitService::convert_raw_token_to_usd(&raw, "TKN20", 20, &pricing) + .await + .expect("20-decimal conversion must not overflow"); + // normalized = 2.0 priced at 3 USD => 6 USD. + assert_eq!(usd, Decimal::from(6)); + } + + // ============================================================================ + // Profitability Validation Tests + // ============================================================================ + + // Helpers for profitability validation + fn create_test_order_with_amounts(input_amount: U256, output_amount: U256) -> Order { + // Create EIP-7683 order data + let eip7683_data = Eip7683OrderData { + user: "0x1111111111111111111111111111111111111111".to_string(), + nonce: U256::from(1), + origin_chain_id: U256::from(1), + expires: (current_timestamp() + 3600) as u32, + fill_deadline: (current_timestamp() + 300) as u32, + input_oracle: "0x0000000000000000000000000000000000000000".to_string(), + inputs: vec![[ + // Use the INPUT token address from create_test_networks_config (0x3e8 = 1000) + U256::from(1000), + input_amount, // amount + ]], + order_id: [1u8; 32], + gas_limit_overrides: GasLimitOverrides::default(), + outputs: vec![MandateOutput { + oracle: [0u8; 32], // Zero oracle for test + settler: [0u8; 32], // Zero settler for test + token: output_token_bytes32(), + amount: output_amount, + recipient: U256::from_str("0x2222222222222222222222222222222222222222") + .unwrap() + .to_be_bytes(), + chain_id: U256::from(137), + call: vec![], + context: vec![], // Empty context for test + }], + raw_order_data: None, + signature: None, + sponsor: None, + lock_type: None, + }; + + Order { + id: "test_order_id".to_string(), + standard: "eip7683".to_string(), + created_at: current_timestamp(), + updated_at: current_timestamp(), + status: OrderStatus::Created, + data: serde_json::to_value(eip7683_data).unwrap(), + solver_address: solver_types::Address([0xAB; 20].to_vec()), + quote_id: Some("test_quote_id".to_string()), + input_chains: vec![ChainSettlerInfo { + chain_id: 1, + settler_address: solver_types::Address([0x11; 20].to_vec()), + }], + output_chains: vec![ChainSettlerInfo { + chain_id: 137, + settler_address: solver_types::Address([0x22; 20].to_vec()), + }], + execution_params: None, + prepare_tx_hash: None, + fill_tx_hash: None, + post_fill_tx_hash: None, + pre_claim_tx_hash: None, + claim_tx_hash: None, + fill_proof: None, + settlement_name: None, + } + } + + fn create_profitable_order() -> Order { // Input: 1 INPUT (~$4000), Output: 3900 OUTPUT = $100 profit (2.5% margin) create_test_order_with_amounts( U256::from_str("1000000000000000000").unwrap(), // 1 INPUT (18 decimals) @@ -6493,6 +7391,26 @@ mod tests { assert_eq!(decoded.output.oracle, B256::ZERO); } + #[tokio::test] + async fn build_fill_tx_for_quote_sets_value_for_native_output() { + let config = config_with_output_settler_on_dest(QUOTE_OUTPUT_SETTLER); + let solver = solver_types::Address(QUOTE_SOLVER.to_vec()); + let mut request = quote_request_with_unresolved_amount(); + request.intent.outputs[0].asset = + InteropAddress::new_ethereum(QUOTE_DEST_CHAIN_ID, alloy_primitives::Address::ZERO); + let validated = create_test_validated_context(true); + let resolved_amount = U256::from(1_000_000u64); + let resolved = resolved_amounts_for_request(&request, resolved_amount); + let service = cost_profit_service_no_delivery_chains(); + + let tx = service + .build_fill_tx_for_quote(&request, &validated, &resolved, &config, &solver, true) + .await + .expect("synthetic native fill tx should build"); + + assert_eq!(tx.value, resolved_amount); + } + #[tokio::test] async fn build_fill_tx_for_quote_skips_source_finality_delay_for_resource_lock() { let config = config_with_output_settler_on_dest(QUOTE_OUTPUT_SETTLER); @@ -6702,18 +7620,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 18, - symbol: "ETH".to_string(), - name: Some("Ether".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, + 0x5C, 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 18, + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -6727,18 +7648,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 6, - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, + 0x5C, 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 6, + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -7528,18 +8452,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 18, - symbol: "ETH".to_string(), - name: Some("Ether".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xA0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, + 0x5C, 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 18, + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -7553,18 +8480,21 @@ mod tests { rpc_urls: vec![], input_settler_address: solver_types::Address([0x11; 20].to_vec()), output_settler_address: solver_types::Address([0x22; 20].to_vec()), - tokens: vec![solver_types::TokenConfig { - address: solver_types::Address( - [ - 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, 0x5C, - 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, - ] - .to_vec(), - ), - decimals: 6, - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - }], + tokens: vec![ + native_eth_token(), + solver_types::TokenConfig { + address: solver_types::Address( + [ + 0xB0, 0xb8, 0x6a, 0x33, 0xE6, 0x44, 0x1b, 0x8C, 0x6A, 0x7f, 0x4C, + 0x5C, 0x1C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, 0x5C, + ] + .to_vec(), + ), + decimals: 6, + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, diff --git a/crates/solver-core/src/engine/token_manager.rs b/crates/solver-core/src/engine/token_manager.rs index 292da692..91db044e 100644 --- a/crates/solver-core/src/engine/token_manager.rs +++ b/crates/solver-core/src/engine/token_manager.rs @@ -25,8 +25,8 @@ use solver_delivery::{ DeliveryService, TransactionAttemptRecorder, TransactionMonitoringEvent, TransactionTracking, }; use solver_types::{ - with_0x_prefix, Address, NetworksConfig, TokenConfig, Transaction, TransactionHash, - TransactionType, + is_native_address, with_0x_prefix, Address, NetworksConfig, TokenConfig, Transaction, + TransactionHash, TransactionType, }; use std::collections::HashMap; use std::sync::Arc; @@ -55,6 +55,10 @@ pub enum TokenManagerError { /// Failed to parse a value. #[error("Failed to parse value: {0}")] ParseError(String), + + /// Native tokens do not support ERC-20 approvals. + #[error("Native token approvals are unsupported")] + NativeApprovalUnsupported, } /// Manages token configurations and approvals across multiple blockchain networks. @@ -231,6 +235,15 @@ impl TokenManager { let networks = self.networks.read().await; for (chain_id, network) in networks.iter() { for token in &network.tokens { + if is_native_address(&token.address) { + tracing::debug!( + token = %token.symbol, + chain_id, + "Skipping approval for native token" + ); + continue; + } + // Process input settler if not zero address if network.input_settler_address.0 != [0u8; 20] { // Check allowance for input settler @@ -335,6 +348,9 @@ impl TokenManager { continue; } } + if is_native_address(&token.address) { + continue; + } if self .ensure_token_approval(*cid, &token.address, &spender, amount) @@ -361,6 +377,16 @@ impl TokenManager { spender: &Address, amount: U256, ) -> Result { + if is_native_address(token_address) { + tracing::debug!( + chain_id, + token = %with_0x_prefix(&hex::encode(&token_address.0)), + spender = %with_0x_prefix(&hex::encode(&spender.0)), + "Skipping approval for native token" + ); + return Ok(false); + } + let solver_address = self.account.get_address().await?; let solver_address_str = with_0x_prefix(&hex::encode(&solver_address.0)); @@ -417,6 +443,10 @@ impl TokenManager { spender: &Address, amount: U256, ) -> Result { + if is_native_address(token_address) { + return Err(TokenManagerError::NativeApprovalUnsupported); + } + // Create approval transaction data // ERC20 approve(address spender, uint256 amount) // Function selector: 0x095ea7b3 @@ -476,13 +506,14 @@ impl TokenManager { let networks = self.networks.read().await; for (chain_id, network) in networks.iter() { for token in &network.tokens { + let token_address = if is_native_address(&token.address) { + None + } else { + Some(hex::encode(&token.address.0)) + }; let balance = self .delivery - .get_balance( - *chain_id, - &solver_address_str, - Some(&hex::encode(&token.address.0)), - ) + .get_balance(*chain_id, &solver_address_str, token_address.as_deref()) .await?; balances.insert((*chain_id, token.clone()), balance); @@ -534,7 +565,7 @@ impl TokenManager { chain_id: u64, token_address: &Address, ) -> Result { - if token_address.0 == [0u8; 20] { + if is_native_address(token_address) { let solver_address = self.account.get_address().await?; let solver_address_str = hex::encode(&solver_address.0); let balance = self @@ -563,7 +594,7 @@ impl TokenManager { recipient: &Address, amount: U256, ) -> Result { - let tx = if token_address.0 == [0u8; 20] { + let tx = if is_native_address(token_address) { // Native token transfer - let delivery service estimate gas // (21k is only enough for EOAs, contracts/multisigs need more) Transaction { @@ -1160,6 +1191,108 @@ mod tests { assert!(result.unwrap()); } + #[tokio::test] + async fn ensure_token_approval_skips_native_token() { + let networks = create_test_networks_config(); + + let mut mock_delivery = MockDeliveryInterface::new(); + mock_delivery.expect_get_allowance().times(0); + mock_delivery.expect_submit().times(0); + mock_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let mut implementations = HashMap::new(); + implementations.insert( + 1, + Arc::new(mock_delivery) as Arc, + ); + let delivery = Arc::new(DeliveryService::new(implementations, 1, 20, 60)); + let account = create_mock_account_service(); + let token_manager = TokenManager::new(networks, delivery, account); + + let native = Address(vec![0u8; 20]); + let spender = parse_address("2222222222222222222222222222222222222222").unwrap(); + + let approved = token_manager + .ensure_token_approval(1, &native, &spender, U256::MAX) + .await + .expect("native approval should be skipped without error"); + + assert!(!approved); + } + + #[tokio::test] + async fn check_balances_routes_configured_native_to_native_balance() { + let native = TokenConfigBuilder::new() + .address(Address(vec![0u8; 20])) + .symbol("ETH") + .decimals(18) + .build(); + let erc20 = TokenConfigBuilder::new() + .address(parse_address("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap()) + .symbol("USDC") + .decimals(6) + .build(); + let mut networks = NetworksConfig::new(); + networks.insert( + 1, + solver_types::NetworkConfig { + name: None, + network_type: solver_types::NetworkType::New, + rpc_urls: vec![], + input_settler_address: parse_address("0x1111111111111111111111111111111111111111") + .unwrap(), + output_settler_address: parse_address("0x2222222222222222222222222222222222222222") + .unwrap(), + tokens: vec![native.clone(), erc20.clone()], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + + let mut mock_delivery = MockDeliveryInterface::new(); + mock_delivery + .expect_get_balance() + .times(2) + .returning(|_, token, chain_id| { + assert_eq!(chain_id, 1); + let balance = match token { + None => "5000000000000000000".to_string(), + Some("a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") => "1000000".to_string(), + Some(other) => panic!("unexpected ERC20 balance query for {other}"), + }; + Box::pin(async move { Ok(balance) }) + }); + mock_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let mut implementations = HashMap::new(); + implementations.insert( + 1, + Arc::new(mock_delivery) as Arc, + ); + let delivery = Arc::new(DeliveryService::new(implementations, 1, 20, 60)); + let account = create_mock_account_service(); + let token_manager = TokenManager::new(networks, delivery, account); + + let balances = token_manager + .check_balances() + .await + .expect("balances should load"); + + assert_eq!( + balances.get(&(1, native)).map(String::as_str), + Some("5000000000000000000") + ); + assert_eq!( + balances.get(&(1, erc20)).map(String::as_str), + Some("1000000") + ); + } + #[tokio::test] async fn withdrawal_submission_is_tracked_as_system_attempt() { let networks = create_test_networks_config(); diff --git a/crates/solver-core/src/handlers/intent.rs b/crates/solver-core/src/handlers/intent.rs index 90db1710..d9075b96 100644 --- a/crates/solver-core/src/handlers/intent.rs +++ b/crates/solver-core/src/handlers/intent.rs @@ -1465,6 +1465,12 @@ mod tests { .decimals(18) .build(); + let native_token = solver_types::utils::tests::builders::TokenConfigBuilder::new() + .address(solver_types::Address(vec![0u8; 20])) + .symbol("ETH".to_string()) + .decimals(18) + .build(); + let output_token = solver_types::utils::tests::builders::TokenConfigBuilder::new() .address(solver_types::Address(vec![0u8; 20])) // Zero address for output .symbol("OUTPUT".to_string()) @@ -1476,7 +1482,7 @@ mod tests { .add_network( 1, solver_types::utils::tests::builders::NetworkConfigBuilder::new() - .tokens(vec![input_token]) + .tokens(vec![native_token, input_token]) .build(), ) .add_network( diff --git a/crates/solver-e2e-tests/src/lib.rs b/crates/solver-e2e-tests/src/lib.rs index dce3d018..4503152e 100644 --- a/crates/solver-e2e-tests/src/lib.rs +++ b/crates/solver-e2e-tests/src/lib.rs @@ -1868,7 +1868,11 @@ fn build_seed_overrides( // test chains as `Parent` — the variant doesn't affect the // settlement flow we exercise here. network_type: Some(NetworkType::Parent), - tokens: vec![token("TOKA", d.token_a), token("TOKB", d.token_b)], + tokens: vec![ + token("ETH", Address::ZERO), + token("TOKA", d.token_a), + token("TOKB", d.token_b), + ], rpc_urls: Some(vec![d.rpc_http.clone()]), input_settler_address: Some(d.input_settler), output_settler_address: Some(d.output_settler), diff --git a/crates/solver-e2e-tests/tests/happy_e2e_native_fill_settle.rs b/crates/solver-e2e-tests/tests/happy_e2e_native_fill_settle.rs new file mode 100644 index 00000000..71ed6d22 --- /dev/null +++ b/crates/solver-e2e-tests/tests/happy_e2e_native_fill_settle.rs @@ -0,0 +1,102 @@ +//! Native-ETH output happy path: user calls `open()` with an ERC20 (TOKA) +//! input, the solver fills a NATIVE ETH output (token = zero address) with +//! attached `msg.value`, then settles/claims on origin. +//! +//! This is the native-output counterpart to `happy_e2e_open_fill_settle`: +//! the input leg is unchanged (ERC20 TOKA, user approve + on-chain `open()`), +//! only the destination OUTPUT switches to native. The all-zero `bytes32` +//! `MandateOutput.token` is the sentinel the contract's `OutputSettlerBase._fill` +//! treats as native (`tokenIdentifier == bytes32(0)` -> `Address.sendValue`). +//! +//! Run with: +//! cargo test -p solver-e2e-tests --test happy_e2e_native_fill_settle \ +//! -- --ignored --nocapture + +use alloy_primitives::{Address, B256}; +use solver_e2e_tests::{ + amount_with_decimals, Finalised, Harness, OutputFilled, StandardOrderBuilder, DEST_CHAIN_ID, + FILL_TIMEOUT, ORIGIN_CHAIN_ID, SETTLE_TIMEOUT, +}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +#[ignore = "requires Anvil + oif-contracts/out; opt-in via --ignored"] +async fn happy_e2e_native_fill_settle() -> anyhow::Result<()> { + let h = Harness::boot().await?; + + let user = h.user_address(); + let recipient = h.recipient_address(); + let solver = h.solver_address(); + + // Input leg stays ERC20 TOKA (mock price $20 -> $20,000 of input value). + // Output leg is NATIVE ETH: at the mock ETH price ($4,615.16), 1 ETH is + // ~$4,615, leaving a very wide (~77%) spread so the fill clears the + // profitability gate comfortably (the ERC20 template runs at ~50%). + let amount_in = amount_with_decimals(1_000); + let amount_out = amount_with_decimals(1); // 1 ETH, native output + + let user_in_before = h.balance(ORIGIN_CHAIN_ID, h.origin.token_a, user).await?; + let recipient_native_before = h.native_balance(DEST_CHAIN_ID, recipient).await?; + let solver_native_before = h.native_balance(DEST_CHAIN_ID, solver).await?; + + h.user_approve(h.origin.token_a, h.origin.input_settler, amount_in) + .await?; + + // Only the OUTPUT changes vs the ERC20 template: token = zero address + // (native sentinel) and a smaller amount to keep the trade profitable. + let order = StandardOrderBuilder::happy_path(&h, "e2e-native-1") + .amount_out(amount_out) + .output_token(Address::ZERO) + .build(); + let order_id: B256 = h.user_open(order).await?; + tracing::info!(%order_id, "Open submitted (native output)"); + + // OutputFilled is token-agnostic; assert it fired for our order with the + // requested amount. + let (filled, _) = h + .await_event::( + DEST_CHAIN_ID, + h.destination.output_settler, + order_id, + FILL_TIMEOUT, + ) + .await?; + assert_eq!(filled.orderId, order_id); + assert_eq!(filled.finalAmount, amount_out); + + let (finalised, _) = h + .await_event::( + ORIGIN_CHAIN_ID, + h.origin.input_settler, + order_id, + SETTLE_TIMEOUT, + ) + .await?; + assert_eq!(finalised.orderId, order_id); + + let user_in_after = h.balance(ORIGIN_CHAIN_ID, h.origin.token_a, user).await?; + let recipient_native_after = h.native_balance(DEST_CHAIN_ID, recipient).await?; + let solver_native_after = h.native_balance(DEST_CHAIN_ID, solver).await?; + + // User paid the full ERC20 input on origin. + assert_eq!(user_in_before - user_in_after, amount_in); + + // Recipient received EXACTLY amount_out in native ETH: the recipient is a + // passive account that submits no transaction, so it pays no gas -> the + // delta is a clean equality. + assert_eq!( + recipient_native_after - recipient_native_before, + amount_out, + "recipient native delta must equal amount_out" + ); + + // The solver funded the native output (plus gas) out of its own balance. + // We do NOT assert an exact solver delta: the solver pays destination gas + // and the settler refunds any excess `msg.value`, so only a lower bound is + // meaningful. + assert!( + solver_native_before - solver_native_after >= amount_out, + "solver must have spent at least amount_out in native ETH" + ); + + Ok(()) +} diff --git a/crates/solver-order/src/implementations/standards/_7683.rs b/crates/solver-order/src/implementations/standards/_7683.rs index 777d34bb..826cfcac 100644 --- a/crates/solver-order/src/implementations/standards/_7683.rs +++ b/crates/solver-order/src/implementations/standards/_7683.rs @@ -19,7 +19,7 @@ use solver_types::{ GasLimitOverrides, LockType, MAX_CALLBACK_DATA_BYTES, MAX_ESCROW_SIGNATURE_BYTES, MAX_ORDER_SIGNATURE_BYTES, MAX_STANDARD_ORDER_INPUTS, MAX_STANDARD_ORDER_OUTPUTS, }, - utils::conversion::hex_to_alloy_address, + utils::conversion::{hex_to_alloy_address, is_native_token_id}, Address, ConfigSchema, Eip7683OrderData, ExecutionParams, FillProof, NetworksConfig, Order, OrderStatus, Schema, Transaction, }; @@ -392,10 +392,16 @@ impl OrderInterface for Eip7683OrderImpl { } .abi_encode(); + let value = if is_native_token_id(&output.token) { + output.amount + } else { + U256::ZERO + }; + Ok(Transaction { to: Some(output_settler_address), data: fill_data, - value: U256::ZERO, + value, chain_id: dest_chain_id, nonce: None, gas_limit: order_data.gas_limit_overrides.fill_gas_limit, @@ -638,6 +644,21 @@ impl OrderInterface for Eip7683OrderImpl { "too many inputs: maximum supported is {MAX_STANDARD_ORDER_INPUTS}" ))); } + for (i, input) in standard_order.inputs.iter().enumerate() { + // `input[0]` is the `[token, amount]` word's token slot: the address lives in + // the low 20 bytes, matching `parse_available_inputs` in solver-types + // (`bytes32_to_address(input[0].to_be_bytes::<32>())`). A native input has a + // zero address regardless of high bits, so a Compact resource lock encodes it as + // `(lockTag << 160) | 0x0` — nonzero high bits, zero address. Check only the low + // 20 bytes so those slip through no longer (mirrors the quote-path guard). Do NOT + // use `is_native_token_id`, which requires all 32 bytes to be zero. + let token = input[0].to_be_bytes::<32>(); + if token[12..].iter().all(|b| *b == 0) { + return Err(OrderError::ValidationFailed(format!( + "native inputs are unsupported: input[{i}]" + ))); + } + } if standard_order.outputs.len() > MAX_STANDARD_ORDER_OUTPUTS { return Err(OrderError::ValidationFailed(format!( "too many outputs: maximum supported is {MAX_STANDARD_ORDER_OUTPUTS}" @@ -1221,6 +1242,35 @@ mod tests { assert!(!tx.data.is_empty()); } + #[tokio::test] + async fn test_generate_fill_transaction_sets_value_for_native_output() { + let networks = create_test_networks(); + let oracle_routes = create_test_oracle_routes(); + let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap(); + + let mut order_data = create_test_order_data(); + order_data.outputs[0].token = [0u8; 32]; + order_data.outputs[0].amount = U256::from(12345); + let order = OrderBuilder::new() + .with_data(serde_json::to_value(&order_data).unwrap()) + .with_solver_address(Address(vec![99u8; 20])) + .with_quote_id(Some("test-quote".to_string())) + .with_input_chain_ids(vec![1]) + .with_output_chain_ids(vec![137]) + .build(); + let params = ExecutionParams { + gas_price: U256::ZERO, + priority_fee: None, + }; + + let tx = order_impl + .generate_fill_transaction(&order, ¶ms) + .await + .expect("native fill transaction should be generated"); + + assert_eq!(tx.value, order_data.outputs[0].amount); + } + #[tokio::test] async fn test_generate_claim_transaction_escrow() { let networks = create_test_networks(); @@ -1559,6 +1609,53 @@ mod tests { } } + #[tokio::test] + async fn test_validate_order_rejects_native_output_with_dutch_context() { + let networks = create_test_networks(); + let oracle_routes = create_test_oracle_routes(); + let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap(); + + // Fill-time value uses `value = output.amount` for a native output token (see the + // `is_native_token_id` branch in `generate_fill_transaction`). That is only sound + // while amount-varying contexts (Dutch/exclusive) are refused, otherwise the ETH + // attached to the fill would diverge from the amount the settler actually pays. + // Lock the invariant: a native OUTPUT (all-zero bytes32 token) plus a Dutch/ + // exclusive context is still rejected. + for context in [ + dutch_auction_context(), // 0x01 Dutch auction + vec![0xe0], // exclusive limit + vec![0xe1], // exclusive Dutch + ] { + let context_tag = context[0]; + let mut standard_order = create_valid_standard_order(); + standard_order.outputs[0].token = alloy_primitives::B256::ZERO; + standard_order.outputs[0].context = context.into(); + let order_bytes = encode_standard_order(&standard_order); + + let result = order_impl.validate_order(&order_bytes).await; + assert!( + result.is_err(), + "expected native output with context 0x{context_tag:02x} to be rejected" + ); + } + + // Guard against over-rejection: a native OUTPUT with a normal (empty) or limit + // (single 0x00 byte) context must still be ACCEPTED. + for context in [Vec::::new(), vec![0x00]] { + let mut standard_order = create_valid_standard_order(); + standard_order.outputs[0].token = alloy_primitives::B256::ZERO; + standard_order.outputs[0].context = context.into(); + let order_bytes = encode_standard_order(&standard_order); + + let result = order_impl.validate_order(&order_bytes).await; + assert!( + result.is_ok(), + "native output with a normal/limit context must be accepted: {:?}", + result.err() + ); + } + } + #[tokio::test] async fn test_validate_order_rejects_unknown_non_empty_context() { let networks = create_test_networks(); @@ -1899,6 +1996,52 @@ mod tests { ); } + #[tokio::test] + async fn test_validate_order_rejects_native_input() { + let networks = create_test_networks(); + let oracle_routes = create_test_oracle_routes(); + let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap(); + + let mut standard_order = create_valid_standard_order(); + standard_order.inputs = vec![[U256::ZERO, U256::from(200)]]; + let order_bytes = encode_standard_order(&standard_order); + + let result = order_impl.validate_order(&order_bytes).await; + assert!(result.is_err(), "expected native input to be rejected"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("native inputs are unsupported"), + "unexpected error message: {err}" + ); + } + + #[tokio::test] + async fn test_validate_order_rejects_compact_native_input() { + let networks = create_test_networks(); + let oracle_routes = create_test_oracle_routes(); + let order_impl = Eip7683OrderImpl::new(networks, oracle_routes).unwrap(); + + // Compact resource-lock native input: input[0] = (lockTag << 160) | 0x0. + // The token address occupies only the low 20 bytes (all zero here), while the + // high bytes carry a nonzero lockTag. The old `input[0].is_zero()` guard checked + // the full 256-bit word, so this native input slipped through. bit 200 sits well + // above the low 160 address bits, so the low 20 bytes stay zero. + let mut standard_order = create_valid_standard_order(); + standard_order.inputs = vec![[U256::from(1) << 200, U256::from(200)]]; + let order_bytes = encode_standard_order(&standard_order); + + let result = order_impl.validate_order(&order_bytes).await; + assert!( + result.is_err(), + "expected Compact native input (zero token in low 20 bytes) to be rejected" + ); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("native inputs are unsupported"), + "unexpected error message: {err}" + ); + } + #[tokio::test] async fn test_validate_order_rejects_too_many_outputs_before_duplicate_check() { let networks = create_test_networks(); diff --git a/crates/solver-order/src/implementations/strategies/simple.rs b/crates/solver-order/src/implementations/strategies/simple.rs index 517e76a5..414639c4 100644 --- a/crates/solver-order/src/implementations/strategies/simple.rs +++ b/crates/solver-order/src/implementations/strategies/simple.rs @@ -5,8 +5,8 @@ use alloy_primitives::{hex, U256}; use async_trait::async_trait; use solver_types::{ - with_0x_prefix, ConfigSchema, ExecutionContext, ExecutionDecision, ExecutionParams, Field, - FieldType, Order, Schema, + is_native_address, with_0x_prefix, ConfigSchema, ExecutionContext, ExecutionDecision, + ExecutionParams, Field, FieldType, Order, Schema, }; use crate::{ExecutionStrategy, StrategyError}; @@ -95,42 +95,57 @@ impl ExecutionStrategy for SimpleStrategy { }); // Get token address from the asset's InteropAddress - let token_address = asset + let (token_address, is_native_token) = asset .ethereum_address() - .map(|addr| hex::encode(addr.as_slice())) + .map(|addr| { + let address = solver_types::Address(addr.as_slice().to_vec()); + (hex::encode(addr.as_slice()), is_native_address(&address)) + }) .unwrap_or_else(|e| { tracing::warn!( order_id = %order.id, error = %e, "Failed to get token address from asset" ); - String::new() + (String::new(), false) }); - // Build the balance key (chain_id, Some(token_address)) - let balance_key = (chain_id, Some(token_address.clone())); + let token_display = if is_native_token { + "native".to_string() + } else { + with_0x_prefix(&token_address) + }; + + let balance_key = if is_native_token { + (chain_id, None) + } else { + (chain_id, Some(token_address.clone())) + }; // Check if we have the balance for this token if let Some(balance_str) = context.solver_balances.get(&balance_key) { // Parse balance and required amount let balance = balance_str.parse::().unwrap_or(U256::ZERO); + // Strategy gate: for a native (zero-address) output this + // intentionally checks only `native_balance >= output.amount`, + // matching the ERC20 pattern above — a cheap early skip, NOT + // the authoritative solvency check. The real insolvency guard + // (`native_balance >= gas + value + fees`) is enforced at + // submit time by the delivery preflight + // (crates/solver-delivery/src/implementations/evm/alloy.rs:121-137). let required = output.amount; if balance < required { tracing::warn!( order_id = %order.id, chain_id = chain_id, - token = %with_0x_prefix(&token_address), + token = %token_display, balance = ?balance, required = ?required, "Insufficient token balance for order" ); return ExecutionDecision::Skip(format!( - "Insufficient balance on chain {}: have {} need {} of token {}", - chain_id, - balance, - required, - with_0x_prefix(&token_address) + "Insufficient balance on chain {chain_id}: have {balance} need {required} of token {token_display}" )); } } else { @@ -138,13 +153,11 @@ impl ExecutionStrategy for SimpleStrategy { tracing::warn!( order_id = %order.id, chain_id = chain_id, - token = %with_0x_prefix(&token_address), + token = %token_display, "No balance information available for token" ); return ExecutionDecision::Skip(format!( - "No balance information for token {} on chain {}", - with_0x_prefix(&token_address), - chain_id + "No balance information for token {token_display} on chain {chain_id}" )); } } @@ -446,6 +459,35 @@ mod tests { } } + #[tokio::test] + async fn test_should_execute_uses_native_balance_for_native_output() { + let strategy = SimpleStrategy::new(100); + let mut order_data = create_test_order_data(); + order_data.outputs[0].token = [0u8; 32]; + order_data.outputs[0].amount = U256::from(95); + let order = create_test_order(order_data); + + let mut context = + create_test_context(vec![(1, "50000000000"), (137, "30000000000")], vec![]); + context + .solver_balances + .insert((137, None), "200".to_string()); + + let decision = strategy.should_execute(&order, &context).await; + + match decision { + ExecutionDecision::Execute(params) => { + assert_eq!(params.gas_price, U256::from(50000000000u64)); + }, + ExecutionDecision::Skip(reason) => { + panic!("Expected Execute for sufficient native balance, got Skip: {reason}"); + }, + ExecutionDecision::Defer(duration) => { + panic!("Expected Execute for sufficient native balance, got Defer: {duration:?}"); + }, + } + } + #[tokio::test] async fn test_should_execute_unknown_standard() { let strategy = SimpleStrategy::new(100); // 100 gwei max diff --git a/crates/solver-pricing/src/lib.rs b/crates/solver-pricing/src/lib.rs index 1acb8a5e..cba1e0e8 100644 --- a/crates/solver-pricing/src/lib.rs +++ b/crates/solver-pricing/src/lib.rs @@ -75,6 +75,9 @@ pub const DEFAULT_TOKEN_MAPPINGS: &[(&str, &str)] = &[ ("WETH", "ethereum"), ("WBTC", "wrapped-bitcoin"), ("MATIC", "matic-network"), + ("POL", "polygon-ecosystem-token"), + ("BNB", "binancecoin"), + ("AVAX", "avalanche-2"), ("ARB", "arbitrum"), ("OP", "optimism"), ]; @@ -360,6 +363,9 @@ mod tests { assert!(DEFAULT_TOKEN_MAPPINGS.contains(&("ETH", "ethereum"))); assert!(DEFAULT_TOKEN_MAPPINGS.contains(&("BTC", "bitcoin"))); assert!(DEFAULT_TOKEN_MAPPINGS.contains(&("USDC", "usd-coin"))); + assert!(DEFAULT_TOKEN_MAPPINGS.contains(&("POL", "polygon-ecosystem-token"))); + assert!(DEFAULT_TOKEN_MAPPINGS.contains(&("BNB", "binancecoin"))); + assert!(DEFAULT_TOKEN_MAPPINGS.contains(&("AVAX", "avalanche-2"))); } // Tests for PricingService using mock implementation diff --git a/crates/solver-service/src/apis/admin.rs b/crates/solver-service/src/apis/admin.rs index 32b9c989..797b29ba 100644 --- a/crates/solver-service/src/apis/admin.rs +++ b/crates/solver-service/src/apis/admin.rs @@ -35,8 +35,8 @@ pub use solver_types::admin_api::{ #[cfg(test)] use solver_types::AdminWhitelistEntry as SolverAdminWhitelistEntry; use solver_types::{ - format_token_amount, with_0x_prefix, AdminConfig, AdminRole, OperatorAdminConfig, - OperatorConfig, OperatorToken, + format_token_amount, is_native_address, with_0x_prefix, AdminConfig, AdminRole, + OperatorAdminConfig, OperatorConfig, OperatorToken, }; use std::sync::Arc; use tokio::sync::RwLock; @@ -176,8 +176,17 @@ pub async fn handle_get_balances( for (chain_id, network) in networks { let mut tokens = Vec::new(); let mut error: Option = None; + let configured_native = network + .tokens + .iter() + .find(|token| is_native_address(&token.address)) + .cloned(); for token in &network.tokens { + if is_native_address(&token.address) { + continue; + } + match state .token_manager .check_balance(chain_id, &token.address) @@ -209,12 +218,25 @@ pub async fn handle_get_balances( .await { Ok(balance) => { - let formatted = format_token_amount(&balance, 18); + let decimals = configured_native + .as_ref() + .map(|token| token.decimals) + .unwrap_or(18); + let formatted = format_token_amount(&balance, decimals); tokens.push(TokenBalance { - address: zero_address.to_string(), - symbol: "NATIVE".to_string(), - name: Some("Native Token".to_string()), - decimals: 18, + address: configured_native + .as_ref() + .map(|token| token.address.to_string()) + .unwrap_or_else(|| zero_address.to_string()), + symbol: configured_native + .as_ref() + .map(|token| token.symbol.clone()) + .unwrap_or_else(|| "NATIVE".to_string()), + name: configured_native + .as_ref() + .and_then(|token| token.name.clone()) + .or_else(|| Some("Native Token".to_string())), + decimals, balance, balance_formatted: formatted, }); @@ -2012,6 +2034,87 @@ mod tests { } } + #[tokio::test] + async fn test_handle_get_balances_uses_configured_native_metadata_once() { + use solver_types::networks::{ + NetworkConfig as RuntimeNetworkConfig, RpcEndpoint, TokenConfig, + }; + + let mut mock_delivery = MockDeliveryInterface::new(); + mock_delivery + .expect_get_balance() + .times(2) + .returning(|_, token, chain_id| { + assert_eq!(chain_id, 1); + let balance = match token { + None => "5000000000000000000".to_string(), + Some("5555555555555555555555555555555555555555") => "1000000".to_string(), + Some(other) => panic!("unexpected balance query for {other}"), + }; + Box::pin(async move { Ok(balance) }) + }); + mock_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let mut implementations: HashMap> = HashMap::new(); + implementations.insert(1, Arc::new(mock_delivery)); + let delivery = Arc::new(DeliveryService::new(implementations, 1, 30, 60)); + let withdrawals = OperatorWithdrawalsConfig { + enabled: true, + recipient_allowlist: vec![], + }; + let operator_config = build_operator_config( + alloy_address("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + withdrawals, + ); + let state = create_admin_state_with_operator_config(operator_config, delivery).await; + + let native = TokenConfig { + address: solver_types::Address(vec![0u8; 20]), + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + decimals: 18, + }; + let erc20 = TokenConfig { + address: solver_address("0x5555555555555555555555555555555555555555"), + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + decimals: 6, + }; + let mut networks = NetworksConfig::default(); + networks.insert( + 1, + RuntimeNetworkConfig { + name: Some("mainnet".to_string()), + network_type: NetworkType::Parent, + rpc_urls: vec![RpcEndpoint::http_only("http://localhost:8545".to_string())], + input_settler_address: solver_address("0x1111111111111111111111111111111111111111"), + output_settler_address: solver_address( + "0x2222222222222222222222222222222222222222", + ), + tokens: vec![native.clone(), erc20], + input_settler_compact_address: None, + the_compact_address: None, + allocator_address: None, + }, + ); + state.token_manager.update_networks(networks).await; + + let response = handle_get_balances(State(state)).await.unwrap().0; + let tokens = &response.networks.get("1").unwrap().tokens; + let native_rows: Vec<_> = tokens + .iter() + .filter(|token| token.address == native.address.to_string()) + .collect(); + + assert_eq!(tokens.len(), 2); + assert_eq!(native_rows.len(), 1); + assert_eq!(native_rows[0].symbol, "ETH"); + assert_eq!(native_rows[0].name.as_deref(), Some("Ether")); + assert_eq!(native_rows[0].balance, "5000000000000000000"); + } + struct EnvVarGuard { key: &'static str, original: Option, diff --git a/crates/solver-service/src/apis/quote/mod.rs b/crates/solver-service/src/apis/quote/mod.rs index 78a4aac0..6d4d629d 100644 --- a/crates/solver-service/src/apis/quote/mod.rs +++ b/crates/solver-service/src/apis/quote/mod.rs @@ -749,12 +749,21 @@ mod tests { .unwrap(), output_settler_address: parse_address("0x2222222222222222222222222222222222222222") .unwrap(), - tokens: vec![solver_types::TokenConfig { - address: parse_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - decimals: 6, - }], + tokens: vec![ + solver_types::TokenConfig { + address: Address(vec![0u8; 20]), + symbol: "ETH".to_string(), + name: Some("Ether".to_string()), + decimals: 18, + }, + solver_types::TokenConfig { + address: parse_address("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + .unwrap(), + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + decimals: 6, + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -770,12 +779,21 @@ mod tests { .unwrap(), output_settler_address: parse_address("0x4444444444444444444444444444444444444444") .unwrap(), - tokens: vec![solver_types::TokenConfig { - address: parse_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174").unwrap(), - symbol: "USDC".to_string(), - name: Some("USD Coin".to_string()), - decimals: 6, - }], + tokens: vec![ + solver_types::TokenConfig { + address: Address(vec![0u8; 20]), + symbol: "POL".to_string(), + name: Some("Polygon Ecosystem Token".to_string()), + decimals: 18, + }, + solver_types::TokenConfig { + address: parse_address("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174") + .unwrap(), + symbol: "USDC".to_string(), + name: Some("USD Coin".to_string()), + decimals: 6, + }, + ], input_settler_compact_address: None, the_compact_address: None, allocator_address: None, @@ -921,6 +939,8 @@ mod tests { let pricing_impl = solver_pricing::implementations::mock::create_mock_pricing(&serde_json::json!({ "pair_prices": { + "ETH/USD": "2000.0", + "POL/USD": "0.5", "USDC/USD": "1.0" } })) diff --git a/crates/solver-service/src/apis/quote/validation.rs b/crates/solver-service/src/apis/quote/validation.rs index 1cef0282..e331ea67 100644 --- a/crates/solver-service/src/apis/quote/validation.rs +++ b/crates/solver-service/src/apis/quote/validation.rs @@ -451,6 +451,12 @@ impl QuoteValidator { let asset_addr = &asset_info.asset; let (chain_id, evm_addr) = Self::extract_chain_and_address(asset_addr)?; + if evm_addr == AlloyAddress::ZERO { + return Err(QuoteError::UnsupportedAsset(format!( + "Native input token is not supported on chain {chain_id}" + ))); + } + // ALL assets must be supported for proper pricing if !Self::is_token_supported(networks, chain_id, &evm_addr) { return Err(QuoteError::UnsupportedAsset(format!( @@ -586,7 +592,7 @@ impl QuoteValidator { let token_addr: solver_types::Address = evm_addr.into(); let balance_str = token_manager - .check_balance(chain_id, &token_addr) + .check_balance_any(chain_id, &token_addr) .await .map_err(|e| QuoteError::Internal(format!("Balance check failed: {e}")))?; @@ -760,11 +766,20 @@ mod tests { /// Creates a minimal mock SolverEngine for testing with custom network configurations async fn create_mock_solver_with_networks(networks: NetworksConfig) -> SolverEngine { + use solver_delivery::DeliveryService; + + let delivery = Arc::new(DeliveryService::new(HashMap::new(), 1, 20, 60)); + create_mock_solver_with_networks_and_delivery(networks, delivery).await + } + + async fn create_mock_solver_with_networks_and_delivery( + networks: NetworksConfig, + delivery: Arc, + ) -> SolverEngine { use solver_account::AccountService; use solver_config::Config; use solver_core::engine::event_bus::EventBus; use solver_core::engine::token_manager::TokenManager; - use solver_delivery::DeliveryService; use solver_discovery::DiscoveryService; use solver_order::OrderService; use solver_settlement::SettlementService; @@ -830,7 +845,6 @@ mod tests { )); let solver_address = Address(vec![1u8; 20]); - let delivery = Arc::new(DeliveryService::new(HashMap::new(), 1, 20, 60)); let discovery = Arc::new(DiscoveryService::new(HashMap::new())); let strategy_config = serde_json::Value::Object(serde_json::Map::new()); @@ -1971,6 +1985,38 @@ mod tests { ); } + #[test] + fn test_validate_and_collect_inputs_with_costs_rejects_native_input_even_when_configured() { + let input_token = InteropAddress::new_ethereum(1, alloy_primitives::Address::ZERO); + let output_token = + InteropAddress::new_ethereum(137, address!("2b2C76e42a8a6dDA8dF24ecCc6C8a9D3f5506bF1")); + + let mut supported_tokens = HashMap::new(); + supported_tokens.insert(1u64, vec![alloy_primitives::Address::ZERO]); + let networks = create_mock_solver_with_token_support(supported_tokens); + + let request = create_simple_test_quote_request( + input_token, + Some("1000000"), + output_token, + None, + SwapType::ExactInput, + ); + let cost_context = + create_mock_cost_context(HashMap::new(), HashMap::new(), SwapType::ExactInput); + + let result = QuoteValidator::validate_and_collect_inputs_with_costs( + &request, + &networks, + &cost_context, + ); + + assert!( + matches!(result, Err(QuoteError::UnsupportedAsset(ref msg)) if msg.contains("Native input token is not supported")), + "Should reject native input token despite config entry: {result:?}" + ); + } + #[test] fn test_validate_and_collect_inputs_with_costs_multiple_inputs_mixed_support() { // Test: Multiple inputs, some supported, some not - should fail if ANY is unsupported @@ -2257,6 +2303,55 @@ mod tests { assert_eq!(asset2.amount, U256::from(750000u64)); // From cost context } + #[tokio::test] + async fn test_ensure_destination_balances_with_costs_uses_native_balance_for_native_output() { + use solver_delivery::{DeliveryInterface, DeliveryService, MockDeliveryInterface}; + + let mut mock_delivery = MockDeliveryInterface::new(); + mock_delivery + .expect_get_balance() + .times(1) + .withf(|_, token, chain_id| token.is_none() && *chain_id == 137) + .returning(|_, _, _| Box::pin(async { Ok("100".to_string()) })); + mock_delivery.expect_config_schema().returning(|| { + Box::new(solver_delivery::implementations::evm::alloy::AlloyDeliverySchema) + }); + + let mut implementations: HashMap> = HashMap::new(); + implementations.insert(137, Arc::new(mock_delivery)); + let delivery = Arc::new(DeliveryService::new(implementations, 1, 20, 60)); + let solver = create_mock_solver_with_networks_and_delivery(HashMap::new(), delivery).await; + + let native_asset = + InteropAddress::new_ethereum(137, address!("0000000000000000000000000000000000000000")); + let outputs = vec![SupportedAsset { + asset: native_asset, + amount: U256::from(100), + }]; + let context = ValidatedQuoteContext { + swap_type: SwapType::ExactOutput, + known_inputs: None, + known_outputs: Some(vec![]), + constraint_inputs: Some(vec![]), + constraint_outputs: None, + }; + let cost_context = + create_mock_cost_context(HashMap::new(), HashMap::new(), SwapType::ExactOutput); + + let result = QuoteValidator::ensure_destination_balances_with_costs( + &solver, + &outputs, + &context, + &cost_context, + ) + .await; + + assert!( + result.is_ok(), + "Native output liquidity should use native balance: {result:?}" + ); + } + // ========== validate_callback_whitelist Tests ========== fn create_test_config_with_callbacks( diff --git a/crates/solver-types/src/lib.rs b/crates/solver-types/src/lib.rs index a82cfab9..79916d9a 100644 --- a/crates/solver-types/src/lib.rs +++ b/crates/solver-types/src/lib.rs @@ -105,10 +105,11 @@ pub use transaction_attempt::{ TransactionAttempt, TransactionAttemptScope, TransactionAttemptStatus, }; pub use utils::{ - bytes32_to_address, current_timestamp, format_token_amount, normalize_bytes32_address, - order_id_to_bytes32, parse_address, truncate_id, wei_string_to_eth_string, with_0x_prefix, - without_0x_prefix, DEFAULT_GAS_PRICE_WEI, MOCK_ETH_SOL_PRICE, MOCK_ETH_USD_PRICE, - MOCK_SOL_USD_PRICE, MOCK_TOKA_USD_PRICE, MOCK_TOKB_USD_PRICE, + bytes32_to_address, current_timestamp, format_token_amount, is_native_address, + is_native_token_id, normalize_bytes32_address, order_id_to_bytes32, parse_address, truncate_id, + wei_string_to_eth_string, with_0x_prefix, without_0x_prefix, DEFAULT_GAS_PRICE_WEI, + MOCK_ETH_SOL_PRICE, MOCK_ETH_USD_PRICE, MOCK_SOL_USD_PRICE, MOCK_TOKA_USD_PRICE, + MOCK_TOKB_USD_PRICE, }; pub use validation::*; pub use versioned::Versioned; diff --git a/crates/solver-types/src/utils/conversion.rs b/crates/solver-types/src/utils/conversion.rs index 402169d1..a3aabfc8 100644 --- a/crates/solver-types/src/utils/conversion.rs +++ b/crates/solver-types/src/utils/conversion.rs @@ -22,6 +22,16 @@ pub fn pow10(dp: u32) -> Decimal { f } +/// True iff the 32-byte mandate token identifier denotes the native gas token. +pub fn is_native_token_id(token: &[u8; 32]) -> bool { + token.iter().all(|b| *b == 0) +} + +/// True iff a solver address is exactly the 20-byte zero address. +pub fn is_native_address(addr: &Address) -> bool { + addr.0.len() == 20 && addr.0.iter().all(|b| *b == 0) +} + /// Ceiling to `dp` decimal places. /// /// Rounds a decimal number up to the specified number of decimal places. @@ -432,6 +442,27 @@ mod tests { } #[allow(clippy::mixed_case_hex_literals)] + #[test] + fn test_is_native_token_id_detects_only_bytes32_zero() { + assert!(is_native_token_id(&[0u8; 32])); + + let mut non_native = [0u8; 32]; + non_native[31] = 1; + assert!(!is_native_token_id(&non_native)); + } + + #[test] + fn test_is_native_address_requires_exact_20_zero_bytes() { + assert!(is_native_address(&Address(vec![0u8; 20]))); + assert!(!is_native_address(&Address(Vec::new()))); + assert!(!is_native_address(&Address(vec![0u8; 19]))); + assert!(!is_native_address(&Address(vec![0u8; 21]))); + + let mut non_native = vec![0u8; 20]; + non_native[19] = 1; + assert!(!is_native_address(&Address(non_native))); + } + #[test] fn test_bytes20_to_alloy_address_valid() { // Test with a valid 20-byte address diff --git a/crates/solver-types/src/utils/mod.rs b/crates/solver-types/src/utils/mod.rs index a4115288..3e1926e4 100644 --- a/crates/solver-types/src/utils/mod.rs +++ b/crates/solver-types/src/utils/mod.rs @@ -16,8 +16,9 @@ pub use constants::{ }; pub use conversion::{ address_to_bytes32, address_to_bytes32_hex, addresses_equal, bytes20_to_alloy_address, - bytes32_to_address, hex_to_alloy_address, normalize_bytes32_address, parse_address, - parse_bytes32_from_hex, solver_address_to_bytes32, wei_string_to_eth_string, + bytes32_to_address, hex_to_alloy_address, is_native_address, is_native_token_id, + normalize_bytes32_address, parse_address, parse_bytes32_from_hex, solver_address_to_bytes32, + wei_string_to_eth_string, }; pub use eip712::{ admin_eip712_types, compute_domain_hash, compute_final_digest, reconstruct_compact_digest,