diff --git a/ows/crates/ows-cli/src/commands/mod.rs b/ows/crates/ows-cli/src/commands/mod.rs index a6fee800..6ada2778 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 00000000..03f8a43c --- /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 880db59b..7204f77a 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 00000000..1147adcb --- /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"); + } +}