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..e033a5634 --- /dev/null +++ b/ows/crates/ows-cli/src/commands/swap.rs @@ -0,0 +1,229 @@ +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 + // 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 + .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. + // 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_guess) + .map_err(|e| CliError::InvalidArgs(format!("invalid amount: {e}")))?; + + // 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 { + 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(), + from_token: from_token.to_string(), + to_token: to_token.to_string(), + from_amount: raw_amount, + from_address, + to_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 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:") { + // Only solana:mainnet is a valid Solana CAIP-2 reference + if rest == "mainnet" || rest == "5eykt4usfpcqjnphnnpqzakosqkp" { + return "1151111081099592".to_string(); + } + // Any other solana: is invalid — return empty to trigger validation error + return String::new(); + } 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(), + } +} diff --git a/ows/crates/ows-cli/src/main.rs b/ows/crates/ows-cli/src/main.rs index 2de08354f..95868d08a 100644 --- a/ows/crates/ows-cli/src/main.rs +++ b/ows/crates/ows-cli/src/main.rs @@ -38,6 +38,11 @@ enum Commands { #[command(subcommand)] subcommand: FundCommands, }, + /// Swap tokens across chains via LI.FI + Swap { + #[command(subcommand)] + subcommand: SwapCommands, + }, /// Pay for x402-enabled API calls Pay { #[command(subcommand)] @@ -245,6 +250,37 @@ enum FundCommands { }, } +#[derive(Subcommand)] +enum SwapCommands { + /// Get a swap quote and show route (no signing) + Quote { + /// Wallet name or ID + #[arg(long, env = "OWS_WALLET")] + wallet: String, + /// Source token (e.g. ETH, USDC) + #[arg(long)] + from: String, + /// Destination token (e.g. USDC, ETH) + #[arg(long)] + to: String, + /// Amount in human-readable format (e.g. 0.1) + #[arg(long)] + amount: String, + /// Source chain (e.g. ethereum, base, arbitrum) + #[arg(long, default_value = "ethereum")] + from_chain: String, + /// Destination chain (e.g. ethereum, base, arbitrum) + #[arg(long)] + to_chain: Option, + /// 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..e19487a9f --- /dev/null +++ b/ows/crates/ows-pay/src/swap.rs @@ -0,0 +1,254 @@ +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 to_address: Option, + 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 mut 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, + ); + if let Some(ref to_addr) = params.to_address { + url.push_str(&format!("&toAddress={to_addr}")); + } + + let resp = client + .get(&url) + .header("Accept", "application/json") + .send() + .await + .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(); + // 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}: {truncated}"), + )); + } + + 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"); + } +}