diff --git a/Cargo.lock b/Cargo.lock index 2d494c8..4aebbd3 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" dependencies = [ "alloy", "alloy-contract", diff --git a/Cargo.toml b/Cargo.toml index f39b7f4..41ef149 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "chain-pilot" -version = "1.1.0" +version = "1.2.0" 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 f1ecdf7..9489a13 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Supported environment variables are intentionally limited. Runtime config is rea | `KEYSTORE_PASSWORD_FILE` | `--password-file` | — | Read keystore password from file | | `KEYSTORE_PASSWORD_ENV` | `--password-env` | — | Read keystore password from named env var | | `KEYSTORE_PASSWORD` | — | — | Default env var used for keystore password | -| `WALLET_ADDRESS` | `--wallet-address` | — | Wallet address for balance/simulate context | +| `WALLET_ADDRESS` | `--wallet-address` | — | Wallet address for balance/simulate and dry-run sender fallback | | `--rpc-url` | CLI only | Chain's built-in public RPC | Explicit JSON-RPC override | | `CHAIN_ID` | `--chain-id` | `1` (Ethereum mainnet) | Active chain ID | | `DODO_API_KEY` | — | Compiled-in default | DODO routing API key | @@ -220,7 +220,7 @@ Simulation is read-only — it costs no gas and does not broadcast any transacti **Execute a swap:** ```bash -# Dry-run: build and simulate the transaction without broadcasting +# Dry-run: build an unsigned external-signer payload without broadcasting chainpilot swap execute --quote-id --dry-run --wallet 0xYourAddress # Live execution with a raw private key @@ -245,12 +245,57 @@ chainpilot swap execute --quote-id --private-key 0x... --skip-estimat | Execute flag | Description | |---------------------|--------------------------------------------------------------------------| -| `--dry-run` | Build and simulate the tx without broadcasting; `--wallet` instead of key | +| `--dry-run` | Build an unsigned external-signer payload without signing or broadcasting; `--wallet` instead of key | | `--wait` | Block until the tx is mined and show final on-chain status | -| `--gas-limit` | Hard override for gas limit | -| `--max-fee-gwei` | Override max fee per gas (EIP-1559), in gwei | -| `--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 | +| `--gas-limit` | Hard override for gas limit; included in dry-run payloads when set | +| `--max-fee-gwei` | Override max fee per gas (EIP-1559), in gwei; included in dry-run payloads as wei hex when set | +| `--gas-buffer-pct` | Live execution only: add N% buffer on top of `eth_estimateGas` result (e.g. `20` = +20%) | +| `--skip-estimate` | Live execution only: skip `eth_estimateGas` and use the quote's gas estimate directly | + +**External signer dry-run contract:** +```bash +# Swap execution payload from a saved quote +chainpilot --json swap execute --quote-id --dry-run --wallet 0xYourAddress + +# ERC-20 approval payload from a saved quote +chainpilot --json swap approve --quote-id --dry-run --wallet-address 0xYourAddress + +# Explicit ERC-20 approval payload +chainpilot --json swap approve \ + --token USDC \ + --spender 0xSpenderAddr \ + --amount 100 \ + --dry-run \ + --wallet-address 0xYourAddress + +# ERC-20 revoke payload +chainpilot --json swap revoke \ + --token 0xTokenAddr \ + --spender 0xSpenderAddr \ + --dry-run \ + --wallet-address 0xYourAddress +``` + +In JSON mode, dry-run swap/approve/revoke responses do not sign or broadcast. +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 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`. +Approve and revoke dry-runs include ERC-20 `approve(address,uint256)` calldata +in `transaction.data`. Systems such as +Privy should still apply their own authorization, confirmation, budget, and risk +checks before submitting the transaction. +All external-signer dry-runs require a sender wallet and fail instead of +omitting `data.from` or `data.transaction` when no sender can be resolved. +Execute dry-run resolves the sender from signer config, `--wallet`, or global +`--wallet-address` / `WALLET_ADDRESS`; approve/revoke dry-runs resolve it from +signer config or global `--wallet-address` / `WALLET_ADDRESS`. +`--gas-limit` and `--max-fee-gwei` are reflected in the unsigned swap +transaction payload; dry-run does not call `eth_estimateGas`, so +`--gas-buffer-pct` and `--skip-estimate` only affect live execution. **Check transaction status:** ```bash @@ -281,7 +326,7 @@ chainpilot swap approve --token USDC --spender 0x... --amount 1000 --private-key chainpilot swap approve --token USDC --spender 0x... --private-key 0x... # Dry-run to preview without sending -chainpilot swap approve --quote-id --dry-run +chainpilot --json swap approve --quote-id --dry-run --wallet-address 0xYourAddress ``` **Revoke an approval:** @@ -292,7 +337,7 @@ chainpilot swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --private-key chainpilot --keystore-path ~/.chainpilot/main.json swap revoke --token 0xTokenAddr --spender 0xSpenderAddr # Dry-run -chainpilot swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --dry-run +chainpilot --json swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --dry-run --wallet-address 0xYourAddress ``` ### Token diff --git a/README_CN.md b/README_CN.md index 02942d2..aa2cbfa 100644 --- a/README_CN.md +++ b/README_CN.md @@ -72,7 +72,7 @@ DODO_PROJECT_ID=your-id | `KEYSTORE_PASSWORD_FILE` | `--password-file` | — | 从文件读取 keystore 密码 | | `KEYSTORE_PASSWORD_ENV` | `--password-env` | — | 从指定环境变量读取 keystore 密码 | | `KEYSTORE_PASSWORD` | — | — | keystore 密码的默认环境变量 | -| `WALLET_ADDRESS` | `--wallet-address` | — | 余额查询 / 模拟时使用的钱包地址 | +| `WALLET_ADDRESS` | `--wallet-address` | — | 余额查询 / 模拟 / dry-run sender fallback 钱包地址 | | `--rpc-url` | 仅 CLI | 链内置公共 RPC | 显式覆盖 JSON-RPC 端点 | | `CHAIN_ID` | `--chain-id` | `1`(以太坊主网) | 当前链 ID | | `DODO_API_KEY` | — | 编译时内嵌默认值 | DODO 路由 API Key | @@ -212,7 +212,7 @@ chainpilot swap simulate --quote-id --wallet 0xYourAddress **执行兑换:** ```bash -# 演习模式:构建并模拟交易,不广播 +# 演习模式:构建未签名的外部 signer payload,不广播 chainpilot swap execute --quote-id --dry-run --wallet 0xYourAddress # 用裸私钥正式执行 @@ -237,12 +237,55 @@ chainpilot swap execute --quote-id --private-key 0x... --skip-estimat | 执行标志 | 说明 | |---------------------|-------------------------------------------------------------------| -| `--dry-run` | 构建并模拟交易,不广播;使用 `--wallet` 代替私钥 | +| `--dry-run` | 构建未签名的外部 signer payload,不签名也不广播;使用 `--wallet` 代替私钥 | | `--wait` | 阻塞直到交易上链,显示最终链上状态 | -| `--gas-limit` | 硬覆盖 Gas 上限 | -| `--max-fee-gwei` | 覆盖 EIP-1559 最大 Gas 费用(gwei) | -| `--gas-buffer-pct` | 在 `eth_estimateGas` 结果上添加 N% 缓冲(如 `20` = +20%) | -| `--skip-estimate` | 跳过 `eth_estimateGas`,直接使用报价中的 Gas 估算 | +| `--gas-limit` | 硬覆盖 Gas 上限;设置后会写入 dry-run payload | +| `--max-fee-gwei` | 覆盖 EIP-1559 最大 Gas 费用(gwei);设置后会以 wei hex 写入 dry-run payload | +| `--gas-buffer-pct` | 仅 live execution 生效:在 `eth_estimateGas` 结果上添加 N% 缓冲(如 `20` = +20%) | +| `--skip-estimate` | 仅 live execution 生效:跳过 `eth_estimateGas`,直接使用报价中的 Gas 估算 | + +**外部 signer dry-run contract:** +```bash +# 从已保存报价生成兑换执行 payload +chainpilot --json swap execute --quote-id --dry-run --wallet 0xYourAddress + +# 从已保存报价生成 ERC-20 授权 payload +chainpilot --json swap approve --quote-id --dry-run --wallet-address 0xYourAddress + +# 显式指定 ERC-20 授权 payload +chainpilot --json swap approve \ + --token USDC \ + --spender 0xSpenderAddr \ + --amount 100 \ + --dry-run \ + --wallet-address 0xYourAddress + +# ERC-20 revoke payload +chainpilot --json swap revoke \ + --token 0xTokenAddr \ + --spender 0xSpenderAddr \ + --dry-run \ + --wallet-address 0xYourAddress +``` + +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 到全局 +`--wallet-address/WALLET_ADDRESS` sender context。approve / revoke 使用全局 +`--wallet-address`。approve / revoke dry-run 会在 `transaction.data` 中返回 +ERC-20 `approve(address,uint256)` calldata。Privy 等外部 signer 系统仍需要在 +提交前自行执行授权、确认、预算和风险校验。 +所有外部 signer dry-run 都必须能解析出 sender wallet;无法解析时会失败, +而不是省略 `data.from` 或 `data.transaction`。execute dry-run 会从 signer +配置、`--wallet` 或全局 `--wallet-address/WALLET_ADDRESS` 解析 sender; +approve / revoke dry-run 会从 signer 配置或全局 +`--wallet-address/WALLET_ADDRESS` 解析 sender。`--gas-limit` 和 +`--max-fee-gwei` 会反映到未签名 swap 交易 payload 中;dry-run 不调用 +`eth_estimateGas`,因此 `--gas-buffer-pct` 和 `--skip-estimate` 只影响 live +execution。 **查询交易状态:** ```bash @@ -273,7 +316,7 @@ chainpilot swap approve --token USDC --spender 0x... --amount 1000 --private-key chainpilot swap approve --token USDC --spender 0x... --private-key 0x... # 演习模式,预览但不发送 -chainpilot swap approve --quote-id --dry-run +chainpilot --json swap approve --quote-id --dry-run --wallet-address 0xYourAddress ``` **撤销授权:** @@ -284,7 +327,7 @@ chainpilot swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --private-key chainpilot --keystore-path ~/.chainpilot/main.json swap revoke --token 0xTokenAddr --spender 0xSpenderAddr # 演习模式 -chainpilot swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --dry-run +chainpilot --json swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --dry-run --wallet-address 0xYourAddress ``` ### Token(代币) diff --git a/skills/chainpilot/SKILL.md b/skills/chainpilot/SKILL.md index 5cd9015..d9e2f3e 100644 --- a/skills/chainpilot/SKILL.md +++ b/skills/chainpilot/SKILL.md @@ -100,7 +100,7 @@ chainpilot [GLOBAL_FLAGS] [SUBcommand_FLAGS] | `--keystore-path ` | `KEYSTORE_PATH` | — | Encrypted JSON keystore for write transactions | | `--password-file ` | `KEYSTORE_PASSWORD_FILE` | — | Read keystore password from file | | `--password-env ` | `KEYSTORE_PASSWORD_ENV` | — | Read keystore password from the named env var | -| `--wallet-address ` | `WALLET_ADDRESS` | — | Read-only wallet context | +| `--wallet-address ` | `WALLET_ADDRESS` | — | Read-only wallet context and dry-run sender fallback | | `--rpc-url ` | — | Chain's public RPC | Explicit JSON-RPC override | | `--chain-id ` | `CHAIN_ID` | `1` | Global chain context | @@ -215,6 +215,60 @@ Quotes have a **dual TTL**: the DODO-issued route expires in 20 minutes; the local default TTL is 18 minutes and expires first. Both `simulate` and `execute` reject stale quotes. +### External signer dry-run workflow + +Use `--json --dry-run` when an external wallet service needs an unsigned +transaction payload instead of ChainPilot signing locally. Dry-run output is not +execution: it does not sign, broadcast, or return a transaction hash. + +```bash +# Swap execution payload from a saved quote +chainpilot --json swap execute --quote-id --dry-run --wallet + +# ERC-20 approval payload from a saved quote +chainpilot --json swap approve --quote-id --dry-run --wallet-address + +# Explicit ERC-20 approval payload +chainpilot --json swap approve \ + --token USDC \ + --spender 0xSpenderAddr \ + --amount 100 \ + --dry-run \ + --wallet-address + +# ERC-20 revoke payload +chainpilot --json swap revoke \ + --token 0xTokenAddr \ + --spender 0xSpenderAddr \ + --dry-run \ + --wallet-address +``` + +The JSON `data` object includes the legacy preview fields plus: + +| Field | Notes | +|---|---| +| `source` | Always `chainpilot` | +| `operation` | `swap_execute`, `approve`, or `revoke` | +| `chain_id` / `caip2` | EVM chain identity | +| `from` | Wallet address supplied for dry-run | +| `transaction.to` | Router for swaps; token contract for approve/revoke | +| `transaction.value` | Native value as hex, e.g. `0x0` | +| `transaction.data` | Calldata; approve/revoke use ERC-20 `approve(address,uint256)` | +| `transaction.chain_id` | Must match `chain_id` | +| `quote` | Present for quote-derived swap execution | +| `risk` | Token, amount, spender/router, and gas metadata when available | + +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 +approve/revoke dry-runs, use the global `--wallet-address` / `WALLET_ADDRESS` +context. If no sender can be resolved, stop on the CLI error instead of +treating the payload as usable. + +External signer integrations must still perform their own authorization, +confirmation, budget, and risk checks before submitting the transaction. + --- ## `swap` Subcommands @@ -304,8 +358,11 @@ chainpilot --keystore-path /path/to/keystore.json swap approve --token USDC --sp # Unlimited approval (omit --amount), using the configured signer context chainpilot swap approve --token USDC --spender 0x... -# Dry-run (no tx sent, signer not needed) -chainpilot swap approve --quote-id --dry-run +# Dry-run external-signer payload from a saved quote +chainpilot --json swap approve --quote-id --dry-run --wallet-address + +# Dry-run external-signer payload with explicit token/spender/amount +chainpilot --json swap approve --token USDC --spender 0x... --amount 1000 --dry-run --wallet-address ``` ### `swap execute` @@ -325,12 +382,15 @@ chainpilot --keystore-path /path/to/keystore.json swap execute --quote-id [ # Non-interactive keystore execution chainpilot --keystore-path /path/to/keystore.json --password-file /path/to/keystore.pass \ swap execute --quote-id [OPTIONS] + +# Dry-run external-signer payload from a saved quote +chainpilot --json swap execute --quote-id --dry-run --wallet ``` | Flag | Description | |---|---| -| `--dry-run` | Simulate execution without broadcasting — use `--wallet` instead of a signer | -| `--wallet ` | Wallet address for dry-run (not `--wallet-address`) | +| `--dry-run` | Build an unsigned swap transaction payload without broadcasting — use `--wallet` or global wallet context instead of a signer | +| `--wallet ` | Preferred wallet address for execute dry-run payloads; falls back to global `--wallet-address` / `WALLET_ADDRESS` when omitted | | `--wait` | Block until mined, print final on-chain status | | `--gas-limit ` | Hard-override gas limit | | `--max-fee-gwei ` | Override EIP-1559 max fee (in gwei) | @@ -341,11 +401,13 @@ chainpilot --keystore-path /path/to/keystore.json --password-file /path/to/keyst ```bash chainpilot swap revoke --token --spender [--dry-run] -chainpilot swap revoke --token USDC --spender 0xRouter -chainpilot --keystore-path /path/to/keystore.json swap revoke --token USDC --spender 0xRouter +chainpilot swap revoke --token 0xTokenAddr --spender 0xRouter +chainpilot --keystore-path /path/to/keystore.json swap revoke --token 0xTokenAddr --spender 0xRouter +chainpilot --json swap revoke --token 0xTokenAddr --spender 0xSpenderAddr --dry-run --wallet-address ``` -- `--dry-run`: dry-run mode, signer not required. +- `--dry-run`: build an unsigned ERC-20 revoke payload without sending; use + `--wallet-address` to populate `data.from`. ### `swap status` diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 346fc98..42382b3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -40,7 +40,8 @@ pub struct Cli { #[arg(long, env = "KEYSTORE_PASSWORD_ENV", global = true)] pub password_env: Option, - /// Wallet address to use for quote/simulate context (overrides WALLET_ADDRESS env var) + /// Wallet address for quote/simulate context and dry-run sender fallback + /// (overrides WALLET_ADDRESS env var) #[arg(long, env = "WALLET_ADDRESS", global = true)] pub wallet_address: Option, diff --git a/src/cli/swap.rs b/src/cli/swap.rs index 830b597..63d2764 100644 --- a/src/cli/swap.rs +++ b/src/cli/swap.rs @@ -13,15 +13,15 @@ pub enum SwapAction { Quote(QuoteArgs), /// Simulate a swap from a saved quote Simulate(SimulateArgs), - /// Execute a swap (requires wallet) + /// Execute a saved swap quote; --dry-run emits an unsigned transaction payload Execute(ExecuteArgs), /// Check swap transaction status Status(StatusArgs), /// Show swap history History(HistoryArgs), - /// Approve token spending + /// Approve token spending; --dry-run emits an unsigned ERC-20 approve payload Approve(ApproveArgs), - /// Revoke token spending approval + /// Revoke token spending approval; --dry-run emits an unsigned ERC-20 revoke payload Revoke(RevokeArgs), } @@ -61,19 +61,19 @@ pub struct ExecuteArgs { #[arg(long)] pub quote_id: String, - /// Simulate execution without broadcasting the transaction + /// Dry-run: build an unsigned external-signer transaction payload without signing or broadcasting #[arg(long)] pub dry_run: bool, - /// Gas limit override + /// Gas limit override; included in dry-run payloads when set #[arg(long)] pub gas_limit: Option, - /// Max fee per gas in gwei + /// Max fee per gas in gwei; included in dry-run payloads as wei hex when set #[arg(long)] pub max_fee_gwei: Option, - /// Wallet address for dry-run simulation (no private key needed; env: WALLET_ADDRESS) + /// Wallet address used as the unsigned transaction sender for dry-run payloads (no private key needed; env: WALLET_ADDRESS) #[arg(long, env = "WALLET_ADDRESS")] pub wallet: Option, @@ -81,11 +81,11 @@ pub struct ExecuteArgs { #[arg(long)] pub wait: bool, - /// Skip eth_estimateGas pre-flight check and use the quote's gas estimate instead + /// Live execution only: skip eth_estimateGas pre-flight and use the quote's gas estimate instead #[arg(long)] pub skip_estimate: bool, - /// Add a percentage buffer on top of eth_estimateGas result (e.g. 20 for +20%) + /// Live execution only: add a percentage buffer on top of eth_estimateGas result (e.g. 20 for +20%) #[arg(long)] pub gas_buffer_pct: Option, } @@ -110,7 +110,7 @@ pub struct HistoryArgs { #[derive(Args)] pub struct ApproveArgs { - /// Quote ID to derive token and spender from (uses from-token and router address) + /// Quote ID to derive token and spender from (uses from-token and DODOApprove contract) #[arg(long)] pub quote_id: Option, @@ -118,7 +118,7 @@ pub struct ApproveArgs { #[arg(long)] pub token: Option, - /// Spender contract address (overrides quote's router address) + /// Spender contract address (overrides quote-derived DODOApprove contract) #[arg(long)] pub spender: Option, @@ -126,19 +126,22 @@ pub struct ApproveArgs { #[arg(long)] pub amount: Option, - /// Dry-run mode: show what would be approved without sending + /// Dry-run: build an unsigned ERC-20 approve transaction payload without signing or sending #[arg(long)] pub dry_run: bool, } #[derive(Args)] pub struct RevokeArgs { + /// Token contract address whose allowance should be revoked #[arg(long)] pub token: String, + /// Spender contract address whose allowance should be revoked #[arg(long)] pub spender: String, + /// Dry-run: build an unsigned ERC-20 revoke transaction payload without signing or sending #[arg(long)] pub dry_run: bool, } diff --git a/src/commands/swap.rs b/src/commands/swap.rs index 112b860..23b25de 100644 --- a/src/commands/swap.rs +++ b/src/commands/swap.rs @@ -15,7 +15,10 @@ 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::{ + ChainPilotOperation, ChainPilotQuote, ChainPilotRisk, ChainPilotTokenAmount, + ChainPilotTransaction, ExecutionResult, ExecutionStatus, SimulationResult, +}; use crate::output::{OutputContext, OutputMode}; use crate::store::QuoteStore; use alloy::primitives::Address; @@ -285,9 +288,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(), @@ -313,7 +316,7 @@ pub async fn handle( 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, - SwapAction::Approve(args) => approve(args, config, store, output_mode).await, + SwapAction::Approve(args) => approve(args, config, store, api, output_mode).await, SwapAction::Revoke(args) => revoke(args, config, output_mode).await, } } @@ -412,7 +415,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 +431,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 +466,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(), @@ -838,9 +846,18 @@ fn dry_run_from_address( subcommand_wallet: Option, global_wallet: Option, ) -> Option { - derived_from_private_key - .or(subcommand_wallet) + subcommand_wallet .or(global_wallet) + .or(derived_from_private_key) +} + +fn require_dry_run_from_address( + derived_from_private_key: Option, + subcommand_wallet: Option, + global_wallet: Option, +) -> Result { + dry_run_from_address(derived_from_private_key, subcommand_wallet, global_wallet) + .ok_or(ChainError::NoDryRunWallet) } fn resolve_effective_gas_limit( @@ -866,6 +883,191 @@ fn resolve_effective_gas_limit( }) } +fn optional_u64_to_hex(value: Option) -> Option { + value.map(|v| format!("0x{v:x}")) +} + +fn optional_gwei_to_wei_hex(value: Option) -> Result> { + let Some(gwei) = value else { + return Ok(None); + }; + if !gwei.is_finite() || gwei < 0.0 { + return Err(ChainError::InvalidAmount( + "max fee gwei must be a non-negative finite number".to_string(), + )); + } + Ok(Some(format!("0x{:x}", (gwei * 1e9) as u128))) +} + +fn decimal_or_hex_to_hex(value: &str) -> Result { + if value.is_empty() { + return Ok("0x0".to_string()); + } + if let Some(hex) = value.strip_prefix("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 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 raw_amount_to_display(raw: &str, decimals: u8) -> Option { + let raw = raw.trim(); + if raw.is_empty() || raw.starts_with('-') { + return None; + } + let scale = 10usize.saturating_pow(decimals as u32); + if scale == 0 { + return Some(raw.to_string()); + } + let padded = if raw.len() <= decimals as usize { + let zeros = "0".repeat(decimals as usize + 1 - raw.len()); + format!("{zeros}{raw}") + } else { + raw.to_string() + }; + let split = padded.len().saturating_sub(decimals as usize); + let (whole, frac) = padded.split_at(split); + let frac = frac.trim_end_matches('0'); + if frac.is_empty() { + Some(whole.to_string()) + } else { + Some(format!("{whole}.{frac}")) + } +} + +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 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 approval_amount_usd_from_quote( + quote: Option<&Quote>, + token: &TokenRef, + raw_amount: &str, +) -> Option { + let quote = quote?; + let quote_token = "e.from_token; + if token.chain_id != quote.chain_id + || token.decimals != quote_token.decimals + || !token.address.eq_ignore_ascii_case("e_token.address) + { + 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; + } + Some((amount * price_per_from).to_string()) +} + +fn build_dry_run_execute_payload( + quote: &Quote, + from_address: &str, + gas_limit: Option, + max_fee_gwei: Option, +) -> Result<( + String, + ChainPilotTransaction, + ChainPilotQuote, + ChainPilotRisk, +)> { + 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_raw = to_raw_amount("e.to_amount_min, quote.to_token.decimals)?; + let min_amount_display = Some(quote.to_amount_min.clone()); + + Ok(( + from_address.to_string(), + ChainPilotTransaction { + 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(gas_limit.or(quote.estimated_gas).or(quote.gas_limit)), + max_fee_per_gas: optional_gwei_to_wei_hex(max_fee_gwei)?, + max_priority_fee_per_gas: None, + }, + ChainPilotQuote { + quote_id: quote.quote_id.to_string(), + expires_at: Some(quote.expires_at), + slippage_bps: slippage_bps(quote.slippage), + }, + ChainPilotRisk { + token_in: Some(ChainPilotTokenAmount { + 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(ChainPilotTokenAmount { + 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(min_amount_raw), + min_amount_display, + }), + spender: None, + router: Some(quote.router_to.clone()), + estimated_gas_usd: quote.estimated_gas_usd.map(|v| v.to_string()), + }, + )) +} + fn resolve_approve_targets( explicit_token: Option<&str>, quote: Option<&Quote>, @@ -897,6 +1099,36 @@ fn resolve_approve_targets( Ok((token, spender)) } +fn approve_token_ref_from_quote_or_input( + quote: Option<&Quote>, + token_input: &str, +) -> Option { + let quote = quote?; + let from_token = "e.from_token; + if token_input.eq_ignore_ascii_case(&from_token.address) + || token_input.eq_ignore_ascii_case(&from_token.symbol) + { + Some(from_token.clone()) + } else { + None + } +} + +async fn resolve_approve_token_ref( + token_input: &str, + quote: Option<&Quote>, + chain_id: u64, + onchain: &OnChainClient, + api: &ApiClients, + config: &AppConfig, + store: &QuoteStore, +) -> Result { + if let Some(token_ref) = approve_token_ref_from_quote_or_input(quote, token_input) { + return Ok(token_ref); + } + resolve_token(token_input, chain_id, onchain, 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); @@ -917,6 +1149,80 @@ fn revoke_calldata(spender_addr: Address) -> String { format!("0x{}", hex::encode(&calldata)) } +fn build_dry_run_approval_result( + operation: ChainPilotOperation, + chain_id: u64, + from_address: String, + token: TokenRef, + spender_addr: Address, + amount_u256: alloy::primitives::U256, + raw_amount: String, + amount_usd: Option, +) -> Result { + 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 _from_addr: Address = from_address + .parse() + .map_err(|_| ChainError::InvalidAddress(from_address.clone()))?; + let token_addr: Address = token + .address + .parse() + .map_err(|_| ChainError::InvalidAddress(token.address.clone()))?; + let calldata = match operation { + ChainPilotOperation::Approve => approve_calldata(spender_addr, amount_u256), + ChainPilotOperation::Revoke => revoke_calldata(spender_addr), + ChainPilotOperation::SwapExecute => unreachable!("approval dry-run does not execute swaps"), + }; + + Ok(crate::models::swap::ApprovalResult { + token: token_addr.to_string(), + spender: spender_addr.to_string(), + raw_amount: raw_amount.clone(), + dry_run: true, + tx_hash: None, + from_address: Some(from_address.clone()), + source: Some("chainpilot".to_string()), + operation: Some(operation), + chain_id: Some(chain_id), + caip2: Some(format!("eip155:{chain_id}")), + r#from: Some(from_address), + transaction: Some(ChainPilotTransaction { + to: token_addr.to_string(), + value: "0x0".to_string(), + data: calldata, + chain_id, + gas: None, + max_fee_per_gas: None, + max_priority_fee_per_gas: None, + }), + quote: None, + risk: ChainPilotRisk { + token_in: Some(ChainPilotTokenAmount { + chain_id, + address: Some(token_addr.to_string()), + 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, @@ -938,6 +1244,14 @@ async fn send_approval_with_deps( dry_run: false, tx_hash: Some(tx_hash), from_address: Some(from_addr.to_string()), + source: None, + operation: None, + chain_id: None, + caip2: None, + r#from: None, + transaction: None, + quote: None, + risk: ChainPilotRisk::default(), }) } @@ -1006,14 +1320,14 @@ async fn execute_quote_with_deps( use std::time::Duration; let (from_address, tx_hash, status, gas_used, effective_gas_price_gwei) = if args.dry_run { - let addr = dry_run_from_address( + let addr = require_dry_run_from_address( crate::chain::resolve_signer(config) .ok() .map(|signer| signer.address().to_string()), args.wallet.clone(), config.wallet_address.clone(), - ); - (addr, None, ExecutionStatus::DryRun, None, None) + )?; + (Some(addr), None, ExecutionStatus::DryRun, None, None) } else { let signer = crate::chain::resolve_signer(config)?; let to_addr: Address = quote_data @@ -1110,9 +1424,38 @@ async fn execute_quote_with_deps( calldata: quote_data.calldata.clone(), to_contract: quote_data.router_to.clone(), value_eth: quote_data.value.clone(), - from_address, + from_address: from_address.clone(), gas_used, effective_gas_price_gwei, + source: None, + operation: None, + chain_id: None, + caip2: None, + r#from: None, + transaction: None, + quote: None, + risk: ChainPilotRisk::default(), + }) + .and_then(|mut result| { + if args.dry_run { + if let Some(from) = from_address.as_deref() { + let (from, tx, quote, risk) = build_dry_run_execute_payload( + quote_data, + from, + args.gas_limit, + args.max_fee_gwei, + )?; + result.source = Some("chainpilot".to_string()); + result.operation = Some(ChainPilotOperation::SwapExecute); + result.chain_id = Some(quote_data.chain_id); + result.caip2 = Some(format!("eip155:{}", quote_data.chain_id)); + result.r#from = Some(from); + result.transaction = Some(tx); + result.quote = Some(quote); + result.risk = risk; + } + } + Ok(result) }) } @@ -1190,9 +1533,10 @@ async fn approve( args: ApproveArgs, config: &AppConfig, store: &QuoteStore, + api: &ApiClients, output_mode: OutputMode, ) -> Result { - use crate::chain::{get_token_info, OnChainClient}; + use crate::chain::OnChainClient; use crate::models::swap::ApprovalResult; use alloy::primitives::{Address, U256}; @@ -1217,23 +1561,38 @@ async fn approve( 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()))?; - - // Get token decimals (for amount conversion): quote is authoritative, else on-chain. - let decimals: u8 = if let Some(q) = "e { - if q.from_token.address.to_lowercase() == token_str.to_lowercase() { - q.from_token.decimals - } else { - get_token_info(onchain, token_addr).await?.decimals + let token_ref = match resolve_approve_token_ref( + &token_str, + quote.as_ref(), + chain_id, + onchain, + api, + config, + store, + ) + .await + { + Ok(token_ref) => token_ref, + Err(e) => { + return Ok(crate::output::print_output::( + Err(e), + "swap.approve", + output_mode, + OutputContext::new(chain_id, args.dry_run), + )); } - } else { - get_token_info(onchain, token_addr).await?.decimals }; + let token_addr: Address = token_ref + .address + .parse() + .map_err(|_| ChainError::InvalidAddress(token_ref.address.clone()))?; + + // Token resolution already returns authoritative decimals for quote, + // tokenlist/custom-token, native-token, and address lookup paths. + let decimals: u8 = token_ref.decimals; // Compute raw approval amount. let (amount_u256, raw_amount_str) = match args.amount { @@ -1276,13 +1635,43 @@ async fn approve( }; if args.dry_run || signer.is_none() { - let result = ApprovalResult { - token: token_addr.to_string(), - spender: spender_addr.to_string(), - raw_amount: raw_amount_str, - dry_run: true, - tx_hash: None, - from_address: signer.as_ref().map(|signer| signer.address().to_string()), + let from_address = require_dry_run_from_address( + signer.as_ref().map(|signer| signer.address().to_string()), + None, + config.wallet_address.clone(), + ); + let from_address = match from_address { + Ok(from_address) => from_address, + Err(e) => { + return Ok(crate::output::print_output::( + Err(e), + "swap.approve", + output_mode, + OutputContext::new(chain_id, true), + )); + } + }; + let approval_amount_usd = + approval_amount_usd_from_quote(quote.as_ref(), &token_ref, &raw_amount_str); + let result = match build_dry_run_approval_result( + ChainPilotOperation::Approve, + chain_id, + from_address, + token_ref, + spender_addr, + amount_u256, + raw_amount_str, + approval_amount_usd, + ) { + Ok(result) => result, + Err(e) => { + return Ok(crate::output::print_output::( + Err(e), + "swap.approve", + output_mode, + OutputContext::new(chain_id, true), + )); + } }; let dry_run = result.dry_run; return Ok(crate::output::print_output::( @@ -1377,13 +1766,47 @@ async fn revoke(args: RevokeArgs, config: &AppConfig, output_mode: OutputMode) - }; if args.dry_run || signer.is_none() { - let result = ApprovalResult { - token: token_addr.to_string(), - spender: spender_addr.to_string(), - raw_amount: "0".to_string(), - dry_run: true, - tx_hash: None, - from_address: signer.as_ref().map(|signer| signer.address().to_string()), + let token_ref = TokenRef { + symbol: "UNKNOWN".to_string(), + address: token_addr.to_string(), + decimals: 0, + chain_id, + }; + let from_address = require_dry_run_from_address( + signer.as_ref().map(|signer| signer.address().to_string()), + None, + config.wallet_address.clone(), + ); + let from_address = match from_address { + Ok(from_address) => from_address, + Err(e) => { + return Ok(crate::output::print_output::( + Err(e), + "swap.revoke", + output_mode, + OutputContext::new(chain_id, true), + )); + } + }; + let result = match build_dry_run_approval_result( + ChainPilotOperation::Revoke, + chain_id, + from_address, + token_ref, + spender_addr, + alloy::primitives::U256::ZERO, + "0".to_string(), + None, + ) { + Ok(result) => result, + Err(e) => { + return Ok(crate::output::print_output::( + Err(e), + "swap.revoke", + output_mode, + OutputContext::new(chain_id, true), + )); + } }; let dry_run = result.dry_run; return Ok(crate::output::print_output::( @@ -1850,13 +2273,19 @@ mod tests { } #[test] - fn dry_run_from_address_prefers_private_key_then_subcommand_then_global_wallet() { + fn dry_run_from_address_prefers_explicit_wallets_then_private_key() { for (derived, subcommand, global, expected) in [ ( Some("0xpk".to_string()), Some("0xsub".to_string()), Some("0xglobal".to_string()), + Some("0xsub".to_string()), + ), + ( Some("0xpk".to_string()), + None, + Some("0xglobal".to_string()), + Some("0xglobal".to_string()), ), ( None, @@ -1876,6 +2305,12 @@ mod tests { } } + #[test] + fn require_dry_run_from_address_errors_without_sender() { + let err = require_dry_run_from_address(None, None, None).unwrap_err(); + assert!(matches!(err, ChainError::NoDryRunWallet)); + } + #[test] fn resolve_effective_gas_limit_obeys_precedence_rules() { struct Case { @@ -1986,6 +2421,128 @@ mod tests { assert!(result.tx_hash.is_none()); } + #[tokio::test] + async fn execute_quote_dry_run_requires_wallet_for_unsigned_payload() { + 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; + + let err = execute_quote_with_deps(&deps, &args, &test_config(1), &sample_quote(1)) + .await + .unwrap_err(); + + assert!(matches!(err, ChainError::NoDryRunWallet)); + } + + #[tokio::test] + async fn execute_quote_dry_run_builds_chainpilot_unsigned_transaction_contract() { + 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(8453); + quote.value = "1000000000000000".to_string(); + quote.raw_dodo_response = serde_json::json!({"resPricePerFromToken": 3000.0}); + + let result = execute_quote_with_deps(&deps, &args, &test_config(8453), "e) + .await + .unwrap(); + + assert_eq!(result.source.as_deref(), Some("chainpilot")); + assert_eq!(result.operation, Some(ChainPilotOperation::SwapExecute)); + assert_eq!(result.chain_id, Some(8453)); + assert_eq!(result.caip2.as_deref(), Some("eip155:8453")); + assert_eq!( + result.r#from.as_deref(), + Some("0x2222222222222222222222222222222222222222") + ); + let tx = result.transaction.as_ref().unwrap(); + assert_eq!(tx.to, quote.router_to); + assert_eq!(tx.value, "0x38d7ea4c68000"); + assert_eq!(tx.data, quote.calldata); + assert_eq!(tx.chain_id, 8453); + assert_eq!(tx.gas.as_deref(), Some("0x2bf20")); + assert_eq!( + result.quote.as_ref().unwrap().quote_id, + quote.quote_id.to_string() + ); + assert_eq!(result.quote.as_ref().unwrap().slippage_bps, Some(50)); + assert_eq!( + result.risk.router.as_deref(), + Some(quote.router_to.as_str()) + ); + assert_eq!( + result.risk.token_in.as_ref().unwrap().amount_usd.as_deref(), + Some("3000") + ); + let token_out = result.risk.token_out.as_ref().unwrap(); + assert_eq!(token_out.amount_display.as_deref(), Some("3000")); + assert_eq!(token_out.min_amount_raw.as_deref(), Some("2970000000")); + assert_eq!(token_out.min_amount_display.as_deref(), Some("2970")); + } + + #[tokio::test] + async fn execute_quote_dry_run_wallet_overrides_configured_signer() { + 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 config = test_config(8453); + config.private_key = + Some("0x59c6995e998f97a5a0044966f0945382dbf7f50a3f2f72f5f7a0b7d7d4f5e5f1".to_string()); + + let result = execute_quote_with_deps(&deps, &args, &config, &sample_quote(8453)) + .await + .unwrap(); + + assert_eq!( + result.r#from.as_deref(), + Some("0x2222222222222222222222222222222222222222") + ); + assert_eq!( + result.from_address.as_deref(), + Some("0x2222222222222222222222222222222222222222") + ); + } + + #[tokio::test] + async fn execute_quote_dry_run_applies_transaction_overrides_to_unsigned_payload() { + 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()); + args.gas_limit = Some(250_000); + args.max_fee_gwei = Some(25.0); + + let result = execute_quote_with_deps(&deps, &args, &test_config(8453), &sample_quote(8453)) + .await + .unwrap(); + + let tx = result.transaction.as_ref().unwrap(); + assert_eq!(tx.gas.as_deref(), Some("0x3d090")); + assert_eq!(tx.max_fee_per_gas.as_deref(), Some("0x5d21dba00")); + } + #[tokio::test] async fn execute_quote_rejects_invalid_router_before_sending() { let deps = MockExecuteDeps { @@ -2081,6 +2638,170 @@ mod tests { assert!(resolved.1.starts_with("0x")); } + #[tokio::test] + async fn 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 chain_client = OnChainClient::for_chain(&config, 8453).await.unwrap(); + + let token_ref = + resolve_approve_token_ref("USDC", None, 8453, &chain_client, &api, &config, &store) + .await + .unwrap(); + + assert_eq!(token_ref.symbol, "USDC"); + assert_eq!( + token_ref.address, + "0x2222222222222222222222222222222222222222" + ); + assert_eq!(token_ref.decimals, 6); + } + + #[test] + fn approval_dry_run_builds_chainpilot_unsigned_transaction_contract() { + let token: Address = "0x2222222222222222222222222222222222222222" + .parse() + .unwrap(); + let spender: Address = "0x3333333333333333333333333333333333333333" + .parse() + .unwrap(); + let token_ref = TokenRef { + symbol: "TEST".to_string(), + address: token.to_string(), + decimals: 6, + chain_id: 8453, + }; + + let result = build_dry_run_approval_result( + ChainPilotOperation::Approve, + 8453, + "0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A".to_string(), + token_ref.clone(), + spender, + alloy::primitives::U256::from(42u64), + "42".to_string(), + Some("123.45".to_string()), + ) + .unwrap(); + + assert_eq!(result.source.as_deref(), Some("chainpilot")); + assert_eq!(result.operation, Some(ChainPilotOperation::Approve)); + assert_eq!(result.chain_id, Some(8453)); + assert_eq!(result.caip2.as_deref(), Some("eip155:8453")); + assert_eq!( + result.r#from.as_deref(), + Some("0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A") + ); + assert_eq!( + result.from_address.as_deref(), + Some("0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A") + ); + let tx = result.transaction.as_ref().unwrap(); + assert_eq!(tx.to, token.to_string()); + assert_eq!(tx.value, "0x0"); + assert_eq!(tx.chain_id, 8453); + assert!(tx.data.starts_with("0x095ea7b3")); + assert_eq!( + result.risk.spender.as_deref(), + Some(spender.to_string().as_str()) + ); + assert_eq!( + result.risk.token_in.as_ref().unwrap().amount_usd.as_deref(), + Some("123.45") + ); + + let revoke = build_dry_run_approval_result( + ChainPilotOperation::Revoke, + 8453, + "0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A".to_string(), + token_ref, + spender, + alloy::primitives::U256::ZERO, + "0".to_string(), + None, + ) + .unwrap(); + assert_eq!(revoke.operation, Some(ChainPilotOperation::Revoke)); + assert_eq!( + revoke.r#from.as_deref(), + Some("0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A") + ); + assert_eq!( + revoke.from_address.as_deref(), + Some("0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A") + ); + assert!(revoke + .transaction + .as_ref() + .unwrap() + .data + .ends_with(&"0".repeat(64))); + } + + #[test] + fn approval_dry_run_rejects_native_token_payload() { + let spender: Address = "0x3333333333333333333333333333333333333333" + .parse() + .unwrap(); + + let err = build_dry_run_approval_result( + ChainPilotOperation::Approve, + 8453, + "0x49Ca8a959a78E926a928Eb0c1c05679a94985a2A".to_string(), + 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 approval_amount_usd_from_quote_uses_actual_matching_approval_amount() { + let mut quote = sample_quote_for_approve(8453); + quote.from_amount_display = 100.0; + quote.raw_dodo_response = serde_json::json!({"resPricePerFromToken": 2.5}); + let token = quote.from_token.clone(); + let raw_amount = crate::commands::to_raw_amount("3", token.decimals).unwrap(); + + assert_eq!( + approval_amount_usd_from_quote(Some("e), &token, &raw_amount).as_deref(), + Some("7.5") + ); + } + + #[test] + fn approval_amount_usd_from_quote_omits_unavailable_or_mismatched_amounts() { + let mut quote = sample_quote_for_approve(8453); + quote.raw_dodo_response = serde_json::json!({"resPricePerFromToken": 2.5}); + let mut other_token = quote.from_token.clone(); + other_token.address = "0x2222222222222222222222222222222222222222".to_string(); + + assert_eq!( + approval_amount_usd_from_quote(Some("e), &other_token, "1000000"), + None + ); + assert_eq!( + approval_amount_usd_from_quote(Some("e), "e.from_token, "unlimited"), + None + ); + } + #[test] fn calldata_builders_encode_expected_selector() { let spender: Address = "0x1111111111111111111111111111111111111111" diff --git a/src/error.rs b/src/error.rs index 7003d07..9bf4a06 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,6 +19,9 @@ pub enum ChainError { #[error("No wallet configured. Set PRIVATE_KEY/KEYSTORE_PATH env vars or use --private-key/--keystore-path")] NoWallet, + #[error("No dry-run wallet configured. Use --wallet, --wallet-address/WALLET_ADDRESS, or configure PRIVATE_KEY/KEYSTORE_PATH to derive the sender")] + NoDryRunWallet, + #[error("Invalid private key: {0}")] InvalidPrivateKey(String), @@ -99,6 +102,15 @@ mod tests { assert!(e.to_string().contains("--keystore-path")); } + #[test] + fn no_dry_run_wallet_error_message() { + let e = ChainError::NoDryRunWallet; + assert!(e.to_string().contains("--wallet")); + assert!(e.to_string().contains("--wallet-address")); + assert!(e.to_string().contains("WALLET_ADDRESS")); + assert!(e.to_string().contains("PRIVATE_KEY")); + } + #[test] fn insufficient_balance_error_message() { let e = ChainError::InsufficientBalance { diff --git a/src/models/swap.rs b/src/models/swap.rs index e4349e9..dbb0f5e 100644 --- a/src/models/swap.rs +++ b/src/models/swap.rs @@ -38,6 +38,22 @@ pub struct ExecutionResult { pub from_address: Option, pub gas_used: Option, pub effective_gas_price_gwei: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chain_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caip2: Option, + #[serde(rename = "from", skip_serializing_if = "Option::is_none")] + pub r#from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote: Option, + #[serde(default, skip_serializing_if = "ChainPilotRisk::is_empty")] + pub risk: ChainPilotRisk, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -77,6 +93,97 @@ pub struct ApprovalResult { pub dry_run: bool, pub tx_hash: Option, pub from_address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub operation: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub chain_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub caip2: Option, + #[serde(rename = "from", skip_serializing_if = "Option::is_none")] + pub r#from: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub transaction: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub quote: Option, + #[serde(default, skip_serializing_if = "ChainPilotRisk::is_empty")] + pub risk: ChainPilotRisk, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ChainPilotOperation { + SwapExecute, + Approve, + Revoke, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChainPilotTransaction { + 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 ChainPilotQuote { + #[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 ChainPilotTokenAmount { + 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 ChainPilotRisk { + #[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, +} + +impl ChainPilotRisk { + pub fn is_empty(&self) -> bool { + self.token_in.is_none() + && self.token_out.is_none() + && self.spender.is_none() + && self.router.is_none() + && self.estimated_gas_usd.is_none() + } } impl SwapHistoryRecord { @@ -203,6 +310,14 @@ mod tests { from_address: Some("0xWallet".to_string()), gas_used: Some(120_000), effective_gas_price_gwei: Some(20.0), + source: None, + operation: None, + chain_id: None, + caip2: None, + r#from: None, + transaction: None, + quote: None, + risk: ChainPilotRisk::default(), } } diff --git a/src/output/json.rs b/src/output/json.rs index b620f45..7fec533 100644 --- a/src/output/json.rs +++ b/src/output/json.rs @@ -70,11 +70,16 @@ fn error_code_and_suggestion(err: &ChainError) -> (String, Option) { .to_string(), ), ), - ChainError::NoWallet => ( + ChainError::NoWallet | ChainError::NoDryRunWallet => ( "no_wallet".to_string(), Some( - "Set PRIVATE_KEY or KEYSTORE_PATH, or use --private-key / --keystore-path." - .to_string(), + match err { + ChainError::NoDryRunWallet => { + "Use --wallet, --wallet-address/WALLET_ADDRESS, or configure PRIVATE_KEY/KEYSTORE_PATH." + } + _ => "Set PRIVATE_KEY or KEYSTORE_PATH, or use --private-key / --keystore-path.", + } + .to_string(), ), ), ChainError::InvalidAmount(_) => ( @@ -140,6 +145,11 @@ mod tests { "invalid_amount", Some("plain decimal"), ), + ( + ChainError::NoDryRunWallet, + "no_wallet", + Some("--wallet-address"), + ), ( ChainError::InsufficientBalance { have: "1".to_string(), diff --git a/src/store/quote.rs b/src/store/quote.rs index 369fb99..c8b0a1c 100644 --- a/src/store/quote.rs +++ b/src/store/quote.rs @@ -182,7 +182,9 @@ mod tests { use super::*; use crate::config::AppConfig; use crate::models::quote::{Quote, TokenRef}; - use crate::models::swap::{ExecutionResult, ExecutionStatus, SwapHistoryRecord}; + use crate::models::swap::{ + ChainPilotRisk, ExecutionResult, ExecutionStatus, SwapHistoryRecord, + }; use crate::models::token::TokenInfo; /// Build a QuoteStore backed by a unique temp directory. @@ -386,6 +388,14 @@ mod tests { from_address: Some("0xWallet".to_string()), gas_used: Some(120_000), effective_gas_price_gwei: Some(20.0), + source: None, + operation: None, + chain_id: None, + caip2: None, + r#from: None, + transaction: None, + quote: None, + risk: ChainPilotRisk::default(), }; let rec = SwapHistoryRecord::from_execution(&q, &execution, "hist-1".to_string()); @@ -416,6 +426,14 @@ mod tests { from_address: None, gas_used: None, effective_gas_price_gwei: None, + source: None, + operation: None, + chain_id: None, + caip2: None, + r#from: None, + transaction: None, + quote: None, + risk: ChainPilotRisk::default(), }; let rec = SwapHistoryRecord::from_execution(&q, &execution, Uuid::new_v4().to_string()); store.save_history(&rec).unwrap();