Skip to content
1 change: 1 addition & 0 deletions ows/crates/ows-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
177 changes: 177 additions & 0 deletions ows/crates/ows-cli/src/commands/swap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
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 is_solana = from_chain.to_lowercase().contains("solana") || lifi_from == "1151111081099592";
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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()))?
};
Comment thread
cursor[bot] marked this conversation as resolved.

// 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,
};
Comment thread
cursor[bot] marked this conversation as resolved.
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: 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,
slippage,
order: order.to_string(),
};
Comment thread
cursor[bot] marked this conversation as resolved.

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<ows_core::ChainType> {
vec![]
}
fn account(&self, _: &str) -> Result<ows_pay::Account, ows_pay::PayError> {
Err(ows_pay::PayError::new(
ows_pay::PayErrorCode::WalletNotFound,
"dry-run",
))
}
fn sign_payload(
&self,
_: &str,
_: &str,
_: &str,
) -> Result<String, ows_pay::PayError> {
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<String, String> {
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<width$}", frac_trimmed, width = decimals as usize);
let combined = format!("{}{}", int_part.trim_start_matches('0'), frac_padded);
let trimmed = combined.trim_start_matches('0');
if trimmed.is_empty() {
Ok("0".into())
} else {
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",
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
}
57 changes: 57 additions & 0 deletions ows/crates/ows-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<String>,
/// 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
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions ows/crates/ows-pay/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
Loading