diff --git a/Cargo.lock b/Cargo.lock index 7aaa499..ccc5154 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,13 +3963,12 @@ dependencies = [ "qp-wormhole-verifier", "qp-zk-circuits-common", "quinn-proto", - "rand 0.9.2", + "rand 0.9.4", "reqwest", "rpassword", "rustls-webpki", "serde", "serde_json", - "serial_test", "sha2 0.10.9", "sp-core", "sp-runtime", @@ -4011,7 +4010,7 @@ dependencies = [ "fastbloom", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash", "rustls", @@ -4077,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", @@ -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 7b9a7d5..5dfb821 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,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 4df3086..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 = "0.1.0" # When published to crates.io +quantus-cli = "1.3.3" # Replace with the latest published version on crates.io ``` ## 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,13 @@ 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`) +- `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..6589240 100644 --- a/README.md +++ b/README.md @@ -367,22 +367,35 @@ 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 -# With tip for priority +# 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 + +# Optional advanced: add a tip to prioritize the transaction quantus send --from crystal_alice --to
--amount 10 --tip 0.1 # With manual nonce 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. `--tip` is optional and omitted by default. + --- ### 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..69db0a7 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,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..." +# Generated SubXT code may require nightly rustfmt. cargo +nightly 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..523c37a 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -2,10 +2,12 @@ use crate::{ chain::client::QuantusClient, cli::send::{ - batch_transfer, get_batch_limits, load_transfers_from_file, 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, - log_info, log_print, log_success, + log_info, log_print, log_success, log_verbose, }; use clap::Subcommand; use colored::Colorize; @@ -30,7 +32,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, @@ -145,36 +148,63 @@ 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: u128 = transfers.iter().map(|(_, amount)| amount).sum(); - let estimated_fee = 50_000_000_000u128; // Rough estimate for batch - - if balance < total_amount + estimated_fee { - 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?; - return Err(crate::error::QuantusError::Generic(format!( - "Insufficient balance. Have: {formatted_balance}, Need: {formatted_needed} (including estimated fees)" - ))); - } + 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)?; + ensure_balance_covers_call( + &quantus_client, + &keypair, + &batch_call, + balance, + exact_required, + submit_tip, + "batch", + ) + .await?; // 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!( - "✅ {} 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_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() + ); + 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..3f34ce9 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -8,12 +8,234 @@ use subxt::{ OnlineClient, }; -#[derive(Debug, Clone, Copy, Default)] +pub type SubxtAccountId32 = subxt::ext::subxt_core::utils::AccountId32; + +#[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 + } +} + +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() + ))), + } +} + +fn should_check_execution_success( + block_hash: &subxt::utils::H256, + already_checked_for: Option<&subxt::utils::H256>, +) -> bool { + 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 { @@ -40,6 +262,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 @@ -122,9 +365,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 +424,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 +462,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 +474,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?; @@ -331,10 +570,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 +585,7 @@ where &mut tx_progress, quantus_client.client(), &tx_hash, - execution_mode.finalized, + execution_mode.transaction_stage(), ) .await?; Ok(tx_hash) @@ -385,11 +622,12 @@ 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}; 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(); @@ -400,7 +638,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,76 +650,85 @@ 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 - )); + 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 => { + execution_success_checked_for = None; + WatchedTxEvent::NoLongerInBestBlock + }, + TxStatus::InBestBlock(tx_in_block) => { + let block_hash = tx_in_block.block_hash(); + match handle_in_best_block( + client, + tx_hash, + block_hash, + target_stage, + &mut execution_success_checked_for, + spinner.as_ref(), + elapsed_secs, + ) + .await + { + 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(); + match handle_in_finalized_block( + client, + tx_hash, + block_hash, + target_stage, + &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), + TxStatus::Invalid { message } => WatchedTxEvent::Invalid(message), + TxStatus::Dropped { message } => WatchedTxEvent::Dropped(message), } - break; }, - TxStatus::Error { message } | TxStatus::Invalid { message } => { - crate::log_error!(" Transaction error: {} (elapsed: {}s)", message, elapsed_secs); + 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) => { + update_waiting_spinner(spinner.as_ref(), target_stage, elapsed_secs); + }, + 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)); } - break; - }, - _ => { - if let Some(ref pb) = spinner { - if finalized { - pb.set_message(format!( - "Waiting for finalized block... ({}s)", - elapsed_secs - )); - } else { - pb.set_message(format!( - "Waiting for block inclusion... ({}s)", - elapsed_secs - )); - } - } - continue; + return Err(err); }, } } - - Ok(()) } fn format_dispatch_error( @@ -604,3 +851,84 @@ 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()); + } + + #[test] + fn default_mode_is_submission_only() { + let mode = ExecutionMode::default(); + + assert_eq!(mode.transaction_stage(), TransactionStage::Submitted); + assert!(!mode.should_watch_transaction()); + } + + #[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 + ); + } + + #[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 f2b2aa3..98cd06d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -606,11 +606,18 @@ 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 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); if is_compatible { log_success!("✅ COMPATIBLE - This CLI version supports the connected node"); @@ -620,7 +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 versions: {:?}", crate::config::COMPATIBLE_RUNTIME_VERSIONS); } log_print!(""); 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 1fad9c9..7e7c783 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); @@ -122,32 +113,103 @@ 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" - ))); - } + let normalized_amount_part = amount_part.strip_prefix('+').unwrap_or(amount_part); + let mut parts = normalized_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,7 +230,186 @@ pub async fn validate_and_format_amount( Ok((raw_amount, formatted)) } -/// Transfer tokens with automatic nonce +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}")) + }) +} + +pub fn effective_tip_amount(tip: Option) -> u128 { + tip.unwrap_or_default() +} + +pub(crate) fn positive_tip_amount(tip: Option) -> Option { + tip.filter(|tip_amount| *tip_amount > 0) +} + +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( + 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 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), + 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:?}" + )) + }) +} + +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, @@ -182,7 +423,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, @@ -198,68 +441,36 @@ 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()); - // 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_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, - Some(tip_to_use), - manual_nonce, - execution_mode, - ) - .await? - } else { - crate::cli::common::submit_transaction( - quantus_client, - from_keypair, - transfer_call, - Some(tip_to_use), - 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); 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() { @@ -268,7 +479,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)); @@ -281,7 +491,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: {})", @@ -291,52 +500,32 @@ pub async fn batch_transfer( ); } - // Prepare all transfer calls as RuntimeCall - let mut calls = Vec::new(); for (to_address, amount) in transfers { + resolve_to_subxt_account_id(to_address)?; 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); + Ok(()) +} - // Use provided tip or default tip - let tip_to_use = tip.unwrap_or(10_000_000_000); +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?; @@ -346,6 +535,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 @@ -368,7 +579,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!( @@ -390,39 +601,62 @@ 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 { - return Err(crate::error::QuantusError::InsufficientBalance { - available: balance, - required: amount, - }); - } - - // 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() + Some(parse_amount(&quantus_client, tip_str).await?) } else { 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")?; + 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, ) .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_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() + ); + 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 +695,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 +742,64 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 Ok((safe_limit, recommended_limit)) } + +#[cfg(test)] +mod tests { + use super::{effective_tip_amount, 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 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()); + 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 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); + + 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()); + } + + #[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 a5a4ff1..b3af9f6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -2,10 +2,19 @@ //! //! 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, +} + +/// 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..b89571f 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, diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3562009..eef9943 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()); @@ -365,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 @@ -375,31 +367,22 @@ 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)?; - 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"); + let dilithium_pair = qp_dilithium_crypto::types::DilithiumPair::from_seed(&seed_bytes_32) + .map_err(|_| WalletError::InvalidMnemonic)?; // Convert to QuantumKeyPair let quantum_keypair = QuantumKeyPair::from_resonance_pair(&dilithium_pair); @@ -576,6 +559,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 +839,28 @@ mod tests { } } + #[tokio::test] + async fn test_wallet_creation_from_seed() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let seed = "0101010101010101010101010101010101010101010101010101010101010101"; + + let wallet_info = wallet_manager + .create_wallet_from_seed("seed-based-wallet", seed, Some("probe-password")) + .await + .expect("Failed to create seed wallet"); + + assert_eq!(wallet_info.name, "seed-based-wallet"); + assert!(wallet_info.address.starts_with("qz")); + 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] async fn test_list_wallets() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await;