From 5db7dca64946896a097ead914f4e3a8a9c910ad9 Mon Sep 17 00:00:00 2001 From: yongjun Date: Mon, 13 Jul 2026 16:49:35 +0800 Subject: [PATCH] fix: correct action dry-run metadata --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 7 + README_CN.md | 9 +- skills/chainpilot/SKILL.md | 12 +- src/api/dodo.rs | 44 ++++++- src/commands/swap.rs | 264 ++++++++++++++++++++++++++++++++++--- 7 files changed, 311 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4aebbd3..52060d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1225,7 +1225,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chain-pilot" -version = "1.2.0" +version = "1.2.1" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index 41ef149..32080d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "1.2.0" +version = "1.2.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/README.md b/README.md index 9489a13..92f5deb 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,13 @@ They include the legacy preview fields plus a stable external-signer payload: `source`, `operation`, `chain_id`, `caip2`, `from`, `transaction { to, value, data, chain_id }`, optional `quote`, and `risk` metadata. `operation` is one of `swap_execute`, `approve`, or `revoke`. +For swap dry-runs, `risk.spender` is populated only for ERC-20 inputs and +points to the chain's configured DODOApprove contract; native-token inputs do +not have an allowance spender. `risk.token_in.amount_usd` is included only when +the input or output token address matches ChainPilot's trusted USD-stablecoin +registry; symbols alone are not trusted for budget metadata. Swap minimum-output +metadata reports `min_amount_raw` in token base units and `min_amount_display` +in human-readable units. For swaps, pass the dry-run wallet with `--wallet`; if omitted, execute dry-run falls back to the global `--wallet-address` / `WALLET_ADDRESS` sender context. For approve/revoke, pass the sender with the global `--wallet-address`. diff --git a/README_CN.md b/README_CN.md index aa2cbfa..f683f3d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -272,8 +272,13 @@ JSON 模式下,swap / approve / revoke 的 dry-run 不会签名或广播交易 响应会保留原有预览字段,并额外包含稳定的外部 signer payload: `source`、`operation`、`chain_id`、`caip2`、`from`、 `transaction { to, value, data, chain_id }`、可选 `quote` 和 `risk` metadata。 -`operation` 取值为 `swap_execute`、`approve` 或 `revoke`。兑换执行优先使用 -`--wallet` 传入 dry-run 钱包;如果省略,会 fallback 到全局 +`operation` 取值为 `swap_execute`、`approve` 或 `revoke`。swap dry-run 中, +`risk.spender` 只会在输入资产为 ERC-20 时写入,并指向该链 +配置的 DODOApprove 合约;原生资产输入没有 allowance spender。 +`risk.token_in.amount_usd` 只会在输入或输出 token 地址命中 ChainPilot 可信 +USD 稳定币地址表时写入,不依赖 token symbol。兑换最低收到 metadata 中, +`min_amount_raw` 使用 token base units,`min_amount_display` 使用人类可读单位。 +兑换执行优先使用 `--wallet` 传入 dry-run 钱包;如果省略,会 fallback 到全局 `--wallet-address/WALLET_ADDRESS` sender context。approve / revoke 使用全局 `--wallet-address`。approve / revoke dry-run 会在 `transaction.data` 中返回 ERC-20 `approve(address,uint256)` calldata。Privy 等外部 signer 系统仍需要在 diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index d9e2f3e..832be46 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -166,7 +166,7 @@ first or use the matching `*_display` field instead. | `from_amount` | ✓ | Same value as `from_amount_display`, stringified | | `to_amount_display` | ✓ | Always prefer this for display | | `to_amount` | ✓ | Same value as `to_amount_display`, stringified | -| `to_amount_min` | **✗ raw** | Integer in `to_token.decimals` units — divide by `10^to_token.decimals` to display | +| `to_amount_min` | ✓ | Minimum output, already human-readable | | `value` | **✗ raw** | Native token value in wei — divide by `1e18` to show as ETH | | `exchange_rate` | ✓ | Human-readable ratio | | `price_impact_pct` | ✓ | Already a percentage | @@ -176,7 +176,7 @@ first or use the matching `*_display` field instead. | Field | Display-ready? | Notes | |---|---|---| | `expected_out` | ✓ | Mirrors `to_amount` — human-readable | -| `min_out` | **✗ raw** | Same raw integer as `to_amount_min`; divide by `10^to_token.decimals` | +| `min_out` | ✓ | Mirrors `to_amount_min` — already human-readable | | `wallet_balance` | **✗ raw** | Integer in from-token's smallest unit | | `current_allowance` | **✗ raw** | Integer in from-token's smallest unit | | `suggested_approve_amount` | **✗ raw** | Integer in from-token's smallest unit | @@ -259,6 +259,14 @@ The JSON `data` object includes the legacy preview fields plus: | `quote` | Present for quote-derived swap execution | | `risk` | Token, amount, spender/router, and gas metadata when available | +For swap dry-runs, `risk.spender` is present only when the input asset is an +ERC-20 and it points to the chain's configured DODOApprove contract. Native-token +swaps do not have an allowance spender. `risk.token_in.amount_usd` is emitted +only when the input or output token address matches ChainPilot's trusted +USD-stablecoin registry; do not infer USD budget metadata from token symbols. +Minimum-output metadata uses `min_amount_raw` for token base units and +`min_amount_display` for human-readable units. + All external-signer dry-run commands require a sender wallet. For `swap execute --dry-run`, use `--wallet`; if it is omitted, the command falls back to the global `--wallet-address` / `WALLET_ADDRESS` sender context. For diff --git a/src/api/dodo.rs b/src/api/dodo.rs index 7307572..fbb4a3b 100644 --- a/src/api/dodo.rs +++ b/src/api/dodo.rs @@ -396,6 +396,8 @@ impl DodoClient { let expires_at = now + Duration::from_secs(quote_ttl_secs); let route_summary = parse_route_summary(&data.route_info); + let target_decimals = data.target_decimals.unwrap_or(to_token.decimals); + Ok(Quote { quote_id: Uuid::new_v4(), created_at: now, @@ -406,14 +408,14 @@ impl DodoClient { .target_symbol .unwrap_or_else(|| to_token.symbol.clone()), address: to_token.address.clone(), - decimals: data.target_decimals.unwrap_or(to_token.decimals), + decimals: target_decimals, chain_id: req.chain_id, }, from_amount: req.amount.clone(), from_amount_display: req.amount_display, to_amount: data.res_amount.to_string(), to_amount_display: data.res_amount, - to_amount_min: data.min_return_amount.clone(), + to_amount_min: raw_amount_to_display(&data.min_return_amount, target_decimals), price_impact_pct: data.price_impact, exchange_rate: if req.amount_display > 0.0 { data.res_amount / req.amount_display @@ -445,6 +447,30 @@ fn parse_optional_u64(value: Option) -> Result> { value.map(FlexibleU64::into_u64).transpose() } +fn raw_amount_to_display(raw: &str, decimals: u8) -> String { + let raw = raw.trim(); + if raw.is_empty() || raw.starts_with('-') { + return raw.to_string(); + } + let decimals = decimals as usize; + if decimals == 0 { + return raw.to_string(); + } + let padded = if raw.len() <= decimals { + format!("{}{}", "0".repeat(decimals + 1 - raw.len()), raw) + } else { + raw.to_string() + }; + let split = padded.len().saturating_sub(decimals); + let (whole, frac) = padded.split_at(split); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + whole.to_string() + } else { + format!("{whole}.{frac}") + } +} + fn parse_route_summary(route_info: &Option) -> Vec { let Some(route_info) = route_info else { return Vec::new(); @@ -478,3 +504,17 @@ fn parse_route_summary(route_info: &Option) -> Vec { hops } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn raw_amount_to_display_converts_dodo_min_return_once() { + assert_eq!( + raw_amount_to_display("1679119161479651", 18), + "0.001679119161479651" + ); + assert_eq!(raw_amount_to_display("2970000000", 6), "2970"); + } +} diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 23b25de..67995e6 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -728,9 +728,12 @@ async fn simulate( has_sufficient_balance = Some(bal >= from_amount_raw); wallet_balance = Some(raw_balance); - match quote_data.router_to.parse::
() { + let spender = crate::config::chain_config(chain_id) + .map(|chain| chain.contracts.dodo_approve) + .unwrap_or(quote_data.router_to.as_str()); + match spender.parse::
() { Err(_) => warnings - .push("Invalid router address in quote.".to_string()), + .push("Invalid DODO approve address.".to_string()), Ok(spender_addr) => { match get_allowance( onchain, @@ -960,15 +963,73 @@ fn token_address_for_payload(token: &TokenRef) -> Option { } } +fn is_trusted_usd_stablecoin(token: &TokenRef) -> bool { + trusted_usd_stablecoin_addresses(token.chain_id) + .iter() + .any(|addr| token.address.eq_ignore_ascii_case(addr)) +} + +fn trusted_usd_stablecoin_addresses(chain_id: u64) -> &'static [&'static str] { + match chain_id { + 1 => &[ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC + "0xdAC17F958D2ee523a2206206994597C13D831ec7", // USDT + "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI + ], + 10 => &[ + "0x0b2C639c533813f4Aa9D7837CAF62653d097Ff85", // USDC + "0x7F5c764cBc14f9669B88837ca1490cCa17c31607", // USDC.e + "0x94b008aD5cdEdDfb7C803f7eFf9F18B6eB2F1384", // USDT + "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", // DAI + ], + 56 => &[ + "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", // USDC + "0x55d398326f99059fF775485246999027B3197955", // USDT + "0x1AF3F329e8BE154074D8769D1FFa4eE058B1DBc3", // DAI + ], + 137 => &[ + "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", // USDC + "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", // USDC.e + "0xc2132D05D31c914a87C6611C10748AEb04B58e8F", // USDT + "0x8f3Cf7ad23Cd3CaDbD9735AFf958023239c6A063", // DAI + ], + 8453 => &[ + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // USDC + "0xd9aAEc86B65D86f6A7B5B1b0c42FFA531710b6CA", // USDbC + ], + 42161 => &[ + "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", // USDC + "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8", // USDC.e + "0xFd086bC7CD5C481DCC9C85ebe478A1C0b69FCbb9", // USDT + "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1", // DAI + ], + 43114 => &[ + "0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E", // USDC + "0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664", // USDC.e + "0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7", // USDT + "0xc7198437980c041c805A1EDcbA50c1Ce5db95118", // USDT.e + "0xd586E7F844cEa2F87f50152665BCbc2C279D8d70", // DAI.e + ], + _ => &[], + } +} + +fn finite_non_negative_amount(value: f64) -> Option { + if value.is_finite() && value >= 0.0 { + Some(value.to_string()) + } else { + None + } +} + 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; + if is_trusted_usd_stablecoin("e.from_token) { + return finite_non_negative_amount(quote.from_amount_display); + } + if is_trusted_usd_stablecoin("e.to_token) { + return finite_non_negative_amount(quote.to_amount_display); } - Some((quote.from_amount_display * price_per_from).to_string()) + None } fn approval_amount_usd_from_quote( @@ -984,22 +1045,53 @@ fn approval_amount_usd_from_quote( { return None; } - 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; - } let amount = raw_amount_to_display(raw_amount, token.decimals)? .parse::() .ok()?; if amount < 0.0 || !amount.is_finite() { return None; } + if is_trusted_usd_stablecoin(token) { + return Some(amount.to_string()); + } + if !is_trusted_usd_stablecoin("e.to_token) || quote.from_amount_display <= 0.0 { + return None; + } + let price_per_from = quote.to_amount_display / quote.from_amount_display; + if price_per_from <= 0.0 || !price_per_from.is_finite() { + return None; + } Some((amount * price_per_from).to_string()) } +fn quote_spender(quote: &Quote) -> Option { + token_address_for_payload("e.from_token) + .and_then(|_| crate::config::chain_config(quote.chain_id)) + .map(|chain| chain.contracts.dodo_approve.to_string()) +} + +fn dodo_raw_min_return_amount(quote: &Quote) -> Option { + let value = quote.raw_dodo_response.get("minReturnAmount")?; + value + .as_str() + .map(str::to_string) + .or_else(|| value.as_u64().map(|n| n.to_string())) +} + +fn quote_min_output_amounts(quote: &Quote) -> Result<(String, Option)> { + if dodo_raw_min_return_amount(quote).as_deref() == Some(quote.to_amount_min.as_str()) { + return Ok(( + quote.to_amount_min.clone(), + raw_amount_to_display("e.to_amount_min, quote.to_token.decimals), + )); + } + + Ok(( + to_raw_amount("e.to_amount_min, quote.to_token.decimals)?, + Some(quote.to_amount_min.clone()), + )) +} + fn build_dry_run_execute_payload( quote: &Quote, from_address: &str, @@ -1019,8 +1111,7 @@ fn build_dry_run_execute_payload( .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_raw = to_raw_amount("e.to_amount_min, quote.to_token.decimals)?; - let min_amount_display = Some(quote.to_amount_min.clone()); + let (min_amount_raw, min_amount_display) = quote_min_output_amounts(quote)?; Ok(( from_address.to_string(), @@ -1061,7 +1152,7 @@ fn build_dry_run_execute_payload( min_amount_raw: Some(min_amount_raw), min_amount_display, }), - spender: None, + spender: quote_spender(quote), router: Some(quote.router_to.clone()), estimated_gas_usd: quote.estimated_gas_usd.map(|v| v.to_string()), }, @@ -2024,9 +2115,14 @@ mod tests { } fn erc20_token(chain_id: u64) -> TokenRef { + let address = match chain_id { + 42161 => "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", + 8453 => "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + _ => "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + }; TokenRef { symbol: "USDC".to_string(), - address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(), + address: address.to_string(), decimals: 6, chain_id, } @@ -2481,6 +2577,7 @@ mod tests { result.risk.router.as_deref(), Some(quote.router_to.as_str()) ); + assert_eq!(result.risk.spender.as_deref(), None); assert_eq!( result.risk.token_in.as_ref().unwrap().amount_usd.as_deref(), Some("3000") @@ -2491,6 +2588,131 @@ mod tests { assert_eq!(token_out.min_amount_display.as_deref(), Some("2970")); } + #[tokio::test] + async fn execute_quote_dry_run_sets_spender_only_for_erc20_input() { + let deps = MockExecuteDeps { + estimated_gas: Ok(21_000), + nonce: Ok(7), + send_tx_result: Ok((Address::ZERO, "0xtx".to_string())), + receipts: RefCell::new(vec![]), + }; + let mut args = execute_args(); + args.dry_run = true; + args.wallet = Some("0x2222222222222222222222222222222222222222".to_string()); + let mut quote = sample_quote(42161); + quote.from_token = erc20_token(42161); + + let result = execute_quote_with_deps(&deps, &args, &test_config(42161), "e) + .await + .unwrap(); + + assert_eq!( + result.risk.spender.as_deref(), + crate::config::chain_config(42161).map(|c| c.contracts.dodo_approve) + ); + } + + #[tokio::test] + async fn execute_quote_dry_run_uses_stablecoin_input_as_budget_amount_usd() { + let deps = MockExecuteDeps { + estimated_gas: Ok(21_000), + nonce: Ok(7), + send_tx_result: Ok((Address::ZERO, "0xtx".to_string())), + receipts: RefCell::new(vec![]), + }; + let mut args = execute_args(); + args.dry_run = true; + args.wallet = Some("0x2222222222222222222222222222222222222222".to_string()); + let mut quote = sample_quote(42161); + quote.from_token = erc20_token(42161); + quote.to_token = native_token(42161); + quote.from_amount = "3".to_string(); + quote.from_amount_display = 3.0; + quote.to_amount = "0.00168248408632968".to_string(); + quote.to_amount_display = 0.00168248408632968; + quote.to_amount_min = "0.001679119161479651".to_string(); + quote.raw_dodo_response = serde_json::json!({"resPricePerFromToken": 0.00056082802877656}); + + let result = execute_quote_with_deps(&deps, &args, &test_config(42161), "e) + .await + .unwrap(); + + assert_eq!( + result.risk.token_in.as_ref().unwrap().amount_usd.as_deref(), + Some("3") + ); + } + + #[tokio::test] + async fn execute_quote_dry_run_omits_amount_usd_for_untrusted_stablecoin_symbol() { + let deps = MockExecuteDeps { + estimated_gas: Ok(21_000), + nonce: Ok(7), + send_tx_result: Ok((Address::ZERO, "0xtx".to_string())), + receipts: RefCell::new(vec![]), + }; + let mut args = execute_args(); + args.dry_run = true; + args.wallet = Some("0x2222222222222222222222222222222222222222".to_string()); + let mut quote = sample_quote(42161); + quote.from_token = TokenRef { + symbol: "USDC".to_string(), + address: "0x2222222222222222222222222222222222222222".to_string(), + decimals: 6, + chain_id: 42161, + }; + quote.to_token = native_token(42161); + quote.from_amount = "3".to_string(); + quote.from_amount_display = 3.0; + quote.to_amount_display = 0.00168248408632968; + + let result = execute_quote_with_deps(&deps, &args, &test_config(42161), "e) + .await + .unwrap(); + + assert_eq!( + result.risk.token_in.as_ref().unwrap().amount_usd.as_deref(), + None + ); + } + + #[tokio::test] + async fn execute_quote_dry_run_keeps_dodo_raw_min_return_without_double_scaling() { + let deps = MockExecuteDeps { + estimated_gas: Ok(21_000), + nonce: Ok(7), + send_tx_result: Ok((Address::ZERO, "0xtx".to_string())), + receipts: RefCell::new(vec![]), + }; + let mut args = execute_args(); + args.dry_run = true; + args.wallet = Some("0x2222222222222222222222222222222222222222".to_string()); + let mut quote = sample_quote(42161); + quote.to_token = native_token(42161); + quote.to_amount_min = "1679119161479651".to_string(); + quote.raw_dodo_response = serde_json::json!({ + "minReturnAmount": "1679119161479651" + }); + + let result = execute_quote_with_deps(&deps, &args, &test_config(42161), "e) + .await + .unwrap(); + + let token_out = result.risk.token_out.as_ref().unwrap(); + assert_eq!( + token_out.min_amount_raw.as_deref(), + Some("1679119161479651") + ); + assert_eq!( + token_out.min_amount_display.as_deref(), + Some("0.001679119161479651") + ); + assert_eq!( + raw_amount_to_display("1679119161479651", 18).as_deref(), + Some("0.001679119161479651") + ); + } + #[tokio::test] async fn execute_quote_dry_run_wallet_overrides_configured_signer() { let deps = MockExecuteDeps { @@ -2781,7 +3003,7 @@ mod tests { assert_eq!( approval_amount_usd_from_quote(Some("e), &token, &raw_amount).as_deref(), - Some("7.5") + Some("3") ); }