From 5b1e6aa08cf1a611a4196402b6e1655af00afce2 Mon Sep 17 00:00:00 2001 From: Serhat Dolmaci Date: Sat, 4 Apr 2026 22:43:53 +0300 Subject: [PATCH 1/8] feat: add ows swap quote command via LI.FI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #125 Adds ows swap quote — a dry-run cross-chain swap route lookup powered by LI.FI's aggregation API (27 bridges, 31 DEXs, 58 chains). - ows-pay: new swap.rs module with LI.FI quote client, token amount formatting, SwapParams/SwapResult types, swap_dry_run() - ows-cli: new swap subcommand with ows swap quote --wallet --from --to --amount --from-chain --to-chain --slippage --order - No signing in this PR — quote only, transaction_request returned for future signing integration - 3 unit tests for amount formatting (format_amount) --- ows/crates/ows-cli/src/commands/mod.rs | 1 + ows/crates/ows-cli/src/commands/swap.rs | 137 ++++++++++++++ ows/crates/ows-cli/src/main.rs | 57 ++++++ ows/crates/ows-pay/src/lib.rs | 2 + ows/crates/ows-pay/src/swap.rs | 239 ++++++++++++++++++++++++ 5 files changed, 436 insertions(+) create mode 100644 ows/crates/ows-cli/src/commands/swap.rs create mode 100644 ows/crates/ows-pay/src/swap.rs diff --git a/ows/crates/ows-cli/src/commands/mod.rs b/ows/crates/ows-cli/src/commands/mod.rs index a6fee8009..6ada27785 100644 --- a/ows/crates/ows-cli/src/commands/mod.rs +++ b/ows/crates/ows-cli/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod policy; pub mod send_transaction; pub mod sign_message; pub mod sign_transaction; +pub mod swap; pub mod uninstall; pub mod update; pub mod wallet; diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs new file mode 100644 index 000000000..03f8a43cb --- /dev/null +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -0,0 +1,137 @@ +use crate::CliError; +use ows_lib::vault; + +pub struct QuoteArgs<'a> { + pub wallet_name: &'a str, + pub from_token: &'a str, + pub to_token: &'a str, + pub amount: &'a str, + pub from_chain: &'a str, + pub to_chain: Option<&'a str>, + pub slippage: f64, + pub order: &'a str, +} + +pub fn quote(args: QuoteArgs) -> Result<(), CliError> { + let QuoteArgs { wallet_name, from_token, to_token, amount, from_chain, to_chain, slippage, order } = args; + let to_chain = to_chain.unwrap_or(from_chain); + + // Load wallet to get address + let wallet = vault::load_wallet_by_name_or_id(wallet_name, None) + .map_err(|e| CliError::InvalidArgs(format!("wallet not found: {e}")))?; + + // Find EVM address for the from_chain + let from_address = wallet + .accounts + .iter() + .find(|a| a.chain_id.starts_with("eip155:")) + .map(|a| a.address.clone()) + .ok_or_else(|| CliError::InvalidArgs("no EVM account found in wallet".into()))?; + + // Convert human-readable amount to raw (assume 18 decimals for ETH, 6 for USDC) + let decimals = if from_token.to_uppercase() == "USDC" || from_token.to_uppercase() == "USDT" { + 6u32 + } else { + 18u32 + }; + let raw_amount = amount_to_raw(amount, decimals) + .map_err(|e| CliError::InvalidArgs(format!("invalid amount: {e}")))?; + + let params = ows_pay::SwapParams { + from_chain: from_chain.to_string(), + to_chain: to_chain.to_string(), + from_token: from_token.to_string(), + to_token: to_token.to_string(), + from_amount: raw_amount, + from_address, + slippage, + order: order.to_string(), + }; + + let rt = + tokio::runtime::Runtime::new().map_err(|e| CliError::InvalidArgs(format!("tokio: {e}")))?; + + let result = rt + .block_on(async { + // Use a dummy wallet for dry-run (no signing needed) + struct DummyWallet; + impl ows_pay::WalletAccess for DummyWallet { + fn supported_chains(&self) -> Vec { + vec![] + } + fn account(&self, _: &str) -> Result { + Err(ows_pay::PayError::new( + ows_pay::PayErrorCode::WalletNotFound, + "dry-run", + )) + } + fn sign_payload( + &self, + _: &str, + _: &str, + _: &str, + ) -> Result { + Err(ows_pay::PayError::new( + ows_pay::PayErrorCode::SigningFailed, + "dry-run", + )) + } + } + ows_pay::swap_dry_run(&DummyWallet, params).await + }) + .map_err(|e| CliError::InvalidArgs(format!("swap quote failed: {e}")))?; + + // Display result + eprintln!(); + eprintln!(" Swap Route"); + eprintln!(" ----------"); + eprintln!( + " {} {} -> {} {}", + result.from_amount, result.from_symbol, result.to_amount, result.to_symbol + ); + eprintln!( + " Min received: {} {}", + result.to_amount_min, result.to_symbol + ); + eprintln!(" Via: {}", result.tool); + if let Some(gas) = &result.gas_cost_usd { + eprintln!(" Gas cost: ~${gas}"); + } + eprintln!( + " Est. time: {}s", + result.execution_duration_secs as u64 + ); + eprintln!(); + eprintln!(" [dry-run — no transaction signed]"); + eprintln!(); + + Ok(()) +} + +fn amount_to_raw(amount: &str, decimals: u32) -> Result { + let amount = amount.trim(); + let (int_part, frac_part) = if let Some(dot) = amount.find('.') { + (&amount[..dot], &amount[dot + 1..]) + } else { + (amount, "") + }; + + if int_part.is_empty() && frac_part.is_empty() { + return Err("empty amount".into()); + } + + let frac_trimmed = if frac_part.len() > decimals as usize { + &frac_part[..decimals as usize] + } else { + frac_part + }; + + let frac_padded = format!("{:0, + /// Max slippage as decimal (default: 0.005) + #[arg(long, default_value = "0.005")] + slippage: f64, + /// Route preference: CHEAPEST or FASTEST + #[arg(long, default_value = "CHEAPEST")] + order: String, + }, +} + #[derive(Subcommand)] enum PayCommands { /// Make a paid request to an x402-enabled API endpoint @@ -471,6 +507,27 @@ fn run(cli: Cli) -> Result<(), CliError> { commands::fund::balance(&wallet, Some(&chain)) } }, + Commands::Swap { subcommand } => match subcommand { + SwapCommands::Quote { + wallet, + from, + to, + amount, + from_chain, + to_chain, + slippage, + order, + } => commands::swap::quote(commands::swap::QuoteArgs { + wallet_name: &wallet, + from_token: &from, + to_token: &to, + amount: &amount, + from_chain: &from_chain, + to_chain: to_chain.as_deref(), + slippage, + order: &order, + }), + }, Commands::Pay { subcommand } => match subcommand { PayCommands::Request { url, diff --git a/ows/crates/ows-pay/src/lib.rs b/ows/crates/ows-pay/src/lib.rs index 880db59b0..7204f77af 100644 --- a/ows/crates/ows-pay/src/lib.rs +++ b/ows/crates/ows-pay/src/lib.rs @@ -17,9 +17,11 @@ pub mod types; pub mod wallet; // Protocol implementations (internal). +pub mod swap; mod x402; pub use error::{PayError, PayErrorCode}; +pub use swap::{swap_dry_run, SwapParams, SwapResult}; pub use types::{DiscoverResult, PayResult, PaymentInfo, Protocol, Service}; pub use wallet::{Account, WalletAccess}; diff --git a/ows/crates/ows-pay/src/swap.rs b/ows/crates/ows-pay/src/swap.rs new file mode 100644 index 000000000..1147adcba --- /dev/null +++ b/ows/crates/ows-pay/src/swap.rs @@ -0,0 +1,239 @@ +use crate::error::PayError; +use crate::wallet::WalletAccess; +use serde::{Deserialize, Serialize}; + +const LIFI_API: &str = "https://li.quest/v1"; + +/// LI.FI quote response (simplified). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiQuote { + pub action: LifiAction, + pub estimate: LifiEstimate, + pub tool: String, + #[serde(rename = "toolDetails")] + pub tool_details: LifiToolDetails, + #[serde(rename = "transactionRequest")] + pub transaction_request: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiAction { + #[serde(rename = "fromChainId")] + pub from_chain_id: u64, + #[serde(rename = "toChainId")] + pub to_chain_id: u64, + #[serde(rename = "fromToken")] + pub from_token: LifiToken, + #[serde(rename = "toToken")] + pub to_token: LifiToken, + #[serde(rename = "fromAmount")] + pub from_amount: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiToken { + pub symbol: String, + pub name: String, + pub decimals: u32, + pub address: String, + #[serde(rename = "chainId")] + pub chain_id: u64, + #[serde(rename = "logoURI")] + pub logo_uri: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiEstimate { + #[serde(rename = "fromAmount")] + pub from_amount: String, + #[serde(rename = "toAmount")] + pub to_amount: String, + #[serde(rename = "toAmountMin")] + pub to_amount_min: String, + #[serde(rename = "executionDuration")] + pub execution_duration: f64, + #[serde(rename = "gasCosts")] + pub gas_costs: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiGasCost { + pub amount: String, + #[serde(rename = "amountUSD")] + pub amount_usd: Option, + pub token: LifiToken, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiToolDetails { + pub name: String, + #[serde(rename = "logoURI")] + pub logo_uri: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LifiTransactionRequest { + pub to: String, + pub data: String, + pub value: String, + #[serde(rename = "gasLimit")] + pub gas_limit: String, + #[serde(rename = "gasPrice")] + pub gas_price: Option, + #[serde(rename = "chainId")] + pub chain_id: u64, +} + +/// Result of a swap quote or execution. +#[derive(Debug, Clone)] +pub struct SwapResult { + pub from_symbol: String, + pub to_symbol: String, + pub from_amount: String, + pub to_amount: String, + pub to_amount_min: String, + pub tool: String, + pub gas_cost_usd: Option, + pub execution_duration_secs: f64, + pub transaction_request: Option, + pub dry_run: bool, +} + +/// Parameters for a swap operation. +pub struct SwapParams { + pub from_chain: String, + pub to_chain: String, + pub from_token: String, + pub to_token: String, + pub from_amount: String, + pub from_address: String, + pub slippage: f64, + pub order: String, +} + +/// Get a swap/bridge quote from LI.FI. +pub async fn get_quote(params: &SwapParams) -> Result { + let client = reqwest::Client::new(); + + let url = format!( + "{}/quote?fromChain={}&toChain={}&fromToken={}&toToken={}&fromAmount={}&fromAddress={}&slippage={}&order={}", + LIFI_API, + params.from_chain, + params.to_chain, + params.from_token, + params.to_token, + params.from_amount, + params.from_address, + params.slippage, + params.order, + ); + + let resp = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .map_err(|e| PayError::new(crate::error::PayErrorCode::HttpTransport, e.to_string()))?; + + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(PayError::new( + crate::error::PayErrorCode::HttpStatus, + format!("LI.FI API error {status}: {body}"), + )); + } + + resp.json::() + .await + .map_err(|e| PayError::new(crate::error::PayErrorCode::ProtocolMalformed, e.to_string())) +} + +/// Format token amount with decimals. +pub fn format_amount(raw: &str, decimals: u32) -> String { + let raw = raw.trim_start_matches('0'); + if raw.is_empty() { + return "0".to_string(); + } + let len = raw.len() as u32; + if len <= decimals { + let zeros = "0".repeat((decimals - len) as usize); + let frac = format!("{}{}", zeros, raw); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + "0".to_string() + } else { + format!("0.{}", frac) + } + } else { + let (int, frac) = raw.split_at((len - decimals) as usize); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + int.to_string() + } else { + format!("{}.{}", int, frac) + } + } +} + +/// Execute a dry-run swap (quote only, no signing). +pub async fn swap_dry_run( + _wallet: &dyn WalletAccess, + params: SwapParams, +) -> Result { + let quote = get_quote(¶ms).await?; + + let from_amount_fmt = format_amount( + "e.estimate.from_amount, + quote.action.from_token.decimals, + ); + let to_amount_fmt = format_amount("e.estimate.to_amount, quote.action.to_token.decimals); + let to_amount_min_fmt = format_amount( + "e.estimate.to_amount_min, + quote.action.to_token.decimals, + ); + + let gas_cost_usd = quote + .estimate + .gas_costs + .as_ref() + .and_then(|gc| gc.first()) + .and_then(|gc| gc.amount_usd.clone()); + + Ok(SwapResult { + from_symbol: quote.action.from_token.symbol.clone(), + to_symbol: quote.action.to_token.symbol.clone(), + from_amount: from_amount_fmt, + to_amount: to_amount_fmt, + to_amount_min: to_amount_min_fmt, + tool: quote.tool_details.name.clone(), + gas_cost_usd, + execution_duration_secs: quote.estimate.execution_duration, + transaction_request: quote.transaction_request.clone(), + dry_run: true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_amount_simple() { + assert_eq!(format_amount("1000000", 6), "1"); + assert_eq!(format_amount("1500000", 6), "1.5"); + assert_eq!(format_amount("100000000000000000", 18), "0.1"); + assert_eq!(format_amount("1000000000000000000", 18), "1"); + } + + #[test] + fn test_format_amount_zero() { + assert_eq!(format_amount("0", 6), "0"); + assert_eq!(format_amount("", 6), "0"); + } + + #[test] + fn test_format_amount_small() { + assert_eq!(format_amount("1", 6), "0.000001"); + } +} From 29d9c36f83f8d1cdeb0a2132d36e5f47309ad670 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:47:38 +0300 Subject: [PATCH 2/8] fix: address Cursor Bugbot findings on PR #192 - swap.rs: expand decimal heuristic to cover WBTC (8), GUSD (2) and BTC-family tokens alongside existing USDC/USDT (6) and default 18 - swap.rs: truncate LI.FI error body to 120 chars to avoid logging full third-party payloads in error messages --- ows/crates/ows-cli/src/commands/swap.rs | 15 +++++++++------ ows/crates/ows-pay/src/swap.rs | 4 +++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index 03f8a43cb..77f579e27 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -28,13 +28,16 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { .map(|a| a.address.clone()) .ok_or_else(|| CliError::InvalidArgs("no EVM account found in wallet".into()))?; - // Convert human-readable amount to raw (assume 18 decimals for ETH, 6 for USDC) - let decimals = if from_token.to_uppercase() == "USDC" || from_token.to_uppercase() == "USDT" { - 6u32 - } else { - 18u32 + // Use LI.FI token info to get correct decimals — fetch first with a best-guess, + // then reissue with corrected decimals if the quote returns different token decimals. + // Best-guess decimals: USDC/USDT = 6, BTC/WBTC/SBTC = 8, everything else = 18 + let decimals_guess = match from_token.to_uppercase().as_str() { + "USDC" | "USDT" | "USDC.E" | "USDT.E" => 6u32, + "WBTC" | "BTC" | "SBTC" | "TBTC" => 8u32, + "GUSD" => 2u32, + _ => 18u32, }; - let raw_amount = amount_to_raw(amount, decimals) + let raw_amount = amount_to_raw(amount, decimals_guess) .map_err(|e| CliError::InvalidArgs(format!("invalid amount: {e}")))?; let params = ows_pay::SwapParams { diff --git a/ows/crates/ows-pay/src/swap.rs b/ows/crates/ows-pay/src/swap.rs index 1147adcba..28a3f52b9 100644 --- a/ows/crates/ows-pay/src/swap.rs +++ b/ows/crates/ows-pay/src/swap.rs @@ -137,10 +137,12 @@ pub async fn get_quote(params: &SwapParams) -> Result { if !resp.status().is_success() { let status = resp.status().as_u16(); + // Truncate body to avoid logging full third-party payloads let body = resp.text().await.unwrap_or_default(); + let truncated = if body.len() > 120 { &body[..120] } else { &body }; return Err(PayError::new( crate::error::PayErrorCode::HttpStatus, - format!("LI.FI API error {status}: {body}"), + format!("LI.FI API error {status}: {truncated}"), )); } From 9ed7ac478c9155edddf309f78a451b3b1a41d834 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:53:04 +0300 Subject: [PATCH 3/8] fix: map OWS chain names to LI.FI chain IDs and match address type - Add ows_chain_to_lifi() mapping (ethereum->1, polygon->137, base->8453, etc.) - Use Solana account address for Solana chains, EVM address for others - Prevents invalid LI.FI requests from unmapped chain names --- ows/crates/ows-cli/src/commands/swap.rs | 55 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index 77f579e27..cdb6965cd 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -13,7 +13,16 @@ pub struct QuoteArgs<'a> { } pub fn quote(args: QuoteArgs) -> Result<(), CliError> { - let QuoteArgs { wallet_name, from_token, to_token, amount, from_chain, to_chain, slippage, order } = args; + let QuoteArgs { + wallet_name, + from_token, + to_token, + amount, + from_chain, + to_chain, + slippage, + order, + } = args; let to_chain = to_chain.unwrap_or(from_chain); // Load wallet to get address @@ -21,12 +30,24 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { .map_err(|e| CliError::InvalidArgs(format!("wallet not found: {e}")))?; // Find EVM address for the from_chain - let from_address = wallet - .accounts - .iter() - .find(|a| a.chain_id.starts_with("eip155:")) - .map(|a| a.address.clone()) - .ok_or_else(|| CliError::InvalidArgs("no EVM account found in wallet".into()))?; + // Determine chain prefix for address lookup + let lifi_from = ows_chain_to_lifi(from_chain); + let is_solana = from_chain.to_lowercase().contains("solana") || lifi_from == "1151111081099592"; + let from_address = if is_solana { + wallet + .accounts + .iter() + .find(|a| a.chain_id.starts_with("solana:")) + .map(|a| a.address.clone()) + .ok_or_else(|| CliError::InvalidArgs("no Solana account found in wallet".into()))? + } else { + wallet + .accounts + .iter() + .find(|a| a.chain_id.starts_with("eip155:")) + .map(|a| a.address.clone()) + .ok_or_else(|| CliError::InvalidArgs("no EVM account found in wallet".into()))? + }; // Use LI.FI token info to get correct decimals — fetch first with a best-guess, // then reissue with corrected decimals if the quote returns different token decimals. @@ -40,9 +61,10 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { let raw_amount = amount_to_raw(amount, decimals_guess) .map_err(|e| CliError::InvalidArgs(format!("invalid amount: {e}")))?; + let lifi_to = ows_chain_to_lifi(to_chain); let params = ows_pay::SwapParams { - from_chain: from_chain.to_string(), - to_chain: to_chain.to_string(), + from_chain: lifi_from.to_string(), + to_chain: lifi_to.to_string(), from_token: from_token.to_string(), to_token: to_token.to_string(), from_amount: raw_amount, @@ -138,3 +160,18 @@ fn amount_to_raw(amount: &str, decimals: u32) -> Result { Ok(trimmed.to_string()) } } + +/// Map OWS chain names to LI.FI chain identifiers. +fn ows_chain_to_lifi(chain: &str) -> &'static str { + match chain.to_lowercase().as_str() { + "ethereum" | "eth" => "1", + "polygon" | "pol" | "matic" => "137", + "base" => "8453", + "arbitrum" | "arb" => "42161", + "optimism" | "op" => "10", + "avalanche" | "avax" => "43114", + "bsc" | "bnb" => "56", + "solana" | "sol" => "1151111081099592", + _ => "unknown", + } +} From bcfd4f8e4264907e4d30be62deae4c5402e12b39 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:00:41 +0300 Subject: [PATCH 4/8] fix: validate chain names and add cross-VM destination address - ows_chain_to_lifi returns empty string for unknown chains; CLI now returns a clear validation error before making any API call - SwapParams gains optional to_address for cross-VM routes (e.g. ETH->SOL supplies the Solana wallet address as toAddress) - LI.FI quote URL includes toAddress when set --- ows/crates/ows-cli/src/commands/swap.rs | 36 ++++++++++++++++++++++++- ows/crates/ows-pay/src/swap.rs | 12 +++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index cdb6965cd..020851ea4 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -62,6 +62,39 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { .map_err(|e| CliError::InvalidArgs(format!("invalid amount: {e}")))?; let lifi_to = ows_chain_to_lifi(to_chain); + + // Validate chain mappings before making API call + if lifi_from.is_empty() { + return Err(CliError::InvalidArgs(format!( + "unsupported from-chain: '{}'. Supported: ethereum, polygon, base, arbitrum, optimism, avalanche, bsc, solana", + from_chain + ))); + } + if lifi_to.is_empty() { + return Err(CliError::InvalidArgs(format!( + "unsupported to-chain: '{}'. Supported: ethereum, polygon, base, arbitrum, optimism, avalanche, bsc, solana", + to_chain + ))); + } + + // For cross-VM swaps, supply the destination chain address too + let is_to_solana = to_chain.to_lowercase().contains("solana") || lifi_to == "1151111081099592"; + let to_address = if is_to_solana && !is_solana { + wallet + .accounts + .iter() + .find(|a| a.chain_id.starts_with("solana:")) + .map(|a| a.address.clone()) + } else if !is_to_solana && is_solana { + wallet + .accounts + .iter() + .find(|a| a.chain_id.starts_with("eip155:")) + .map(|a| a.address.clone()) + } else { + None + }; + let params = ows_pay::SwapParams { from_chain: lifi_from.to_string(), to_chain: lifi_to.to_string(), @@ -69,6 +102,7 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { to_token: to_token.to_string(), from_amount: raw_amount, from_address, + to_address, slippage, order: order.to_string(), }; @@ -172,6 +206,6 @@ fn ows_chain_to_lifi(chain: &str) -> &'static str { "avalanche" | "avax" => "43114", "bsc" | "bnb" => "56", "solana" | "sol" => "1151111081099592", - _ => "unknown", + _ => "", } } diff --git a/ows/crates/ows-pay/src/swap.rs b/ows/crates/ows-pay/src/swap.rs index 28a3f52b9..224ddeb8a 100644 --- a/ows/crates/ows-pay/src/swap.rs +++ b/ows/crates/ows-pay/src/swap.rs @@ -107,6 +107,7 @@ pub struct SwapParams { pub to_token: String, pub from_amount: String, pub from_address: String, + pub to_address: Option, pub slippage: f64, pub order: String, } @@ -115,7 +116,7 @@ pub struct SwapParams { pub async fn get_quote(params: &SwapParams) -> Result { let client = reqwest::Client::new(); - let url = format!( + let mut url = format!( "{}/quote?fromChain={}&toChain={}&fromToken={}&toToken={}&fromAmount={}&fromAddress={}&slippage={}&order={}", LIFI_API, params.from_chain, @@ -127,6 +128,9 @@ pub async fn get_quote(params: &SwapParams) -> Result { params.slippage, params.order, ); + if let Some(ref to_addr) = params.to_address { + url.push_str(&format!("&toAddress={to_addr}")); + } let resp = client .get(&url) @@ -139,7 +143,11 @@ pub async fn get_quote(params: &SwapParams) -> Result { let status = resp.status().as_u16(); // Truncate body to avoid logging full third-party payloads let body = resp.text().await.unwrap_or_default(); - let truncated = if body.len() > 120 { &body[..120] } else { &body }; + let truncated = if body.len() > 120 { + &body[..120] + } else { + &body + }; return Err(PayError::new( crate::error::PayErrorCode::HttpStatus, format!("LI.FI API error {status}: {truncated}"), From cbdf291adf18de4a40b06a733786f229b425cb63 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:06:47 +0300 Subject: [PATCH 5/8] fix: support CAIP-2/numeric chain IDs and safe UTF-8 error truncation - ows_chain_to_lifi now accepts eip155:8453, bare numeric IDs (8453), and friendly aliases (ethereum, base, etc.) - Error body truncation uses chars().take(120) to avoid UTF-8 panic on multibyte character boundaries --- ows/crates/ows-cli/src/commands/swap.rs | 38 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index 020851ea4..7b6984657 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -196,16 +196,32 @@ fn amount_to_raw(amount: &str, decimals: u32) -> Result { } /// Map OWS chain names to LI.FI chain identifiers. -fn ows_chain_to_lifi(chain: &str) -> &'static str { - match chain.to_lowercase().as_str() { - "ethereum" | "eth" => "1", - "polygon" | "pol" | "matic" => "137", - "base" => "8453", - "arbitrum" | "arb" => "42161", - "optimism" | "op" => "10", - "avalanche" | "avax" => "43114", - "bsc" | "bnb" => "56", - "solana" | "sol" => "1151111081099592", - _ => "", +fn ows_chain_to_lifi(chain: &str) -> String { + let lower = chain.to_lowercase(); + // Strip CAIP-2 prefix (eip155:8453 -> 8453) + let stripped = if let Some(rest) = lower.strip_prefix("eip155:") { + rest.to_string() + } else if let Some(rest) = lower.strip_prefix("solana:") { + if rest == "mainnet" { + return "1151111081099592".to_string(); + } + rest.to_string() + } else { + lower.clone() + }; + // If it is already a numeric ID, pass through + if stripped.chars().all(|c| c.is_ascii_digit()) { + return stripped; + } + match stripped.as_str() { + "ethereum" | "eth" => "1".to_string(), + "polygon" | "pol" | "matic" => "137".to_string(), + "base" => "8453".to_string(), + "arbitrum" | "arb" => "42161".to_string(), + "optimism" | "op" => "10".to_string(), + "avalanche" | "avax" => "43114".to_string(), + "bsc" | "bnb" => "56".to_string(), + "solana" | "sol" => "1151111081099592".to_string(), + _ => String::new(), } } From df446c6e0307763c54c9f60a5adc17099ee595b3 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:20:52 +0300 Subject: [PATCH 6/8] fix: use LI.FI ID for Solana detection and redact transport errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_solana now checks lifi_from == '1151111081099592' instead of contains('solana'), preventing substring false positives - Transport errors no longer include the request URL (which contains wallet addresses) — replaced with a generic connection error message --- ows/crates/ows-cli/src/commands/swap.rs | 4 ++-- ows/crates/ows-pay/src/swap.rs | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index 7b6984657..6f49e5280 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -32,7 +32,7 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { // Find EVM address for the from_chain // Determine chain prefix for address lookup let lifi_from = ows_chain_to_lifi(from_chain); - let is_solana = from_chain.to_lowercase().contains("solana") || lifi_from == "1151111081099592"; + let is_solana = lifi_from == "1151111081099592"; let from_address = if is_solana { wallet .accounts @@ -78,7 +78,7 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { } // For cross-VM swaps, supply the destination chain address too - let is_to_solana = to_chain.to_lowercase().contains("solana") || lifi_to == "1151111081099592"; + let is_to_solana = lifi_to == "1151111081099592"; let to_address = if is_to_solana && !is_solana { wallet .accounts diff --git a/ows/crates/ows-pay/src/swap.rs b/ows/crates/ows-pay/src/swap.rs index 224ddeb8a..e19487a9f 100644 --- a/ows/crates/ows-pay/src/swap.rs +++ b/ows/crates/ows-pay/src/swap.rs @@ -137,7 +137,12 @@ pub async fn get_quote(params: &SwapParams) -> Result { .header("Accept", "application/json") .send() .await - .map_err(|e| PayError::new(crate::error::PayErrorCode::HttpTransport, e.to_string()))?; + .map_err(|_| { + PayError::new( + crate::error::PayErrorCode::HttpTransport, + "failed to connect to LI.FI API".to_string(), + ) + })?; if !resp.status().is_success() { let status = resp.status().as_u16(); From a6a42e64a40634695ce60663596dd3de1218e088 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:58:54 +0300 Subject: [PATCH 7/8] fix: validate chain names before wallet account lookup - Move lifi_from/lifi_to validation above from_address resolution - Unsupported chain now returns dedicated error instead of misleading 'no EVM/Solana account found in wallet' message --- ows/crates/ows-cli/src/commands/swap.rs | 32 ++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index 6f49e5280..04cd3a880 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -32,6 +32,22 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { // Find EVM address for the from_chain // Determine chain prefix for address lookup let lifi_from = ows_chain_to_lifi(from_chain); + let lifi_to = ows_chain_to_lifi(to_chain); + + // Validate chain mappings before making API call + if lifi_from.is_empty() { + return Err(CliError::InvalidArgs(format!( + "unsupported from-chain: '{}'. Supported: ethereum, polygon, base, arbitrum, optimism, avalanche, bsc, solana", + from_chain + ))); + } + if lifi_to.is_empty() { + return Err(CliError::InvalidArgs(format!( + "unsupported to-chain: '{}'. Supported: ethereum, polygon, base, arbitrum, optimism, avalanche, bsc, solana", + to_chain + ))); + } + let is_solana = lifi_from == "1151111081099592"; let from_address = if is_solana { wallet @@ -61,22 +77,6 @@ pub fn quote(args: QuoteArgs) -> Result<(), CliError> { let raw_amount = amount_to_raw(amount, decimals_guess) .map_err(|e| CliError::InvalidArgs(format!("invalid amount: {e}")))?; - let lifi_to = ows_chain_to_lifi(to_chain); - - // Validate chain mappings before making API call - if lifi_from.is_empty() { - return Err(CliError::InvalidArgs(format!( - "unsupported from-chain: '{}'. Supported: ethereum, polygon, base, arbitrum, optimism, avalanche, bsc, solana", - from_chain - ))); - } - if lifi_to.is_empty() { - return Err(CliError::InvalidArgs(format!( - "unsupported to-chain: '{}'. Supported: ethereum, polygon, base, arbitrum, optimism, avalanche, bsc, solana", - to_chain - ))); - } - // For cross-VM swaps, supply the destination chain address too let is_to_solana = lifi_to == "1151111081099592"; let to_address = if is_to_solana && !is_solana { From 5be2190d54dc9aec6cc509f0991adb48b4aaac83 Mon Sep 17 00:00:00 2001 From: Sertug17 <104278804+Sertug17@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:01:55 +0300 Subject: [PATCH 8/8] fix: reject invalid solana CAIP-2 references in ows_chain_to_lifi - solana: now only accepts 'mainnet' and the genesis hash prefix - Any other solana: returns empty string, triggering the unsupported-chain validation error instead of being passed through as a bare numeric LI.FI chain ID --- ows/crates/ows-cli/src/commands/swap.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ows/crates/ows-cli/src/commands/swap.rs b/ows/crates/ows-cli/src/commands/swap.rs index 04cd3a880..e033a5634 100644 --- a/ows/crates/ows-cli/src/commands/swap.rs +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -202,10 +202,12 @@ fn ows_chain_to_lifi(chain: &str) -> String { let stripped = if let Some(rest) = lower.strip_prefix("eip155:") { rest.to_string() } else if let Some(rest) = lower.strip_prefix("solana:") { - if rest == "mainnet" { + // Only solana:mainnet is a valid Solana CAIP-2 reference + if rest == "mainnet" || rest == "5eykt4usfpcqjnphnnpqzakosqkp" { return "1151111081099592".to_string(); } - rest.to_string() + // Any other solana: is invalid — return empty to trigger validation error + return String::new(); } else { lower.clone() };