From 0da05415e27b47a2c749e1032dcea8d25157baf9 Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 20 Apr 2026 22:15:05 -0500 Subject: [PATCH 1/6] *Improves tx waiting, fees, and compatibility *Clarifies transaction lifecycle handling so sends can return on submission by default or wait for inclusion or finalization, with more accurate status reporting and safer post-submit behavior. *Improves transfer safety by parsing decimal amounts exactly, estimating fees before send and batch operations, and checking balances against amount, tip, and fee needs to avoid misleading success paths. *Tightens compatibility checks to require matching runtime and transaction versions, updates regeneration guidance and usage docs, and removes wallet debug output while rejecting duplicate developer wallet creation. --- LIBRARY_USAGE.md | 51 +++--- README.md | 30 +++- regenerate_metadata.sh | 6 +- src/cli/batch.rs | 97 +++++++++-- src/cli/common.rs | 369 +++++++++++++++++++++++++++++++-------- src/cli/mod.rs | 115 ++++++++----- src/cli/send.rs | 380 ++++++++++++++++++++++++++++++++--------- src/config/mod.rs | 25 ++- src/main.rs | 7 +- src/wallet/mod.rs | 81 ++++++--- 10 files changed, 883 insertions(+), 278 deletions(-) diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index 4df3086..e3b304b 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -8,7 +8,7 @@ This document explains how to use `quantus-cli` as a library in your Rust applic [dependencies] quantus-cli = { path = "." } # For local development # or -quantus-cli = "0.1.0" # When published to crates.io +quantus-cli = "1.3.3" # Current published version ``` ## Basic Usage @@ -158,7 +158,7 @@ async fn query_balance() -> Result<(), Box> { let storage_addr = api::storage().system().account(subxt_account_id); let account_info = client.client().storage().at(None).fetch_or_default(&storage_addr).await?; - println!("Balance: {} DEV", account_info.data.free); + println!("Balance (raw units): {}", account_info.data.free); Ok(()) } @@ -169,8 +169,9 @@ async fn query_balance() -> Result<(), Box> { ```rust use quantus_cli::{ chain::client::QuantusClient, + cli::common::ExecutionMode, + transfer, wallet::WalletManager, - AccountId32, }; async fn send_transaction() -> Result<(), Box> { @@ -181,31 +182,22 @@ async fn send_transaction() -> Result<(), Box> { let wallet_data = wallet_manager.load_wallet("my_wallet", "password")?; let keypair = wallet_data.keypair; - // Parse recipient address + // Recipient address let to_address = "qzkeicNBtW2AG2E7USjDcLzAL8d9WxTZnV2cbtXoDzWxzpHC2"; - let to_account_id = AccountId32::from_ss58check(to_address)?; - - // Create transfer call - use quantus_cli::chain::quantus_subxt::api; - use subxt::tx::TxClient; - - let to_account_bytes: [u8; 32] = *to_account_id.as_ref(); - let to_subxt_account_id = subxt::utils::AccountId32::from(to_account_bytes); - - let transfer_call = api::tx().balances().transfer( - to_subxt_account_id.into(), - 1000000000000, // 1 DEV - ); - - // Submit transaction - let tx_hash = client - .client() - .tx() - .sign_and_submit_then_watch_default(&transfer_call, &keypair) - .await? - .wait_for_finalized_success() - .await? - .extrinsic_hash(); + + // Submit and wait for inclusion in a best block + let tx_hash = transfer( + &client, + &keypair, + to_address, + 1_000_000_000_000, // raw units, e.g. 1 token on a 12-decimal chain + None, + ExecutionMode { + wait_for_transaction: true, + finalized: false, + }, + ) + .await?; println!("Transaction hash: {:?}", tx_hash); @@ -213,6 +205,11 @@ async fn send_transaction() -> Result<(), Box> { } ``` +`ExecutionMode` semantics: +- `wait_for_transaction = false`: return after submission (`submitted`) +- `wait_for_transaction = true`: return after best-block inclusion (`included`) +- `finalized = true`: return after finalization (`finalized`); this implies waiting + ### Service Architecture For web services or applications that need to manage multiple wallets: diff --git a/README.md b/README.md index 2a98b9e..3d4449a 100644 --- a/README.md +++ b/README.md @@ -367,9 +367,15 @@ quantus wallet export --name my_wallet --format mnemonic ### Sending Tokens ```bash -# Simple transfer +# Simple transfer (default: submit and return once the node accepts the extrinsic) quantus send --from crystal_alice --to
--amount 10.5 +# Wait for inclusion in a best block +quantus send --from crystal_alice --to
--amount 10.5 --wait-for-transaction + +# Wait for finalization (implies --wait-for-transaction) +quantus send --from crystal_alice --to
--amount 10.5 --finalized-tx + # With tip for priority quantus send --from crystal_alice --to
--amount 10 --tip 0.1 @@ -377,12 +383,19 @@ quantus send --from crystal_alice --to
--amount 10 --tip 0.1 quantus send --from crystal_alice --to
--amount 10 --nonce 42 ``` +Transaction status terms: +- `submitted`: accepted by the node, but not yet known to be in a block +- `included`: observed in a best block +- `finalized`: observed in a finalized block + +`--amount` and `--tip` use exact decimal parsing based on the chain's configured decimals. Malformed values, negative values, over-precision, or values that would round to zero are rejected. + --- ### Batch Transfers ```bash -# From a JSON file +# From a JSON file (amounts are raw smallest-unit integers) quantus batch send --from crystal_alice --batch-file transfers.json # Generate identical test transfers @@ -509,7 +522,7 @@ quantus call \ | `quantus system --runtime` | Runtime version details | | `quantus metadata --pallet Balances` | Explore chain metadata | | `quantus version` | CLI version | -| `quantus compatibility-check` | Check CLI/node compatibility | +| `quantus compatibility-check` | Check CLI/node spec-version and transaction-version compatibility | --- @@ -820,12 +833,14 @@ The project includes a script to regenerate SubXT types and metadata when the bl 1. **Updates metadata**: Downloads the latest chain metadata to `src/quantus_metadata.scale` 2. **Generates types**: Creates type-safe Rust code in `src/chain/quantus_subxt.rs` 3. **Formats code**: Automatically formats the generated code with `cargo fmt` +4. **Prompts compatibility update**: Reminds you to update the supported runtime/transaction pair in `src/config/mod.rs` **When to use:** - After updating the Quantus runtime - When new pallets are added to the chain - When existing pallet APIs change - To ensure CLI compatibility with the latest chain version +- Before updating the `quantus compatibility-check` allowlist **Requirements:** - `subxt-cli` must be installed: `cargo install subxt-cli` @@ -849,7 +864,14 @@ Using node URL: ws://127.0.0.1:9944 Updating metadata file at src/quantus_metadata.scale... Generating SubXT types to src/chain/quantus_subxt.rs... Formatting generated code... +Reminder: update src/config/mod.rs with the new compatible spec/transaction version pair. Done! ``` -This ensures the CLI always has the latest type definitions and can interact with new chain features. +After regeneration, re-run: + +```bash +quantus compatibility-check --node-url +``` + +The checked-in compatibility gate now requires both the runtime `spec_version` and `transaction_version` to match a supported pair. diff --git a/regenerate_metadata.sh b/regenerate_metadata.sh index edd54f9..520da68 100755 --- a/regenerate_metadata.sh +++ b/regenerate_metadata.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -euo pipefail # Default node URL NODE_URL="ws://127.0.0.1:9944" @@ -32,6 +33,7 @@ echo "Generating SubXT types to src/chain/quantus_subxt.rs..." subxt codegen --url "$NODE_URL" > src/chain/quantus_subxt.rs echo "Formatting generated code..." -cargo +nightly fmt -- src/chain/quantus_subxt.rs +cargo fmt -- src/chain/quantus_subxt.rs -echo "Done!" \ No newline at end of file +echo "Reminder: update src/config/mod.rs with the new compatible spec/transaction version pair." +echo "Done!" diff --git a/src/cli/batch.rs b/src/cli/batch.rs index b1b84c8..0146167 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -30,7 +30,8 @@ pub enum BatchCommands { #[arg(long)] tip: Option, - /// Batch file with transfers (JSON format: [{"to": "address", "amount": "1000"}, ...]) + /// Batch file with transfers (JSON format: [{"to": "address", "amount": "1000"}, ...]; + /// amounts are raw smallest-unit integers) #[arg(long)] batch_file: Option, @@ -75,7 +76,7 @@ pub async fn handle_batch_command( count, to, amount, - } => + } => { handle_batch_send_command( from, node_url, @@ -88,9 +89,11 @@ pub async fn handle_batch_command( amount, execution_mode, ) - .await, - BatchCommands::Config { limits, info } => - handle_batch_config_command(node_url, limits, info).await, + .await + }, + BatchCommands::Config { limits, info } => { + handle_batch_config_command(node_url, limits, info).await + }, } } @@ -145,36 +148,98 @@ async fn handle_batch_send_command( // Load wallet let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; let from_account_id = keypair.to_account_id_ss58check(); + let effective_tip = crate::cli::send::effective_tip_amount(tip_amount); // Check balance let balance = crate::cli::send::get_balance(&quantus_client, &from_account_id).await?; - let total_amount: u128 = transfers.iter().map(|(_, amount)| amount).sum(); - let estimated_fee = 50_000_000_000u128; // Rough estimate for batch + let total_amount = transfers.iter().try_fold(0u128, |acc, (_, amount)| { + acc.checked_add(*amount).ok_or_else(|| { + crate::error::QuantusError::Generic( + "Batch amount total is too large to represent".to_string(), + ) + }) + })?; + let exact_required = total_amount.checked_add(effective_tip).ok_or_else(|| { + crate::error::QuantusError::Generic( + "Batch amount total is too large to represent".to_string(), + ) + })?; - if balance < total_amount + estimated_fee { + if balance < exact_required { let formatted_balance = crate::cli::send::format_balance_with_symbol(&quantus_client, balance).await?; - let formatted_needed = crate::cli::send::format_balance_with_symbol( - &quantus_client, - total_amount + estimated_fee, - ) - .await?; + let formatted_needed = + crate::cli::send::format_balance_with_symbol(&quantus_client, exact_required).await?; return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (including estimated fees)" + "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (including tip)" ))); } + let batch_call = crate::cli::send::build_batch_transfer_call(&transfers)?; + match crate::cli::send::estimate_transaction_partial_fee( + &quantus_client, + &keypair, + &batch_call, + Some(effective_tip), + ) + .await + { + Ok(estimated_fee) => { + let estimated_total = exact_required.checked_add(estimated_fee).ok_or_else(|| { + crate::error::QuantusError::Generic( + "Batch amount total is too large to represent".to_string(), + ) + })?; + if balance < estimated_total { + let formatted_balance = + crate::cli::send::format_balance_with_symbol(&quantus_client, balance).await?; + let formatted_needed = + crate::cli::send::format_balance_with_symbol(&quantus_client, estimated_total) + .await?; + let formatted_tip = + crate::cli::send::format_balance_with_symbol(&quantus_client, effective_tip) + .await?; + let formatted_fee = + crate::cli::send::format_balance_with_symbol(&quantus_client, estimated_fee) + .await?; + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + ))); + } + }, + Err(err) => { + log_print!( + "â„šī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", + err + ); + }, + } + // Submit batch transaction let tx_hash = batch_transfer(&quantus_client, &keypair, transfers, tip_amount, execution_mode).await?; + let transaction_stage = execution_mode.transaction_stage(); log_print!( - "✅ {} Batch transaction submitted! Hash: {:?}", + "✅ {} Batch transaction {}. Hash: {:?}", "SUCCESS".bright_green().bold(), + transaction_stage.status_label(), tx_hash ); - log_success!("🎉 {} Batch transaction confirmed!", "FINISHED".bright_green().bold()); + if !execution_mode.should_refresh_post_submit_state() { + log_print!( + "â„šī¸ The batch transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized-tx to wait before returning.", + transaction_stage.success_detail() + ); + return Ok(()); + } + + log_success!( + "🎉 {} Batch transaction {}.", + "FINISHED".bright_green().bold(), + transaction_stage.success_detail() + ); // Show updated balance let new_balance = crate::cli::send::get_balance(&quantus_client, &from_account_id).await?; diff --git a/src/cli/common.rs b/src/cli/common.rs index 84c2c3c..82d0392 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -8,12 +8,113 @@ use subxt::{ OnlineClient, }; -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ExecutionMode { pub finalized: bool, pub wait_for_transaction: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransactionStage { + Submitted, + Included, + Finalized, +} + +impl ExecutionMode { + pub fn transaction_stage(self) -> TransactionStage { + if self.finalized { + TransactionStage::Finalized + } else if self.wait_for_transaction { + TransactionStage::Included + } else { + TransactionStage::Submitted + } + } + + pub fn should_watch_transaction(self) -> bool { + self.transaction_stage() != TransactionStage::Submitted + } + + pub fn should_refresh_post_submit_state(self) -> bool { + self.should_watch_transaction() + } +} + +impl TransactionStage { + pub fn status_label(self) -> &'static str { + match self { + Self::Submitted => "submitted", + Self::Included => "included", + Self::Finalized => "finalized", + } + } + + pub fn success_detail(self) -> &'static str { + match self { + Self::Submitted => "accepted by the node", + Self::Included => "included in a best block", + Self::Finalized => "finalized in a block", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WatchedTxEvent { + Validated, + Broadcasted, + NoLongerInBestBlock, + InBestBlock, + InFinalizedBlock, + Error(String), + Invalid(String), + Dropped(String), + StreamError(String), + StreamEnded, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WatchDecision { + Continue, + WaitForFinalization, + Success, +} + +fn describe_watched_tx_event( + event: WatchedTxEvent, + target_stage: TransactionStage, +) -> Result { + match event { + WatchedTxEvent::Validated + | WatchedTxEvent::Broadcasted + | WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), + WatchedTxEvent::InBestBlock => { + if target_stage == TransactionStage::Finalized { + Ok(WatchDecision::WaitForFinalization) + } else { + Ok(WatchDecision::Success) + } + }, + WatchedTxEvent::InFinalizedBlock => Ok(WatchDecision::Success), + WatchedTxEvent::Error(message) => { + Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))) + }, + WatchedTxEvent::Invalid(message) => { + Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))) + }, + WatchedTxEvent::Dropped(message) => { + Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))) + }, + WatchedTxEvent::StreamError(message) => Err(crate::error::QuantusError::NetworkError( + format!("Transaction status stream error: {message}"), + )), + WatchedTxEvent::StreamEnded => Err(crate::error::QuantusError::NetworkError(format!( + "Transaction status stream ended before the transaction was {}", + target_stage.status_label() + ))), + } +} + /// Resolve address - if it's a wallet name, return the wallet's address /// If it's already an SS58 address, return it as is pub fn resolve_address(address_or_wallet_name: &str) -> Result { @@ -122,9 +223,9 @@ pub async fn get_incremented_nonce_with_client( /// Submit transaction with optional finalization check /// -/// By default (finalized=false), waits until transaction is in the best block (fast) -/// With finalized=true, waits until transaction is in a finalized block (slow in PoW chains) -/// With wait_for_transaction=false, returns immediately after submission without waiting +/// By default, returns immediately after the node accepts the transaction submission. +/// With `wait_for_transaction=true`, waits until the transaction is in a best block. +/// With `finalized=true`, waits until the transaction is in a finalized block. pub async fn submit_transaction( quantus_client: &crate::chain::client::QuantusClient, from_keypair: &crate::wallet::QuantumKeyPair, @@ -181,7 +282,7 @@ where params_builder = params_builder.tip(tip_amount); log_verbose!("💰 Using tip: {} to increase priority", tip_amount); } else { - log_verbose!("💰 No tip specified, using default priority"); + log_verbose!("💰 No tip specified"); } // Try to get chain parameters from the client @@ -219,7 +320,7 @@ where crate::log_verbose!("📝 Encoded call: 0x{}", hex::encode(&encoded_call)); crate::log_print!("📝 Encoded call size: {} bytes", encoded_call.len()); - if execution_mode.wait_for_transaction { + if execution_mode.should_watch_transaction() { match quantus_client .client() .tx() @@ -231,15 +332,11 @@ where let tx_hash = tx_progress.extrinsic_hash(); - if !execution_mode.wait_for_transaction { - return Ok(tx_hash); - } - wait_tx_inclusion( &mut tx_progress, quantus_client.client(), &tx_hash, - execution_mode.finalized, + execution_mode.transaction_stage(), ) .await?; @@ -249,11 +346,11 @@ where let error_msg = format!("{e:?}"); // Check if it's a retryable error - let is_retryable = error_msg.contains("Priority is too low") || - error_msg.contains("Transaction is outdated") || - error_msg.contains("Transaction is temporarily banned") || - error_msg.contains("Transaction has a bad signature") || - error_msg.contains("Invalid Transaction"); + let is_retryable = error_msg.contains("Priority is too low") + || error_msg.contains("Transaction is outdated") + || error_msg.contains("Transaction is temporarily banned") + || error_msg.contains("Transaction has a bad signature") + || error_msg.contains("Invalid Transaction"); if is_retryable && attempt < 5 { log_verbose!( @@ -331,10 +428,8 @@ where log_verbose!("đŸ”ĸ Using manual nonce: {}", nonce); log_verbose!("📤 Submitting transaction with manual nonce..."); - crate::log_print!("submit with wait for transaction: {}", execution_mode.wait_for_transaction); // Submit the transaction with manual nonce - - if execution_mode.wait_for_transaction { + if execution_mode.should_watch_transaction() { match quantus_client .client() .tx() @@ -348,7 +443,7 @@ where &mut tx_progress, quantus_client.client(), &tx_hash, - execution_mode.finalized, + execution_mode.transaction_stage(), ) .await?; Ok(tx_hash) @@ -385,7 +480,7 @@ async fn wait_tx_inclusion( tx_progress: &mut TxProgress>, client: &OnlineClient, tx_hash: &subxt::utils::H256, - finalized: bool, + target_stage: TransactionStage, ) -> Result<()> { use indicatif::{ProgressBar, ProgressStyle}; @@ -400,7 +495,7 @@ async fn wait_tx_inclusion( .unwrap(), ); - if finalized { + if target_stage == TransactionStage::Finalized { pb.set_message("Waiting for finalized block... (0s)"); } else { pb.set_message("Waiting for block inclusion... (0s)"); @@ -412,59 +507,107 @@ async fn wait_tx_inclusion( None }; - while let Some(Ok(status)) = tx_progress.next().await { + loop { let elapsed_secs = start_time.elapsed().as_secs(); - crate::log_verbose!(" Transaction status: {:?} (elapsed: {}s)", status, elapsed_secs); - - match status { - TxStatus::Validated => - if let Some(ref pb) = spinner { - pb.set_message(format!("Transaction validated ✓ ({}s)", elapsed_secs)); - }, - TxStatus::InBestBlock(tx_in_block) => { - let block_hash = tx_in_block.block_hash(); - crate::log_verbose!(" Transaction included in block: {:?}", block_hash); - check_execution_success(client, &block_hash, tx_hash).await?; - if finalized { - if let Some(ref pb) = spinner { - pb.set_message(format!( - "In best block, waiting for finalization... ({}s)", - elapsed_secs - )); - } - continue; - } else { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "✅ Transaction included in block! ({}s)", - elapsed_secs - )); - } - break; - }; - }, - TxStatus::InFinalizedBlock(tx_in_block) => { - let block_hash = tx_in_block.block_hash(); - crate::log_verbose!(" Transaction finalized in block: {:?}", block_hash); - check_execution_success(client, &block_hash, tx_hash).await?; - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "✅ Transaction finalized! ({}s)", - elapsed_secs - )); - } - break; - }, - TxStatus::Error { message } | TxStatus::Invalid { message } => { - crate::log_error!(" Transaction error: {} (elapsed: {}s)", message, elapsed_secs); - if let Some(pb) = spinner { - pb.finish_with_message(format!("❌ Transaction error! ({}s)", elapsed_secs)); + let next_event = match tx_progress.next().await { + Some(Ok(status)) => { + crate::log_verbose!( + " Transaction status: {:?} (elapsed: {}s)", + status, + elapsed_secs + ); + + match status { + TxStatus::Validated => { + if let Some(ref pb) = spinner { + pb.set_message(format!("Transaction validated ✓ ({}s)", elapsed_secs)); + } + WatchedTxEvent::Validated + }, + TxStatus::Broadcasted => WatchedTxEvent::Broadcasted, + TxStatus::NoLongerInBestBlock => WatchedTxEvent::NoLongerInBestBlock, + TxStatus::InBestBlock(tx_in_block) => { + let block_hash = tx_in_block.block_hash(); + crate::log_verbose!(" Transaction included in block: {:?}", block_hash); + if let Err(err) = + check_execution_success(client, &block_hash, tx_hash).await + { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "❌ Transaction failed in block ({}s)", + elapsed_secs + )); + } + return Err(err); + } + match describe_watched_tx_event(WatchedTxEvent::InBestBlock, target_stage)? + { + WatchDecision::WaitForFinalization => { + if let Some(ref pb) = spinner { + pb.set_message(format!( + "In best block, waiting for finalization... ({}s)", + elapsed_secs + )); + } + continue; + }, + WatchDecision::Success => { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "✅ Transaction included in block! ({}s)", + elapsed_secs + )); + } + return Ok(()); + }, + WatchDecision::Continue => continue, + } + }, + TxStatus::InFinalizedBlock(tx_in_block) => { + let block_hash = tx_in_block.block_hash(); + crate::log_verbose!(" Transaction finalized in block: {:?}", block_hash); + if let Err(err) = + check_execution_success(client, &block_hash, tx_hash).await + { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "❌ Transaction failed in finalized block ({}s)", + elapsed_secs + )); + } + return Err(err); + } + match describe_watched_tx_event( + WatchedTxEvent::InFinalizedBlock, + target_stage, + )? { + WatchDecision::Success => { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "✅ Transaction finalized! ({}s)", + elapsed_secs + )); + } + return Ok(()); + }, + WatchDecision::Continue | WatchDecision::WaitForFinalization => { + continue + }, + } + }, + TxStatus::Error { message } => WatchedTxEvent::Error(message), + TxStatus::Invalid { message } => WatchedTxEvent::Invalid(message), + TxStatus::Dropped { message } => WatchedTxEvent::Dropped(message), } - break; }, - _ => { + Some(Err(err)) => WatchedTxEvent::StreamError(err.to_string()), + None => WatchedTxEvent::StreamEnded, + }; + + match describe_watched_tx_event(next_event, target_stage) { + Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => { if let Some(ref pb) = spinner { - if finalized { + if target_stage == TransactionStage::Finalized { pb.set_message(format!( "Waiting for finalized block... ({}s)", elapsed_secs @@ -476,12 +619,17 @@ async fn wait_tx_inclusion( )); } } - continue; + }, + Ok(WatchDecision::Success) => return Ok(()), + Err(err) => { + crate::log_error!(" {} (elapsed: {}s)", err, elapsed_secs); + if let Some(pb) = spinner { + pb.finish_with_message(format!("❌ Transaction error! ({}s)", elapsed_secs)); + } + return Err(err); }, } } - - Ok(()) } fn format_dispatch_error( @@ -604,3 +752,76 @@ async fn check_execution_success( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finalized_mode_implies_waiting_for_finalization() { + let mode = ExecutionMode { finalized: true, wait_for_transaction: false }; + + assert_eq!(mode.transaction_stage(), TransactionStage::Finalized); + assert!(mode.should_watch_transaction()); + assert!(mode.should_refresh_post_submit_state()); + } + + #[test] + fn default_mode_is_submission_only() { + let mode = ExecutionMode::default(); + + assert_eq!(mode.transaction_stage(), TransactionStage::Submitted); + assert!(!mode.should_watch_transaction()); + assert!(!mode.should_refresh_post_submit_state()); + } + + #[test] + fn watched_failures_are_terminal_errors() { + assert!(describe_watched_tx_event( + WatchedTxEvent::Error("boom".to_string()), + TransactionStage::Included, + ) + .is_err()); + assert!(describe_watched_tx_event( + WatchedTxEvent::Invalid("bad nonce".to_string()), + TransactionStage::Included, + ) + .is_err()); + assert!(describe_watched_tx_event( + WatchedTxEvent::Dropped("dropped".to_string()), + TransactionStage::Included, + ) + .is_err()); + assert!(describe_watched_tx_event( + WatchedTxEvent::StreamError("rpc failed".to_string()), + TransactionStage::Included, + ) + .is_err()); + assert!( + describe_watched_tx_event(WatchedTxEvent::StreamEnded, TransactionStage::Included,) + .is_err() + ); + } + + #[test] + fn inclusion_and_finalization_have_distinct_success_states() { + assert_eq!( + describe_watched_tx_event(WatchedTxEvent::InBestBlock, TransactionStage::Included) + .unwrap(), + WatchDecision::Success + ); + assert_eq!( + describe_watched_tx_event(WatchedTxEvent::InBestBlock, TransactionStage::Finalized) + .unwrap(), + WatchDecision::WaitForFinalization + ); + assert_eq!( + describe_watched_tx_event( + WatchedTxEvent::InFinalizedBlock, + TransactionStage::Finalized, + ) + .unwrap(), + WatchDecision::Success + ); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f2b2aa3..a5379ae 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -309,7 +309,7 @@ pub async fn execute_command( ) -> crate::error::Result<()> { match command { Commands::Wallet(wallet_cmd) => wallet::handle_wallet_command(wallet_cmd, node_url).await, - Commands::Send { from, to, amount, password, password_file, tip, nonce } => + Commands::Send { from, to, amount, password, password_file, tip, nonce } => { send::handle_send_command( from, to, @@ -321,45 +321,60 @@ pub async fn execute_command( nonce, execution_mode, ) - .await, - Commands::Batch(batch_cmd) => - batch::handle_batch_command(batch_cmd, node_url, execution_mode).await, - Commands::Reversible(reversible_cmd) => - reversible::handle_reversible_command(reversible_cmd, node_url, execution_mode).await, - Commands::HighSecurity(hs_cmd) => - high_security::handle_high_security_command(hs_cmd, node_url, execution_mode).await, - Commands::Recovery(recovery_cmd) => - recovery::handle_recovery_command(recovery_cmd, node_url, execution_mode).await, - Commands::Multisig(multisig_cmd) => - multisig::handle_multisig_command(multisig_cmd, node_url, execution_mode).await, - Commands::Scheduler(scheduler_cmd) => - scheduler::handle_scheduler_command(scheduler_cmd, node_url, execution_mode).await, - Commands::Storage(storage_cmd) => - storage::handle_storage_command(storage_cmd, node_url, execution_mode).await, - Commands::TechCollective(tech_collective_cmd) => + .await + }, + Commands::Batch(batch_cmd) => { + batch::handle_batch_command(batch_cmd, node_url, execution_mode).await + }, + Commands::Reversible(reversible_cmd) => { + reversible::handle_reversible_command(reversible_cmd, node_url, execution_mode).await + }, + Commands::HighSecurity(hs_cmd) => { + high_security::handle_high_security_command(hs_cmd, node_url, execution_mode).await + }, + Commands::Recovery(recovery_cmd) => { + recovery::handle_recovery_command(recovery_cmd, node_url, execution_mode).await + }, + Commands::Multisig(multisig_cmd) => { + multisig::handle_multisig_command(multisig_cmd, node_url, execution_mode).await + }, + Commands::Scheduler(scheduler_cmd) => { + scheduler::handle_scheduler_command(scheduler_cmd, node_url, execution_mode).await + }, + Commands::Storage(storage_cmd) => { + storage::handle_storage_command(storage_cmd, node_url, execution_mode).await + }, + Commands::TechCollective(tech_collective_cmd) => { tech_collective::handle_tech_collective_command( tech_collective_cmd, node_url, execution_mode, ) - .await, - Commands::Preimage(preimage_cmd) => - preimage::handle_preimage_command(preimage_cmd, node_url, execution_mode).await, - Commands::TechReferenda(tech_referenda_cmd) => + .await + }, + Commands::Preimage(preimage_cmd) => { + preimage::handle_preimage_command(preimage_cmd, node_url, execution_mode).await + }, + Commands::TechReferenda(tech_referenda_cmd) => { tech_referenda::handle_tech_referenda_command( tech_referenda_cmd, node_url, execution_mode, ) - .await, - Commands::Referenda(referenda_cmd) => - referenda::handle_referenda_command(referenda_cmd, node_url, execution_mode).await, - Commands::Treasury(treasury_cmd) => - treasury::handle_treasury_command(treasury_cmd, node_url, execution_mode).await, - Commands::Transfers(transfers_cmd) => - transfers::handle_transfers_command(transfers_cmd).await, - Commands::Runtime(runtime_cmd) => - runtime::handle_runtime_command(runtime_cmd, node_url, execution_mode).await, + .await + }, + Commands::Referenda(referenda_cmd) => { + referenda::handle_referenda_command(referenda_cmd, node_url, execution_mode).await + }, + Commands::Treasury(treasury_cmd) => { + treasury::handle_treasury_command(treasury_cmd, node_url, execution_mode).await + }, + Commands::Transfers(transfers_cmd) => { + transfers::handle_transfers_command(transfers_cmd).await + }, + Commands::Runtime(runtime_cmd) => { + runtime::handle_runtime_command(runtime_cmd, node_url, execution_mode).await + }, Commands::Call { pallet, call, @@ -370,7 +385,7 @@ pub async fn execute_command( tip, offline, call_data_only, - } => + } => { handle_generic_call_command( pallet, call, @@ -384,7 +399,8 @@ pub async fn execute_command( node_url, execution_mode, ) - .await, + .await + }, Commands::Balance { address } => { let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -405,11 +421,12 @@ pub async fn execute_command( Ok(()) }, Commands::Developer(dev_cmd) => handle_developer_command(dev_cmd).await, - Commands::Events { block, block_hash, latest: _, finalized, pallet, raw, no_decode } => + Commands::Events { block, block_hash, latest: _, finalized, pallet, raw, no_decode } => { events::handle_events_command( block, block_hash, finalized, pallet, raw, !no_decode, node_url, ) - .await, + .await + }, Commands::System { runtime, metadata, rpc_methods } => { if runtime || metadata || rpc_methods { system::handle_system_extended_command( @@ -424,16 +441,18 @@ pub async fn execute_command( system::handle_system_command(node_url).await } }, - Commands::Metadata { no_docs, stats_only, pallet } => - metadata::handle_metadata_command(node_url, no_docs, stats_only, pallet).await, + Commands::Metadata { no_docs, stats_only, pallet } => { + metadata::handle_metadata_command(node_url, no_docs, stats_only, pallet).await + }, Commands::Version => { log_print!("CLI Version: Quantus CLI v{}", env!("CARGO_PKG_VERSION")); Ok(()) }, Commands::CompatibilityCheck => handle_compatibility_check(node_url).await, Commands::Block(block_cmd) => block::handle_block_command(block_cmd, node_url).await, - Commands::Wormhole(wormhole_cmd) => - wormhole::handle_wormhole_command(wormhole_cmd, node_url).await, + Commands::Wormhole(wormhole_cmd) => { + wormhole::handle_wormhole_command(wormhole_cmd, node_url).await + }, Commands::Multisend { from, addresses_file, @@ -445,7 +464,7 @@ pub async fn execute_command( password_file, tip, yes, - } => + } => { multisend::handle_multisend_command( from, node_url, @@ -460,7 +479,8 @@ pub async fn execute_command( yes, execution_mode, ) - .await, + .await + }, } } @@ -606,11 +626,19 @@ async fn handle_compatibility_check(node_url: &str) -> crate::error::Result<()> log_print!(""); // Check compatibility - let is_compatible = crate::config::is_runtime_compatible(runtime_version.spec_version); + let is_compatible = crate::config::is_runtime_compatible( + runtime_version.spec_version, + runtime_version.transaction_version, + ); log_print!("🔍 Compatibility Analysis:"); log_print!(" â€ĸ Supported Runtime Versions: {:?}", crate::config::COMPATIBLE_RUNTIME_VERSIONS); + log_print!( + " â€ĸ Supported Transaction Versions: {:?}", + crate::config::COMPATIBLE_TRANSACTION_VERSIONS + ); log_print!(" â€ĸ Current Runtime Version: {}", runtime_version.spec_version); + log_print!(" â€ĸ Current Transaction Version: {}", runtime_version.transaction_version); if is_compatible { log_success!("✅ COMPATIBLE - This CLI version supports the connected node"); @@ -620,7 +648,10 @@ async fn handle_compatibility_check(node_url: &str) -> crate::error::Result<()> log_error!("❌ INCOMPATIBLE - This CLI version may not work with the connected node"); log_print!(" â€ĸ Some features may not work correctly"); log_print!(" â€ĸ Consider updating the CLI or connecting to a compatible node"); - log_print!(" â€ĸ Supported versions: {:?}", crate::config::COMPATIBLE_RUNTIME_VERSIONS); + log_print!(" â€ĸ Supported runtime/version pairs:"); + for runtime in crate::config::COMPATIBLE_RUNTIMES { + log_print!(" - spec {} / tx {}", runtime.spec_version, runtime.transaction_version); + } } log_print!(""); diff --git a/src/cli/send.rs b/src/cli/send.rs index 1fad9c9..06c8592 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -7,6 +7,8 @@ use crate::{ use colored::Colorize; use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; +pub const DEFAULT_PRIORITY_TIP: u128 = 10_000_000_000; + /// Account balance data pub struct AccountBalanceData { pub free: u128, @@ -122,32 +124,108 @@ pub async fn parse_amount(quantus_client: &QuantusClient, amount_str: &str) -> R /// Parse amount string with specific decimals pub fn parse_amount_with_decimals(amount_str: &str, decimals: u8) -> Result { - let amount_part = amount_str.split_whitespace().next().unwrap_or(""); + let amount_part = amount_str.trim(); if amount_part.is_empty() { return Err(crate::error::QuantusError::Generic("Amount cannot be empty".to_string())); } - let parsed_amount: f64 = amount_part.parse().map_err(|_| { - crate::error::QuantusError::Generic(format!( - "Invalid amount format: '{amount_part}'. Use formats like '10', '10.5', '0.0001'" - )) - })?; - - if parsed_amount < 0.0 { + if amount_part.starts_with('-') { return Err(crate::error::QuantusError::Generic("Amount cannot be negative".to_string())); } - if let Some(decimal_part) = amount_part.split('.').nth(1) { - if decimal_part.len() > decimals as usize { - return Err(crate::error::QuantusError::Generic(format!( - "Too many decimal places. Maximum {decimals} decimal places allowed for this chain" - ))); - } + if amount_part.starts_with('+') { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'" + ))); + } + + let mut parts = amount_part.split('.'); + let whole_part = parts.next().unwrap_or_default(); + let fractional_part = parts.next(); + if parts.next().is_some() { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'" + ))); + } + + if whole_part.is_empty() && fractional_part.is_none() { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'" + ))); + } + + if !whole_part.is_empty() && !whole_part.chars().all(|ch| ch.is_ascii_digit()) { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'" + ))); + } + + let fractional_part = fractional_part.unwrap_or_default(); + if !fractional_part.is_empty() && !fractional_part.chars().all(|ch| ch.is_ascii_digit()) { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'" + ))); + } + + if whole_part.is_empty() && fractional_part.is_empty() { + return Err(crate::error::QuantusError::Generic(format!( + "Invalid amount format: '{amount_part}'. Use plain decimal strings like '10', '10.5', or '0.0001'" + ))); + } + + if fractional_part.len() > decimals as usize { + return Err(crate::error::QuantusError::Generic(format!( + "Too many decimal places. Maximum {decimals} decimal places allowed for this chain" + ))); } - let multiplier = 10_f64.powi(decimals as i32); - let raw_amount = (parsed_amount * multiplier).round() as u128; + let multiplier = 10_u128.checked_pow(decimals as u32).ok_or_else(|| { + crate::error::QuantusError::Generic(format!("Unsupported chain decimals value: {decimals}")) + })?; + + let whole_value = if whole_part.is_empty() { + 0 + } else { + whole_part.parse::().map_err(|_| { + crate::error::QuantusError::Generic(format!( + "Amount is too large to represent: '{amount_part}'" + )) + })? + }; + + let whole_raw = whole_value.checked_mul(multiplier).ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Amount is too large to represent: '{amount_part}'" + )) + })?; + + let fractional_raw = if fractional_part.is_empty() { + 0 + } else { + let fractional_value = fractional_part.parse::().map_err(|_| { + crate::error::QuantusError::Generic(format!( + "Amount is too large to represent: '{amount_part}'" + )) + })?; + let padding = decimals as usize - fractional_part.len(); + let scale = 10_u128.checked_pow(padding as u32).ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Unsupported chain decimals value: {decimals}" + )) + })?; + fractional_value.checked_mul(scale).ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Amount is too large to represent: '{amount_part}'" + )) + })? + }; + + let raw_amount = whole_raw.checked_add(fractional_raw).ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Amount is too large to represent: '{amount_part}'" + )) + })?; if raw_amount == 0 { return Err(crate::error::QuantusError::Generic( @@ -168,6 +246,96 @@ pub async fn validate_and_format_amount( Ok((raw_amount, formatted)) } +fn checked_add(lhs: u128, rhs: u128, context: &str) -> Result { + lhs.checked_add(rhs).ok_or_else(|| { + crate::error::QuantusError::Generic(format!("Value overflow while computing {context}")) + }) +} + +pub fn effective_tip_amount(tip: Option) -> u128 { + tip.unwrap_or(DEFAULT_PRIORITY_TIP) +} + +fn build_transfer_call(resolved_address: &str, amount: u128) -> Result { + let (to_account_id_sp, _) = SpAccountId32::from_ss58check_with_version(resolved_address) + .map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Invalid destination address: {e:?}")) + })?; + + let to_account_id_bytes: [u8; 32] = *to_account_id_sp.as_ref(); + let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); + + Ok(quantus_subxt::api::tx().balances().transfer_allow_death( + subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), + amount, + )) +} + +pub(crate) fn build_batch_transfer_call( + transfers: &[(String, u128)], +) -> Result { + use quantus_subxt::api::runtime_types::{ + pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall, + }; + + let mut calls = Vec::with_capacity(transfers.len()); + for (to_address, amount) in transfers { + let resolved_address = crate::cli::common::resolve_address(to_address)?; + let to_account_id_sp = SpAccountId32::from_ss58check(&resolved_address).map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Invalid destination address {resolved_address}: {e:?}" + )) + })?; + + let to_account_id_bytes: [u8; 32] = *to_account_id_sp.as_ref(); + let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); + + calls.push(RuntimeCall::Balances(BalancesCall::transfer_allow_death { + dest: subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), + value: *amount, + })); + } + + Ok(quantus_subxt::api::tx().utility().batch(calls)) +} + +pub async fn estimate_transaction_partial_fee( + quantus_client: &QuantusClient, + from_keypair: &crate::wallet::QuantumKeyPair, + call: &Call, + tip: Option, +) -> Result +where + Call: subxt::tx::Payload, +{ + let signer = from_keypair.to_subxt_signer().map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) + })?; + + use subxt::config::DefaultExtrinsicParamsBuilder; + let mut params_builder = DefaultExtrinsicParamsBuilder::new().mortal(256); + if let Some(tip_amount) = tip { + params_builder = params_builder.tip(tip_amount); + } + + let mut tx_client = quantus_client.client().tx(); + let signed_tx = + tx_client + .create_signed(call, &signer, params_builder.build()) + .await + .map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to prepare transaction for fee estimation: {e:?}" + )) + })?; + + signed_tx.partial_fee_estimate().await.map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to estimate transaction fee: {e:?}" + )) + }) +} + /// Transfer tokens with automatic nonce #[allow(dead_code)] // Used by external libraries via lib.rs export pub async fn transfer( @@ -201,27 +369,10 @@ pub async fn transfer_with_nonce( let resolved_address = resolve_address(to_address)?; log_verbose!(" Resolved to: {}", resolved_address.bright_green()); - // Parse the destination address - let (to_account_id_sp, _) = SpAccountId32::from_ss58check_with_version(&resolved_address) - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Invalid destination address: {e:?}")) - })?; - - // Convert to subxt_core AccountId32 - let to_account_id_bytes: [u8; 32] = *to_account_id_sp.as_ref(); - let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); - log_verbose!("âœī¸ Creating balance transfer extrinsic..."); - // Create the transfer call using static API from quantus_subxt - let transfer_call = quantus_subxt::api::tx().balances().transfer_allow_death( - subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id.clone()), - amount, - ); - - // Use provided tip or default tip of 10 DEV to increase priority and avoid temporarily - // banned errors - let tip_to_use = tip.unwrap_or(10_000_000_000); // Use provided tip or default 10 DEV + let transfer_call = build_transfer_call(&resolved_address, amount)?; + let tip_to_use = effective_tip_amount(tip); // Submit the transaction with optional manual nonce let tx_hash = if let Some(manual_nonce) = nonce { @@ -291,45 +442,14 @@ pub async fn batch_transfer( ); } - // Prepare all transfer calls as RuntimeCall - let mut calls = Vec::new(); - for (to_address, amount) in transfers { + for (to_address, amount) in &transfers { log_verbose!(" To: {} Amount: {}", to_address.bright_green(), amount); - - // Resolve the destination address - let resolved_address = crate::cli::common::resolve_address(&to_address)?; - - // Parse the destination address - let to_account_id_sp = SpAccountId32::from_ss58check(&resolved_address).map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Invalid destination address {resolved_address}: {e:?}" - )) - })?; - - // Convert to subxt_core AccountId32 - let to_account_id_bytes: [u8; 32] = *to_account_id_sp.as_ref(); - let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); - - // Create the transfer call as RuntimeCall - use quantus_subxt::api::runtime_types::{ - pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall, - }; - - let transfer_call = RuntimeCall::Balances(BalancesCall::transfer_allow_death { - dest: subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), - value: amount, - }); - - calls.push(transfer_call); } - - log_verbose!("âœī¸ Creating batch extrinsic with {} calls...", calls.len()); - - // Create the batch call using utility pallet - let batch_call = quantus_subxt::api::tx().utility().batch(calls); + log_verbose!("âœī¸ Creating batch extrinsic with {} calls...", transfers.len()); + let batch_call = build_batch_transfer_call(&transfers)?; // Use provided tip or default tip - let tip_to_use = tip.unwrap_or(10_000_000_000); + let tip_to_use = effective_tip_amount(tip); // Submit the batch transaction let tx_hash = crate::cli::common::submit_transaction( @@ -390,25 +510,60 @@ pub async fn handle_send_command( let formatted_balance = format_balance_with_symbol(&quantus_client, balance).await?; log_verbose!("💰 Current balance: {}", formatted_balance.bright_yellow()); - if balance < amount { + // Parse tip amount if provided + let tip_amount = if let Some(tip_str) = &tip { + Some(parse_amount(&quantus_client, tip_str).await?) + } else { + None + }; + let effective_tip = effective_tip_amount(tip_amount); + + let exact_required = checked_add(amount, effective_tip, "required send balance")?; + if balance < exact_required { return Err(crate::error::QuantusError::InsufficientBalance { available: balance, - required: amount, + required: exact_required, }); } + let transfer_call = build_transfer_call(&resolved_address, amount)?; + match estimate_transaction_partial_fee( + &quantus_client, + &keypair, + &transfer_call, + Some(effective_tip), + ) + .await + { + Ok(estimated_fee) => { + let estimated_total = + checked_add(exact_required, estimated_fee, "required send balance")?; + if balance < estimated_total { + let formatted_tip = + format_balance_with_symbol(&quantus_client, effective_tip).await?; + let formatted_fee = + format_balance_with_symbol(&quantus_client, estimated_fee).await?; + let formatted_required = + format_balance_with_symbol(&quantus_client, estimated_total).await?; + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance for amount + tip + estimated fee. Have: {formatted_balance}, Need: {formatted_required} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + ))); + } + let formatted_estimated_fee = + format_balance_with_symbol(&quantus_client, estimated_fee).await?; + log_verbose!("💸 Estimated network fee: {}", formatted_estimated_fee.bright_cyan()); + }, + Err(err) => { + log_verbose!( + "âš ī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", + err + ); + }, + } + // Create and submit transaction log_verbose!("âœī¸ {} Signing transaction...", "SIGN".bright_magenta().bold()); - // Parse tip amount if provided - let tip_amount = if let Some(tip_str) = &tip { - // Get chain properties for proper decimal parsing - let (_, decimals) = get_chain_properties(&quantus_client).await?; - parse_amount_with_decimals(tip_str, decimals).ok() - } else { - None - }; - // Submit transaction let tx_hash = transfer_with_nonce( &quantus_client, @@ -421,8 +576,27 @@ pub async fn handle_send_command( ) .await?; - log_print!("✅ {} Transaction submitted! Hash: {:?}", "SUCCESS".bright_green().bold(), tx_hash); - log_success!("🎉 {} Transaction confirmed!", "FINISHED".bright_green().bold()); + let transaction_stage = execution_mode.transaction_stage(); + log_print!( + "✅ {} Transaction {}. Hash: {:?}", + "SUCCESS".bright_green().bold(), + transaction_stage.status_label(), + tx_hash + ); + + if !execution_mode.should_refresh_post_submit_state() { + log_print!( + "â„šī¸ The transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized-tx to wait before returning.", + transaction_stage.success_detail() + ); + return Ok(()); + } + + log_success!( + "🎉 {} Transaction {}.", + "FINISHED".bright_green().bold(), + transaction_stage.success_detail() + ); // Show updated balance with proper formatting let new_balance = get_balance(&quantus_client, &from_account_id).await?; @@ -461,7 +635,7 @@ pub async fn load_transfers_from_file(file_path: &str) -> Result().map_err(|e| { crate::error::QuantusError::Generic(format!("Invalid amount '{}': {e:?}", entry.amount)) })?; @@ -508,3 +682,41 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 Ok((safe_limit, recommended_limit)) } + +#[cfg(test)] +mod tests { + use super::parse_amount_with_decimals; + + #[test] + fn parses_exact_decimal_amounts() { + assert_eq!(parse_amount_with_decimals("0.1", 12).unwrap(), 100_000_000_000); + assert_eq!(parse_amount_with_decimals("0.000000000001", 12).unwrap(), 1); + assert_eq!(parse_amount_with_decimals("1.000000000000", 12).unwrap(), 1_000_000_000_000); + } + + #[test] + fn rejects_malformed_and_invalid_amounts() { + assert!(parse_amount_with_decimals("", 12).is_err()); + assert!(parse_amount_with_decimals("-1", 12).is_err()); + assert!(parse_amount_with_decimals("abc", 12).is_err()); + assert!(parse_amount_with_decimals("1e3", 12).is_err()); + assert!(parse_amount_with_decimals("1.2.3", 12).is_err()); + assert!(parse_amount_with_decimals("0", 12).is_err()); + assert!(parse_amount_with_decimals("0.000000000000", 12).is_err()); + assert!(parse_amount_with_decimals("0.0000000000001", 12).is_err()); + } + + #[test] + fn handles_u128_boundaries_exactly() { + assert_eq!(parse_amount_with_decimals(&u128::MAX.to_string(), 0).unwrap(), u128::MAX); + + let factor = 10_u128.pow(12); + let whole = u128::MAX / factor; + let fractional = u128::MAX % factor; + let max_value = format!("{whole}.{:012}", fractional); + assert_eq!(parse_amount_with_decimals(&max_value, 12).unwrap(), u128::MAX); + + let overflow = format!("{}.0", whole + 1); + assert!(parse_amount_with_decimals(&overflow, 12).is_err()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index a5a4ff1..a8132f9 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,10 +2,25 @@ //! //! This module handles runtime compatibility information. -/// List of runtime spec versions that this CLI is compatible with -pub const COMPATIBLE_RUNTIME_VERSIONS: &[u32] = &[123]; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CompatibleRuntime { + pub spec_version: u32, + pub transaction_version: u32, +} + +/// List of runtime spec versions that this CLI is compatible with. +pub const COMPATIBLE_RUNTIME_VERSIONS: &[u32] = &[127]; + +/// List of transaction versions that this CLI is compatible with. +pub const COMPATIBLE_TRANSACTION_VERSIONS: &[u32] = &[2]; + +/// Supported runtime / transaction version pairs for the checked-in metadata snapshot. +pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = + &[CompatibleRuntime { spec_version: 127, transaction_version: 2 }]; -/// Check if a runtime version is compatible with this CLI -pub fn is_runtime_compatible(spec_version: u32) -> bool { - COMPATIBLE_RUNTIME_VERSIONS.contains(&spec_version) +/// Check if a runtime version pair is compatible with this CLI. +pub fn is_runtime_compatible(spec_version: u32, transaction_version: u32) -> bool { + COMPATIBLE_RUNTIMES.iter().any(|runtime| { + runtime.spec_version == spec_version && runtime.transaction_version == transaction_version + }) } diff --git a/src/main.rs b/src/main.rs index 4b90cb1..440a804 100644 --- a/src/main.rs +++ b/src/main.rs @@ -40,12 +40,13 @@ struct Cli { #[arg(long, global = true, default_value = "ws://127.0.0.1:9944")] node_url: String, - /// Transaction finalization + /// Wait for transaction finalization before returning + /// Implies `--wait-for-transaction` /// NOTE: waiting for finalized transaction may take a while in PoW chain #[arg(long, global = true, default_value = "false")] finalized_tx: bool, - /// Wait for transaction validation/inclusion before returning + /// Wait for transaction inclusion in a best block before returning /// Default: false #[arg(long, global = true, default_value = "false")] wait_for_transaction: bool, @@ -72,7 +73,7 @@ async fn main() -> Result<(), QuantusError> { // Create execution mode from CLI args let execution_mode = cli::common::ExecutionMode { finalized: cli.finalized_tx, - wait_for_transaction: cli.wait_for_transaction, + wait_for_transaction: cli.wait_for_transaction || cli.finalized_tx, }; // Execute the command with timing diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3562009..092c680 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -17,8 +17,6 @@ use qp_rusty_crystals_hdwallet::{ use rand::{rng, RngCore}; use serde::{Deserialize, Serialize}; -use sp_runtime::traits::IdentifyAccount; - /// Default derivation path for Quantus wallets: m/44'/189189'/0'/0'/0' pub const DEFAULT_DERIVATION_PATH: &str = "m/44'/189189'/0'/0'/0'"; @@ -111,6 +109,9 @@ impl WalletManager { pub async fn create_developer_wallet(&self, name: &str) -> Result { // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + if keystore.load_wallet(name)?.is_some() { + return Err(WalletError::AlreadyExists.into()); + } // Generate the appropriate test keypair let resonance_pair = match name { @@ -122,15 +123,6 @@ impl WalletManager { let quantum_keypair = QuantumKeyPair::from_resonance_pair(&resonance_pair); - // Format addresses with SS58 version 189 (Quantus format) - use sp_core::{crypto::Ss58Codec, Pair}; - let resonance_addr = resonance_pair - .public() - .into_account() - .to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)); - println!("🔑 Resonance pair: {:?}", resonance_addr); - println!("🔑 Quantum keypair: {:?}", quantum_keypair.to_account_id_ss58check()); - // Create wallet data let mut metadata = std::collections::HashMap::new(); metadata.insert("version".to_string(), "1.0.0".to_string()); @@ -389,17 +381,8 @@ impl WalletManager { let seed_array: [u8; 32] = seed_bytes.try_into().map_err(|_| WalletError::InvalidMnemonic)?; - println!("Debug: seed_array length: {}", seed_array.len()); - println!("Debug: seed_hex: {}", seed_hex); - println!("Debug: calling DilithiumPair::from_seed"); - let dilithium_pair = qp_dilithium_crypto::types::DilithiumPair::from_seed(&seed_array) - .map_err(|e| { - println!("Debug: DilithiumPair::from_seed failed with error: {:?}", e); - WalletError::InvalidMnemonic - })?; - - println!("Debug: DilithiumPair created successfully"); + .map_err(|_| WalletError::InvalidMnemonic)?; // Convert to QuantumKeyPair let quantum_keypair = QuantumKeyPair::from_resonance_pair(&dilithium_pair); @@ -526,7 +509,9 @@ pub fn load_keypair_from_wallet( #[cfg(test)] mod tests { use super::*; + use serial_test::serial; use std::fs; + use std::process::Command; use tempfile::TempDir; async fn create_test_wallet_manager() -> (WalletManager, TempDir) { @@ -576,6 +561,23 @@ mod tests { } } + #[tokio::test] + async fn test_developer_wallet_duplicate_rejected() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + + let wallet_info = wallet_manager + .create_developer_wallet("crystal_alice") + .await + .expect("Failed to create developer wallet"); + assert_eq!(wallet_info.name, "crystal_alice"); + + let result = wallet_manager.create_developer_wallet("crystal_alice").await; + assert!(matches!( + result, + Err(crate::error::QuantusError::Wallet(WalletError::AlreadyExists)) + )); + } + #[tokio::test] async fn test_wallet_file_creation() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; @@ -839,6 +841,43 @@ mod tests { } } + #[tokio::test] + #[ignore] + async fn seed_output_probe() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let seed_hex = "0101010101010101010101010101010101010101010101010101010101010101"; + + let wallet_info = wallet_manager + .create_wallet_from_seed("seed-output-probe", seed_hex, Some("probe-password")) + .await + .expect("Failed to create seed wallet"); + + assert_eq!(wallet_info.name, "seed-output-probe"); + assert!(wallet_info.address.starts_with("qz")); + } + + #[test] + #[serial] + fn test_wallet_from_seed_does_not_emit_secret_output() { + let output = Command::new(std::env::current_exe().expect("Failed to locate test binary")) + .args(["--ignored", "--exact", "seed_output_probe", "--nocapture", "--test-threads=1"]) + .output() + .expect("Failed to run seed output probe"); + + assert!(output.status.success(), "probe test failed: {:?}", output); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + + assert!( + !combined.contains("0101010101010101010101010101010101010101010101010101010101010101"), + "seed hex leaked in output: {combined}" + ); + assert!(!combined.contains("Debug:"), "debug output leaked: {combined}"); + assert!(!combined.contains("seed_array"), "seed internals leaked in output: {combined}"); + } + #[tokio::test] async fn test_list_wallets() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; From a03929a4d59cc6fed47a12b1330e52f4b1d97dcc Mon Sep 17 00:00:00 2001 From: Ethan Date: Mon, 20 Apr 2026 22:20:12 -0500 Subject: [PATCH 2/6] *fmt --- src/cli/batch.rs | 10 ++--- src/cli/common.rs | 41 +++++++++---------- src/cli/mod.rs | 100 +++++++++++++++++++--------------------------- src/wallet/mod.rs | 3 +- 4 files changed, 63 insertions(+), 91 deletions(-) diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 0146167..7e149d7 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -76,7 +76,7 @@ pub async fn handle_batch_command( count, to, amount, - } => { + } => handle_batch_send_command( from, node_url, @@ -89,11 +89,9 @@ pub async fn handle_batch_command( amount, execution_mode, ) - .await - }, - BatchCommands::Config { limits, info } => { - handle_batch_config_command(node_url, limits, info).await - }, + .await, + BatchCommands::Config { limits, info } => + handle_batch_config_command(node_url, limits, info).await, } } diff --git a/src/cli/common.rs b/src/cli/common.rs index 82d0392..fb47d13 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -85,26 +85,22 @@ fn describe_watched_tx_event( target_stage: TransactionStage, ) -> Result { match event { - WatchedTxEvent::Validated - | WatchedTxEvent::Broadcasted - | WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), - WatchedTxEvent::InBestBlock => { + WatchedTxEvent::Validated | + WatchedTxEvent::Broadcasted | + WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), + WatchedTxEvent::InBestBlock => if target_stage == TransactionStage::Finalized { Ok(WatchDecision::WaitForFinalization) } else { Ok(WatchDecision::Success) - } - }, + }, WatchedTxEvent::InFinalizedBlock => Ok(WatchDecision::Success), - WatchedTxEvent::Error(message) => { - Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))) - }, - WatchedTxEvent::Invalid(message) => { - Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))) - }, - WatchedTxEvent::Dropped(message) => { - Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))) - }, + WatchedTxEvent::Error(message) => + Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))), + WatchedTxEvent::Invalid(message) => + Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))), + WatchedTxEvent::Dropped(message) => + Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))), WatchedTxEvent::StreamError(message) => Err(crate::error::QuantusError::NetworkError( format!("Transaction status stream error: {message}"), )), @@ -346,11 +342,11 @@ where let error_msg = format!("{e:?}"); // Check if it's a retryable error - let is_retryable = error_msg.contains("Priority is too low") - || error_msg.contains("Transaction is outdated") - || error_msg.contains("Transaction is temporarily banned") - || error_msg.contains("Transaction has a bad signature") - || error_msg.contains("Invalid Transaction"); + let is_retryable = error_msg.contains("Priority is too low") || + error_msg.contains("Transaction is outdated") || + error_msg.contains("Transaction is temporarily banned") || + error_msg.contains("Transaction has a bad signature") || + error_msg.contains("Invalid Transaction"); if is_retryable && attempt < 5 { log_verbose!( @@ -590,9 +586,8 @@ async fn wait_tx_inclusion( } return Ok(()); }, - WatchDecision::Continue | WatchDecision::WaitForFinalization => { - continue - }, + WatchDecision::Continue | WatchDecision::WaitForFinalization => + continue, } }, TxStatus::Error { message } => WatchedTxEvent::Error(message), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a5379ae..c788e9b 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -309,7 +309,7 @@ pub async fn execute_command( ) -> crate::error::Result<()> { match command { Commands::Wallet(wallet_cmd) => wallet::handle_wallet_command(wallet_cmd, node_url).await, - Commands::Send { from, to, amount, password, password_file, tip, nonce } => { + Commands::Send { from, to, amount, password, password_file, tip, nonce } => send::handle_send_command( from, to, @@ -321,60 +321,45 @@ pub async fn execute_command( nonce, execution_mode, ) - .await - }, - Commands::Batch(batch_cmd) => { - batch::handle_batch_command(batch_cmd, node_url, execution_mode).await - }, - Commands::Reversible(reversible_cmd) => { - reversible::handle_reversible_command(reversible_cmd, node_url, execution_mode).await - }, - Commands::HighSecurity(hs_cmd) => { - high_security::handle_high_security_command(hs_cmd, node_url, execution_mode).await - }, - Commands::Recovery(recovery_cmd) => { - recovery::handle_recovery_command(recovery_cmd, node_url, execution_mode).await - }, - Commands::Multisig(multisig_cmd) => { - multisig::handle_multisig_command(multisig_cmd, node_url, execution_mode).await - }, - Commands::Scheduler(scheduler_cmd) => { - scheduler::handle_scheduler_command(scheduler_cmd, node_url, execution_mode).await - }, - Commands::Storage(storage_cmd) => { - storage::handle_storage_command(storage_cmd, node_url, execution_mode).await - }, - Commands::TechCollective(tech_collective_cmd) => { + .await, + Commands::Batch(batch_cmd) => + batch::handle_batch_command(batch_cmd, node_url, execution_mode).await, + Commands::Reversible(reversible_cmd) => + reversible::handle_reversible_command(reversible_cmd, node_url, execution_mode).await, + Commands::HighSecurity(hs_cmd) => + high_security::handle_high_security_command(hs_cmd, node_url, execution_mode).await, + Commands::Recovery(recovery_cmd) => + recovery::handle_recovery_command(recovery_cmd, node_url, execution_mode).await, + Commands::Multisig(multisig_cmd) => + multisig::handle_multisig_command(multisig_cmd, node_url, execution_mode).await, + Commands::Scheduler(scheduler_cmd) => + scheduler::handle_scheduler_command(scheduler_cmd, node_url, execution_mode).await, + Commands::Storage(storage_cmd) => + storage::handle_storage_command(storage_cmd, node_url, execution_mode).await, + Commands::TechCollective(tech_collective_cmd) => tech_collective::handle_tech_collective_command( tech_collective_cmd, node_url, execution_mode, ) - .await - }, - Commands::Preimage(preimage_cmd) => { - preimage::handle_preimage_command(preimage_cmd, node_url, execution_mode).await - }, - Commands::TechReferenda(tech_referenda_cmd) => { + .await, + Commands::Preimage(preimage_cmd) => + preimage::handle_preimage_command(preimage_cmd, node_url, execution_mode).await, + Commands::TechReferenda(tech_referenda_cmd) => tech_referenda::handle_tech_referenda_command( tech_referenda_cmd, node_url, execution_mode, ) - .await - }, - Commands::Referenda(referenda_cmd) => { - referenda::handle_referenda_command(referenda_cmd, node_url, execution_mode).await - }, - Commands::Treasury(treasury_cmd) => { - treasury::handle_treasury_command(treasury_cmd, node_url, execution_mode).await - }, - Commands::Transfers(transfers_cmd) => { - transfers::handle_transfers_command(transfers_cmd).await - }, - Commands::Runtime(runtime_cmd) => { - runtime::handle_runtime_command(runtime_cmd, node_url, execution_mode).await - }, + .await, + Commands::Referenda(referenda_cmd) => + referenda::handle_referenda_command(referenda_cmd, node_url, execution_mode).await, + Commands::Treasury(treasury_cmd) => + treasury::handle_treasury_command(treasury_cmd, node_url, execution_mode).await, + Commands::Transfers(transfers_cmd) => + transfers::handle_transfers_command(transfers_cmd).await, + Commands::Runtime(runtime_cmd) => + runtime::handle_runtime_command(runtime_cmd, node_url, execution_mode).await, Commands::Call { pallet, call, @@ -385,7 +370,7 @@ pub async fn execute_command( tip, offline, call_data_only, - } => { + } => handle_generic_call_command( pallet, call, @@ -399,8 +384,7 @@ pub async fn execute_command( node_url, execution_mode, ) - .await - }, + .await, Commands::Balance { address } => { let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -421,12 +405,11 @@ pub async fn execute_command( Ok(()) }, Commands::Developer(dev_cmd) => handle_developer_command(dev_cmd).await, - Commands::Events { block, block_hash, latest: _, finalized, pallet, raw, no_decode } => { + Commands::Events { block, block_hash, latest: _, finalized, pallet, raw, no_decode } => events::handle_events_command( block, block_hash, finalized, pallet, raw, !no_decode, node_url, ) - .await - }, + .await, Commands::System { runtime, metadata, rpc_methods } => { if runtime || metadata || rpc_methods { system::handle_system_extended_command( @@ -441,18 +424,16 @@ pub async fn execute_command( system::handle_system_command(node_url).await } }, - Commands::Metadata { no_docs, stats_only, pallet } => { - metadata::handle_metadata_command(node_url, no_docs, stats_only, pallet).await - }, + Commands::Metadata { no_docs, stats_only, pallet } => + metadata::handle_metadata_command(node_url, no_docs, stats_only, pallet).await, Commands::Version => { log_print!("CLI Version: Quantus CLI v{}", env!("CARGO_PKG_VERSION")); Ok(()) }, Commands::CompatibilityCheck => handle_compatibility_check(node_url).await, Commands::Block(block_cmd) => block::handle_block_command(block_cmd, node_url).await, - Commands::Wormhole(wormhole_cmd) => { - wormhole::handle_wormhole_command(wormhole_cmd, node_url).await - }, + Commands::Wormhole(wormhole_cmd) => + wormhole::handle_wormhole_command(wormhole_cmd, node_url).await, Commands::Multisend { from, addresses_file, @@ -464,7 +445,7 @@ pub async fn execute_command( password_file, tip, yes, - } => { + } => multisend::handle_multisend_command( from, node_url, @@ -479,8 +460,7 @@ pub async fn execute_command( yes, execution_mode, ) - .await - }, + .await, } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 092c680..497a24e 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -510,8 +510,7 @@ pub fn load_keypair_from_wallet( mod tests { use super::*; use serial_test::serial; - use std::fs; - use std::process::Command; + use std::{fs, process::Command}; use tempfile::TempDir; async fn create_test_wallet_manager() -> (WalletManager, TempDir) { From 889c1c84e18c42121bde9c9d5a60771e3fd2a3f7 Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Apr 2026 20:14:02 -0500 Subject: [PATCH 3/6] fix: address Nikolaus PR review comments --- Cargo.lock | 42 ------------ Cargo.toml | 1 - LIBRARY_USAGE.md | 4 +- README.md | 4 +- regenerate_metadata.sh | 3 +- src/cli/batch.rs | 89 ++++++++++++++++--------- src/cli/common.rs | 77 ++++++++++++++------- src/cli/mod.rs | 13 ++-- src/cli/send.rs | 148 ++++++++++++++++++++++++++++------------- src/config/mod.rs | 6 -- src/wallet/mod.rs | 52 +++++---------- 11 files changed, 238 insertions(+), 201 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index add5cc9..0287c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3969,7 +3969,6 @@ dependencies = [ "rustls-webpki", "serde", "serde_json", - "serial_test", "sha2 0.10.9", "sp-core", "sp-runtime", @@ -4572,15 +4571,6 @@ dependencies = [ "yap", ] -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -4626,12 +4616,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "sec1" version = "0.7.3" @@ -4802,32 +4786,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serial_test" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" -dependencies = [ - "futures-executor", - "futures-util", - "log", - "once_cell", - "parking_lot", - "scc", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "sha1" version = "0.10.6" diff --git a/Cargo.toml b/Cargo.toml index a7b8668..98b74e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,7 +97,6 @@ qp-wormhole-circuit-builder = { version = "2.0.1" } [dev-dependencies] tempfile = "3.8.1" -serial_test = "3.1" qp-poseidon-core = "1.4.0" # Optimize build scripts and their dependencies in dev mode. diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index e3b304b..071da0d 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -8,7 +8,7 @@ This document explains how to use `quantus-cli` as a library in your Rust applic [dependencies] quantus-cli = { path = "." } # For local development # or -quantus-cli = "1.3.3" # Current published version +quantus-cli = "1.3.3" # Replace with the latest published version on crates.io ``` ## Basic Usage @@ -205,6 +205,8 @@ async fn send_transaction() -> Result<(), Box> { } ``` +Passing `None` for the tip means no tip is attached. Use `Some(raw_tip)` only when you explicitly want one. + `ExecutionMode` semantics: - `wait_for_transaction = false`: return after submission (`submitted`) - `wait_for_transaction = true`: return after best-block inclusion (`included`) diff --git a/README.md b/README.md index 3d4449a..6589240 100644 --- a/README.md +++ b/README.md @@ -376,7 +376,7 @@ quantus send --from crystal_alice --to
--amount 10.5 --wait-for-transa # Wait for finalization (implies --wait-for-transaction) quantus send --from crystal_alice --to
--amount 10.5 --finalized-tx -# With tip for priority +# Optional advanced: add a tip to prioritize the transaction quantus send --from crystal_alice --to
--amount 10 --tip 0.1 # With manual nonce @@ -388,7 +388,7 @@ Transaction status terms: - `included`: observed in a best block - `finalized`: observed in a finalized block -`--amount` and `--tip` use exact decimal parsing based on the chain's configured decimals. Malformed values, negative values, over-precision, or values that would round to zero are rejected. +`--amount` and `--tip` use exact decimal parsing based on the chain's configured decimals. Malformed values, negative values, over-precision, or values that would round to zero are rejected. `--tip` is optional and omitted by default. --- diff --git a/regenerate_metadata.sh b/regenerate_metadata.sh index 520da68..69db0a7 100755 --- a/regenerate_metadata.sh +++ b/regenerate_metadata.sh @@ -33,7 +33,8 @@ echo "Generating SubXT types to src/chain/quantus_subxt.rs..." subxt codegen --url "$NODE_URL" > src/chain/quantus_subxt.rs echo "Formatting generated code..." -cargo fmt -- src/chain/quantus_subxt.rs +# Generated SubXT code may require nightly rustfmt. +cargo +nightly fmt -- src/chain/quantus_subxt.rs echo "Reminder: update src/config/mod.rs with the new compatible spec/transaction version pair." echo "Done!" diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 7e149d7..d01b42a 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -2,14 +2,22 @@ use crate::{ chain::client::QuantusClient, cli::send::{ - batch_transfer, get_batch_limits, load_transfers_from_file, validate_and_format_amount, + build_batch_transfer_call, get_batch_limits, load_transfers_from_file, + submit_prebuilt_batch_transfer_call, validate_and_format_amount, + validate_batch_transfer_request, }, error::Result, - log_info, log_print, log_success, + log_info, log_print, log_success, log_verbose, }; use clap::Subcommand; use colored::Colorize; +const BATCH_AMOUNT_TOTAL_TOO_LARGE: &str = "Batch amount total is too large to represent"; + +fn batch_amount_overflow_error() -> crate::error::QuantusError { + crate::error::QuantusError::Generic(BATCH_AMOUNT_TOTAL_TOO_LARGE.to_string()) +} + #[derive(Subcommand, Debug)] pub enum BatchCommands { /// Send tokens to multiple recipients in a single batch transaction @@ -146,76 +154,91 @@ async fn handle_batch_send_command( // Load wallet let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; let from_account_id = keypair.to_account_id_ss58check(); + validate_batch_transfer_request(&quantus_client, &keypair, &transfers).await?; + let effective_tip = crate::cli::send::effective_tip_amount(tip_amount); + let submit_tip = crate::cli::send::positive_tip_amount(tip_amount); // Check balance let balance = crate::cli::send::get_balance(&quantus_client, &from_account_id).await?; let total_amount = transfers.iter().try_fold(0u128, |acc, (_, amount)| { - acc.checked_add(*amount).ok_or_else(|| { - crate::error::QuantusError::Generic( - "Batch amount total is too large to represent".to_string(), - ) - }) - })?; - let exact_required = total_amount.checked_add(effective_tip).ok_or_else(|| { - crate::error::QuantusError::Generic( - "Batch amount total is too large to represent".to_string(), - ) + acc.checked_add(*amount).ok_or_else(batch_amount_overflow_error) })?; + let exact_required = total_amount + .checked_add(effective_tip) + .ok_or_else(batch_amount_overflow_error)?; if balance < exact_required { let formatted_balance = crate::cli::send::format_balance_with_symbol(&quantus_client, balance).await?; let formatted_needed = crate::cli::send::format_balance_with_symbol(&quantus_client, exact_required).await?; + let detail = if submit_tip.is_some() { " (including tip)" } else { "" }; return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (including tip)" + "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed}{detail}" ))); } - let batch_call = crate::cli::send::build_batch_transfer_call(&transfers)?; + log_verbose!("âœī¸ Creating batch extrinsic with {} calls...", transfers.len()); + let batch_call = build_batch_transfer_call(&transfers)?; match crate::cli::send::estimate_transaction_partial_fee( &quantus_client, &keypair, &batch_call, - Some(effective_tip), + submit_tip, ) .await { Ok(estimated_fee) => { - let estimated_total = exact_required.checked_add(estimated_fee).ok_or_else(|| { - crate::error::QuantusError::Generic( - "Batch amount total is too large to represent".to_string(), - ) - })?; + let estimated_total = exact_required + .checked_add(estimated_fee) + .ok_or_else(batch_amount_overflow_error)?; if balance < estimated_total { let formatted_balance = crate::cli::send::format_balance_with_symbol(&quantus_client, balance).await?; let formatted_needed = crate::cli::send::format_balance_with_symbol(&quantus_client, estimated_total) .await?; - let formatted_tip = - crate::cli::send::format_balance_with_symbol(&quantus_client, effective_tip) - .await?; let formatted_fee = crate::cli::send::format_balance_with_symbol(&quantus_client, estimated_fee) .await?; + if let Some(tip_amount) = submit_tip { + let formatted_tip = + crate::cli::send::format_balance_with_symbol(&quantus_client, tip_amount) + .await?; + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + ))); + } return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (estimated fee: {formatted_fee})" ))); } }, - Err(err) => { - log_print!( - "â„šī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", - err - ); - }, + Err(err) => + if submit_tip.is_some() { + log_print!( + "â„šī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", + err + ); + } else { + log_print!( + "â„šī¸ Fee estimation unavailable; proceeding with exact amount check only: {}", + err + ); + }, } // Submit batch transaction - let tx_hash = - batch_transfer(&quantus_client, &keypair, transfers, tip_amount, execution_mode).await?; + let tx_hash = submit_prebuilt_batch_transfer_call( + &quantus_client, + &keypair, + &transfers, + batch_call, + tip_amount, + execution_mode, + ) + .await?; let transaction_stage = execution_mode.transaction_stage(); log_print!( @@ -225,7 +248,7 @@ async fn handle_batch_send_command( tx_hash ); - if !execution_mode.should_refresh_post_submit_state() { + if !execution_mode.should_watch_transaction() { log_print!( "â„šī¸ The batch transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized-tx to wait before returning.", transaction_stage.success_detail() diff --git a/src/cli/common.rs b/src/cli/common.rs index fb47d13..1edce45 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -35,10 +35,6 @@ impl ExecutionMode { pub fn should_watch_transaction(self) -> bool { self.transaction_stage() != TransactionStage::Submitted } - - pub fn should_refresh_post_submit_state(self) -> bool { - self.should_watch_transaction() - } } impl TransactionStage { @@ -111,6 +107,13 @@ fn describe_watched_tx_event( } } +fn should_check_execution_success( + block_hash: &subxt::utils::H256, + already_checked_for: Option<&subxt::utils::H256>, +) -> bool { + already_checked_for != Some(block_hash) +} + /// Resolve address - if it's a wallet name, return the wallet's address /// If it's already an SS58 address, return it as is pub fn resolve_address(address_or_wallet_name: &str) -> Result { @@ -481,6 +484,7 @@ async fn wait_tx_inclusion( use indicatif::{ProgressBar, ProgressStyle}; let start_time = std::time::Instant::now(); + let mut execution_success_checked_for = None; let spinner = if !crate::log::is_verbose() { let pb = ProgressBar::new_spinner(); @@ -521,20 +525,29 @@ async fn wait_tx_inclusion( WatchedTxEvent::Validated }, TxStatus::Broadcasted => WatchedTxEvent::Broadcasted, - TxStatus::NoLongerInBestBlock => WatchedTxEvent::NoLongerInBestBlock, + TxStatus::NoLongerInBestBlock => { + execution_success_checked_for = None; + WatchedTxEvent::NoLongerInBestBlock + }, TxStatus::InBestBlock(tx_in_block) => { let block_hash = tx_in_block.block_hash(); crate::log_verbose!(" Transaction included in block: {:?}", block_hash); - if let Err(err) = - check_execution_success(client, &block_hash, tx_hash).await - { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "❌ Transaction failed in block ({}s)", - elapsed_secs - )); + if should_check_execution_success( + &block_hash, + execution_success_checked_for.as_ref(), + ) { + if let Err(err) = + check_execution_success(client, &block_hash, tx_hash).await + { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "❌ Transaction failed in block ({}s)", + elapsed_secs + )); + } + return Err(err); } - return Err(err); + execution_success_checked_for = Some(block_hash); } match describe_watched_tx_event(WatchedTxEvent::InBestBlock, target_stage)? { @@ -562,16 +575,22 @@ async fn wait_tx_inclusion( TxStatus::InFinalizedBlock(tx_in_block) => { let block_hash = tx_in_block.block_hash(); crate::log_verbose!(" Transaction finalized in block: {:?}", block_hash); - if let Err(err) = - check_execution_success(client, &block_hash, tx_hash).await - { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "❌ Transaction failed in finalized block ({}s)", - elapsed_secs - )); + if should_check_execution_success( + &block_hash, + execution_success_checked_for.as_ref(), + ) { + if let Err(err) = + check_execution_success(client, &block_hash, tx_hash).await + { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "❌ Transaction failed in finalized block ({}s)", + elapsed_secs + )); + } + return Err(err); } - return Err(err); + execution_success_checked_for = Some(block_hash); } match describe_watched_tx_event( WatchedTxEvent::InFinalizedBlock, @@ -758,7 +777,6 @@ mod tests { assert_eq!(mode.transaction_stage(), TransactionStage::Finalized); assert!(mode.should_watch_transaction()); - assert!(mode.should_refresh_post_submit_state()); } #[test] @@ -767,7 +785,6 @@ mod tests { assert_eq!(mode.transaction_stage(), TransactionStage::Submitted); assert!(!mode.should_watch_transaction()); - assert!(!mode.should_refresh_post_submit_state()); } #[test] @@ -819,4 +836,14 @@ mod tests { WatchDecision::Success ); } + + #[test] + fn execution_success_check_is_skipped_for_same_block() { + let best_block_hash = subxt::utils::H256::from([7u8; 32]); + let finalized_block_hash = subxt::utils::H256::from([8u8; 32]); + + assert!(should_check_execution_success(&best_block_hash, None)); + assert!(!should_check_execution_success(&best_block_hash, Some(&best_block_hash),)); + assert!(should_check_execution_success(&finalized_block_hash, Some(&best_block_hash),)); + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c788e9b..98cd06d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -612,11 +612,10 @@ async fn handle_compatibility_check(node_url: &str) -> crate::error::Result<()> ); log_print!("🔍 Compatibility Analysis:"); - log_print!(" â€ĸ Supported Runtime Versions: {:?}", crate::config::COMPATIBLE_RUNTIME_VERSIONS); - log_print!( - " â€ĸ Supported Transaction Versions: {:?}", - crate::config::COMPATIBLE_TRANSACTION_VERSIONS - ); + log_print!(" â€ĸ Supported runtime/transaction pairs:"); + for runtime in crate::config::COMPATIBLE_RUNTIMES { + log_print!(" - spec {} / tx {}", runtime.spec_version, runtime.transaction_version); + } log_print!(" â€ĸ Current Runtime Version: {}", runtime_version.spec_version); log_print!(" â€ĸ Current Transaction Version: {}", runtime_version.transaction_version); @@ -628,10 +627,6 @@ async fn handle_compatibility_check(node_url: &str) -> crate::error::Result<()> log_error!("❌ INCOMPATIBLE - This CLI version may not work with the connected node"); log_print!(" â€ĸ Some features may not work correctly"); log_print!(" â€ĸ Consider updating the CLI or connecting to a compatible node"); - log_print!(" â€ĸ Supported runtime/version pairs:"); - for runtime in crate::config::COMPATIBLE_RUNTIMES { - log_print!(" - spec {} / tx {}", runtime.spec_version, runtime.transaction_version); - } } log_print!(""); diff --git a/src/cli/send.rs b/src/cli/send.rs index 06c8592..6948668 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -7,8 +7,6 @@ use crate::{ use colored::Colorize; use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; -pub const DEFAULT_PRIORITY_TIP: u128 = 10_000_000_000; - /// Account balance data pub struct AccountBalanceData { pub free: u128, @@ -134,13 +132,8 @@ pub fn parse_amount_with_decimals(amount_str: &str, decimals: u8) -> Result Result { } pub fn effective_tip_amount(tip: Option) -> u128 { - tip.unwrap_or(DEFAULT_PRIORITY_TIP) + tip.unwrap_or_default() +} + +pub(crate) fn positive_tip_amount(tip: Option) -> Option { + tip.filter(|tip_amount| *tip_amount > 0) } fn build_transfer_call(resolved_address: &str, amount: u128) -> Result { @@ -372,7 +369,7 @@ pub async fn transfer_with_nonce( log_verbose!("âœī¸ Creating balance transfer extrinsic..."); let transfer_call = build_transfer_call(&resolved_address, amount)?; - let tip_to_use = effective_tip_amount(tip); + let submit_tip = positive_tip_amount(tip); // Submit the transaction with optional manual nonce let tx_hash = if let Some(manual_nonce) = nonce { @@ -381,7 +378,7 @@ pub async fn transfer_with_nonce( quantus_client, from_keypair, transfer_call, - Some(tip_to_use), + submit_tip, manual_nonce, execution_mode, ) @@ -391,7 +388,7 @@ pub async fn transfer_with_nonce( quantus_client, from_keypair, transfer_call, - Some(tip_to_use), + submit_tip, execution_mode, ) .await? @@ -402,15 +399,12 @@ pub async fn transfer_with_nonce( Ok(tx_hash) } -/// Batch transfer tokens to multiple recipients in a single transaction -pub async fn batch_transfer( +pub(crate) async fn validate_batch_transfer_request( quantus_client: &QuantusClient, from_keypair: &crate::wallet::QuantumKeyPair, - transfers: Vec<(String, u128)>, // (to_address, amount) pairs - tip: Option, - execution_mode: crate::cli::common::ExecutionMode, -) -> Result { - log_verbose!("🚀 Creating batch transfer transaction with {} transfers...", transfers.len()); + transfers: &[(String, u128)], +) -> Result<()> { + log_verbose!("🚀 Preparing batch transfer transaction with {} transfers...", transfers.len()); log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); if transfers.is_empty() { @@ -419,7 +413,6 @@ pub async fn batch_transfer( )); } - // Get dynamic limits from chain let (safe_limit, recommended_limit) = get_batch_limits(quantus_client).await.unwrap_or((500, 1000)); @@ -432,7 +425,6 @@ pub async fn batch_transfer( ))); } - // Warn about large batches if transfers.len() as u32 > safe_limit { log_verbose!( "âš ī¸ Large batch ({} transfers) - approaching chain limits (safe: {}, max: {})", @@ -442,21 +434,31 @@ pub async fn batch_transfer( ); } - for (to_address, amount) in &transfers { + for (to_address, amount) in transfers { log_verbose!(" To: {} Amount: {}", to_address.bright_green(), amount); } - log_verbose!("âœī¸ Creating batch extrinsic with {} calls...", transfers.len()); - let batch_call = build_batch_transfer_call(&transfers)?; - // Use provided tip or default tip - let tip_to_use = effective_tip_amount(tip); + Ok(()) +} + +pub(crate) async fn submit_prebuilt_batch_transfer_call( + quantus_client: &QuantusClient, + from_keypair: &crate::wallet::QuantumKeyPair, + transfers: &[(String, u128)], + batch_call: Call, + tip: Option, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result +where + Call: subxt::tx::Payload, +{ + log_verbose!("📤 Submitting batch extrinsic with {} calls...", transfers.len()); - // Submit the batch transaction let tx_hash = crate::cli::common::submit_transaction( quantus_client, from_keypair, batch_call, - Some(tip_to_use), + positive_tip_amount(tip), execution_mode, ) .await?; @@ -466,6 +468,28 @@ pub async fn batch_transfer( Ok(tx_hash) } +/// Batch transfer tokens to multiple recipients in a single transaction +pub async fn batch_transfer( + quantus_client: &QuantusClient, + from_keypair: &crate::wallet::QuantumKeyPair, + transfers: Vec<(String, u128)>, // (to_address, amount) pairs + tip: Option, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result { + validate_batch_transfer_request(quantus_client, from_keypair, &transfers).await?; + log_verbose!("âœī¸ Creating batch extrinsic with {} calls...", transfers.len()); + let batch_call = build_batch_transfer_call(&transfers)?; + submit_prebuilt_batch_transfer_call( + quantus_client, + from_keypair, + &transfers, + batch_call, + tip, + execution_mode, + ) + .await +} + // (Removed custom `AccountData` struct – we now use the runtime-generated type) /// Handle the send command @@ -517,6 +541,7 @@ pub async fn handle_send_command( None }; let effective_tip = effective_tip_amount(tip_amount); + let submit_tip = positive_tip_amount(tip_amount); let exact_required = checked_add(amount, effective_tip, "required send balance")?; if balance < exact_required { @@ -527,38 +552,44 @@ pub async fn handle_send_command( } let transfer_call = build_transfer_call(&resolved_address, amount)?; - match estimate_transaction_partial_fee( - &quantus_client, - &keypair, - &transfer_call, - Some(effective_tip), - ) - .await + match estimate_transaction_partial_fee(&quantus_client, &keypair, &transfer_call, submit_tip) + .await { Ok(estimated_fee) => { let estimated_total = checked_add(exact_required, estimated_fee, "required send balance")?; if balance < estimated_total { - let formatted_tip = - format_balance_with_symbol(&quantus_client, effective_tip).await?; let formatted_fee = format_balance_with_symbol(&quantus_client, estimated_fee).await?; let formatted_required = format_balance_with_symbol(&quantus_client, estimated_total).await?; + if let Some(tip_amount) = submit_tip { + let formatted_tip = + format_balance_with_symbol(&quantus_client, tip_amount).await?; + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance for amount + tip + estimated fee. Have: {formatted_balance}, Need: {formatted_required} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + ))); + } return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance for amount + tip + estimated fee. Have: {formatted_balance}, Need: {formatted_required} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + "Insufficient balance for amount + estimated fee. Have: {formatted_balance}, Need: {formatted_required} (estimated fee: {formatted_fee})" ))); } let formatted_estimated_fee = format_balance_with_symbol(&quantus_client, estimated_fee).await?; log_verbose!("💸 Estimated network fee: {}", formatted_estimated_fee.bright_cyan()); }, - Err(err) => { - log_verbose!( - "âš ī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", - err - ); - }, + Err(err) => + if submit_tip.is_some() { + log_verbose!( + "âš ī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", + err + ); + } else { + log_verbose!( + "âš ī¸ Fee estimation unavailable; proceeding with exact amount check only: {}", + err + ); + }, } // Create and submit transaction @@ -584,7 +615,7 @@ pub async fn handle_send_command( tx_hash ); - if !execution_mode.should_refresh_post_submit_state() { + if !execution_mode.should_watch_transaction() { log_print!( "â„šī¸ The transaction was {} but this command did not wait for block inclusion. Use --wait-for-transaction or --finalized-tx to wait before returning.", transaction_stage.success_detail() @@ -685,7 +716,7 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 #[cfg(test)] mod tests { - use super::parse_amount_with_decimals; + use super::{effective_tip_amount, parse_amount_with_decimals}; #[test] fn parses_exact_decimal_amounts() { @@ -694,6 +725,12 @@ mod tests { assert_eq!(parse_amount_with_decimals("1.000000000000", 12).unwrap(), 1_000_000_000_000); } + #[test] + fn accepts_single_leading_plus_for_positive_amounts() { + assert_eq!(parse_amount_with_decimals("+1.0", 12).unwrap(), 1_000_000_000_000); + assert_eq!(parse_amount_with_decimals("+0.000000000001", 12).unwrap(), 1); + } + #[test] fn rejects_malformed_and_invalid_amounts() { assert!(parse_amount_with_decimals("", 12).is_err()); @@ -706,6 +743,13 @@ mod tests { assert!(parse_amount_with_decimals("0.0000000000001", 12).is_err()); } + #[test] + fn rejects_invalid_plus_prefixed_amounts() { + assert!(parse_amount_with_decimals("+", 12).is_err()); + assert!(parse_amount_with_decimals("++1", 12).is_err()); + assert!(parse_amount_with_decimals("+0", 12).is_err()); + } + #[test] fn handles_u128_boundaries_exactly() { assert_eq!(parse_amount_with_decimals(&u128::MAX.to_string(), 0).unwrap(), u128::MAX); @@ -719,4 +763,14 @@ mod tests { let overflow = format!("{}.0", whole + 1); assert!(parse_amount_with_decimals(&overflow, 12).is_err()); } + + #[test] + fn default_tip_amount_is_zero() { + assert_eq!(effective_tip_amount(None), 0); + } + + #[test] + fn provided_tip_amount_is_preserved() { + assert_eq!(effective_tip_amount(Some(42)), 42); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index a8132f9..b3af9f6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,12 +8,6 @@ pub struct CompatibleRuntime { pub transaction_version: u32, } -/// List of runtime spec versions that this CLI is compatible with. -pub const COMPATIBLE_RUNTIME_VERSIONS: &[u32] = &[127]; - -/// List of transaction versions that this CLI is compatible with. -pub const COMPATIBLE_TRANSACTION_VERSIONS: &[u32] = &[2]; - /// Supported runtime / transaction version pairs for the checked-in metadata snapshot. pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = &[CompatibleRuntime { spec_version: 127, transaction_version: 2 }]; diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 497a24e..eef9943 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -357,7 +357,7 @@ impl WalletManager { pub async fn create_wallet_from_seed( &self, name: &str, - seed_hex: &str, + seed: &str, password: Option<&str>, ) -> Result { // Check if wallet already exists @@ -367,21 +367,21 @@ impl WalletManager { } // Validate seed hex format (should be 64 hex characters for 32 bytes) - if seed_hex.len() != 64 { + if seed.len() != 64 { return Err(WalletError::InvalidMnemonic.into()); // Reusing error type } // Convert hex to bytes - let seed_bytes = hex::decode(seed_hex).map_err(|_| WalletError::InvalidMnemonic)?; + let seed_bytes = hex::decode(seed).map_err(|_| WalletError::InvalidMnemonic)?; if seed_bytes.len() != 32 { return Err(WalletError::InvalidMnemonic.into()); } // Create DilithiumPair from seed - let seed_array: [u8; 32] = + let seed_bytes_32: [u8; 32] = seed_bytes.try_into().map_err(|_| WalletError::InvalidMnemonic)?; - let dilithium_pair = qp_dilithium_crypto::types::DilithiumPair::from_seed(&seed_array) + let dilithium_pair = qp_dilithium_crypto::types::DilithiumPair::from_seed(&seed_bytes_32) .map_err(|_| WalletError::InvalidMnemonic)?; // Convert to QuantumKeyPair @@ -509,8 +509,7 @@ pub fn load_keypair_from_wallet( #[cfg(test)] mod tests { use super::*; - use serial_test::serial; - use std::{fs, process::Command}; + use std::fs; use tempfile::TempDir; async fn create_test_wallet_manager() -> (WalletManager, TempDir) { @@ -841,40 +840,25 @@ mod tests { } #[tokio::test] - #[ignore] - async fn seed_output_probe() { + async fn test_wallet_creation_from_seed() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; - let seed_hex = "0101010101010101010101010101010101010101010101010101010101010101"; + let seed = "0101010101010101010101010101010101010101010101010101010101010101"; let wallet_info = wallet_manager - .create_wallet_from_seed("seed-output-probe", seed_hex, Some("probe-password")) + .create_wallet_from_seed("seed-based-wallet", seed, Some("probe-password")) .await .expect("Failed to create seed wallet"); - assert_eq!(wallet_info.name, "seed-output-probe"); + assert_eq!(wallet_info.name, "seed-based-wallet"); assert!(wallet_info.address.starts_with("qz")); - } - - #[test] - #[serial] - fn test_wallet_from_seed_does_not_emit_secret_output() { - let output = Command::new(std::env::current_exe().expect("Failed to locate test binary")) - .args(["--ignored", "--exact", "seed_output_probe", "--nocapture", "--test-threads=1"]) - .output() - .expect("Failed to run seed output probe"); - - assert!(output.status.success(), "probe test failed: {:?}", output); - - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}{stderr}"); - - assert!( - !combined.contains("0101010101010101010101010101010101010101010101010101010101010101"), - "seed hex leaked in output: {combined}" - ); - assert!(!combined.contains("Debug:"), "debug output leaked: {combined}"); - assert!(!combined.contains("seed_array"), "seed internals leaked in output: {combined}"); + assert_eq!(wallet_info.derivation_path, "m/"); + + let wallet_data = wallet_manager + .load_wallet("seed-based-wallet", "probe-password") + .expect("Failed to load seed wallet"); + assert!(wallet_data.mnemonic.is_none()); + assert_eq!(wallet_data.derivation_path, "m/"); + assert_eq!(wallet_data.metadata.get("from_seed").map(String::as_str), Some("true")); } #[tokio::test] From e3e3fb8048c6db2b2eb124986d19f3666965810e Mon Sep 17 00:00:00 2001 From: Ethan Date: Fri, 24 Apr 2026 21:07:13 -0500 Subject: [PATCH 4/6] *fix cargo audit issue --- Cargo.lock | 26 +++++++++++++------------- Cargo.toml | 4 ++-- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0287c30..efa00f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1639,7 +1639,7 @@ checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" dependencies = [ "getrandom 0.3.4", "libm", - "rand 0.9.2", + "rand 0.9.4", "siphasher", ] @@ -3186,7 +3186,7 @@ dependencies = [ "p3-maybe-rayon", "p3-util", "paste", - "rand 0.9.2", + "rand 0.9.4", "serde", "tracing", ] @@ -3205,7 +3205,7 @@ dependencies = [ "p3-symmetric", "p3-util", "paste", - "rand 0.9.2", + "rand 0.9.4", "serde", ] @@ -3219,7 +3219,7 @@ dependencies = [ "p3-field", "p3-maybe-rayon", "p3-util", - "rand 0.9.2", + "rand 0.9.4", "serde", "tracing", "transpose", @@ -3241,7 +3241,7 @@ dependencies = [ "p3-field", "p3-symmetric", "p3-util", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] @@ -3254,7 +3254,7 @@ dependencies = [ "p3-mds", "p3-symmetric", "p3-util", - "rand 0.9.2", + "rand 0.9.4", ] [[package]] @@ -3790,7 +3790,7 @@ dependencies = [ "p3-field", "p3-goldilocks", "p3-poseidon2", - "rand 0.9.2", + "rand 0.9.4", "rand_chacha 0.9.0", ] @@ -3963,7 +3963,7 @@ dependencies = [ "qp-wormhole-verifier", "qp-zk-circuits-common", "quinn-proto", - "rand 0.9.2", + "rand 0.9.4", "reqwest", "rpassword", "rustls-webpki", @@ -4010,7 +4010,7 @@ dependencies = [ "fastbloom", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -4076,9 +4076,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4400,9 +4400,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index 98b74e3..a05a138 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,8 +64,8 @@ bytes = "1.11.1" # Force patched version of quinn-proto (RUSTSEC-2026-0037) quinn-proto = "0.11.14" -# Force patched version of rustls-webpki (RUSTSEC-2026-0098, RUSTSEC-2026-0099) -rustls-webpki = "0.103.12" +# Force patched version of rustls-webpki (RUSTSEC-2026-0098, RUSTSEC-2026-0099, RUSTSEC-2026-0104) +rustls-webpki = "0.103.13" # Blockchain deps: align with chain workspace; use chain primitives for qp-dilithium-crypto so sp-* versions match codec = { package = "parity-scale-codec", version = "3.7", features = ["derive"] } From 9ced4e40e5eedc182d4717ab381627903916e38b Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 3 May 2026 22:55:56 -0500 Subject: [PATCH 5/6] *Improves transfer validation and address handling *Centralizes balance and fee checks so single and batch transfers report shortages more consistently and avoid duplicated overflow logic. *Adds shared address-to-account conversion for transfer and recovery commands, reducing repeated parsing code and keeping wallet-name resolution consistent. *Refactors transaction status watching into smaller helpers for clearer success and failure handling, and separates finalization from the generic wait flag. --- src/cli/batch.rs | 88 +++----------- src/cli/common.rs | 288 +++++++++++++++++++++++++++++--------------- src/cli/recovery.rs | 114 +++--------------- src/cli/send.rs | 254 +++++++++++++++++++++----------------- src/main.rs | 2 +- 5 files changed, 367 insertions(+), 379 deletions(-) diff --git a/src/cli/batch.rs b/src/cli/batch.rs index d01b42a..45099c7 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -2,8 +2,8 @@ use crate::{ chain::client::QuantusClient, cli::send::{ - build_batch_transfer_call, get_batch_limits, load_transfers_from_file, - submit_prebuilt_batch_transfer_call, validate_and_format_amount, + build_batch_transfer_call, checked_add, ensure_balance_covers_call, get_batch_limits, + load_transfers_from_file, submit_prebuilt_batch_transfer_call, validate_and_format_amount, validate_batch_transfer_request, }, error::Result, @@ -12,12 +12,6 @@ use crate::{ use clap::Subcommand; use colored::Colorize; -const BATCH_AMOUNT_TOTAL_TOO_LARGE: &str = "Batch amount total is too large to represent"; - -fn batch_amount_overflow_error() -> crate::error::QuantusError { - crate::error::QuantusError::Generic(BATCH_AMOUNT_TOTAL_TOO_LARGE.to_string()) -} - #[derive(Subcommand, Debug)] pub enum BatchCommands { /// Send tokens to multiple recipients in a single batch transaction @@ -84,7 +78,7 @@ pub async fn handle_batch_command( count, to, amount, - } => + } => { handle_batch_send_command( from, node_url, @@ -97,9 +91,11 @@ pub async fn handle_batch_command( amount, execution_mode, ) - .await, - BatchCommands::Config { limits, info } => - handle_batch_config_command(node_url, limits, info).await, + .await + }, + BatchCommands::Config { limits, info } => { + handle_batch_config_command(node_url, limits, info).await + }, } } @@ -161,73 +157,23 @@ async fn handle_batch_send_command( // Check balance let balance = crate::cli::send::get_balance(&quantus_client, &from_account_id).await?; - let total_amount = transfers.iter().try_fold(0u128, |acc, (_, amount)| { - acc.checked_add(*amount).ok_or_else(batch_amount_overflow_error) - })?; - let exact_required = total_amount - .checked_add(effective_tip) - .ok_or_else(batch_amount_overflow_error)?; - - if balance < exact_required { - let formatted_balance = - crate::cli::send::format_balance_with_symbol(&quantus_client, balance).await?; - let formatted_needed = - crate::cli::send::format_balance_with_symbol(&quantus_client, exact_required).await?; - let detail = if submit_tip.is_some() { " (including tip)" } else { "" }; - return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed}{detail}" - ))); - } + let total_amount = transfers + .iter() + .try_fold(0u128, |acc, (_, amount)| checked_add(acc, *amount, "batch transfer total"))?; + let exact_required = checked_add(total_amount, effective_tip, "required batch balance")?; log_verbose!("âœī¸ Creating batch extrinsic with {} calls...", transfers.len()); let batch_call = build_batch_transfer_call(&transfers)?; - match crate::cli::send::estimate_transaction_partial_fee( + ensure_balance_covers_call( &quantus_client, &keypair, &batch_call, + balance, + exact_required, submit_tip, + "batch", ) - .await - { - Ok(estimated_fee) => { - let estimated_total = exact_required - .checked_add(estimated_fee) - .ok_or_else(batch_amount_overflow_error)?; - if balance < estimated_total { - let formatted_balance = - crate::cli::send::format_balance_with_symbol(&quantus_client, balance).await?; - let formatted_needed = - crate::cli::send::format_balance_with_symbol(&quantus_client, estimated_total) - .await?; - let formatted_fee = - crate::cli::send::format_balance_with_symbol(&quantus_client, estimated_fee) - .await?; - if let Some(tip_amount) = submit_tip { - let formatted_tip = - crate::cli::send::format_balance_with_symbol(&quantus_client, tip_amount) - .await?; - return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (tip: {formatted_tip}, estimated fee: {formatted_fee})" - ))); - } - return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (estimated fee: {formatted_fee})" - ))); - } - }, - Err(err) => - if submit_tip.is_some() { - log_print!( - "â„šī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", - err - ); - } else { - log_print!( - "â„šī¸ Fee estimation unavailable; proceeding with exact amount check only: {}", - err - ); - }, - } + .await?; // Submit batch transaction let tx_hash = submit_prebuilt_batch_transfer_call( diff --git a/src/cli/common.rs b/src/cli/common.rs index 1edce45..d442703 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -8,6 +8,8 @@ use subxt::{ OnlineClient, }; +pub type SubxtAccountId32 = subxt::ext::subxt_core::utils::AccountId32; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ExecutionMode { pub finalized: bool, @@ -81,22 +83,26 @@ fn describe_watched_tx_event( target_stage: TransactionStage, ) -> Result { match event { - WatchedTxEvent::Validated | - WatchedTxEvent::Broadcasted | - WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), - WatchedTxEvent::InBestBlock => + WatchedTxEvent::Validated + | WatchedTxEvent::Broadcasted + | WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), + WatchedTxEvent::InBestBlock => { if target_stage == TransactionStage::Finalized { Ok(WatchDecision::WaitForFinalization) } else { Ok(WatchDecision::Success) - }, + } + }, WatchedTxEvent::InFinalizedBlock => Ok(WatchDecision::Success), - WatchedTxEvent::Error(message) => - Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))), - WatchedTxEvent::Invalid(message) => - Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))), - WatchedTxEvent::Dropped(message) => - Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))), + WatchedTxEvent::Error(message) => { + Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))) + }, + WatchedTxEvent::Invalid(message) => { + Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))) + }, + WatchedTxEvent::Dropped(message) => { + Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))) + }, WatchedTxEvent::StreamError(message) => Err(crate::error::QuantusError::NetworkError( format!("Transaction status stream error: {message}"), )), @@ -114,6 +120,127 @@ fn should_check_execution_success( already_checked_for != Some(block_hash) } +type TxWatchFlow = std::ops::ControlFlow, ()>; + +fn update_waiting_spinner( + spinner: Option<&indicatif::ProgressBar>, + target_stage: TransactionStage, + elapsed_secs: u64, +) { + if let Some(pb) = spinner { + if target_stage == TransactionStage::Finalized { + pb.set_message(format!("Waiting for finalized block... ({}s)", elapsed_secs)); + } else { + pb.set_message(format!("Waiting for block inclusion... ({}s)", elapsed_secs)); + } + } +} + +fn finish_failed_execution( + spinner: Option<&indicatif::ProgressBar>, + message: &str, + elapsed_secs: u64, +) { + if let Some(pb) = spinner { + pb.finish_with_message(format!("{message} ({}s)", elapsed_secs)); + } +} + +async fn ensure_execution_success_for_block( + client: &OnlineClient, + block_hash: &subxt::utils::H256, + tx_hash: &subxt::utils::H256, + execution_success_checked_for: &mut Option, +) -> Result<()> { + if should_check_execution_success(block_hash, execution_success_checked_for.as_ref()) { + check_execution_success(client, block_hash, tx_hash).await?; + *execution_success_checked_for = Some(*block_hash); + } + Ok(()) +} + +async fn handle_in_best_block( + client: &OnlineClient, + tx_hash: &subxt::utils::H256, + block_hash: subxt::utils::H256, + target_stage: TransactionStage, + execution_success_checked_for: &mut Option, + spinner: Option<&indicatif::ProgressBar>, + elapsed_secs: u64, +) -> TxWatchFlow { + crate::log_verbose!(" Transaction included in block: {:?}", block_hash); + if let Err(err) = ensure_execution_success_for_block( + client, + &block_hash, + tx_hash, + execution_success_checked_for, + ) + .await + { + finish_failed_execution(spinner, "❌ Transaction failed in block", elapsed_secs); + return std::ops::ControlFlow::Break(Err(err)); + } + + match describe_watched_tx_event(WatchedTxEvent::InBestBlock, target_stage) { + Ok(WatchDecision::WaitForFinalization) => { + if let Some(pb) = spinner { + pb.set_message(format!( + "In best block, waiting for finalization... ({}s)", + elapsed_secs + )); + } + std::ops::ControlFlow::Continue(()) + }, + Ok(WatchDecision::Success) => { + if let Some(pb) = spinner { + pb.finish_with_message(format!( + "✅ Transaction included in block! ({}s)", + elapsed_secs + )); + } + std::ops::ControlFlow::Break(Ok(())) + }, + Ok(WatchDecision::Continue) => std::ops::ControlFlow::Continue(()), + Err(err) => std::ops::ControlFlow::Break(Err(err)), + } +} + +async fn handle_in_finalized_block( + client: &OnlineClient, + tx_hash: &subxt::utils::H256, + block_hash: subxt::utils::H256, + target_stage: TransactionStage, + execution_success_checked_for: &mut Option, + spinner: Option<&indicatif::ProgressBar>, + elapsed_secs: u64, +) -> TxWatchFlow { + crate::log_verbose!(" Transaction finalized in block: {:?}", block_hash); + if let Err(err) = ensure_execution_success_for_block( + client, + &block_hash, + tx_hash, + execution_success_checked_for, + ) + .await + { + finish_failed_execution(spinner, "❌ Transaction failed in finalized block", elapsed_secs); + return std::ops::ControlFlow::Break(Err(err)); + } + + match describe_watched_tx_event(WatchedTxEvent::InFinalizedBlock, target_stage) { + Ok(WatchDecision::Success) => { + if let Some(pb) = spinner { + pb.finish_with_message(format!("✅ Transaction finalized! ({}s)", elapsed_secs)); + } + std::ops::ControlFlow::Break(Ok(())) + }, + Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => { + std::ops::ControlFlow::Continue(()) + }, + Err(err) => std::ops::ControlFlow::Break(Err(err)), + } +} + /// Resolve address - if it's a wallet name, return the wallet's address /// If it's already an SS58 address, return it as is pub fn resolve_address(address_or_wallet_name: &str) -> Result { @@ -140,6 +267,27 @@ pub fn resolve_address(address_or_wallet_name: &str) -> Result { ))) } +/// Resolve a wallet name or SS58 address and convert it into the AccountId32 type used by SubXT. +pub fn resolve_to_subxt_account_id(address_or_wallet_name: &str) -> Result { + let (_, account_id) = resolve_address_with_subxt_account_id(address_or_wallet_name)?; + Ok(account_id) +} + +/// Resolve a wallet name or SS58 address and return both the SS58 string and SubXT account id. +pub fn resolve_address_with_subxt_account_id( + address_or_wallet_name: &str, +) -> Result<(String, SubxtAccountId32)> { + let resolved_address = resolve_address(address_or_wallet_name)?; + let (account_id_sp, _) = + AccountId32::from_ss58check_with_version(&resolved_address).map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Invalid destination address {resolved_address}: {e:?}" + )) + })?; + let account_id_bytes: [u8; 32] = *account_id_sp.as_ref(); + Ok((resolved_address, SubxtAccountId32::from(account_id_bytes))) +} + /// Get fresh nonce for account from the latest block using existing QuantusClient /// This function ensures we always get the most current nonce from the chain /// to avoid "Transaction is outdated" errors @@ -345,11 +493,11 @@ where let error_msg = format!("{e:?}"); // Check if it's a retryable error - let is_retryable = error_msg.contains("Priority is too low") || - error_msg.contains("Transaction is outdated") || - error_msg.contains("Transaction is temporarily banned") || - error_msg.contains("Transaction has a bad signature") || - error_msg.contains("Invalid Transaction"); + let is_retryable = error_msg.contains("Priority is too low") + || error_msg.contains("Transaction is outdated") + || error_msg.contains("Transaction is temporarily banned") + || error_msg.contains("Transaction has a bad signature") + || error_msg.contains("Invalid Transaction"); if is_retryable && attempt < 5 { log_verbose!( @@ -531,82 +679,36 @@ async fn wait_tx_inclusion( }, TxStatus::InBestBlock(tx_in_block) => { let block_hash = tx_in_block.block_hash(); - crate::log_verbose!(" Transaction included in block: {:?}", block_hash); - if should_check_execution_success( - &block_hash, - execution_success_checked_for.as_ref(), - ) { - if let Err(err) = - check_execution_success(client, &block_hash, tx_hash).await - { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "❌ Transaction failed in block ({}s)", - elapsed_secs - )); - } - return Err(err); - } - execution_success_checked_for = Some(block_hash); - } - match describe_watched_tx_event(WatchedTxEvent::InBestBlock, target_stage)? + match handle_in_best_block( + client, + tx_hash, + block_hash, + target_stage, + &mut execution_success_checked_for, + spinner.as_ref(), + elapsed_secs, + ) + .await { - WatchDecision::WaitForFinalization => { - if let Some(ref pb) = spinner { - pb.set_message(format!( - "In best block, waiting for finalization... ({}s)", - elapsed_secs - )); - } - continue; - }, - WatchDecision::Success => { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "✅ Transaction included in block! ({}s)", - elapsed_secs - )); - } - return Ok(()); - }, - WatchDecision::Continue => continue, + std::ops::ControlFlow::Continue(()) => continue, + std::ops::ControlFlow::Break(result) => return result, } }, TxStatus::InFinalizedBlock(tx_in_block) => { let block_hash = tx_in_block.block_hash(); - crate::log_verbose!(" Transaction finalized in block: {:?}", block_hash); - if should_check_execution_success( - &block_hash, - execution_success_checked_for.as_ref(), - ) { - if let Err(err) = - check_execution_success(client, &block_hash, tx_hash).await - { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "❌ Transaction failed in finalized block ({}s)", - elapsed_secs - )); - } - return Err(err); - } - execution_success_checked_for = Some(block_hash); - } - match describe_watched_tx_event( - WatchedTxEvent::InFinalizedBlock, + match handle_in_finalized_block( + client, + tx_hash, + block_hash, target_stage, - )? { - WatchDecision::Success => { - if let Some(pb) = spinner { - pb.finish_with_message(format!( - "✅ Transaction finalized! ({}s)", - elapsed_secs - )); - } - return Ok(()); - }, - WatchDecision::Continue | WatchDecision::WaitForFinalization => - continue, + &mut execution_success_checked_for, + spinner.as_ref(), + elapsed_secs, + ) + .await + { + std::ops::ControlFlow::Continue(()) => continue, + std::ops::ControlFlow::Break(result) => return result, } }, TxStatus::Error { message } => WatchedTxEvent::Error(message), @@ -620,19 +722,7 @@ async fn wait_tx_inclusion( match describe_watched_tx_event(next_event, target_stage) { Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => { - if let Some(ref pb) = spinner { - if target_stage == TransactionStage::Finalized { - pb.set_message(format!( - "Waiting for finalized block... ({}s)", - elapsed_secs - )); - } else { - pb.set_message(format!( - "Waiting for block inclusion... ({}s)", - elapsed_secs - )); - } - } + update_waiting_spinner(spinner.as_ref(), target_stage, elapsed_secs); }, Ok(WatchDecision::Success) => return Ok(()), Err(err) => { diff --git a/src/cli/recovery.rs b/src/cli/recovery.rs index 6b04467..b883403 100644 --- a/src/cli/recovery.rs +++ b/src/cli/recovery.rs @@ -1,5 +1,7 @@ use crate::{ - chain::quantus_subxt, cli::common::resolve_address, log_error, log_print, log_success, + chain::quantus_subxt, + cli::common::{resolve_address_with_subxt_account_id, resolve_to_subxt_account_id}, + log_error, log_print, log_success, }; use clap::Subcommand; // no colored output needed here @@ -180,12 +182,7 @@ pub async fn handle_recovery_command( let rescuer_addr = rescuer_key.to_account_id_ss58check(); log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); - let lost_resolved = resolve_address(&lost)?; - let lost_id_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let lost_id_bytes: [u8; 32] = *lost_id_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_id_bytes); + let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .initiate_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); @@ -209,18 +206,8 @@ pub async fn handle_recovery_command( RecoveryCommands::Vouch { friend, lost, rescuer, password, password_file } => { let friend_key = crate::wallet::load_keypair_from_wallet(&friend, password, password_file)?; - let lost_resolved = resolve_address(&lost)?; - let rescuer_resolved = resolve_address(&rescuer)?; - let lost_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let lost_bytes: [u8; 32] = *lost_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_bytes); - let rescuer_sp = SpAccountId32::from_ss58check(&rescuer_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid rescuer address: {e:?}")) - })?; - let rescuer_bytes: [u8; 32] = *rescuer_sp.as_ref(); - let rescuer_id = subxt::ext::subxt_core::utils::AccountId32::from(rescuer_bytes); + let lost_id = resolve_to_subxt_account_id(&lost)?; + let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx().recovery().vouch_recovery( subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id), subxt::ext::subxt_core::utils::MultiAddress::Id(rescuer_id), @@ -244,12 +231,7 @@ pub async fn handle_recovery_command( RecoveryCommands::Claim { rescuer, lost, password, password_file } => { let rescuer_key = crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let lost_resolved = resolve_address(&lost)?; - let lost_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let lost_bytes: [u8; 32] = *lost_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_bytes); + let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .claim_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); @@ -286,32 +268,14 @@ pub async fn handle_recovery_command( log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); - let lost_resolved = resolve_address(&lost)?; - let dest_resolved = resolve_address(&dest)?; + let (lost_resolved, lost_id) = resolve_address_with_subxt_account_id(&lost)?; + let (dest_resolved, dest_id) = resolve_address_with_subxt_account_id(&dest)?; log_print!("🆘 Lost input: {} -> {}", lost, lost_resolved); log_print!("đŸŽ¯ Dest input: {} -> {}", dest, dest_resolved); log_print!("🛟 keep_alive: {}", keep_alive); - let lost_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let dest_sp = SpAccountId32::from_ss58check(&dest_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid dest address: {e:?}")) - })?; - - let lost_id_bytes: [u8; 32] = *lost_sp.as_ref(); - let dest_id_bytes: [u8; 32] = *dest_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_id_bytes); - let dest_id = subxt::ext::subxt_core::utils::AccountId32::from(dest_id_bytes); - // Check proxy mapping for rescuer - let rescuer_sp = SpAccountId32::from_ss58check(&rescuer_addr).map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Invalid rescuer address from wallet: {e:?}" - )) - })?; - let rescuer_id_bytes: [u8; 32] = *rescuer_sp.as_ref(); - let rescuer_id = subxt::ext::subxt_core::utils::AccountId32::from(rescuer_id_bytes); + let rescuer_id = resolve_to_subxt_account_id(&rescuer_addr)?; let proxy_storage = quantus_subxt::api::storage().recovery().proxy(rescuer_id); let latest = quantus_client.get_latest_block().await?; let proxy_result = @@ -404,25 +368,13 @@ pub async fn handle_recovery_command( log_print!("🔑 Rescuer: {}", rescuer); log_print!("🔑 Rescuer address: {}", rescuer_addr); - let lost_resolved = resolve_address(&lost)?; - let dest_resolved = resolve_address(&dest)?; + let (lost_resolved, lost_id) = resolve_address_with_subxt_account_id(&lost)?; + let (dest_resolved, dest_id) = resolve_address_with_subxt_account_id(&dest)?; log_print!("🆘 Lost input: {} -> {}", lost, lost_resolved); log_print!("đŸŽ¯ Dest input: {} -> {}", dest, dest_resolved); log_print!("đŸ’ĩ amount_quan: {} (QUAN_DECIMALS={})", amount_quan, QUAN_DECIMALS); log_print!("🛟 keep_alive: {}", keep_alive); - let lost_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let dest_sp = SpAccountId32::from_ss58check(&dest_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid dest address: {e:?}")) - })?; - - let lost_id_bytes: [u8; 32] = *lost_sp.as_ref(); - let dest_id_bytes: [u8; 32] = *dest_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_id_bytes); - let dest_id = subxt::ext::subxt_core::utils::AccountId32::from(dest_id_bytes); - let amount_plancks = amount_quan.saturating_mul(QUAN_DECIMALS); log_print!("đŸ’ĩ amount_plancks: {}", amount_plancks); @@ -499,12 +451,7 @@ pub async fn handle_recovery_command( RecoveryCommands::Close { lost, rescuer, password, password_file } => { let lost_key = crate::wallet::load_keypair_from_wallet(&lost, password, password_file)?; - let rescuer_resolved = resolve_address(&rescuer)?; - let rescuer_sp = SpAccountId32::from_ss58check(&rescuer_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid rescuer address: {e:?}")) - })?; - let rescuer_bytes: [u8; 32] = *rescuer_sp.as_ref(); - let rescuer_id = subxt::ext::subxt_core::utils::AccountId32::from(rescuer_bytes); + let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let call = quantus_subxt::api::tx() .recovery() .close_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(rescuer_id)); @@ -529,12 +476,7 @@ pub async fn handle_recovery_command( RecoveryCommands::CancelProxy { rescuer, lost, password, password_file } => { let rescuer_key = crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let lost_resolved = resolve_address(&lost)?; - let lost_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let lost_bytes: [u8; 32] = *lost_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_bytes); + let lost_id = resolve_to_subxt_account_id(&lost)?; let call = quantus_subxt::api::tx() .recovery() .cancel_recovered(subxt::ext::subxt_core::utils::MultiAddress::Id(lost_id)); @@ -556,18 +498,8 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Active { lost, rescuer } => { - let lost_resolved = resolve_address(&lost)?; - let rescuer_resolved = resolve_address(&rescuer)?; - let lost_sp = SpAccountId32::from_ss58check(&lost_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid lost address: {e:?}")) - })?; - let lost_bytes: [u8; 32] = *lost_sp.as_ref(); - let lost_id = subxt::ext::subxt_core::utils::AccountId32::from(lost_bytes); - let rescuer_sp = SpAccountId32::from_ss58check(&rescuer_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid rescuer address: {e:?}")) - })?; - let rescuer_bytes: [u8; 32] = *rescuer_sp.as_ref(); - let rescuer_id = subxt::ext::subxt_core::utils::AccountId32::from(rescuer_bytes); + let lost_id = resolve_to_subxt_account_id(&lost)?; + let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let storage_addr = quantus_subxt::api::storage().recovery().active_recoveries(lost_id, rescuer_id); let latest = quantus_client.get_latest_block().await?; @@ -595,12 +527,7 @@ pub async fn handle_recovery_command( }, RecoveryCommands::ProxyOf { rescuer } => { - let rescuer_resolved = resolve_address(&rescuer)?; - let rescuer_sp = SpAccountId32::from_ss58check(&rescuer_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid rescuer address: {e:?}")) - })?; - let rescuer_bytes: [u8; 32] = *rescuer_sp.as_ref(); - let rescuer_id = subxt::ext::subxt_core::utils::AccountId32::from(rescuer_bytes); + let rescuer_id = resolve_to_subxt_account_id(&rescuer)?; let storage_addr = quantus_subxt::api::storage().recovery().proxy(rescuer_id); let latest = quantus_client.get_latest_block().await?; let value = quantus_client @@ -620,12 +547,7 @@ pub async fn handle_recovery_command( }, RecoveryCommands::Config { account } => { - let account_resolved = resolve_address(&account)?; - let account_sp = SpAccountId32::from_ss58check(&account_resolved).map_err(|e| { - crate::error::QuantusError::Generic(format!("Invalid account address: {e:?}")) - })?; - let account_bytes: [u8; 32] = *account_sp.as_ref(); - let account_id = subxt::ext::subxt_core::utils::AccountId32::from(account_bytes); + let account_id = resolve_to_subxt_account_id(&account)?; let storage_addr = quantus_subxt::api::storage().recovery().recoverable(account_id); let latest = quantus_client.get_latest_block().await?; let value = quantus_client diff --git a/src/cli/send.rs b/src/cli/send.rs index 6948668..b3b4166 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -1,11 +1,12 @@ use crate::{ chain::{client::QuantusClient, quantus_subxt}, - cli::common::resolve_address, + cli::common::{ + resolve_address_with_subxt_account_id, resolve_to_subxt_account_id, SubxtAccountId32, + }, error::Result, log_info, log_print, log_success, log_verbose, }; use colored::Colorize; -use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; /// Account balance data pub struct AccountBalanceData { @@ -23,17 +24,7 @@ pub async fn get_account_data( log_verbose!("💰 Querying balance for account: {}", account_address.bright_green()); - // Decode the SS58 address into `AccountId32` (sp-core) first â€Ļ - let (account_id_sp, _) = - SpAccountId32::from_ss58check_with_version(account_address).map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Invalid account address '{account_address}': {e:?}" - )) - })?; - - // â€Ļ then convert into the `subxt` representation expected by the generated API. - let bytes: [u8; 32] = *account_id_sp.as_ref(); - let account_id = subxt::ext::subxt_core::utils::AccountId32::from(bytes); + let account_id = resolve_to_subxt_account_id(account_address)?; // Build the storage key for `System::Account` and fetch (or default-init) it. let storage_addr = api::storage().system().account(account_id); @@ -239,7 +230,7 @@ pub async fn validate_and_format_amount( Ok((raw_amount, formatted)) } -fn checked_add(lhs: u128, rhs: u128, context: &str) -> Result { +pub(crate) fn checked_add(lhs: u128, rhs: u128, context: &str) -> Result { lhs.checked_add(rhs).ok_or_else(|| { crate::error::QuantusError::Generic(format!("Value overflow while computing {context}")) }) @@ -253,19 +244,14 @@ pub(crate) fn positive_tip_amount(tip: Option) -> Option { tip.filter(|tip_amount| *tip_amount > 0) } -fn build_transfer_call(resolved_address: &str, amount: u128) -> Result { - let (to_account_id_sp, _) = SpAccountId32::from_ss58check_with_version(resolved_address) - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Invalid destination address: {e:?}")) - })?; - - let to_account_id_bytes: [u8; 32] = *to_account_id_sp.as_ref(); - let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); - - Ok(quantus_subxt::api::tx().balances().transfer_allow_death( +fn build_transfer_call_for_account_id( + to_account_id: SubxtAccountId32, + amount: u128, +) -> impl subxt::tx::Payload { + quantus_subxt::api::tx().balances().transfer_allow_death( subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), amount, - )) + ) } pub(crate) fn build_batch_transfer_call( @@ -277,15 +263,7 @@ pub(crate) fn build_batch_transfer_call( let mut calls = Vec::with_capacity(transfers.len()); for (to_address, amount) in transfers { - let resolved_address = crate::cli::common::resolve_address(to_address)?; - let to_account_id_sp = SpAccountId32::from_ss58check(&resolved_address).map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Invalid destination address {resolved_address}: {e:?}" - )) - })?; - - let to_account_id_bytes: [u8; 32] = *to_account_id_sp.as_ref(); - let to_account_id = subxt::ext::subxt_core::utils::AccountId32::from(to_account_id_bytes); + let to_account_id = resolve_to_subxt_account_id(to_address)?; calls.push(RuntimeCall::Balances(BalancesCall::transfer_allow_death { dest: subxt::ext::subxt_core::utils::MultiAddress::Id(to_account_id), @@ -333,7 +311,106 @@ where }) } -/// Transfer tokens with automatic nonce +pub(crate) async fn ensure_balance_covers_call( + quantus_client: &QuantusClient, + keypair: &crate::wallet::QuantumKeyPair, + call: &Call, + balance: u128, + exact_required: u128, + submit_tip: Option, + label: &str, +) -> Result<()> +where + Call: subxt::tx::Payload, +{ + if balance < exact_required { + return Err(crate::error::QuantusError::InsufficientBalance { + available: balance, + required: exact_required, + }); + } + + match estimate_transaction_partial_fee(quantus_client, keypair, call, submit_tip).await { + Ok(estimated_fee) => { + let estimated_total = + checked_add(exact_required, estimated_fee, "required balance including fee")?; + if balance < estimated_total { + let formatted_balance = format_balance_with_symbol(quantus_client, balance).await?; + let formatted_needed = + format_balance_with_symbol(quantus_client, estimated_total).await?; + let formatted_fee = + format_balance_with_symbol(quantus_client, estimated_fee).await?; + if let Some(tip_amount) = submit_tip { + let formatted_tip = + format_balance_with_symbol(quantus_client, tip_amount).await?; + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance for {label}. Have: {formatted_balance}, Need: {formatted_needed} (tip: {formatted_tip}, estimated fee: {formatted_fee})" + ))); + } + return Err(crate::error::QuantusError::Generic(format!( + "Insufficient balance for {label}. Have: {formatted_balance}, Need: {formatted_needed} (estimated fee: {formatted_fee})" + ))); + } + + let formatted_estimated_fee = + format_balance_with_symbol(quantus_client, estimated_fee).await?; + log_verbose!("💸 Estimated network fee: {}", formatted_estimated_fee.bright_cyan()); + }, + Err(err) => { + if submit_tip.is_some() { + log_verbose!( + "âš ī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", + err + ); + } else { + log_verbose!( + "âš ī¸ Fee estimation unavailable; proceeding with exact amount check only: {}", + err + ); + } + }, + } + + Ok(()) +} + +async fn submit_transfer_call( + quantus_client: &QuantusClient, + from_keypair: &crate::wallet::QuantumKeyPair, + transfer_call: Call, + submit_tip: Option, + nonce: Option, + execution_mode: crate::cli::common::ExecutionMode, +) -> Result +where + Call: subxt::tx::Payload, +{ + if let Some(manual_nonce) = nonce { + log_verbose!("đŸ”ĸ Using manual nonce: {}", manual_nonce); + crate::cli::common::submit_transaction_with_nonce( + quantus_client, + from_keypair, + transfer_call, + submit_tip, + manual_nonce, + execution_mode, + ) + .await + } else { + crate::cli::common::submit_transaction( + quantus_client, + from_keypair, + transfer_call, + submit_tip, + execution_mode, + ) + .await + } +} + +/// Transfer tokens with automatic nonce. +/// +/// Pass `Some(0)` or `None` to omit a tip. #[allow(dead_code)] // Used by external libraries via lib.rs export pub async fn transfer( quantus_client: &QuantusClient, @@ -347,7 +424,9 @@ pub async fn transfer( .await } -/// Transfer tokens with manual nonce override +/// Transfer tokens with manual nonce override. +/// +/// Pass `Some(0)` or `None` to omit a tip. pub async fn transfer_with_nonce( quantus_client: &QuantusClient, from_keypair: &crate::wallet::QuantumKeyPair, @@ -363,36 +442,24 @@ pub async fn transfer_with_nonce( log_verbose!(" Amount: {}", amount); // Resolve the destination address (could be wallet name or SS58 address) - let resolved_address = resolve_address(to_address)?; + let (resolved_address, to_account_id) = resolve_address_with_subxt_account_id(to_address)?; log_verbose!(" Resolved to: {}", resolved_address.bright_green()); log_verbose!("âœī¸ Creating balance transfer extrinsic..."); - let transfer_call = build_transfer_call(&resolved_address, amount)?; + let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); let submit_tip = positive_tip_amount(tip); // Submit the transaction with optional manual nonce - let tx_hash = if let Some(manual_nonce) = nonce { - log_verbose!("đŸ”ĸ Using manual nonce: {}", manual_nonce); - crate::cli::common::submit_transaction_with_nonce( - quantus_client, - from_keypair, - transfer_call, - submit_tip, - manual_nonce, - execution_mode, - ) - .await? - } else { - crate::cli::common::submit_transaction( - quantus_client, - from_keypair, - transfer_call, - submit_tip, - execution_mode, - ) - .await? - }; + let tx_hash = submit_transfer_call( + quantus_client, + from_keypair, + transfer_call, + submit_tip, + nonce, + execution_mode, + ) + .await?; log_verbose!("📋 Transaction submitted: {:?}", tx_hash); @@ -435,6 +502,7 @@ pub(crate) async fn validate_batch_transfer_request( } for (to_address, amount) in transfers { + resolve_to_subxt_account_id(to_address)?; log_verbose!(" To: {} Amount: {}", to_address.bright_green(), amount); } @@ -512,7 +580,7 @@ pub async fn handle_send_command( validate_and_format_amount(&quantus_client, amount_str).await?; // Resolve the destination address (could be wallet name or SS58 address) - let resolved_address = resolve_address(&to_address)?; + let (resolved_address, to_account_id) = resolve_address_with_subxt_account_id(&to_address)?; log_info!("🚀 Initiating transfer of {} to {}", formatted_amount, resolved_address); log_verbose!( @@ -542,66 +610,28 @@ pub async fn handle_send_command( }; let effective_tip = effective_tip_amount(tip_amount); let submit_tip = positive_tip_amount(tip_amount); - let exact_required = checked_add(amount, effective_tip, "required send balance")?; - if balance < exact_required { - return Err(crate::error::QuantusError::InsufficientBalance { - available: balance, - required: exact_required, - }); - } - - let transfer_call = build_transfer_call(&resolved_address, amount)?; - match estimate_transaction_partial_fee(&quantus_client, &keypair, &transfer_call, submit_tip) - .await - { - Ok(estimated_fee) => { - let estimated_total = - checked_add(exact_required, estimated_fee, "required send balance")?; - if balance < estimated_total { - let formatted_fee = - format_balance_with_symbol(&quantus_client, estimated_fee).await?; - let formatted_required = - format_balance_with_symbol(&quantus_client, estimated_total).await?; - if let Some(tip_amount) = submit_tip { - let formatted_tip = - format_balance_with_symbol(&quantus_client, tip_amount).await?; - return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance for amount + tip + estimated fee. Have: {formatted_balance}, Need: {formatted_required} (tip: {formatted_tip}, estimated fee: {formatted_fee})" - ))); - } - return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance for amount + estimated fee. Have: {formatted_balance}, Need: {formatted_required} (estimated fee: {formatted_fee})" - ))); - } - let formatted_estimated_fee = - format_balance_with_symbol(&quantus_client, estimated_fee).await?; - log_verbose!("💸 Estimated network fee: {}", formatted_estimated_fee.bright_cyan()); - }, - Err(err) => - if submit_tip.is_some() { - log_verbose!( - "âš ī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", - err - ); - } else { - log_verbose!( - "âš ī¸ Fee estimation unavailable; proceeding with exact amount check only: {}", - err - ); - }, - } + let transfer_call = build_transfer_call_for_account_id(to_account_id, amount); + ensure_balance_covers_call( + &quantus_client, + &keypair, + &transfer_call, + balance, + exact_required, + submit_tip, + "send", + ) + .await?; // Create and submit transaction log_verbose!("âœī¸ {} Signing transaction...", "SIGN".bright_magenta().bold()); // Submit transaction - let tx_hash = transfer_with_nonce( + let tx_hash = submit_transfer_call( &quantus_client, &keypair, - &resolved_address, - amount, - tip_amount, + transfer_call, + submit_tip, nonce, execution_mode, ) diff --git a/src/main.rs b/src/main.rs index 440a804..b89571f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -73,7 +73,7 @@ async fn main() -> Result<(), QuantusError> { // Create execution mode from CLI args let execution_mode = cli::common::ExecutionMode { finalized: cli.finalized_tx, - wait_for_transaction: cli.wait_for_transaction || cli.finalized_tx, + wait_for_transaction: cli.wait_for_transaction, }; // Execute the command with timing From 65c99ec1056cc6d7048e9d8daa4065dbe07beedd Mon Sep 17 00:00:00 2001 From: Ethan Date: Sun, 3 May 2026 23:00:13 -0500 Subject: [PATCH 6/6] *fmt --- src/cli/batch.rs | 10 ++++------ src/cli/common.rs | 41 ++++++++++++++++++----------------------- src/cli/send.rs | 5 ++--- 3 files changed, 24 insertions(+), 32 deletions(-) diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 45099c7..523c37a 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -78,7 +78,7 @@ pub async fn handle_batch_command( count, to, amount, - } => { + } => handle_batch_send_command( from, node_url, @@ -91,11 +91,9 @@ pub async fn handle_batch_command( amount, execution_mode, ) - .await - }, - BatchCommands::Config { limits, info } => { - handle_batch_config_command(node_url, limits, info).await - }, + .await, + BatchCommands::Config { limits, info } => + handle_batch_config_command(node_url, limits, info).await, } } diff --git a/src/cli/common.rs b/src/cli/common.rs index d442703..3f34ce9 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -83,26 +83,22 @@ fn describe_watched_tx_event( target_stage: TransactionStage, ) -> Result { match event { - WatchedTxEvent::Validated - | WatchedTxEvent::Broadcasted - | WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), - WatchedTxEvent::InBestBlock => { + WatchedTxEvent::Validated | + WatchedTxEvent::Broadcasted | + WatchedTxEvent::NoLongerInBestBlock => Ok(WatchDecision::Continue), + WatchedTxEvent::InBestBlock => if target_stage == TransactionStage::Finalized { Ok(WatchDecision::WaitForFinalization) } else { Ok(WatchDecision::Success) - } - }, + }, WatchedTxEvent::InFinalizedBlock => Ok(WatchDecision::Success), - WatchedTxEvent::Error(message) => { - Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))) - }, - WatchedTxEvent::Invalid(message) => { - Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))) - }, - WatchedTxEvent::Dropped(message) => { - Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))) - }, + WatchedTxEvent::Error(message) => + Err(crate::error::QuantusError::NetworkError(format!("Transaction error: {message}"))), + WatchedTxEvent::Invalid(message) => + Err(crate::error::QuantusError::NetworkError(format!("Transaction invalid: {message}"))), + WatchedTxEvent::Dropped(message) => + Err(crate::error::QuantusError::NetworkError(format!("Transaction dropped: {message}"))), WatchedTxEvent::StreamError(message) => Err(crate::error::QuantusError::NetworkError( format!("Transaction status stream error: {message}"), )), @@ -234,9 +230,8 @@ async fn handle_in_finalized_block( } std::ops::ControlFlow::Break(Ok(())) }, - Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => { - std::ops::ControlFlow::Continue(()) - }, + Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => + std::ops::ControlFlow::Continue(()), Err(err) => std::ops::ControlFlow::Break(Err(err)), } } @@ -493,11 +488,11 @@ where let error_msg = format!("{e:?}"); // Check if it's a retryable error - let is_retryable = error_msg.contains("Priority is too low") - || error_msg.contains("Transaction is outdated") - || error_msg.contains("Transaction is temporarily banned") - || error_msg.contains("Transaction has a bad signature") - || error_msg.contains("Invalid Transaction"); + let is_retryable = error_msg.contains("Priority is too low") || + error_msg.contains("Transaction is outdated") || + error_msg.contains("Transaction is temporarily banned") || + error_msg.contains("Transaction has a bad signature") || + error_msg.contains("Invalid Transaction"); if is_retryable && attempt < 5 { log_verbose!( diff --git a/src/cli/send.rs b/src/cli/send.rs index b3b4166..7e7c783 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -356,7 +356,7 @@ where format_balance_with_symbol(quantus_client, estimated_fee).await?; log_verbose!("💸 Estimated network fee: {}", formatted_estimated_fee.bright_cyan()); }, - Err(err) => { + Err(err) => if submit_tip.is_some() { log_verbose!( "âš ī¸ Fee estimation unavailable; proceeding with exact amount+tip check only: {}", @@ -367,8 +367,7 @@ where "âš ī¸ Fee estimation unavailable; proceeding with exact amount check only: {}", err ); - } - }, + }, } Ok(())