Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
137 changes: 137 additions & 0 deletions ows/crates/ows-cli/src/commands/swap.rs
Original file line number Diff line number Diff line change
@@ -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
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded decimals wrong for most token types

High Severity

The decimal heuristic only recognizes USDC and USDT as 6-decimal tokens and defaults everything else to 18. Common tokens like WBTC (8 decimals), GUSD (2 decimals), or any other non-18-decimal token will have amount_to_raw produce a wildly incorrect raw amount. For example, swapping 0.1 WBTC computes a fromAmount of 10^17 instead of 10^7 — off by a factor of 10 billion — resulting in a completely wrong quote from the LI.FI API.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5b1e6aa. Configure here.

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<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())
}
}
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