From 88884509ae675a8e27e4b87d63ada4964a0eecfa Mon Sep 17 00:00:00 2001 From: yongjun Date: Thu, 9 Jul 2026 18:36:17 +0800 Subject: [PATCH 1/4] feat: add swap prepare payloads for external signers --- README.md | 26 ++ src/cli/swap.rs | 67 +++++ src/commands/swap.rs | 662 ++++++++++++++++++++++++++++++++++++++++++- src/models/swap.rs | 79 ++++++ src/output/table.rs | 34 +++ 5 files changed, 859 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index f1ecdf7..a00c8c0 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,32 @@ chainpilot swap execute --quote-id --private-key 0x... --skip-estimat | `--gas-buffer-pct` | Add N% buffer on top of `eth_estimateGas` result (e.g. `20` = +20%) | | `--skip-estimate` | Skip `eth_estimateGas` and use the quote's gas estimate directly | +**Prepare an unsigned transaction for an external signer:** +```bash +# Swap execution payload from a saved quote +chainpilot --json swap prepare execute --quote-id --wallet 0xYourAddress + +# ERC-20 approval payload +chainpilot --json swap prepare approve --quote-id --wallet 0xYourAddress +chainpilot --json swap prepare approve \ + --token 0xTokenAddr \ + --spender 0xSpenderAddr \ + --amount 100 \ + --wallet 0xYourAddress + +# ERC-20 revoke payload +chainpilot --json swap prepare revoke \ + --token 0xTokenAddr \ + --spender 0xSpenderAddr \ + --wallet 0xYourAddress +``` + +`swap prepare` does not sign or broadcast. In JSON mode it returns a stable +unsigned transaction payload with `source`, `operation`, `chain_id`, `caip2`, +`from`, `transaction { to, value, data, chain_id }`, and risk metadata. This is +intended for external signing systems such as Privy; user confirmation, +authorization checks, signing, and broadcasting must happen outside ChainPilot. + **Check transaction status:** ```bash chainpilot swap status --tx-hash 0x... diff --git a/src/cli/swap.rs b/src/cli/swap.rs index 830b597..fb8d535 100644 --- a/src/cli/swap.rs +++ b/src/cli/swap.rs @@ -13,6 +13,8 @@ pub enum SwapAction { Quote(QuoteArgs), /// Simulate a swap from a saved quote Simulate(SimulateArgs), + /// Prepare an unsigned transaction payload for an external signer + Prepare(PrepareCmd), /// Execute a swap (requires wallet) Execute(ExecuteArgs), /// Check swap transaction status @@ -55,6 +57,71 @@ pub struct SimulateArgs { pub wallet: Option, } +#[derive(Args)] +pub struct PrepareCmd { + #[command(subcommand)] + pub action: PrepareAction, +} + +#[derive(Subcommand)] +pub enum PrepareAction { + /// Prepare a swap execution transaction from a saved quote + Execute(PrepareExecuteArgs), + /// Prepare an ERC-20 approval transaction + Approve(PrepareApproveArgs), + /// Prepare an ERC-20 revoke transaction + Revoke(PrepareRevokeArgs), +} + +#[derive(Args)] +pub struct PrepareExecuteArgs { + /// Quote ID returned by `chainpilot swap quote` + #[arg(long)] + pub quote_id: String, + + /// Wallet address that will sign and send the transaction via an external signer + #[arg(long, env = "WALLET_ADDRESS")] + pub wallet: Option, +} + +#[derive(Args)] +pub struct PrepareApproveArgs { + /// Quote ID to derive token and spender from (uses from-token and DODOApprove spender) + #[arg(long)] + pub quote_id: Option, + + /// Token symbol or address to approve (overrides quote's from-token) + #[arg(long)] + pub token: Option, + + /// Spender contract address (overrides quote's spender) + #[arg(long)] + pub spender: Option, + + /// Amount to approve in human-readable units (omit for unlimited / U256::MAX) + #[arg(long)] + pub amount: Option, + + /// Wallet address that will sign and send the transaction via an external signer + #[arg(long, env = "WALLET_ADDRESS")] + pub wallet: Option, +} + +#[derive(Args)] +pub struct PrepareRevokeArgs { + /// Token contract address + #[arg(long)] + pub token: String, + + /// Spender contract address + #[arg(long)] + pub spender: String, + + /// Wallet address that will sign and send the transaction via an external signer + #[arg(long, env = "WALLET_ADDRESS")] + pub wallet: Option, +} + #[derive(Args)] pub struct ExecuteArgs { /// Quote ID returned by `chainpilot swap quote` diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 112b860..08252de 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -8,14 +8,18 @@ use alloy_signer_local::PrivateKeySigner; use crate::api::ApiClients; use crate::chain::{AddressRef, OnChainClient}; use crate::cli::swap::{ - ApproveArgs, ExecuteArgs, HistoryArgs, QuoteArgs, RevokeArgs, SimulateArgs, StatusArgs, + ApproveArgs, ExecuteArgs, HistoryArgs, PrepareAction, PrepareApproveArgs, PrepareCmd, + PrepareExecuteArgs, PrepareRevokeArgs, QuoteArgs, RevokeArgs, SimulateArgs, StatusArgs, SwapAction, SwapCmd, }; use crate::commands::{parse_display_amount, resolve_token, to_raw_amount}; use crate::config::AppConfig; use crate::error::{ChainError, Result}; use crate::models::quote::{Quote, QuoteRequest, RouteHop, TokenRef}; -use crate::models::swap::{ExecutionResult, ExecutionStatus, SimulationResult}; +use crate::models::swap::{ + ExecutionResult, ExecutionStatus, PreparedChainpilotTransaction, PreparedOperation, + PreparedQuote, PreparedRisk, PreparedTokenAmount, PreparedTransaction, SimulationResult, +}; use crate::output::{OutputContext, OutputMode}; use crate::store::QuoteStore; use alloy::primitives::Address; @@ -285,9 +289,9 @@ fn swap_non_evm_tx_hash_error(raw: &str) -> Option { // base58-shaped strings ≥ 32 chars are Solana signatures (~88 typical). let looks_base58 = !trimmed.is_empty() - && trimmed.bytes().all(|b| { - b.is_ascii_alphanumeric() && b != b'0' && b != b'O' && b != b'I' && b != b'l' - }); + && trimmed + .bytes() + .all(|b| b.is_ascii_alphanumeric() && b != b'0' && b != b'O' && b != b'I' && b != b'l'); if looks_base58 && trimmed.len() >= 32 { return Some(ChainError::Config( "swap status is not supported for Solana — DODO swap history only tracks EVM transactions".to_string(), @@ -310,6 +314,7 @@ pub async fn handle( match cmd.action { SwapAction::Quote(args) => quote(args, config, store, api, output_mode).await, SwapAction::Simulate(args) => simulate(args, config, store, output_mode).await, + SwapAction::Prepare(args) => prepare(args, config, store, api, output_mode).await, SwapAction::Execute(args) => execute(args, config, store, output_mode).await, SwapAction::Status(args) => status(args, config, output_mode).await, SwapAction::History(args) => history(args, config, store, output_mode).await, @@ -412,7 +417,8 @@ async fn build_svm_quote(args: &QuoteArgs, api: &ApiClients) -> Result { // Resolve both mints via Jupiter for decimals + symbol. The two lookups are // independent, so run them concurrently. A miss means the mint isn't in // Jupiter's index, so it isn't routable here. - let (from_res, to_res) = tokio::join!(api.jupiter.token(&args.from), api.jupiter.token(&args.to)); + let (from_res, to_res) = + tokio::join!(api.jupiter.token(&args.from), api.jupiter.token(&args.to)); let from_meta = from_res?.ok_or_else(|| { ChainError::Config(format!("Solana mint not found on Jupiter: {}", args.from)) })?; @@ -427,7 +433,12 @@ async fn build_svm_quote(args: &QuoteArgs, api: &ApiClients) -> Result { let jq = api .jupiter - .quote(&from_meta.address, &to_meta.address, &amount_raw, slippage_bps) + .quote( + &from_meta.address, + &to_meta.address, + &amount_raw, + slippage_bps, + ) .await?; let to_amount_display = raw_to_display(&jq.out_amount, to_meta.decimals); @@ -457,8 +468,7 @@ async fn build_svm_quote(args: &QuoteArgs, api: &ApiClients) -> Result { } let now = Utc::now(); - let expires_at = - now + std::time::Duration::from_secs(crate::config::DEFAULT_QUOTE_TTL_SECS); + let expires_at = now + std::time::Duration::from_secs(crate::config::DEFAULT_QUOTE_TTL_SECS); Ok(Quote { quote_id: uuid::Uuid::new_v4(), @@ -816,6 +826,260 @@ async fn simulate( )) } +async fn prepare( + args: PrepareCmd, + config: &AppConfig, + store: &QuoteStore, + api: &ApiClients, + output_mode: OutputMode, +) -> Result { + match args.action { + PrepareAction::Execute(args) => prepare_execute(args, config, store, output_mode).await, + PrepareAction::Approve(args) => { + prepare_approve(args, config, store, api, output_mode).await + } + PrepareAction::Revoke(args) => prepare_revoke(args, config, output_mode).await, + } +} + +async fn prepare_execute( + args: PrepareExecuteArgs, + config: &AppConfig, + store: &QuoteStore, + output_mode: OutputMode, +) -> Result { + let quote_data = match store.load_quote(&args.quote_id)? { + Some(q) => q, + None => { + return Ok( + crate::output::print_output::( + Err(ChainError::QuoteNotFound(args.quote_id)), + "swap.prepare.execute", + output_mode, + OutputContext::new(config.chain_id, true), + ), + ); + } + }; + let chain_id = quote_data.chain_id; + let from_address = match dry_run_from_address( + crate::chain::resolve_signer(config) + .ok() + .map(|signer| signer.address().to_string()), + args.wallet, + config.wallet_address.clone(), + ) { + Some(addr) => addr, + None => { + return Ok( + crate::output::print_output::( + Err(ChainError::NoWallet), + "swap.prepare.execute", + output_mode, + OutputContext::new(chain_id, true), + ), + ); + } + }; + + let result = build_prepared_execute_payload("e_data, &from_address); + Ok( + crate::output::print_output::( + result, + "swap.prepare.execute", + output_mode, + OutputContext::new(chain_id, true), + ), + ) +} + +async fn prepare_approve( + args: PrepareApproveArgs, + config: &AppConfig, + store: &QuoteStore, + api: &ApiClients, + output_mode: OutputMode, +) -> Result { + use crate::chain::{get_token_info, OnChainClient}; + use alloy::primitives::{Address, U256}; + + let quote = match &args.quote_id { + Some(id) => store.load_quote(id)?, + None => None, + }; + let chain_id = quote + .as_ref() + .map(|q| q.chain_id) + .unwrap_or(config.chain_id); + let from_address = match dry_run_from_address( + crate::chain::resolve_signer(config) + .ok() + .map(|signer| signer.address().to_string()), + args.wallet.clone(), + config.wallet_address.clone(), + ) { + Some(addr) => addr, + None => { + return Ok( + crate::output::print_output::( + Err(ChainError::NoWallet), + "swap.prepare.approve", + output_mode, + OutputContext::new(chain_id, true), + ), + ); + } + }; + + let (token_str, spender_str) = resolve_approve_targets( + args.token.as_deref(), + quote.as_ref(), + args.spender.as_deref(), + chain_id, + )?; + let token_addr: Address = token_str + .parse() + .map_err(|_| ChainError::InvalidAddress(token_str.clone()))?; + let spender_addr: Address = spender_str + .parse() + .map_err(|_| ChainError::InvalidAddress(spender_str.clone()))?; + + let token_ref = if let Some(q) = "e { + if q.from_token.address.to_lowercase() == token_str.to_lowercase() { + q.from_token.clone() + } else { + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + let info = get_token_info(&chain_client, token_addr).await?; + TokenRef { + symbol: info.symbol, + address: token_addr.to_string(), + decimals: info.decimals, + chain_id, + } + } + } else if args.amount.is_some() { + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + resolve_token(&token_str, chain_id, &chain_client, api, config, store).await? + } else { + TokenRef { + symbol: "ERC20".to_string(), + address: token_addr.to_string(), + decimals: 0, + chain_id, + } + }; + + let (amount_u256, raw_amount_str) = match args.amount { + None => (U256::MAX, "unlimited".to_string()), + Some(human) => { + let raw = to_raw_amount(&human, token_ref.decimals)?; + let raw_u256 = U256::from_str_radix(&raw, 10) + .map_err(|_| ChainError::InvalidAmount(human.clone()))?; + (raw_u256, raw) + } + }; + let amount_usd = quote.as_ref().and_then(quote_token_in_amount_usd); + + let result = build_prepared_approval_payload( + PreparedOperation::Approve, + chain_id, + &from_address, + token_ref, + spender_addr, + amount_u256, + raw_amount_str, + amount_usd, + ); + Ok( + crate::output::print_output::( + result, + "swap.prepare.approve", + output_mode, + OutputContext::new(chain_id, true), + ), + ) +} + +async fn prepare_revoke( + args: PrepareRevokeArgs, + config: &AppConfig, + output_mode: OutputMode, +) -> Result { + use alloy::primitives::{Address, U256}; + + let chain_id = config.chain_id; + let from_address = match dry_run_from_address( + crate::chain::resolve_signer(config) + .ok() + .map(|signer| signer.address().to_string()), + args.wallet, + config.wallet_address.clone(), + ) { + Some(addr) => addr, + None => { + return Ok( + crate::output::print_output::( + Err(ChainError::NoWallet), + "swap.prepare.revoke", + output_mode, + OutputContext::new(chain_id, true), + ), + ); + } + }; + let token_addr: Address = match args.token.parse() { + Ok(a) => a, + Err(_) => { + return Ok( + crate::output::print_output::( + Err(ChainError::InvalidAddress(args.token)), + "swap.prepare.revoke", + output_mode, + OutputContext::new(chain_id, true), + ), + ); + } + }; + let spender_addr: Address = match args.spender.parse() { + Ok(a) => a, + Err(_) => { + return Ok( + crate::output::print_output::( + Err(ChainError::InvalidAddress(args.spender)), + "swap.prepare.revoke", + output_mode, + OutputContext::new(chain_id, true), + ), + ); + } + }; + let token_ref = TokenRef { + symbol: "ERC20".to_string(), + address: token_addr.to_string(), + decimals: 0, + chain_id, + }; + + let result = build_prepared_approval_payload( + PreparedOperation::Revoke, + chain_id, + &from_address, + token_ref, + spender_addr, + U256::ZERO, + "0".to_string(), + None, + ); + Ok( + crate::output::print_output::( + result, + "swap.prepare.revoke", + output_mode, + OutputContext::new(chain_id, true), + ), + ) +} + fn simulation_base_warnings(quote: &Quote) -> Vec { let mut warnings = Vec::new(); if quote.estimated_gas.is_none() && quote.gas_limit.is_none() { @@ -917,6 +1181,205 @@ fn revoke_calldata(spender_addr: Address) -> String { format!("0x{}", hex::encode(&calldata)) } +fn decimal_or_hex_to_hex(value: &str) -> Result { + if value.is_empty() { + return Ok("0x0".to_string()); + } + if value.starts_with("0x") { + let hex = value.trim_start_matches("0x"); + if hex.is_empty() { + return Ok("0x0".to_string()); + } + let parsed = alloy::primitives::U256::from_str_radix(hex, 16) + .map_err(|_| ChainError::InvalidAmount(value.to_string()))?; + return Ok(format!("0x{:x}", parsed)); + } + let parsed = alloy::primitives::U256::from_str_radix(value, 10) + .map_err(|_| ChainError::InvalidAmount(value.to_string()))?; + Ok(format!("0x{:x}", parsed)) +} + +fn optional_u64_to_hex(value: Option) -> Option { + value.map(|v| format!("0x{v:x}")) +} + +fn slippage_bps(slippage_pct: f64) -> Option { + if slippage_pct.is_finite() && slippage_pct >= 0.0 { + Some((slippage_pct * 100.0).round() as u64) + } else { + None + } +} + +fn token_address_for_payload(token: &TokenRef) -> Option { + if token + .address + .eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR) + { + None + } else { + Some(token.address.clone()) + } +} + +fn raw_amount_to_display(raw: &str, decimals: u8) -> Option { + let trimmed = raw.trim(); + if trimmed.is_empty() + || trimmed.starts_with('-') + || !trimmed.chars().all(|c| c.is_ascii_digit()) + { + return None; + } + if decimals == 0 { + return Some(trimmed.to_string()); + } + + let decimals_len = decimals as usize; + let padded = if trimmed.len() <= decimals_len { + format!("{:0>width$}", trimmed, width = decimals_len + 1) + } else { + trimmed.to_string() + }; + let split_at = padded.len() - decimals_len; + let int_part = &padded[..split_at]; + let frac_part = padded[split_at..].trim_end_matches('0'); + if frac_part.is_empty() { + Some(int_part.to_string()) + } else { + Some(format!("{int_part}.{frac_part}")) + } +} + +fn quote_token_in_amount_usd(quote: &Quote) -> Option { + let price_per_from = quote + .raw_dodo_response + .get("resPricePerFromToken") + .and_then(|value| value.as_f64())?; + if price_per_from <= 0.0 || !price_per_from.is_finite() { + return None; + } + Some((quote.from_amount_display * price_per_from).to_string()) +} + +fn build_prepared_execute_payload( + quote: &Quote, + from_address: &str, +) -> Result { + let _to_addr: Address = quote + .router_to + .parse() + .map_err(|_| ChainError::InvalidAddress(quote.router_to.clone()))?; + let _from_addr: Address = from_address + .parse() + .map_err(|_| ChainError::InvalidAddress(from_address.to_string()))?; + let amount_in_raw = to_raw_amount("e.from_amount, quote.from_token.decimals)?; + let min_amount_display = raw_amount_to_display("e.to_amount_min, quote.to_token.decimals); + + Ok(PreparedChainpilotTransaction { + source: "chainpilot".to_string(), + operation: PreparedOperation::SwapExecute, + chain_id: quote.chain_id, + caip2: format!("eip155:{}", quote.chain_id), + from_address: from_address.to_string(), + transaction: PreparedTransaction { + to: quote.router_to.clone(), + value: decimal_or_hex_to_hex("e.value)?, + data: quote.calldata.clone(), + chain_id: quote.chain_id, + gas: optional_u64_to_hex(quote.estimated_gas.or(quote.gas_limit)), + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }, + quote: Some(PreparedQuote { + quote_id: quote.quote_id.to_string(), + expires_at: Some(quote.expires_at), + slippage_bps: slippage_bps(quote.slippage), + }), + risk: PreparedRisk { + token_in: Some(PreparedTokenAmount { + chain_id: quote.chain_id, + address: token_address_for_payload("e.from_token), + symbol: quote.from_token.symbol.clone(), + decimals: quote.from_token.decimals, + amount_raw: Some(amount_in_raw), + amount_display: Some(quote.from_amount_display.to_string()), + amount_usd: quote_token_in_amount_usd(quote), + min_amount_raw: None, + min_amount_display: None, + }), + token_out: Some(PreparedTokenAmount { + chain_id: quote.chain_id, + address: token_address_for_payload("e.to_token), + symbol: quote.to_token.symbol.clone(), + decimals: quote.to_token.decimals, + amount_raw: None, + amount_display: Some(quote.to_amount_display.to_string()), + amount_usd: None, + min_amount_raw: Some(quote.to_amount_min.clone()), + min_amount_display, + }), + spender: None, + router: Some(quote.router_to.clone()), + estimated_gas_usd: quote.estimated_gas_usd.map(|v| v.to_string()), + }, + }) +} + +fn build_prepared_approval_payload( + operation: PreparedOperation, + chain_id: u64, + from_address: &str, + token: TokenRef, + spender_addr: Address, + amount_u256: alloy::primitives::U256, + raw_amount: String, + amount_usd: Option, +) -> Result { + let _from_addr: Address = from_address + .parse() + .map_err(|_| ChainError::InvalidAddress(from_address.to_string()))?; + let token_addr = token.address.clone(); + let calldata = match operation { + PreparedOperation::Approve => approve_calldata(spender_addr, amount_u256), + PreparedOperation::Revoke => revoke_calldata(spender_addr), + PreparedOperation::SwapExecute => unreachable!("approval payload does not execute swaps"), + }; + Ok(PreparedChainpilotTransaction { + source: "chainpilot".to_string(), + operation, + chain_id, + caip2: format!("eip155:{chain_id}"), + from_address: from_address.to_string(), + transaction: PreparedTransaction { + to: token_addr.clone(), + value: "0x0".to_string(), + data: calldata, + chain_id, + gas: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }, + quote: None, + risk: PreparedRisk { + token_in: Some(PreparedTokenAmount { + chain_id, + address: Some(token_addr), + symbol: token.symbol, + decimals: token.decimals, + amount_raw: Some(raw_amount), + amount_display: None, + amount_usd, + min_amount_raw: None, + min_amount_display: None, + }), + token_out: None, + spender: Some(spender_addr.to_string()), + router: None, + estimated_gas_usd: None, + }, + }) +} + async fn send_approval_with_deps( deps: &D, chain_id: u64, @@ -2093,6 +2556,187 @@ mod tests { assert!(approve.len() > revoke.len() - 1); } + #[test] + fn prepare_execute_builds_edge_unsigned_transaction_contract() { + let mut quote = sample_quote(8453); + quote.value = "1000000000000000".to_string(); + quote.raw_dodo_response = serde_json::json!({"resPricePerFromToken": 3000.0}); + let from = "0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A"; + + let prepared = build_prepared_execute_payload("e, from).unwrap(); + + assert_eq!(prepared.source, "chainpilot"); + assert_eq!(prepared.operation, PreparedOperation::SwapExecute); + assert_eq!(prepared.chain_id, 8453); + assert_eq!(prepared.caip2, "eip155:8453"); + assert_eq!(prepared.from_address, from); + assert_eq!(prepared.transaction.to, quote.router_to); + assert_eq!(prepared.transaction.value, "0x38d7ea4c68000"); + assert_eq!(prepared.transaction.data, quote.calldata); + assert_eq!(prepared.transaction.chain_id, 8453); + assert_eq!(prepared.transaction.gas.as_deref(), Some("0x2bf20")); + assert_eq!( + prepared.quote.as_ref().unwrap().quote_id, + quote.quote_id.to_string() + ); + assert_eq!(prepared.quote.as_ref().unwrap().slippage_bps, Some(50)); + assert_eq!( + prepared.risk.router.as_deref(), + Some(quote.router_to.as_str()) + ); + assert_eq!( + prepared + .risk + .token_in + .as_ref() + .unwrap() + .amount_raw + .as_deref(), + Some("1000000000000000000") + ); + assert_eq!( + prepared + .risk + .token_in + .as_ref() + .unwrap() + .amount_usd + .as_deref(), + Some("3000") + ); + assert_eq!( + prepared + .risk + .token_out + .as_ref() + .unwrap() + .min_amount_raw + .as_deref(), + Some("2970") + ); + assert_eq!( + prepared + .risk + .token_out + .as_ref() + .unwrap() + .min_amount_display + .as_deref(), + Some("0.00297") + ); + } + + #[test] + fn prepare_approve_and_revoke_build_edge_unsigned_transaction_contracts() { + let token: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + let spender: Address = "0x3333333333333333333333333333333333333333" + .parse() + .unwrap(); + let from = "0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A"; + let amount = alloy::primitives::U256::from(42u64); + let token_ref = TokenRef { + symbol: "TEST".to_string(), + address: token.to_string(), + decimals: 6, + chain_id: 8453, + }; + + let approve = build_prepared_approval_payload( + PreparedOperation::Approve, + 8453, + from, + token_ref.clone(), + spender, + amount, + "42".to_string(), + Some("123.45".to_string()), + ) + .unwrap(); + let revoke = build_prepared_approval_payload( + PreparedOperation::Revoke, + 8453, + from, + token_ref, + spender, + alloy::primitives::U256::ZERO, + "0".to_string(), + None, + ) + .unwrap(); + + assert_eq!(approve.operation, PreparedOperation::Approve); + assert_eq!(approve.transaction.to, token.to_string()); + assert_eq!(approve.transaction.value, "0x0"); + assert_eq!(approve.transaction.chain_id, 8453); + assert_eq!( + approve.risk.spender.as_deref(), + Some(spender.to_string().as_str()) + ); + assert_eq!( + approve + .risk + .token_in + .as_ref() + .unwrap() + .amount_raw + .as_deref(), + Some("42") + ); + assert_eq!( + approve + .risk + .token_in + .as_ref() + .unwrap() + .amount_usd + .as_deref(), + Some("123.45") + ); + assert!(approve.transaction.data.starts_with("0x095ea7b3")); + + assert_eq!(revoke.operation, PreparedOperation::Revoke); + assert_eq!(revoke.transaction.to, token.to_string()); + assert_eq!(revoke.transaction.value, "0x0"); + assert_eq!( + revoke.risk.spender.as_deref(), + Some(spender.to_string().as_str()) + ); + assert_eq!( + revoke.risk.token_in.as_ref().unwrap().amount_raw.as_deref(), + Some("0") + ); + assert!(revoke.transaction.data.ends_with(&"0".repeat(64))); + } + + #[test] + fn prepare_approval_rejects_invalid_from_address() { + let token_ref = TokenRef { + symbol: "TEST".to_string(), + address: "0x2222222222222222222222222222222222222222".to_string(), + decimals: 6, + chain_id: 8453, + }; + let spender: Address = "0x3333333333333333333333333333333333333333" + .parse() + .unwrap(); + + let err = build_prepared_approval_payload( + PreparedOperation::Approve, + 8453, + "not-an-address", + token_ref, + spender, + alloy::primitives::U256::from(42u64), + "42".to_string(), + None, + ) + .unwrap_err(); + + assert!(matches!(err, ChainError::InvalidAddress(addr) if addr == "not-an-address")); + } + #[tokio::test] async fn send_approval_with_deps_returns_tx_hash_and_sender() { let private_key = "0x59c6995e998f97a5a0044966f0945382dbf7f50a3f2f72f5f7a0b7d7d4f5e5f1"; diff --git a/src/models/swap.rs b/src/models/swap.rs index e4349e9..d953dc1 100644 --- a/src/models/swap.rs +++ b/src/models/swap.rs @@ -79,6 +79,85 @@ pub struct ApprovalResult { pub from_address: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PreparedOperation { + SwapExecute, + Approve, + Revoke, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreparedTransaction { + pub to: String, + pub value: String, + pub data: String, + pub chain_id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub gas: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_fee_per_gas: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_priority_fee_per_gas: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreparedQuote { + #[serde(skip_serializing_if = "String::is_empty")] + pub quote_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub slippage_bps: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreparedTokenAmount { + pub chain_id: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + pub symbol: String, + pub decimals: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount_raw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount_display: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub amount_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_amount_raw: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_amount_display: Option, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreparedRisk { + #[serde(skip_serializing_if = "Option::is_none")] + pub token_in: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_out: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub spender: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub router: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub estimated_gas_usd: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PreparedChainpilotTransaction { + pub source: String, + pub operation: PreparedOperation, + pub chain_id: u64, + pub caip2: String, + #[serde(rename = "from")] + pub from_address: String, + pub transaction: PreparedTransaction, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote: Option, + pub risk: PreparedRisk, +} + impl SwapHistoryRecord { pub fn from_execution( quote: &crate::models::quote::Quote, diff --git a/src/output/table.rs b/src/output/table.rs index e2bc1d9..9c821b3 100644 --- a/src/output/table.rs +++ b/src/output/table.rs @@ -162,6 +162,40 @@ impl TableRenderable for crate::models::swap::ApprovalResult { } } +impl TableRenderable for crate::models::swap::PreparedChainpilotTransaction { + fn render_table(&self) { + let mut table = Table::new(); + table.set_header(vec!["Field", "Value"]); + table.add_row(vec!["Source", &self.source]); + table.add_row(vec!["Operation", &format!("{:?}", self.operation)]); + table.add_row(vec!["Chain ID", &self.chain_id.to_string()]); + table.add_row(vec!["CAIP-2", &self.caip2]); + table.add_row(vec!["From", &self.from_address]); + table.add_row(vec!["To", &self.transaction.to]); + table.add_row(vec!["Value", &self.transaction.value]); + if let Some(ref gas) = self.transaction.gas { + table.add_row(vec!["Gas", gas]); + } + if let Some(ref quote) = self.quote { + table.add_row(vec!["Quote ID", "e.quote_id]); + if let Some(expires_at) = quote.expires_at { + table.add_row(vec![ + "Quote Expires", + &expires_at.format("%Y-%m-%d %H:%M:%S UTC").to_string(), + ]); + } + } + if let Some(ref router) = self.risk.router { + table.add_row(vec!["Router", router]); + } + if let Some(ref spender) = self.risk.spender { + table.add_row(vec!["Spender", spender]); + } + table.add_row(vec!["Calldata", &self.transaction.data]); + println!("{}", table); + } +} + impl TableRenderable for crate::models::token::TokenOwnershipActionResult { fn render_table(&self) { let mut table = Table::new(); From bf3f1db91becab88a8a7728a07a6e98ba70f7823 Mon Sep 17 00:00:00 2001 From: yongjun Date: Fri, 10 Jul 2026 10:35:24 +0800 Subject: [PATCH 2/4] fix: support token symbols in swap prepare approve --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands/swap.rs | 134 +++++++++++++++++++++++++++++++++---------- 3 files changed, 107 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d494c8..c6437b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "1.1.0" +version = "1.2.0-rc.1" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index f39b7f4..62d7179 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "1.1.0" +version = "1.2.0-rc.1" description = "A CLI tool for on-chain DeFi operations on EVM-compatible networks" homepage = "https://github.com/DODOEX/ChainPilot" documentation = "https://github.com/DODOEX/ChainPilot#readme" diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 08252de..1e6fa54 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -900,7 +900,6 @@ async fn prepare_approve( api: &ApiClients, output_mode: OutputMode, ) -> Result { - use crate::chain::{get_token_info, OnChainClient}; use alloy::primitives::{Address, U256}; let quote = match &args.quote_id { @@ -937,37 +936,19 @@ async fn prepare_approve( args.spender.as_deref(), chain_id, )?; - let token_addr: Address = token_str - .parse() - .map_err(|_| ChainError::InvalidAddress(token_str.clone()))?; let spender_addr: Address = spender_str .parse() .map_err(|_| ChainError::InvalidAddress(spender_str.clone()))?; - - let token_ref = if let Some(q) = "e { - if q.from_token.address.to_lowercase() == token_str.to_lowercase() { - q.from_token.clone() - } else { - let chain_client = OnChainClient::for_chain(config, chain_id).await?; - let info = get_token_info(&chain_client, token_addr).await?; - TokenRef { - symbol: info.symbol, - address: token_addr.to_string(), - decimals: info.decimals, - chain_id, - } - } - } else if args.amount.is_some() { - let chain_client = OnChainClient::for_chain(config, chain_id).await?; - resolve_token(&token_str, chain_id, &chain_client, api, config, store).await? - } else { - TokenRef { - symbol: "ERC20".to_string(), - address: token_addr.to_string(), - decimals: 0, - chain_id, - } - }; + let token_ref = resolve_prepare_approve_token_ref( + &token_str, + quote.as_ref(), + chain_id, + args.amount.as_deref(), + config, + store, + api, + ) + .await?; let (amount_u256, raw_amount_str) = match args.amount { None => (U256::MAX, "unlimited".to_string()), @@ -1161,6 +1142,58 @@ fn resolve_approve_targets( Ok((token, spender)) } +fn prepare_approve_token_ref_from_quote_or_input( + quote: Option<&Quote>, + token_input: &str, +) -> Option { + let quote = quote?; + let from_token = "e.from_token; + if from_token.address.eq_ignore_ascii_case(token_input) + || from_token.symbol.eq_ignore_ascii_case(token_input) + { + Some(from_token.clone()) + } else { + None + } +} + +async fn resolve_prepare_approve_token_ref( + token_input: &str, + quote: Option<&Quote>, + chain_id: u64, + amount: Option<&str>, + config: &AppConfig, + store: &QuoteStore, + api: &ApiClients, +) -> Result { + if let Some(token_ref) = prepare_approve_token_ref_from_quote_or_input(quote, token_input) { + return Ok(token_ref); + } + + if let Ok(token_addr) = token_input.parse::
() { + if quote.is_none() && amount.is_none() { + return Ok(TokenRef { + symbol: "ERC20".to_string(), + address: token_addr.to_string(), + decimals: 0, + chain_id, + }); + } + + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + let info = crate::chain::get_token_info(&chain_client, token_addr).await?; + return Ok(TokenRef { + symbol: info.symbol, + address: token_addr.to_string(), + decimals: info.decimals, + chain_id, + }); + } + + let chain_client = OnChainClient::for_chain(config, chain_id).await?; + resolve_token(token_input, chain_id, &chain_client, api, config, store).await +} + fn approve_calldata(spender_addr: Address, amount_u256: alloy::primitives::U256) -> String { let selector = [0x09u8, 0x5e, 0xa7, 0xb3]; let mut calldata = Vec::with_capacity(68); @@ -2710,6 +2743,49 @@ mod tests { assert!(revoke.transaction.data.ends_with(&"0".repeat(64))); } + #[test] + fn prepare_approve_symbol_token_matches_quote_from_token() { + let quote = sample_quote_for_approve(8453); + let token_ref = + prepare_approve_token_ref_from_quote_or_input(Some("e), "USDC").unwrap(); + + assert_eq!(token_ref.symbol, "USDC"); + assert_eq!(token_ref.address, quote.from_token.address); + assert_eq!(token_ref.decimals, 6); + assert_eq!(token_ref.chain_id, 8453); + } + + #[tokio::test] + async fn prepare_approve_symbol_token_resolves_custom_token_without_quote() { + let config = test_config(8453); + let store = QuoteStore::new(&config).unwrap(); + store + .save_custom_token(&crate::models::token::CustomTokenRecord { + address: "0x2222222222222222222222222222222222222222".to_string(), + symbol: "USDC".to_string(), + name: "USD Coin".to_string(), + decimals: 6, + chain_id: 8453, + added_at: Utc::now(), + source: "test".to_string(), + }) + .unwrap(); + let api = ApiClients::new(&config).unwrap(); + + let token_ref = + resolve_prepare_approve_token_ref("USDC", None, 8453, None, &config, &store, &api) + .await + .unwrap(); + + assert_eq!(token_ref.symbol, "USDC"); + assert_eq!( + token_ref.address, + "0x2222222222222222222222222222222222222222" + ); + assert_eq!(token_ref.decimals, 6); + assert_eq!(token_ref.chain_id, 8453); + } + #[test] fn prepare_approval_rejects_invalid_from_address() { let token_ref = TokenRef { From 53590288ff6b3511b736e6d717a47ac816f53867 Mon Sep 17 00:00:00 2001 From: yongjun Date: Fri, 10 Jul 2026 10:58:03 +0800 Subject: [PATCH 3/4] fix: reject native token prepare approvals --- README.md | 12 +++++++++++- src/commands/swap.rs | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a00c8c0..523f3db 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,7 @@ chainpilot --json swap prepare execute --quote-id --wallet 0xYourAddr # ERC-20 approval payload chainpilot --json swap prepare approve --quote-id --wallet 0xYourAddress chainpilot --json swap prepare approve \ - --token 0xTokenAddr \ + --token USDC \ --spender 0xSpenderAddr \ --amount 100 \ --wallet 0xYourAddress @@ -277,6 +277,16 @@ unsigned transaction payload with `source`, `operation`, `chain_id`, `caip2`, `from`, `transaction { to, value, data, chain_id }`, and risk metadata. This is intended for external signing systems such as Privy; user confirmation, authorization checks, signing, and broadcasting must happen outside ChainPilot. +Use `swap simulate` to inspect route execution, gas, and warnings before this +step; use `swap prepare` only when an external signer needs the exact unsigned +transaction object. + +`swap prepare approve --quote-id` only applies when the quote spends an ERC-20 +input token. Native input tokens such as ETH, MATIC, BNB, or the chain native +asset do not require ERC-20 approval and are rejected. For explicit approvals, +`--token` accepts either a token address or a resolvable symbol from the +tokenlist/cache/custom token store; use the token address when the symbol is +ambiguous or unavailable. **Check transaction status:** ```bash diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 1e6fa54..88cf733 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -1371,6 +1371,15 @@ fn build_prepared_approval_payload( let _from_addr: Address = from_address .parse() .map_err(|_| ChainError::InvalidAddress(from_address.to_string()))?; + if token + .address + .eq_ignore_ascii_case(crate::config::chains::NATIVE_ADDR) + { + return Err(ChainError::Config(format!( + "native token {} does not require ERC-20 approval", + token.symbol + ))); + } let token_addr = token.address.clone(); let calldata = match operation { PreparedOperation::Approve => approve_calldata(spender_addr, amount_u256), @@ -2743,6 +2752,28 @@ mod tests { assert!(revoke.transaction.data.ends_with(&"0".repeat(64))); } + #[test] + fn prepare_approval_rejects_native_token_payload() { + let spender: Address = "0x3333333333333333333333333333333333333333" + .parse() + .unwrap(); + let from = "0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A"; + + let err = build_prepared_approval_payload( + PreparedOperation::Approve, + 8453, + from, + native_token(8453), + spender, + alloy::primitives::U256::MAX, + "unlimited".to_string(), + None, + ) + .unwrap_err(); + + assert!(matches!(err, ChainError::Config(msg) if msg.contains("native token"))); + } + #[test] fn prepare_approve_symbol_token_matches_quote_from_token() { let quote = sample_quote_for_approve(8453); From 23c539a0d2eb88e4b30a66fe91bba6ae3310abdf Mon Sep 17 00:00:00 2001 From: yongjun Date: Fri, 10 Jul 2026 11:03:29 +0800 Subject: [PATCH 4/4] chore: bump version to 1.2.0-rc.2 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c6437b1..57266b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "1.2.0-rc.1" +version = "1.2.0-rc.2" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 62d7179..7c7e2fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "1.2.0-rc.1" +version = "1.2.0-rc.2" description = "A CLI tool for on-chain DeFi operations on EVM-compatible networks" homepage = "https://github.com/DODOEX/ChainPilot" documentation = "https://github.com/DODOEX/ChainPilot#readme"