From 03dad4d658b68d0d00127e1fb81f74890dfb08c8 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:30:59 +0800 Subject: [PATCH 01/39] fix(wallet): stop accepting mnemonic and seed via CLI flags Authority secrets on --mnemonic/--seed were visible in process argv, shell history, and CI logs. Always read them from a hidden prompt instead. Co-authored-by: Cursor --- src/cli/wallet.rs | 69 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 38f0227..0f3c80c 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -65,10 +65,6 @@ pub enum WalletCommands { #[arg(short, long)] name: String, - /// Mnemonic phrase (24 words, will prompt if not provided) - #[arg(short, long)] - mnemonic: Option, - /// Password to encrypt the wallet (optional, will prompt if not provided) #[arg(short, long)] password: Option, @@ -88,10 +84,6 @@ pub enum WalletCommands { #[arg(short, long)] name: String, - /// 32-byte seed in hex format (64 hex characters) - #[arg(short, long)] - seed: String, - /// Password to encrypt the wallet (optional, will prompt if not provided) #[arg(short, long)] password: Option, @@ -556,14 +548,13 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::Import { name, mnemonic, password, derivation_path, no_derivation } => { + WalletCommands::Import { name, password, derivation_path, no_derivation } => { log_print!("đŸ“Ĩ Importing wallet..."); let wallet_manager = WalletManager::new()?; - // Get mnemonic from user if not provided - let mnemonic_phrase = - if let Some(mnemonic) = mnemonic { mnemonic } else { get_mnemonic_from_user()? }; + // Always read mnemonic from a hidden prompt so it never appears in process argv. + let mnemonic_phrase = get_mnemonic_from_user()?; // Get password from user if not provided let final_password = @@ -614,11 +605,17 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::FromSeed { name, seed, password } => { + WalletCommands::FromSeed { name, password } => { log_print!("🌱 Creating wallet from seed..."); let wallet_manager = WalletManager::new()?; + // Always read seed from a hidden prompt so it never appears in process argv. + log_print!("Enter 32-byte seed in hex format (64 hex characters):"); + let seed = rpassword::read_password() + .map_err(|e| QuantusError::Generic(format!("Failed to read seed: {e}")))?; + let seed = seed.trim().to_string(); + // Get password from user if not provided let final_password = crate::wallet::password::get_wallet_password(&name, password, None)?; @@ -808,3 +805,49 @@ pub async fn handle_wallet_command( }, } } + +#[cfg(test)] +mod tests { + use clap::Parser; + + #[derive(Parser, Debug)] + #[command(name = "quantus")] + struct TestCli { + #[command(subcommand)] + command: crate::cli::Commands, + } + + #[test] + fn wallet_import_rejects_mnemonic_cli_argument() { + let result = TestCli::try_parse_from([ + "quantus", + "wallet", + "import", + "--name", + "poc", + "--mnemonic", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", + ]); + assert!( + result.is_err(), + "wallet import must not accept --mnemonic on the command line" + ); + } + + #[test] + fn wallet_from_seed_rejects_seed_cli_argument() { + let result = TestCli::try_parse_from([ + "quantus", + "wallet", + "from-seed", + "--name", + "poc", + "--seed", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + ]); + assert!( + result.is_err(), + "wallet from-seed must not accept --seed on the command line" + ); + } +} From 253c79136764e346d8c091fecb0c79b9c38d386e Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:32:50 +0800 Subject: [PATCH 02/39] fix(wallet): skip malformed files when listing wallets A single corrupt or non-canonical wallet JSON aborted the entire list. Validate Quantus SS58 addresses at load and isolate per-file failures. Co-authored-by: Cursor --- src/error.rs | 3 ++ src/wallet/keystore.rs | 14 ++++++++ src/wallet/mod.rs | 82 ++++++++++++++++++++++++++++++++++++------ 3 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/error.rs b/src/error.rs index d8532c9..f258d08 100644 --- a/src/error.rs +++ b/src/error.rs @@ -56,6 +56,9 @@ pub enum WalletError { #[error("Invalid password (or corrupted wallet file)")] InvalidPassword, + #[error("Invalid wallet address")] + InvalidAddress, + #[error("Key generation failed")] KeyGeneration, diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index a3af4c1..bd69ebb 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -161,9 +161,23 @@ impl Keystore { let wallet_json = std::fs::read_to_string(wallet_file)?; let wallet: EncryptedWallet = serde_json::from_str(&wallet_json)?; + Self::validate_wallet_address(&wallet.address)?; Ok(Some(wallet)) } + fn validate_wallet_address(address: &str) -> Result<()> { + use crate::cli::address_format::quantus_ss58_format; + + let (account_id, format) = AccountId32::from_ss58check_with_version(address) + .map_err(|_| WalletError::InvalidAddress)?; + if format != quantus_ss58_format() + || account_id.to_ss58check_with_version(quantus_ss58_format()) != address + { + return Err(WalletError::InvalidAddress.into()); + } + Ok(()) + } + /// List all wallet files pub fn list_wallets(&self) -> Result> { let mut wallets = Vec::new(); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 12c6b8d..4a5df75 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -169,17 +169,22 @@ impl WalletManager { let mut wallets = Vec::new(); for name in wallet_names { - if let Some(encrypted_wallet) = keystore.load_wallet(&name)? { - // Create wallet info using stored public address - let wallet_info = WalletInfo { - name: encrypted_wallet.name, - address: encrypted_wallet.address, // Address is stored unencrypted - created_at: encrypted_wallet.created_at, - key_type: "Dilithium ML-DSA-87".to_string(), - derivation_path: "[Encrypted]".to_string(), // Derivation path is encrypted - }; - wallets.push(wallet_info); - } + let Some(encrypted_wallet) = (match keystore.load_wallet(&name) { + Ok(wallet) => wallet, + Err(_) => continue, + }) else { + continue; + }; + + // Create wallet info using stored public address + let wallet_info = WalletInfo { + name: encrypted_wallet.name, + address: encrypted_wallet.address, // Address is stored unencrypted + created_at: encrypted_wallet.created_at, + key_type: "Dilithium ML-DSA-87".to_string(), + derivation_path: "[Encrypted]".to_string(), // Derivation path is encrypted + }; + wallets.push(wallet_info); } // Sort by creation date (newest first) @@ -972,4 +977,59 @@ mod tests { assert!(result.is_none()); } + + #[tokio::test] + async fn list_wallets_skips_malformed_files_and_rejects_invalid_addresses() { + use sp_core::crypto::{AccountId32, Ss58Codec}; + + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let created = wallet_manager + .create_developer_wallet("crystal_alice") + .await + .expect("developer wallet creation should succeed"); + + let corrupt_path = wallet_manager.wallets_dir.join("corrupt.json"); + fs::write(&corrupt_path, b"{\"name\":").expect("write malformed wallet file"); + + let listed = wallet_manager + .list_wallets() + .expect("listing must skip one malformed wallet file and still return valid wallets"); + assert!( + listed.iter().any(|w| w.name == created.name), + "valid wallet must remain listable despite a malformed sibling file" + ); + + fs::remove_file(&corrupt_path).expect("remove malformed file"); + + let keystore = Keystore::new(&wallet_manager.wallets_dir); + let mut forged = keystore + .load_wallet("crystal_alice") + .expect("valid wallet load") + .expect("valid wallet exists"); + forged.name = "forged_address_wallet".to_string(); + forged.address = "not a Quantus SS58 account".to_string(); + keystore.save_wallet(&forged).expect("save forged-address wallet JSON"); + + assert!( + matches!( + keystore.load_wallet("forged_address_wallet"), + Err(crate::error::QuantusError::Wallet(WalletError::InvalidAddress)) + ), + "load boundary must reject non-canonical wallet addresses" + ); + + let listed_after = wallet_manager.list_wallets().expect("listing after forgery"); + assert!( + listed_after.iter().any(|w| w.name == created.name), + "valid wallet must remain listable" + ); + assert!( + listed_after.iter().all(|w| AccountId32::from_ss58check_with_version(&w.address).is_ok()), + "listing must not return addresses the SS58 parser rejects" + ); + assert!( + listed_after.iter().all(|w| w.name != "forged_address_wallet"), + "forged-address wallet must be omitted from listing" + ); + } } From d2ac2439963b1c11f21b894a48ce83ec481ade45 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:35:30 +0800 Subject: [PATCH 03/39] fix(multisig): correlate MultisigCreated to creator and params Taking the first same-block MultisigCreated event could report another transaction's address. Match creator, signers, threshold, and nonce. Co-authored-by: Cursor --- src/cli/multisig.rs | 157 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 140 insertions(+), 17 deletions(-) diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index d7d224e..9decbba 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -8,6 +8,8 @@ use colored::Colorize; use hex; use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; +type SubxtAccountId32 = subxt::ext::subxt_core::utils::AccountId32; + // Base unit (QUAN) decimals for amount conversions const QUAN_DECIMALS: u128 = 1_000_000_000_000; // 10^12 const DEFAULT_TRANSFER_EXPIRY_BLOCKS: u32 = (2 * 60 * 60) / 10; // ~2h at 10s/block @@ -469,6 +471,57 @@ pub fn predict_multisig_address( account_id.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) } +fn keypair_to_subxt_account_id(keypair: &crate::wallet::QuantumKeyPair) -> SubxtAccountId32 { + let account_id = keypair.to_account_id_32(); + let account_bytes: [u8; 32] = *account_id.as_ref(); + SubxtAccountId32::from(account_bytes) +} + +fn sorted_account_ids_equal(left: &[SubxtAccountId32], right: &[SubxtAccountId32]) -> bool { + if left.len() != right.len() { + return false; + } + + let mut left_sorted = left.to_vec(); + left_sorted.sort(); + let mut right_sorted = right.to_vec(); + right_sorted.sort(); + left_sorted == right_sorted +} + +fn matching_multisig_created_address( + event: &quantus_subxt::api::multisig::events::MultisigCreated, + creator: &SubxtAccountId32, + signers: &[SubxtAccountId32], + threshold: u32, + nonce: u64, +) -> Option { + if &event.creator != creator + || event.threshold != threshold + || event.nonce != nonce + || !sorted_account_ids_equal(&event.signers, signers) + { + return None; + } + + let addr_bytes: &[u8; 32] = event.multisig_address.as_ref(); + let addr = SpAccountId32::from(*addr_bytes); + Some(addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189))) +} + +#[cfg(test)] +fn find_matching_multisig_created_address<'a>( + events: impl IntoIterator, + creator: &SubxtAccountId32, + signers: &[SubxtAccountId32], + threshold: u32, + nonce: u64, +) -> Option { + events + .into_iter() + .find_map(|ev| matching_multisig_created_address(ev, creator, signers, threshold, nonce)) +} + /// Create a multisig account /// /// # Arguments @@ -497,6 +550,7 @@ pub async fn create_multisig( .create_multisig(signers.clone(), threshold, nonce); // Submit transaction + let creator_account_id = keypair_to_subxt_account_id(creator_keypair); let execution_mode = ExecutionMode { finalized: false, wait_for_transaction: wait_for_inclusion }; let tx_hash = crate::cli::common::submit_transaction( @@ -508,21 +562,34 @@ pub async fn create_multisig( ) .await?; - // If waiting, extract address from events + // If waiting, extract the matching address from events let multisig_address = if wait_for_inclusion { let latest_block_hash = quantus_client.get_latest_block().await?; let events = quantus_client.client().events().at(latest_block_hash).await?; - let mut multisig_events = + let multisig_events = events.find::(); - let address: Option = if let Some(Ok(ev)) = multisig_events.next() { - let addr_bytes: &[u8; 32] = ev.multisig_address.as_ref(); - let addr = SpAccountId32::from(*addr_bytes); - Some(addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189))) - } else { - None - }; + let mut address: Option = None; + for event_result in multisig_events { + match event_result { + Ok(ev) => { + if let Some(matching_address) = matching_multisig_created_address( + &ev, + &creator_account_id, + &signers, + threshold, + nonce, + ) { + address = Some(matching_address); + break; + } + }, + Err(e) => { + log_verbose!("Error parsing event: {:?}", e); + }, + } + } address } else { None @@ -1087,6 +1154,7 @@ async fn handle_create_multisig( // Load keypair let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; + let creator_account_id = keypair_to_subxt_account_id(&keypair); // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1124,7 +1192,7 @@ async fn handle_create_multisig( let latest_block_hash = quantus_client.get_latest_block().await?; let events = quantus_client.client().events().at(latest_block_hash).await?; - // Find MultisigCreated event + // Find MultisigCreated event matching this create let multisig_events = events.find::(); @@ -1132,13 +1200,17 @@ async fn handle_create_multisig( for event_result in multisig_events { match event_result { Ok(ev) => { - let addr_bytes: &[u8; 32] = ev.multisig_address.as_ref(); - let addr = SpAccountId32::from(*addr_bytes); - actual_address = Some(addr.to_ss58check_with_version( - sp_core::crypto::Ss58AddressFormat::custom(189), - )); - log_verbose!("Found MultisigCreated event"); - break; + if let Some(address) = matching_multisig_created_address( + &ev, + &creator_account_id, + &signer_addresses, + threshold, + nonce, + ) { + actual_address = Some(address); + log_verbose!("Found matching MultisigCreated event"); + break; + } }, Err(e) => { log_verbose!("Error parsing event: {:?}", e); @@ -3070,3 +3142,54 @@ async fn handle_high_security_set( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use quantus_subxt::api::multisig::events::MultisigCreated; + + fn account(byte: u8) -> SubxtAccountId32 { + SubxtAccountId32::from([byte; 32]) + } + + fn ss58(account_id: &SubxtAccountId32) -> String { + let addr_bytes: &[u8; 32] = account_id.as_ref(); + let addr = SpAccountId32::from(*addr_bytes); + addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) + } + + #[test] + fn find_matching_multisig_created_address_skips_unrelated_same_block_event() { + let creator = account(1); + let signers = vec![account(10), account(11)]; + let threshold = 2u32; + let nonce = 7u64; + let wanted_address = account(99); + + let wrong_first = MultisigCreated { + creator: account(2), + multisig_address: account(88), + signers: vec![account(20), account(21)], + threshold: 1, + nonce: 1, + }; + let matching = MultisigCreated { + creator: creator.clone(), + multisig_address: wanted_address.clone(), + // Unsorted relative to query signers; matcher must compare sorted. + signers: vec![account(11), account(10)], + threshold, + nonce, + }; + + let selected = find_matching_multisig_created_address( + [&wrong_first, &matching], + &creator, + &signers, + threshold, + nonce, + ); + + assert_eq!(selected, Some(ss58(&wanted_address))); + } +} From e6dd6689ac567dfbda444ec822e508e93424939a Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:35:30 +0800 Subject: [PATCH 04/39] fix(wallet): reject raw --password CLI credentials Passwords on --password/-p were visible in process argv and logs. Reject them at the shared helper and wallet-create boundary. Co-authored-by: Cursor --- src/cli/wallet.rs | 17 ++++++++-------- src/wallet/password.rs | 46 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 51 insertions(+), 12 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 0f3c80c..2e4d116 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -4,7 +4,10 @@ use crate::{ cli::address_format::QuantusSS58, error::QuantusError, log_error, log_print, log_success, log_verbose, - wallet::{password::get_mnemonic_from_user, WalletManager, DEFAULT_DERIVATION_PATH}, + wallet::{ + password::{get_mnemonic_from_user, reject_cli_password}, + WalletManager, DEFAULT_DERIVATION_PATH, + }, }; use clap::Subcommand; use colored::Colorize; @@ -286,21 +289,19 @@ pub async fn handle_wallet_command( WalletCommands::Create { name, password, derivation_path, no_derivation } => { log_print!("🔐 Creating new quantum wallet..."); + reject_cli_password(&password)?; + let wallet_manager = WalletManager::new()?; // Choose creation method based on flags let result = if no_derivation { // Use master seed directly (like quantus-node --no-derivation) - wallet_manager.create_wallet_no_derivation(&name, password.as_deref()).await + wallet_manager.create_wallet_no_derivation(&name, None).await } else if derivation_path == DEFAULT_DERIVATION_PATH { - wallet_manager.create_wallet(&name, password.as_deref()).await + wallet_manager.create_wallet(&name, None).await } else { wallet_manager - .create_wallet_with_derivation_path( - &name, - password.as_deref(), - &derivation_path, - ) + .create_wallet_with_derivation_path(&name, None, &derivation_path) .await }; diff --git a/src/wallet/password.rs b/src/wallet/password.rs index 5a613fc..ab5b9e5 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -7,10 +7,13 @@ pub fn get_wallet_password( password: Option, password_file: Option, ) -> Result { - // Option 1: Use CLI password flag if provided - if let Some(pwd) = password { - log_verbose!("🔑 Using password from --password flag"); - return Ok(pwd); + // Raw passwords passed through command-line arguments are visible in process + // listings and command logs. Use --password-file, QUANTUS_WALLET_PASSWORD, + // wallet-specific environment variables, or the masked prompt instead. + if password.is_some() { + return Err(crate::error::QuantusError::Generic( + "Passing wallet passwords with --password/-p is not supported; use --password-file, QUANTUS_WALLET_PASSWORD, or the interactive prompt".to_string(), + )); } // Option 2: Read password from file if provided @@ -69,3 +72,38 @@ pub fn get_password_from_user(prompt: &str) -> Result { })?; Ok(password) } + +/// Reject raw `--password`/`-p` values for handlers that bypass [`get_wallet_password`]. +pub fn reject_cli_password(password: &Option) -> Result<()> { + if password.is_some() { + return Err(crate::error::QuantusError::Generic( + "Passing wallet passwords with --password/-p is not supported; use an interactive prompt or a supported non-argv secret source".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_wallet_password_rejects_cli_password_flag() { + let err = get_wallet_password("w", Some("secret".into()), None).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("--password"), + "expected unsupported --password message, got: {msg}" + ); + } + + #[test] + fn wallet_create_rejects_cli_password() { + let err = reject_cli_password(&Some("secret".into())).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("--password"), + "expected unsupported --password message, got: {msg}" + ); + } +} From 58b87806877e4a2a326b13b372129a37f37502cb Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:35:31 +0800 Subject: [PATCH 05/39] fix(tx): fail when watched extrinsic is missing from block check_execution_success treated a missing extrinsic hash as success. Return a NetworkError so callers do not report false confirmations. Co-authored-by: Cursor --- src/cli/common.rs | 60 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index efbb116..c224761 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -116,6 +116,16 @@ fn should_check_execution_success( already_checked_for != Some(block_hash) } +/// Require the watched extrinsic to be present in the reported block. +/// Returns its index for event scanning, or an error if the hash is absent. +fn require_extrinsic_index(our_extrinsic_index: Option) -> Result { + our_extrinsic_index.ok_or_else(|| { + crate::error::QuantusError::NetworkError( + "Extrinsic hash not found in reported block".to_string(), + ) + }) +} + type TxWatchFlow = std::ops::ControlFlow, ()>; fn update_waiting_spinner( @@ -823,25 +833,25 @@ pub(crate) async fn check_execution_success( crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}")) })?; + let ext_idx = require_extrinsic_index(our_extrinsic_index)?; + let metadata = client.metadata(); - if let Some(ext_idx) = our_extrinsic_index { - for event_result in events.iter() { - let event = event_result.map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}")) - })?; + for event_result in events.iter() { + let event = event_result.map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}")) + })?; - if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() { - if event_ext_idx == ext_idx as u32 { - if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) = - event.as_event::() - { - let error_msg = format_dispatch_error(&dispatch_error, &metadata); - crate::log_error!(" Transaction failed: {}", error_msg); - return Err(crate::error::QuantusError::NetworkError(format!( - "Transaction execution failed: {}", - error_msg - ))); - } + if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() { + if event_ext_idx == ext_idx as u32 { + if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) = + event.as_event::() + { + let error_msg = format_dispatch_error(&dispatch_error, &metadata); + crate::log_error!(" Transaction failed: {}", error_msg); + return Err(crate::error::QuantusError::NetworkError(format!( + "Transaction execution failed: {}", + error_msg + ))); } } } @@ -929,4 +939,20 @@ mod tests { assert!(!should_check_execution_success(&best_block_hash, Some(&best_block_hash),)); assert!(should_check_execution_success(&finalized_block_hash, Some(&best_block_hash),)); } + + #[test] + fn missing_extrinsic_hash_in_reported_block_is_error() { + let err = require_extrinsic_index(None).expect_err("absent extrinsic must not succeed"); + match err { + crate::error::QuantusError::NetworkError(msg) => { + assert!( + msg.contains("not found in reported block"), + "unexpected error message: {msg}" + ); + }, + other => panic!("expected NetworkError, got {other:?}"), + } + + assert_eq!(require_extrinsic_index(Some(3)).unwrap(), 3); + } } From 3bc368ccd722fe531c2555e28c61562a6669e8a8 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:38:57 +0800 Subject: [PATCH 06/39] fix(wallet): enforce owner-only keystore permissions Wallet directories and files inherited umask defaults, allowing local users to read ciphertext and KDF metadata. Set dir 0700 and files 0600. Co-authored-by: Cursor --- src/wallet/keystore.rs | 40 +++++++++++++++++++++++-- src/wallet/mod.rs | 66 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index bd69ebb..13622a9 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -28,6 +28,43 @@ use std::path::Path; use qp_dilithium_crypto::types::{DilithiumPair, DilithiumPublic}; use sp_runtime::traits::IdentifyAccount; +/// Atomically persist wallet JSON via temp file + rename. +#[cfg(unix)] +fn write_wallet_file_atomically(tmp: &Path, final_path: &Path, data: &[u8]) -> Result<()> { + use std::io::Write; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + // Create the temp file with 0600 before any ciphertext hits disk. + let mut file = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(tmp)?; + // mode() only applies on create; force 0600 if a leftover tmp existed. + let mut perms = file.metadata()?.permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(tmp, perms)?; + file.write_all(data)?; + file.sync_all()?; + drop(file); + + std::fs::rename(tmp, final_path)?; + + // Belt-and-suspenders: enforce owner-only on the final path too. + let mut perms = std::fs::metadata(final_path)?.permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(final_path, perms)?; + Ok(()) +} + +#[cfg(not(unix))] +fn write_wallet_file_atomically(tmp: &Path, final_path: &Path, data: &[u8]) -> Result<()> { + std::fs::write(tmp, data)?; + std::fs::rename(tmp, final_path)?; + Ok(()) +} + /// Quantum-safe key pair using Dilithium post-quantum signatures #[derive(Debug, Clone, Serialize, Deserialize)] pub struct QuantumKeyPair { @@ -146,8 +183,7 @@ impl Keystore { let wallet_json = serde_json::to_string_pretty(wallet)?; // Write to a temp file and rename so a crash mid-write can never leave a // truncated file behind - it may hold the only copy of the key material. - std::fs::write(&tmp_file, wallet_json)?; - std::fs::rename(&tmp_file, wallet_file)?; + write_wallet_file_atomically(&tmp_file, &wallet_file, wallet_json.as_bytes())?; Ok(()) } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 4a5df75..39da740 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -35,6 +35,16 @@ pub struct WalletManager { wallets_dir: std::path::PathBuf, } +#[cfg(unix)] +fn ensure_dir_owner_only(path: &std::path::Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(path, perms)?; + Ok(()) +} + impl WalletManager { /// Create a new wallet manager pub fn new() -> Result { @@ -43,8 +53,15 @@ impl WalletManager { .join(".quantus") .join("wallets"); + Self::from_wallets_dir(wallets_dir) + } + + /// Create a wallet manager rooted at `wallets_dir`, creating it if needed. + fn from_wallets_dir(wallets_dir: std::path::PathBuf) -> Result { // Create directory if it doesn't exist std::fs::create_dir_all(&wallets_dir)?; + #[cfg(unix)] + ensure_dir_owner_only(&wallets_dir)?; Ok(Self { wallets_dir }) } @@ -537,13 +554,56 @@ mod tests { async fn create_test_wallet_manager() -> (WalletManager, TempDir) { let temp_dir = TempDir::new().expect("Failed to create temp directory"); let wallets_dir = temp_dir.path().join("wallets"); - fs::create_dir_all(&wallets_dir).expect("Failed to create wallets directory"); - - let wallet_manager = WalletManager { wallets_dir }; + let wallet_manager = WalletManager::from_wallets_dir(wallets_dir) + .expect("Failed to create wallets directory"); (wallet_manager, temp_dir) } + #[cfg(unix)] + #[test] + fn test_wallet_storage_uses_owner_only_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let wallets_dir = temp_dir.path().join("wallets"); + + // Exercise WalletManager::new's directory creation path + let wallet_manager = WalletManager::from_wallets_dir(wallets_dir) + .expect("Failed to create wallets directory"); + + let dir_mode = fs::metadata(&wallet_manager.wallets_dir) + .expect("stat wallets dir") + .permissions() + .mode() & + 0o777; + assert_eq!(dir_mode, 0o700, "wallets directory must be owner-only (0700)"); + + + let keystore = Keystore::new(&wallet_manager.wallets_dir); + let mut entropy = [9u8; 32]; + let dilithium_keypair = qp_rusty_crystals_dilithium::ml_dsa_87::Keypair::generate( + qp_rusty_crystals_hdwallet::SensitiveBytes32::from(&mut entropy), + ); + let quantum_keypair = QuantumKeyPair::from_dilithium_keypair(&dilithium_keypair); + let wallet_data = WalletData { + name: "perm-test-wallet".to_string(), + keypair: quantum_keypair, + mnemonic: None, + derivation_path: DEFAULT_DERIVATION_PATH.to_string(), + metadata: std::collections::HashMap::new(), + }; + let encrypted = keystore + .encrypt_wallet_data(&wallet_data, "perm-test-password") + .expect("encrypt wallet"); + keystore.save_wallet(&encrypted).expect("save wallet"); + + let wallet_file = wallet_manager.wallets_dir.join("perm-test-wallet.json"); + let file_mode = + fs::metadata(&wallet_file).expect("stat wallet file").permissions().mode() & 0o777; + assert_eq!(file_mode, 0o600, "wallet file must be owner-read/write (0600)"); + } + #[tokio::test] async fn test_wallet_creation() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; From 27d8a9f6847545286c30f74b03ffa072a914d0b6 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:38:57 +0800 Subject: [PATCH 07/39] fix(wallet): require restrictive password-file permissions --password-file accepted world-readable files. On Unix, require a regular file owned by the caller with no group/other access bits. Co-authored-by: Cursor --- src/wallet/password.rs | 81 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/wallet/password.rs b/src/wallet/password.rs index ab5b9e5..4ba2e7e 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -1,6 +1,51 @@ use crate::{error::Result, log_print, log_verbose, wallet::WalletManager}; use colored::Colorize; +/// Ensure a password file is a regular file owned by the current user with +/// no group/other access bits set before reading its contents. +#[cfg(unix)] +fn validate_password_file_permissions(file_path: &str) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + unsafe extern "C" { + fn geteuid() -> u32; + } + + let metadata = std::fs::metadata(file_path).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to inspect password file '{file_path}': {e}" + )) + })?; + + if !metadata.is_file() { + return Err(crate::error::QuantusError::Generic(format!( + "Password file '{file_path}' is not a regular file" + ))); + } + + // SAFETY: geteuid is a POSIX libc function with no preconditions. + let effective_uid = unsafe { geteuid() }; + if metadata.uid() != effective_uid { + return Err(crate::error::QuantusError::Generic(format!( + "Password file '{file_path}' must be owned by the current user" + ))); + } + + let mode = metadata.mode() & 0o777; + if mode & 0o077 != 0 { + return Err(crate::error::QuantusError::Generic(format!( + "Password file '{file_path}' must not be accessible by group or other users (mode {mode:o})" + ))); + } + + Ok(()) +} + +#[cfg(not(unix))] +fn validate_password_file_permissions(_file_path: &str) -> Result<()> { + Ok(()) +} + /// Get wallet password with convenience options pub fn get_wallet_password( wallet_name: &str, @@ -19,6 +64,7 @@ pub fn get_wallet_password( // Option 2: Read password from file if provided if let Some(file_path) = password_file { log_verbose!("🔑 Reading password from file: {}", file_path); + validate_password_file_permissions(&file_path)?; let pwd = std::fs::read_to_string(&file_path) .map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -106,4 +152,39 @@ mod tests { "expected unsupported --password message, got: {msg}" ); } + + #[cfg(unix)] + mod password_file_permissions { + use super::*; + use std::fs; + use std::os::unix::fs::PermissionsExt; + + fn write_password_file(mode: u32) -> (tempfile::TempDir, String) { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("wallet-password.txt"); + fs::write(&path, "correct horse battery staple\n").expect("write password file"); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)) + .expect("set password file mode"); + let path_str = path.to_string_lossy().into_owned(); + (dir, path_str) + } + + #[test] + fn rejects_group_or_world_readable_password_file() { + let (_dir, path) = write_password_file(0o644); + let err = validate_password_file_permissions(&path).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("must not be accessible by group or other"), + "expected restrictive-mode rejection, got: {msg}" + ); + } + + #[test] + fn accepts_owner_only_password_file() { + let (_dir, path) = write_password_file(0o600); + validate_password_file_permissions(&path) + .expect("owner-only password file owned by self should be accepted"); + } + } } From 046088348bd63032a5b4c5a172cd180e445ecd85 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:38:57 +0800 Subject: [PATCH 08/39] fix(batch): use utility.batch_all for atomic transfers User-facing batch transfers used non-atomic utility.batch while docs promised fail-all semantics. Switch the builder to batch_all. Co-authored-by: Cursor --- src/cli/batch.rs | 4 ++-- src/cli/send.rs | 21 +++++++++++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 523c37a..25e65ed 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -239,7 +239,7 @@ async fn handle_batch_config_command( if show_info { log_print!("â„šī¸ {} Batch Transfer Information", "CONFIG".bright_cyan().bold()); - log_print!(" â€ĸ Batch transfers use utility.batch() pallet"); + log_print!(" â€ĸ Batch transfers use utility.batch_all() pallet"); log_print!(" â€ĸ All transfers in one transaction (atomic)"); log_print!(" â€ĸ Single nonce used for all transfers"); log_print!(" â€ĸ Lower fees compared to individual transfers"); @@ -269,7 +269,7 @@ async fn handle_batch_config_command( // Show info log_print!("â„šī¸ {} Batch Transfer Information", "CONFIG".bright_cyan().bold()); - log_print!(" â€ĸ Batch transfers use utility.batch() pallet"); + log_print!(" â€ĸ Batch transfers use utility.batch_all() pallet"); log_print!(" â€ĸ All transfers in one transaction (atomic)"); log_print!(" â€ĸ Single nonce used for all transfers"); log_print!(" â€ĸ Lower fees compared to individual transfers"); diff --git a/src/cli/send.rs b/src/cli/send.rs index 7e7c783..c282e73 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -271,7 +271,9 @@ pub(crate) fn build_batch_transfer_call( })); } - Ok(quantus_subxt::api::tx().utility().batch(calls)) + // batch_all is atomic: any child call failure aborts and reverts the whole batch. + // utility.batch() can partially apply earlier calls and still return Ok. + Ok(quantus_subxt::api::tx().utility().batch_all(calls)) } pub async fn estimate_transaction_partial_fee( @@ -745,7 +747,22 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 #[cfg(test)] mod tests { - use super::{effective_tip_amount, parse_amount_with_decimals}; + use super::{build_batch_transfer_call, effective_tip_amount, parse_amount_with_decimals}; + use subxt::tx::Payload; + + /// Substrate Alice (valid SS58); used only to construct a call for metadata checks. + const TEST_DEST: &str = "5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"; + + #[test] + fn batch_transfer_call_uses_atomic_batch_all() { + let call = build_batch_transfer_call(&[(TEST_DEST.to_string(), 1)]).unwrap(); + let details = call.validation_details().expect("static payload exposes call metadata"); + assert_eq!(details.pallet_name, "Utility"); + assert_eq!( + details.call_name, "batch_all", + "user-facing batch transfers must be atomic (utility.batch_all), not utility.batch" + ); + } #[test] fn parses_exact_decimal_amounts() { From f111d9b9842ac9eba43d55900f2865f24f55b935 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:38:57 +0800 Subject: [PATCH 09/39] fix(wormhole): require persisted mnemonic for HD secrets Generating an ephemeral mnemonic when a wallet had none could strand funds at irrecoverable addresses. Error instead and require a mnemonic. Co-authored-by: Cursor --- src/cli/wormhole.rs | 56 +++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c1b1e82..c111f35 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -15,8 +15,7 @@ use clap::Subcommand; use indicatif::{ProgressBar, ProgressStyle}; use plonky2::plonk::proof::ProofWithPublicInputs; use qp_rusty_crystals_hdwallet::{ - derive_wormhole_from_mnemonic, generate_mnemonic, SensitiveBytes32, WormholePair, - QUANTUS_WORMHOLE_CHAIN_ID, + derive_wormhole_from_mnemonic, WormholePair, QUANTUS_WORMHOLE_CHAIN_ID, }; use qp_wormhole_aggregator::config::CircuitBinsConfig; use qp_wormhole_circuit::inputs::ParsePrivateBatchPublicInputs; @@ -25,7 +24,6 @@ use qp_zk_circuits_common::{ circuit::{C, D, F}, utils::BytesDigest, }; -use rand::RngCore; use sp_core::crypto::{AccountId32, Ss58Codec}; use subxt::{ blocks::Block, @@ -1914,23 +1912,13 @@ fn load_multiround_wallet( let wallet_address = wallet_data.keypair.to_account_id_ss58check(); let wallet_account_id = SubxtAccountId(wallet_data.keypair.to_account_id_32().into()); - // Get or generate mnemonic for HD derivation - let mnemonic = match wallet_data.mnemonic { - Some(m) => { - log_verbose!("Using wallet mnemonic for HD derivation"); - m - }, - None => { - log_print!("Wallet has no mnemonic - generating random mnemonic for wormhole secrets"); - let mut entropy = [0u8; 32]; - rand::rng().fill_bytes(&mut entropy); - let sensitive_entropy = SensitiveBytes32::from(&mut entropy); - let m = generate_mnemonic(sensitive_entropy).map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to generate mnemonic: {:?}", e)) - })?; - m - }, - }; + // Require a persisted mnemonic for deterministic wormhole HD derivation. + let mnemonic = wallet_data.mnemonic.ok_or_else(|| { + crate::error::QuantusError::Generic( + "Wallet does not contain a mnemonic. Use a wallet created from a mnemonic, or supply --mnemonic/--secret where supported.".to_string(), + ) + })?; + log_verbose!("Using wallet mnemonic for HD derivation"); Ok(MultiroundWalletContext { wallet_name: wallet_name.to_string(), @@ -4496,4 +4484,32 @@ mod tests { ); } } + + #[tokio::test] + #[serial_test::serial] + async fn load_multiround_wallet_errors_when_wallet_has_no_mnemonic() { + let home = tempfile::tempdir().unwrap(); + std::env::set_var("HOME", home.path()); + + let wallet_manager = WalletManager::new().unwrap(); + wallet_manager.create_developer_wallet("crystal_alice").await.unwrap(); + let stored = wallet_manager.load_wallet("crystal_alice", "").unwrap(); + assert!( + stored.mnemonic.is_none(), + "developer wallet must exercise the no-mnemonic secret path" + ); + + match load_multiround_wallet("crystal_alice", None, None) { + Ok(_) => panic!( + "wallet without mnemonic must error instead of generating an ephemeral one" + ), + Err(err) => { + let msg = err.to_string(); + assert!( + msg.contains("does not contain a mnemonic"), + "expected mnemonic-required error, got: {msg}" + ); + }, + } + } } From dfd96c0642f2b5123cf4c7636418ed2aa78c27bd Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:42:52 +0800 Subject: [PATCH 10/39] fix(tx): stop unsafe nonce-bump retries on ambiguous errors Error-substring retries re-signed with an incremented nonce and could duplicate extrinsics. Submit once with a fresh nonce and surface Subxt errors. Co-authored-by: Cursor --- src/cli/common.rs | 289 +++++++++++++++++++++++----------------------- 1 file changed, 144 insertions(+), 145 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index c224761..2dd70cd 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -341,7 +341,9 @@ pub async fn get_fresh_nonce_with_client( } /// Get incremented nonce for retry scenarios from the latest block using existing QuantusClient -/// This is useful when a transaction fails but the chain doesn't update the nonce +/// This is useful when a transaction fails but the chain doesn't update the nonce. +/// Not used by `submit_transaction` (auto nonce-bump retry removed); kept for intentional callers. +#[allow(dead_code)] pub async fn get_incremented_nonce_with_client( quantus_client: &crate::chain::client::QuantusClient, from_keypair: &crate::wallet::QuantumKeyPair, @@ -373,6 +375,30 @@ pub async fn get_incremented_nonce_with_client( Ok(incremented_nonce) } +/// Whether a formatted submission error may trigger automatic resubmit that bumps +/// the nonce and re-signs the same call. +/// +/// Always `false`. Matching English substrings from untrusted RPC text is imprecise, +/// and bumping the nonce without proving the prior extrinsic was rejected can +/// duplicate non-idempotent transactions. Bad-signature / Invalid Transaction / +/// pool / ambiguous errors must not be auto-retried this way. +#[cfg_attr(not(test), allow(dead_code))] +fn is_retryable_submission_error(error_msg: &str) -> bool { + // Categories that were previously (incorrectly) treated as transient. + const UNSAFE_OR_AMBIGUOUS: &[&str] = &[ + "Transaction has a bad signature", + "Invalid Transaction", + "Priority is too low", + "Transaction is outdated", + "Transaction is temporarily banned", + ]; + if UNSAFE_OR_AMBIGUOUS.iter().any(|needle| error_msg.contains(needle)) { + return false; + } + // Unknown / ambiguous formatted errors are also not safe for nonce-bump retry. + false +} + /// Submit transaction with optional finalization check /// /// By default, returns immediately after the node accepts the transaction submission. @@ -392,151 +418,105 @@ where crate::error::QuantusError::NetworkError(format!("Failed to convert keypair: {e:?}")) })?; - // Retry logic with automatic nonce management - let mut attempt = 0; - let mut current_nonce = None; + // Get a fresh nonce from the best block. Do not automatically resubmit the same + // call with a different nonce after a submission error: without authoritative + // confirmation that the prior extrinsic was rejected, doing so can duplicate + // non-idempotent transactions. + let nonce = get_fresh_nonce_with_client(quantus_client, from_keypair).await?; + log_verbose!("đŸ”ĸ Using fresh nonce from best block: {}", nonce); - loop { - attempt += 1; - // Get fresh nonce for each attempt, or increment if we have a previous nonce - let nonce = if let Some(prev_nonce) = current_nonce { - // After first failure, try with incremented nonce - let incremented_nonce = - get_incremented_nonce_with_client(quantus_client, from_keypair, prev_nonce).await?; - log_verbose!( - "đŸ”ĸ Using incremented nonce from best block: {} (previous: {})", - incremented_nonce, - prev_nonce - ); - incremented_nonce - } else { - // First attempt - get fresh nonce from best block - let fresh_nonce = get_fresh_nonce_with_client(quantus_client, from_keypair).await?; - log_verbose!("đŸ”ĸ Using fresh nonce from best block: {}", fresh_nonce); - fresh_nonce - }; - current_nonce = Some(nonce); + // Get current block for logging using latest block hash + let latest_block_hash = quantus_client.get_latest_block().await.map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to get latest block: {e:?}")) + })?; - // Get current block for logging using latest block hash - let latest_block_hash = quantus_client.get_latest_block().await.map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to get latest block: {e:?}")) + log_verbose!("🔗 Latest block hash: {:?}", latest_block_hash); + + // Create custom params with fresh nonce and optional tip + use subxt::config::DefaultExtrinsicParamsBuilder; + let mut params_builder = DefaultExtrinsicParamsBuilder::new() + .mortal(256) // Value higher than our finalization - TODO: should come from config + .nonce(nonce); + + if let Some(tip_amount) = tip { + params_builder = params_builder.tip(tip_amount); + log_verbose!("💰 Using tip: {} to increase priority", tip_amount); + } else { + log_verbose!("💰 No tip specified"); + } + + // Try to get chain parameters from the client + // let genesis_hash = quantus_client.get_genesis_hash().await?; + // let (spec_version, transaction_version) = quantus_client.get_runtime_version().await?; + + // log_verbose!("🔍 Chain parameters:"); + // log_verbose!(" Genesis hash: {:?}", genesis_hash); + // log_verbose!(" Spec version: {}", spec_version); + // log_verbose!(" Transaction version: {}", transaction_version); + + // For now, just use the default params + let params = params_builder.build(); + + // Log transaction parameters for debugging + log_verbose!("🔍 Transaction parameters:"); + log_verbose!(" Nonce: {}", nonce); + log_verbose!(" Tip: {:?}", tip); + log_verbose!(" Latest block hash: {:?}", latest_block_hash); + + // Get and log era information + log_verbose!(" Era: Using default era from SubXT"); + log_verbose!(" Genesis hash: Using default from SubXT"); + log_verbose!(" Spec version: Using default from SubXT"); + + // Log additional debugging info + log_verbose!("🔍 Additional debugging:"); + log_verbose!(" Call type: {:?}", std::any::type_name::()); + + let metadata = quantus_client.client().metadata(); + let encoded_call = + <_ as subxt::tx::Payload>::encode_call_data(&call, &metadata).map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to encode call: {:?}", e)) })?; + crate::log_verbose!("📝 Encoded call: 0x{}", hex::encode(&encoded_call)); + crate::log_print!("📝 Encoded call size: {} bytes", encoded_call.len()); - log_verbose!("🔗 Latest block hash: {:?}", latest_block_hash); + if execution_mode.should_watch_transaction() { + match quantus_client + .client() + .tx() + .sign_and_submit_then_watch(&call, &signer, params) + .await + { + Ok(mut tx_progress) => { + crate::log_verbose!("📋 Transaction submitted: {:?}", tx_progress); - // Create custom params with fresh nonce and optional tip - use subxt::config::DefaultExtrinsicParamsBuilder; - let mut params_builder = DefaultExtrinsicParamsBuilder::new() - .mortal(256) // Value higher than our finalization - TODO: should come from config - .nonce(nonce); + let tx_hash = tx_progress.extrinsic_hash(); - if let Some(tip_amount) = tip { - params_builder = params_builder.tip(tip_amount); - log_verbose!("💰 Using tip: {} to increase priority", tip_amount); - } else { - log_verbose!("💰 No tip specified"); - } + wait_tx_inclusion( + &mut tx_progress, + quantus_client.client(), + &tx_hash, + execution_mode.transaction_stage(), + ) + .await?; - // Try to get chain parameters from the client - // let genesis_hash = quantus_client.get_genesis_hash().await?; - // let (spec_version, transaction_version) = quantus_client.get_runtime_version().await?; - - // log_verbose!("🔍 Chain parameters:"); - // log_verbose!(" Genesis hash: {:?}", genesis_hash); - // log_verbose!(" Spec version: {}", spec_version); - // log_verbose!(" Transaction version: {}", transaction_version); - - // For now, just use the default params - let params = params_builder.build(); - - // Log transaction parameters for debugging - log_verbose!("🔍 Transaction parameters:"); - log_verbose!(" Nonce: {}", nonce); - log_verbose!(" Tip: {:?}", tip); - log_verbose!(" Latest block hash: {:?}", latest_block_hash); - - // Get and log era information - log_verbose!(" Era: Using default era from SubXT"); - log_verbose!(" Genesis hash: Using default from SubXT"); - log_verbose!(" Spec version: Using default from SubXT"); - - // Log additional debugging info - log_verbose!("🔍 Additional debugging:"); - log_verbose!(" Call type: {:?}", std::any::type_name::()); - - let metadata = quantus_client.client().metadata(); - let encoded_call = - <_ as subxt::tx::Payload>::encode_call_data(&call, &metadata).map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to encode call: {:?}", e)) - })?; - crate::log_verbose!("📝 Encoded call: 0x{}", hex::encode(&encoded_call)); - crate::log_print!("📝 Encoded call size: {} bytes", encoded_call.len()); - - if execution_mode.should_watch_transaction() { - match quantus_client - .client() - .tx() - .sign_and_submit_then_watch(&call, &signer, params) - .await - { - Ok(mut tx_progress) => { - crate::log_verbose!("📋 Transaction submitted: {:?}", tx_progress); - - let tx_hash = tx_progress.extrinsic_hash(); - - wait_tx_inclusion( - &mut tx_progress, - quantus_client.client(), - &tx_hash, - execution_mode.transaction_stage(), - ) - .await?; - - return Ok(tx_hash); - }, - Err(e) => { - 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"); - - if is_retryable && attempt < 5 { - log_verbose!( - "âš ī¸ Transaction error detected (attempt {}/5): {}", - attempt, - error_msg - ); - - // Exponential backoff: 2s, 4s, 8s, 16s - let delay = std::cmp::min(2u64.pow(attempt as u32), 16); - log_verbose!("âŗ Waiting {} seconds before retry...", delay); - tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await; - continue; - } else { - log_verbose!("❌ Final error after {} attempts: {}", attempt, error_msg); - return Err(crate::error::QuantusError::NetworkError(format!( - "Failed to submit transaction: {e:?}" - ))); - } - }, - } - } else { - match quantus_client.client().tx().sign_and_submit(&call, &signer, params).await { - Ok(tx_hash) => { - crate::log_print!("✅ Transaction submitted: {:?}", tx_hash); - return Ok(tx_hash); - }, - Err(e) => { - log_error!("❌ Failed to submit transaction: {e:?}"); - return Err(crate::error::QuantusError::NetworkError(format!( - "Failed to submit transaction: {e:?}" - ))); - }, - } + Ok(tx_hash) + }, + Err(e) => { + log_error!("❌ Failed to submit transaction: {e:?}"); + Err(e.into()) + }, + } + } else { + match quantus_client.client().tx().sign_and_submit(&call, &signer, params).await { + Ok(tx_hash) => { + crate::log_print!("✅ Transaction submitted: {:?}", tx_hash); + Ok(tx_hash) + }, + Err(e) => { + log_error!("❌ Failed to submit transaction: {e:?}"); + Err(e.into()) + }, } } } @@ -602,9 +582,7 @@ where }, Err(e) => { log_error!("❌ Failed to submit transaction with manual nonce {}: {e:?}", nonce); - Err(crate::error::QuantusError::NetworkError(format!( - "Failed to submit transaction with nonce {nonce}: {e:?}" - ))) + Err(e.into()) }, } } else { @@ -615,9 +593,7 @@ where }, Err(e) => { log_error!("❌ Failed to submit transaction: {e:?}"); - Err(crate::error::QuantusError::NetworkError(format!( - "Failed to submit transaction: {e:?}" - ))) + Err(e.into()) }, } } @@ -955,4 +931,27 @@ mod tests { assert_eq!(require_extrinsic_index(Some(3)).unwrap(), 3); } + + #[test] + fn unsafe_submission_errors_are_not_retryable() { + assert!(!is_retryable_submission_error("Transaction has a bad signature")); + assert!(!is_retryable_submission_error( + "RpcError: Invalid Transaction: Transaction has a bad signature" + )); + assert!(!is_retryable_submission_error("Invalid Transaction")); + assert!(!is_retryable_submission_error( + "Failed to submit transaction: Invalid Transaction" + )); + assert!(!is_retryable_submission_error("Priority is too low")); + assert!(!is_retryable_submission_error("Transaction is outdated")); + assert!(!is_retryable_submission_error("Transaction is temporarily banned")); + } + + #[test] + fn ambiguous_submission_errors_are_not_retryable() { + assert!(!is_retryable_submission_error("connection reset by peer")); + assert!(!is_retryable_submission_error("timeout waiting for response")); + assert!(!is_retryable_submission_error("")); + assert!(!is_retryable_submission_error("some unknown node error")); + } } From 02c741a297a6b11907bbf039176e693bc16c2e24 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:42:52 +0800 Subject: [PATCH 11/39] fix(wallet): authenticate address metadata and fail closed on migration Envelope address was trusted without keypair binding, enabling transfer redirect and spoofed listings. Validate on decrypt, stop passwordless trust of the envelope, and propagate legacy migration save failures. Co-authored-by: Cursor --- src/error.rs | 3 + src/wallet/keystore.rs | 84 +++++++++++++++++++ src/wallet/mod.rs | 179 ++++++++++++++++++++++++++++++++--------- 3 files changed, 230 insertions(+), 36 deletions(-) diff --git a/src/error.rs b/src/error.rs index f258d08..206dc26 100644 --- a/src/error.rs +++ b/src/error.rs @@ -67,6 +67,9 @@ pub enum WalletError { #[error("Decryption failed. Check your password.")] Decryption, + + #[error("Wallet integrity check failed: {0}")] + Integrity(String), } /// Type alias for Results using QuantusError diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 13622a9..3522d41 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -322,6 +322,17 @@ impl Keystore { // 3. Deserialize the wallet data let wallet_data: WalletData = serde_json::from_slice(&decrypted_data)?; + // 4. The plaintext envelope address is not AEAD-authenticated, so it must + // match the address derived from the decrypted key material before the + // wallet file is accepted as intact. + let derived_address = wallet_data.keypair.to_account_id_ss58check(); + if encrypted.address != derived_address { + return Err(WalletError::Integrity( + "stored address does not match decrypted keypair".to_string(), + ) + .into()); + } + Ok(wallet_data) } @@ -900,6 +911,33 @@ mod tests { ); } + #[test] + fn decrypt_rejects_tampered_envelope_address() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let keystore = Keystore::new(temp_dir.path()); + let victim = make_test_wallet_data("integrity-victim", 10); + let attacker = make_test_wallet_data("integrity-attacker", 11); + + let mut encrypted = keystore + .encrypt_wallet_data(&victim, "correct-password") + .expect("Encryption should succeed"); + let victim_address = encrypted.address.clone(); + let attacker_address = attacker.keypair.to_account_id_ss58check(); + assert_ne!(victim_address, attacker_address); + + // Attacker rewrites only the plaintext envelope address; ciphertext is untouched. + encrypted.address = attacker_address; + + let result = keystore.decrypt_wallet_data(&encrypted, "correct-password"); + assert!( + matches!( + result, + Err(crate::error::QuantusError::Wallet(WalletError::Integrity(_))) + ), + "tampered envelope address must fail integrity after authenticated decrypt, got: {result:?}" + ); + } + #[test] fn test_legacy_wallet_decrypt_and_migration() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -947,4 +985,50 @@ mod tests { assert_eq!(decrypted.name, data.name); assert_eq!(decrypted.keypair.private_key, data.keypair.private_key); } + + /// When migration cannot persist the re-encrypted wallet, load_wallet must + /// fail closed rather than returning Ok while leaving password-bypassable + /// key material on disk. + #[cfg(unix)] + #[test] + fn test_legacy_migration_save_failure_fails_closed() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let keystore = Keystore::new(temp_dir.path()); + let data = make_test_wallet_data("legacy-ro-wallet", 12); + + let legacy = encrypt_legacy(&data, "pw"); + assert!(Keystore::has_embedded_key_material(&legacy)); + keystore.save_wallet(&legacy).expect("Save should succeed"); + + // Force migration save to fail (cannot create .json.tmp in read-only dir). + let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions(); + perms.set_mode(0o555); + fs::set_permissions(temp_dir.path(), perms).unwrap(); + + use crate::wallet::WalletManager; + let wallet_manager = WalletManager { wallets_dir: temp_dir.path().to_path_buf() }; + let result = wallet_manager.load_wallet("legacy-ro-wallet", "pw"); + + // Restore writability so TempDir cleanup and assertions can proceed. + let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(temp_dir.path(), perms).unwrap(); + + assert!( + result.is_err(), + "load_wallet must Err when migration cannot save, got: {result:?}" + ); + + let reloaded = keystore + .load_wallet("legacy-ro-wallet") + .expect("Load should succeed") + .expect("Wallet should exist"); + assert!( + Keystore::has_embedded_key_material(&reloaded), + "failed migration must leave legacy file with embedded digest" + ); + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 39da740..6656659 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -193,13 +193,27 @@ impl WalletManager { continue; }; - // Create wallet info using stored public address - let wallet_info = WalletInfo { - name: encrypted_wallet.name, - address: encrypted_wallet.address, // Address is stored unencrypted - created_at: encrypted_wallet.created_at, - key_type: "Dilithium ML-DSA-87".to_string(), - derivation_path: "[Encrypted]".to_string(), // Derivation path is encrypted + // Only expose an address when it can be authenticated via empty-password + // decrypt (developer / no-password wallets). Never trust the plaintext + // envelope address for password-protected wallets. + let wallet_info = match keystore.decrypt_wallet_data(&encrypted_wallet, "") { + Ok(wallet_data) => WalletInfo { + name: wallet_data.name, + address: wallet_data.keypair.to_account_id_ss58check(), + created_at: encrypted_wallet.created_at, + key_type: "Dilithium ML-DSA-87".to_string(), + derivation_path: "[Encrypted]".to_string(), + }, + Err(crate::error::QuantusError::Wallet( + WalletError::InvalidPassword | WalletError::Integrity(_), + )) => WalletInfo { + name, + address: "[Encrypted]".to_string(), + created_at: encrypted_wallet.created_at, + key_type: "Dilithium ML-DSA-87".to_string(), + derivation_path: "[Encrypted]".to_string(), + }, + Err(_) => continue, }; wallets.push(wallet_info); } @@ -458,26 +472,41 @@ impl WalletManager { derivation_path: wallet_data.derivation_path, })) }, - Err(_) => { - // Wrong password, return basic info + Err(crate::error::QuantusError::Wallet(WalletError::InvalidPassword)) => { + // Wrong password, return basic info without trusting envelope metadata Ok(Some(WalletInfo { - name: encrypted_wallet.name, + name: name.to_string(), address: "[Wrong password]".to_string(), created_at: encrypted_wallet.created_at, key_type: "Dilithium ML-DSA-87".to_string(), derivation_path: "[Wrong password]".to_string(), })) }, + Err(e) => Err(e), } } else { - // No password provided, return basic info with public address - Ok(Some(WalletInfo { - name: encrypted_wallet.name, - address: encrypted_wallet.address, // Address is public - created_at: encrypted_wallet.created_at, - key_type: "Dilithium ML-DSA-87".to_string(), - derivation_path: "[Encrypted]".to_string(), // Derivation path is encrypted - })) + match keystore.decrypt_wallet_data(&encrypted_wallet, "") { + Ok(wallet_data) => { + let address = wallet_data.keypair.to_account_id_ss58check(); + Ok(Some(WalletInfo { + name: wallet_data.name, + address, + created_at: encrypted_wallet.created_at, + key_type: "Dilithium ML-DSA-87".to_string(), + derivation_path: "[Encrypted]".to_string(), + })) + }, + Err(crate::error::QuantusError::Wallet( + WalletError::InvalidPassword | WalletError::Integrity(_), + )) => Ok(Some(WalletInfo { + name: name.to_string(), + address: "[Encrypted]".to_string(), + created_at: encrypted_wallet.created_at, + key_type: "Dilithium ML-DSA-87".to_string(), + derivation_path: "[Encrypted]".to_string(), + })), + Err(e) => Err(e), + } } } else { Ok(None) @@ -498,17 +527,11 @@ impl WalletManager { // determines the AES key) in `argon2_params`. Re-encrypt without it on unlock. // Note: once migrated, the file can no longer be opened by older CLI // versions (they fail with "invalid password"). - // A failed save is non-fatal: the wallet decrypted fine, so don't block - // access (e.g. read-only wallets dir). + // Fail closed on migration save failure: returning Ok would leave a + // password-bypassable wallet file on disk. if Keystore::has_embedded_key_material(&encrypted_wallet) { - let migration = keystore - .encrypt_wallet_data(&wallet_data, password) - .and_then(|migrated| keystore.save_wallet(&migrated)); - if let Err(e) = migration { - crate::log_print!( - "âš ī¸ Could not re-encrypt wallet '{name}' to remove embedded key material: {e}" - ); - } + let migrated = keystore.encrypt_wallet_data(&wallet_data, password)?; + keystore.save_wallet(&migrated)?; } Ok(wallet_data) @@ -520,13 +543,20 @@ impl WalletManager { keystore.delete_wallet(name) } - /// Find wallet by name and return its address + /// Find wallet by name and return its authenticated address when available without a password pub fn find_wallet_address(&self, name: &str) -> Result> { let keystore = Keystore::new(&self.wallets_dir); if let Some(encrypted_wallet) = keystore.load_wallet(name)? { - // Return the stored address (it's stored unencrypted) - Ok(Some(encrypted_wallet.address)) + // Wallet-name resolution must not trust the plaintext envelope address. + // Only empty-password wallets can be authenticated without prompting. + match keystore.decrypt_wallet_data(&encrypted_wallet, "") { + Ok(wallet_data) => Ok(Some(wallet_data.keypair.to_account_id_ss58check())), + Err(crate::error::QuantusError::Wallet( + WalletError::InvalidPassword | WalletError::Integrity(_), + )) => Ok(None), + Err(e) => Err(e), + } } else { Ok(None) } @@ -979,10 +1009,15 @@ mod tests { assert!(wallet_names.contains(&&"wallet-2".to_string())); assert!(wallet_names.contains(&&"imported-wallet".to_string())); - // Check that addresses are real addresses (now stored unencrypted) + // Empty-password wallets expose authenticated addresses; password-protected + // wallets must not leak the unauthenticated envelope address. for wallet in &wallets { - assert!(wallet.address.starts_with("qz")); // Real SS58 addresses start with 5 assert_eq!(wallet.key_type, "Dilithium ML-DSA-87"); + if wallet.name == "wallet-2" { + assert!(wallet.address.starts_with("qz")); + } else { + assert_eq!(wallet.address, "[Encrypted]"); + } } // Check sorting (newest first) @@ -1000,14 +1035,14 @@ mod tests { .await .expect("Failed to create wallet"); - // Test getting wallet without password + // Passwordless view must not trust the unauthenticated envelope address let wallet_info = wallet_manager .get_wallet("test-get-wallet", None) .expect("Failed to get wallet") .expect("Wallet should exist"); assert_eq!(wallet_info.name, "test-get-wallet"); - assert_eq!(wallet_info.address, created_wallet.address); // Now returns real address + assert_eq!(wallet_info.address, "[Encrypted]"); // Test getting wallet with wrong password // Now with real quantum-safe encryption, wrong password should be detected @@ -1038,6 +1073,75 @@ mod tests { assert!(result.is_none()); } + #[tokio::test] + async fn passwordless_paths_do_not_trust_tampered_envelope_address() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + + let victim = wallet_manager + .create_wallet("victim_alias", Some("correct horse battery staple")) + .await + .expect("victim wallet"); + let attacker = wallet_manager + .create_wallet("attacker_wallet", Some("attacker password")) + .await + .expect("attacker wallet"); + assert_ne!(victim.address, attacker.address); + + let keystore = Keystore::new(&wallet_manager.wallets_dir); + let mut tampered = keystore + .load_wallet("victim_alias") + .expect("load") + .expect("victim exists"); + tampered.address = attacker.address.clone(); + keystore.save_wallet(&tampered).expect("persist tampered envelope"); + + let decrypt_result = + wallet_manager.load_wallet("victim_alias", "correct horse battery staple"); + assert!( + matches!( + decrypt_result, + Err(crate::error::QuantusError::Wallet(WalletError::Integrity(_))) + ), + "correct-password decrypt must reject envelope/keypair mismatch, got: {decrypt_result:?}" + ); + + let lookup = wallet_manager + .find_wallet_address("victim_alias") + .expect("passwordless resolution must not panic"); + assert_eq!( + lookup, None, + "password-protected wallets must refuse unauthenticated wallet-name resolution" + ); + + let listed = wallet_manager + .list_wallets() + .expect("list") + .into_iter() + .find(|w| w.name == "victim_alias") + .expect("victim should still be listed"); + assert_ne!( + listed.address, attacker.address, + "list_wallets must not display the attacker-substituted envelope address" + ); + assert!( + listed.address.contains('['), + "password-protected wallets must use a placeholder without authenticated decrypt" + ); + + let viewed = wallet_manager + .get_wallet("victim_alias", None) + .expect("view") + .expect("victim exists"); + assert_ne!( + viewed.address, attacker.address, + "passwordless get_wallet must not display the attacker-substituted envelope address" + ); + assert!( + viewed.address.contains('['), + "passwordless view of a password-protected wallet must use a placeholder" + ); + } + #[tokio::test] async fn list_wallets_skips_malformed_files_and_rejects_invalid_addresses() { use sp_core::crypto::{AccountId32, Ss58Codec}; @@ -1084,7 +1188,10 @@ mod tests { "valid wallet must remain listable" ); assert!( - listed_after.iter().all(|w| AccountId32::from_ss58check_with_version(&w.address).is_ok()), + listed_after.iter().all(|w| { + w.address == "[Encrypted]" || + AccountId32::from_ss58check_with_version(&w.address).is_ok() + }), "listing must not return addresses the SS58 parser rejects" ); assert!( From bfd228ee768b9b515a15d17f3fd5fac09e3d8698 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:46:50 +0800 Subject: [PATCH 12/39] fix(wallet): harden storage races and exclusive wallet creation Predictable temp paths and check-then-write overwrites allowed races and wallet replacement. Use exclusive create, safer temps, name checks, and locks. Co-authored-by: Cursor --- src/error.rs | 3 + src/wallet/keystore.rs | 427 ++++++++++++++++++++++++++++++++++++----- src/wallet/mod.rs | 32 +-- 3 files changed, 397 insertions(+), 65 deletions(-) diff --git a/src/error.rs b/src/error.rs index 206dc26..5ec217a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -59,6 +59,9 @@ pub enum WalletError { #[error("Invalid wallet address")] InvalidAddress, + #[error("Invalid wallet name")] + InvalidName, + #[error("Key generation failed")] KeyGeneration, diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 3522d41..ceb625d 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -4,7 +4,7 @@ /// - Quantum-safe encrypting and storing wallet data using Argon2 + AES-256-GCM /// - Loading and decrypting wallet data with post-quantum cryptography /// - Managing wallet files on disk with quantum-resistant security -use crate::error::{Result, WalletError}; +use crate::error::{QuantusError, Result, WalletError}; use qp_rusty_crystals_dilithium::ml_dsa_87::{Keypair, PublicKey, SecretKey}; #[cfg(test)] use qp_rusty_crystals_hdwallet::SensitiveBytes32; @@ -23,46 +23,148 @@ use aes_gcm::{ use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, Version}; use rand::{rng, RngCore}; -use std::path::Path; +use std::{ + collections::HashSet, + fs::{self, File, OpenOptions}, + io::{ErrorKind, Read, Write}, + path::{Path, PathBuf}, + sync::{Condvar, Mutex, OnceLock}, +}; use qp_dilithium_crypto::types::{DilithiumPair, DilithiumPublic}; use sp_runtime::traits::IdentifyAccount; -/// Atomically persist wallet JSON via temp file + rename. -#[cfg(unix)] -fn write_wallet_file_atomically(tmp: &Path, final_path: &Path, data: &[u8]) -> Result<()> { - use std::io::Write; - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - - // Create the temp file with 0600 before any ciphertext hits disk. - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(tmp)?; - // mode() only applies on create; force 0600 if a leftover tmp existed. - let mut perms = file.metadata()?.permissions(); - perms.set_mode(0o600); - std::fs::set_permissions(tmp, perms)?; - file.write_all(data)?; - file.sync_all()?; +fn keystore_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +fn wallet_filename(name: &str) -> Result { + if name.is_empty() || + name.contains('/') || + name.contains('\\') || + name == "." || + name == ".." + { + return Err(WalletError::InvalidName.into()); + } + Ok(format!("{name}.json")) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn set_no_follow(options: &mut OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + const O_NOFOLLOW: i32 = 0o400000; + options.custom_flags(O_NOFOLLOW); +} + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +fn set_no_follow(_options: &mut OpenOptions) {} + +fn open_wallet_for_read(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + set_no_follow(&mut options); + options.open(path) +} + +/// Exclusively create a random temporary file in the wallet directory. +/// `create_new` / O_EXCL refuses an existing path (including a pre-positioned symlink). +fn create_unique_temp(storage_path: &Path, name: &str) -> std::io::Result<(PathBuf, File)> { + for _ in 0..32 { + let mut nonce = [0u8; 16]; + rng().fill_bytes(&mut nonce); + let tmp_path = storage_path.join(format!(".{name}.{}.tmp", hex::encode(nonce))); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + set_no_follow(&mut options); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + match options.open(&tmp_path) { + Ok(file) => return Ok((tmp_path, file)), + Err(e) if e.kind() == ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new( + ErrorKind::AlreadyExists, + "could not create unique wallet temporary file", + )) +} + +fn write_temp_wallet_bytes(storage_path: &Path, name: &str, data: &[u8]) -> Result { + let (tmp_path, mut file) = create_unique_temp(storage_path, name)?; + let result = (|| -> Result<()> { + file.write_all(data)?; + file.sync_all()?; + Ok(()) + })(); + if let Err(e) = result { + let _ = fs::remove_file(&tmp_path); + return Err(e); + } drop(file); - std::fs::rename(tmp, final_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&tmp_path)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(&tmp_path, perms)?; + } + + Ok(tmp_path) +} - // Belt-and-suspenders: enforce owner-only on the final path too. - let mut perms = std::fs::metadata(final_path)?.permissions(); - perms.set_mode(0o600); - std::fs::set_permissions(final_path, perms)?; - Ok(()) +#[cfg(unix)] +fn same_file_metadata(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool { + use std::os::unix::fs::MetadataExt; + a.dev() == b.dev() && a.ino() == b.ino() } #[cfg(not(unix))] -fn write_wallet_file_atomically(tmp: &Path, final_path: &Path, data: &[u8]) -> Result<()> { - std::fs::write(tmp, data)?; - std::fs::rename(tmp, final_path)?; - Ok(()) +fn same_file_metadata(a: &std::fs::Metadata, b: &std::fs::Metadata) -> bool { + a.len() == b.len() && a.modified().ok() == b.modified().ok() +} + +struct WalletCreateLocks { + active: Mutex>, + available: Condvar, +} + +static WALLET_CREATE_LOCKS: OnceLock = OnceLock::new(); + +pub(crate) struct WalletCreateGuard { + path: PathBuf, + locks: &'static WalletCreateLocks, +} + +impl WalletCreateLocks { + fn lock(path: PathBuf) -> WalletCreateGuard { + let locks = WALLET_CREATE_LOCKS.get_or_init(|| WalletCreateLocks { + active: Mutex::new(HashSet::new()), + available: Condvar::new(), + }); + let mut active = locks.active.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + while active.contains(&path) { + active = + locks.available.wait(active).unwrap_or_else(|poisoned| poisoned.into_inner()); + } + active.insert(path.clone()); + WalletCreateGuard { path, locks } + } +} + +impl Drop for WalletCreateGuard { + fn drop(&mut self) { + let mut active = + self.locks.active.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + active.remove(&self.path); + self.locks.available.notify_all(); + } } /// Quantum-safe key pair using Dilithium post-quantum signatures @@ -139,7 +241,7 @@ impl QuantumKeyPair { } /// Quantum-safe encrypted wallet data structure -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, PartialEq)] pub struct EncryptedWallet { pub name: String, pub address: String, // SS58-encoded address (public, not encrypted) @@ -176,26 +278,118 @@ impl Keystore { Self { storage_path: storage_path.as_ref().to_path_buf() } } - /// Save an encrypted wallet to disk + /// Acquire the per-wallet-name create lock for check-then-save creation flows. + pub(crate) fn lock_wallet_create(&self, name: &str) -> Result { + let file_name = wallet_filename(name)?; + Ok(WalletCreateLocks::lock(self.storage_path.join(file_name))) + } + + /// Save an encrypted wallet to disk (may replace an existing wallet file). pub fn save_wallet(&self, wallet: &EncryptedWallet) -> Result<()> { - let wallet_file = self.storage_path.join(format!("{}.json", wallet.name)); - let tmp_file = self.storage_path.join(format!("{}.json.tmp", wallet.name)); + let _guard = keystore_lock() + .lock() + .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + self.save_wallet_unlocked(wallet) + } + + /// Save a newly-created wallet only if no wallet with this name exists. + pub fn save_new_wallet(&self, wallet: &EncryptedWallet) -> Result<()> { + let _guard = keystore_lock() + .lock() + .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + let file_name = wallet_filename(&wallet.name)?; + let wallet_file = self.storage_path.join(&file_name); let wallet_json = serde_json::to_string_pretty(wallet)?; - // Write to a temp file and rename so a crash mid-write can never leave a - // truncated file behind - it may hold the only copy of the key material. - write_wallet_file_atomically(&tmp_file, &wallet_file, wallet_json.as_bytes())?; - Ok(()) + let tmp_file = write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; + + // Atomically create the destination without replacing an existing wallet. + // hard_link fails with AlreadyExists when the final name is taken. + match fs::hard_link(&tmp_file, &wallet_file) { + Ok(()) => { + let _ = fs::remove_file(&tmp_file); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&wallet_file)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(&wallet_file, perms)?; + } + Ok(()) + }, + Err(e) if e.kind() == ErrorKind::AlreadyExists => { + let _ = fs::remove_file(&tmp_file); + Err(WalletError::AlreadyExists.into()) + }, + Err(e) => { + let _ = fs::remove_file(&tmp_file); + Err(e.into()) + }, + } + } + + /// Save a replacement only if the stored wallet still matches the caller's snapshot. + pub fn save_wallet_if_current( + &self, + wallet: &EncryptedWallet, + expected: &EncryptedWallet, + ) -> Result { + let _guard = keystore_lock() + .lock() + .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + match self.load_wallet_unlocked(&expected.name)? { + Some(current) if current == *expected => { + self.save_wallet_unlocked(wallet)?; + Ok(true) + }, + _ => Ok(false), + } + } + + fn save_wallet_unlocked(&self, wallet: &EncryptedWallet) -> Result<()> { + let file_name = wallet_filename(&wallet.name)?; + let wallet_file = self.storage_path.join(&file_name); + let wallet_json = serde_json::to_string_pretty(wallet)?; + // Unpredictable, exclusively-created temp so attackers cannot pre-position a + // symlink at a deterministic path. rename replaces the directory entry only. + let tmp_file = write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; + match fs::rename(&tmp_file, &wallet_file) { + Ok(()) => { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&wallet_file)?.permissions(); + perms.set_mode(0o600); + fs::set_permissions(&wallet_file, perms)?; + } + Ok(()) + }, + Err(e) => { + let _ = fs::remove_file(&tmp_file); + Err(e.into()) + }, + } } /// Load an encrypted wallet from disk pub fn load_wallet(&self, name: &str) -> Result> { - let wallet_file = self.storage_path.join(format!("{name}.json")); + let _guard = keystore_lock() + .lock() + .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + self.load_wallet_unlocked(name) + } - if !wallet_file.exists() { - return Ok(None); + fn load_wallet_unlocked(&self, name: &str) -> Result> { + let wallet_file = self.storage_path.join(wallet_filename(name)?); + let mut file = match open_wallet_for_read(&wallet_file) { + Ok(file) => file, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + if !file.metadata()?.file_type().is_file() { + return Err(QuantusError::Generic("wallet path is not a regular file".to_string())); } - - let wallet_json = std::fs::read_to_string(wallet_file)?; + let mut wallet_json = String::new(); + file.read_to_string(&mut wallet_json)?; let wallet: EncryptedWallet = serde_json::from_str(&wallet_json)?; Self::validate_wallet_address(&wallet.address)?; Ok(Some(wallet)) @@ -228,7 +422,9 @@ impl Keystore { if path.extension().and_then(|s| s.to_str()) == Some("json") { if let Some(name) = path.file_stem().and_then(|s| s.to_str()) { - wallets.push(name.to_string()); + if wallet_filename(name).is_ok() { + wallets.push(name.to_string()); + } } } } @@ -238,14 +434,37 @@ impl Keystore { /// Delete a wallet file pub fn delete_wallet(&self, name: &str) -> Result { - let wallet_file = self.storage_path.join(format!("{name}.json")); + let _guard = keystore_lock() + .lock() + .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + let wallet_file = self.storage_path.join(wallet_filename(name)?); + let before = match fs::symlink_metadata(&wallet_file) { + Ok(metadata) => metadata, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + }; + if !before.file_type().is_file() { + return Err(QuantusError::Generic( + "refusing to delete non-regular wallet file".to_string(), + )); + } + + let (tombstone, tombstone_file) = create_unique_temp(&self.storage_path, name)?; + drop(tombstone_file); + fs::remove_file(&tombstone)?; + match fs::rename(&wallet_file, &tombstone) { + Ok(()) => {}, + Err(e) if e.kind() == ErrorKind::NotFound => return Ok(false), + Err(e) => return Err(e.into()), + } - if wallet_file.exists() { - std::fs::remove_file(wallet_file)?; - Ok(true) - } else { - Ok(false) + let after = fs::symlink_metadata(&tombstone)?; + if !after.file_type().is_file() || !same_file_metadata(&before, &after) { + let _ = fs::rename(&tombstone, &wallet_file); + return Err(QuantusError::Generic("wallet changed during delete".to_string())); } + fs::remove_file(tombstone)?; + Ok(true) } /// Encrypt wallet data using quantum-safe Argon2 + AES-256-GCM @@ -1031,4 +1250,110 @@ mod tests { "failed migration must leave legacy file with embedded digest" ); } + + /// #160598: a predictable `{name}.json.tmp` symlink must not be followed or + /// cause an outside file to be overwritten when saving a wallet. + #[cfg(unix)] + #[test] + fn save_wallet_does_not_follow_predictable_tmp_symlink() { + use std::fs; + use std::os::unix::fs::symlink; + + let temp = TempDir::new().expect("temp dir"); + let wallets_dir = temp.path().join("wallets"); + let outside_dir = temp.path().join("outside"); + fs::create_dir_all(&wallets_dir).expect("wallet dir"); + fs::create_dir_all(&outside_dir).expect("outside dir"); + + let victim = outside_dir.join("outside_component_state.txt"); + let original = b"owned by another local component\n"; + fs::write(&victim, original).expect("seed victim"); + + let wallet_name = "raceable-wallet"; + let predictable_tmp = wallets_dir.join(format!("{wallet_name}.json.tmp")); + symlink(&victim, &predictable_tmp).expect("attacker symlink at predictable tmp"); + + let keystore = Keystore::new(&wallets_dir); + let data = make_test_wallet_data(wallet_name, 21); + let encrypted = keystore + .encrypt_wallet_data(&data, "password chosen by wallet owner") + .expect("encrypt"); + + keystore.save_wallet(&encrypted).expect("save must succeed without following symlink"); + + assert_eq!( + fs::read(&victim).expect("read victim"), + original, + "outside file must not be overwritten via predictable tmp symlink" + ); + let final_path = wallets_dir.join(format!("{wallet_name}.json")); + assert!(final_path.is_file(), "final wallet must be a regular file"); + assert!( + fs::symlink_metadata(&final_path).expect("stat").file_type().is_file(), + "final wallet entry must not be a symlink" + ); + } + + /// #160737: exclusive create must refuse to replace an existing wallet file. + #[test] + fn save_new_wallet_does_not_replace_existing() { + let temp = TempDir::new().expect("temp dir"); + let keystore = Keystore::new(temp.path()); + + let original = make_test_wallet_data("exclusive-wallet", 22); + let first = keystore.encrypt_wallet_data(&original, "pw").expect("encrypt first"); + keystore.save_new_wallet(&first).expect("first create must succeed"); + + let replacement = make_test_wallet_data("exclusive-wallet", 23); + let second = keystore.encrypt_wallet_data(&replacement, "pw").expect("encrypt second"); + let result = keystore.save_new_wallet(&second); + assert!( + matches!(result, Err(crate::error::QuantusError::Wallet(WalletError::AlreadyExists))), + "second create must fail with AlreadyExists, got: {result:?}" + ); + + let loaded = keystore + .load_wallet("exclusive-wallet") + .expect("load") + .expect("wallet present"); + assert_eq!( + loaded.address, first.address, + "existing wallet key material must not be replaced" + ); + assert_ne!(loaded.address, second.address); + } + + /// #160598 / #160737: path separators and traversal names are rejected. + #[test] + fn rejects_wallet_names_with_path_separators() { + let temp = TempDir::new().expect("temp dir"); + let keystore = Keystore::new(temp.path()); + let data = make_test_wallet_data("safe-name", 24); + let mut encrypted = + keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + + for bad_name in ["../evil", "foo/bar", "foo\\bar", ".", "..", ""] { + encrypted.name = bad_name.to_string(); + let save = keystore.save_wallet(&encrypted); + assert!( + matches!(save, Err(crate::error::QuantusError::Wallet(WalletError::InvalidName))), + "save_wallet must reject {bad_name:?}, got: {save:?}" + ); + let create = keystore.save_new_wallet(&encrypted); + assert!( + matches!(create, Err(crate::error::QuantusError::Wallet(WalletError::InvalidName))), + "save_new_wallet must reject {bad_name:?}, got: {create:?}" + ); + let load = keystore.load_wallet(bad_name); + assert!( + matches!(load, Err(crate::error::QuantusError::Wallet(WalletError::InvalidName))), + "load_wallet must reject {bad_name:?}, got: {load:?}" + ); + let delete = keystore.delete_wallet(bad_name); + assert!( + matches!(delete, Err(crate::error::QuantusError::Wallet(WalletError::InvalidName))), + "delete_wallet must reject {bad_name:?}, got: {delete:?}" + ); + } + } } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 6656659..d86de7c 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -8,7 +8,7 @@ pub mod keystore; pub mod password; -use crate::error::{Result, WalletError}; +use crate::error::{QuantusError, Result, WalletError}; pub use keystore::{Keystore, QuantumKeyPair, WalletData}; use qp_dilithium_crypto::DilithiumPair; use qp_rusty_crystals_hdwallet::{ @@ -79,8 +79,8 @@ impl WalletManager { password: Option<&str>, derivation_path: &str, ) -> Result { - // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -111,7 +111,7 @@ impl WalletManager { // Encrypt and save the wallet let password = password.unwrap_or(""); // Use empty password if none provided let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, password)?; - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -124,8 +124,8 @@ impl WalletManager { /// Create a new developer wallet pub async fn create_developer_wallet(&self, name: &str) -> Result { - // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -159,7 +159,7 @@ impl WalletManager { // Encrypt and save the wallet with empty password for test wallets let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, "")?; - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -240,8 +240,8 @@ impl WalletManager { name: &str, password: Option<&str>, ) -> Result { - // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -276,7 +276,7 @@ impl WalletManager { // Encrypt and save the wallet let password = password.unwrap_or(""); // Use empty password if none provided let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, password)?; - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -294,8 +294,8 @@ impl WalletManager { mnemonic: &str, password: Option<&str>, ) -> Result { - // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -328,7 +328,7 @@ impl WalletManager { // Encrypt and save the wallet let password = password.unwrap_or(""); // Use empty password if none provided let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, password)?; - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -347,8 +347,8 @@ impl WalletManager { password: Option<&str>, derivation_path: &str, ) -> Result { - // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -378,7 +378,7 @@ impl WalletManager { // Encrypt and save the wallet let password = password.unwrap_or(""); // Use empty password if none provided let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, password)?; - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -396,8 +396,8 @@ impl WalletManager { seed: &str, password: Option<&str>, ) -> Result { - // Check if wallet already exists let keystore = Keystore::new(&self.wallets_dir); + let _create_guard = keystore.lock_wallet_create(name)?; if keystore.load_wallet(name)?.is_some() { return Err(WalletError::AlreadyExists.into()); } @@ -443,7 +443,7 @@ impl WalletManager { // Encrypt and save the wallet let password = password.unwrap_or(""); // Use empty password if none provided let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, password)?; - keystore.save_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -531,7 +531,11 @@ impl WalletManager { // password-bypassable wallet file on disk. if Keystore::has_embedded_key_material(&encrypted_wallet) { let migrated = keystore.encrypt_wallet_data(&wallet_data, password)?; - keystore.save_wallet(&migrated)?; + if !keystore.save_wallet_if_current(&migrated, &encrypted_wallet)? { + return Err(QuantusError::Generic( + "wallet changed during legacy migration".to_string(), + )); + } } Ok(wallet_data) From a8051b1b0a3ac7f80804296203927056b15e6205 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:46:50 +0800 Subject: [PATCH 13/39] fix(client): verify Quantus runtime identity at connect time RPC connections trusted any node metadata for signing context. Require spec name quantus and a compatible runtime version before proceeding. Co-authored-by: Cursor --- src/chain/client.rs | 18 +++++++++ src/config/mod.rs | 98 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/chain/client.rs b/src/chain/client.rs index 00f73da..463e5b7 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -101,6 +101,24 @@ impl QuantusClient { // Create SubXT client using the configured RPC client let client = OnlineClient::::from_rpc_client(rpc_client).await?; + // Reject nodes that do not identify as a supported Quantus runtime before the + // client can be used to encode or sign transactions. + use jsonrpsee::core::client::ClientT; + let runtime_version: serde_json::Value = ws_client + .request::("state_getRuntimeVersion", []) + .await + .map_err(|e| { + QuantusError::NetworkError(format!("Failed to fetch runtime version: {e:?}")) + })?; + crate::config::validate_runtime_version_value(&runtime_version).map_err(|e| { + match e { + QuantusError::NetworkError(msg) => QuantusError::NetworkError(format!( + "{msg} (from {node_url})" + )), + other => other, + } + })?; + log_verbose!("✅ Connected to Quantus node successfully!"); Ok(QuantusClient { client, rpc_client: ws_client, node_url: node_url.to_string() }) diff --git a/src/config/mod.rs b/src/config/mod.rs index 5ec9c21..27f90bd 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,11 +1,16 @@ //! Runtime compatibility configuration. +use crate::error::{QuantusError, Result}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CompatibleRuntime { pub spec_version: u32, pub transaction_version: u32, } +/// Expected runtime spec name for Quantus nodes. +pub const EXPECTED_RUNTIME_SPEC_NAME: &str = "quantus"; + /// Supported runtime / transaction version pairs. pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = &[ CompatibleRuntime { spec_version: 134, transaction_version: 2 }, @@ -20,3 +25,96 @@ pub fn is_runtime_compatible(spec_version: u32, transaction_version: u32) -> boo runtime.spec_version == spec_version && runtime.transaction_version == transaction_version }) } + +/// Validate that a connected node's runtime identity is a supported Quantus runtime. +/// +/// Rejects wrong `specName` values and version pairs outside [`COMPATIBLE_RUNTIMES`]. +pub fn validate_runtime_identity( + spec_name: &str, + spec_version: u32, + transaction_version: u32, +) -> Result<()> { + if spec_name != EXPECTED_RUNTIME_SPEC_NAME || + !is_runtime_compatible(spec_version, transaction_version) + { + return Err(QuantusError::NetworkError(format!( + "Unsupported Quantus runtime: specName={spec_name}, specVersion={spec_version}, transactionVersion={transaction_version}" + ))); + } + Ok(()) +} + +/// Parse `state_getRuntimeVersion` JSON and reject unsupported Quantus runtimes. +pub fn validate_runtime_version_value(runtime_version: &serde_json::Value) -> Result<()> { + let spec_name = runtime_version["specName"].as_str().ok_or_else(|| { + QuantusError::NetworkError("Failed to parse runtime spec name".to_string()) + })?; + let spec_version = runtime_version["specVersion"].as_u64().ok_or_else(|| { + QuantusError::NetworkError("Failed to parse spec version".to_string()) + })? as u32; + let transaction_version = + runtime_version["transactionVersion"].as_u64().ok_or_else(|| { + QuantusError::NetworkError("Failed to parse transaction version".to_string()) + })? as u32; + + validate_runtime_identity(spec_name, spec_version, transaction_version) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn validate_runtime_identity_accepts_compatible_quantus_runtime() { + validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 136, 3) + .expect("compatible quantus runtime must be accepted"); + } + + #[test] + fn validate_runtime_identity_rejects_wrong_spec_name() { + let err = validate_runtime_identity("quantus-impersonator", 136, 3).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unsupported Quantus runtime") && msg.contains("quantus-impersonator"), + "expected wrong-spec-name rejection, got: {msg}" + ); + } + + #[test] + fn validate_runtime_identity_rejects_incompatible_runtime_versions() { + let err = validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 999_999, 999_999) + .unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Unsupported Quantus runtime") && + msg.contains("999999") && + msg.contains(EXPECTED_RUNTIME_SPEC_NAME), + "expected incompatible-version rejection, got: {msg}" + ); + } + + #[test] + fn validate_runtime_version_value_rejects_wrong_spec_name() { + let value = json!({ + "specName": "polkadot", + "specVersion": 136, + "transactionVersion": 3, + }); + let err = validate_runtime_version_value(&value).unwrap_err(); + assert!( + err.to_string().contains("polkadot"), + "expected wrong-spec-name rejection via JSON helper" + ); + } + + #[test] + fn validate_runtime_version_value_rejects_incompatible_runtime() { + let value = json!({ + "specName": "quantus", + "specVersion": 1, + "transactionVersion": 1, + }); + assert!(validate_runtime_version_value(&value).is_err()); + } +} From 304d6d5016a22e7c3d9f35189a46dba56e7fbc5d Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:46:50 +0800 Subject: [PATCH 14/39] fix(system): fail closed on invalid RPC token properties Missing or out-of-range tokenDecimals/symbol/ss58Format silently mis-scaled amounts. Validate properties before using them for formatting. Co-authored-by: Cursor --- src/cli/system.rs | 151 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 15 deletions(-) diff --git a/src/cli/system.rs b/src/cli/system.rs index 020bd0a..a65b99e 100644 --- a/src/cli/system.rs +++ b/src/cli/system.rs @@ -1,6 +1,7 @@ //! `quantus system` subcommand - system information use crate::{ chain::client::{ChainConfig, QuantusClient}, + error::QuantusError, log_print, log_verbose, }; use colored::Colorize; @@ -14,6 +15,9 @@ use subxt::{ PolkadotConfig, }; +/// Maximum decimal places supported by u128 balance scaling (10^38 fits in u128). +pub const MAX_SUPPORTED_TOKEN_DECIMALS: u64 = 38; + /// Chain native token information structure #[derive(Debug, Clone)] pub struct TokenInfo { @@ -22,6 +26,51 @@ pub struct TokenInfo { pub ss58_format: Option, } +/// Parse and validate chain token properties from RPC `chainspec_v1_properties`. +/// +/// Fail closed: missing/out-of-range `tokenDecimals`, empty `tokenSymbol`, and +/// `ss58Format` values above 255 are rejected instead of defaulted or truncated. +pub fn parse_token_info_from_properties( + properties: &serde_json::Map, +) -> crate::error::Result { + let symbol = properties + .get("tokenSymbol") + .and_then(|v| v.as_str()) + .filter(|symbol| !symbol.is_empty()) + .ok_or_else(|| { + QuantusError::NetworkError( + "Invalid or missing chain property tokenSymbol".to_string(), + ) + })? + .to_string(); + + let decimals = properties + .get("tokenDecimals") + .and_then(|v| v.as_u64()) + .filter(|decimals| *decimals <= MAX_SUPPORTED_TOKEN_DECIMALS) + .ok_or_else(|| { + QuantusError::NetworkError(format!( + "Invalid or missing chain property tokenDecimals; expected an integer between 0 and {MAX_SUPPORTED_TOKEN_DECIMALS}" + )) + })? as u8; + + let ss58_format = properties + .get("ss58Format") + .map(|v| { + v.as_u64() + .and_then(|format| u8::try_from(format).ok()) + .ok_or_else(|| { + QuantusError::NetworkError( + "Invalid chain property ss58Format; expected an integer between 0 and 255" + .to_string(), + ) + }) + }) + .transpose()?; + + Ok(TokenInfo { symbol, decimals, ss58_format }) +} + /// Chain information from ChainHead API #[derive(Debug, Clone)] pub struct ChainInfo { @@ -48,21 +97,7 @@ impl ChainHeadTokenClient { pub async fn get_token_info(&self) -> Result> { // Get system properties using chainspec_v1_properties let properties: serde_json::Map = self.rpc.chainspec_v1_properties().await?; - - // Extract token symbol - let symbol = properties - .get("tokenSymbol") - .and_then(|v| v.as_str()) - .unwrap_or("UNIT") // default to UNIT if no information - .to_string(); - - // Extract decimal places - let decimals = properties.get("tokenDecimals").and_then(|v| v.as_u64()).unwrap_or(0) as u8; // default to 0 if no information - - // Extract SS58 format (optional) - let ss58_format = properties.get("ss58Format").and_then(|v| v.as_u64()).map(|v| v as u8); - - Ok(TokenInfo { symbol, decimals, ss58_format }) + Ok(parse_token_info_from_properties(&properties)?) } /// Gets chain name @@ -361,3 +396,89 @@ async fn list_rpc_methods(quantus_client: &QuantusClient) -> crate::error::Resul Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn props(value: serde_json::Value) -> serde_json::Map { + value.as_object().expect("test properties must be an object").clone() + } + + #[test] + fn parse_token_info_accepts_valid_properties() { + let info = parse_token_info_from_properties(&props(json!({ + "tokenSymbol": "QUAN", + "tokenDecimals": 12, + "ss58Format": 42, + }))) + .expect("valid token properties must be accepted"); + assert_eq!(info.symbol, "QUAN"); + assert_eq!(info.decimals, 12); + assert_eq!(info.ss58_format, Some(42)); + } + + #[test] + fn parse_token_info_rejects_missing_token_decimals() { + let err = parse_token_info_from_properties(&props(json!({ + "tokenSymbol": "QUAN", + }))) + .unwrap_err(); + assert!( + err.to_string().contains("tokenDecimals"), + "expected missing tokenDecimals rejection, got: {err}" + ); + } + + #[test] + fn parse_token_info_rejects_out_of_range_token_decimals() { + let err = parse_token_info_from_properties(&props(json!({ + "tokenSymbol": "QUAN", + "tokenDecimals": MAX_SUPPORTED_TOKEN_DECIMALS + 1, + }))) + .unwrap_err(); + assert!( + err.to_string().contains("tokenDecimals"), + "expected out-of-range tokenDecimals rejection, got: {err}" + ); + } + + #[test] + fn parse_token_info_rejects_empty_symbol() { + let err = parse_token_info_from_properties(&props(json!({ + "tokenSymbol": "", + "tokenDecimals": 12, + }))) + .unwrap_err(); + assert!( + err.to_string().contains("tokenSymbol"), + "expected empty symbol rejection, got: {err}" + ); + } + + #[test] + fn parse_token_info_rejects_ss58_format_above_255() { + let err = parse_token_info_from_properties(&props(json!({ + "tokenSymbol": "QUAN", + "tokenDecimals": 12, + "ss58Format": 256, + }))) + .unwrap_err(); + assert!( + err.to_string().contains("ss58Format"), + "expected ss58Format>255 rejection, got: {err}" + ); + } + + #[test] + fn parse_token_info_allows_missing_ss58_format() { + let info = parse_token_info_from_properties(&props(json!({ + "tokenSymbol": "QUAN", + "tokenDecimals": 0, + }))) + .expect("ss58Format is optional"); + assert_eq!(info.decimals, 0); + assert_eq!(info.ss58_format, None); + } +} From 5fc7920d05d1341119e0c79fa3dbc914a4d84606 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:46:50 +0800 Subject: [PATCH 15/39] fix(wormhole): bind transfer events to from amount and count Destination-only matching could select another same-block transfer. Require a unique match on from, amount, and transfer_count. Co-authored-by: Cursor --- src/cli/wormhole.rs | 469 ++++++++++++++++++++++++++++++++------------ 1 file changed, 347 insertions(+), 122 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c111f35..30d1444 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1528,13 +1528,7 @@ pub async fn submit_unsigned_verify_private_batch( while let Some(Ok(status)) = tx_progress.next().await { match status { - TxStatus::InBestBlock(tx_in_block) => { - return Ok(( - IncludedAt::Best, - tx_in_block.block_hash(), - tx_in_block.extrinsic_hash(), - )); - }, + TxStatus::InBestBlock(_) => continue, TxStatus::InFinalizedBlock(tx_in_block) => { return Ok(( IncludedAt::Finalized, @@ -1693,13 +1687,7 @@ pub async fn submit_unsigned_verify_public_batch( while let Some(Ok(status)) = tx_progress.next().await { match status { - TxStatus::InBestBlock(tx_in_block) => { - return Ok(( - IncludedAt::Best, - tx_in_block.block_hash(), - tx_in_block.extrinsic_hash(), - )); - }, + TxStatus::InBestBlock(_) => continue, TxStatus::InFinalizedBlock(tx_in_block) => { return Ok(( IncludedAt::Finalized, @@ -1794,6 +1782,103 @@ pub struct TransferInfo { pub leaf_index: u64, } +/// Expected attributes used to uniquely bind a `NativeTransferred` event. +/// +/// Optional fields are wildcards when `None`. Call sites that know the intended +/// funding account / amount / transfer_count should set them so a same-block +/// transfer to the same destination cannot be selected by destination alone. +#[derive(Debug, Clone)] +struct ExpectedTransferEvent { + wormhole_address: SubxtAccountId, + funding_account: Option, + amount: Option, + transfer_count: Option, + leaf_index: Option, +} + +struct RoundProofGeneration { + proof_files: Vec, + expected_transfers: Vec, +} + +fn push_expected_transfer( + expected: &mut Vec, + wormhole_address: SubxtAccountId, + funding_account: SubxtAccountId, + amount: u128, + transfer_count: Option, + leaf_index: Option, +) { + if let Some(existing) = expected.iter_mut().find(|e| { + e.wormhole_address == wormhole_address && + e.funding_account.as_ref() == Some(&funding_account) && + e.transfer_count == transfer_count && + e.leaf_index == leaf_index + }) { + let current = existing.amount.unwrap_or(0); + existing.amount = Some(current.saturating_add(amount)); + } else { + expected.push(ExpectedTransferEvent { + wormhole_address, + funding_account: Some(funding_account), + amount: Some(amount), + transfer_count, + leaf_index, + }); + } +} + +fn event_matches_expected( + event: &wormhole::events::NativeTransferred, + expected: &ExpectedTransferEvent, +) -> bool { + event.to == expected.wormhole_address && + expected.funding_account.as_ref().map_or(true, |from| &event.from == from) && + expected.amount.map_or(true, |amount| event.amount == amount) && + expected.transfer_count.map_or(true, |count| event.transfer_count == count) && + expected.leaf_index.map_or(true, |leaf| event.leaf_index == leaf) +} + +fn parse_expected_transfer_events( + events: &[wormhole::events::NativeTransferred], + expected_transfers: &[ExpectedTransferEvent], + block_hash: subxt::utils::H256, +) -> Result, crate::error::QuantusError> { + let mut transfer_infos = Vec::with_capacity(expected_transfers.len()); + + for expected in expected_transfers { + let matches: Vec<&wormhole::events::NativeTransferred> = + events.iter().filter(|event| event_matches_expected(event, expected)).collect(); + + let matching_event = match matches.as_slice() { + [event] => *event, + [] => { + return Err(crate::error::QuantusError::Generic(format!( + "No transfer event found matching expected attributes for address {:?}", + expected.wormhole_address + ))); + }, + _ => { + return Err(crate::error::QuantusError::Generic(format!( + "Ambiguous transfer events matching expected attributes for address {:?}", + expected.wormhole_address + ))); + }, + }; + + transfer_infos.push(TransferInfo { + block_hash, + transfer_count: matching_event.transfer_count, + amount: matching_event.amount, + wormhole_address: expected.wormhole_address.clone(), + funding_account: matching_event.from.clone(), + leaf_index: matching_event.leaf_index, + }); + } + + Ok(transfer_infos) +} + /// Derive a wormhole secret using HD derivation /// Path: m/44'/189189189'/0'/round'/index' fn derive_wormhole_secret( @@ -1831,34 +1916,29 @@ async fn get_minting_account( } /// Parse transfer info from NativeTransferred events in a block and updates block hash for all -/// transfers +/// transfers. +/// +/// Destination-only matching rejects ambiguous duplicate destinations instead of +/// accepting the first event. Internal call sites that know intended +/// from/amount/transfer_count bind those attributes before accepting an event. pub fn parse_transfer_events( events: &[wormhole::events::NativeTransferred], expected_addresses: &[SubxtAccountId], block_hash: subxt::utils::H256, ) -> Result, crate::error::QuantusError> { - let mut transfer_infos = Vec::new(); - - for expected_addr in expected_addresses { - // Find the event matching this address - let matching_event = events.iter().find(|e| &e.to == expected_addr).ok_or_else(|| { - crate::error::QuantusError::Generic(format!( - "No transfer event found for address {:?}", - expected_addr - )) - })?; - - transfer_infos.push(TransferInfo { - block_hash, - transfer_count: matching_event.transfer_count, - amount: matching_event.amount, - wormhole_address: expected_addr.clone(), - funding_account: matching_event.from.clone(), - leaf_index: matching_event.leaf_index, - }); - } + let expected_transfers: Vec = expected_addresses + .iter() + .cloned() + .map(|wormhole_address| ExpectedTransferEvent { + wormhole_address, + funding_account: None, + amount: None, + transfer_count: None, + leaf_index: None, + }) + .collect(); - Ok(transfer_infos) + parse_expected_transfer_events(events, &expected_transfers, block_hash) } /// Configuration for multiround execution @@ -2046,63 +2126,43 @@ async fn execute_initial_transfers( &quantum_keypair, batch_tx, None, - ExecutionMode { finalized: false, wait_for_transaction: true }, + ExecutionMode { finalized: true, wait_for_transaction: true }, ) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Batch transfer failed: {}", e)))?; - // Get the block hash for the transfer info + // Inclusion waited for finalization; read events from the current best tip. let block = at_best_block(quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; let block_hash = block.hash(); - // Fetch events from the block to get leaf_index values let events_api = quantus_client.client().events().at(block_hash).await.map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)) })?; + let transfer_events: Vec = events_api + .find::() + .filter_map(|e| e.ok()) + .collect(); - // Build transfer info using the transfer counts we captured before the batch - // and leaf_index from events let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.to_account_id_32().into()); - let mut transfers = Vec::with_capacity(num_proofs); - - for (i, secret) in secrets.iter().enumerate() { - let wormhole_address = SubxtAccountId(secret.address); - - // Find the matching event to get leaf_index - let event = events_api - .find::() - .find(|e| { - if let Ok(evt) = e { - evt.to == wormhole_address && evt.transfer_count == transfer_counts_before[i] - } else { - false - } - }) - .ok_or_else(|| { - crate::error::QuantusError::Generic(format!( - "No transfer event found for address {}", - hex::encode(secret.address) - )) - })? - .map_err(|e| { - crate::error::QuantusError::Generic(format!("Event decode error: {}", e)) - })?; - - transfers.push(TransferInfo { - block_hash, - transfer_count: transfer_counts_before[i], - amount: partition_amounts[i], - wormhole_address, - funding_account: funding_account.clone(), - leaf_index: event.leaf_index, - }); - } + let expected_transfers: Vec = secrets + .iter() + .enumerate() + .map(|(i, secret)| ExpectedTransferEvent { + wormhole_address: SubxtAccountId(secret.address), + funding_account: Some(funding_account.clone()), + amount: Some(partition_amounts[i]), + transfer_count: Some(transfer_counts_before[i]), + leaf_index: None, + }) + .collect(); + let transfers = + parse_expected_transfer_events(&transfer_events, &expected_transfers, block_hash)?; log_success!( - " {} transfers submitted in a single batch (block {})", + " {} transfers submitted in a single finalized batch (block {})", num_proofs, hex::encode(block_hash.0) ); @@ -2116,9 +2176,10 @@ async fn generate_round_proofs( secrets: &[WormholePair], transfers: &[TransferInfo], exit_accounts: &[SubxtAccountId], + minting_account: &SubxtAccountId, round_dir: &str, num_proofs: usize, -) -> crate::error::Result> { +) -> crate::error::Result { use colored::Colorize; log_print!("{}", "Step 2: Generating proofs...".bright_yellow()); @@ -2141,12 +2202,31 @@ async fn generate_round_proofs( // Log the random partition log_print!(" Random output partition:"); + let mut expected_transfers = Vec::new(); for (i, assignment) in output_assignments.iter().enumerate() { let amt1_planck = (assignment.output_amount_1 as u128) * SCALE_DOWN_FACTOR; let ss58_1 = bytes_to_quantus_ss58(&assignment.exit_account_1); + if assignment.output_amount_1 > 0 { + push_expected_transfer( + &mut expected_transfers, + SubxtAccountId(assignment.exit_account_1), + minting_account.clone(), + amt1_planck, + None, + None, + ); + } if assignment.output_amount_2 > 0 { let amt2_planck = (assignment.output_amount_2 as u128) * SCALE_DOWN_FACTOR; let ss58_2 = bytes_to_quantus_ss58(&assignment.exit_account_2); + push_expected_transfer( + &mut expected_transfers, + SubxtAccountId(assignment.exit_account_2), + minting_account.clone(), + amt2_planck, + None, + None, + ); log_print!( " Proof {}: {} ({}) -> {}, {} ({}) -> {}", i + 1, @@ -2217,7 +2297,7 @@ async fn generate_round_proofs( proof_gen_elapsed.as_secs_f64() / num_proofs as f64, ); - Ok(proof_files) + Ok(RoundProofGeneration { proof_files, expected_transfers }) } /// Derive wormhole secrets for a round @@ -2459,11 +2539,12 @@ async fn run_multiround( } // Step 2: Generate proofs with random output partitioning - let proof_files = generate_round_proofs( + let RoundProofGeneration { proof_files, expected_transfers } = generate_round_proofs( &quantus_client, &secrets, ¤t_transfers, &exit_accounts, + &minting_account, &round_dir, num_proofs, ) @@ -2507,7 +2588,7 @@ async fn run_multiround( if !is_final { log_print!("{}", "Step 5: Capturing transfer info for next round...".bright_yellow()); - // Parse events to get transfer info for next round's wormhole addresses + // Reorder expected transfers to match next-round secret indices. let next_round_addresses: Vec = (1..=num_proofs) .map(|i| { let next_secret = @@ -2515,9 +2596,27 @@ async fn run_multiround( SubxtAccountId(next_secret.address) }) .collect(); + let expected_ordered: Vec = next_round_addresses + .iter() + .map(|addr| { + expected_transfers + .iter() + .find(|e| &e.wormhole_address == addr) + .cloned() + .ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "No expected transfer for next-round address {:?}", + addr + )) + }) + }) + .collect::>()?; - current_transfers = - parse_transfer_events(&transfer_events, &next_round_addresses, verification_block)?; + current_transfers = parse_expected_transfer_events( + &transfer_events, + &expected_ordered, + verification_block, + )?; log_print!( " Captured {} transfer(s) for round {}", @@ -3306,6 +3405,7 @@ async fn run_dissolve( let quantus_client = QuantusClient::new(node_url) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to connect: {}", e)))?; + let minting_account = get_minting_account(quantus_client.client()).await?; // Create output directory std::fs::create_dir_all(&output_dir).map_err(|e| { @@ -3326,6 +3426,26 @@ async fn run_dissolve( let initial_secret = derive_wormhole_secret(&wallet.mnemonic, 0, 1)?; let wormhole_address = SubxtAccountId(initial_secret.address); + let transfer_count_before = quantus_client + .client() + .storage() + .at_latest() + .await + .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get storage: {}", e)))? + .fetch( + &quantus_node::api::storage() + .wormhole() + .transfer_count(wormhole_address.clone()), + ) + .await + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to fetch transfer count for initial dissolve address: {}", + e + )) + })? + .unwrap_or(0); + // Transfer to the wormhole address let transfer_tx = quantus_node::api::tx().balances().transfer_allow_death( subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address.clone()), @@ -3342,12 +3462,11 @@ async fn run_dissolve( &quantum_keypair, transfer_tx, None, - ExecutionMode { finalized: false, wait_for_transaction: true }, + ExecutionMode { finalized: true, wait_for_transaction: true }, ) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Initial transfer failed: {}", e)))?; - // Get block and event let block = at_best_block(&quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; @@ -3356,19 +3475,32 @@ async fn run_dissolve( quantus_client.client().events().at(block_hash).await.map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)) })?; - let event = events_api + let transfer_events: Vec = events_api .find::() - .find(|e| if let Ok(evt) = e { evt.to.0 == initial_secret.address } else { false }) - .ok_or_else(|| crate::error::QuantusError::Generic("No transfer event found".to_string()))? - .map_err(|e| crate::error::QuantusError::Generic(format!("Event decode error: {}", e)))?; + .filter_map(|e| e.ok()) + .collect(); + let expected_initial = [ExpectedTransferEvent { + wormhole_address: wormhole_address.clone(), + funding_account: Some(funding_account.clone()), + amount: Some(amount), + transfer_count: Some(transfer_count_before), + leaf_index: None, + }]; + let initial_transfer = + parse_expected_transfer_events(&transfer_events, &expected_initial, block_hash)? + .into_iter() + .next() + .ok_or_else(|| { + crate::error::QuantusError::Generic("No initial transfer event found".to_string()) + })?; let mut current_outputs = vec![DissolveOutput { secret: *initial_secret.secret.as_bytes(), - amount, - transfer_count: event.transfer_count, - funding_account: funding_account.clone(), + amount: initial_transfer.amount, + transfer_count: initial_transfer.transfer_count, + funding_account: initial_transfer.funding_account, proof_block_hash: block_hash, - leaf_index: event.leaf_index, + leaf_index: initial_transfer.leaf_index, }]; log_success!(" Funded 1 wormhole address with {}", format_balance(amount)); @@ -3419,6 +3551,7 @@ async fn run_dissolve( // Use the proof_block_hash from the first input (all inputs in a batch // were created in the same verification block from the previous layer). let batch_proof_block_hash = batch_inputs[0].proof_block_hash; + let mut expected_child_outputs: Vec<([u8; 32], ExpectedTransferEvent)> = Vec::new(); for (i, input) in batch_inputs.iter().enumerate() { let global_idx = batch_start + i; @@ -3437,6 +3570,26 @@ async fn run_dissolve( output_amount_2: output_2.max(1), exit_account_2: next_secrets[exit_2_idx].address, }; + expected_child_outputs.push(( + *next_secrets[exit_1_idx].secret.as_bytes(), + ExpectedTransferEvent { + wormhole_address: SubxtAccountId(next_secrets[exit_1_idx].address), + funding_account: Some(minting_account.clone()), + amount: Some((assignment.output_amount_1 as u128) * SCALE_DOWN_FACTOR), + transfer_count: None, + leaf_index: None, + }, + )); + expected_child_outputs.push(( + *next_secrets[exit_2_idx].secret.as_bytes(), + ExpectedTransferEvent { + wormhole_address: SubxtAccountId(next_secrets[exit_2_idx].address), + funding_account: Some(minting_account.clone()), + amount: Some((assignment.output_amount_2 as u128) * SCALE_DOWN_FACTOR), + transfer_count: None, + leaf_index: None, + }, + )); let proof_file = format!("{}/batch{}_proof{}.hex", layer_dir, batch_idx, i); @@ -3475,36 +3628,27 @@ async fn run_dissolve( log_success!(" Verified in block 0x{}", hex::encode(verification_block.0)); - // Collect next layer's outputs from the transfer events - // Use the verification_block as the proof_block_hash for the next layer - for (i, _input) in batch_inputs.iter().enumerate() { - let global_idx = batch_start + i; - let exit_1_idx = global_idx * 2; - let exit_2_idx = global_idx * 2 + 1; - - for (secret_idx, target_address) in [ - (exit_1_idx, &next_secrets[exit_1_idx]), - (exit_2_idx, &next_secrets[exit_2_idx]), - ] { - let event = transfer_events - .iter() - .find(|e| e.to.0 == target_address.address) - .ok_or_else(|| { - crate::error::QuantusError::Generic(format!( - "No transfer event for output {} at layer {}", - secret_idx, layer - )) - })?; - - all_next_outputs.push(DissolveOutput { - secret: *target_address.secret.as_bytes(), - amount: event.amount, - transfer_count: event.transfer_count, - funding_account: event.from.clone(), - proof_block_hash: verification_block, - leaf_index: event.leaf_index, - }); - } + // Collect next layer's outputs from the transfer events. + // Use the verification_block as the proof_block_hash for the next layer. + let expected_events: Vec = + expected_child_outputs.iter().map(|(_, expected)| expected.clone()).collect(); + let parsed_outputs = parse_expected_transfer_events( + &transfer_events, + &expected_events, + verification_block, + )?; + + for ((secret, _expected), transfer) in + expected_child_outputs.into_iter().zip(parsed_outputs.into_iter()) + { + all_next_outputs.push(DissolveOutput { + secret, + amount: transfer.amount, + transfer_count: transfer.transfer_count, + funding_account: transfer.funding_account, + proof_block_hash: verification_block, + leaf_index: transfer.leaf_index, + }); } } @@ -4485,6 +4629,87 @@ mod tests { } } + fn acct(seed: u8) -> SubxtAccountId { + SubxtAccountId([seed; 32]) + } + + #[test] + fn parse_expected_transfer_events_binds_by_from_and_amount_not_destination_alone() { + let shared_to = acct(0x42); + let attacker_from = acct(0xA1); + let intended_from = acct(0xB2); + let block_hash = subxt::utils::H256([0xCC; 32]); + + let attacker_event = wormhole::events::NativeTransferred { + from: attacker_from.clone(), + to: shared_to.clone(), + amount: 111, + transfer_count: 7, + leaf_index: 70, + }; + let intended_event = wormhole::events::NativeTransferred { + from: intended_from.clone(), + to: shared_to.clone(), + amount: 999_000, + transfer_count: 42, + leaf_index: 420, + }; + + // Destination-only first-match would pick the attacker event. With expected + // from/amount/transfer_count the intended transfer must be selected instead. + let expected = [ExpectedTransferEvent { + wormhole_address: shared_to.clone(), + funding_account: Some(intended_from.clone()), + amount: Some(999_000), + transfer_count: Some(42), + leaf_index: None, + }]; + let parsed = parse_expected_transfer_events( + &[ + wormhole::events::NativeTransferred { + from: attacker_from.clone(), + to: shared_to.clone(), + amount: 111, + transfer_count: 7, + leaf_index: 70, + }, + intended_event, + ], + &expected, + block_hash, + ) + .expect("expected attributes uniquely identify the intended transfer"); + + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0].funding_account, intended_from); + assert_eq!(parsed[0].amount, 999_000); + assert_eq!(parsed[0].transfer_count, 42); + assert_eq!(parsed[0].leaf_index, 420); + assert_ne!(parsed[0].funding_account, attacker_from); + assert_ne!(parsed[0].amount, attacker_event.amount); + + // Destination-only public helper must refuse ambiguous duplicates. + let ambiguous = parse_transfer_events( + &[attacker_event, wormhole::events::NativeTransferred { + from: intended_from, + to: shared_to.clone(), + amount: 999_000, + transfer_count: 42, + leaf_index: 420, + }], + &[shared_to], + block_hash, + ); + assert!( + ambiguous.is_err(), + "destination-only parse must not accept first-match among duplicate destinations" + ); + assert!( + ambiguous.unwrap_err().to_string().contains("Ambiguous"), + "expected ambiguous-match error" + ); + } + #[tokio::test] #[serial_test::serial] async fn load_multiround_wallet_errors_when_wallet_has_no_mnemonic() { From c5d3f2d6a199504d0206b255dfb4896ba9af4840 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:46:50 +0800 Subject: [PATCH 16/39] fix(update): verify release archive SHA-256 before install Self-update applied GitHub archives without checking published sha256sums. Download the sibling checksum file and verify before replace. Co-authored-by: Cursor --- src/cli/update.rs | 274 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 263 insertions(+), 11 deletions(-) diff --git a/src/cli/update.rs b/src/cli/update.rs index 31632a6..4900528 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -4,9 +4,18 @@ //! platform and replaces the running `quantus` binary in place. Cross-platform //! binary replacement (including the Windows "can't overwrite a running exe" //! case) is handled by the `self_update` crate. +//! +//! Before installing, the downloaded archive is verified against the sibling +//! `sha256sums-*.txt` asset published by the release workflow. use crate::{error::QuantusError, log_print, log_success}; use colored::Colorize; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{self, Write}, + path::Path, +}; const REPO_OWNER: &str = "Quantus-Network"; const REPO_NAME: &str = "quantus-cli"; @@ -110,6 +119,64 @@ pub fn latest_stable_version() -> crate::error::Result { Ok(release.version.trim_start_matches('v').to_string()) } +/// Verify `data` matches the expected SHA-256 hex digest (case-insensitive). +/// +/// Used to bind a downloaded release archive to the published `sha256sums` +/// digest before the archive is extracted or the running binary is replaced. +fn verify_sha256(data: &[u8], expected_hex: &str) -> crate::error::Result<()> { + let expected = expected_hex.trim().to_ascii_lowercase(); + if expected.len() != 64 || !expected.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(QuantusError::Generic(format!( + "Invalid SHA-256 digest (expected 64 hex chars): {expected_hex}" + ))); + } + + let actual = hex::encode(Sha256::digest(data)); + if actual != expected { + return Err(QuantusError::Generic(format!( + "Release archive SHA-256 mismatch: expected {expected}, got {actual}. \ + Refusing to install." + ))); + } + Ok(()) +} + +/// Parse the expected SHA-256 hex for `asset_name` from a `sha256sums` file body. +/// +/// Accepts the GNU/`shasum -a 256` line format: ` ` (one or more +/// spaces; optional `*` binary-mode prefix on the filename). +fn expected_hash_from_sha256sums( + sums_text: &str, + asset_name: &str, +) -> crate::error::Result { + for line in sums_text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut parts = line.split_whitespace(); + let Some(hash) = parts.next() else { + continue; + }; + let Some(name) = parts.next() else { + continue; + }; + let name = name.strip_prefix('*').unwrap_or(name); + if name == asset_name || Path::new(name).file_name().and_then(|n| n.to_str()) == Some(asset_name) + { + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(QuantusError::Generic(format!( + "Malformed SHA-256 digest for {asset_name} in sha256sums file" + ))); + } + return Ok(hash.to_ascii_lowercase()); + } + } + Err(QuantusError::Generic(format!( + "No SHA-256 digest found for asset `{asset_name}` in release sha256sums file" + ))) +} + /// Blocking implementation that talks to GitHub and replaces the binary. fn run_update( check_only: bool, @@ -132,23 +199,164 @@ fn run_update( let mut builder = configure_updater(); builder.show_download_progress(true).no_confirm(yes); - if let Some(version) = version { - // Accept both `1.5.0` and `v1.5.0`; the release tags include the `v`. - let tag = if version.starts_with('v') { version } else { format!("v{version}") }; - builder.target_version_tag(&tag); + let target_tag = version.map(|v| if v.starts_with('v') { v } else { format!("v{v}") }); + if let Some(ref tag) = target_tag { + builder.target_version_tag(tag); } - let status = builder - .build() - .map_err(map_self_update_err)? - .update() + let updater = builder.build().map_err(map_self_update_err)?; + let release = if let Some(ref tag) = target_tag { + updater.get_release_version(tag).map_err(map_self_update_err)? + } else { + let latest = updater.get_latest_release().map_err(map_self_update_err)?; + if !self_update::version::bump_is_greater(current, &latest.version).unwrap_or(false) { + return Ok(UpdateOutcome::AlreadyLatest(latest.version)); + } + latest + }; + + install_verified_release(updater.as_ref(), &release, yes)?; + Ok(UpdateOutcome::Updated(release.version)) +} + +/// Download the release archive and its published sha256sums, verify integrity, +/// then extract and replace the running binary. +fn install_verified_release( + updater: &dyn self_update::update::ReleaseUpdate, + release: &self_update::update::Release, + yes: bool, +) -> crate::error::Result<()> { + let target = updater.target(); + let archive_asset = release + .asset_for(&target, Some(ASSET_IDENTIFIER)) + .ok_or_else(|| { + QuantusError::Generic(format!( + "No release archive found for target `{target}` (looking for {ASSET_IDENTIFIER})" + )) + })?; + let sums_asset = release + .assets + .iter() + .find(|a| a.name.contains("sha256sums") && a.name.contains(&target)) + .cloned() + .ok_or_else(|| { + QuantusError::Generic(format!( + "No sha256sums asset found for target `{target}` in release v{}", + release.version + )) + })?; + + log_print!(""); + log_print!("{} release status:", BIN_NAME); + log_print!(" * Current exe: {:?}", updater.bin_install_path()); + log_print!(" * New exe release: {}", archive_asset.name); + log_print!(" * Checksum file: {}", sums_asset.name); + log_print!( + "\nThe new release will be downloaded, SHA-256 verified, extracted, and the existing binary will be replaced." + ); + + if !yes { + confirm_update()?; + } + + log_print!("Downloading checksums..."); + let mut sums_bytes = Vec::new(); + download_asset(&sums_asset.download_url, &mut sums_bytes, false)?; + let sums_text = std::str::from_utf8(&sums_bytes).map_err(|e| { + QuantusError::Generic(format!("Release sha256sums file is not valid UTF-8: {e}")) + })?; + let expected_hex = expected_hash_from_sha256sums(sums_text, &archive_asset.name)?; + + let tmp_dir = self_update::TempDir::new() + .map_err(|e| QuantusError::Generic(format!("Failed to create temp dir for update: {e}")))?; + let archive_path = tmp_dir.path().join(&archive_asset.name); + + log_print!("Downloading..."); + { + let mut archive_file = fs::File::create(&archive_path).map_err(|e| { + QuantusError::Generic(format!("Failed to create temp archive file: {e}")) + })?; + download_asset(&archive_asset.download_url, &mut archive_file, true)?; + archive_file + .flush() + .map_err(|e| QuantusError::Generic(format!("Failed to flush archive download: {e}")))?; + } + + log_print!("Verifying SHA-256..."); + let archive_bytes = fs::read(&archive_path) + .map_err(|e| QuantusError::Generic(format!("Failed to read downloaded archive: {e}")))?; + verify_sha256(&archive_bytes, &expected_hex)?; + log_print!(" Checksum OK."); + + let bin_path = substitute_bin_path( + &updater.bin_path_in_archive(), + &release.version, + &target, + &updater.bin_name(), + ); + + log_print!("Extracting archive..."); + self_update::Extract::from_source(&archive_path) + .extract_file(tmp_dir.path(), &bin_path) .map_err(map_self_update_err)?; - if status.updated() { - Ok(UpdateOutcome::Updated(status.version().to_string())) + let new_exe = tmp_dir.path().join(&bin_path); + let install_path = updater.bin_install_path(); + + log_print!("Replacing binary file..."); + let current_exe = std::env::current_exe().map_err(|e| { + QuantusError::Generic(format!("Failed to resolve current executable path: {e}")) + })?; + if install_path == current_exe { + self_update::self_replace::self_replace(&new_exe).map_err(|e| { + QuantusError::Generic(format!("Failed to replace running binary: {e}")) + })?; } else { - Ok(UpdateOutcome::AlreadyLatest(status.version().to_string())) + self_update::Move::from_source(&new_exe) + .to_dest(&install_path) + .map_err(map_self_update_err)?; + } + + Ok(()) +} + +fn substitute_bin_path(template: &str, version: &str, target: &str, bin: &str) -> String { + template + .replace("{{ version }}", version) + .replace("{{version}}", version) + .replace("{{ target }}", target) + .replace("{{target}}", target) + .replace("{{ bin }}", bin) + .replace("{{bin}}", bin) +} + +fn download_asset(url: &str, dest: &mut impl Write, show_progress: bool) -> crate::error::Result<()> { + let mut download = self_update::Download::from_url(url); + download + .set_header( + reqwest::header::ACCEPT, + "application/octet-stream" + .parse() + .expect("static ACCEPT header"), + ) + .show_progress(show_progress); + download.download_to(dest).map_err(map_self_update_err) +} + +fn confirm_update() -> crate::error::Result<()> { + print!("Do you want to continue? [Y/n] "); + io::stdout() + .flush() + .map_err(|e| QuantusError::Generic(format!("Failed to flush confirmation prompt: {e}")))?; + let mut response = String::new(); + io::stdin() + .read_line(&mut response) + .map_err(|e| QuantusError::Generic(format!("Failed to read confirmation: {e}")))?; + let response = response.trim().to_lowercase(); + if !response.is_empty() && response != "y" && response != "yes" { + return Err(QuantusError::Generic("Update aborted".to_string())); } + Ok(()) } /// Convert a `self_update` error into a `QuantusError` with a friendly hint for @@ -166,3 +374,47 @@ fn map_self_update_err(err: self_update::errors::Error) -> QuantusError { QuantusError::Generic(format!("Self-update failed: {msg}")) } } + +#[cfg(test)] +mod tests { + use super::{expected_hash_from_sha256sums, verify_sha256}; + use sha2::{Digest, Sha256}; + + #[test] + fn verify_sha256_match_accepts_mismatch_refuses() { + let data = b"quantus-release-archive-fixture"; + let matching = hex::encode(Sha256::digest(data)); + assert!( + verify_sha256(data, &matching).is_ok(), + "matching digest must accept the archive bytes" + ); + assert!( + verify_sha256(data, &matching.to_ascii_uppercase()).is_ok(), + "hex comparison must be case-insensitive" + ); + + let mismatched = "0".repeat(64); + let err = verify_sha256(data, &mismatched).expect_err("mismatch must refuse install"); + let msg = err.to_string(); + assert!( + msg.contains("SHA-256 mismatch") && msg.contains("Refusing to install"), + "expected refusal message, got: {msg}" + ); + } + + #[test] + fn expected_hash_from_sha256sums_parses_asset_line() { + let asset = "quantus-cli-v1.6.0-aarch64-apple-darwin.tar.gz"; + let hash = "a".repeat(64); + let sums = format!("{hash} {asset}\n"); + assert_eq!(expected_hash_from_sha256sums(&sums, asset).unwrap(), hash); + + let other = "b".repeat(64); + let sums_multi = format!( + "{other} other-asset.tar.gz\n{hash} *{asset}\n" + ); + assert_eq!(expected_hash_from_sha256sums(&sums_multi, asset).unwrap(), hash); + + assert!(expected_hash_from_sha256sums(&sums, "missing.tar.gz").is_err()); + } +} From eeaefa74a1055f03acb8c3f87e558291da2119a8 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:55:44 +0800 Subject: [PATCH 17/39] fix(wallet): refuse to persist wallets with embedded AES key material Legacy Argon2 digests must not be rewritten to disk. Encrypt already strips digests; save paths now reject any remaining embedded key material. Co-authored-by: Cursor --- src/wallet/keystore.rs | 231 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 208 insertions(+), 23 deletions(-) diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index ceb625d..979f371 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -210,17 +210,25 @@ impl QuantumKeyPair { } } - pub fn to_account_id_32(&self) -> AccountId32 { + pub fn try_to_account_id_32(&self) -> Result { // Use the DilithiumPublic's into_account method for correct address generation - let resonance_public = - DilithiumPublic::from_slice(&self.public_key).expect("Invalid public key"); - resonance_public.into_account() + let resonance_public = DilithiumPublic::from_slice(&self.public_key) + .map_err(|_| crate::error::WalletError::InvalidPublicKey)?; + Ok(resonance_public.into_account()) } - pub fn to_account_id_ss58check(&self) -> String { + pub fn to_account_id_32(&self) -> AccountId32 { + self.try_to_account_id_32().unwrap_or_else(|_| AccountId32::from([0u8; 32])) + } + + pub fn try_to_account_id_ss58check(&self) -> Result { use crate::cli::address_format::quantus_ss58_format; - let account = self.to_account_id_32(); - account.to_ss58check_with_version(quantus_ss58_format()) + let account = self.try_to_account_id_32()?; + Ok(account.to_ss58check_with_version(quantus_ss58_format())) + } + + pub fn to_account_id_ss58check(&self) -> String { + self.try_to_account_id_ss58check().unwrap_or_default() } /// Convert to subxt Signer for use @@ -297,6 +305,7 @@ impl Keystore { let _guard = keystore_lock() .lock() .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + Self::ensure_no_embedded_key_material(wallet)?; let file_name = wallet_filename(&wallet.name)?; let wallet_file = self.storage_path.join(&file_name); let wallet_json = serde_json::to_string_pretty(wallet)?; @@ -346,6 +355,7 @@ impl Keystore { } fn save_wallet_unlocked(&self, wallet: &EncryptedWallet) -> Result<()> { + Self::ensure_no_embedded_key_material(wallet)?; let file_name = wallet_filename(&wallet.name)?; let wallet_file = self.storage_path.join(&file_name); let wallet_json = serde_json::to_string_pretty(wallet)?; @@ -508,7 +518,7 @@ impl Keystore { Ok(EncryptedWallet { name: data.name.clone(), - address: data.keypair.to_account_id_ss58check(), // Store public address + address: data.keypair.try_to_account_id_ss58check()?, // Store public address encrypted_data, kyber_ciphertext: vec![], // Reserved for future ML-KEM implementation kyber_public_key: vec![], // Reserved for future ML-KEM implementation @@ -526,6 +536,12 @@ impl Keystore { encrypted: &EncryptedWallet, password: &str, ) -> Result { + // Only known wallet encryption formats may be decrypted with these rules. + match encrypted.encryption_version { + 1 | 2 => {}, + _ => return Err(WalletError::Decryption.into()), + } + // 1. Re-derive the AES key from the password and the stored salt + params. // The key itself is never stored in the wallet file. let aes_key = Self::derive_aes_key(encrypted, password)?; @@ -533,7 +549,9 @@ impl Keystore { // 2. Decrypt the data. An AES-GCM authentication failure means the password // was wrong (or the file was tampered with) - this is the password check. - let nonce = Nonce::from(<[u8; 12]>::try_from(&encrypted.aes_nonce[..]).unwrap()); + let nonce_bytes = <[u8; 12]>::try_from(&encrypted.aes_nonce[..]) + .map_err(|_| WalletError::Decryption)?; + let nonce = Nonce::from(nonce_bytes); let decrypted_data = cipher .decrypt(&nonce, encrypted.encrypted_data.as_ref()) .map_err(|_| WalletError::InvalidPassword)?; @@ -544,7 +562,7 @@ impl Keystore { // 4. The plaintext envelope address is not AEAD-authenticated, so it must // match the address derived from the decrypted key material before the // wallet file is accepted as intact. - let derived_address = wallet_data.keypair.to_account_id_ss58check(); + let derived_address = wallet_data.keypair.try_to_account_id_ss58check()?; if encrypted.address != derived_address { return Err(WalletError::Integrity( "stored address does not match decrypted keypair".to_string(), @@ -559,30 +577,35 @@ impl Keystore { /// and parameters. Works for both the current format (params only) and legacy /// files (params + digest); the embedded digest of legacy files is ignored. fn derive_aes_key(encrypted: &EncryptedWallet, password: &str) -> Result> { - // The cost parameters come from the wallet file, so cap them: a crafted - // file could otherwise request an enormous m_cost and force a huge - // allocation. Limits are far above anything we ever write (defaults are - // m=19456 KiB, t=2, p=1). - const MAX_M_COST: u32 = 1 << 20; // 1 GiB (in KiB) - const MAX_T_COST: u32 = 64; - const MAX_P_COST: u32 = 16; + // The cost parameters come from the wallet file. Treat them as an + // untrusted wallet-format profile, not as caller-selectable work factors: + // generated wallets use Argon2id v=19 with the library default costs + // (currently m=19456 KiB, t=2, p=1), and accepting higher values lets a + // crafted file force expensive memory/CPU work before password validation. + const SUPPORTED_M_COST: u32 = Params::DEFAULT_M_COST; + const SUPPORTED_T_COST: u32 = Params::DEFAULT_T_COST; + const SUPPORTED_P_COST: u32 = Params::DEFAULT_P_COST; let parsed = PasswordHash::new(&encrypted.argon2_params).map_err(|_| WalletError::Decryption)?; - let algorithm = - Algorithm::new(parsed.algorithm.as_str()).map_err(|_| WalletError::Decryption)?; + if parsed.algorithm.as_str() != "argon2id" { + return Err(WalletError::Decryption.into()); + } let version = Version::try_from(parsed.version.unwrap_or(Version::V0x13 as u32)) .map_err(|_| WalletError::Decryption)?; + if version != Version::V0x13 { + return Err(WalletError::Decryption.into()); + } let m_cost = parsed.params.get_decimal("m").unwrap_or(Params::DEFAULT_M_COST); let t_cost = parsed.params.get_decimal("t").unwrap_or(Params::DEFAULT_T_COST); let p_cost = parsed.params.get_decimal("p").unwrap_or(Params::DEFAULT_P_COST); - if m_cost > MAX_M_COST || t_cost > MAX_T_COST || p_cost > MAX_P_COST { + if m_cost != SUPPORTED_M_COST || t_cost != SUPPORTED_T_COST || p_cost != SUPPORTED_P_COST { return Err(WalletError::Decryption.into()); } let params = Params::new(m_cost, t_cost, p_cost, None).map_err(|_| WalletError::Decryption)?; - let argon2 = Argon2::new(algorithm, version, params); + let argon2 = Argon2::new(Algorithm::Argon2id, version, params); let mut key = [0u8; 32]; argon2 @@ -601,6 +624,32 @@ impl Keystore { .map(|h| h.hash.is_some()) .unwrap_or(false) } + + /// Refuse to persist wallets that still embed the Argon2 digest (AES key material). + fn ensure_no_embedded_key_material(wallet: &EncryptedWallet) -> Result<()> { + if Self::has_embedded_key_material(wallet) { + return Err(QuantusError::Generic( + "Refusing to persist wallet that embeds Argon2 digest key material; unlock to migrate first" + .to_string(), + )); + } + Ok(()) + } + + /// Test-only helper to plant a legacy on-disk wallet that embeds key material. + #[cfg(test)] + pub(crate) fn save_wallet_unchecked_for_tests(&self, wallet: &EncryptedWallet) -> Result<()> { + let _guard = keystore_lock() + .lock() + .map_err(|_| QuantusError::Generic("keystore lock poisoned".to_string()))?; + let file_name = wallet_filename(&wallet.name)?; + let wallet_file = self.storage_path.join(&file_name); + let wallet_json = serde_json::to_string_pretty(wallet)?; + let tmp_file = + write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; + fs::rename(&tmp_file, &wallet_file)?; + Ok(()) + } } #[cfg(test)] @@ -1157,6 +1206,99 @@ mod tests { ); } + fn craft_wallet_with_argon2_costs( + data: &WalletData, + password: &str, + m_cost: u32, + t_cost: u32, + p_cost: u32, + ) -> EncryptedWallet { + let salt = vec![0x42; 16]; + let nonce_bytes = [0x24; 12]; + let params = Params::new(m_cost, t_cost, p_cost, None).expect("valid Argon2 params"); + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut derived = [0u8; 32]; + argon2 + .hash_password_into(password.as_bytes(), &salt, &mut derived) + .expect("derive key"); + let cipher = Aes256Gcm::new(Key::::from_slice(&derived)); + let plaintext = serde_json::to_vec(data).expect("serialize"); + let encrypted_data = cipher + .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref()) + .expect("encrypt"); + EncryptedWallet { + name: data.name.clone(), + address: data.keypair.to_account_id_ss58check(), + encrypted_data, + kyber_ciphertext: vec![], + kyber_public_key: vec![], + argon2_salt: salt, + argon2_params: format!("$argon2id$v=19$m={m_cost},t={t_cost},p={p_cost}"), + aes_nonce: nonce_bytes.to_vec(), + encryption_version: 2, + created_at: chrono::Utc::now(), + } + } + + #[test] + fn above_profile_argon2_params_are_rejected_on_decrypt() { + // #160715: costs above the generated-wallet profile must be rejected before + // Argon2 runs (defaults are m=DEFAULT_M_COST, t=DEFAULT_T_COST, p=DEFAULT_P_COST). + let temp_dir = TempDir::new().expect("temp dir"); + let keystore = Keystore::new(temp_dir.path()); + let data = make_test_wallet_data("high-cost", 11); + let crafted = craft_wallet_with_argon2_costs(&data, "", 32_768, 3, 1); + let err = keystore + .decrypt_wallet_data(&crafted, "") + .expect_err("above-profile Argon2 metadata must be rejected"); + assert!( + matches!(err, crate::error::QuantusError::Wallet(WalletError::Decryption)), + "unexpected error: {err}" + ); + } + + #[test] + fn malformed_public_key_returns_error_instead_of_panicking() { + // #160640: address derivation must not unwind on garbage public keys. + let keypair = QuantumKeyPair { + public_key: vec![0x41], + private_key: vec![0x42; 32], + }; + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _ = keypair.to_account_id_ss58check(); + })); + assert!( + panicked.is_ok(), + "malformed decrypted public keys must not unwind CLI/library callers" + ); + assert!( + matches!( + keypair.try_to_account_id_ss58check(), + Err(crate::error::QuantusError::Wallet(WalletError::InvalidPublicKey)) + ), + "fallible conversion must report InvalidPublicKey" + ); + } + + #[test] + fn save_wallet_refuses_embedded_key_material() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let keystore = Keystore::new(temp_dir.path()); + let data = make_test_wallet_data("legacy-refuse", 10); + let legacy = encrypt_legacy(&data, "pw"); + assert!(Keystore::has_embedded_key_material(&legacy)); + + let err = keystore.save_wallet(&legacy).expect_err("must refuse digest-bearing wallets"); + assert!( + err.to_string().contains("embeds Argon2 digest"), + "unexpected error: {err}" + ); + assert!( + !temp_dir.path().join("legacy-refuse.json").exists(), + "digest-bearing wallet must not be written" + ); + } + #[test] fn test_legacy_wallet_decrypt_and_migration() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -1166,7 +1308,9 @@ mod tests { // Save a wallet in the legacy format (digest embedded in argon2_params) let legacy = encrypt_legacy(&data, "pw"); assert!(Keystore::has_embedded_key_material(&legacy)); - keystore.save_wallet(&legacy).expect("Save should succeed"); + keystore + .save_wallet_unchecked_for_tests(&legacy) + .expect("Save should succeed"); // Legacy files must still decrypt with the correct password... let decrypted = keystore @@ -1220,7 +1364,9 @@ mod tests { let legacy = encrypt_legacy(&data, "pw"); assert!(Keystore::has_embedded_key_material(&legacy)); - keystore.save_wallet(&legacy).expect("Save should succeed"); + keystore + .save_wallet_unchecked_for_tests(&legacy) + .expect("Save should succeed"); // Force migration save to fail (cannot create .json.tmp in read-only dir). let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions(); @@ -1323,6 +1469,45 @@ mod tests { assert_ne!(loaded.address, second.address); } + /// #159340: unsupported encryption_version must be rejected before decrypt. + #[test] + fn decrypt_rejects_unsupported_encryption_version() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let keystore = Keystore::new(temp_dir.path()); + let data = make_test_wallet_data("bad-version", 25); + let mut encrypted = + keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + assert_eq!(encrypted.encryption_version, 2); + encrypted.encryption_version = u32::MAX; + + let result = keystore.decrypt_wallet_data(&encrypted, "pw"); + assert!( + matches!(result, Err(crate::error::QuantusError::Wallet(WalletError::Decryption))), + "unsupported encryption_version must be rejected, got: {result:?}" + ); + } + + /// #159340: malformed AES-GCM nonce length must return Decryption, not panic. + #[test] + fn decrypt_rejects_malformed_aes_nonce_length() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let keystore = Keystore::new(temp_dir.path()); + let data = make_test_wallet_data("bad-nonce", 26); + let mut encrypted = + keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + assert_eq!(encrypted.aes_nonce.len(), 12); + encrypted.aes_nonce.truncate(1); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + keystore.decrypt_wallet_data(&encrypted, "pw") + })); + match result { + Ok(Err(crate::error::QuantusError::Wallet(WalletError::Decryption))) => {}, + Ok(other) => panic!("expected Decryption error, got: {other:?}"), + Err(_) => panic!("malformed AES-GCM nonce length must not panic"), + } + } + /// #160598 / #160737: path separators and traversal names are rejected. #[test] fn rejects_wallet_names_with_path_separators() { From 95c1fc9b9094b3213df2f1096e300183d2c91fd3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:31 +0800 Subject: [PATCH 18/39] fix(wallet): return errors for malformed public keys Address derivation previously panicked on bad Dilithium public key bytes. Propagate InvalidPublicKey through wallet creation and views. Co-authored-by: Cursor --- src/error.rs | 3 +++ src/wallet/mod.rs | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/error.rs b/src/error.rs index 5ec217a..9d988e5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -65,6 +65,9 @@ pub enum WalletError { #[error("Key generation failed")] KeyGeneration, + #[error("Invalid public key")] + InvalidPublicKey, + #[error("Encryption failed: {0}")] Encryption(String), diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index d86de7c..745034b 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -98,7 +98,7 @@ impl WalletManager { metadata.insert("version".to_string(), "1.0.0".to_string()); metadata.insert("algorithm".to_string(), "ML-DSA-87".to_string()); metadata.insert("derivation_path".to_string(), derivation_path.to_string()); - let address = quantum_keypair.to_account_id_ss58check(); + let address = quantum_keypair.try_to_account_id_ss58check()?; let wallet_data = WalletData { name: name.to_string(), @@ -147,7 +147,7 @@ impl WalletManager { metadata.insert("test_wallet".to_string(), "true".to_string()); // Generate address from public key - let address = quantum_keypair.to_account_id_ss58check(); + let address = quantum_keypair.try_to_account_id_ss58check()?; let wallet_data = WalletData { name: name.to_string(), @@ -199,7 +199,7 @@ impl WalletManager { let wallet_info = match keystore.decrypt_wallet_data(&encrypted_wallet, "") { Ok(wallet_data) => WalletInfo { name: wallet_data.name, - address: wallet_data.keypair.to_account_id_ss58check(), + address: wallet_data.keypair.try_to_account_id_ss58check()?, created_at: encrypted_wallet.created_at, key_type: "Dilithium ML-DSA-87".to_string(), derivation_path: "[Encrypted]".to_string(), @@ -263,7 +263,7 @@ impl WalletManager { metadata.insert("no_derivation".to_string(), "true".to_string()); // Generate address from public key - let address = quantum_keypair.to_account_id_ss58check(); + let address = quantum_keypair.try_to_account_id_ss58check()?; let wallet_data = WalletData { name: name.to_string(), @@ -315,7 +315,7 @@ impl WalletManager { metadata.insert("no_derivation".to_string(), "true".to_string()); // Generate address from public key - let address = quantum_keypair.to_account_id_ss58check(); + let address = quantum_keypair.try_to_account_id_ss58check()?; let wallet_data = WalletData { name: name.to_string(), @@ -365,7 +365,7 @@ impl WalletManager { metadata.insert("derivation_path".to_string(), derivation_path.to_string()); // Generate address from public key - let address = quantum_keypair.to_account_id_ss58check(); + let address = quantum_keypair.try_to_account_id_ss58check()?; let wallet_data = WalletData { name: name.to_string(), @@ -430,7 +430,7 @@ impl WalletManager { metadata.insert("from_seed".to_string(), "true".to_string()); // Generate address from public key - let address = quantum_keypair.to_account_id_ss58check(); + let address = quantum_keypair.try_to_account_id_ss58check()?; let wallet_data = WalletData { name: name.to_string(), @@ -463,7 +463,7 @@ impl WalletManager { // Decrypt and show full details match keystore.decrypt_wallet_data(&encrypted_wallet, pwd) { Ok(wallet_data) => { - let address = wallet_data.keypair.to_account_id_ss58check(); + let address = wallet_data.keypair.try_to_account_id_ss58check()?; Ok(Some(WalletInfo { name: wallet_data.name, address, @@ -487,7 +487,7 @@ impl WalletManager { } else { match keystore.decrypt_wallet_data(&encrypted_wallet, "") { Ok(wallet_data) => { - let address = wallet_data.keypair.to_account_id_ss58check(); + let address = wallet_data.keypair.try_to_account_id_ss58check()?; Ok(Some(WalletInfo { name: wallet_data.name, address, @@ -555,7 +555,7 @@ impl WalletManager { // Wallet-name resolution must not trust the plaintext envelope address. // Only empty-password wallets can be authenticated without prompting. match keystore.decrypt_wallet_data(&encrypted_wallet, "") { - Ok(wallet_data) => Ok(Some(wallet_data.keypair.to_account_id_ss58check())), + Ok(wallet_data) => Ok(Some(wallet_data.keypair.try_to_account_id_ss58check()?)), Err(crate::error::QuantusError::Wallet( WalletError::InvalidPassword | WalletError::Integrity(_), )) => Ok(None), From 986c2cd8509285f1fcd7e80fd8cf23830c5a27a7 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:31 +0800 Subject: [PATCH 19/39] fix(client): redact WebSocket URL credentials in diagnostics Node URLs with userinfo were logged and embedded in errors. Sanitize diagnostics so passwords are not disclosed. Co-authored-by: Cursor --- src/chain/client.rs | 96 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 89 insertions(+), 7 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index 463e5b7..fcf7d3f 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -52,14 +52,40 @@ pub struct QuantusClient { } impl QuantusClient { + /// Return a URL suitable for logs and user-facing diagnostics by removing credentials. + fn sanitize_url_for_diagnostics(url: &str) -> String { + let Some(scheme_end) = url.find("://") else { + return url.to_string(); + }; + + let authority_start = scheme_end + 3; + let authority_end = url[authority_start..] + .find(|c| matches!(c, '/' | '?' | '#')) + .map(|offset| authority_start + offset) + .unwrap_or(url.len()); + let authority = &url[authority_start..authority_end]; + + if let Some(userinfo_end) = authority.rfind('@') { + format!( + "{}{}{}", + &url[..authority_start], + &authority[userinfo_end + 1..], + &url[authority_end..] + ) + } else { + url.to_string() + } + } + /// Create a new QuantusClient by connecting to the specified node URL pub async fn new(node_url: &str) -> crate::error::Result { - log_verbose!("🔗 Connecting to Quantus node: {}", node_url); + let display_node_url = Self::sanitize_url_for_diagnostics(node_url); + log_verbose!("🔗 Connecting to Quantus node: {}", display_node_url); // Validate URL format and provide helpful error messages if !node_url.starts_with("ws://") && !node_url.starts_with("wss://") { return Err(QuantusError::NetworkError(format!( - "Invalid WebSocket URL: '{node_url}'. URL must start with 'ws://' (unsecured) or 'wss://' (secured)" + "Invalid WebSocket URL: '{display_node_url}'. URL must start with 'ws://' (unsecured) or 'wss://' (secured)" ))); } @@ -73,16 +99,16 @@ impl QuantusClient { .await .map_err(|e| { // Provide more helpful error messages for common issues - let error_str = format!("{e:?}"); + let error_str = format!("{e:?}").replace(node_url, &display_node_url); let error_msg = if error_str.contains("TimedOut") || error_str.contains("timed out") { if node_url.starts_with("ws://") { format!( "Connection timed out. Try using 'wss://{}' instead of '{}'", - node_url.strip_prefix("ws://").unwrap_or(node_url), - node_url + display_node_url.strip_prefix("ws://").unwrap_or(&display_node_url), + display_node_url ) } else { - format!("Connection timed out. Please check if the node is running and accessible at: {node_url}") + format!("Connection timed out. Please check if the node is running and accessible at: {display_node_url}") } } else if error_str.contains("HTTP") { format!("HTTP error: {error_str}. This might indicate the node doesn't support WebSocket connections") @@ -113,7 +139,7 @@ impl QuantusClient { crate::config::validate_runtime_version_value(&runtime_version).map_err(|e| { match e { QuantusError::NetworkError(msg) => QuantusError::NetworkError(format!( - "{msg} (from {node_url})" + "{msg} (from {display_node_url})" )), other => other, } @@ -293,3 +319,59 @@ impl subxt::tx::Signer for qp_dilithium_crypto::types::DilithiumPai qp_dilithium_crypto::types::DilithiumSignatureScheme::Dilithium(signature_with_public) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn quantus_client_new_redacts_userinfo_in_invalid_url_error() { + let secret = format!( + "rpc-token-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock must be after unix epoch") + .as_nanos() + ); + let attacker_controlled_url = + format!("https://api-user:{secret}@rpc.example.invalid/ws"); + + let error = match QuantusClient::new(&attacker_controlled_url).await { + Ok(_) => panic!("non-WebSocket scheme must fail"), + Err(error) => error, + }; + let diagnostic = error.to_string(); + + assert!( + !diagnostic.contains(&secret), + "NetworkError must not expose URL userinfo; diagnostic was: {diagnostic}" + ); + assert!( + !diagnostic.contains(&format!("api-user:{secret}")), + "NetworkError must not expose raw credentialed URL; diagnostic was: {diagnostic}" + ); + assert!( + diagnostic.contains("rpc.example.invalid"), + "sanitized host should remain visible; diagnostic was: {diagnostic}" + ); + } + + #[test] + fn sanitize_url_for_diagnostics_strips_userinfo() { + assert_eq!( + QuantusClient::sanitize_url_for_diagnostics( + "wss://user:pass@rpc.example.com/path?q=1" + ), + "wss://rpc.example.com/path?q=1" + ); + assert_eq!( + QuantusClient::sanitize_url_for_diagnostics("ws://token@localhost:9944"), + "ws://localhost:9944" + ); + assert_eq!( + QuantusClient::sanitize_url_for_diagnostics("wss://rpc.example.com"), + "wss://rpc.example.com" + ); + } +} From a710bf643617428fb44b4b9ea6723eeffde1bc17 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 20/39] fix(wormhole): remove --secret argv and verify extrinsic failures Secrets on --secret were visible in process argv; require --secret-file. Also treat ExtrinsicFailed as dominant so failed txs are not verified. Co-authored-by: Cursor --- src/cli/wormhole.rs | 244 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 193 insertions(+), 51 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 30d1444..59d6c7a 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -248,6 +248,15 @@ pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> { .map_err(|_| "Failed to convert secret to 32-byte array".to_string()) } +/// Read a hex-encoded secret from a file and validate that it is exactly 32 bytes. +fn read_secret_hex_file(path: &str) -> Result { + let secret_hex = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read secret file: {}", e))?; + let secret_hex = secret_hex.trim().to_string(); + parse_secret_hex(&secret_hex)?; + Ok(secret_hex) +} + /// Parse an exit account from either hex or SS58 format pub fn parse_exit_account(exit_account_str: &str) -> Result<[u8; 32], String> { if let Some(hex_str) = exit_account_str.strip_prefix("0x") { @@ -502,6 +511,32 @@ pub struct VerificationResult { pub error_message: Option, } +/// Apply ProofVerified evidence with failure-dominant semantics. +fn apply_proof_verified_to_result(result: &mut VerificationResult, exit_amount: u128) { + result.exit_amount = Some(exit_amount); + if result.error_message.is_none() { + result.success = true; + } +} + +/// Apply ExtrinsicFailed evidence; dispatch failure always clears success. +fn apply_extrinsic_failed_to_result(result: &mut VerificationResult, error_msg: String) { + result.success = false; + result.error_message = Some(error_msg); +} + +/// Finalize SDK event collection: any ExtrinsicFailed dominates ProofVerified. +fn finalize_wormhole_event_collection( + found_proof_verified: bool, + dispatch_error_message: Option, + transfer_events: Vec, +) -> crate::error::Result<(bool, Vec)> { + if let Some(error_msg) = dispatch_error_message { + return Err(crate::error::QuantusError::Generic(error_msg)); + } + Ok((found_proof_verified, transfer_events)) +} + /// Check for proof verification events in a transaction /// Returns whether ProofVerified event was found and the exit amount async fn check_proof_verification_events( @@ -574,17 +609,19 @@ async fn check_proof_verification_events( if let Ok(Some(proof_verified)) = event.as_event::() { - verification_result.success = true; - verification_result.exit_amount = Some(proof_verified.exit_amount); + apply_proof_verified_to_result( + &mut verification_result, + proof_verified.exit_amount, + ); } - // Check for ExtrinsicFailed event + // Check for ExtrinsicFailed event. Dispatch failure dominates any + // ProofVerified event regardless of event ordering. if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) = event.as_event::() { let error_msg = format_dispatch_error(&dispatch_error, &metadata); - verification_result.success = false; - verification_result.error_message = Some(error_msg); + apply_extrinsic_failed_to_result(&mut verification_result, error_msg); } } } @@ -635,17 +672,17 @@ fn format_dispatch_error( #[derive(Subcommand, Debug)] pub enum WormholeCommands { - /// Derive the unspendable wormhole address from a secret + /// Derive the unspendable wormhole address from a secret file Address { - /// Secret (32-byte hex string) - used to derive the unspendable account + /// File containing the secret (32-byte hex string) used to derive the unspendable account #[arg(long)] - secret: String, + secret_file: String, }, /// Generate a wormhole proof from an existing transfer Prove { - /// Secret (32-byte hex string) used for the transfer + /// File containing the secret (32-byte hex string) used for the transfer #[arg(long)] - secret: String, + secret_file: String, /// Funding amount that was transferred #[arg(long)] @@ -834,19 +871,19 @@ pub enum WormholeCommands { /// It mirrors the withdrawal flow used by the miner app. CollectRewards { /// Wallet name (used for HD derivation of wormhole secret and exit address) - /// Either --wallet, --mnemonic, or --secret must be provided. - #[arg(short, long, required_unless_present_any = ["mnemonic", "secret"], conflicts_with_all = ["mnemonic", "secret"])] + /// Either --wallet, --mnemonic, or --secret-file must be provided. + #[arg(short, long, required_unless_present_any = ["mnemonic", "secret_file"], conflicts_with_all = ["mnemonic", "secret_file"])] wallet: Option, /// Mnemonic phrase for HD derivation (alternative to --wallet) /// Use this to derive wormhole secrets without a stored wallet. - #[arg(short = 'm', long, required_unless_present_any = ["wallet", "secret"], conflicts_with_all = ["wallet", "secret"])] + #[arg(short = 'm', long, required_unless_present_any = ["wallet", "secret_file"], conflicts_with_all = ["wallet", "secret_file"])] mnemonic: Option, - /// Direct wormhole secret (32-byte hex string, alternative to --wallet or --mnemonic) + /// File containing the direct wormhole secret (32-byte hex string, alternative to --wallet or --mnemonic) /// Use this with a secret generated by `quantus-node key quantus --scheme wormhole` #[arg(long, required_unless_present_any = ["wallet", "mnemonic"], conflicts_with_all = ["wallet", "mnemonic"])] - secret: Option, + secret_file: Option, /// Password for the wallet (only used with --wallet) #[arg(short, long)] @@ -860,7 +897,7 @@ pub enum WormholeCommands { #[arg(short, long)] amount: Option, - /// Destination address for withdrawn funds (required when using --mnemonic or --secret) + /// Destination address for withdrawn funds (required when using --mnemonic or --secret-file) #[arg(long)] destination: Option, @@ -868,7 +905,7 @@ pub enum WormholeCommands { #[arg(long, default_value = "https://sub2.quantus.com/v1/graphql")] subsquid_url: String, - /// Wormhole address index for HD derivation (default: 0, ignored when using --secret) + /// Wormhole address index for HD derivation (default: 0, ignored when using --secret-file) #[arg(long, default_value = "0")] wormhole_index: usize, @@ -885,14 +922,14 @@ pub enum WormholeCommands { /// Given a secret (or wallet) and transfer count(s), computes the nullifier(s) and checks /// if they exist in Subsquid (meaning the corresponding transfer has been withdrawn). CheckNullifier { - /// Secret (32-byte hex string) - the wormhole secret. - /// Either --secret or --wallet must be provided. + /// File containing the secret (32-byte hex string) for the wormhole secret. + /// Either --secret-file or --wallet must be provided. #[arg(long, required_unless_present = "wallet")] - secret: Option, + secret_file: Option, /// Wallet name (used for HD derivation of wormhole secret). - /// Either --secret or --wallet must be provided. - #[arg(short, long, required_unless_present = "secret")] + /// Either --secret-file or --wallet must be provided. + #[arg(short, long, required_unless_present = "secret_file")] wallet: Option, /// Password for the wallet (only used with --wallet) @@ -922,9 +959,9 @@ pub async fn handle_wormhole_command( node_url: &str, ) -> crate::error::Result<()> { match command { - WormholeCommands::Address { secret } => show_wormhole_address(secret), + WormholeCommands::Address { secret_file } => show_wormhole_address(secret_file), WormholeCommands::Prove { - secret, + secret_file, amount, exit_account, block, @@ -956,6 +993,9 @@ pub async fn handle_wormhole_command( exit_account_2: [0u8; 32], }; + let secret = + read_secret_hex_file(&secret_file).map_err(crate::error::QuantusError::Generic)?; + let prove_start = std::time::Instant::now(); generate_proof( &secret, @@ -1050,7 +1090,7 @@ pub async fn handle_wormhole_command( WormholeCommands::CollectRewards { wallet, mnemonic, - secret, + secret_file, password, password_file, amount, @@ -1063,7 +1103,7 @@ pub async fn handle_wormhole_command( run_collect_rewards( wallet, mnemonic, - secret, + secret_file, password, password_file, amount, @@ -1076,7 +1116,7 @@ pub async fn handle_wormhole_command( ) .await, WormholeCommands::CheckNullifier { - secret, + secret_file, wallet, password, password_file, @@ -1085,7 +1125,7 @@ pub async fn handle_wormhole_command( subsquid_url, } => run_check_nullifier( - secret, + secret_file, wallet, password, password_file, @@ -1104,9 +1144,11 @@ pub async fn handle_wormhole_command( /// Derive and display the unspendable wormhole address from a secret. /// Users can then send funds to this address using `quantus send`. -fn show_wormhole_address(secret_hex: String) -> crate::error::Result<()> { +fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { use colored::Colorize; + let secret_hex = + read_secret_hex_file(&secret_file).map_err(crate::error::QuantusError::Generic)?; let secret_array = parse_secret_hex(&secret_hex).map_err(crate::error::QuantusError::Generic)?; let secret: BytesDigest = secret_array.try_into().map_err(|e| { @@ -1585,6 +1627,7 @@ async fn collect_wormhole_events_for_extrinsic( let mut transfer_events = Vec::new(); let mut found_proof_verified = false; + let mut dispatch_error_message = None; log_verbose!(" Events for our extrinsic (idx={}):", our_ext_idx); @@ -1604,6 +1647,7 @@ async fn collect_wormhole_events_for_extrinsic( let metadata = quantus_client.client().metadata(); let error_msg = format_dispatch_error(&dispatch_error, &metadata); log_print!(" DispatchError: {}", error_msg); + dispatch_error_message = Some(error_msg); } if let Ok(Some(_)) = event.as_event::() { @@ -1618,7 +1662,11 @@ async fn collect_wormhole_events_for_extrinsic( } } - Ok((found_proof_verified, transfer_events)) + finalize_wormhole_event_collection( + found_proof_verified, + dispatch_error_message, + transfer_events, + ) } async fn verify_private_batch(proof_file: String, node_url: &str) -> crate::error::Result<()> { @@ -1995,7 +2043,7 @@ fn load_multiround_wallet( // Require a persisted mnemonic for deterministic wormhole HD derivation. let mnemonic = wallet_data.mnemonic.ok_or_else(|| { crate::error::QuantusError::Generic( - "Wallet does not contain a mnemonic. Use a wallet created from a mnemonic, or supply --mnemonic/--secret where supported.".to_string(), + "Wallet does not contain a mnemonic. Use a wallet created from a mnemonic, or supply --mnemonic/--secret-file where supported.".to_string(), ) })?; log_verbose!("Using wallet mnemonic for HD derivation"); @@ -3696,7 +3744,7 @@ async fn run_dissolve( async fn run_collect_rewards( wallet_name: Option, mnemonic_arg: Option, - secret_arg: Option, + secret_file_arg: Option, password: Option, password_file: Option, amount: Option, @@ -3718,7 +3766,7 @@ async fn run_collect_rewards( log_print!("=================================================="); log_print!(""); - // Get credential and wallet address from wallet, mnemonic, or secret + // Get credential and wallet address from wallet, mnemonic, or secret file let (credential, wallet_address) = if let Some(wallet_name) = wallet_name { // Load from stored wallet let wallet = load_multiround_wallet(&wallet_name, password, password_file)?; @@ -3729,23 +3777,25 @@ async fn run_collect_rewards( } else if let Some(mnemonic) = mnemonic_arg { // Use provided mnemonic directly (WormholeCredential::Mnemonic { phrase: mnemonic, wormhole_index }, None) - } else if let Some(secret) = secret_arg { - // Use provided secret directly (no HD derivation) + } else if let Some(secret_file) = secret_file_arg { + // Use provided secret file directly (no HD derivation) + let secret = + read_secret_hex_file(&secret_file).map_err(crate::error::QuantusError::Generic)?; (WormholeCredential::Secret { hex: secret }, None) } else { return Err(crate::error::QuantusError::Generic( - "Either --wallet, --mnemonic, or --secret must be provided".to_string(), + "Either --wallet, --mnemonic, or --secret-file must be provided".to_string(), )); }; - // Destination address - required when using mnemonic or secret directly + // Destination address - required when using mnemonic or secret file directly let destination_address = if let Some(dest) = &destination { dest.clone() } else if let Some(addr) = wallet_address.as_ref() { addr.clone() } else { return Err(crate::error::QuantusError::Generic( - "--destination is required when using --mnemonic or --secret".to_string(), + "--destination is required when using --mnemonic or --secret-file".to_string(), )); }; @@ -3931,7 +3981,7 @@ fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate: /// Given a secret (or wallet) and transfer count(s), computes the nullifier(s) and checks /// if they exist in the indexer (meaning the transfer was already withdrawn). async fn run_check_nullifier( - secret_hex: Option, + secret_file: Option, wallet_name: Option, password: Option, password_file: Option, @@ -3942,8 +3992,9 @@ async fn run_check_nullifier( use crate::subsquid::{compute_address_hash, SubsquidClient}; use colored::Colorize; - // Get secret either directly or from wallet - let secret = if let Some(hex) = secret_hex { + // Get secret either directly from a file or from wallet + let secret = if let Some(path) = secret_file { + let hex = read_secret_hex_file(&path).map_err(crate::error::QuantusError::Generic)?; parse_secret_hex(&hex).map_err(crate::error::QuantusError::Generic)? } else if let Some(wallet) = wallet_name { // Load wallet and derive wormhole secret @@ -3953,7 +4004,7 @@ async fn run_check_nullifier( let mnemonic = wallet_data.mnemonic.ok_or_else(|| { crate::error::QuantusError::Generic( - "Wallet does not contain a mnemonic. Use --secret instead.".to_string(), + "Wallet does not contain a mnemonic. Use --secret-file instead.".to_string(), ) })?; @@ -3968,7 +4019,7 @@ async fn run_check_nullifier( secret } else { return Err(crate::error::QuantusError::Generic( - "Either --secret or --wallet must be provided".to_string(), + "Either --secret-file or --wallet must be provided".to_string(), )); }; @@ -4597,7 +4648,7 @@ mod tests { let err = try_parse_collect_rewards(&[]).unwrap_err(); let s = err.to_string(); assert!( - s.contains("--wallet") || s.contains("--mnemonic") || s.contains("--secret"), + s.contains("--wallet") || s.contains("--mnemonic") || s.contains("--secret-file"), "expected missing-credential error, got: {s}" ); } @@ -4606,19 +4657,15 @@ mod tests { fn collect_rewards_accepts_each_credential_alone() { assert!(try_parse_collect_rewards(&["--wallet", "w"]).is_ok()); assert!(try_parse_collect_rewards(&["--mnemonic", "word ".repeat(24).trim()]).is_ok()); - assert!(try_parse_collect_rewards(&[ - "--secret", - "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", - ]) - .is_ok()); + assert!(try_parse_collect_rewards(&["--secret-file", "secret.hex"]).is_ok()); } #[test] fn collect_rewards_credentials_mutually_exclusive() { let pairs: &[(&str, &str, &str, &str)] = &[ ("--wallet", "w", "--mnemonic", "m"), - ("--wallet", "w", "--secret", "s"), - ("--mnemonic", "m", "--secret", "s"), + ("--wallet", "w", "--secret-file", "s"), + ("--mnemonic", "m", "--secret-file", "s"), ]; for (a, av, b, bv) in pairs { let err = try_parse_collect_rewards(&[a, av, b, bv]).unwrap_err().to_string(); @@ -4629,10 +4676,105 @@ mod tests { } } + /// #160103: wormhole secrets must not be accepted on argv (use --secret-file). + #[test] + fn wormhole_rejects_secret_cli_argument() { + use clap::Parser; + + #[derive(Parser, Debug)] + #[command(name = "quantus")] + struct TestCli { + #[command(subcommand)] + command: crate::cli::Commands, + } + + let secret = "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"; + for args in [ + vec!["quantus", "wormhole", "address", "--secret", secret], + vec![ + "quantus", + "wormhole", + "prove", + "--secret", + secret, + "--amount", + "1", + "--exit-account", + "0x1111111111111111111111111111111111111111111111111111111111111111", + "--block", + "0x2222222222222222222222222222222222222222222222222222222222222222", + "--transfer-count", + "0", + "--leaf-index", + "0", + "--funding-account", + "0x3333333333333333333333333333333333333333333333333333333333333333", + ], + vec!["quantus", "wormhole", "collect-rewards", "--secret", secret], + vec![ + "quantus", + "wormhole", + "check-nullifier", + "--secret", + secret, + "--transfer-counts", + "0", + ], + ] { + let result = TestCli::try_parse_from(args.clone()); + assert!( + result.is_err(), + "wormhole must not accept --secret on argv; args={args:?}" + ); + } + } + fn acct(seed: u8) -> SubxtAccountId { SubxtAccountId([seed; 32]) } + #[test] + fn proof_verified_after_extrinsic_failed_stays_unsuccessful() { + // Vulnerable order-dependent parser set success=true when ProofVerified + // arrived after ExtrinsicFailed. + let mut result = + VerificationResult { success: false, exit_amount: None, error_message: None }; + apply_extrinsic_failed_to_result(&mut result, "Wormhole::InvalidProof".to_string()); + apply_proof_verified_to_result(&mut result, 42); + assert!(!result.success, "ExtrinsicFailed must dominate later ProofVerified"); + assert_eq!(result.exit_amount, Some(42)); + assert_eq!(result.error_message.as_deref(), Some("Wormhole::InvalidProof")); + } + + #[test] + fn extrinsic_failed_after_proof_verified_clears_success() { + let mut result = + VerificationResult { success: false, exit_amount: None, error_message: None }; + apply_proof_verified_to_result(&mut result, 99); + assert!(result.success); + apply_extrinsic_failed_to_result(&mut result, "dispatch failed".to_string()); + assert!(!result.success, "later ExtrinsicFailed must clear success"); + assert!(result.error_message.is_some()); + } + + #[test] + fn sdk_event_collection_errors_when_extrinsic_failed_even_if_proof_verified() { + let transfers = vec![wormhole::events::NativeTransferred { + from: acct(1), + to: acct(2), + amount: 10, + transfer_count: 1, + leaf_index: 1, + }]; + let err = finalize_wormhole_event_collection( + true, + Some("Wormhole::InvalidProof".to_string()), + transfers, + ) + .expect_err("SDK helpers must not treat failed extrinsics as verified"); + assert!(err.to_string().contains("InvalidProof")); + } + #[test] fn parse_expected_transfer_events_binds_by_from_and_amount_not_destination_alone() { let shared_to = acct(0x42); From 0bec0ea9c77669f37d149da0a4483f5ea410db4f Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 21/39] fix(wallet): write exported mnemonics to a protected file Printing mnemonics to stdout risked shoulder-surfing and log capture. Require --output and write an owner-only file instead. Co-authored-by: Cursor --- src/cli/wallet.rs | 137 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 128 insertions(+), 9 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 2e4d116..68967c1 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -12,6 +12,8 @@ use crate::{ use clap::Subcommand; use colored::Colorize; use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; use std::io::{self, Write}; /// Wallet management commands @@ -60,6 +62,10 @@ pub enum WalletCommands { /// Export format: mnemonic, private-key #[arg(short, long, default_value = "mnemonic")] format: String, + + /// Write the mnemonic to this file instead of printing it (created with owner-only permissions) + #[arg(short, long)] + output: Option, }, /// Import wallet from mnemonic phrase @@ -280,6 +286,30 @@ async fn fetch_pending_transfers_for_guardian( Ok((total, per_account)) } +fn write_mnemonic_to_protected_file( + path: &std::path::Path, + mnemonic: &str, +) -> crate::error::Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + options.mode(0o600); + + let mut file = options.open(path).map_err(|e| { + QuantusError::Generic(format!("Failed to create mnemonic export file: {e}")) + })?; + file.write_all(mnemonic.as_bytes()).map_err(|e| { + QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")) + })?; + file.write_all(b"\n").map_err(|e| { + QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")) + })?; + file.sync_all().map_err(|e| { + QuantusError::Generic(format!("Failed to sync mnemonic export file: {e}")) + })?; + Ok(()) +} + /// Handle wallet commands pub async fn handle_wallet_command( command: WalletCommands, @@ -515,7 +545,7 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::Export { name, password, format } => { + WalletCommands::Export { name, password, format, output } => { log_print!("📤 Exporting wallet..."); if format.to_lowercase() != "mnemonic" { @@ -525,20 +555,30 @@ pub async fn handle_wallet_command( )); } + let Some(output_path) = output else { + log_error!( + "Refusing to print the mnemonic to stdout. Use --output to create a protected export file." + ); + return Err(crate::error::QuantusError::Generic( + "Mnemonic export requires --output".to_string(), + )); + }; + let wallet_manager = WalletManager::new()?; match wallet_manager.export_mnemonic(&name, password.as_deref()) { Ok(mnemonic) => { + write_mnemonic_to_protected_file(&output_path, &mnemonic)?; log_success!("✅ Wallet exported successfully!"); - log_print!("\nYour secret mnemonic phrase:"); - log_print!("{}", "--------------------------------------------------".dimmed()); - log_print!("{}", mnemonic.bright_yellow()); - log_print!("{}", "--------------------------------------------------".dimmed()); log_print!( - "\n{}", - "âš ī¸ Keep this phrase safe and secret. Anyone with this phrase can access your funds." - .bright_red() - ); + "Mnemonic written to: {}", + output_path.display().to_string().bright_cyan() + ); + log_print!( + "{}", + "âš ī¸ Keep this file safe and secret. Anyone with this phrase can access your funds." + .bright_red() + ); }, Err(e) => { log_error!("{}", format!("❌ Failed to export wallet: {e}").red()); @@ -809,7 +849,10 @@ pub async fn handle_wallet_command( #[cfg(test)] mod tests { + use super::*; use clap::Parser; + use serial_test::serial; + use tempfile::TempDir; #[derive(Parser, Debug)] #[command(name = "quantus")] @@ -818,6 +861,82 @@ mod tests { command: crate::cli::Commands, } + #[tokio::test] + #[serial] + async fn wallet_export_without_output_refuses_stdout_mnemonic() { + // #159469: export must not emit the recovery secret via log_print/stdout. + let home = TempDir::new().expect("temp HOME"); + std::env::set_var("HOME", home.path()); + std::env::set_var("QUANTUS_NO_UPDATE_CHECK", "1"); + + let manager = WalletManager::new().expect("wallet manager"); + manager + .create_wallet("export-leak", Some("")) + .await + .expect("create wallet"); + + let result = handle_wallet_command( + WalletCommands::Export { + name: "export-leak".to_string(), + password: None, + format: "mnemonic".to_string(), + output: None, + }, + "ws://127.0.0.1:9944", + ) + .await; + + assert!( + result.is_err(), + "export without --output must refuse stdout mnemonic emission" + ); + assert!( + result.unwrap_err().to_string().contains("requires --output"), + "error should mention --output" + ); + } + + #[tokio::test] + #[serial] + async fn wallet_export_writes_mnemonic_to_protected_file_not_stdout_path() { + let home = TempDir::new().expect("temp HOME"); + std::env::set_var("HOME", home.path()); + std::env::set_var("QUANTUS_NO_UPDATE_CHECK", "1"); + std::env::remove_var("QUANTUS_WALLET_PASSWORD"); + std::env::remove_var("QUANTUS_WALLET_PASSWORD_EXPORT_FILE"); + + let manager = WalletManager::new().expect("wallet manager"); + manager + .create_wallet("export-file", Some("")) + .await + .expect("create wallet"); + let mnemonic = manager + .export_mnemonic("export-file", None) + .expect("export mnemonic for fixture"); + + let out = home.path().join("mnemonic.txt"); + handle_wallet_command( + WalletCommands::Export { + name: "export-file".to_string(), + password: None, + format: "mnemonic".to_string(), + output: Some(out.clone()), + }, + "ws://127.0.0.1:9944", + ) + .await + .expect("export with --output must succeed"); + + let written = std::fs::read_to_string(&out).expect("export file"); + assert_eq!(written.trim(), mnemonic.trim()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&out).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "export file must be owner-read/write only"); + } + } + #[test] fn wallet_import_rejects_mnemonic_cli_argument() { let result = TestCli::try_parse_from([ From 1e9e9103befb026a0411c846cca69bd8d95a69cb Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 22/39] fix(multisig): deduplicate signers before predict and threshold Duplicate signers inflated predicted addresses and could satisfy thresholds incorrectly. Sort and dedup before prediction and checks. Co-authored-by: Cursor --- src/cli/multisig.rs | 57 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 9decbba..04a626b 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -446,9 +446,10 @@ pub fn predict_multisig_address( }) .collect(); - // Sort signers for deterministic address (same as runtime does) + // Sort and deduplicate signers for deterministic address (same as runtime does) let mut sorted_signers = sp_signers; sorted_signers.sort(); + sorted_signers.dedup(); // Build data to hash: pallet_id || sorted_signers || threshold || nonce // IMPORTANT: Must match runtime encoding exactly @@ -1104,7 +1105,7 @@ async fn handle_create_multisig( log_print!("🔐 {} Creating multisig...", "MULTISIG".bright_magenta().bold()); // Parse signers - convert to AccountId32 - let signer_addresses: Vec = signers + let mut signer_addresses: Vec = signers .split(',') .map(|s| s.trim()) .map(|addr| { @@ -1123,6 +1124,14 @@ async fn handle_create_multisig( Ok(subxt::ext::subxt_core::utils::AccountId32::from(bytes)) }) .collect::, crate::error::QuantusError>>()?; + signer_addresses.sort_by_key(|account| { + let bytes: [u8; 32] = *account.as_ref(); + bytes + }); + signer_addresses.dedup_by_key(|account| { + let bytes: [u8; 32] = *account.as_ref(); + bytes + }); log_verbose!("Signers: {} addresses", signer_addresses.len()); log_verbose!("Threshold: {}", threshold); @@ -1263,7 +1272,7 @@ async fn handle_predict_address( log_print!(""); // Parse signers - convert to AccountId32 - let signer_addresses: Vec = signers + let mut signer_addresses: Vec = signers .split(',') .map(|s| s.trim()) .map(|addr| { @@ -1282,6 +1291,14 @@ async fn handle_predict_address( Ok(subxt::ext::subxt_core::utils::AccountId32::from(bytes)) }) .collect::, crate::error::QuantusError>>()?; + signer_addresses.sort_by_key(|account| { + let bytes: [u8; 32] = *account.as_ref(); + bytes + }); + signer_addresses.dedup_by_key(|account| { + let bytes: [u8; 32] = *account.as_ref(); + bytes + }); // Validate inputs if signer_addresses.is_empty() { @@ -3158,6 +3175,40 @@ mod tests { addr.to_ss58check_with_version(sp_core::crypto::Ss58AddressFormat::custom(189)) } + #[test] + fn duplicate_signers_do_not_inflate_predicted_multisig_address() { + // #160052: prediction must hash the unique sorted signer set. + let signer = account(7); + let with_dup = predict_multisig_address(vec![signer.clone(), signer.clone()], 2, 0); + let unique = predict_multisig_address(vec![signer], 2, 0); + assert_eq!( + with_dup, unique, + "multisig address prediction must ignore duplicate signers" + ); + } + + #[tokio::test] + async fn duplicate_signers_threshold_rejected_after_dedup() { + // #160052: threshold validated against unique signers, not raw CSV length. + let signer_ss58 = ss58(&account(7)); + let duplicate_csv = format!("{0},{0}", signer_ss58); + let result = handle_multisig_command( + MultisigCommands::PredictAddress { + signers: duplicate_csv, + threshold: 2, + nonce: 0, + }, + "ws://127.0.0.1:9944", + ExecutionMode::default(), + ) + .await; + assert!( + result.is_err(), + "duplicate-only signer sets with threshold 2 must fail local validation: {:?}", + result + ); + } + #[test] fn find_matching_multisig_created_address_skips_unrelated_same_block_event() { let creator = account(1); From b7039e64f1296cf17e7644fcf4c2d74fc6863f93 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 23/39] fix(storage): bound pagination against overflow and stuck cursors Checked count accumulation and reject non-advancing key cursors so malicious RPC pages cannot loop or wrap the entry count. Co-authored-by: Cursor --- src/cli/storage.rs | 98 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 12 deletions(-) diff --git a/src/cli/storage.rs b/src/cli/storage.rs index e379d4e..90df84f 100644 --- a/src/cli/storage.rs +++ b/src/cli/storage.rs @@ -377,6 +377,44 @@ pub async fn show_storage_stats( Ok(()) } +/// Accumulate a page of storage keys into `total_count` with overflow checks. +fn accumulate_storage_key_count(total_count: u32, keys_len: usize) -> crate::error::Result { + let keys_count = u32::try_from(keys_len).map_err(|_| { + QuantusError::Generic("RPC returned too many storage keys in one page".to_string()) + })?; + total_count.checked_add(keys_count).ok_or_else(|| { + QuantusError::Generic("Storage entry count exceeds u32::MAX".to_string()) + }) +} + +/// Decide the next `state_getKeysPaged` start key, rejecting non-advancing cursors. +/// +/// Returns `Ok(None)` when pagination is complete (short page). +fn next_storage_pagination_key( + start_key: Option<&str>, + keys: &[String], + page_size: u32, +) -> crate::error::Result> { + let keys_count = u32::try_from(keys.len()).map_err(|_| { + QuantusError::Generic("RPC returned too many storage keys in one page".to_string()) + })?; + if keys_count < page_size { + return Ok(None); + } + + let next_start_key = keys.last().cloned().ok_or_else(|| { + QuantusError::Generic("RPC returned an empty full storage key page".to_string()) + })?; + if let Some(current_start_key) = start_key { + if next_start_key.as_str() <= current_start_key { + return Err(QuantusError::NetworkError(format!( + "Storage key pagination did not advance: start_key {current_start_key}, last key {next_start_key}" + ))); + } + } + Ok(Some(next_start_key)) +} + /// Count storage entries using RPC calls with pagination pub async fn count_storage_entries( quantus_client: &crate::chain::client::QuantusClient, @@ -418,20 +456,13 @@ pub async fn count_storage_entries( )) })?; - let keys_count = keys.len() as u32; - total_count += keys_count; - - log_verbose!("📊 Fetched {} keys (total: {})", keys_count, total_count); + total_count = accumulate_storage_key_count(total_count, keys.len())?; - // If we got less than page_size keys, we're done - if keys_count < page_size { - break; - } + log_verbose!("📊 Fetched {} keys (total: {})", keys.len(), total_count); - // Set start_key to the last key for next iteration - start_key = keys.last().cloned(); - if start_key.is_none() { - break; + match next_storage_pagination_key(start_key.as_deref(), &keys, page_size)? { + Some(next) => start_key = Some(next), + None => break, } } @@ -820,3 +851,46 @@ fn encode_storage_key(key_value: &str, key_type: &str) -> crate::error::Result = (0..1000).map(|i| format!("0x{:04x}", i % 2)).collect(); + // Full page whose last key equals the prior start_key (malicious/stuck RPC). + let stuck_key = page.last().cloned().unwrap(); + let err = next_storage_pagination_key(Some(&stuck_key), &page, 1000) + .expect_err("same-cursor pagination must fail closed"); + assert!( + err.to_string().contains("did not advance"), + "unexpected pagination error: {err}" + ); + } + + #[test] + fn next_storage_pagination_key_completes_on_short_page() { + let page = vec!["0x01".to_string(), "0x02".to_string()]; + assert_eq!(next_storage_pagination_key(None, &page, 1000).unwrap(), None); + } + + #[test] + fn next_storage_pagination_key_advances_on_full_page() { + let page: Vec = (0..1000).map(|i| format!("0x{i:04x}")).collect(); + let next = next_storage_pagination_key(Some("0x0000"), &page, 1000) + .expect("advancing cursor must succeed") + .expect("full page must yield next start key"); + assert_eq!(next, *page.last().unwrap()); + } +} From 5015a2e6d3363573705a415d5cfb057363f57f90 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 24/39] fix(tx): bound transaction-status subscription waits Unbounded tx_progress waits could hang forever. Apply inactivity and overall deadlines and surface stream timeouts. Co-authored-by: Cursor --- src/cli/common.rs | 201 ++++++++++++++++++++++++++++++---------------- 1 file changed, 131 insertions(+), 70 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 2dd70cd..018299f 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -10,6 +10,10 @@ use subxt::{ pub type SubxtAccountId32 = subxt::ext::subxt_core::utils::AccountId32; +const TX_STATUS_INACTIVITY_TIMEOUT_SECS: u64 = 30; +const TX_STATUS_INCLUDED_TIMEOUT_SECS: u64 = 5 * 60; +const TX_STATUS_FINALIZED_TIMEOUT_SECS: u64 = 30 * 60; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ExecutionMode { pub finalized: bool, @@ -57,6 +61,14 @@ impl TransactionStage { } } +fn tx_status_watch_timeout_secs(target_stage: TransactionStage) -> u64 { + match target_stage { + TransactionStage::Submitted => 0, + TransactionStage::Included => TX_STATUS_INCLUDED_TIMEOUT_SECS, + TransactionStage::Finalized => TX_STATUS_FINALIZED_TIMEOUT_SECS, + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum WatchedTxEvent { Validated, @@ -69,6 +81,7 @@ enum WatchedTxEvent { Dropped(String), StreamError(String), StreamEnded, + StreamTimedOut, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -106,6 +119,11 @@ fn describe_watched_tx_event( "Transaction status stream ended before the transaction was {}", target_stage.status_label() ))), + WatchedTxEvent::StreamTimedOut => Err(crate::error::QuantusError::NetworkError(format!( + "Transaction status stream timed out after {} seconds without updates before the transaction was {}", + TX_STATUS_INACTIVITY_TIMEOUT_SECS, + target_stage.status_label() + ))), } } @@ -300,10 +318,9 @@ pub async fn get_fresh_nonce_with_client( quantus_client: &crate::chain::client::QuantusClient, from_keypair: &crate::wallet::QuantumKeyPair, ) -> Result { - let (from_account_id, _version) = - AccountId32::from_ss58check_with_version(&from_keypair.to_account_id_ss58check()).map_err( - |e| crate::error::QuantusError::NetworkError(format!("Invalid from address: {e:?}")), - )?; + let from_account_id = from_keypair.try_to_account_id_32().map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Invalid from keypair public key: {e}")) + })?; // Get nonce from the latest block (best block) let latest_nonce = quantus_client @@ -349,10 +366,9 @@ pub async fn get_incremented_nonce_with_client( from_keypair: &crate::wallet::QuantumKeyPair, base_nonce: u64, ) -> Result { - let (from_account_id, _version) = - AccountId32::from_ss58check_with_version(&from_keypair.to_account_id_ss58check()).map_err( - |e| crate::error::QuantusError::NetworkError(format!("Invalid from address: {e:?}")), - )?; + let from_account_id = from_keypair.try_to_account_id_32().map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Invalid from keypair public key: {e}")) + })?; // Get current nonce from the latest block let current_nonce = quantus_client @@ -636,69 +652,90 @@ async fn wait_tx_inclusion( None }; - loop { - let elapsed_secs = start_time.elapsed().as_secs(); - let next_event = match tx_progress.next().await { - Some(Ok(status)) => { - crate::log_verbose!( - " Transaction status: {:?} (elapsed: {}s)", - status, - elapsed_secs - ); + let watch_timeout_secs = tx_status_watch_timeout_secs(target_stage); - 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), - } - }, - Some(Err(err)) => WatchedTxEvent::StreamError(err.to_string()), - None => WatchedTxEvent::StreamEnded, + loop { + let elapsed_before_wait = start_time.elapsed().as_secs(); + let remaining_watch_secs = watch_timeout_secs.saturating_sub(elapsed_before_wait); + let (next_event, elapsed_secs) = if remaining_watch_secs == 0 { + (WatchedTxEvent::StreamTimedOut, elapsed_before_wait) + } else { + let next_status = tokio::time::timeout( + std::time::Duration::from_secs(std::cmp::min( + TX_STATUS_INACTIVITY_TIMEOUT_SECS, + remaining_watch_secs, + )), + tx_progress.next(), + ) + .await; + let elapsed_secs = start_time.elapsed().as_secs(); + let next_event = match next_status { + Ok(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), + } + }, + Ok(Some(Err(err))) => WatchedTxEvent::StreamError(err.to_string()), + Ok(None) => WatchedTxEvent::StreamEnded, + Err(_) => WatchedTxEvent::StreamTimedOut, + }; + (next_event, elapsed_secs) }; match describe_watched_tx_event(next_event, target_stage) { @@ -882,6 +919,30 @@ mod tests { describe_watched_tx_event(WatchedTxEvent::StreamEnded, TransactionStage::Included,) .is_err() ); + let timeout_err = describe_watched_tx_event( + WatchedTxEvent::StreamTimedOut, + TransactionStage::Included, + ) + .expect_err("silent subscription must time out instead of waiting forever"); + assert!( + timeout_err.to_string().contains("timed out"), + "unexpected timeout error: {timeout_err}" + ); + } + + #[test] + fn transaction_status_watch_deadlines_are_finite() { + assert_eq!(tx_status_watch_timeout_secs(TransactionStage::Submitted), 0); + assert_eq!( + tx_status_watch_timeout_secs(TransactionStage::Included), + TX_STATUS_INCLUDED_TIMEOUT_SECS + ); + assert_eq!( + tx_status_watch_timeout_secs(TransactionStage::Finalized), + TX_STATUS_FINALIZED_TIMEOUT_SECS + ); + assert!(TX_STATUS_INACTIVITY_TIMEOUT_SECS > 0); + assert!(TX_STATUS_INCLUDED_TIMEOUT_SECS < TX_STATUS_FINALIZED_TIMEOUT_SECS); } #[test] From 12d3ff629f38ac16a756fb92eb2f4e2878edd882 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 25/39] fix(rewards): use checked addition for indexer transfer totals Untrusted Subsquid amounts were summed with wrapping u128 +=. Reject overflows instead of silently wrapping totals. Co-authored-by: Cursor --- src/collect_rewards_lib.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index c13d21d..36e7575 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -302,7 +302,8 @@ pub async fn collect_rewards( // Calculate total available (only unspent) let mut total_available: u128 = 0; for t in &unspent_transfers { - total_available += parse_transfer_amount(&t.amount, &format!("transfer {}", t.id))?; + let amount = parse_transfer_amount(&t.amount, &format!("transfer {}", t.id))?; + total_available = checked_add_amount(total_available, amount, "total available transfers")?; } // Determine amount to withdraw @@ -331,7 +332,7 @@ pub async fn collect_rewards( break; } selected_transfers.push(t); - selected_total += amt; + selected_total = checked_add_amount(selected_total, amt, "selected transfers")?; } if config.dry_run { @@ -496,8 +497,10 @@ pub async fn collect_rewards( let (block_hash, tx_hash, transfer_events) = submit_and_get_events(&quantus_client, aggregated_proof, bins_dir).await?; - let batch_amount: u128 = transfer_events.iter().map(|e| e.amount).sum(); - total_withdrawn += batch_amount; + let batch_amount = transfer_events.iter().try_fold(0_u128, |acc, e| { + checked_add_amount(acc, e.amount, "batch withdrawal events") + })?; + total_withdrawn = checked_add_amount(total_withdrawn, batch_amount, "total withdrawn")?; progress.on_batch_submitted(batch_idx + 1, batches.len(), batch_amount); @@ -567,7 +570,7 @@ pub async fn query_pending_transfers( let leaf_index = parse_leaf_index(&t.leaf_index, &ctx)?; let transfer_count = parse_transfer_count(&t.transfer_count, &ctx)?; - total_available += amount; + total_available = checked_add_amount(total_available, amount, "pending transfers")?; pending.push(PendingTransfer { block_height: t.block_height, @@ -620,7 +623,7 @@ pub async fn query_pending_transfers_for_address( let leaf_index = parse_leaf_index(&t.leaf_index, &ctx)?; let transfer_count = parse_transfer_count(&t.transfer_count, &ctx)?; - total_available += amount; + total_available = checked_add_amount(total_available, amount, "pending transfers")?; pending.push(PendingTransfer { block_height: t.block_height, @@ -640,6 +643,15 @@ pub async fn query_pending_transfers_for_address( // Internal Helper Functions // ============================================================================ +fn checked_add_amount(acc: u128, amount: u128, context: &str) -> Result { + acc.checked_add(amount).ok_or_else(|| { + CollectRewardsError::from(format!( + "Transfer amount overflow while accumulating {}", + context + )) + }) +} + /// Parse a transfer amount string to u128 fn parse_transfer_amount(amount_str: &str, context: &str) -> Result { amount_str.parse::().map_err(|e| { @@ -1072,6 +1084,18 @@ mod tests { assert_eq!(format!("{}", err), "test error"); } + #[test] + fn checked_add_amount_rejects_indexer_overflow() { + let err = checked_add_amount(u128::MAX, 2, "pending transfers") + .expect_err("untrusted transfer totals must not wrap on overflow"); + assert!( + err.message.contains("overflow"), + "unexpected overflow error: {}", + err.message + ); + assert_eq!(checked_add_amount(10, 5, "pending transfers").unwrap(), 15); + } + #[test] fn test_pre_submission_nullifier_query_count_is_bounded() { assert!( From bcdab11b91fba3b0786268bd5cf2e1ebb197101e Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:56:32 +0800 Subject: [PATCH 26/39] fix(batch): enforce runtime batched_calls_limit for batch size Batch sizing used a soft heuristic that could exceed the runtime call count limit. Read Utility::batched_calls_limit and fail closed. Co-authored-by: Cursor --- src/cli/send.rs | 64 ++++++++++++++++++++++++------------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/cli/send.rs b/src/cli/send.rs index c282e73..4ab3be5 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -481,12 +481,11 @@ pub(crate) async fn validate_batch_transfer_request( )); } - let (safe_limit, recommended_limit) = - get_batch_limits(quantus_client).await.unwrap_or((500, 1000)); + let (safe_limit, recommended_limit) = get_batch_limits(quantus_client).await?; if transfers.len() as u32 > recommended_limit { return Err(crate::error::QuantusError::Generic(format!( - "Too many transfers in batch ({}) - chain limit is ~{} (safe: {})", + "Too many transfers in batch ({}) - chain batched calls limit is {} (safe: {})", transfers.len(), recommended_limit, safe_limit @@ -707,47 +706,38 @@ pub async fn load_transfers_from_file(file_path: &str) -> Result (u32, u32) { + (batched_calls_limit / 2, batched_calls_limit) +} + /// Get chain constants for batch limits pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u32)> { - // Try to get actual chain constants let constants = quantus_client.client().constants(); - - // Get block weight limit - let block_weight_limit = constants - .at(&quantus_subxt::api::constants().system().block_weights()) - .map(|weights| weights.max_block.ref_time) - .unwrap_or(2_000_000_000_000); // Default 2 trillion weight units - - // Estimate transfers per block (rough calculation) - let transfer_weight = 1_500_000_000u64; // Rough estimate per transfer - let max_transfers_by_weight = (block_weight_limit / transfer_weight) as u32; - - // Get max extrinsic length - let max_extrinsic_length = constants - .at(&quantus_subxt::api::constants().system().block_length()) - .map(|length| length.max.normal) - .unwrap_or(5_242_880); // Default 5MB - - // Estimate transfers per extrinsic size (very rough) - let transfer_size = 100u32; // Rough estimate per transfer in bytes - let max_transfers_by_size = max_extrinsic_length / transfer_size; - - let recommended_limit = std::cmp::min(max_transfers_by_weight, max_transfers_by_size); - let safe_limit = recommended_limit / 2; // Be conservative + let batched_calls_limit = constants + .at(&quantus_subxt::api::constants().utility().batched_calls_limit()) + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to read Utility::batched_calls_limit from runtime metadata: {e:?}" + )) + })?; + let (safe_limit, recommended_limit) = limits_from_batched_calls_limit(batched_calls_limit); log_verbose!( - "📊 Chain limits: weight allows ~{}, size allows ~{}", - max_transfers_by_weight, - max_transfers_by_size + "📊 Chain batched calls limit: {} (safe: {})", + batched_calls_limit, + safe_limit ); - log_verbose!("📊 Recommended batch size: {} (safe: {})", recommended_limit, safe_limit); Ok((safe_limit, recommended_limit)) } #[cfg(test)] mod tests { - use super::{build_batch_transfer_call, effective_tip_amount, parse_amount_with_decimals}; + use super::{ + build_batch_transfer_call, effective_tip_amount, limits_from_batched_calls_limit, + parse_amount_with_decimals, + }; use subxt::tx::Payload; /// Substrate Alice (valid SS58); used only to construct a call for metadata checks. @@ -810,6 +800,16 @@ mod tests { assert!(parse_amount_with_decimals(&overflow, 12).is_err()); } + #[test] + fn batch_limits_come_from_runtime_batched_calls_limit() { + // Heuristic weight/length estimates previously returned unrelated numbers and + // validate_batch_transfer_request fell back to (500, 1000) on errors. + let (safe, recommended) = limits_from_batched_calls_limit(40); + assert_eq!(recommended, 40, "recommended must be Utility::batched_calls_limit"); + assert_eq!(safe, 20, "safe limit is half the runtime call-count limit"); + assert_ne!(recommended, 1000, "must not use the hard-coded heuristic fallback"); + } + #[test] fn default_tip_amount_is_zero() { assert_eq!(effective_tip_amount(None), 0); From fb5b36f51f5174fb298a9fcc31fb484c22b17ed1 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:08:42 +0800 Subject: [PATCH 27/39] fix(subsquid): harden exhaustive transfer queries and spent filtering Require aggregate counts, offset-paginate single over-limit blocks, apply caller offset once globally, and exclude spent nullifiers from pending sets. Co-authored-by: Cursor --- src/collect_rewards_lib.rs | 287 ++++++++++++++++++++++++-- src/subsquid/client.rs | 406 ++++++++++++++++++++++++++++++++----- 2 files changed, 622 insertions(+), 71 deletions(-) diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index 36e7575..3201ae5 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -561,10 +561,15 @@ pub async fn query_pending_transfers( let incoming_transfers: Vec<_> = transfers.into_iter().filter(|t| t.to_hash == address_hash).collect(); + let secret_bytes: [u8; 32] = *wormhole_secret.secret.as_bytes(); + let unspent_transfers = + filter_unspent_transfers_by_indexer(&incoming_transfers, &secret_bytes, &subsquid_client) + .await?; + let mut total_available: u128 = 0; let mut pending = Vec::new(); - for t in &incoming_transfers { + for t in &unspent_transfers { let ctx = format!("transfer {}", t.id); let amount = parse_transfer_amount(&t.amount, &ctx)?; let leaf_index = parse_leaf_index(&t.leaf_index, &ctx)?; @@ -590,6 +595,10 @@ pub async fn query_pending_transfers( /// /// Use this when you already have the wormhole address and don't need to derive it. /// +/// Address-only discovery cannot reconcile spent nullifiers. When any incoming +/// transfers are present, this returns an error directing callers to use a +/// secret-bearing API (`query_pending_transfers` / `collect_rewards`). +/// /// # Arguments /// * `wormhole_address_bytes` - The 32-byte wormhole address /// * `subsquid_url` - The Subsquid GraphQL endpoint URL @@ -614,29 +623,18 @@ pub async fn query_pending_transfers_for_address( let incoming_transfers: Vec<_> = transfers.into_iter().filter(|t| t.to_hash == address_hash).collect(); - let mut total_available: u128 = 0; - let mut pending = Vec::new(); - - for t in &incoming_transfers { - let ctx = format!("transfer {}", t.id); - let amount = parse_transfer_amount(&t.amount, &ctx)?; - let leaf_index = parse_leaf_index(&t.leaf_index, &ctx)?; - let transfer_count = parse_transfer_count(&t.transfer_count, &ctx)?; - - total_available = checked_add_amount(total_available, amount, "pending transfers")?; - - pending.push(PendingTransfer { - block_height: t.block_height, - block_hash: t.block_id.clone(), - amount, - leaf_index, - transfer_count, - wormhole_address: wormhole_address.clone(), - funding_account: t.from_id.clone(), - }); + if !incoming_transfers.is_empty() { + return Err(CollectRewardsError::from( + "Cannot determine available withdrawals for an address without the wormhole secret; use query_pending_transfers or collect_rewards so spent nullifiers can be reconciled" + .to_string(), + )); } - Ok(QueryPendingTransfersResult { wormhole_address, transfers: pending, total_available }) + Ok(QueryPendingTransfersResult { + wormhole_address, + transfers: vec![], + total_available: 0, + }) } // ============================================================================ @@ -1027,6 +1025,48 @@ fn validate_pre_submission_nullifier_count(count: usize) -> Result<()> { Ok(()) } +/// Filter transfers against spent nullifiers reported by the indexer. +async fn filter_unspent_transfers_by_indexer( + transfers: &[Transfer], + secret_bytes: &[u8; 32], + subsquid_client: &SubsquidClient, +) -> Result> { + if transfers.is_empty() { + return Ok(vec![]); + } + + let mut seen_nullifiers = std::collections::HashSet::new(); + let mut transfers_with_nullifiers = Vec::new(); + let mut nullifier_pairs = Vec::new(); + + for transfer in transfers { + let ctx = format!("transfer {}", transfer.id); + let transfer_count = parse_transfer_count(&transfer.transfer_count, &ctx)?; + let nullifier = + wormhole_lib::compute_nullifier(secret_bytes, transfer_count).map_err(|e| { + CollectRewardsError::from(format!("Failed to compute nullifier: {}", e.message)) + })?; + + if !seen_nullifiers.insert(nullifier) { + continue; + } + + let nullifier_hex = hex::encode(nullifier); + let nullifier_hash = compute_address_hash(&nullifier); + nullifier_pairs.push((nullifier_hex.clone(), nullifier_hash)); + transfers_with_nullifiers.push((transfer.clone(), nullifier_hex)); + } + + let spent = subsquid_client.check_nullifiers_spent(&nullifier_pairs, 8).await?; + + Ok(transfers_with_nullifiers + .into_iter() + .filter_map(|(transfer, nullifier_hex)| { + (!spent.contains(&nullifier_hex)).then_some(transfer) + }) + .collect()) +} + /// Filter transfers against `UsedNullifiers` at one pinned best-chain snapshot. async fn filter_unspent_transfers_onchain( transfers: &[Transfer], @@ -1226,4 +1266,207 @@ mod tests { assert_eq!(m_address_bytes, s_address_bytes); assert_eq!(m_secret_bytes, s_secret_bytes); } + + fn read_http_request(stream: &mut std::net::TcpStream) -> String { + use std::io::Read; + use std::time::Duration; + + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut buf = Vec::new(); + let mut tmp = [0u8; 1024]; + let mut header_end = None; + let mut content_len = 0usize; + + loop { + let n = stream.read(&mut tmp).unwrap(); + assert_ne!(n, 0, "mock indexer connection closed before request was complete"); + buf.extend_from_slice(&tmp[..n]); + + if header_end.is_none() { + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + header_end = Some(pos + 4); + let headers = String::from_utf8_lossy(&buf[..pos]); + for line in headers.lines() { + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_len = value.trim().parse().unwrap(); + } + } + } + } + } + + if let Some(end) = header_end { + if buf.len() >= end + content_len { + break; + } + } + } + + String::from_utf8(buf).unwrap() + } + + fn write_json_response(stream: &mut std::net::TcpStream, body: serde_json::Value) { + use std::io::Write; + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + } + + /// #159890: address-only queries must not report availability without secret reconciliation. + #[tokio::test] + async fn pending_transfer_query_for_address_refuses_without_secret() { + use serde_json::json; + use std::net::TcpListener; + use std::thread; + + let secret = [7u8; 32]; + let wormhole_address = wormhole_lib::compute_wormhole_address(&secret).unwrap(); + let address_hash = compute_address_hash(&wormhole_address); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mock_url = format!("http://{}", listener.local_addr().unwrap()); + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let _ = read_http_request(&mut stream); + write_json_response( + &mut stream, + json!({ + "data": { + "transfers": [{ + "id": "spent-transfer", + "block_id": "0xspentblock", + "block": { "height": 11 }, + "timestamp": "2026-01-01T00:00:00Z", + "extrinsic_id": "0xspentextrinsic", + "from_id": "miner-a", + "to_id": "wormhole", + "amount": "100", + "fee": "0", + "from_hash": "from-hash-a", + "to_hash": address_hash, + "leaf_index": "40", + "transfer_count": "5" + }], + "meta": { "aggregate": { "count": 1 } } + } + }), + ); + }); + + let err = query_pending_transfers_for_address(&wormhole_address, &mock_url) + .await + .expect_err("address-only query must refuse when transfers exist"); + assert!( + err.message.contains("without the wormhole secret"), + "unexpected error: {}", + err.message + ); + } + + /// #159890: mnemonic pending-transfer query excludes spent nullifiers via indexer. + #[tokio::test] + async fn query_pending_transfers_excludes_spent_nullifiers() { + use serde_json::json; + use std::net::TcpListener; + use std::thread; + + let path = format!("m/44'/{}/0'/1'/0'", QUANTUS_WORMHOLE_CHAIN_ID); + let wormhole_secret = derive_wormhole_from_mnemonic(TEST_MNEMONIC, None, &path).unwrap(); + let secret_bytes: [u8; 32] = *wormhole_secret.secret.as_bytes(); + let address_hash = compute_address_hash(&wormhole_secret.address); + + let spent_transfer_count = 5u64; + let spent_nullifier = + wormhole_lib::compute_nullifier(&secret_bytes, spent_transfer_count).unwrap(); + let spent_nullifier_hex = hex::encode(spent_nullifier); + let spent_nullifier_hash = compute_address_hash(&spent_nullifier); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mock_url = format!("http://{}", listener.local_addr().unwrap()); + let address_hash_for_server = address_hash.clone(); + let spent_hex_for_server = spent_nullifier_hex.clone(); + let spent_hash_for_server = spent_nullifier_hash.clone(); + + thread::spawn(move || { + for _ in 0..2 { + let (mut stream, _) = listener.accept().unwrap(); + let request = read_http_request(&mut stream); + let body = request.split("\r\n\r\n").nth(1).unwrap_or_default(); + if body.contains("TransfersByHashPrefix") { + write_json_response( + &mut stream, + json!({ + "data": { + "transfers": [ + { + "id": "spent-transfer", + "block_id": "0xspentblock", + "block": { "height": 11 }, + "timestamp": "2026-01-01T00:00:00Z", + "extrinsic_id": "0xspentextrinsic", + "from_id": "miner-a", + "to_id": "wormhole", + "amount": "100", + "fee": "0", + "from_hash": "from-hash-a", + "to_hash": address_hash_for_server, + "leaf_index": "40", + "transfer_count": "5" + }, + { + "id": "unspent-transfer", + "block_id": "0xunspentblock", + "block": { "height": 12 }, + "timestamp": "2026-01-01T00:00:01Z", + "extrinsic_id": "0xunspentextrinsic", + "from_id": "miner-b", + "to_id": "wormhole", + "amount": "7", + "fee": "0", + "from_hash": "from-hash-b", + "to_hash": address_hash_for_server, + "leaf_index": "41", + "transfer_count": "6" + } + ], + "meta": { "aggregate": { "count": 2 } } + } + }), + ); + } else if body.contains("NullifiersByPrefix") { + write_json_response( + &mut stream, + json!({ + "data": { + "nullifiers": [{ + "nullifier": spent_hex_for_server, + "nullifier_hash": spent_hash_for_server, + "block": { "height": 20 }, + "timestamp": "2026-01-01T00:00:02Z", + "wormholeExtrinsic": { "extrinsic_id": "0xwithdrawal" } + }] + } + }), + ); + } else { + panic!("unexpected GraphQL request body: {body}"); + } + } + }); + + let reported = query_pending_transfers(TEST_MNEMONIC, 0, &mock_url) + .await + .expect("mnemonic pending query should reconcile nullifiers"); + + assert_eq!(reported.transfers.len(), 1); + assert_eq!(reported.transfers[0].transfer_count, 6); + assert_eq!(reported.total_available, 7); + assert!(!reported.transfers.iter().any(|t| t.transfer_count == spent_transfer_count)); + } } diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index 7b0b705..9d145a3 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -81,8 +81,31 @@ impl SubsquidClient { from_prefixes: Option>, params: TransferQueryParams, ) -> Result> { - // Hasura table query with an aggregate count so we can emulate the old - // server's "too many results" rejection for overly broad queries. + let (transfers, total_count) = self + .query_transfers_by_prefix_page(to_prefixes, from_prefixes, params) + .await?; + + if total_count > SERVER_MAX_LIMIT as i64 { + // Same wording as the old server so query_all_transfers_by_prefix + // keeps binary-splitting block ranges on this marker. + return Err(QuantusError::Generic(format!( + "Query returned {} results, which exceeds the limit of {}. \ + Please use longer hash prefixes or a narrower block range for more specific queries.", + total_count, SERVER_MAX_LIMIT + ))); + } + + Ok(transfers) + } + + async fn query_transfers_by_prefix_page( + &self, + to_prefixes: Option>, + from_prefixes: Option>, + params: TransferQueryParams, + ) -> Result<(Vec, i64)> { + // Hasura table query with an aggregate count so callers can detect when a + // block range needs further narrowing or offset-based pagination. let query = r#" query TransfersByHashPrefix($where: transfer_bool_exp!, $limit: Int!, $offset: Int!) { transfers: transfer( @@ -124,18 +147,14 @@ impl SubsquidClient { let data: HasuraTransfersData = self.execute(&request).await?; - let total_count = data.meta.aggregate.map(|a| a.count).unwrap_or(0); - if total_count > SERVER_MAX_LIMIT as i64 { - // Same wording as the old server so query_all_transfers_by_prefix - // keeps binary-splitting block ranges on this marker. - return Err(QuantusError::Generic(format!( - "Query returned {} results, which exceeds the limit of {}. \ - Please use longer hash prefixes or a narrower block range for more specific queries.", - total_count, SERVER_MAX_LIMIT - ))); - } + let total_count = data.meta.aggregate.map(|a| a.count).ok_or_else(|| { + QuantusError::Generic( + "Missing transfer aggregate count in indexer response".to_string(), + ) + })?; + let transfers = data.transfers.into_iter().map(Transfer::from).collect(); - Ok(data.transfers.into_iter().map(Transfer::from).collect()) + Ok((transfers, total_count)) } /// Build a Hasura `transfer_bool_exp` where-clause from prefix lists and params. @@ -233,24 +252,24 @@ impl SubsquidClient { .ok_or_else(|| QuantusError::Generic("No data in response".to_string())) } - /// Fetch every transfer matching the given prefixes, paginating by block range. + /// Fetch every transfer matching the given prefixes. /// - /// The server caps any single query at 1000 results and rejects larger result sets - /// with a "Query returned N results, which exceeds the limit of 1000" error. This - /// method handles that by binary-splitting the `[after_block, before_block]` range - /// whenever the cap is hit, then concatenating results. + /// The server caps any single query at 1000 results. This method handles that + /// by binary-splitting the `[after_block, before_block]` range whenever the cap + /// is hit, then falling back to offset pagination if a single block still exceeds + /// the cap. /// /// `base_params.after_block` / `base_params.before_block` are honored as the initial /// bounds; unset means `0` / `i32::MAX` (GraphQL `Int` is signed 32-bit so we can't - /// exceed that). Other filters (amount, offset) are forwarded unchanged. `limit` is - /// always set to the server max (1000) per sub-query. + /// exceed that). Other filters (amount) are forwarded unchanged. `limit` is + /// always set to the server max (1000) per sub-query. `offset` is applied once to + /// the complete ordered result set, not to each block-range sub-query. pub async fn query_all_transfers_by_prefix( &self, to_prefixes: Option>, from_prefixes: Option>, base_params: TransferQueryParams, ) -> Result> { - const LIMIT_EXCEEDED_MARKER: &str = "exceeds the limit"; const MAX_BLOCK_SENTINEL: u32 = i32::MAX as u32; let initial_lo = base_params.after_block.unwrap_or(0); @@ -260,6 +279,7 @@ impl SubsquidClient { return Ok(vec![]); } + let global_offset = base_params.offset as usize; let mut all: Vec = Vec::new(); let mut stack: Vec<(u32, u32)> = vec![(initial_lo, initial_hi)]; @@ -268,29 +288,71 @@ impl SubsquidClient { .clone() .with_after_block(lo) .with_before_block(hi) - .with_limit(SERVER_MAX_LIMIT); - - match self - .query_transfers_by_prefix(to_prefixes.clone(), from_prefixes.clone(), params) - .await - { - Ok(transfers) => all.extend(transfers), - Err(e) if e.to_string().contains(LIMIT_EXCEEDED_MARKER) => { - if lo == hi { - return Err(QuantusError::Generic(format!( - "More than {} transfers in single block {}: {}", - SERVER_MAX_LIMIT, lo, e - ))); - } - let mid = lo + (hi - lo) / 2; - stack.push((mid + 1, hi)); - stack.push((lo, mid)); - }, - Err(e) => return Err(e), + .with_limit(SERVER_MAX_LIMIT) + .with_offset(0); + + let (transfers, total_count) = self + .query_transfers_by_prefix_page( + to_prefixes.clone(), + from_prefixes.clone(), + params.clone(), + ) + .await?; + + if total_count <= SERVER_MAX_LIMIT as i64 { + all.extend(transfers); + continue; + } + + if lo != hi { + let mid = lo + (hi - lo) / 2; + stack.push((mid + 1, hi)); + stack.push((lo, mid)); + continue; + } + + all.extend(transfers); + let total_count = u32::try_from(total_count).map_err(|_| { + QuantusError::Generic(format!( + "Transfer count {} for block {} exceeds supported pagination range", + total_count, lo + )) + })?; + let mut offset = params.offset.checked_add(SERVER_MAX_LIMIT).ok_or_else(|| { + QuantusError::Generic(format!( + "Transfer pagination offset overflow for block {}", + lo + )) + })?; + + while offset < total_count { + let page_params = params.clone().with_offset(offset); + let (page, _) = self + .query_transfers_by_prefix_page( + to_prefixes.clone(), + from_prefixes.clone(), + page_params, + ) + .await?; + + if page.is_empty() { + return Err(QuantusError::Generic(format!( + "Indexer returned an empty transfer page before offset {} of {} for block {}", + offset, total_count, lo + ))); + } + + all.extend(page); + offset = offset.checked_add(SERVER_MAX_LIMIT).ok_or_else(|| { + QuantusError::Generic(format!( + "Transfer pagination offset overflow for block {}", + lo + )) + })?; } } - Ok(all) + Ok(all.into_iter().skip(global_offset).collect()) } /// Query transfers for a set of addresses using privacy-preserving hash prefixes. @@ -468,6 +530,16 @@ impl SubsquidClient { #[cfg(test)] mod tests { use super::*; + use serde_json::{json, Value}; + use std::collections::HashSet; + use std::io::{Read, Write}; + use std::net::{TcpListener, TcpStream}; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }; + use std::thread; + use std::time::Duration; #[test] fn test_transfer_query_params_builder() { @@ -483,13 +555,249 @@ mod tests { assert_eq!(params.before_block, Some(2000)); } - // Guards the substring the paginator matches on. If the server ever changes this - // wording, `query_all_transfers_by_prefix` will stop triggering binary-split and - // this test will fail loudly. - #[test] - fn test_server_limit_error_marker() { - let server_message = "Query returned 1234 results, which exceeds the limit of 1000. \ - Please use longer hash prefixes for more specific queries."; - assert!(server_message.contains("exceeds the limit")); + fn transfer_row(i: usize, block_height: i64) -> Value { + json!({ + "id": format!("transfer-{i}"), + "block_id": format!("block-{i}"), + "block": { "height": block_height }, + "timestamp": "2026-01-01T00:00:00Z", + "extrinsic_id": null, + "from_id": "qzFrom", + "to_id": "qzTo", + "amount": "1", + "fee": "0", + "from_hash": "from-hash", + "to_hash": "target-prefix-full-hash", + "leaf_index": i.to_string(), + "transfer_count": (i + 1).to_string() + }) + } + + fn read_http_request(stream: &mut TcpStream) -> String { + stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + let mut header_end = None; + let mut content_len = 0usize; + + loop { + let n = stream.read(&mut tmp).unwrap(); + assert_ne!(n, 0, "mock indexer closed before request completed"); + buf.extend_from_slice(&tmp[..n]); + + if header_end.is_none() { + if let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + header_end = Some(pos + 4); + let headers = String::from_utf8_lossy(&buf[..pos]); + for line in headers.lines() { + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + content_len = value.trim().parse().unwrap(); + } + } + } + } + } + + if let Some(end) = header_end { + if buf.len() >= end + content_len { + break; + } + } + } + + String::from_utf8(buf).unwrap() + } + + fn write_json_response(stream: &mut TcpStream, body: Value) { + let body = body.to_string(); + write!( + stream, + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + } + + fn parse_request_vars(request: &str) -> (usize, usize, u64, u64) { + let body = request.split("\r\n\r\n").nth(1).unwrap_or_default(); + let request_json: Value = serde_json::from_str(body).expect("GraphQL JSON body"); + let variables = &request_json["variables"]; + let height = &variables["where"]["block"]["height"]; + let after = height["_gte"].as_u64().unwrap_or(0); + let before = height["_lte"].as_u64().unwrap_or(u64::from(u32::MAX)); + let limit = variables["limit"].as_u64().expect("limit") as usize; + let offset = variables["offset"].as_u64().expect("offset") as usize; + (limit, offset, after, before) + } + + /// #160776: missing/null aggregate must not be treated as a complete page. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn missing_aggregate_count_rejects_incomplete_prefix_page() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let rows: Arc> = Arc::new((0..=1000).map(|i| transfer_row(i, i as i64)).collect()); + let request_count = Arc::new(AtomicUsize::new(0)); + let server_rows = Arc::clone(&rows); + let server_count = Arc::clone(&request_count); + + thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + server_count.fetch_add(1, Ordering::SeqCst); + let request = read_http_request(&mut stream); + let (limit, _offset, _lo, _hi) = parse_request_vars(&request); + let transfers: Vec = server_rows.iter().take(limit).cloned().collect(); + write_json_response( + &mut stream, + json!({ + "data": { + "transfers": transfers, + "meta": { "aggregate": null } + } + }), + ); + }); + + let client = SubsquidClient::new(endpoint).unwrap(); + let err = client + .query_all_transfers_by_prefix( + Some(vec!["target".to_string()]), + None, + TransferQueryParams::new().with_after_block(0).with_before_block(1000), + ) + .await + .expect_err("missing aggregate must fail closed"); + + assert!( + err.to_string().contains("Missing transfer aggregate count"), + "unexpected error: {err}" + ); + assert_eq!(request_count.load(Ordering::SeqCst), 1); + } + + /// #159916: a single over-limit block must be offset-paginated, not aborted. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn single_block_over_limit_is_offset_paginated() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let total = 1001usize; + let rows: Arc> = Arc::new((0..total).map(|i| transfer_row(i, 42)).collect()); + let observed_offsets = Arc::new(Mutex::new(Vec::new())); + let server_rows = Arc::clone(&rows); + let server_offsets = Arc::clone(&observed_offsets); + + thread::spawn(move || { + for _ in 0..4 { + let Ok((mut stream, _)) = listener.accept() else { break }; + let request = read_http_request(&mut stream); + let (limit, offset, lo, hi) = parse_request_vars(&request); + assert_eq!(lo, 42); + assert_eq!(hi, 42); + server_offsets.lock().unwrap().push(offset); + let page: Vec = + server_rows.iter().skip(offset).take(limit).cloned().collect(); + write_json_response( + &mut stream, + json!({ + "data": { + "transfers": page, + "meta": { "aggregate": { "count": total as i64 } } + } + }), + ); + } + }); + + let client = SubsquidClient::new(endpoint).unwrap(); + let transfers = client + .query_all_transfers_by_prefix( + Some(vec!["target".to_string()]), + None, + TransferQueryParams::new().with_after_block(42).with_before_block(42), + ) + .await + .expect("single-block over-limit fetch must complete via offset pages"); + + assert_eq!(transfers.len(), total); + assert_eq!(transfers.first().map(|t| t.id.as_str()), Some("transfer-0")); + assert_eq!(transfers.last().map(|t| t.id.as_str()), Some("transfer-1000")); + let offsets = observed_offsets.lock().unwrap().clone(); + assert!(offsets.contains(&0), "expected first page at offset 0: {offsets:?}"); + assert!(offsets.contains(&1000), "expected second page at offset 1000: {offsets:?}"); + } + + /// #160777: caller offset must apply once globally across split ranges. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn query_all_transfers_applies_offset_globally_across_split_ranges() { + const TOTAL_TRANSFERS: u32 = 1001; + const GLOBAL_OFFSET: u32 = 1; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + + thread::spawn(move || { + for stream in listener.incoming().take(32) { + let Ok(mut stream) = stream else { continue }; + let request = read_http_request(&mut stream); + let (limit, offset, lo, hi) = parse_request_vars(&request); + let matching: Vec = + (0..TOTAL_TRANSFERS).filter(|h| *h >= lo as u32 && *h <= hi as u32).collect(); + let aggregate_count = matching.len(); + let page: Vec = matching + .into_iter() + .skip(offset) + .take(limit) + .map(|height| transfer_row(height as usize, height as i64)) + .collect(); + write_json_response( + &mut stream, + json!({ + "data": { + "transfers": page, + "meta": { "aggregate": { "count": aggregate_count as i64 } } + } + }), + ); + } + }); + + let client = SubsquidClient::new(endpoint).unwrap(); + + let complete = client + .query_all_transfers_by_prefix( + Some(vec!["eligible".to_string()]), + None, + TransferQueryParams::new() + .with_after_block(0) + .with_before_block(TOTAL_TRANSFERS) + .with_offset(0), + ) + .await + .expect("baseline exhaustive query"); + + let expected: HashSet = complete + .iter() + .skip(GLOBAL_OFFSET as usize) + .map(|t| t.id.clone()) + .collect(); + + let shifted = client + .query_all_transfers_by_prefix( + Some(vec!["eligible".to_string()]), + None, + TransferQueryParams::new() + .with_after_block(0) + .with_before_block(TOTAL_TRANSFERS) + .with_offset(GLOBAL_OFFSET), + ) + .await + .expect("global offset query"); + + let shifted_ids: HashSet = shifted.iter().map(|t| t.id.clone()).collect(); + assert_eq!( + shifted_ids, expected, + "offset must skip once across the complete ordered result set" + ); } } From fbd38ed310461ac4b5437fbd247c6e5b598067d2 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:08:42 +0800 Subject: [PATCH 28/39] fix(wormhole): zeroize proof-generation secrets after use Proof inputs retained secret bytes after generation. Clear them before returning so secrets do not linger in process memory. Co-authored-by: Cursor --- Cargo.toml | 1 + src/wormhole_lib.rs | 111 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9404314..e927000 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,7 @@ qp-zk-circuits-common = { version = "3.1.0", default-features = false, features hex = "0.4" qp-poseidon-core = "3.0.2" qp-wormhole-circuit-builder = { version = "3.1.0" } +sha2 = "0.10" [dev-dependencies] qp-poseidon-core = "3.0.2" diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index 52a63af..c6eab9d 100644 --- a/src/wormhole_lib.rs +++ b/src/wormhole_lib.rs @@ -18,7 +18,12 @@ use qp_zk_circuits_common::{ utils::{digest_to_bytes, BytesDigest}, zk_merkle::SIBLINGS_PER_LEVEL, }; -use std::path::Path; +use std::{ + mem::size_of, + path::Path, + ptr, + sync::atomic::{compiler_fence, Ordering}, +}; /// Native asset id for QTU token pub const NATIVE_ASSET_ID: u32 = 0; @@ -52,6 +57,30 @@ impl From for WormholeLibError { } } +fn zeroize_bytes(bytes: &mut [u8]) { + for byte in bytes { + unsafe { ptr::write_volatile(byte, 0) }; + } + compiler_fence(Ordering::SeqCst); +} + +fn zeroize_bytes_digest(digest: &mut BytesDigest) { + let ptr = ptr::addr_of_mut!(*digest).cast::(); + for offset in 0..size_of::() { + unsafe { ptr.add(offset).write_volatile(0) }; + } + compiler_fence(Ordering::SeqCst); +} + +#[allow(invalid_reference_casting)] +fn zeroize_input_secret(input: &ProofGenerationInput) { + let ptr = ptr::addr_of!(input.secret).cast_mut().cast::(); + for offset in 0..input.secret.len() { + unsafe { ptr.add(offset).write_volatile(0) }; + } + compiler_fence(Ordering::SeqCst); +} + /// Input data for generating a wormhole proof. /// All fields are raw bytes - no chain client required. #[derive(Debug, Clone)] @@ -189,7 +218,7 @@ pub fn generate_proof( common_bin_path: &Path, ) -> Result { // Convert secret to BytesDigest - let secret_digest: BytesDigest = input + let mut secret_digest: BytesDigest = input .secret .try_into() .map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?; @@ -205,6 +234,8 @@ pub fn generate_proof( // Verify the wormhole address matches what we computed from the secret if *unspendable_bytes != input.wormhole_address { + zeroize_bytes_digest(&mut secret_digest); + zeroize_input_secret(input); return Err(WormholeLibError::from( "Wormhole address doesn't match the computed unspendable account from secret" .to_string(), @@ -243,6 +274,7 @@ pub fn generate_proof( zk_merkle_siblings: input.zk_merkle_siblings.clone(), zk_merkle_positions: input.zk_merkle_positions.clone(), }; + zeroize_bytes_digest(&mut secret_digest); let public = PublicCircuitInputs { asset_id: input.asset_id, @@ -268,22 +300,30 @@ pub fn generate_proof( block_number: input.block_number, }; - let circuit_inputs = CircuitInputs { public, private }; + let mut circuit_inputs = CircuitInputs { public, private }; // Leaf prover is built from the canonical circuit config (no longer loads prover.bin). // Paths are kept for API compatibility with callers that still pass bin locations. let _ = (prover_bin_path, common_bin_path); let prover = qp_wormhole_prover::build_fresh(); - let prover_with_inputs = prover - .commit(&circuit_inputs) - .map_err(|e| WormholeLibError::from(format!("Failed to commit inputs: {}", e)))?; + let result = (|| -> Result { + let prover_with_inputs = prover + .commit(&circuit_inputs) + .map_err(|e| WormholeLibError::from(format!("Failed to commit inputs: {}", e)))?; + + let proof = prover_with_inputs + .prove() + .map_err(|e| WormholeLibError::from(format!("Proof generation failed: {}", e)))?; - let proof = prover_with_inputs - .prove() - .map_err(|e| WormholeLibError::from(format!("Proof generation failed: {}", e)))?; + Ok(ProofGenerationOutput { proof_bytes: proof.to_bytes(), nullifier: *nullifier_bytes }) + })(); - Ok(ProofGenerationOutput { proof_bytes: proof.to_bytes(), nullifier: *nullifier_bytes }) + zeroize_bytes_digest(&mut circuit_inputs.private.secret); + zeroize_bytes(&mut digest_padded); + zeroize_input_secret(input); + + result } #[cfg(test)] @@ -322,4 +362,55 @@ mod tests { let address2 = compute_wormhole_address(&secret).unwrap(); assert_eq!(address, address2); } + + fn decode_32(hex_str: &str) -> [u8; 32] { + let bytes = hex::decode(hex_str).expect("valid hex fixture"); + bytes.try_into().expect("fixture is 32 bytes") + } + + /// #160105: generate_proof must clear the caller-owned secret after use. + #[test] + fn secret_is_zeroized_after_successful_wormhole_proof_generation() { + let secret = decode_32("4c8587bd422e01d961acdc75e7d66f6761b7af7c9b1864a492f369c9d6724f05"); + let transfer_count = 4u64; + let wormhole_address = compute_wormhole_address(&secret).expect("secret derives address"); + + let input = ProofGenerationInput { + secret, + transfer_count, + wormhole_address, + input_amount: 100, + block_hash: [0u8; 32], + block_number: 0, + parent_hash: [0u8; 32], + state_root: decode_32("ae6e4ff0dca1ef5ede9dccc84365cecfab4e431c6f3086216bc3b819cdf0a893"), + extrinsics_root: [0u8; 32], + digest: vec![ + 8, 6, 112, 111, 119, 95, 128, 233, 182, 183, 107, 158, 1, 115, 19, 219, 126, 253, 86, + 30, 208, 176, 70, 21, 45, 180, 229, 9, 62, 91, 4, 6, 53, 245, 52, 48, 38, 123, 225, + 5, 112, 111, 119, 95, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 79, 226, + ], + zk_tree_root: [0u8; 32], + zk_merkle_siblings: vec![], + zk_merkle_positions: vec![], + exit_account_1: [0u8; 32], + exit_account_2: [0u8; 32], + output_amount_1: 0, + output_amount_2: 0, + volume_fee_bps: VOLUME_FEE_BPS, + asset_id: NATIVE_ASSET_ID, + }; + + let output = + generate_proof(&input, Path::new("ignored-prover.bin"), Path::new("ignored-common.bin")) + .expect("real wormhole proof generation succeeds"); + + assert!(!output.proof_bytes.is_empty(), "the real prover produced a proof"); + assert_eq!( + input.secret, [0u8; 32], + "generate_proof must zeroize the caller-owned secret before returning" + ); + } } From 8cd2d4c6254d2e9d7157368c6a1f082acdf44447 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:08:42 +0800 Subject: [PATCH 29/39] fix(wormhole): validate Merkle depth and prefer finalized snapshots Reject oversized/mismatched ZK Merkle proofs and read recursive flow state from finalized blocks instead of best-block tips. Co-authored-by: Cursor --- src/cli/wormhole.rs | 221 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 193 insertions(+), 28 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 59d6c7a..b390940 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -52,37 +52,82 @@ pub type Hash256 = [u8; 32]; /// /// This is the client-side representation of the proof returned by `zkTree_getMerkleProof`. /// Siblings are unsorted - the client computes position hints by sorting siblings + current hash. -#[derive(Debug, Clone, serde::Deserialize)] +#[derive(Debug, Clone)] #[allow(dead_code)] // Fields used for deserialization and future use when ZK trie is deployed pub struct ZkMerkleProofRpc { /// Index of the leaf pub leaf_index: u64, /// The leaf data (SCALE-encoded ZkLeaf) - #[serde(with = "byte_array")] pub leaf_data: Vec, /// Leaf hash - #[serde(with = "hash_array")] pub leaf_hash: Hash256, /// Sibling hashes at each level (3 siblings per level for 4-ary tree). /// These are unsorted - client sorts and computes positions. - #[serde(with = "siblings_format")] pub siblings: Vec<[Hash256; 3]>, /// Current tree root - #[serde(with = "hash_array")] pub root: Hash256, /// Current tree depth pub depth: u8, } +impl<'de> serde::Deserialize<'de> for ZkMerkleProofRpc { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + struct RawZkMerkleProofRpc { + leaf_index: u64, + #[serde(with = "byte_array")] + leaf_data: Vec, + #[serde(with = "hash_array")] + leaf_hash: Hash256, + #[serde(with = "siblings_format")] + siblings: Vec<[Hash256; 3]>, + #[serde(with = "hash_array")] + root: Hash256, + depth: u8, + } + + let raw = ::deserialize(deserializer)?; + if raw.depth as usize != raw.siblings.len() { + return Err(serde::de::Error::custom(format!( + "depth {} does not match siblings length {}", + raw.depth, + raw.siblings.len() + ))); + } + + Ok(Self { + leaf_index: raw.leaf_index, + leaf_data: raw.leaf_data, + leaf_hash: raw.leaf_hash, + siblings: raw.siblings, + root: raw.root, + depth: raw.depth, + }) + } +} + /// Helper module for deserializing byte arrays (chain sends as array of numbers) mod byte_array { use serde::{Deserialize, Deserializer}; + const ZK_LEAF_DATA_LEN: usize = 60; + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, { - Vec::::deserialize(deserializer) + let bytes = Vec::::deserialize(deserializer)?; + if bytes.len() != ZK_LEAF_DATA_LEN { + return Err(serde::de::Error::custom(format!( + "expected {} bytes, got {}", + ZK_LEAF_DATA_LEN, + bytes.len() + ))); + } + Ok(bytes) } } @@ -103,6 +148,7 @@ mod hash_array { /// Helper module for deserializing siblings array (chain sends as array of arrays of numbers) mod siblings_format { + use qp_zk_circuits_common::zk_merkle::MAX_DEPTH; use serde::{Deserialize, Deserializer}; pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> @@ -111,6 +157,13 @@ mod siblings_format { { // Chain sends: Vec<[[u8; 32]; 3]> serialized as array of arrays of arrays of numbers let levels: Vec>> = Deserialize::deserialize(deserializer)?; + if levels.len() > MAX_DEPTH { + return Err(serde::de::Error::custom(format!( + "proof depth {} exceeds max {}", + levels.len(), + MAX_DEPTH + ))); + } levels .into_iter() .map(|level| { @@ -1179,6 +1232,31 @@ fn show_wormhole_address(secret_file: String) -> crate::error::Result<()> { Ok(()) } +/// Fetch the latest finalized block as a fully materialised subxt `Block`. +/// +/// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest +/// of the SDK surface. Network/decoding failures are wrapped in +/// [`crate::error::QuantusError::NetworkError`]. +pub async fn at_finalized_block( + quantus_client: &QuantusClient, +) -> crate::error::Result>> { + let finalized_block: subxt::utils::H256 = quantus_client + .rpc_client() + .request("chain_getFinalizedHead", rpc_params![]) + .await + .map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to fetch finalized block hash: {e:?}" + )) + })?; + let block = quantus_client.client().blocks().at(finalized_block).await.map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to fetch finalized block {finalized_block:?}: {e:?}" + )) + })?; + Ok(block) +} + /// Fetch the latest (best) block as a fully materialised subxt `Block`. /// /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest @@ -1306,7 +1384,7 @@ pub async fn aggregate_proofs( // De-quantize to show actual amount that will be minted let dequantized_amount = (account_data.summed_output_amount as u128) * SCALE_DOWN_FACTOR; - let ss58_address = slice_to_quantus_ss58(exit_bytes); + let ss58_address = slice_to_quantus_ss58(exit_bytes)?; log_print!( " [{}] {} -> {} quantized ({} planck = {})", idx, @@ -1377,7 +1455,10 @@ pub async fn aggregate_public_batch( e )) })?; - log_print!(" Aggregator (fee rebate recipient): {}", slice_to_quantus_ss58(&aggregator_bytes)); + log_print!( + " Aggregator (fee rebate recipient): {}", + slice_to_quantus_ss58(&aggregator_bytes)? + ); let bins_dir = crate::bins::ensure_bins_dir()?; let agg_config = CircuitBinsConfig::load(&bins_dir).map_err(|e| { @@ -1488,7 +1569,7 @@ pub async fn aggregate_public_batch( log_print!( " [{}] {} -> {}", idx, - slice_to_quantus_ss58(exit_bytes), + slice_to_quantus_ss58(exit_bytes)?, format_balance(dequantized_amount) ); } @@ -2036,12 +2117,12 @@ fn load_multiround_wallet( ) -> crate::error::Result { let wallet_manager = WalletManager::new()?; let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?; - let wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; + let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; let wallet_address = wallet_data.keypair.to_account_id_ss58check(); let wallet_account_id = SubxtAccountId(wallet_data.keypair.to_account_id_32().into()); // Require a persisted mnemonic for deterministic wormhole HD derivation. - let mnemonic = wallet_data.mnemonic.ok_or_else(|| { + let mnemonic = wallet_data.take_mnemonic().ok_or_else(|| { crate::error::QuantusError::Generic( "Wallet does not contain a mnemonic. Use a wallet created from a mnemonic, or supply --mnemonic/--secret-file where supported.".to_string(), ) @@ -2052,7 +2133,7 @@ fn load_multiround_wallet( wallet_name: wallet_name.to_string(), wallet_address, wallet_account_id, - keypair: wallet_data.keypair, + keypair: wallet_data.take_keypair(), mnemonic, }) } @@ -2146,16 +2227,21 @@ async fn execute_initial_transfers( // The transfer_count used in the proof is the count at the time of transfer, // which equals the count before the transfer (since it increments after). let client = quantus_client.client(); + let finalized_block_hash = at_finalized_block(quantus_client) + .await + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to get finalized block for transfer counts: {}", + e + )) + })? + .hash(); let mut transfer_counts_before: Vec = Vec::with_capacity(num_proofs); for secret in secrets.iter() { let wormhole_address = SubxtAccountId(secret.address); let count = client .storage() - .at_latest() - .await - .map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to get storage: {}", e)) - })? + .at(finalized_block_hash) .fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address)) .await .map_err(|e| { @@ -2179,8 +2265,8 @@ async fn execute_initial_transfers( .await .map_err(|e| crate::error::QuantusError::Generic(format!("Batch transfer failed: {}", e)))?; - // Inclusion waited for finalization; read events from the current best tip. - let block = at_best_block(quantus_client) + // Inclusion waited for finalization; read events from the finalized tip. + let block = at_finalized_block(quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; let block_hash = block.hash(); @@ -2232,8 +2318,8 @@ async fn generate_round_proofs( log_print!("{}", "Step 2: Generating proofs...".bright_yellow()); - // All proofs in an aggregation batch must use the same block for storage proofs. - let proof_block = at_best_block(quantus_client) + // All proofs in an aggregation batch must use the same finalized block for storage proofs. + let proof_block = at_finalized_block(quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; let proof_block_hash = proof_block.hash(); @@ -3208,7 +3294,7 @@ async fn parse_proof_file( log_print!( "Aggregator: 0x{} ({})", hex::encode(inputs.aggregator_address.as_ref()), - slice_to_quantus_ss58(inputs.aggregator_address.as_ref()) + slice_to_quantus_ss58(inputs.aggregator_address.as_ref())? ); log_print!("Asset ID: {}", inputs.asset_id); log_print!("Volume Fee BPS: {}", inputs.volume_fee_bps); @@ -3474,12 +3560,19 @@ async fn run_dissolve( let initial_secret = derive_wormhole_secret(&wallet.mnemonic, 0, 1)?; let wormhole_address = SubxtAccountId(initial_secret.address); + let finalized_block_hash = at_finalized_block(&quantus_client) + .await + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to get finalized block for dissolve transfer count: {}", + e + )) + })? + .hash(); let transfer_count_before = quantus_client .client() .storage() - .at_latest() - .await - .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get storage: {}", e)))? + .at(finalized_block_hash) .fetch( &quantus_node::api::storage() .wormhole() @@ -3515,7 +3608,7 @@ async fn run_dissolve( .await .map_err(|e| crate::error::QuantusError::Generic(format!("Initial transfer failed: {}", e)))?; - let block = at_best_block(&quantus_client) + let block = at_finalized_block(&quantus_client) .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)))?; let block_hash = block.hash(); @@ -4000,9 +4093,9 @@ async fn run_check_nullifier( // Load wallet and derive wormhole secret let wallet_manager = WalletManager::new()?; let wallet_password = password::get_wallet_password(&wallet, password, password_file)?; - let wallet_data = wallet_manager.load_wallet(&wallet, &wallet_password)?; + let mut wallet_data = wallet_manager.load_wallet(&wallet, &wallet_password)?; - let mnemonic = wallet_data.mnemonic.ok_or_else(|| { + let mnemonic = wallet_data.take_mnemonic().ok_or_else(|| { crate::error::QuantusError::Generic( "Wallet does not contain a mnemonic. Use --secret-file instead.".to_string(), ) @@ -4121,9 +4214,81 @@ async fn run_check_nullifier( #[cfg(test)] mod tests { use super::*; + use qp_zk_circuits_common::zk_merkle::MAX_DEPTH; + use serde_json::json; use std::collections::HashSet; use tempfile::NamedTempFile; + fn hash_bytes(seed: u16) -> Vec { + let mut out = vec![0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = seed.wrapping_add(i as u16) as u8; + } + out + } + + /// #160110: oversized/mismatched Merkle proof RPC payloads must fail deserialization. + #[test] + fn malicious_zk_merkle_rpc_rejects_oversized_mismatched_depth() { + let sibling_levels: Vec<_> = (0..=u8::MAX as u16) + .map(|level| { + vec![ + hash_bytes(level.wrapping_mul(3)), + hash_bytes(level.wrapping_mul(3).wrapping_add(1)), + hash_bytes(level.wrapping_mul(3).wrapping_add(2)), + ] + }) + .collect(); + + let malicious_rpc_response = json!({ + "leaf_index": 7_u64, + "leaf_data": [42_u8], + "leaf_hash": hash_bytes(900), + "siblings": sibling_levels, + "root": hash_bytes(901), + "depth": 1_u8 + }); + + let err = serde_json::from_value::(malicious_rpc_response) + .expect_err("oversized mismatched Merkle proof must be rejected"); + let message = err.to_string(); + assert!( + message.contains("exceeds max") + || message.contains("expected 60 bytes") + || message.contains("does not match siblings length"), + "unexpected rejection reason: {message}" + ); + assert!( + MAX_DEPTH < u8::MAX as usize, + "test assumes circuit MAX_DEPTH is below attacker-supplied depth" + ); + } + + #[test] + fn zk_merkle_rpc_rejects_depth_sibling_mismatch() { + let siblings = vec![vec![hash_bytes(1), hash_bytes(2), hash_bytes(3)]]; + let response = json!({ + "leaf_index": 1_u64, + "leaf_data": vec![0_u8; 60], + "leaf_hash": hash_bytes(10), + "siblings": siblings, + "root": hash_bytes(11), + "depth": 2_u8 + }); + let err = serde_json::from_value::(response) + .expect_err("depth must match siblings length"); + assert!(err.to_string().contains("does not match siblings length")); + } + + #[test] + fn recursive_flows_prefer_finalized_inclusion() { + // Unsigned verify paths return only Finalized; Best remains for API + // compatibility but recursive snapshot/proof code uses at_finalized_block. + assert_eq!(IncludedAt::Finalized.label(), "finalized block"); + assert_ne!(IncludedAt::Best.label(), IncludedAt::Finalized.label()); + let _: *const () = at_finalized_block as *const (); + } + #[test] fn test_compute_output_amount() { // 0.1% fee (10 bps): output = input * 9990 / 10000 From 06997c2bcc8457d9cec0e99d5fa7a16515687d4b Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:08:42 +0800 Subject: [PATCH 30/39] fix(wallet): zeroize secret material after encrypt and decrypt Key material and plaintext buffers were retained after use. Clear them on Drop and after crypto operations; redact Debug output. Co-authored-by: Cursor --- src/wallet/keystore.rs | 183 +++++++++++++++++++++++++++++++++++------ src/wallet/mod.rs | 25 ++++-- src/wallet/password.rs | 6 +- 3 files changed, 180 insertions(+), 34 deletions(-) diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 979f371..4b9a8c0 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -25,6 +25,7 @@ use rand::{rng, RngCore}; use std::{ collections::HashSet, + fmt, fs::{self, File, OpenOptions}, io::{ErrorKind, Read, Write}, path::{Path, PathBuf}, @@ -34,6 +35,17 @@ use std::{ use qp_dilithium_crypto::types::{DilithiumPair, DilithiumPublic}; use sp_runtime::traits::IdentifyAccount; +pub(crate) fn zeroize_bytes(bytes: &mut [u8]) { + for byte in bytes { + unsafe { std::ptr::write_volatile(byte, 0) }; + } + std::sync::atomic::compiler_fence(std::sync::atomic::Ordering::SeqCst); +} + +pub(crate) fn zeroize_string(value: &mut String) { + unsafe { zeroize_bytes(value.as_mut_vec()) }; +} + fn keystore_lock() -> &'static Mutex<()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) @@ -168,12 +180,27 @@ impl Drop for WalletCreateGuard { } /// Quantum-safe key pair using Dilithium post-quantum signatures -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] pub struct QuantumKeyPair { pub public_key: Vec, pub private_key: Vec, } +impl fmt::Debug for QuantumKeyPair { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("QuantumKeyPair") + .field("public_key_len", &self.public_key.len()) + .field("private_key", &"[redacted]") + .finish() + } +} + +impl Drop for QuantumKeyPair { + fn drop(&mut self) { + zeroize_bytes(&mut self.private_key); + } +} + impl QuantumKeyPair { /// Create from rusty-crystals Keypair pub fn from_dilithium_keypair(keypair: &Keypair) -> Self { @@ -186,12 +213,11 @@ impl QuantumKeyPair { /// Convert to rusty-crystals Keypair #[allow(dead_code)] pub fn to_dilithium_keypair(&self) -> Result { - // TODO: Implement conversion from bytes back to Keypair - // For now, generate a new one as placeholder - // This function should properly reconstruct the Keypair from stored bytes Ok(Keypair { - public: PublicKey::from_bytes(&self.public_key).expect("Failed to parse public key"), - secret: SecretKey::from_bytes(&self.private_key).expect("Failed to parse private key"), + public: PublicKey::from_bytes(&self.public_key) + .map_err(|_| crate::error::WalletError::KeyGeneration)?, + secret: SecretKey::from_bytes(&self.private_key) + .map_err(|_| crate::error::WalletError::KeyGeneration)?, }) } @@ -240,11 +266,10 @@ impl QuantumKeyPair { } #[allow(dead_code)] - pub fn ss58_to_account_id(s: &str) -> Vec { - // from_ss58check returns a Result, we unwrap it to panic on invalid input. - // We then convert the AccountId32 struct to a Vec to be compatible with Polkadart's - // typedef. - AsRef::<[u8]>::as_ref(&AccountId32::from_ss58check_with_version(s).unwrap().0).to_vec() + pub fn ss58_to_account_id(s: &str) -> Result> { + let account = AccountId32::from_ss58check_with_version(s) + .map_err(|_| crate::error::WalletError::KeyGeneration)?; + Ok(AsRef::<[u8]>::as_ref(&account.0).to_vec()) } } @@ -266,7 +291,7 @@ pub struct EncryptedWallet { } /// Wallet data structure (before encryption) -#[derive(Debug, Serialize, Deserialize)] +#[derive(Serialize, Deserialize)] pub struct WalletData { pub name: String, pub keypair: QuantumKeyPair, @@ -275,6 +300,41 @@ pub struct WalletData { pub metadata: std::collections::HashMap, } +impl fmt::Debug for WalletData { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WalletData") + .field("name", &self.name) + .field("keypair", &self.keypair) + .field("mnemonic", &self.mnemonic.as_ref().map(|_| "[redacted]")) + .field("derivation_path", &self.derivation_path) + .field("metadata", &self.metadata) + .finish() + } +} + +impl Drop for WalletData { + fn drop(&mut self) { + if let Some(mnemonic) = &mut self.mnemonic { + zeroize_string(mnemonic); + } + } +} + +impl WalletData { + /// Take the keypair out without moving other fields (compatible with `Drop`). + pub fn take_keypair(&mut self) -> QuantumKeyPair { + std::mem::replace( + &mut self.keypair, + QuantumKeyPair { public_key: Vec::new(), private_key: Vec::new() }, + ) + } + + /// Take the mnemonic out without moving other fields (compatible with `Drop`). + pub fn take_mnemonic(&mut self) -> Option { + self.mnemonic.take() + } +} + /// Keystore manager for handling encrypted wallet storage pub struct Keystore { storage_path: std::path::PathBuf, @@ -498,15 +558,18 @@ impl Keystore { // 3. Use password hash as AES-256 key (quantum-safe with 256-bit key) let hash_bytes = password_hash.hash.as_ref().unwrap().as_bytes(); - let aes_key = Key::::from(<[u8; 32]>::try_from(&hash_bytes[..32]).unwrap()); + let mut key_bytes = <[u8; 32]>::try_from(&hash_bytes[..32]).unwrap(); + let aes_key = Key::::from(key_bytes); + zeroize_bytes(&mut key_bytes); let cipher = Aes256Gcm::new(&aes_key); // 4. Generate nonce and encrypt the wallet data let nonce = Aes256Gcm::generate_nonce(&mut AesOsRng); - let serialized_data = serde_json::to_vec(data)?; + let mut serialized_data = serde_json::to_vec(data)?; let encrypted_data = cipher .encrypt(&nonce, serialized_data.as_ref()) .map_err(|e| WalletError::Encryption(e.to_string()))?; + zeroize_bytes(&mut serialized_data); // 5. Store the Argon2 parameters WITHOUT the digest. The digest determines // the AES key, so persisting it next to the ciphertext would let anyone @@ -552,12 +615,14 @@ impl Keystore { let nonce_bytes = <[u8; 12]>::try_from(&encrypted.aes_nonce[..]) .map_err(|_| WalletError::Decryption)?; let nonce = Nonce::from(nonce_bytes); - let decrypted_data = cipher + let mut decrypted_data = cipher .decrypt(&nonce, encrypted.encrypted_data.as_ref()) .map_err(|_| WalletError::InvalidPassword)?; - // 3. Deserialize the wallet data - let wallet_data: WalletData = serde_json::from_slice(&decrypted_data)?; + // 3. Deserialize the wallet data, then clear the plaintext buffer. + let wallet_data_result = serde_json::from_slice::(&decrypted_data); + zeroize_bytes(&mut decrypted_data); + let wallet_data: WalletData = wallet_data_result?; // 4. The plaintext envelope address is not AEAD-authenticated, so it must // match the address derived from the decrypted key material before the @@ -612,7 +677,9 @@ impl Keystore { .hash_password_into(password.as_bytes(), &encrypted.argon2_salt, &mut key) .map_err(|_| WalletError::Decryption)?; - Ok(Key::::from(key)) + let aes_key = Key::::from(key); + zeroize_bytes(&mut key); + Ok(aes_key) } /// Returns true if the wallet embeds the Argon2 digest in `argon2_params` @@ -660,6 +727,47 @@ mod tests { use sp_core::Pair; use tempfile::TempDir; + #[test] + fn quantum_keypair_debug_redacts_private_key() { + let keypair = QuantumKeyPair { + public_key: vec![1, 2, 3], + private_key: vec![0xde, 0xad, 0xbe, 0xef], + }; + let rendered = format!("{keypair:?}"); + assert!( + rendered.contains("[redacted]"), + "private key must be redacted in Debug output, got: {rendered}" + ); + assert!( + !rendered.contains("dead") && !rendered.contains("beef") && !rendered.contains("222"), + "Debug must not leak private key bytes: {rendered}" + ); + } + + #[test] + fn wallet_data_debug_redacts_mnemonic() { + let data = WalletData { + name: "test".to_string(), + keypair: QuantumKeyPair { public_key: vec![1], private_key: vec![2] }, + mnemonic: Some("abandon ability able about above absent".to_string()), + derivation_path: "m/".to_string(), + metadata: Default::default(), + }; + let rendered = format!("{data:?}"); + assert!(rendered.contains("[redacted]"), "mnemonic must be redacted: {rendered}"); + assert!( + !rendered.contains("abandon"), + "Debug must not leak mnemonic words: {rendered}" + ); + } + + #[test] + fn zeroize_bytes_clears_buffer() { + let mut secret = vec![1u8, 2, 3, 4, 5]; + zeroize_bytes(&mut secret); + assert!(secret.iter().all(|&b| b == 0)); + } + #[test] fn test_quantum_keypair_from_dilithium_keypair() { // Generate a test keypair @@ -786,7 +894,8 @@ mod tests { for ss58_address in test_cases { // Convert SS58 to account ID bytes - let account_bytes = QuantumKeyPair::ss58_to_account_id(&ss58_address); + let account_bytes = + QuantumKeyPair::ss58_to_account_id(&ss58_address).expect("valid SS58"); // Verify length (AccountId32 should be 32 bytes) assert_eq!(account_bytes.len(), 32, "Account ID should be 32 bytes"); @@ -863,7 +972,7 @@ mod tests { #[test] fn test_invalid_ss58_address_handling() { - // Test with invalid SS58 addresses + // #160783: invalid SS58 must return Err, not panic. let invalid_addresses = vec![ "invalid", "5", // Too short @@ -872,12 +981,40 @@ mod tests { ]; for invalid_addr in invalid_addresses { - let result = - std::panic::catch_unwind(|| QuantumKeyPair::ss58_to_account_id(invalid_addr)); - assert!(result.is_err(), "Should panic on invalid address: {invalid_addr}"); + let panicked = std::panic::catch_unwind(|| { + QuantumKeyPair::ss58_to_account_id(invalid_addr) + }); + assert!(panicked.is_ok(), "Must not panic on invalid address: {invalid_addr}"); + assert!( + matches!( + panicked.unwrap(), + Err(crate::error::QuantusError::Wallet(WalletError::KeyGeneration)) + ), + "Should return KeyGeneration for invalid address: {invalid_addr}" + ); } } + #[test] + fn to_dilithium_keypair_rejects_malformed_key_bytes() { + // #160783: malformed key material must not panic. + let keypair = QuantumKeyPair { + public_key: vec![1, 2, 3], + private_key: vec![4, 5, 6], + }; + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + keypair.to_dilithium_keypair() + })); + assert!(panicked.is_ok(), "malformed keys must not panic"); + assert!( + matches!( + panicked.unwrap(), + Err(crate::error::QuantusError::Wallet(WalletError::KeyGeneration)) + ), + "expected KeyGeneration error" + ); + } + #[test] fn test_stored_wallet_address_generation() { sp_core::crypto::set_default_ss58_version(sp_core::crypto::Ss58AddressFormat::custom(189)); diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 745034b..33f0a9f 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -90,6 +90,7 @@ impl WalletManager { rng().fill_bytes(&mut seed); let sensitive_seed = SensitiveBytes32::from(&mut seed); let mnemonic = generate_mnemonic(sensitive_seed).map_err(|_| WalletError::KeyGeneration)?; + keystore::zeroize_bytes(&mut seed); let dilithium_keypair = derive_key_from_mnemonic(&mnemonic, None, derivation_path) .map_err(|_| WalletError::KeyGeneration)?; let quantum_keypair = QuantumKeyPair::from_dilithium_keypair(&dilithium_keypair); @@ -176,7 +177,12 @@ impl WalletManager { let wallet_data = self.load_wallet(name, &final_password)?; - wallet_data.mnemonic.ok_or_else(|| WalletError::MnemonicNotAvailable.into()) + // Clone before Drop zeroizes the in-memory mnemonic on wallet_data drop. + wallet_data + .mnemonic + .as_ref() + .cloned() + .ok_or_else(|| WalletError::MnemonicNotAvailable.into()) } /// List all wallets @@ -198,7 +204,7 @@ impl WalletManager { // envelope address for password-protected wallets. let wallet_info = match keystore.decrypt_wallet_data(&encrypted_wallet, "") { Ok(wallet_data) => WalletInfo { - name: wallet_data.name, + name: wallet_data.name.clone(), address: wallet_data.keypair.try_to_account_id_ss58check()?, created_at: encrypted_wallet.created_at, key_type: "Dilithium ML-DSA-87".to_string(), @@ -250,6 +256,7 @@ impl WalletManager { rng().fill_bytes(&mut seed); let sensitive_seed = SensitiveBytes32::from(&mut seed); let mnemonic = generate_mnemonic(sensitive_seed).map_err(|_| WalletError::KeyGeneration)?; + keystore::zeroize_bytes(&mut seed); let seed64 = mnemonic_to_seed(mnemonic.clone(), None).map_err(|_| WalletError::KeyGeneration)?; let dilithium_pair = @@ -465,11 +472,11 @@ impl WalletManager { Ok(wallet_data) => { let address = wallet_data.keypair.try_to_account_id_ss58check()?; Ok(Some(WalletInfo { - name: wallet_data.name, + name: wallet_data.name.clone(), address, created_at: encrypted_wallet.created_at, key_type: "Dilithium ML-DSA-87".to_string(), - derivation_path: wallet_data.derivation_path, + derivation_path: wallet_data.derivation_path.clone(), })) }, Err(crate::error::QuantusError::Wallet(WalletError::InvalidPassword)) => { @@ -489,7 +496,7 @@ impl WalletManager { Ok(wallet_data) => { let address = wallet_data.keypair.try_to_account_id_ss58check()?; Ok(Some(WalletInfo { - name: wallet_data.name, + name: wallet_data.name.clone(), address, created_at: encrypted_wallet.created_at, key_type: "Dilithium ML-DSA-87".to_string(), @@ -574,9 +581,8 @@ pub fn load_keypair_from_wallet( ) -> Result { let wallet_manager = WalletManager::new()?; let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?; - let wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; - let keypair = wallet_data.keypair; - Ok(keypair) + let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; + Ok(wallet_data.take_keypair()) } #[cfg(test)] @@ -784,7 +790,8 @@ mod tests { assert!(ss58_address.len() >= 47, "SS58 address should be at least 47 characters"); // Test round-trip conversion - let converted_account_bytes = keystore::QuantumKeyPair::ss58_to_account_id(&ss58_address); + let converted_account_bytes = keystore::QuantumKeyPair::ss58_to_account_id(&ss58_address) + .expect("valid SS58 should decode"); let account_bytes: &[u8] = account_id.as_ref(); assert_eq!(converted_account_bytes, account_bytes); } diff --git a/src/wallet/password.rs b/src/wallet/password.rs index 4ba2e7e..ab1443a 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -104,10 +104,12 @@ pub fn get_wallet_password( /// Get mnemonic phrase from user pub fn get_mnemonic_from_user() -> Result { log_print!("{}", "Please enter or paste your secret phrase:".bright_yellow()); - let mnemonic = rpassword::read_password().map_err(|e| { + let mut mnemonic = rpassword::read_password().map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to read secret phrase: {e}")) })?; - Ok(mnemonic.trim().to_string()) + let trimmed = mnemonic.trim().to_string(); + crate::wallet::keystore::zeroize_string(&mut mnemonic); + Ok(trimmed) } /// Get password from user securely From fdaee0c67a2a73618e92658676139fab1e0c2d16 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:08:42 +0800 Subject: [PATCH 31/39] fix(cli): validate amounts delays ranks and fallible address helpers Harden decimal formatting, transfer display, collective remove rank, delay encoding, and public helpers that previously panicked on bad input. Co-authored-by: Cursor --- src/chain/client.rs | 7 +-- src/cli/address_format.rs | 25 +++++++-- src/cli/common.rs | 111 +++++++++++++++++++++++++++++++++++-- src/cli/generic_call.rs | 18 ++++-- src/cli/high_security.rs | 8 +-- src/cli/mod.rs | 2 +- src/cli/multisig.rs | 4 +- src/cli/reversible.rs | 12 ++-- src/cli/send.rs | 20 ++++++- src/cli/tech_collective.rs | 60 +++++++++++++++++++- src/cli/transfers.rs | 99 ++++++++++++++++++++++++++++++--- src/lib.rs | 4 +- 12 files changed, 326 insertions(+), 44 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index fcf7d3f..2f21f23 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -6,7 +6,7 @@ use crate::{error::QuantusError, log_verbose}; use jsonrpsee::ws_client::{WsClient, WsClientBuilder}; use qp_dilithium_crypto::types::DilithiumSignatureScheme; -use sp_core::{crypto::AccountId32, ByteArray}; +use sp_core::crypto::AccountId32; use sp_runtime::{traits::IdentifyAccount, MultiAddress}; use std::{sync::Arc, time::Duration}; use subxt::{ @@ -299,11 +299,8 @@ impl QuantusClient { impl subxt::tx::Signer for qp_dilithium_crypto::types::DilithiumPair { fn account_id(&self) -> ::AccountId { use sp_core::Pair; - let resonance_public = - qp_dilithium_crypto::types::DilithiumPublic::from_slice(self.public().as_slice()) - .expect("Invalid public key"); ::into_account( - resonance_public, + self.public(), ) } diff --git a/src/cli/address_format.rs b/src/cli/address_format.rs index 99dde5e..e552fce 100644 --- a/src/cli/address_format.rs +++ b/src/cli/address_format.rs @@ -2,6 +2,7 @@ /// /// This module provides unified functions for formatting addresses in the Quantus /// SS58 format (version 189). +use crate::error::{QuantusError, Result}; use sp_core::crypto::{Ss58AddressFormat, Ss58Codec}; /// Returns the Quantus SS58 address format (version 189) @@ -36,8 +37,24 @@ pub fn bytes_to_quantus_ss58(bytes: &[u8; 32]) -> String { sp_account_id.to_ss58check_with_version(quantus_ss58_format()) } -/// Convert a byte slice to Quantus SS58 format (panics if not 32 bytes) -pub fn slice_to_quantus_ss58(bytes: &[u8]) -> String { - let arr: [u8; 32] = bytes.try_into().expect("account must be 32 bytes"); - bytes_to_quantus_ss58(&arr) +/// Convert a byte slice to Quantus SS58 format. +pub fn slice_to_quantus_ss58(bytes: &[u8]) -> Result { + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| QuantusError::Generic("account must be 32 bytes".to_string()))?; + Ok(bytes_to_quantus_ss58(&arr)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slice_to_quantus_ss58_rejects_non_32_byte_input() { + // #160783: malformed address buffers must not panic. + let err = slice_to_quantus_ss58(&[0u8; 16]).expect_err("short slice must error"); + assert!(err.to_string().contains("32 bytes"), "unexpected error: {err}"); + let ok = slice_to_quantus_ss58(&[0u8; 32]).expect("32-byte slice must succeed"); + assert!(ok.starts_with("qz")); + } } diff --git a/src/cli/common.rs b/src/cli/common.rs index 018299f..fb4282b 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -10,6 +10,7 @@ use subxt::{ pub type SubxtAccountId32 = subxt::ext::subxt_core::utils::AccountId32; +const MILLIS_PER_SECOND: u64 = 1_000; const TX_STATUS_INACTIVITY_TIMEOUT_SECS: u64 = 30; const TX_STATUS_INCLUDED_TIMEOUT_SECS: u64 = 5 * 60; const TX_STATUS_FINALIZED_TIMEOUT_SECS: u64 = 30 * 60; @@ -61,6 +62,24 @@ impl TransactionStage { } } +pub(crate) fn delay_blocks_to_u32(blocks: u64) -> Result { + u32::try_from(blocks).map_err(|_| { + crate::error::QuantusError::Generic(format!( + "Delay in blocks ({blocks}) exceeds the maximum supported block delay ({})", + u32::MAX + )) + }) +} + +pub(crate) fn delay_seconds_to_millis(seconds: u64) -> Result { + seconds.checked_mul(MILLIS_PER_SECOND).ok_or_else(|| { + crate::error::QuantusError::Generic(format!( + "Delay in seconds ({seconds}) exceeds the maximum supported timestamp delay ({})", + u64::MAX / MILLIS_PER_SECOND + )) + }) +} + fn tx_status_watch_timeout_secs(target_stage: TransactionStage) -> u64 { match target_stage { TransactionStage::Submitted => 0, @@ -791,6 +810,45 @@ pub(crate) fn format_dispatch_error( } } +async fn verify_preimage_on_chain( + quantus_client: &crate::chain::client::QuantusClient, + expected_preimage: &[u8], +) -> Result<()> { + use sp_runtime::traits::{BlakeTwo256, Hash}; + + let preimage_hash: sp_core::H256 = BlakeTwo256::hash(expected_preimage); + let preimage_len = u32::try_from(expected_preimage.len()).map_err(|_| { + crate::error::QuantusError::Generic(format!( + "Preimage is too large to address: {} bytes", + expected_preimage.len() + )) + })?; + let latest_block_hash = quantus_client.get_latest_block().await?; + let storage_at = quantus_client.client().storage().at(latest_block_hash); + let preimage_addr = crate::chain::quantus_subxt::api::storage() + .preimage() + .preimage_for((preimage_hash, preimage_len)); + + match storage_at.fetch(&preimage_addr).await.map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to fetch preimage {:?} ({} bytes): {e:?}", + preimage_hash, preimage_len + )) + })? { + Some(stored_preimage) if stored_preimage.0.as_slice() == expected_preimage => Ok(()), + Some(stored_preimage) => Err(crate::error::QuantusError::Generic(format!( + "On-chain preimage mismatch for {:?}: expected {} bytes, found {} bytes", + preimage_hash, + preimage_len, + stored_preimage.0.len() + ))), + None => Err(crate::error::QuantusError::Generic(format!( + "Expected preimage {:?} ({} bytes) is not present on-chain", + preimage_hash, preimage_len + ))), + } +} + pub async fn submit_preimage( quantus_client: &crate::chain::client::QuantusClient, keypair: &crate::wallet::QuantumKeyPair, @@ -799,7 +857,7 @@ pub async fn submit_preimage( ) -> Result<()> { type PreimageBytes = crate::chain::quantus_subxt::api::preimage::calls::types::note_preimage::Bytes; - let bounded_bytes: PreimageBytes = encoded_call; + let bounded_bytes: PreimageBytes = encoded_call.clone(); crate::log_print!("📝 Submitting preimage..."); let note_preimage_tx = @@ -808,15 +866,22 @@ pub async fn submit_preimage( match submit_transaction(quantus_client, keypair, note_preimage_tx, None, wait_mode).await { Ok(_) => { + verify_preimage_on_chain(quantus_client, &encoded_call).await?; crate::log_success!("Preimage submitted"); }, - Err(e) if e.to_string().contains("AlreadyNoted") => { + Err(e) => { + // Do not trust formatted error substrings (e.g. "AlreadyNoted"). Only + // continue when the expected preimage bytes are present on-chain. + verify_preimage_on_chain(quantus_client, &encoded_call).await.map_err(|verify_err| { + crate::error::QuantusError::Generic(format!( + "Preimage submission failed ({e}); on-chain verification also failed ({verify_err})" + )) + })?; crate::log_print!( - "✅ {} Preimage already exists on-chain, continuing", + "✅ {} Expected preimage already exists on-chain, continuing", "OK".bright_green().bold() ); }, - Err(e) => return Err(e), } Ok(()) } @@ -877,6 +942,28 @@ pub(crate) async fn check_execution_success( mod tests { use super::*; + #[test] + fn delay_blocks_to_u32_rejects_values_above_u32_max() { + let too_large = u32::MAX as u64 + 7200; + let err = delay_blocks_to_u32(too_large).unwrap_err(); + assert!( + err.to_string().contains("exceeds the maximum supported block delay"), + "unexpected error: {err}" + ); + assert_eq!(delay_blocks_to_u32(u32::MAX as u64).unwrap(), u32::MAX); + } + + #[test] + fn delay_seconds_to_millis_rejects_overflow() { + let too_large = (u64::MAX / MILLIS_PER_SECOND) + 1; + let err = delay_seconds_to_millis(too_large).unwrap_err(); + assert!( + err.to_string().contains("exceeds the maximum supported timestamp delay"), + "unexpected error: {err}" + ); + assert_eq!(delay_seconds_to_millis(1).unwrap(), 1_000); + } + #[test] fn finalized_mode_implies_waiting_for_finalization() { let mode = ExecutionMode { finalized: true, wait_for_transaction: false }; @@ -1015,4 +1102,20 @@ mod tests { assert!(!is_retryable_submission_error("")); assert!(!is_retryable_submission_error("some unknown node error")); } + + #[test] + fn submit_preimage_does_not_classify_already_noted_by_substring() { + // #160718: control flow must not branch on the literal "AlreadyNoted" in + // formatted errors; success after a submit failure requires on-chain + // preimage verification instead. + let source = include_str!("common.rs"); + assert!( + !source.contains("contains(\"AlreadyNoted\")"), + "submit_preimage must not accept errors based on AlreadyNoted substrings" + ); + assert!( + source.contains("verify_preimage_on_chain"), + "submit_preimage must verify expected preimage bytes on-chain" + ); + } } diff --git a/src/cli/generic_call.rs b/src/cli/generic_call.rs index 9d718a6..2cef18b 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -254,16 +254,26 @@ async fn submit_tech_collective_remove_member( args: &[Value], execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { - if args.len() != 1 { + if args.len() != 2 { return Err(QuantusError::Generic( - "TechCollective remove_member requires 1 argument: [member_address]".to_string(), + "TechCollective remove_member requires 2 arguments: [member_address, min_rank]" + .to_string(), )); } let member_address = args[0].as_str().ok_or_else(|| { - QuantusError::Generic("Argument must be a string (member_address)".to_string()) + QuantusError::Generic("First argument must be a string (member_address)".to_string()) })?; + let min_rank = args[1] + .as_u64() + .or_else(|| args[1].as_str().and_then(|rank| rank.parse::().ok())) + .ok_or_else(|| { + QuantusError::Generic("Second argument must be a number (min_rank)".to_string()) + })?; + let min_rank = u16::try_from(min_rank) + .map_err(|_| QuantusError::Generic("min_rank must fit in u16".to_string()))?; + let (member_account_id, _) = AccountId32::from_ss58check_with_version(member_address) .map_err(|e| QuantusError::Generic(format!("Invalid member_address: {e:?}")))?; @@ -274,7 +284,7 @@ async fn submit_tech_collective_remove_member( let call = quantus_subxt::api::tx().tech_collective().remove_member( subxt::ext::subxt_core::utils::MultiAddress::Id(member_account_id_subxt), - 0u16, // Default rank + min_rank, ); crate::cli::common::submit_transaction(quantus_client, from_keypair, call, None, execution_mode) diff --git a/src/cli/high_security.rs b/src/cli/high_security.rs index dadded4..0af3442 100644 --- a/src/cli/high_security.rs +++ b/src/cli/high_security.rs @@ -1,6 +1,7 @@ use crate::{ - chain::quantus_subxt, cli::address_format::QuantusSS58, log_error, log_print, log_success, - log_verbose, + chain::quantus_subxt, + cli::{address_format::QuantusSS58, common::delay_seconds_to_millis}, + log_error, log_print, log_success, log_verbose, }; use clap::Subcommand; use colored::Colorize; @@ -147,8 +148,7 @@ pub async fn handle_high_security_command( use quantus_subxt::api::reversible_transfers::calls::types::set_high_security::Delay as HsDelay; let delay_value = match (delay_blocks, delay_seconds) { (Some(blocks), None) => HsDelay::BlockNumber(blocks), - (None, Some(seconds)) => HsDelay::Timestamp(seconds * 1000), /* Convert seconds */ - // to milliseconds + (None, Some(seconds)) => HsDelay::Timestamp(delay_seconds_to_millis(seconds)?), (None, None) => { log_error!("❌ You must specify either --delay-blocks or --delay-seconds"); return Err(crate::error::QuantusError::Generic( diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b989fb8..4078557 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -369,7 +369,7 @@ pub async fn execute_command( 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, + transfers::handle_transfers_command(transfers_cmd, node_url).await, Commands::Runtime(runtime_cmd) => runtime::handle_runtime_command(runtime_cmd, node_url, execution_mode).await, Commands::Call { diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 04a626b..8ad3751 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -1,6 +1,6 @@ use crate::{ chain::quantus_subxt::{self}, - cli::common::ExecutionMode, + cli::common::{delay_seconds_to_millis, ExecutionMode}, log_error, log_print, log_success, log_verbose, }; use clap::Subcommand; @@ -3034,7 +3034,7 @@ async fn handle_high_security_set( let delay_value = if let Some(blocks) = delay_blocks { HsDelay::BlockNumber(blocks) } else if let Some(seconds) = delay_seconds { - HsDelay::Timestamp(seconds * 1000) // Convert seconds to milliseconds + HsDelay::Timestamp(delay_seconds_to_millis(seconds)?) } else { return Err(crate::error::QuantusError::Generic("Missing delay parameter".to_string())); }; diff --git a/src/cli/reversible.rs b/src/cli/reversible.rs index a57f96d..c76514b 100644 --- a/src/cli/reversible.rs +++ b/src/cli/reversible.rs @@ -1,6 +1,9 @@ use crate::{ chain::quantus_subxt, - cli::{address_format::QuantusSS58, common::resolve_address}, + cli::{ + address_format::QuantusSS58, + common::{delay_blocks_to_u32, delay_seconds_to_millis, resolve_address}, + }, error::Result, log_info, log_print, log_verbose, }; @@ -211,10 +214,11 @@ pub async fn schedule_transfer_with_delay( // Convert delay to proper BlockNumberOrTimestamp let delay_value = if unit_blocks { - quantus_subxt::api::reversible_transfers::calls::types::schedule_transfer_with_delay::Delay::BlockNumber(delay as u32) + let blocks = delay_blocks_to_u32(delay)?; + quantus_subxt::api::reversible_transfers::calls::types::schedule_transfer_with_delay::Delay::BlockNumber(blocks) } else { - // Convert seconds to milliseconds for the runtime - quantus_subxt::api::reversible_transfers::calls::types::schedule_transfer_with_delay::Delay::Timestamp(delay * 1000) + let millis = delay_seconds_to_millis(delay)?; + quantus_subxt::api::reversible_transfers::calls::types::schedule_transfer_with_delay::Delay::Timestamp(millis) }; log_verbose!("âœī¸ Creating schedule_transfer_with_delay extrinsic..."); diff --git a/src/cli/send.rs b/src/cli/send.rs index 4ab3be5..3dd8310 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -87,7 +87,9 @@ pub fn format_balance(amount: u128, decimals: u8) -> String { return amount.to_string(); } - let divisor = 10_u128.pow(decimals as u32); + let Some(divisor) = 10_u128.checked_pow(decimals as u32) else { + return format!(""); + }; let whole_part = amount / divisor; let fractional_part = amount % divisor; @@ -735,8 +737,8 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 #[cfg(test)] mod tests { use super::{ - build_batch_transfer_call, effective_tip_amount, limits_from_batched_calls_limit, - parse_amount_with_decimals, + build_batch_transfer_call, effective_tip_amount, format_balance, + limits_from_batched_calls_limit, parse_amount_with_decimals, }; use subxt::tx::Payload; @@ -810,6 +812,18 @@ mod tests { assert_ne!(recommended, 1000, "must not use the hard-coded heuristic fallback"); } + #[test] + fn format_balance_rejects_unsupported_decimals_without_panic() { + let formatted = std::panic::catch_unwind(|| format_balance(u128::MAX, 39)) + .expect("format_balance must not panic on unsupported decimals"); + assert!( + formatted.contains("unsupported decimals"), + "expected unsupported-decimals marker, got: {formatted}" + ); + assert_eq!(format_balance(1_500_000_000_000, 12), "1.5"); + assert_eq!(format_balance(42, 0), "42"); + } + #[test] fn default_tip_amount_is_zero() { assert_eq!(effective_tip_amount(None), 0); diff --git a/src/cli/tech_collective.rs b/src/cli/tech_collective.rs index 5db10da..9bd106f 100644 --- a/src/cli/tech_collective.rs +++ b/src/cli/tech_collective.rs @@ -43,6 +43,10 @@ pub enum TechCollectiveCommands { #[arg(short, long)] who: String, + /// Minimum rank required for removal (must be the member's rank or greater) + #[arg(long)] + min_rank: u16, + /// Wallet name to sign with (must have root permissions) #[arg(short, long)] from: String, @@ -143,10 +147,12 @@ pub async fn remove_member( quantus_client: &crate::chain::client::QuantusClient, from_keypair: &crate::wallet::QuantumKeyPair, who_address: &str, + min_rank: u16, execution_mode: crate::cli::common::ExecutionMode, ) -> crate::error::Result { log_verbose!("đŸ›ī¸ Removing member from Tech Collective..."); log_verbose!(" Member: {}", who_address.bright_cyan()); + log_verbose!(" Minimum rank: {}", min_rank); // Parse the member address let (member_account_sp, _) = AccountId32::from_ss58check_with_version(who_address) @@ -160,7 +166,7 @@ pub async fn remove_member( let remove_member_call = quantus_subxt::api::tx().tech_collective().remove_member( subxt::ext::subxt_core::utils::MultiAddress::Id(member_account_id), - 0u16, // Use rank 0 as default + min_rank, ); let tx_hash = crate::cli::common::submit_transaction( @@ -339,16 +345,18 @@ pub async fn handle_tech_collective_command( ); }, - TechCollectiveCommands::RemoveMember { who, from, password, password_file } => { + TechCollectiveCommands::RemoveMember { who, min_rank, from, password, password_file } => { log_print!("đŸ›ī¸ Removing member from Tech Collective "); log_print!(" 👤 Member: {}", who.bright_cyan()); + log_print!(" đŸŽ–ī¸ Minimum rank: {}", min_rank); log_print!(" 🔑 Signed by: {}", from.bright_yellow()); // Load wallet let keypair = crate::wallet::load_keypair_from_wallet(&from, password, password_file)?; // Submit transaction - let tx_hash = remove_member(&quantus_client, &keypair, &who, execution_mode).await?; + let tx_hash = + remove_member(&quantus_client, &keypair, &who, min_rank, execution_mode).await?; log_print!( "✅ {} Remove member transaction submitted! Hash: {:?}", @@ -474,3 +482,49 @@ pub async fn handle_tech_collective_command( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + + #[derive(Debug, Parser)] + struct TestCli { + #[command(subcommand)] + command: TechCollectiveCommands, + } + + #[test] + fn remove_member_requires_min_rank_argument() { + let err = TestCli::try_parse_from([ + "tech-collective", + "remove-member", + "--who", + "qzAddress", + "--from", + "operator", + ]) + .unwrap_err(); + let rendered = err.to_string(); + assert!( + rendered.contains("min-rank") || rendered.contains("required"), + "expected missing --min-rank to fail clap parse, got: {rendered}" + ); + + let parsed = TestCli::try_parse_from([ + "tech-collective", + "remove-member", + "--who", + "qzAddress", + "--min-rank", + "1", + "--from", + "operator", + ]) + .expect("--min-rank must be accepted"); + match parsed.command { + TechCollectiveCommands::RemoveMember { min_rank, .. } => assert_eq!(min_rank, 1), + other => panic!("expected RemoveMember, got {other:?}"), + } + } +} diff --git a/src/cli/transfers.rs b/src/cli/transfers.rs index ca0ef6c..90813ec 100644 --- a/src/cli/transfers.rs +++ b/src/cli/transfers.rs @@ -5,10 +5,10 @@ //! exact addresses to the indexer. use crate::{ - cli::send::format_balance, + cli::send::{format_balance, get_chain_properties}, error::{QuantusError, Result}, log_error, log_print, log_success, log_verbose, - subsquid::{compute_address_hash, get_hash_prefix, SubsquidClient, TransferQueryParams}, + subsquid::{compute_address_hash, get_hash_prefix, SubsquidClient, Transfer, TransferQueryParams}, wallet::WalletManager, }; use clap::Subcommand; @@ -67,7 +67,7 @@ pub enum TransfersCommands { } /// Handle transfers commands -pub async fn handle_transfers_command(cmd: TransfersCommands) -> Result<()> { +pub async fn handle_transfers_command(cmd: TransfersCommands, node_url: &str) -> Result<()> { match cmd { TransfersCommands::Query { subsquid_url, @@ -88,6 +88,7 @@ pub async fn handle_transfers_command(cmd: TransfersCommands) -> Result<()> { limit, wallet, json, + node_url, ) .await, TransfersCommands::HashAddress { address, prefix_len } => @@ -106,6 +107,7 @@ async fn handle_query_command( limit: u32, wallet_name: Option, json_output: bool, + node_url: &str, ) -> Result<()> { // Validate prefix length if prefix_len == 0 || prefix_len > 64 { @@ -186,10 +188,23 @@ async fn handle_query_command( if transfers.is_empty() { log_print!("No transfers found for your addresses."); } else { + let parsed_transfers: Vec<_> = transfers + .iter() + .map(|transfer| { + Ok(( + transfer, + parse_transfer_amount(transfer)?, + transfer_timestamp_prefix(transfer)?, + )) + }) + .collect::>()?; + let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; + let (symbol, decimals) = get_chain_properties(&quantus_client).await?; + log_success!("Found {} transfers:", transfers.len().to_string().bright_green()); log_print!(""); - for transfer in &transfers { + for (transfer, amount, timestamp) in parsed_transfers { // Determine if this is incoming or outgoing let our_address_hashes: std::collections::HashSet = raw_addresses.iter().map(compute_address_hash).collect(); @@ -204,14 +219,13 @@ async fn handle_query_command( (false, false) => "???".dimmed(), // Shouldn't happen }; - // Parse and format amount (12 decimals is standard for Substrate) - let amount: u128 = transfer.amount.parse().unwrap_or(0); - let formatted_amount = format!("{} DEV", format_balance(amount, 12)); + // Format indexer amount with the connected chain properties. + let formatted_amount = format!("{} {}", format_balance(amount, decimals), symbol); log_print!( " [{}] {} | Block {} | {} | {} -> {}", direction, - &transfer.timestamp[..19], // Truncate to YYYY-MM-DDTHH:MM:SS + timestamp, // Truncate to YYYY-MM-DDTHH:MM:SS transfer.block_height.to_string().bright_yellow(), formatted_amount.bright_cyan(), truncate_address(&transfer.from_id), @@ -228,6 +242,24 @@ async fn handle_query_command( Ok(()) } +fn parse_transfer_amount(transfer: &Transfer) -> Result { + transfer.amount.parse().map_err(|_| { + QuantusError::Generic(format!( + "Invalid transfer amount from indexer for transfer {}: '{}'", + transfer.id, transfer.amount + )) + }) +} + +fn transfer_timestamp_prefix(transfer: &Transfer) -> Result<&str> { + transfer.timestamp.get(..19).ok_or_else(|| { + QuantusError::Generic(format!( + "Invalid transfer timestamp from indexer for transfer {}: '{}'", + transfer.id, transfer.timestamp + )) + }) +} + /// Handle the hash-address subcommand fn handle_hash_address_command(address: &str, prefix_len: usize) -> Result<()> { // Parse the SS58 address @@ -261,3 +293,54 @@ fn truncate_address(address: &str) -> String { address.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_transfer(amount: &str, timestamp: &str) -> Transfer { + Transfer { + id: "t1".to_string(), + block_id: "b1".to_string(), + block_height: 1, + timestamp: timestamp.to_string(), + extrinsic_hash: None, + from_id: "from".to_string(), + to_id: "to".to_string(), + amount: amount.to_string(), + fee: "0".to_string(), + from_hash: "aa".to_string(), + to_hash: "bb".to_string(), + leaf_index: "0".to_string(), + transfer_count: "0".to_string(), + } + } + + #[test] + fn parse_transfer_amount_rejects_invalid_values() { + let bad = sample_transfer("not-a-number", "2024-01-01T00:00:00.000Z"); + let err = parse_transfer_amount(&bad).unwrap_err(); + assert!( + err.to_string().contains("Invalid transfer amount"), + "unexpected error: {err}" + ); + assert_eq!( + parse_transfer_amount(&sample_transfer("12345", "2024-01-01T00:00:00.000Z")).unwrap(), + 12345 + ); + } + + #[test] + fn transfer_timestamp_prefix_rejects_short_timestamps() { + let short = sample_transfer("1", "short"); + let err = transfer_timestamp_prefix(&short).unwrap_err(); + assert!( + err.to_string().contains("Invalid transfer timestamp"), + "unexpected error: {err}" + ); + assert_eq!( + transfer_timestamp_prefix(&sample_transfer("1", "2024-01-01T00:00:00.000Z")).unwrap(), + "2024-01-01T00:00:00" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index e71fd2d..febcfb4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,8 +59,8 @@ pub use wormhole_lib::{ // into `chain::quantus_subxt::api::wormhole::events::*`. pub use chain::quantus_subxt::api::wormhole::events::NativeTransferred; pub use cli::wormhole::{ - aggregate_proofs, at_best_block, compute_merkle_positions, decode_full_leaf_data, - get_zk_merkle_proof, parse_transfer_events, read_proof_file, + aggregate_proofs, at_best_block, at_finalized_block, compute_merkle_positions, + decode_full_leaf_data, get_zk_merkle_proof, parse_transfer_events, read_proof_file, submit_unsigned_verify_private_batch, verify_private_batch_and_get_events, write_proof_file, IncludedAt, TransferInfo, }; From 7f569f96d041054398d0c69175b64e0ca3e744ee Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:08:42 +0800 Subject: [PATCH 32/39] fix(bins): authenticate circuit artifacts and publish atomically Load circuit bundles only after manifest hash checks, refuse symlink redirection, and publish generated-bins via atomic directory replace. Co-authored-by: Cursor --- build.rs | 115 +++++++----- src/batch_verifier.rs | 2 + src/bins.rs | 409 ++++++++++++++++++++++++++++++++++++++---- src/bins_consts.rs | 4 + src/bins_fs.rs | 103 +++++++++++ 5 files changed, 557 insertions(+), 76 deletions(-) create mode 100644 src/bins_fs.rs diff --git a/build.rs b/build.rs index 4a4a53d..2b8a0e8 100644 --- a/build.rs +++ b/build.rs @@ -7,9 +7,9 @@ //! to manually run `quantus developer build-circuits`. //! //! Outputs are written to `OUT_DIR` (required by cargo) and, during local source -//! builds only, linked/copied to `generated-bins/` in the project root. When the -//! crate is consumed via `cargo install` or `cargo publish` verification, the -//! manifest lives under `~/.cargo/registry/src/` or `target/package/` +//! builds only, atomically published to `generated-bins/` in the project root. +//! When the crate is consumed via `cargo install` or `cargo publish` verification, +//! the manifest lives under `~/.cargo/registry/src/` or `target/package/` //! respectively — locations the installed binary cannot reach — so the project //! copy is skipped. Installed binaries regenerate the files on first run via //! `crate::bins::ensure_bins_dir()`. @@ -17,9 +17,11 @@ //! Set `SKIP_CIRCUIT_BUILD=1` to skip circuit generation (useful for CI jobs //! that don't need the circuits, like clippy/doc checks). -use std::{env, path::Path, time::Instant}; +use sha2::{Digest, Sha256}; +use std::{env, time::Instant}; include!("src/bins_consts.rs"); +include!("src/bins_fs.rs"); /// Compute Poseidon2 hash of bytes and return hex string fn poseidon_hex(data: &[u8]) -> String { @@ -40,6 +42,65 @@ fn print_bin_hash(dir: &Path, filename: &str) { } } +const MANIFESTED_FILES: &[&str] = &[ + "verifier.bin", + "common.bin", + "private_batch_prover.bin", + "private_batch_verifier.bin", + "private_batch_common.bin", + "public_batch_prover.bin", + "public_batch_verifier.bin", + "public_batch_common.bin", + "dummy_proof.bin", + "dummy_private_batch_proof.bin", + "config.json", + VERSION_MARKER, +]; + +fn file_sha256_hex(dir: &Path, filename: &str) -> String { + let data = + std::fs::read(dir.join(filename)).expect("Failed to read generated artifact for manifest"); + let mut hasher = Sha256::new(); + hasher.update(&data); + hex::encode(hasher.finalize()) +} + +fn json_escape(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn write_manifest( + dir: &Path, + pkg_version: &str, + num_leaf_proofs: usize, + num_private_batch_proofs: usize, +) { + let mut content = String::new(); + content.push_str("{\n"); + content.push_str(" \"manifest_version\": 1,\n"); + content.push_str(&format!( + " \"package_version\": \"{}\",\n", + json_escape(pkg_version) + )); + content.push_str(&format!(" \"num_leaf_proofs\": {},\n", num_leaf_proofs)); + content.push_str(&format!( + " \"num_private_batch_proofs\": {},\n", + num_private_batch_proofs + )); + content.push_str(" \"files\": {\n"); + for (idx, filename) in MANIFESTED_FILES.iter().enumerate() { + let comma = if idx + 1 == MANIFESTED_FILES.len() { "" } else { "," }; + content.push_str(&format!( + " \"{}\": \"{}\"{}\n", + json_escape(filename), + file_sha256_hex(dir, filename), + comma + )); + } + content.push_str(" }\n}\n"); + std::fs::write(dir.join(MANIFEST_FILE), content).expect("Failed to write artifact manifest"); +} + fn main() { // Allow skipping circuit generation for CI jobs that don't need it if env::var("SKIP_CIRCUIT_BUILD").is_ok() { @@ -95,6 +156,7 @@ fn main() { let pkg_version = env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION not set"); std::fs::write(build_output_dir.join(VERSION_MARKER), &pkg_version) .expect("Failed to write version marker"); + write_manifest(&build_output_dir, &pkg_version, num_leaf_proofs, num_private_batch_proofs); let elapsed = start.elapsed(); println!( @@ -113,49 +175,14 @@ fn main() { print_bin_hash(&build_output_dir, "public_batch_verifier.bin"); print_bin_hash(&build_output_dir, "public_batch_prover.bin"); - // Copy bins to project root for runtime access, but only during local source - // builds — never during `cargo publish` verification (manifest_dir is inside - // `target/package/`) nor during `cargo install` (manifest_dir is inside - // `.cargo/registry/src/`). In those cases the installed binary can't see the - // project dir; runtime lazy-generation takes over instead. + // Atomically publish a real directory (not a symlink) for runtime access. + // Symlinks are refused by runtime `ensure_bins_dir` (#160699), and the old + // check/remove/symlink/copy sequence was racy (#160700). let project_bins = Path::new(&manifest_dir).join("generated-bins"); let is_source_build = !manifest_dir.contains("target/package/") && !manifest_dir.contains(".cargo/registry/src"); if is_source_build { - // Prefer a symlink to avoid copying large prover binaries on every build. - // If symlink creation fails (e.g. on filesystems without symlink support), - // fall back to copying and surface errors. - #[cfg(unix)] - { - use std::os::unix::fs::symlink; - // Remove any existing dir/file/symlink at destination. - if let Ok(meta) = std::fs::symlink_metadata(&project_bins) { - if meta.is_dir() { - std::fs::remove_dir_all(&project_bins) - .expect("Failed to remove existing generated-bins directory"); - } else { - std::fs::remove_file(&project_bins) - .expect("Failed to remove existing generated-bins file/symlink"); - } - } - if let Err(e) = symlink(&build_output_dir, &project_bins) { - println!( - "cargo:warning=[quantus-cli] Failed to symlink generated-bins ({}). Falling back to copy...", - e - ); - } else { - // Symlink created successfully; we're done. - return; - } - } - - std::fs::create_dir_all(&project_bins).expect("Failed to create generated-bins directory"); - let entries = std::fs::read_dir(&build_output_dir) - .expect("Failed to read generated-bins directory in OUT_DIR"); - for entry in entries { - let entry = entry.expect("Failed to read generated-bins entry"); - let dest = project_bins.join(entry.file_name()); - std::fs::copy(entry.path(), dest).expect("Failed to copy generated-bins file"); - } + publish_dir_atomically(&build_output_dir, &project_bins) + .unwrap_or_else(|e| panic!("Failed to publish generated-bins: {e}")); } } diff --git a/src/batch_verifier.rs b/src/batch_verifier.rs index 3456ea3..fc5b2e2 100644 --- a/src/batch_verifier.rs +++ b/src/batch_verifier.rs @@ -127,6 +127,7 @@ fn load_batch_verifier_from_files( /// Load the private-batch verifier from `bins_dir`, applying batch profile checks /// (not the leaf keccak256 pin). pub fn load_private_batch_verifier(bins_dir: &Path) -> Result { + crate::bins::verify_manifest(bins_dir)?; let config = CircuitBinsConfig::load(bins_dir).map_err(|e| { QuantusError::Generic(format!( "Failed to load circuit bins config from {}: {e}", @@ -145,6 +146,7 @@ pub fn load_private_batch_verifier(bins_dir: &Path) -> Result /// Load the public-batch verifier from `bins_dir`, applying batch profile checks /// (not the leaf keccak256 pin). pub fn load_public_batch_verifier(bins_dir: &Path) -> Result { + crate::bins::verify_manifest(bins_dir)?; let config = CircuitBinsConfig::load(bins_dir).map_err(|e| { QuantusError::Generic(format!( "Failed to load circuit bins config from {}: {e}", diff --git a/src/bins.rs b/src/bins.rs index a6ec2c0..66824d6 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -18,7 +18,12 @@ use crate::{ error::{QuantusError, Result}, log_print, log_success, }; -use std::path::{Path, PathBuf}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; include!("bins_consts.rs"); @@ -43,6 +48,30 @@ const REQUIRED_FILES: &[&str] = &[ "config.json", ]; +const MANIFESTED_FILES: &[&str] = &[ + "verifier.bin", + "common.bin", + "private_batch_prover.bin", + "private_batch_verifier.bin", + "private_batch_common.bin", + "public_batch_prover.bin", + "public_batch_verifier.bin", + "public_batch_common.bin", + "dummy_proof.bin", + "dummy_private_batch_proof.bin", + "config.json", + VERSION_MARKER, +]; + +#[derive(serde::Deserialize, serde::Serialize)] +struct ArtifactManifest { + manifest_version: u32, + package_version: String, + num_leaf_proofs: usize, + num_private_batch_proofs: usize, + files: std::collections::BTreeMap, +} + /// Resolve the path where circuit binaries should live. /// /// This never generates anything; see [`ensure_bins_dir`] for the full @@ -71,52 +100,165 @@ fn user_bins_dir() -> PathBuf { /// Resolve the bins directory and generate any missing circuit binaries. /// /// Safe to call multiple times; regeneration only happens when the target is -/// empty, partially populated, or was produced by a different CLI version. +/// empty. Incomplete or unauthenticated directories are rejected rather than +/// overwritten. pub fn ensure_bins_dir() -> Result { let dir = resolve_bins_dir(); + ensure_safe_bins_dir(&dir)?; if is_ready(&dir) { return Ok(dir); } + if REQUIRED_FILES.iter().any(|f| dir.join(f).exists()) { + return Err(QuantusError::Generic(format!( + "Circuit artifact directory {} is incomplete or lacks a valid manifest; remove it or regenerate trusted artifacts", + dir.display() + ))); + } + let num_leaf_proofs = env_num_leaf_proofs(); let num_private_batch_proofs = env_num_private_batch_proofs(); generate(&dir, num_leaf_proofs, num_private_batch_proofs)?; Ok(dir) } -fn is_ready(dir: &Path) -> bool { - if !REQUIRED_FILES.iter().all(|f| dir.join(f).exists()) { - return false; - } - // Check CLI version matches - let version_ok = match std::fs::read_to_string(dir.join(VERSION_MARKER)) { - Ok(v) => v.trim() == env!("CARGO_PKG_VERSION"), - Err(_) => return false, - }; - if !version_ok { - return false; - } - // Check circuit sizing in config.json matches current settings - let config_path = dir.join("config.json"); - match std::fs::read_to_string(&config_path) { - Ok(content) => { - // Parse just the sizing fields to avoid pulling in full config dependency - #[derive(serde::Deserialize)] - struct ConfigCheck { - num_leaf_proofs: usize, - #[serde(default, alias = "num_layer0_proofs")] - num_private_batch_proofs: Option, +fn ensure_safe_bins_dir(dir: &Path) -> Result<()> { + match fs::symlink_metadata(dir) { + Ok(meta) => { + if meta.file_type().is_symlink() { + return Err(QuantusError::Generic(format!( + "Refusing to use symlinked bins directory {}", + dir.display() + ))); } - match serde_json::from_str::(&content) { - Ok(config) => - config.num_leaf_proofs == env_num_leaf_proofs() && - config.num_private_batch_proofs == Some(env_num_private_batch_proofs()), - Err(_) => false, + if !meta.is_dir() { + return Err(QuantusError::Generic(format!( + "Bins path {} is not a directory", + dir.display() + ))); } }, - Err(_) => false, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}, + Err(e) => { + return Err(QuantusError::Generic(format!( + "Failed to inspect bins directory {}: {}", + dir.display(), + e + ))); + }, + } + Ok(()) +} + +fn ensure_regular_file(path: &Path) -> Result<()> { + let meta = fs::symlink_metadata(path).map_err(|e| { + QuantusError::Generic(format!("Failed to inspect circuit artifact {}: {e}", path.display())) + })?; + if meta.file_type().is_symlink() { + return Err(QuantusError::Generic(format!( + "Refusing to use symlinked circuit artifact {}", + path.display() + ))); + } + if !meta.is_file() { + return Err(QuantusError::Generic(format!( + "Circuit artifact {} is not a regular file", + path.display() + ))); + } + Ok(()) +} + +fn is_ready(dir: &Path) -> bool { + REQUIRED_FILES.iter().all(|f| dir.join(f).exists()) && verify_manifest(dir).is_ok() +} + +/// Authenticate a circuit-artifact directory against its SHA-256 manifest. +pub(crate) fn verify_manifest(dir: &Path) -> Result<()> { + ensure_safe_bins_dir(dir)?; + ensure_regular_file(&dir.join(MANIFEST_FILE))?; + let content = std::fs::read_to_string(dir.join(MANIFEST_FILE)).map_err(|e| { + QuantusError::Generic(format!( + "Failed to read circuit artifact manifest {}: {e}", + dir.join(MANIFEST_FILE).display() + )) + })?; + let manifest: ArtifactManifest = serde_json::from_str(&content).map_err(|e| { + QuantusError::Generic(format!( + "Failed to parse circuit artifact manifest {}: {e}", + dir.join(MANIFEST_FILE).display() + )) + })?; + validate_manifest(dir, &manifest) +} + +fn validate_manifest(dir: &Path, manifest: &ArtifactManifest) -> Result<()> { + if manifest.manifest_version != 1 { + return Err(QuantusError::Generic(format!( + "Unsupported circuit artifact manifest version {}", + manifest.manifest_version + ))); + } + if manifest.package_version != env!("CARGO_PKG_VERSION") { + return Err(QuantusError::Generic( + "Circuit artifact manifest package version mismatch".to_string(), + )); + } + if manifest.num_leaf_proofs != env_num_leaf_proofs() || + manifest.num_private_batch_proofs != env_num_private_batch_proofs() + { + return Err(QuantusError::Generic( + "Circuit artifact manifest sizing does not match current settings".to_string(), + )); + } + if manifest.files.len() != MANIFESTED_FILES.len() { + return Err(QuantusError::Generic( + "Circuit artifact manifest file set mismatch".to_string(), + )); } + for filename in MANIFESTED_FILES { + let expected = manifest.files.get(*filename).ok_or_else(|| { + QuantusError::Generic(format!("Circuit artifact manifest lacks {filename}")) + })?; + let path = dir.join(filename); + ensure_regular_file(&path)?; + let actual = file_sha256_hex(&path)?; + if &actual != expected { + return Err(QuantusError::Generic(format!( + "Circuit artifact hash mismatch for {filename}" + ))); + } + } + Ok(()) +} + +fn write_manifest(dir: &Path, num_leaf_proofs: usize, num_private_batch_proofs: usize) -> Result<()> { + let mut files = std::collections::BTreeMap::new(); + for filename in MANIFESTED_FILES { + ensure_regular_file(&dir.join(filename))?; + files.insert((*filename).to_string(), file_sha256_hex(&dir.join(filename))?); + } + let manifest = ArtifactManifest { + manifest_version: 1, + package_version: env!("CARGO_PKG_VERSION").to_string(), + num_leaf_proofs, + num_private_batch_proofs, + files, + }; + let content = serde_json::to_string_pretty(&manifest).map_err(|e| { + QuantusError::Generic(format!("Failed to serialize circuit artifact manifest: {e}")) + })?; + atomic_write_new_file(&dir.join(MANIFEST_FILE), content.as_bytes()) +} + +fn file_sha256_hex(path: &Path) -> Result { + let data = std::fs::read(path).map_err(|e| { + QuantusError::Generic(format!("Failed to read circuit artifact {}: {e}", path.display())) + })?; + let mut hasher = Sha256::new(); + hasher.update(&data); + Ok(hex::encode(hasher.finalize())) } fn env_num_leaf_proofs() -> usize { @@ -133,10 +275,60 @@ fn env_num_private_batch_proofs() -> usize { .unwrap_or(DEFAULT_NUM_PRIVATE_BATCH_PROOFS) } +fn atomic_write_new_file(path: &Path, contents: &[u8]) -> Result<()> { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| QuantusError::Generic("Invalid artifact filename".to_string()))?; + let temp_path = parent.join(format!("{}.tmp-{}", file_name, std::process::id())); + + if let Ok(meta) = fs::symlink_metadata(path) { + if meta.file_type().is_symlink() { + return Err(QuantusError::Generic(format!( + "Refusing to overwrite symlinked artifact {}", + path.display() + ))); + } + } + if let Ok(meta) = fs::symlink_metadata(&temp_path) { + if meta.file_type().is_symlink() { + return Err(QuantusError::Generic(format!( + "Refusing to overwrite symlinked temporary artifact {}", + temp_path.display() + ))); + } + if meta.is_file() { + let _ = fs::remove_file(&temp_path); + } + } + + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .map_err(|e| QuantusError::Generic(format!("Failed to create {}: {}", path.display(), e)))?; + file.write_all(contents) + .and_then(|_| file.sync_all()) + .map_err(|e| QuantusError::Generic(format!("Failed to write {}: {}", path.display(), e)))?; + drop(file); + + fs::rename(&temp_path, path).map_err(|e| { + let _ = fs::remove_file(&temp_path); + QuantusError::Generic(format!("Failed to publish {}: {}", path.display(), e)) + })?; + Ok(()) +} + +fn write_version_marker_safely(dir: &Path) -> Result<()> { + atomic_write_new_file(&dir.join(VERSION_MARKER), env!("CARGO_PKG_VERSION").as_bytes()) +} + fn generate(dir: &Path, num_leaf_proofs: usize, num_private_batch_proofs: usize) -> Result<()> { std::fs::create_dir_all(dir).map_err(|e| { QuantusError::Generic(format!("Failed to create bins directory {}: {}", dir.display(), e)) })?; + ensure_safe_bins_dir(dir)?; log_print!(""); log_print!("đŸ› ī¸ Generating ZK circuit binaries (first-time setup, ~30s)..."); @@ -152,12 +344,165 @@ fn generate(dir: &Path, num_leaf_proofs: usize, num_private_batch_proofs: usize) Some(num_private_batch_proofs), ) .map_err(|e| QuantusError::Generic(format!("Failed to generate circuit binaries: {}", e)))?; + ensure_safe_bins_dir(dir)?; - std::fs::write(dir.join(VERSION_MARKER), env!("CARGO_PKG_VERSION")) - .map_err(|e| QuantusError::Generic(format!("Failed to write version marker: {}", e)))?; + write_version_marker_safely(dir)?; + write_manifest(dir, num_leaf_proofs, num_private_batch_proofs)?; let elapsed = start.elapsed(); log_success!("Circuit binaries ready in {:.1}s", elapsed.as_secs_f64()); log_print!(""); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::os::unix::fs::symlink; + use tempfile::TempDir; + + fn seed_required_files(dir: &Path) { + for name in REQUIRED_FILES { + fs::write(dir.join(name), format!("contents-of-{name}")).unwrap(); + } + fs::write(dir.join(VERSION_MARKER), env!("CARGO_PKG_VERSION")).unwrap(); + } + + fn write_valid_manifest_for_dir(dir: &Path) { + write_manifest(dir, env_num_leaf_proofs(), env_num_private_batch_proofs()).unwrap(); + } + + #[test] + fn verify_manifest_rejects_tampered_artifact() { + // #160697: readiness/load must authenticate artifact bytes, not just names. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + write_valid_manifest_for_dir(dir); + assert!(verify_manifest(dir).is_ok()); + + fs::write(dir.join("private_batch_verifier.bin"), b"attacker-substituted-circuit").unwrap(); + let err = verify_manifest(dir).expect_err("tampered verifier must fail authentication"); + assert!( + err.to_string().contains("hash mismatch"), + "unexpected error: {err}" + ); + assert!(!is_ready(dir)); + } + + #[test] + #[serial] + fn ensure_bins_dir_rejects_incomplete_unauthenticated_directory() { + // #160697: do not regenerate over an existing unverified artifact set. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path().join("generated-bins"); + fs::create_dir_all(&dir).unwrap(); + seed_required_files(&dir); + // No manifest.json + + std::env::set_var(BINS_DIR_ENV, &dir); + let result = ensure_bins_dir(); + std::env::remove_var(BINS_DIR_ENV); + + let err = result.expect_err("incomplete/unauthenticated dir must be rejected"); + assert!( + err.to_string().contains("lacks a valid manifest"), + "unexpected error: {err}" + ); + } + + #[test] + #[serial] + fn ensure_bins_dir_rejects_symlinked_directory() { + // #160699: artifact directory must not be a symlink redirect. + let tmp = TempDir::new().unwrap(); + let real = tmp.path().join("real-bins"); + let link = tmp.path().join("generated-bins"); + fs::create_dir_all(&real).unwrap(); + seed_required_files(&real); + write_valid_manifest_for_dir(&real); + symlink(&real, &link).unwrap(); + + std::env::set_var(BINS_DIR_ENV, &link); + let result = ensure_bins_dir(); + std::env::remove_var(BINS_DIR_ENV); + + let err = result.expect_err("symlinked bins dir must be rejected"); + assert!( + err.to_string().contains("symlinked bins directory"), + "unexpected error: {err}" + ); + } + + #[test] + fn version_marker_write_refuses_existing_symlink() { + // #160699: marker publication must not follow a pre-existing symlink. + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + fs::create_dir_all(dir).unwrap(); + let victim = tmp.path().join("victim.txt"); + fs::write(&victim, b"do-not-overwrite").unwrap(); + symlink(&victim, dir.join(VERSION_MARKER)).unwrap(); + + let err = write_version_marker_safely(dir).expect_err("must refuse symlink marker"); + assert!(err.to_string().contains("symlinked"), "unexpected error: {err}"); + assert_eq!(fs::read_to_string(&victim).unwrap(), "do-not-overwrite"); + } + + #[test] + fn verify_manifest_rejects_symlinked_artifact_file() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + write_valid_manifest_for_dir(dir); + + let evil = tmp.path().join("evil-verifier.bin"); + fs::write(&evil, b"redirected").unwrap(); + fs::remove_file(dir.join("verifier.bin")).unwrap(); + symlink(&evil, dir.join("verifier.bin")).unwrap(); + + let err = verify_manifest(dir).expect_err("symlinked artifact must be rejected"); + assert!(err.to_string().contains("symlinked"), "unexpected error: {err}"); + } + + // Shared publish helpers from build.rs (#160700). + include!("bins_fs.rs"); + + #[test] + fn publish_dir_atomically_replaces_destination_symlink_without_following() { + // #160700: publishing must not write through a swapped destination symlink. + let tmp = TempDir::new().unwrap(); + let src = tmp.path().join("src"); + let dest = tmp.path().join("generated-bins"); + let victim_dir = tmp.path().join("victim-dir"); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&victim_dir).unwrap(); + fs::write(src.join("config.json"), b"{\"ok\":true}").unwrap(); + fs::write(src.join("verifier.bin"), b"trusted").unwrap(); + fs::write(victim_dir.join("keep-me.txt"), b"safe").unwrap(); + symlink(&victim_dir, &dest).unwrap(); + + publish_dir_atomically(&src, &dest).expect("publish must succeed"); + + assert!(dest.is_dir()); + assert!(!fs::symlink_metadata(&dest).unwrap().file_type().is_symlink()); + assert_eq!(fs::read(dest.join("verifier.bin")).unwrap(), b"trusted"); + assert_eq!(fs::read(victim_dir.join("keep-me.txt")).unwrap(), b"safe"); + assert!(!victim_dir.join("config.json").exists()); + } + + #[test] + fn remove_path_nofollow_removes_symlink_without_deleting_target() { + let tmp = TempDir::new().unwrap(); + let target = tmp.path().join("target-dir"); + let link = tmp.path().join("link-dir"); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("keep.txt"), b"keep").unwrap(); + symlink(&target, &link).unwrap(); + + remove_path_nofollow(&link).expect("remove symlink"); + assert!(!link.exists()); + assert!(target.join("keep.txt").exists()); + } +} diff --git a/src/bins_consts.rs b/src/bins_consts.rs index 76fc4bf..199758b 100644 --- a/src/bins_consts.rs +++ b/src/bins_consts.rs @@ -3,6 +3,10 @@ /// Shared by `build.rs` and `crate::bins` via `include!`. const VERSION_MARKER: &str = ".quantus-cli-version"; +/// Filename of the manifest binding generated circuit artifacts to hashes and sizing. +/// Shared by `build.rs` and `crate::bins` via `include!`. +const MANIFEST_FILE: &str = "manifest.json"; + /// Number of leaf proofs aggregated into a single batch. /// /// 7 is optimal for mobile devices: fits in degree_bits=15 (~1.5 GB peak memory). diff --git a/src/bins_fs.rs b/src/bins_fs.rs new file mode 100644 index 0000000..f360401 --- /dev/null +++ b/src/bins_fs.rs @@ -0,0 +1,103 @@ +// Filesystem helpers for publishing circuit artifact directories. +// Included by `build.rs` (no crate prelude) and by `crate::bins` tests. + +use std::fs; +#[allow(unused_imports)] // Path is provided by build.rs when included there +use std::path::{Path, PathBuf}; + +/// Remove a path without following a destination that was swapped to a symlink +/// between inspection and deletion. +fn remove_path_nofollow(path: &Path) -> std::result::Result<(), String> { + match fs::symlink_metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Failed to inspect {}: {}", path.display(), e)), + Ok(meta) if meta.file_type().is_symlink() || meta.is_file() => { + fs::remove_file(path).map_err(|e| format!("Failed to remove {}: {}", path.display(), e)) + }, + Ok(meta) if meta.is_dir() => { + // Rename aside first so a TOCTOU swap to a symlink cannot redirect + // remove_dir_all onto an attacker-chosen directory. + let trash = path.with_file_name(format!( + ".{}.trash-{}", + path.file_name().and_then(|s| s.to_str()).unwrap_or("path"), + std::process::id() + )); + if trash.exists() || fs::symlink_metadata(&trash).is_ok() { + remove_path_nofollow(&trash)?; + } + fs::rename(path, &trash) + .map_err(|e| format!("Failed to quarantine {}: {}", path.display(), e))?; + match fs::symlink_metadata(&trash) { + Ok(m) if m.file_type().is_symlink() || m.is_file() => fs::remove_file(&trash) + .map_err(|e| { + format!("Failed to remove quarantined path {}: {}", trash.display(), e) + }), + Ok(m) if m.is_dir() => fs::remove_dir_all(&trash).map_err(|e| { + format!("Failed to remove quarantined dir {}: {}", trash.display(), e) + }), + Ok(_) => Err(format!("Unexpected quarantined path type at {}", trash.display())), + Err(e) => Err(format!( + "Failed to inspect quarantined path {}: {}", + trash.display(), + e + )), + } + }, + Ok(_) => Err(format!("Unexpected path type at {}", path.display())), + } +} + +/// Atomically publish `src` directory contents to `dest` via a staging directory +/// and rename, refusing symlink destinations at each step. +fn publish_dir_atomically(src: &Path, dest: &Path) -> std::result::Result<(), String> { + let parent = dest + .parent() + .ok_or_else(|| "destination must have a parent directory".to_string())?; + let staging: PathBuf = + parent.join(format!(".generated-bins.staging-{}", std::process::id())); + + remove_path_nofollow(&staging)?; + fs::create_dir_all(&staging) + .map_err(|e| format!("Failed to create staging directory {}: {}", staging.display(), e))?; + if fs::symlink_metadata(&staging) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(format!("Staging path {} unexpectedly became a symlink", staging.display())); + } + + let entries = fs::read_dir(src) + .map_err(|e| format!("Failed to read source directory {}: {}", src.display(), e))?; + for entry in entries { + let entry = + entry.map_err(|e| format!("Failed to read source directory entry: {}", e))?; + let dest_file = staging.join(entry.file_name()); + if let Ok(meta) = fs::symlink_metadata(&dest_file) { + if meta.file_type().is_symlink() { + return Err(format!( + "Refusing to copy onto symlinked staging artifact {}", + dest_file.display() + )); + } + } + fs::copy(entry.path(), &dest_file).map_err(|e| { + format!( + "Failed to copy {} -> {}: {}", + entry.path().display(), + dest_file.display(), + e + ) + })?; + } + + remove_path_nofollow(dest)?; + if let Err(e) = fs::rename(&staging, dest) { + let _ = fs::remove_dir_all(&staging); + return Err(format!( + "Failed to publish directory to {}: {}", + dest.display(), + e + )); + } + Ok(()) +} From ccc244a3c885a68036b83ddd0cf7684d93ed8818 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:13:18 +0800 Subject: [PATCH 33/39] fix(cli): bound ranges and reject silent zero coercions Cap block-list and storage-iterate limits, surface missing extrinsic and nonce absence, reject bad JSON numerics and duplicate multisend recipients, and use checked metadata counters. Co-authored-by: Cursor --- src/chain/client.rs | 33 +++++++++++-- src/cli/block.rs | 77 ++++++++++++++++++++++++++++- src/cli/generic_call.rs | 103 +++++++++++++++++++++++++++++++++++--- src/cli/metadata.rs | 38 ++++++++++++-- src/cli/multisend.rs | 35 +++++++++++++ src/cli/storage.rs | 38 ++++++++++++++ src/cli/wallet.rs | 17 +++++-- src/cli/wormhole.rs | 106 ++++++++++++++++++++++++---------------- src/wallet/mod.rs | 27 +++++++++- 9 files changed, 409 insertions(+), 65 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index 2f21f23..d74c4d7 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -186,6 +186,17 @@ impl QuantusClient { Ok(latest_hash) } + /// Interpret a System::Account nonce lookup without collapsing absence into a silent zero. + /// + /// Returns `(nonce, account_exists)`. Missing accounts use nonce `0` (correct for the first + /// extrinsic) but callers can log the absence explicitly. + pub(crate) fn interpret_account_nonce(fetched_nonce: Option) -> (u32, bool) { + match fetched_nonce { + Some(nonce) => (nonce, true), + None => (0, false), + } + } + /// Get account nonce from the best block (latest) using direct RPC call /// This bypasses SubXT's default behavior of using finalized blocks pub async fn get_account_nonce_from_best_block( @@ -208,10 +219,16 @@ impl QuantusClient { let storage_at = self.client.storage().at(latest_block_hash); - let account_info = storage_at.fetch_or_default(&storage_addr).await?; - - log_verbose!("✅ Nonce from best block: {}", account_info.nonce); - Ok(account_info.nonce as u64) + let account_info = storage_at.fetch(&storage_addr).await?; + let (nonce, exists) = Self::interpret_account_nonce(account_info.map(|info| info.nonce)); + if exists { + log_verbose!("✅ Nonce from best block: {}", nonce); + } else { + log_verbose!( + "âš ī¸ Account has no on-chain entry at best block; using nonce 0 for first extrinsic" + ); + } + Ok(nonce as u64) } /// Get genesis hash using RPC call @@ -371,4 +388,12 @@ mod tests { "wss://rpc.example.com" ); } + + #[test] + fn interpret_account_nonce_distinguishes_absent_account() { + // #159454/#159455: absence must not be indistinguishable from a real nonce-0 account. + assert_eq!(QuantusClient::interpret_account_nonce(None), (0, false)); + assert_eq!(QuantusClient::interpret_account_nonce(Some(0)), (0, true)); + assert_eq!(QuantusClient::interpret_account_nonce(Some(7)), (7, true)); + } } diff --git a/src/cli/block.rs b/src/cli/block.rs index 54fb843..abdcac6 100644 --- a/src/cli/block.rs +++ b/src/cli/block.rs @@ -790,6 +790,37 @@ async fn get_account_nonce_at_block( Ok(account_info.nonce) } +/// Maximum number of blocks `quantus block list` will process in one invocation. +pub(crate) const MAX_BLOCK_LIST_COUNT: u32 = 10_000; + +/// Validate block-list range bounds before any RPC work. +/// +/// Rejects inverted ranges, zero step, and ranges that would issue more than +/// [`MAX_BLOCK_LIST_COUNT`] RPC iterations. +pub(crate) fn validate_block_list_range( + start: u32, + end: u32, + step: u32, +) -> crate::error::Result { + if step == 0 { + return Err(QuantusError::Generic( + "Block list --step must be greater than 0".to_string(), + )); + } + if start > end { + return Err(QuantusError::Generic(format!( + "Invalid block list range: start ({start}) must be <= end ({end})" + ))); + } + let block_count = (end - start) / step + 1; + if block_count > MAX_BLOCK_LIST_COUNT { + return Err(QuantusError::Generic(format!( + "Block list range too large: {block_count} blocks exceeds maximum of {MAX_BLOCK_LIST_COUNT}. Narrow --start/--end or increase --step" + ))); + } + Ok(block_count) +} + /// Handle block list command pub async fn handle_block_list_command( start: u32, @@ -804,12 +835,13 @@ pub async fn handle_block_list_command( ); let step = step.unwrap_or(1); + let block_count = validate_block_list_range(start, end, step)?; if step > 1 { log_print!("📏 Step: {}", step.to_string().bright_cyan()); } let quantus_client = QuantusClient::new(node_url).await?; - list_blocks_in_range(&quantus_client, start, end, step).await + list_blocks_in_range(&quantus_client, start, end, step, block_count).await } /// List blocks in range with summary information @@ -818,6 +850,7 @@ async fn list_blocks_in_range( start: u32, end: u32, step: u32, + expected_count: u32, ) -> crate::error::Result<()> { use jsonrpsee::core::client::ClientT; @@ -831,7 +864,7 @@ async fn list_blocks_in_range( let mut previous_timestamp: Option = None; // Progress indicator - log_print!("📊 Processing {} blocks...", ((end - start) / step + 1).to_string().bright_cyan()); + log_print!("📊 Processing {} blocks...", expected_count.to_string().bright_cyan()); // Print table header log_print!(""); @@ -1026,3 +1059,43 @@ async fn list_blocks_in_range( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_block_list_range_rejects_inverted_bounds() { + let err = validate_block_list_range(100, 50, 1) + .expect_err("start > end must fail before underflow"); + assert!( + err.to_string().contains("must be <= end"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_block_list_range_rejects_zero_step() { + let err = validate_block_list_range(1, 10, 0).expect_err("step 0 must fail"); + assert!(err.to_string().contains("step"), "unexpected error: {err}"); + } + + #[test] + fn validate_block_list_range_rejects_unbounded_span() { + let err = validate_block_list_range(0, MAX_BLOCK_LIST_COUNT, 1) + .expect_err("span above MAX_BLOCK_LIST_COUNT must fail"); + assert!( + err.to_string().contains("too large"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_block_list_range_accepts_max_span() { + assert_eq!( + validate_block_list_range(0, MAX_BLOCK_LIST_COUNT - 1, 1).unwrap(), + MAX_BLOCK_LIST_COUNT + ); + } +} + diff --git a/src/cli/generic_call.rs b/src/cli/generic_call.rs index 2cef18b..930e731 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -7,6 +7,52 @@ use colored::Colorize; use serde_json::Value; use sp_core::crypto::{AccountId32, Ss58Codec}; +/// Parse a JSON value as `u128`, accepting string or number forms. +/// +/// Rejects non-numeric types instead of silently coercing them to zero. +pub(crate) fn parse_json_u128(value: &Value, label: &str) -> crate::error::Result { + if let Some(s) = value.as_str() { + return s.parse::().map_err(|_| { + QuantusError::Generic(format!("{label} must be a number (got string '{s}')")) + }); + } + if let Some(n) = value.as_u64() { + return Ok(u128::from(n)); + } + if let Some(n) = value.as_number() { + return n.to_string().parse::().map_err(|_| { + QuantusError::Generic(format!("{label} must be a non-negative integer")) + }); + } + Err(QuantusError::Generic(format!( + "{label} must be a JSON string or number (got {value})" + ))) +} + +/// Parse a JSON value as `u32`, accepting number or numeric string forms. +pub(crate) fn parse_json_u32(value: &Value, label: &str) -> crate::error::Result { + if let Some(n) = value.as_u64() { + return u32::try_from(n).map_err(|_| { + QuantusError::Generic(format!("{label} exceeds u32::MAX")) + }); + } + if let Some(s) = value.as_str() { + return s.parse::().map_err(|_| { + QuantusError::Generic(format!("{label} must be a u32 (got string '{s}')")) + }); + } + Err(QuantusError::Generic(format!( + "{label} must be a JSON number or numeric string (got {value})" + ))) +} + +/// Parse a JSON boolean without silently defaulting missing/wrong types to false. +pub(crate) fn parse_json_bool(value: &Value, label: &str) -> crate::error::Result { + value.as_bool().ok_or_else(|| { + QuantusError::Generic(format!("{label} must be a JSON boolean (got {value})")) + }) +} + /// Execute a generic call to any pallet pub async fn execute_generic_call( quantus_client: &crate::chain::client::QuantusClient, @@ -142,9 +188,7 @@ async fn submit_balance_transfer( QuantusError::Generic("First argument must be a string (to_address)".to_string()) })?; - let amount: u128 = args[1].as_str().unwrap_or("0").parse().map_err(|_| { - QuantusError::Generic("Second argument must be a number (amount)".to_string()) - })?; + let amount = parse_json_u128(&args[1], "Second argument (amount)")?; // Convert to AccountId32 let (to_account_id, _) = AccountId32::from_ss58check_with_version(to_address) @@ -304,8 +348,8 @@ async fn submit_tech_collective_vote( )); } - let referendum_index: u32 = args[0].as_u64().unwrap_or(0) as u32; - let aye = args[1].as_bool().unwrap_or(false); + let referendum_index = parse_json_u32(&args[0], "First argument (referendum_index)")?; + let aye = parse_json_bool(&args[1], "Second argument (aye)")?; let vote_call = quantus_subxt::api::tx().tech_collective().vote(referendum_index, aye); @@ -337,9 +381,7 @@ async fn submit_reversible_transfer( QuantusError::Generic("First argument must be a string (to_address)".to_string()) })?; - let amount: u128 = args[1].as_str().unwrap_or("0").parse().map_err(|_| { - QuantusError::Generic("Second argument must be a number (amount)".to_string()) - })?; + let amount = parse_json_u128(&args[1], "Second argument (amount)")?; let (to_account_id, _) = AccountId32::from_ss58check_with_version(to_address) .map_err(|e| QuantusError::Generic(format!("Invalid to_address: {e:?}")))?; @@ -381,3 +423,48 @@ pub async fn handle_generic_call( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parse_json_u128_accepts_string_and_number() { + assert_eq!(parse_json_u128(&json!("1000"), "amount").unwrap(), 1000); + assert_eq!(parse_json_u128(&json!(1000), "amount").unwrap(), 1000); + assert_eq!( + parse_json_u128(&json!(1_000_000_000_000u64), "amount").unwrap(), + 1_000_000_000_000 + ); + } + + #[test] + fn parse_json_u128_rejects_non_numeric_without_zero_default() { + let err = parse_json_u128(&json!(true), "amount").expect_err("bool must not become 0"); + assert!(err.to_string().contains("must be a JSON string or number"), "unexpected: {err}"); + let err = parse_json_u128(&json!(null), "amount").expect_err("null must not become 0"); + assert!(err.to_string().contains("must be a JSON string or number"), "unexpected: {err}"); + } + + #[test] + fn parse_json_u32_rejects_missing_number_without_zero_default() { + let err = parse_json_u32(&json!("not-a-number"), "referendum_index") + .expect_err("invalid string must fail"); + assert!(err.to_string().contains("must be a u32"), "unexpected: {err}"); + let err = parse_json_u32(&json!(true), "referendum_index") + .expect_err("bool must not become referendum 0"); + assert!( + err.to_string().contains("must be a JSON number"), + "unexpected: {err}" + ); + assert_eq!(parse_json_u32(&json!(7), "referendum_index").unwrap(), 7); + } + + #[test] + fn parse_json_bool_rejects_non_bool() { + let err = parse_json_bool(&json!(1), "aye").expect_err("number must not become false"); + assert!(err.to_string().contains("boolean"), "unexpected: {err}"); + assert!(parse_json_bool(&json!(true), "aye").unwrap()); + } +} diff --git a/src/cli/metadata.rs b/src/cli/metadata.rs index 41c1f22..77e7f2f 100644 --- a/src/cli/metadata.rs +++ b/src/cli/metadata.rs @@ -1,8 +1,15 @@ //! `quantus metadata` subcommand - metadata exploration -use crate::{chain::client::ChainConfig, log_print, log_verbose}; +use crate::{chain::client::ChainConfig, error::QuantusError, log_print, log_verbose}; use colored::Colorize; use subxt::OnlineClient; +/// Accumulate metadata statistics with overflow checks. +pub(crate) fn accumulate_metadata_count(total: usize, add: usize) -> crate::error::Result { + total.checked_add(add).ok_or_else(|| { + QuantusError::Generic("Metadata statistics counter overflowed usize".to_string()) + }) +} + /// Explore chain metadata and display all available pallets and calls pub async fn explore_chain_metadata( client: &OnlineClient, @@ -112,15 +119,16 @@ pub async fn get_metadata_stats(client: &OnlineClient) -> crate::er log_print!(" 🔗 API: Type-safe SubXT"); // Count calls across all pallets - let mut total_calls = 0; - let mut total_storage = 0; + let mut total_calls = 0usize; + let mut total_storage = 0usize; for pallet in &pallets { if let Some(calls) = pallet.call_variants() { - total_calls += calls.len(); + total_calls = accumulate_metadata_count(total_calls, calls.len())?; } if let Some(storage_metadata) = pallet.storage() { - total_storage += storage_metadata.entries().len(); + total_storage = + accumulate_metadata_count(total_storage, storage_metadata.entries().len())?; } } @@ -145,3 +153,23 @@ pub async fn handle_metadata_command( explore_chain_metadata(quantus_client.client(), no_docs, pallet_filter).await } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accumulate_metadata_count_rejects_usize_overflow() { + let err = accumulate_metadata_count(usize::MAX, 1) + .expect_err("unchecked metadata accumulation must not wrap"); + assert!( + err.to_string().contains("overflowed"), + "unexpected overflow error: {err}" + ); + } + + #[test] + fn accumulate_metadata_count_adds_within_bounds() { + assert_eq!(accumulate_metadata_count(10, 5).unwrap(), 15); + } +} diff --git a/src/cli/multisend.rs b/src/cli/multisend.rs index 4b5a321..8d4410a 100644 --- a/src/cli/multisend.rs +++ b/src/cli/multisend.rs @@ -18,10 +18,24 @@ use crate::{ use colored::Colorize; use rand::{seq::SliceRandom, Rng}; use std::{ + collections::HashSet, fs, io::{self, Write}, }; +/// Reject duplicate resolved recipient addresses before amount distribution. +pub(crate) fn ensure_unique_recipients(addresses: &[String]) -> Result<()> { + let mut seen = HashSet::with_capacity(addresses.len()); + for addr in addresses { + if !seen.insert(addr.as_str()) { + return Err(QuantusError::Generic(format!( + "Duplicate recipient address in multisend list: {addr}" + ))); + } + } + Ok(()) +} + /// Generate a random distribution of amounts across n recipients. /// /// Each amount will be in the range [min, max] and all amounts will sum to exactly `total`. @@ -179,6 +193,7 @@ pub async fn handle_multisend_command( let resolved = resolve_address(addr)?; resolved_addresses.push(resolved); } + ensure_unique_recipients(&resolved_addresses)?; let n = resolved_addresses.len(); log_verbose!("Resolved {} addresses", n); @@ -390,4 +405,24 @@ mod tests { // (though this isn't guaranteed - it's probabilistic) assert!(seen_distributions.len() > 1, "Expected multiple different distributions"); } + + #[test] + fn ensure_unique_recipients_rejects_duplicates() { + let addrs = vec![ + "qzAddrA".to_string(), + "qzAddrB".to_string(), + "qzAddrA".to_string(), + ]; + let err = ensure_unique_recipients(&addrs).expect_err("duplicates must fail"); + assert!( + err.to_string().contains("Duplicate recipient"), + "unexpected error: {err}" + ); + } + + #[test] + fn ensure_unique_recipients_accepts_distinct() { + let addrs = vec!["qzAddrA".to_string(), "qzAddrB".to_string()]; + ensure_unique_recipients(&addrs).expect("distinct recipients must succeed"); + } } diff --git a/src/cli/storage.rs b/src/cli/storage.rs index 90df84f..f51fc48 100644 --- a/src/cli/storage.rs +++ b/src/cli/storage.rs @@ -469,6 +469,24 @@ pub async fn count_storage_entries( Ok(total_count) } +/// Maximum entries `quantus storage iterate --limit` will request in one RPC call. +pub(crate) const MAX_STORAGE_ITERATE_LIMIT: u32 = 1000; + +/// Cap/validate the storage iterate `--limit` before forwarding to RPC. +/// +/// `0` means count-only and is always accepted. +pub(crate) fn validate_storage_iterate_limit(limit: u32) -> crate::error::Result { + if limit == 0 { + return Ok(0); + } + if limit > MAX_STORAGE_ITERATE_LIMIT { + return Err(QuantusError::Generic(format!( + "Storage iterate --limit {limit} exceeds maximum of {MAX_STORAGE_ITERATE_LIMIT}" + ))); + } + Ok(limit) +} + /// Iterate through storage map entries with real RPC calls pub async fn iterate_storage_entries( quantus_client: &crate::chain::client::QuantusClient, @@ -478,6 +496,7 @@ pub async fn iterate_storage_entries( decode_as: Option, block_identifier: Option, ) -> crate::error::Result<()> { + let limit = validate_storage_iterate_limit(limit)?; log_print!( "🔄 Iterating storage {}::{} (limit: {})", pallet_name.bright_green(), @@ -893,4 +912,23 @@ mod tests { .expect("full page must yield next start key"); assert_eq!(next, *page.last().unwrap()); } + + #[test] + fn validate_storage_iterate_limit_rejects_above_max() { + let err = validate_storage_iterate_limit(MAX_STORAGE_ITERATE_LIMIT + 1) + .expect_err("limit above max must fail"); + assert!( + err.to_string().contains("exceeds maximum"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_storage_iterate_limit_allows_count_only_and_max() { + assert_eq!(validate_storage_iterate_limit(0).unwrap(), 0); + assert_eq!( + validate_storage_iterate_limit(MAX_STORAGE_ITERATE_LIMIT).unwrap(), + MAX_STORAGE_ITERATE_LIMIT + ); + } } diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 68967c1..9ca32e4 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -157,14 +157,23 @@ pub async fn get_account_nonce( let storage_at = quantus_client.client().storage().at(latest_block_hash); let account_info = storage_at - .fetch_or_default(&storage_addr) + .fetch(&storage_addr) .await .map_err(|e| QuantusError::NetworkError(format!("Failed to fetch account info: {e:?}")))?; - log_verbose!("✅ Account info retrieved with storage query!"); - log_verbose!("đŸ”ĸ Nonce: {}", account_info.nonce); + let (nonce, exists) = crate::chain::client::QuantusClient::interpret_account_nonce( + account_info.map(|info| info.nonce), + ); + if exists { + log_verbose!("✅ Account info retrieved with storage query!"); + } else { + log_print!( + "âš ī¸ Account has no on-chain System::Account entry; reporting nonce 0 (new/unused account)" + ); + } + log_verbose!("đŸ”ĸ Nonce: {} (exists={})", nonce, exists); - Ok(account_info.nonce) + Ok(nonce) } /// Fetch high-security status from chain for an account (SS58). Returns None if disabled or on diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index b390940..0602eac 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -578,6 +578,19 @@ fn apply_extrinsic_failed_to_result(result: &mut VerificationResult, error_msg: result.error_message = Some(error_msg); } +/// Require the submitted extrinsic hash to be present in the included block. +/// +/// Missing hash is an explicit error (reorg / wrong block), not a verification failure. +fn require_proof_verification_extrinsic_index( + our_extrinsic_index: Option, +) -> crate::error::Result { + our_extrinsic_index.ok_or_else(|| { + crate::error::QuantusError::Generic( + "Could not find submitted extrinsic in included block".to_string(), + ) + }) +} + /// Finalize SDK event collection: any ExtrinsicFailed dominates ProofVerified. fn finalize_wormhole_event_collection( found_proof_verified: bool, @@ -609,12 +622,13 @@ async fn check_proof_verification_events( crate::error::QuantusError::NetworkError(format!("Failed to get extrinsics: {e:?}")) })?; - // Find our extrinsic index + // Find our extrinsic index — fail closed if the hash is absent from this block. let our_extrinsic_index = extrinsics .iter() .enumerate() .find(|(_, ext)| ext.hash() == *tx_hash) .map(|(idx, _)| idx); + let ext_idx = require_proof_verification_extrinsic_index(our_extrinsic_index)?; let events = block.events().await.map_err(|e| { crate::error::QuantusError::NetworkError(format!("Failed to fetch events: {e:?}")) @@ -630,52 +644,50 @@ async fn check_proof_verification_events( log_print!("📋 Transaction Events:"); } - if let Some(ext_idx) = our_extrinsic_index { - for event_result in events.iter() { - let event = event_result.map_err(|e| { - crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}")) - })?; - - // Only process events for our extrinsic - if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() { - if event_ext_idx != ext_idx as u32 { - continue; - } + for event_result in events.iter() { + let event = event_result.map_err(|e| { + crate::error::QuantusError::NetworkError(format!("Failed to decode event: {e:?}")) + })?; - // Display event in verbose mode - if verbose { - log_print!( - " 📌 {}.{}", - event.pallet_name().bright_cyan(), - event.variant_name().bright_yellow() - ); + // Only process events for our extrinsic + if let subxt::events::Phase::ApplyExtrinsic(event_ext_idx) = event.phase() { + if event_ext_idx != ext_idx as u32 { + continue; + } - // Try to decode and display event details - if let Ok(typed_event) = - event.as_root_event::() - { - log_print!(" 📝 {:?}", typed_event); - } - } + // Display event in verbose mode + if verbose { + log_print!( + " 📌 {}.{}", + event.pallet_name().bright_cyan(), + event.variant_name().bright_yellow() + ); - // Check for ProofVerified event - if let Ok(Some(proof_verified)) = - event.as_event::() + // Try to decode and display event details + if let Ok(typed_event) = + event.as_root_event::() { - apply_proof_verified_to_result( - &mut verification_result, - proof_verified.exit_amount, - ); + log_print!(" 📝 {:?}", typed_event); } + } - // Check for ExtrinsicFailed event. Dispatch failure dominates any - // ProofVerified event regardless of event ordering. - if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) = - event.as_event::() - { - let error_msg = format_dispatch_error(&dispatch_error, &metadata); - apply_extrinsic_failed_to_result(&mut verification_result, error_msg); - } + // Check for ProofVerified event + if let Ok(Some(proof_verified)) = + event.as_event::() + { + apply_proof_verified_to_result( + &mut verification_result, + proof_verified.exit_amount, + ); + } + + // Check for ExtrinsicFailed event. Dispatch failure dominates any + // ProofVerified event regardless of event ordering. + if let Ok(Some(ExtrinsicFailed { dispatch_error, .. })) = + event.as_event::() + { + let error_msg = format_dispatch_error(&dispatch_error, &metadata); + apply_extrinsic_failed_to_result(&mut verification_result, error_msg); } } } @@ -4898,6 +4910,18 @@ mod tests { SubxtAccountId([seed; 32]) } + #[test] + fn missing_extrinsic_in_proof_verification_block_is_error() { + // #160033: absent hash must not collapse to success=false / "no ProofVerified". + let err = require_proof_verification_extrinsic_index(None) + .expect_err("missing extrinsic must error"); + assert!( + err.to_string().contains("Could not find submitted extrinsic"), + "unexpected error: {err}" + ); + assert_eq!(require_proof_verification_extrinsic_index(Some(2)).unwrap(), 2); + } + #[test] fn proof_verified_after_extrinsic_failed_stays_unsuccessful() { // Vulnerable order-dependent parser set success=true when ProofVerified diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 33f0a9f..b30a9a4 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -158,7 +158,9 @@ impl WalletManager { metadata, }; - // Encrypt and save the wallet with empty password for test wallets + // Empty password is intentional for crystal_* developer wallets: these are + // well-known genesis test keys for local development, not custody material. + // File permissions remain owner-only (0600) via Keystore::save_new_wallet. let encrypted_wallet = keystore.encrypt_wallet_data(&wallet_data, "")?; keystore.save_new_wallet(&encrypted_wallet)?; @@ -698,6 +700,29 @@ mod tests { )); } + #[tokio::test] + #[cfg(unix)] + async fn developer_wallet_empty_password_is_intentional_and_owner_only() { + // #159457: empty password remains intentional for crystal_*; world-readable + // file perms are not — save path must enforce 0600. + use std::os::unix::fs::PermissionsExt; + + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + wallet_manager + .create_developer_wallet("crystal_bob") + .await + .expect("create developer wallet"); + + wallet_manager + .load_wallet("crystal_bob", "") + .expect("empty password must unlock crystal_* developer wallets"); + + let wallet_file = wallet_manager.wallets_dir.join("crystal_bob.json"); + let mode = + fs::metadata(&wallet_file).expect("stat wallet").permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "developer wallet file must be owner-read/write only"); + } + #[tokio::test] async fn test_wallet_file_creation() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; From 3d781aa54e3051069548bd0c31bef36b440ca9b5 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:27:54 +0800 Subject: [PATCH 34/39] fix(wallet): require an explicit password when creating wallets Create previously fell through to an empty password after rejecting --password. Obtain a new password via file, env, or confirmed prompt, and require --allow-empty-password for empty development wallets. Co-authored-by: Cursor --- src/cli/wallet.rs | 36 +++++++-- src/wallet/password.rs | 176 ++++++++++++++++++++++++++++++++++------- 2 files changed, 176 insertions(+), 36 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 9ca32e4..a465243 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -5,7 +5,7 @@ use crate::{ error::QuantusError, log_error, log_print, log_success, log_verbose, wallet::{ - password::{get_mnemonic_from_user, reject_cli_password}, + password::{get_mnemonic_from_user, get_new_wallet_password}, WalletManager, DEFAULT_DERIVATION_PATH, }, }; @@ -25,10 +25,18 @@ pub enum WalletCommands { #[arg(short, long)] name: String, - /// Password to encrypt the wallet (optional, will prompt if not provided) + /// Password to encrypt the wallet (unsupported on argv; use --password-file or prompt) #[arg(short, long)] password: Option, + /// Read encryption password from file (owner-only on Unix) + #[arg(long)] + password_file: Option, + + /// Allow creating a wallet with an empty password (development only) + #[arg(long)] + allow_empty_password: bool, + /// Derivation path (default: m/44'/189189'/0'/0/0) #[arg(short = 'd', long, default_value = DEFAULT_DERIVATION_PATH)] derivation_path: String, @@ -325,22 +333,36 @@ pub async fn handle_wallet_command( node_url: &str, ) -> crate::error::Result<()> { match command { - WalletCommands::Create { name, password, derivation_path, no_derivation } => { + WalletCommands::Create { + name, + password, + password_file, + allow_empty_password, + derivation_path, + no_derivation, + } => { log_print!("🔐 Creating new quantum wallet..."); - reject_cli_password(&password)?; + let final_password = + get_new_wallet_password(&name, password, password_file, allow_empty_password)?; let wallet_manager = WalletManager::new()?; // Choose creation method based on flags let result = if no_derivation { // Use master seed directly (like quantus-node --no-derivation) - wallet_manager.create_wallet_no_derivation(&name, None).await + wallet_manager + .create_wallet_no_derivation(&name, Some(&final_password)) + .await } else if derivation_path == DEFAULT_DERIVATION_PATH { - wallet_manager.create_wallet(&name, None).await + wallet_manager.create_wallet(&name, Some(&final_password)).await } else { wallet_manager - .create_wallet_with_derivation_path(&name, None, &derivation_path) + .create_wallet_with_derivation_path( + &name, + Some(&final_password), + &derivation_path, + ) .await }; diff --git a/src/wallet/password.rs b/src/wallet/password.rs index ab1443a..486a362 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -46,50 +46,84 @@ fn validate_password_file_permissions(_file_path: &str) -> Result<()> { Ok(()) } -/// Get wallet password with convenience options -pub fn get_wallet_password( - wallet_name: &str, - password: Option, - password_file: Option, -) -> Result { - // Raw passwords passed through command-line arguments are visible in process - // listings and command logs. Use --password-file, QUANTUS_WALLET_PASSWORD, - // wallet-specific environment variables, or the masked prompt instead. +fn reject_raw_cli_password(password: &Option) -> Result<()> { if password.is_some() { return Err(crate::error::QuantusError::Generic( "Passing wallet passwords with --password/-p is not supported; use --password-file, QUANTUS_WALLET_PASSWORD, or the interactive prompt".to_string(), )); } + Ok(()) +} - // Option 2: Read password from file if provided - if let Some(file_path) = password_file { - log_verbose!("🔑 Reading password from file: {}", file_path); - validate_password_file_permissions(&file_path)?; - let pwd = std::fs::read_to_string(&file_path) - .map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to read password file '{file_path}': {e}" - )) - })? - .trim() - .to_string(); - return Ok(pwd); - } - - // Option 3: Check environment variable +fn read_password_file(file_path: &str) -> Result { + log_verbose!("🔑 Reading password from file: {}", file_path); + validate_password_file_permissions(file_path)?; + let pwd = std::fs::read_to_string(file_path) + .map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to read password file '{file_path}': {e}" + )) + })? + .trim() + .to_string(); + Ok(pwd) +} + +fn password_from_env(wallet_name: &str) -> Option { if let Ok(env_password) = std::env::var("QUANTUS_WALLET_PASSWORD") { log_verbose!("🔑 Using password from QUANTUS_WALLET_PASSWORD environment variable"); - return Ok(env_password); + return Some(env_password); } - // Option 4: Check for wallet-specific environment variable let wallet_env_var = format!("QUANTUS_WALLET_PASSWORD_{}", wallet_name.to_uppercase()); if let Ok(env_password) = std::env::var(&wallet_env_var) { log_verbose!("🔑 Using password from {} environment variable", wallet_env_var); + return Some(env_password); + } + + None +} + +/// Reject empty passwords unless explicitly allowed for development wallets. +pub fn ensure_password_allowed(password: String, allow_empty: bool) -> Result { + if password.is_empty() && !allow_empty { + return Err(crate::error::QuantusError::Generic( + "Empty wallet passwords are not allowed; provide a password via --password-file, QUANTUS_WALLET_PASSWORD, or the interactive prompt (use --allow-empty-password only for development wallets)".to_string(), + )); + } + Ok(password) +} + +/// Confirm that two newly entered passwords match. +pub fn confirm_new_password(first: &str, second: &str) -> Result { + if first != second { + return Err(crate::error::QuantusError::Generic( + "Passwords do not match".to_string(), + )); + } + Ok(first.to_string()) +} + +/// Get wallet password with convenience options +pub fn get_wallet_password( + wallet_name: &str, + password: Option, + password_file: Option, +) -> Result { + // Raw passwords passed through command-line arguments are visible in process + // listings and command logs. Use --password-file, QUANTUS_WALLET_PASSWORD, + // wallet-specific environment variables, or the masked prompt instead. + reject_raw_cli_password(&password)?; + + if let Some(file_path) = password_file { + return read_password_file(&file_path); + } + + if let Some(env_password) = password_from_env(wallet_name) { return Ok(env_password); } - // Option 5: Try empty password first (for development wallets) + // Try empty password first (for development wallets) log_verbose!("🔑 Trying empty password first..."); let wallet_manager = WalletManager::new()?; if wallet_manager.load_wallet(wallet_name, "").is_ok() { @@ -97,10 +131,41 @@ pub fn get_wallet_password( return Ok("".to_string()); } - // Option 6: Prompt user for password get_password_from_user(&format!("Enter password for wallet '{wallet_name}'")) } +/// Obtain a password for creating a new wallet. +/// +/// Unlike [`get_wallet_password`], this never silently defaults to an empty +/// password. Empty passwords require `allow_empty`. Interactive entry is confirmed. +pub fn get_new_wallet_password( + wallet_name: &str, + password: Option, + password_file: Option, + allow_empty: bool, +) -> Result { + reject_raw_cli_password(&password)?; + + if let Some(file_path) = password_file { + return ensure_password_allowed(read_password_file(&file_path)?, allow_empty); + } + + if let Some(env_password) = password_from_env(wallet_name) { + return ensure_password_allowed(env_password, allow_empty); + } + + if allow_empty { + log_verbose!("🔑 Creating wallet with explicitly allowed empty password"); + return Ok(String::new()); + } + + let first = + get_password_from_user(&format!("Enter a password for new wallet '{wallet_name}'"))?; + let second = get_password_from_user("Confirm password")?; + let confirmed = confirm_new_password(&first, &second)?; + ensure_password_allowed(confirmed, allow_empty) +} + /// Get mnemonic phrase from user pub fn get_mnemonic_from_user() -> Result { log_print!("{}", "Please enter or paste your secret phrase:".bright_yellow()); @@ -134,6 +199,7 @@ pub fn reject_cli_password(password: &Option) -> Result<()> { #[cfg(test)] mod tests { use super::*; + use serial_test::serial; #[test] fn get_wallet_password_rejects_cli_password_flag() { @@ -155,6 +221,58 @@ mod tests { ); } + #[test] + fn get_new_wallet_password_rejects_cli_password_flag() { + let err = get_new_wallet_password("w", Some("secret".into()), None, false).unwrap_err(); + assert!(err.to_string().contains("--password")); + } + + #[test] + fn ensure_password_allowed_rejects_empty_without_opt_in() { + let err = ensure_password_allowed(String::new(), false).unwrap_err(); + assert!(err.to_string().contains("--allow-empty-password")); + } + + #[test] + fn ensure_password_allowed_accepts_empty_with_opt_in() { + assert_eq!(ensure_password_allowed(String::new(), true).unwrap(), ""); + } + + #[test] + fn confirm_new_password_requires_match() { + assert!(confirm_new_password("a", "b").is_err()); + assert_eq!(confirm_new_password("same", "same").unwrap(), "same"); + } + + #[test] + fn get_new_wallet_password_allow_empty_without_other_sources() { + let pwd = get_new_wallet_password("brand-new-wallet", None, None, true).unwrap(); + assert_eq!(pwd, ""); + } + + #[test] + #[serial] + fn get_new_wallet_password_uses_env_and_rejects_empty_env_without_opt_in() { + // SAFETY: serial_test isolates this from other env-mutating tests. + unsafe { + std::env::remove_var("QUANTUS_WALLET_PASSWORD"); + std::env::remove_var("QUANTUS_WALLET_PASSWORD_ENVWALLET"); + std::env::set_var("QUANTUS_WALLET_PASSWORD", "env-secret"); + } + let pwd = get_new_wallet_password("envwallet", None, None, false).unwrap(); + assert_eq!(pwd, "env-secret"); + + unsafe { + std::env::set_var("QUANTUS_WALLET_PASSWORD", ""); + } + let err = get_new_wallet_password("envwallet", None, None, false).unwrap_err(); + assert!(err.to_string().contains("--allow-empty-password")); + + unsafe { + std::env::remove_var("QUANTUS_WALLET_PASSWORD"); + } + } + #[cfg(unix)] mod password_file_permissions { use super::*; From 82867b5db8812199ee7618909752e78848982be9 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:39:38 +0800 Subject: [PATCH 35/39] chore: silence clippy -D warnings failures Replace deprecated GenericArray::from_slice usage, simplify char/find and Option helpers, const-assert tx timeouts, allow intentional public SDK dead_code, and stop moving out of Drop WalletData in examples. Co-authored-by: Cursor --- examples/basic_usage.rs | 4 +- examples/service.rs | 6 +-- examples/wallet_ops.rs | 4 +- examples/wormhole_sdk_e2e.rs | 4 +- src/chain/client.rs | 20 +++---- src/cli/common.rs | 22 ++++---- src/cli/wormhole.rs | 69 ++++++++++++------------ src/wallet/keystore.rs | 101 +++++++++++++---------------------- src/wallet/password.rs | 32 ++--------- 9 files changed, 103 insertions(+), 159 deletions(-) diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index afdf2c5..d12169a 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -35,8 +35,8 @@ async fn main() -> Result<()> { println!("🔗 Connected to Quantus node"); // 4. Load the wallet for transactions - let wallet_data = wallet_manager.load_wallet("lib_example_wallet", "example_password")?; - let keypair = wallet_data.keypair; + let mut wallet_data = wallet_manager.load_wallet("lib_example_wallet", "example_password")?; + let keypair = wallet_data.take_keypair(); // 5. Get account balance let account_id = keypair.to_account_id_32(); diff --git a/examples/service.rs b/examples/service.rs index b58001a..2f371db 100644 --- a/examples/service.rs +++ b/examples/service.rs @@ -77,7 +77,7 @@ impl WalletService { let balance = self.get_wallet_balance(name, password).await?; Ok(WalletInfo { - name: wallet_data.name, + name: wallet_data.name.clone(), address: wallet_data.keypair.to_account_id_ss58check(), balance, created_at: chrono::Utc::now().to_rfc3339(), // Could be stored in wallet data @@ -152,9 +152,9 @@ impl WalletService { /// Private method to perform transfer async fn perform_transfer(&self, request: &TransferRequest) -> Result { // Load sender wallet - let wallet_data = + let mut wallet_data = self.wallet_manager.load_wallet(&request.from_wallet, &request.password)?; - let keypair = wallet_data.keypair; + let keypair = wallet_data.take_keypair(); // Parse recipient address let to_account_id = AccountId32::from_ss58check(&request.to_address) diff --git a/examples/wallet_ops.rs b/examples/wallet_ops.rs index 7614127..85cec74 100644 --- a/examples/wallet_ops.rs +++ b/examples/wallet_ops.rs @@ -72,8 +72,8 @@ impl QuantusApp { amount: u128, ) -> Result { // Load sender wallet - let wallet_data = self.wallet_manager.load_wallet(from_wallet, from_password)?; - let keypair = wallet_data.keypair; + let mut wallet_data = self.wallet_manager.load_wallet(from_wallet, from_password)?; + let keypair = wallet_data.take_keypair(); // Parse recipient address let (to_account_id, _) = AccountId32::from_ss58check_with_version(to_address) diff --git a/examples/wormhole_sdk_e2e.rs b/examples/wormhole_sdk_e2e.rs index 3d36e7f..eb11b7e 100644 --- a/examples/wormhole_sdk_e2e.rs +++ b/examples/wormhole_sdk_e2e.rs @@ -149,8 +149,8 @@ async fn main() -> Result<()> { // 1. wallet ---------------------------------------------------------------- let wm = WalletManager::new()?; - let wallet = wm.load_wallet(&args.funder, &args.password)?; - let funder_kp = wallet.keypair; + let mut wallet = wm.load_wallet(&args.funder, &args.password)?; + let funder_kp = wallet.take_keypair(); let funder_ss58 = funder_kp.to_account_id_ss58check(); println!(" wallet : {funder_ss58}"); diff --git a/src/chain/client.rs b/src/chain/client.rs index d74c4d7..130c968 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -60,7 +60,7 @@ impl QuantusClient { let authority_start = scheme_end + 3; let authority_end = url[authority_start..] - .find(|c| matches!(c, '/' | '?' | '#')) + .find(['/', '?', '#']) .map(|offset| authority_start + offset) .unwrap_or(url.len()); let authority = &url[authority_start..authority_end]; @@ -136,13 +136,10 @@ impl QuantusClient { .map_err(|e| { QuantusError::NetworkError(format!("Failed to fetch runtime version: {e:?}")) })?; - crate::config::validate_runtime_version_value(&runtime_version).map_err(|e| { - match e { - QuantusError::NetworkError(msg) => QuantusError::NetworkError(format!( - "{msg} (from {display_node_url})" - )), - other => other, - } + crate::config::validate_runtime_version_value(&runtime_version).map_err(|e| match e { + QuantusError::NetworkError(msg) => + QuantusError::NetworkError(format!("{msg} (from {display_node_url})")), + other => other, })?; log_verbose!("✅ Connected to Quantus node successfully!"); @@ -348,8 +345,7 @@ mod tests { .expect("clock must be after unix epoch") .as_nanos() ); - let attacker_controlled_url = - format!("https://api-user:{secret}@rpc.example.invalid/ws"); + let attacker_controlled_url = format!("https://api-user:{secret}@rpc.example.invalid/ws"); let error = match QuantusClient::new(&attacker_controlled_url).await { Ok(_) => panic!("non-WebSocket scheme must fail"), @@ -374,9 +370,7 @@ mod tests { #[test] fn sanitize_url_for_diagnostics_strips_userinfo() { assert_eq!( - QuantusClient::sanitize_url_for_diagnostics( - "wss://user:pass@rpc.example.com/path?q=1" - ), + QuantusClient::sanitize_url_for_diagnostics("wss://user:pass@rpc.example.com/path?q=1"), "wss://rpc.example.com/path?q=1" ); assert_eq!( diff --git a/src/cli/common.rs b/src/cli/common.rs index fb4282b..fd1edfd 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -872,11 +872,13 @@ pub async fn submit_preimage( Err(e) => { // Do not trust formatted error substrings (e.g. "AlreadyNoted"). Only // continue when the expected preimage bytes are present on-chain. - verify_preimage_on_chain(quantus_client, &encoded_call).await.map_err(|verify_err| { - crate::error::QuantusError::Generic(format!( + verify_preimage_on_chain(quantus_client, &encoded_call).await.map_err( + |verify_err| { + crate::error::QuantusError::Generic(format!( "Preimage submission failed ({e}); on-chain verification also failed ({verify_err})" )) - })?; + }, + )?; crate::log_print!( "✅ {} Expected preimage already exists on-chain, continuing", "OK".bright_green().bold() @@ -1006,11 +1008,9 @@ mod tests { describe_watched_tx_event(WatchedTxEvent::StreamEnded, TransactionStage::Included,) .is_err() ); - let timeout_err = describe_watched_tx_event( - WatchedTxEvent::StreamTimedOut, - TransactionStage::Included, - ) - .expect_err("silent subscription must time out instead of waiting forever"); + let timeout_err = + describe_watched_tx_event(WatchedTxEvent::StreamTimedOut, TransactionStage::Included) + .expect_err("silent subscription must time out instead of waiting forever"); assert!( timeout_err.to_string().contains("timed out"), "unexpected timeout error: {timeout_err}" @@ -1028,8 +1028,10 @@ mod tests { tx_status_watch_timeout_secs(TransactionStage::Finalized), TX_STATUS_FINALIZED_TIMEOUT_SECS ); - assert!(TX_STATUS_INACTIVITY_TIMEOUT_SECS > 0); - assert!(TX_STATUS_INCLUDED_TIMEOUT_SECS < TX_STATUS_FINALIZED_TIMEOUT_SECS); + const { + assert!(TX_STATUS_INACTIVITY_TIMEOUT_SECS > 0); + assert!(TX_STATUS_INCLUDED_TIMEOUT_SECS < TX_STATUS_FINALIZED_TIMEOUT_SECS); + } } #[test] diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 0602eac..eda9644 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -303,8 +303,8 @@ pub fn parse_secret_hex(secret_hex: &str) -> Result<[u8; 32], String> { /// Read a hex-encoded secret from a file and validate that it is exactly 32 bytes. fn read_secret_hex_file(path: &str) -> Result { - let secret_hex = std::fs::read_to_string(path) - .map_err(|e| format!("Failed to read secret file: {}", e))?; + let secret_hex = + std::fs::read_to_string(path).map_err(|e| format!("Failed to read secret file: {}", e))?; let secret_hex = secret_hex.trim().to_string(); parse_secret_hex(&secret_hex)?; Ok(secret_hex) @@ -672,9 +672,7 @@ async fn check_proof_verification_events( } // Check for ProofVerified event - if let Ok(Some(proof_verified)) = - event.as_event::() - { + if let Ok(Some(proof_verified)) = event.as_event::() { apply_proof_verified_to_result( &mut verification_result, proof_verified.exit_amount, @@ -945,8 +943,9 @@ pub enum WormholeCommands { #[arg(short = 'm', long, required_unless_present_any = ["wallet", "secret_file"], conflicts_with_all = ["wallet", "secret_file"])] mnemonic: Option, - /// File containing the direct wormhole secret (32-byte hex string, alternative to --wallet or --mnemonic) - /// Use this with a secret generated by `quantus-node key quantus --scheme wormhole` + /// File containing the direct wormhole secret (32-byte hex string, alternative to --wallet + /// or --mnemonic) Use this with a secret generated by `quantus-node key quantus --scheme + /// wormhole` #[arg(long, required_unless_present_any = ["wallet", "mnemonic"], conflicts_with_all = ["wallet", "mnemonic"])] secret_file: Option, @@ -962,7 +961,8 @@ pub enum WormholeCommands { #[arg(short, long)] amount: Option, - /// Destination address for withdrawn funds (required when using --mnemonic or --secret-file) + /// Destination address for withdrawn funds (required when using --mnemonic or + /// --secret-file) #[arg(long)] destination: Option, @@ -1274,6 +1274,8 @@ pub async fn at_finalized_block( /// Uses [`crate::error::Result`] (not `anyhow`) so it composes with the rest /// of the SDK surface. Network/decoding failures are wrapped in /// [`crate::error::QuantusError::NetworkError`]. +// Public SDK helper (re-exported from lib); unused by the CLI binary itself. +#[allow(dead_code)] pub async fn at_best_block( quantus_client: &QuantusClient, ) -> crate::error::Result>> { @@ -1611,6 +1613,8 @@ pub async fn aggregate_public_batch( /// [`submit_unsigned_verify_private_batch`] alongside the block + tx hash. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IncludedAt { + /// Inclusion in a best (non-finalized) block. Kept for SDK callers. + #[allow(dead_code)] Best, Finalized, } @@ -1974,10 +1978,10 @@ fn event_matches_expected( expected: &ExpectedTransferEvent, ) -> bool { event.to == expected.wormhole_address && - expected.funding_account.as_ref().map_or(true, |from| &event.from == from) && - expected.amount.map_or(true, |amount| event.amount == amount) && - expected.transfer_count.map_or(true, |count| event.transfer_count == count) && - expected.leaf_index.map_or(true, |leaf| event.leaf_index == leaf) + expected.funding_account.as_ref().is_none_or(|from| &event.from == from) && + expected.amount.is_none_or(|amount| event.amount == amount) && + expected.transfer_count.is_none_or(|count| event.transfer_count == count) && + expected.leaf_index.is_none_or(|leaf| event.leaf_index == leaf) } fn parse_expected_transfer_events( @@ -2062,6 +2066,8 @@ async fn get_minting_account( /// Destination-only matching rejects ambiguous duplicate destinations instead of /// accepting the first event. Internal call sites that know intended /// from/amount/transfer_count bind those attributes before accepting an event. +// Public SDK helper (re-exported from lib); unused by the CLI binary itself. +#[allow(dead_code)] pub fn parse_transfer_events( events: &[wormhole::events::NativeTransferred], expected_addresses: &[SubxtAccountId], @@ -3585,11 +3591,7 @@ async fn run_dissolve( .client() .storage() .at(finalized_block_hash) - .fetch( - &quantus_node::api::storage() - .wormhole() - .transfer_count(wormhole_address.clone()), - ) + .fetch(&quantus_node::api::storage().wormhole().transfer_count(wormhole_address.clone())) .await .map_err(|e| { crate::error::QuantusError::Generic(format!( @@ -4265,9 +4267,9 @@ mod tests { .expect_err("oversized mismatched Merkle proof must be rejected"); let message = err.to_string(); assert!( - message.contains("exceeds max") - || message.contains("expected 60 bytes") - || message.contains("does not match siblings length"), + message.contains("exceeds max") || + message.contains("expected 60 bytes") || + message.contains("does not match siblings length"), "unexpected rejection reason: {message}" ); assert!( @@ -4899,10 +4901,7 @@ mod tests { ], ] { let result = TestCli::try_parse_from(args.clone()); - assert!( - result.is_err(), - "wormhole must not accept --secret on argv; args={args:?}" - ); + assert!(result.is_err(), "wormhole must not accept --secret on argv; args={args:?}"); } } @@ -5021,13 +5020,16 @@ mod tests { // Destination-only public helper must refuse ambiguous duplicates. let ambiguous = parse_transfer_events( - &[attacker_event, wormhole::events::NativeTransferred { - from: intended_from, - to: shared_to.clone(), - amount: 999_000, - transfer_count: 42, - leaf_index: 420, - }], + &[ + attacker_event, + wormhole::events::NativeTransferred { + from: intended_from, + to: shared_to.clone(), + amount: 999_000, + transfer_count: 42, + leaf_index: 420, + }, + ], &[shared_to], block_hash, ); @@ -5056,9 +5058,8 @@ mod tests { ); match load_multiround_wallet("crystal_alice", None, None) { - Ok(_) => panic!( - "wallet without mnemonic must error instead of generating an ephemeral one" - ), + Ok(_) => + panic!("wallet without mnemonic must error instead of generating an ephemeral one"), Err(err) => { let msg = err.to_string(); assert!( diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 4b9a8c0..ded0f1b 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -52,12 +52,7 @@ fn keystore_lock() -> &'static Mutex<()> { } fn wallet_filename(name: &str) -> Result { - if name.is_empty() || - name.contains('/') || - name.contains('\\') || - name == "." || - name == ".." - { + if name.is_empty() || name.contains('/') || name.contains('\\') || name == "." || name == ".." { return Err(WalletError::InvalidName.into()); } Ok(format!("{name}.json")) @@ -162,8 +157,7 @@ impl WalletCreateLocks { }); let mut active = locks.active.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); while active.contains(&path) { - active = - locks.available.wait(active).unwrap_or_else(|poisoned| poisoned.into_inner()); + active = locks.available.wait(active).unwrap_or_else(|poisoned| poisoned.into_inner()); } active.insert(path.clone()); WalletCreateGuard { path, locks } @@ -172,8 +166,7 @@ impl WalletCreateLocks { impl Drop for WalletCreateGuard { fn drop(&mut self) { - let mut active = - self.locks.active.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut active = self.locks.active.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); active.remove(&self.path); self.locks.available.notify_all(); } @@ -353,6 +346,8 @@ impl Keystore { } /// Save an encrypted wallet to disk (may replace an existing wallet file). + // Public keystore API; migration/create paths use specialized helpers. + #[allow(dead_code)] pub fn save_wallet(&self, wallet: &EncryptedWallet) -> Result<()> { let _guard = keystore_lock() .lock() @@ -369,7 +364,8 @@ impl Keystore { let file_name = wallet_filename(&wallet.name)?; let wallet_file = self.storage_path.join(&file_name); let wallet_json = serde_json::to_string_pretty(wallet)?; - let tmp_file = write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; + let tmp_file = + write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; // Atomically create the destination without replacing an existing wallet. // hard_link fails with AlreadyExists when the final name is taken. @@ -421,7 +417,8 @@ impl Keystore { let wallet_json = serde_json::to_string_pretty(wallet)?; // Unpredictable, exclusively-created temp so attackers cannot pre-position a // symlink at a deterministic path. rename replaces the directory entry only. - let tmp_file = write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; + let tmp_file = + write_temp_wallet_bytes(&self.storage_path, &wallet.name, wallet_json.as_bytes())?; match fs::rename(&tmp_file, &wallet_file) { Ok(()) => { #[cfg(unix)] @@ -470,8 +467,8 @@ impl Keystore { let (account_id, format) = AccountId32::from_ss58check_with_version(address) .map_err(|_| WalletError::InvalidAddress)?; - if format != quantus_ss58_format() - || account_id.to_ss58check_with_version(quantus_ss58_format()) != address + if format != quantus_ss58_format() || + account_id.to_ss58check_with_version(quantus_ss58_format()) != address { return Err(WalletError::InvalidAddress.into()); } @@ -612,8 +609,8 @@ impl Keystore { // 2. Decrypt the data. An AES-GCM authentication failure means the password // was wrong (or the file was tampered with) - this is the password check. - let nonce_bytes = <[u8; 12]>::try_from(&encrypted.aes_nonce[..]) - .map_err(|_| WalletError::Decryption)?; + let nonce_bytes = + <[u8; 12]>::try_from(&encrypted.aes_nonce[..]).map_err(|_| WalletError::Decryption)?; let nonce = Nonce::from(nonce_bytes); let mut decrypted_data = cipher .decrypt(&nonce, encrypted.encrypted_data.as_ref()) @@ -729,10 +726,8 @@ mod tests { #[test] fn quantum_keypair_debug_redacts_private_key() { - let keypair = QuantumKeyPair { - public_key: vec![1, 2, 3], - private_key: vec![0xde, 0xad, 0xbe, 0xef], - }; + let keypair = + QuantumKeyPair { public_key: vec![1, 2, 3], private_key: vec![0xde, 0xad, 0xbe, 0xef] }; let rendered = format!("{keypair:?}"); assert!( rendered.contains("[redacted]"), @@ -755,10 +750,7 @@ mod tests { }; let rendered = format!("{data:?}"); assert!(rendered.contains("[redacted]"), "mnemonic must be redacted: {rendered}"); - assert!( - !rendered.contains("abandon"), - "Debug must not leak mnemonic words: {rendered}" - ); + assert!(!rendered.contains("abandon"), "Debug must not leak mnemonic words: {rendered}"); } #[test] @@ -981,9 +973,8 @@ mod tests { ]; for invalid_addr in invalid_addresses { - let panicked = std::panic::catch_unwind(|| { - QuantumKeyPair::ss58_to_account_id(invalid_addr) - }); + let panicked = + std::panic::catch_unwind(|| QuantumKeyPair::ss58_to_account_id(invalid_addr)); assert!(panicked.is_ok(), "Must not panic on invalid address: {invalid_addr}"); assert!( matches!( @@ -998,10 +989,7 @@ mod tests { #[test] fn to_dilithium_keypair_rejects_malformed_key_bytes() { // #160783: malformed key material must not panic. - let keypair = QuantumKeyPair { - public_key: vec![1, 2, 3], - private_key: vec![4, 5, 6], - }; + let keypair = QuantumKeyPair { public_key: vec![1, 2, 3], private_key: vec![4, 5, 6] }; let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { keypair.to_dilithium_keypair() })); @@ -1358,11 +1346,11 @@ mod tests { argon2 .hash_password_into(password.as_bytes(), &salt, &mut derived) .expect("derive key"); - let cipher = Aes256Gcm::new(Key::::from_slice(&derived)); + let aes_key = Key::::from(derived); + let cipher = Aes256Gcm::new(&aes_key); + let nonce = Nonce::from(nonce_bytes); let plaintext = serde_json::to_vec(data).expect("serialize"); - let encrypted_data = cipher - .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_ref()) - .expect("encrypt"); + let encrypted_data = cipher.encrypt(&nonce, plaintext.as_ref()).expect("encrypt"); EncryptedWallet { name: data.name.clone(), address: data.keypair.to_account_id_ss58check(), @@ -1397,10 +1385,7 @@ mod tests { #[test] fn malformed_public_key_returns_error_instead_of_panicking() { // #160640: address derivation must not unwind on garbage public keys. - let keypair = QuantumKeyPair { - public_key: vec![0x41], - private_key: vec![0x42; 32], - }; + let keypair = QuantumKeyPair { public_key: vec![0x41], private_key: vec![0x42; 32] }; let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { let _ = keypair.to_account_id_ss58check(); })); @@ -1426,10 +1411,7 @@ mod tests { assert!(Keystore::has_embedded_key_material(&legacy)); let err = keystore.save_wallet(&legacy).expect_err("must refuse digest-bearing wallets"); - assert!( - err.to_string().contains("embeds Argon2 digest"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("embeds Argon2 digest"), "unexpected error: {err}"); assert!( !temp_dir.path().join("legacy-refuse.json").exists(), "digest-bearing wallet must not be written" @@ -1445,9 +1427,7 @@ mod tests { // Save a wallet in the legacy format (digest embedded in argon2_params) let legacy = encrypt_legacy(&data, "pw"); assert!(Keystore::has_embedded_key_material(&legacy)); - keystore - .save_wallet_unchecked_for_tests(&legacy) - .expect("Save should succeed"); + keystore.save_wallet_unchecked_for_tests(&legacy).expect("Save should succeed"); // Legacy files must still decrypt with the correct password... let decrypted = keystore @@ -1492,8 +1472,7 @@ mod tests { #[cfg(unix)] #[test] fn test_legacy_migration_save_failure_fails_closed() { - use std::fs; - use std::os::unix::fs::PermissionsExt; + use std::{fs, os::unix::fs::PermissionsExt}; let temp_dir = TempDir::new().expect("Failed to create temp directory"); let keystore = Keystore::new(temp_dir.path()); @@ -1501,9 +1480,7 @@ mod tests { let legacy = encrypt_legacy(&data, "pw"); assert!(Keystore::has_embedded_key_material(&legacy)); - keystore - .save_wallet_unchecked_for_tests(&legacy) - .expect("Save should succeed"); + keystore.save_wallet_unchecked_for_tests(&legacy).expect("Save should succeed"); // Force migration save to fail (cannot create .json.tmp in read-only dir). let mut perms = fs::metadata(temp_dir.path()).unwrap().permissions(); @@ -1539,8 +1516,7 @@ mod tests { #[cfg(unix)] #[test] fn save_wallet_does_not_follow_predictable_tmp_symlink() { - use std::fs; - use std::os::unix::fs::symlink; + use std::{fs, os::unix::fs::symlink}; let temp = TempDir::new().expect("temp dir"); let wallets_dir = temp.path().join("wallets"); @@ -1562,7 +1538,9 @@ mod tests { .encrypt_wallet_data(&data, "password chosen by wallet owner") .expect("encrypt"); - keystore.save_wallet(&encrypted).expect("save must succeed without following symlink"); + keystore + .save_wallet(&encrypted) + .expect("save must succeed without following symlink"); assert_eq!( fs::read(&victim).expect("read victim"), @@ -1595,10 +1573,8 @@ mod tests { "second create must fail with AlreadyExists, got: {result:?}" ); - let loaded = keystore - .load_wallet("exclusive-wallet") - .expect("load") - .expect("wallet present"); + let loaded = + keystore.load_wallet("exclusive-wallet").expect("load").expect("wallet present"); assert_eq!( loaded.address, first.address, "existing wallet key material must not be replaced" @@ -1612,8 +1588,7 @@ mod tests { let temp_dir = TempDir::new().expect("Failed to create temp directory"); let keystore = Keystore::new(temp_dir.path()); let data = make_test_wallet_data("bad-version", 25); - let mut encrypted = - keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + let mut encrypted = keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); assert_eq!(encrypted.encryption_version, 2); encrypted.encryption_version = u32::MAX; @@ -1630,8 +1605,7 @@ mod tests { let temp_dir = TempDir::new().expect("Failed to create temp directory"); let keystore = Keystore::new(temp_dir.path()); let data = make_test_wallet_data("bad-nonce", 26); - let mut encrypted = - keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + let mut encrypted = keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); assert_eq!(encrypted.aes_nonce.len(), 12); encrypted.aes_nonce.truncate(1); @@ -1651,8 +1625,7 @@ mod tests { let temp = TempDir::new().expect("temp dir"); let keystore = Keystore::new(temp.path()); let data = make_test_wallet_data("safe-name", 24); - let mut encrypted = - keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + let mut encrypted = keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); for bad_name in ["../evil", "foo/bar", "foo\\bar", ".", "..", ""] { encrypted.name = bad_name.to_string(); diff --git a/src/wallet/password.rs b/src/wallet/password.rs index 486a362..c062821 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -97,9 +97,7 @@ pub fn ensure_password_allowed(password: String, allow_empty: bool) -> Result Result { if first != second { - return Err(crate::error::QuantusError::Generic( - "Passwords do not match".to_string(), - )); + return Err(crate::error::QuantusError::Generic("Passwords do not match".to_string())); } Ok(first.to_string()) } @@ -186,16 +184,6 @@ pub fn get_password_from_user(prompt: &str) -> Result { Ok(password) } -/// Reject raw `--password`/`-p` values for handlers that bypass [`get_wallet_password`]. -pub fn reject_cli_password(password: &Option) -> Result<()> { - if password.is_some() { - return Err(crate::error::QuantusError::Generic( - "Passing wallet passwords with --password/-p is not supported; use an interactive prompt or a supported non-argv secret source".to_string(), - )); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -205,20 +193,7 @@ mod tests { fn get_wallet_password_rejects_cli_password_flag() { let err = get_wallet_password("w", Some("secret".into()), None).unwrap_err(); let msg = err.to_string(); - assert!( - msg.contains("--password"), - "expected unsupported --password message, got: {msg}" - ); - } - - #[test] - fn wallet_create_rejects_cli_password() { - let err = reject_cli_password(&Some("secret".into())).unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("--password"), - "expected unsupported --password message, got: {msg}" - ); + assert!(msg.contains("--password"), "expected unsupported --password message, got: {msg}"); } #[test] @@ -276,8 +251,7 @@ mod tests { #[cfg(unix)] mod password_file_permissions { use super::*; - use std::fs; - use std::os::unix::fs::PermissionsExt; + use std::{fs, os::unix::fs::PermissionsExt}; fn write_password_file(mode: u32) -> (tempfile::TempDir, String) { let dir = tempfile::tempdir().expect("temp dir"); From e6f112ad12c78fe0fc6c67702c7223c9dd2ba860 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 15:39:51 +0800 Subject: [PATCH 36/39] style: apply rustfmt after clippy run Co-authored-by: Cursor --- build.rs | 10 ++------ src/bins.rs | 34 +++++++++++++------------- src/cli/block.rs | 15 +++--------- src/cli/generic_call.rs | 21 +++++++--------- src/cli/metadata.rs | 5 +--- src/cli/multisend.rs | 11 ++------- src/cli/multisig.rs | 19 +++++---------- src/cli/send.rs | 6 +---- src/cli/storage.rs | 21 +++++----------- src/cli/system.rs | 18 ++++++-------- src/cli/transfers.rs | 14 ++++------- src/cli/update.rs | 34 +++++++++++++------------- src/cli/wallet.rs | 49 ++++++++++++-------------------------- src/collect_rewards_lib.rs | 21 ++++------------ src/config/mod.rs | 16 ++++++------- src/subsquid/client.rs | 33 +++++++++++++------------ src/wallet/mod.rs | 13 ++++------ src/wormhole_lib.rs | 21 +++++++++------- 18 files changed, 134 insertions(+), 227 deletions(-) diff --git a/build.rs b/build.rs index 2b8a0e8..55bc5db 100644 --- a/build.rs +++ b/build.rs @@ -78,15 +78,9 @@ fn write_manifest( let mut content = String::new(); content.push_str("{\n"); content.push_str(" \"manifest_version\": 1,\n"); - content.push_str(&format!( - " \"package_version\": \"{}\",\n", - json_escape(pkg_version) - )); + content.push_str(&format!(" \"package_version\": \"{}\",\n", json_escape(pkg_version))); content.push_str(&format!(" \"num_leaf_proofs\": {},\n", num_leaf_proofs)); - content.push_str(&format!( - " \"num_private_batch_proofs\": {},\n", - num_private_batch_proofs - )); + content.push_str(&format!(" \"num_private_batch_proofs\": {},\n", num_private_batch_proofs)); content.push_str(" \"files\": {\n"); for (idx, filename) in MANIFESTED_FILES.iter().enumerate() { let comma = if idx + 1 == MANIFESTED_FILES.len() { "" } else { "," }; diff --git a/src/bins.rs b/src/bins.rs index 66824d6..92477be 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -233,7 +233,11 @@ fn validate_manifest(dir: &Path, manifest: &ArtifactManifest) -> Result<()> { Ok(()) } -fn write_manifest(dir: &Path, num_leaf_proofs: usize, num_private_batch_proofs: usize) -> Result<()> { +fn write_manifest( + dir: &Path, + num_leaf_proofs: usize, + num_private_batch_proofs: usize, +) -> Result<()> { let mut files = std::collections::BTreeMap::new(); for filename in MANIFESTED_FILES { ensure_regular_file(&dir.join(filename))?; @@ -303,11 +307,14 @@ fn atomic_write_new_file(path: &Path, contents: &[u8]) -> Result<()> { } } - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp_path) - .map_err(|e| QuantusError::Generic(format!("Failed to create {}: {}", path.display(), e)))?; + let mut file = + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .map_err(|e| { + QuantusError::Generic(format!("Failed to create {}: {}", path.display(), e)) + })?; file.write_all(contents) .and_then(|_| file.sync_all()) .map_err(|e| QuantusError::Generic(format!("Failed to write {}: {}", path.display(), e)))?; @@ -384,10 +391,7 @@ mod tests { fs::write(dir.join("private_batch_verifier.bin"), b"attacker-substituted-circuit").unwrap(); let err = verify_manifest(dir).expect_err("tampered verifier must fail authentication"); - assert!( - err.to_string().contains("hash mismatch"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("hash mismatch"), "unexpected error: {err}"); assert!(!is_ready(dir)); } @@ -406,10 +410,7 @@ mod tests { std::env::remove_var(BINS_DIR_ENV); let err = result.expect_err("incomplete/unauthenticated dir must be rejected"); - assert!( - err.to_string().contains("lacks a valid manifest"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("lacks a valid manifest"), "unexpected error: {err}"); } #[test] @@ -429,10 +430,7 @@ mod tests { std::env::remove_var(BINS_DIR_ENV); let err = result.expect_err("symlinked bins dir must be rejected"); - assert!( - err.to_string().contains("symlinked bins directory"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("symlinked bins directory"), "unexpected error: {err}"); } #[test] diff --git a/src/cli/block.rs b/src/cli/block.rs index abdcac6..ce73712 100644 --- a/src/cli/block.rs +++ b/src/cli/block.rs @@ -803,9 +803,7 @@ pub(crate) fn validate_block_list_range( step: u32, ) -> crate::error::Result { if step == 0 { - return Err(QuantusError::Generic( - "Block list --step must be greater than 0".to_string(), - )); + return Err(QuantusError::Generic("Block list --step must be greater than 0".to_string())); } if start > end { return Err(QuantusError::Generic(format!( @@ -1068,10 +1066,7 @@ mod tests { fn validate_block_list_range_rejects_inverted_bounds() { let err = validate_block_list_range(100, 50, 1) .expect_err("start > end must fail before underflow"); - assert!( - err.to_string().contains("must be <= end"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("must be <= end"), "unexpected error: {err}"); } #[test] @@ -1084,10 +1079,7 @@ mod tests { fn validate_block_list_range_rejects_unbounded_span() { let err = validate_block_list_range(0, MAX_BLOCK_LIST_COUNT, 1) .expect_err("span above MAX_BLOCK_LIST_COUNT must fail"); - assert!( - err.to_string().contains("too large"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("too large"), "unexpected error: {err}"); } #[test] @@ -1098,4 +1090,3 @@ mod tests { ); } } - diff --git a/src/cli/generic_call.rs b/src/cli/generic_call.rs index 930e731..7da15f0 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -20,21 +20,19 @@ pub(crate) fn parse_json_u128(value: &Value, label: &str) -> crate::error::Resul return Ok(u128::from(n)); } if let Some(n) = value.as_number() { - return n.to_string().parse::().map_err(|_| { - QuantusError::Generic(format!("{label} must be a non-negative integer")) - }); + return n + .to_string() + .parse::() + .map_err(|_| QuantusError::Generic(format!("{label} must be a non-negative integer"))); } - Err(QuantusError::Generic(format!( - "{label} must be a JSON string or number (got {value})" - ))) + Err(QuantusError::Generic(format!("{label} must be a JSON string or number (got {value})"))) } /// Parse a JSON value as `u32`, accepting number or numeric string forms. pub(crate) fn parse_json_u32(value: &Value, label: &str) -> crate::error::Result { if let Some(n) = value.as_u64() { - return u32::try_from(n).map_err(|_| { - QuantusError::Generic(format!("{label} exceeds u32::MAX")) - }); + return u32::try_from(n) + .map_err(|_| QuantusError::Generic(format!("{label} exceeds u32::MAX"))); } if let Some(s) = value.as_str() { return s.parse::().map_err(|_| { @@ -454,10 +452,7 @@ mod tests { assert!(err.to_string().contains("must be a u32"), "unexpected: {err}"); let err = parse_json_u32(&json!(true), "referendum_index") .expect_err("bool must not become referendum 0"); - assert!( - err.to_string().contains("must be a JSON number"), - "unexpected: {err}" - ); + assert!(err.to_string().contains("must be a JSON number"), "unexpected: {err}"); assert_eq!(parse_json_u32(&json!(7), "referendum_index").unwrap(), 7); } diff --git a/src/cli/metadata.rs b/src/cli/metadata.rs index 77e7f2f..d91986c 100644 --- a/src/cli/metadata.rs +++ b/src/cli/metadata.rs @@ -162,10 +162,7 @@ mod tests { fn accumulate_metadata_count_rejects_usize_overflow() { let err = accumulate_metadata_count(usize::MAX, 1) .expect_err("unchecked metadata accumulation must not wrap"); - assert!( - err.to_string().contains("overflowed"), - "unexpected overflow error: {err}" - ); + assert!(err.to_string().contains("overflowed"), "unexpected overflow error: {err}"); } #[test] diff --git a/src/cli/multisend.rs b/src/cli/multisend.rs index 8d4410a..5df2ebe 100644 --- a/src/cli/multisend.rs +++ b/src/cli/multisend.rs @@ -408,16 +408,9 @@ mod tests { #[test] fn ensure_unique_recipients_rejects_duplicates() { - let addrs = vec![ - "qzAddrA".to_string(), - "qzAddrB".to_string(), - "qzAddrA".to_string(), - ]; + let addrs = vec!["qzAddrA".to_string(), "qzAddrB".to_string(), "qzAddrA".to_string()]; let err = ensure_unique_recipients(&addrs).expect_err("duplicates must fail"); - assert!( - err.to_string().contains("Duplicate recipient"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("Duplicate recipient"), "unexpected error: {err}"); } #[test] diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 8ad3751..d86524e 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -497,10 +497,10 @@ fn matching_multisig_created_address( threshold: u32, nonce: u64, ) -> Option { - if &event.creator != creator - || event.threshold != threshold - || event.nonce != nonce - || !sorted_account_ids_equal(&event.signers, signers) + if &event.creator != creator || + event.threshold != threshold || + event.nonce != nonce || + !sorted_account_ids_equal(&event.signers, signers) { return None; } @@ -3181,10 +3181,7 @@ mod tests { let signer = account(7); let with_dup = predict_multisig_address(vec![signer.clone(), signer.clone()], 2, 0); let unique = predict_multisig_address(vec![signer], 2, 0); - assert_eq!( - with_dup, unique, - "multisig address prediction must ignore duplicate signers" - ); + assert_eq!(with_dup, unique, "multisig address prediction must ignore duplicate signers"); } #[tokio::test] @@ -3193,11 +3190,7 @@ mod tests { let signer_ss58 = ss58(&account(7)); let duplicate_csv = format!("{0},{0}", signer_ss58); let result = handle_multisig_command( - MultisigCommands::PredictAddress { - signers: duplicate_csv, - threshold: 2, - nonce: 0, - }, + MultisigCommands::PredictAddress { signers: duplicate_csv, threshold: 2, nonce: 0 }, "ws://127.0.0.1:9944", ExecutionMode::default(), ) diff --git a/src/cli/send.rs b/src/cli/send.rs index 3dd8310..6b5e45d 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -725,11 +725,7 @@ pub async fn get_batch_limits(quantus_client: &QuantusClient) -> Result<(u32, u3 })?; let (safe_limit, recommended_limit) = limits_from_batched_calls_limit(batched_calls_limit); - log_verbose!( - "📊 Chain batched calls limit: {} (safe: {})", - batched_calls_limit, - safe_limit - ); + log_verbose!("📊 Chain batched calls limit: {} (safe: {})", batched_calls_limit, safe_limit); Ok((safe_limit, recommended_limit)) } diff --git a/src/cli/storage.rs b/src/cli/storage.rs index f51fc48..6cc0483 100644 --- a/src/cli/storage.rs +++ b/src/cli/storage.rs @@ -382,9 +382,9 @@ fn accumulate_storage_key_count(total_count: u32, keys_len: usize) -> crate::err let keys_count = u32::try_from(keys_len).map_err(|_| { QuantusError::Generic("RPC returned too many storage keys in one page".to_string()) })?; - total_count.checked_add(keys_count).ok_or_else(|| { - QuantusError::Generic("Storage entry count exceeds u32::MAX".to_string()) - }) + total_count + .checked_add(keys_count) + .ok_or_else(|| QuantusError::Generic("Storage entry count exceeds u32::MAX".to_string())) } /// Decide the next `state_getKeysPaged` start key, rejecting non-advancing cursors. @@ -879,10 +879,7 @@ mod tests { fn accumulate_storage_key_count_rejects_u32_overflow() { let err = accumulate_storage_key_count(u32::MAX, 1) .expect_err("unchecked u32 accumulation must not wrap"); - assert!( - err.to_string().contains("u32::MAX"), - "unexpected overflow error: {err}" - ); + assert!(err.to_string().contains("u32::MAX"), "unexpected overflow error: {err}"); } #[test] @@ -892,10 +889,7 @@ mod tests { let stuck_key = page.last().cloned().unwrap(); let err = next_storage_pagination_key(Some(&stuck_key), &page, 1000) .expect_err("same-cursor pagination must fail closed"); - assert!( - err.to_string().contains("did not advance"), - "unexpected pagination error: {err}" - ); + assert!(err.to_string().contains("did not advance"), "unexpected pagination error: {err}"); } #[test] @@ -917,10 +911,7 @@ mod tests { fn validate_storage_iterate_limit_rejects_above_max() { let err = validate_storage_iterate_limit(MAX_STORAGE_ITERATE_LIMIT + 1) .expect_err("limit above max must fail"); - assert!( - err.to_string().contains("exceeds maximum"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("exceeds maximum"), "unexpected error: {err}"); } #[test] diff --git a/src/cli/system.rs b/src/cli/system.rs index a65b99e..5efd2c1 100644 --- a/src/cli/system.rs +++ b/src/cli/system.rs @@ -38,9 +38,7 @@ pub fn parse_token_info_from_properties( .and_then(|v| v.as_str()) .filter(|symbol| !symbol.is_empty()) .ok_or_else(|| { - QuantusError::NetworkError( - "Invalid or missing chain property tokenSymbol".to_string(), - ) + QuantusError::NetworkError("Invalid or missing chain property tokenSymbol".to_string()) })? .to_string(); @@ -57,14 +55,12 @@ pub fn parse_token_info_from_properties( let ss58_format = properties .get("ss58Format") .map(|v| { - v.as_u64() - .and_then(|format| u8::try_from(format).ok()) - .ok_or_else(|| { - QuantusError::NetworkError( - "Invalid chain property ss58Format; expected an integer between 0 and 255" - .to_string(), - ) - }) + v.as_u64().and_then(|format| u8::try_from(format).ok()).ok_or_else(|| { + QuantusError::NetworkError( + "Invalid chain property ss58Format; expected an integer between 0 and 255" + .to_string(), + ) + }) }) .transpose()?; diff --git a/src/cli/transfers.rs b/src/cli/transfers.rs index 90813ec..b7fa66f 100644 --- a/src/cli/transfers.rs +++ b/src/cli/transfers.rs @@ -8,7 +8,9 @@ use crate::{ cli::send::{format_balance, get_chain_properties}, error::{QuantusError, Result}, log_error, log_print, log_success, log_verbose, - subsquid::{compute_address_hash, get_hash_prefix, SubsquidClient, Transfer, TransferQueryParams}, + subsquid::{ + compute_address_hash, get_hash_prefix, SubsquidClient, Transfer, TransferQueryParams, + }, wallet::WalletManager, }; use clap::Subcommand; @@ -320,10 +322,7 @@ mod tests { fn parse_transfer_amount_rejects_invalid_values() { let bad = sample_transfer("not-a-number", "2024-01-01T00:00:00.000Z"); let err = parse_transfer_amount(&bad).unwrap_err(); - assert!( - err.to_string().contains("Invalid transfer amount"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("Invalid transfer amount"), "unexpected error: {err}"); assert_eq!( parse_transfer_amount(&sample_transfer("12345", "2024-01-01T00:00:00.000Z")).unwrap(), 12345 @@ -334,10 +333,7 @@ mod tests { fn transfer_timestamp_prefix_rejects_short_timestamps() { let short = sample_transfer("1", "short"); let err = transfer_timestamp_prefix(&short).unwrap_err(); - assert!( - err.to_string().contains("Invalid transfer timestamp"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("Invalid transfer timestamp"), "unexpected error: {err}"); assert_eq!( transfer_timestamp_prefix(&sample_transfer("1", "2024-01-01T00:00:00.000Z")).unwrap(), "2024-01-01T00:00:00" diff --git a/src/cli/update.rs b/src/cli/update.rs index 4900528..177f7af 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -162,7 +162,8 @@ fn expected_hash_from_sha256sums( continue; }; let name = name.strip_prefix('*').unwrap_or(name); - if name == asset_name || Path::new(name).file_name().and_then(|n| n.to_str()) == Some(asset_name) + if name == asset_name || + Path::new(name).file_name().and_then(|n| n.to_str()) == Some(asset_name) { if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { return Err(QuantusError::Generic(format!( @@ -227,13 +228,11 @@ fn install_verified_release( yes: bool, ) -> crate::error::Result<()> { let target = updater.target(); - let archive_asset = release - .asset_for(&target, Some(ASSET_IDENTIFIER)) - .ok_or_else(|| { - QuantusError::Generic(format!( - "No release archive found for target `{target}` (looking for {ASSET_IDENTIFIER})" - )) - })?; + let archive_asset = release.asset_for(&target, Some(ASSET_IDENTIFIER)).ok_or_else(|| { + QuantusError::Generic(format!( + "No release archive found for target `{target}` (looking for {ASSET_IDENTIFIER})" + )) + })?; let sums_asset = release .assets .iter() @@ -308,9 +307,8 @@ fn install_verified_release( QuantusError::Generic(format!("Failed to resolve current executable path: {e}")) })?; if install_path == current_exe { - self_update::self_replace::self_replace(&new_exe).map_err(|e| { - QuantusError::Generic(format!("Failed to replace running binary: {e}")) - })?; + self_update::self_replace::self_replace(&new_exe) + .map_err(|e| QuantusError::Generic(format!("Failed to replace running binary: {e}")))?; } else { self_update::Move::from_source(&new_exe) .to_dest(&install_path) @@ -330,14 +328,16 @@ fn substitute_bin_path(template: &str, version: &str, target: &str, bin: &str) - .replace("{{bin}}", bin) } -fn download_asset(url: &str, dest: &mut impl Write, show_progress: bool) -> crate::error::Result<()> { +fn download_asset( + url: &str, + dest: &mut impl Write, + show_progress: bool, +) -> crate::error::Result<()> { let mut download = self_update::Download::from_url(url); download .set_header( reqwest::header::ACCEPT, - "application/octet-stream" - .parse() - .expect("static ACCEPT header"), + "application/octet-stream".parse().expect("static ACCEPT header"), ) .show_progress(show_progress); download.download_to(dest).map_err(map_self_update_err) @@ -410,9 +410,7 @@ mod tests { assert_eq!(expected_hash_from_sha256sums(&sums, asset).unwrap(), hash); let other = "b".repeat(64); - let sums_multi = format!( - "{other} other-asset.tar.gz\n{hash} *{asset}\n" - ); + let sums_multi = format!("{other} other-asset.tar.gz\n{hash} *{asset}\n"); assert_eq!(expected_hash_from_sha256sums(&sums_multi, asset).unwrap(), hash); assert!(expected_hash_from_sha256sums(&sums, "missing.tar.gz").is_err()); diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index a465243..5256fc5 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -12,9 +12,9 @@ use crate::{ use clap::Subcommand; use colored::Colorize; use sp_core::crypto::{AccountId32 as SpAccountId32, Ss58Codec}; +use std::io::{self, Write}; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt; -use std::io::{self, Write}; /// Wallet management commands #[derive(Subcommand, Debug)] @@ -71,7 +71,8 @@ pub enum WalletCommands { #[arg(short, long, default_value = "mnemonic")] format: String, - /// Write the mnemonic to this file instead of printing it (created with owner-only permissions) + /// Write the mnemonic to this file instead of printing it (created with owner-only + /// permissions) #[arg(short, long)] output: Option, }, @@ -315,15 +316,12 @@ fn write_mnemonic_to_protected_file( let mut file = options.open(path).map_err(|e| { QuantusError::Generic(format!("Failed to create mnemonic export file: {e}")) })?; - file.write_all(mnemonic.as_bytes()).map_err(|e| { - QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")) - })?; - file.write_all(b"\n").map_err(|e| { - QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")) - })?; - file.sync_all().map_err(|e| { - QuantusError::Generic(format!("Failed to sync mnemonic export file: {e}")) - })?; + file.write_all(mnemonic.as_bytes()) + .map_err(|e| QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")))?; + file.write_all(b"\n") + .map_err(|e| QuantusError::Generic(format!("Failed to write mnemonic export file: {e}")))?; + file.sync_all() + .map_err(|e| QuantusError::Generic(format!("Failed to sync mnemonic export file: {e}")))?; Ok(()) } @@ -351,9 +349,7 @@ pub async fn handle_wallet_command( // Choose creation method based on flags let result = if no_derivation { // Use master seed directly (like quantus-node --no-derivation) - wallet_manager - .create_wallet_no_derivation(&name, Some(&final_password)) - .await + wallet_manager.create_wallet_no_derivation(&name, Some(&final_password)).await } else if derivation_path == DEFAULT_DERIVATION_PATH { wallet_manager.create_wallet(&name, Some(&final_password)).await } else { @@ -901,10 +897,7 @@ mod tests { std::env::set_var("QUANTUS_NO_UPDATE_CHECK", "1"); let manager = WalletManager::new().expect("wallet manager"); - manager - .create_wallet("export-leak", Some("")) - .await - .expect("create wallet"); + manager.create_wallet("export-leak", Some("")).await.expect("create wallet"); let result = handle_wallet_command( WalletCommands::Export { @@ -917,10 +910,7 @@ mod tests { ) .await; - assert!( - result.is_err(), - "export without --output must refuse stdout mnemonic emission" - ); + assert!(result.is_err(), "export without --output must refuse stdout mnemonic emission"); assert!( result.unwrap_err().to_string().contains("requires --output"), "error should mention --output" @@ -937,10 +927,7 @@ mod tests { std::env::remove_var("QUANTUS_WALLET_PASSWORD_EXPORT_FILE"); let manager = WalletManager::new().expect("wallet manager"); - manager - .create_wallet("export-file", Some("")) - .await - .expect("create wallet"); + manager.create_wallet("export-file", Some("")).await.expect("create wallet"); let mnemonic = manager .export_mnemonic("export-file", None) .expect("export mnemonic for fixture"); @@ -979,10 +966,7 @@ mod tests { "--mnemonic", "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art", ]); - assert!( - result.is_err(), - "wallet import must not accept --mnemonic on the command line" - ); + assert!(result.is_err(), "wallet import must not accept --mnemonic on the command line"); } #[test] @@ -996,9 +980,6 @@ mod tests { "--seed", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ]); - assert!( - result.is_err(), - "wallet from-seed must not accept --seed on the command line" - ); + assert!(result.is_err(), "wallet from-seed must not accept --seed on the command line"); } } diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index 3201ae5..0e5de2c 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -630,11 +630,7 @@ pub async fn query_pending_transfers_for_address( )); } - Ok(QueryPendingTransfersResult { - wormhole_address, - transfers: vec![], - total_available: 0, - }) + Ok(QueryPendingTransfersResult { wormhole_address, transfers: vec![], total_available: 0 }) } // ============================================================================ @@ -1128,11 +1124,7 @@ mod tests { fn checked_add_amount_rejects_indexer_overflow() { let err = checked_add_amount(u128::MAX, 2, "pending transfers") .expect_err("untrusted transfer totals must not wrap on overflow"); - assert!( - err.message.contains("overflow"), - "unexpected overflow error: {}", - err.message - ); + assert!(err.message.contains("overflow"), "unexpected overflow error: {}", err.message); assert_eq!(checked_add_amount(10, 5, "pending transfers").unwrap(), 15); } @@ -1268,8 +1260,7 @@ mod tests { } fn read_http_request(stream: &mut std::net::TcpStream) -> String { - use std::io::Read; - use std::time::Duration; + use std::{io::Read, time::Duration}; stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); let mut buf = Vec::new(); @@ -1322,8 +1313,7 @@ mod tests { #[tokio::test] async fn pending_transfer_query_for_address_refuses_without_secret() { use serde_json::json; - use std::net::TcpListener; - use std::thread; + use std::{net::TcpListener, thread}; let secret = [7u8; 32]; let wormhole_address = wormhole_lib::compute_wormhole_address(&secret).unwrap(); @@ -1373,8 +1363,7 @@ mod tests { #[tokio::test] async fn query_pending_transfers_excludes_spent_nullifiers() { use serde_json::json; - use std::net::TcpListener; - use std::thread; + use std::{net::TcpListener, thread}; let path = format!("m/44'/{}/0'/1'/0'", QUANTUS_WORMHOLE_CHAIN_ID); let wormhole_secret = derive_wormhole_from_mnemonic(TEST_MNEMONIC, None, &path).unwrap(); diff --git a/src/config/mod.rs b/src/config/mod.rs index 27f90bd..f7f67e6 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -49,13 +49,13 @@ pub fn validate_runtime_version_value(runtime_version: &serde_json::Value) -> Re let spec_name = runtime_version["specName"].as_str().ok_or_else(|| { QuantusError::NetworkError("Failed to parse runtime spec name".to_string()) })?; - let spec_version = runtime_version["specVersion"].as_u64().ok_or_else(|| { - QuantusError::NetworkError("Failed to parse spec version".to_string()) + let spec_version = runtime_version["specVersion"] + .as_u64() + .ok_or_else(|| QuantusError::NetworkError("Failed to parse spec version".to_string()))? + as u32; + let transaction_version = runtime_version["transactionVersion"].as_u64().ok_or_else(|| { + QuantusError::NetworkError("Failed to parse transaction version".to_string()) })? as u32; - let transaction_version = - runtime_version["transactionVersion"].as_u64().ok_or_else(|| { - QuantusError::NetworkError("Failed to parse transaction version".to_string()) - })? as u32; validate_runtime_identity(spec_name, spec_version, transaction_version) } @@ -83,8 +83,8 @@ mod tests { #[test] fn validate_runtime_identity_rejects_incompatible_runtime_versions() { - let err = validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 999_999, 999_999) - .unwrap_err(); + let err = + validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 999_999, 999_999).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("Unsupported Quantus runtime") && diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index 9d145a3..c4a2c11 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -81,9 +81,8 @@ impl SubsquidClient { from_prefixes: Option>, params: TransferQueryParams, ) -> Result> { - let (transfers, total_count) = self - .query_transfers_by_prefix_page(to_prefixes, from_prefixes, params) - .await?; + let (transfers, total_count) = + self.query_transfers_by_prefix_page(to_prefixes, from_prefixes, params).await?; if total_count > SERVER_MAX_LIMIT as i64 { // Same wording as the old server so query_all_transfers_by_prefix @@ -531,15 +530,17 @@ impl SubsquidClient { mod tests { use super::*; use serde_json::{json, Value}; - use std::collections::HashSet; - use std::io::{Read, Write}; - use std::net::{TcpListener, TcpStream}; - use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, Mutex, + use std::{ + collections::HashSet, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::Duration, }; - use std::thread; - use std::time::Duration; #[test] fn test_transfer_query_params_builder() { @@ -637,7 +638,8 @@ mod tests { async fn missing_aggregate_count_rejects_incomplete_prefix_page() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let endpoint = format!("http://{}", listener.local_addr().unwrap()); - let rows: Arc> = Arc::new((0..=1000).map(|i| transfer_row(i, i as i64)).collect()); + let rows: Arc> = + Arc::new((0..=1000).map(|i| transfer_row(i, i as i64)).collect()); let request_count = Arc::new(AtomicUsize::new(0)); let server_rows = Arc::clone(&rows); let server_count = Arc::clone(&request_count); @@ -776,11 +778,8 @@ mod tests { .await .expect("baseline exhaustive query"); - let expected: HashSet = complete - .iter() - .skip(GLOBAL_OFFSET as usize) - .map(|t| t.id.clone()) - .collect(); + let expected: HashSet = + complete.iter().skip(GLOBAL_OFFSET as usize).map(|t| t.id.clone()).collect(); let shifted = client .query_all_transfers_by_prefix( diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index b30a9a4..6c911d2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -617,11 +617,9 @@ mod tests { let dir_mode = fs::metadata(&wallet_manager.wallets_dir) .expect("stat wallets dir") .permissions() - .mode() & - 0o777; + .mode() & 0o777; assert_eq!(dir_mode, 0o700, "wallets directory must be owner-only (0700)"); - let keystore = Keystore::new(&wallet_manager.wallets_dir); let mut entropy = [9u8; 32]; let dilithium_keypair = qp_rusty_crystals_dilithium::ml_dsa_87::Keypair::generate( @@ -718,8 +716,7 @@ mod tests { .expect("empty password must unlock crystal_* developer wallets"); let wallet_file = wallet_manager.wallets_dir.join("crystal_bob.json"); - let mode = - fs::metadata(&wallet_file).expect("stat wallet").permissions().mode() & 0o777; + let mode = fs::metadata(&wallet_file).expect("stat wallet").permissions().mode() & 0o777; assert_eq!(mode, 0o600, "developer wallet file must be owner-read/write only"); } @@ -1124,10 +1121,8 @@ mod tests { assert_ne!(victim.address, attacker.address); let keystore = Keystore::new(&wallet_manager.wallets_dir); - let mut tampered = keystore - .load_wallet("victim_alias") - .expect("load") - .expect("victim exists"); + let mut tampered = + keystore.load_wallet("victim_alias").expect("load").expect("victim exists"); tampered.address = attacker.address.clone(); keystore.save_wallet(&tampered).expect("persist tampered envelope"); diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index c6eab9d..22c474c 100644 --- a/src/wormhole_lib.rs +++ b/src/wormhole_lib.rs @@ -383,14 +383,16 @@ mod tests { block_hash: [0u8; 32], block_number: 0, parent_hash: [0u8; 32], - state_root: decode_32("ae6e4ff0dca1ef5ede9dccc84365cecfab4e431c6f3086216bc3b819cdf0a893"), + state_root: decode_32( + "ae6e4ff0dca1ef5ede9dccc84365cecfab4e431c6f3086216bc3b819cdf0a893", + ), extrinsics_root: [0u8; 32], digest: vec![ - 8, 6, 112, 111, 119, 95, 128, 233, 182, 183, 107, 158, 1, 115, 19, 219, 126, 253, 86, - 30, 208, 176, 70, 21, 45, 180, 229, 9, 62, 91, 4, 6, 53, 245, 52, 48, 38, 123, 225, - 5, 112, 111, 119, 95, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 8, 6, 112, 111, 119, 95, 128, 233, 182, 183, 107, 158, 1, 115, 19, 219, 126, 253, + 86, 30, 208, 176, 70, 21, 45, 180, 229, 9, 62, 91, 4, 6, 53, 245, 52, 48, 38, 123, + 225, 5, 112, 111, 119, 95, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, - 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 79, 226, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 18, 79, 226, ], zk_tree_root: [0u8; 32], zk_merkle_siblings: vec![], @@ -403,9 +405,12 @@ mod tests { asset_id: NATIVE_ASSET_ID, }; - let output = - generate_proof(&input, Path::new("ignored-prover.bin"), Path::new("ignored-common.bin")) - .expect("real wormhole proof generation succeeds"); + let output = generate_proof( + &input, + Path::new("ignored-prover.bin"), + Path::new("ignored-common.bin"), + ) + .expect("real wormhole proof generation succeeds"); assert!(!output.proof_bytes.is_empty(), "the real prover produced a proof"); assert_eq!( From 2a587984043362e3041ed6146f1d23cfa5ce19b3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 17:41:27 +0800 Subject: [PATCH 37/39] fix(tx): don't abort finalization waits on 30s inactivity After best-block inclusion, wait only on the overall watch deadline so PoW finality gaps longer than 30s don't fail --finalized. Distinguish inactivity vs overall-deadline timeout errors. Co-authored-by: Cursor --- src/cli/common.rs | 123 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 19 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index fd1edfd..52c112b 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -88,6 +88,22 @@ fn tx_status_watch_timeout_secs(target_stage: TransactionStage) -> u64 { } } +/// How long to wait for the next status update. +/// +/// A short inactivity timeout detects stalled streams before inclusion. After a +/// transaction is in a best block and we are waiting for PoW finalization, silent +/// gaps can exceed that inactivity window, so only the overall watch deadline applies. +fn next_status_wait_secs(remaining_watch_secs: u64, apply_inactivity_timeout: bool) -> u64 { + if remaining_watch_secs == 0 { + return 0; + } + if apply_inactivity_timeout { + remaining_watch_secs.min(TX_STATUS_INACTIVITY_TIMEOUT_SECS) + } else { + remaining_watch_secs + } +} + #[derive(Debug, Clone, PartialEq, Eq)] enum WatchedTxEvent { Validated, @@ -100,7 +116,10 @@ enum WatchedTxEvent { Dropped(String), StreamError(String), StreamEnded, - StreamTimedOut, + /// No status updates within the short inactivity window. + InactivityTimedOut { timeout_secs: u64 }, + /// Overall inclusion/finalization deadline elapsed. + WatchDeadlineTimedOut { elapsed_secs: u64 }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -138,11 +157,16 @@ fn describe_watched_tx_event( "Transaction status stream ended before the transaction was {}", target_stage.status_label() ))), - WatchedTxEvent::StreamTimedOut => Err(crate::error::QuantusError::NetworkError(format!( - "Transaction status stream timed out after {} seconds without updates before the transaction was {}", - TX_STATUS_INACTIVITY_TIMEOUT_SECS, - target_stage.status_label() - ))), + WatchedTxEvent::InactivityTimedOut { timeout_secs } => + Err(crate::error::QuantusError::NetworkError(format!( + "Transaction status stream timed out after {timeout_secs} seconds without updates before the transaction was {}", + target_stage.status_label() + ))), + WatchedTxEvent::WatchDeadlineTimedOut { elapsed_secs } => + Err(crate::error::QuantusError::NetworkError(format!( + "Timed out after waiting {elapsed_secs} seconds for the transaction to be {}", + target_stage.status_label() + ))), } } @@ -672,18 +696,23 @@ async fn wait_tx_inclusion( }; let watch_timeout_secs = tx_status_watch_timeout_secs(target_stage); + // After best-block inclusion while targeting finalization, PoW can be silent for + // longer than the inactivity window; only the overall deadline should abort then. + let mut waiting_for_finalization = false; loop { let elapsed_before_wait = start_time.elapsed().as_secs(); let remaining_watch_secs = watch_timeout_secs.saturating_sub(elapsed_before_wait); - let (next_event, elapsed_secs) = if remaining_watch_secs == 0 { - (WatchedTxEvent::StreamTimedOut, elapsed_before_wait) + let apply_inactivity_timeout = !waiting_for_finalization; + let wait_secs = next_status_wait_secs(remaining_watch_secs, apply_inactivity_timeout); + let (next_event, elapsed_secs) = if wait_secs == 0 { + ( + WatchedTxEvent::WatchDeadlineTimedOut { elapsed_secs: elapsed_before_wait }, + elapsed_before_wait, + ) } else { let next_status = tokio::time::timeout( - std::time::Duration::from_secs(std::cmp::min( - TX_STATUS_INACTIVITY_TIMEOUT_SECS, - remaining_watch_secs, - )), + std::time::Duration::from_secs(wait_secs), tx_progress.next(), ) .await; @@ -709,6 +738,9 @@ async fn wait_tx_inclusion( TxStatus::Broadcasted => WatchedTxEvent::Broadcasted, TxStatus::NoLongerInBestBlock => { execution_success_checked_for = None; + // Reorged out of best block; resume inactivity protection until + // we see inclusion again. + waiting_for_finalization = false; WatchedTxEvent::NoLongerInBestBlock }, TxStatus::InBestBlock(tx_in_block) => { @@ -724,7 +756,12 @@ async fn wait_tx_inclusion( ) .await { - std::ops::ControlFlow::Continue(()) => continue, + std::ops::ControlFlow::Continue(()) => { + if target_stage == TransactionStage::Finalized { + waiting_for_finalization = true; + } + continue; + }, std::ops::ControlFlow::Break(result) => return result, } }, @@ -752,7 +789,18 @@ async fn wait_tx_inclusion( }, Ok(Some(Err(err))) => WatchedTxEvent::StreamError(err.to_string()), Ok(None) => WatchedTxEvent::StreamEnded, - Err(_) => WatchedTxEvent::StreamTimedOut, + Err(_) => { + if apply_inactivity_timeout && + wait_secs == TX_STATUS_INACTIVITY_TIMEOUT_SECS && + remaining_watch_secs > TX_STATUS_INACTIVITY_TIMEOUT_SECS + { + WatchedTxEvent::InactivityTimedOut { + timeout_secs: TX_STATUS_INACTIVITY_TIMEOUT_SECS, + } + } else { + WatchedTxEvent::WatchDeadlineTimedOut { elapsed_secs } + } + }, }; (next_event, elapsed_secs) }; @@ -1008,12 +1056,32 @@ mod tests { describe_watched_tx_event(WatchedTxEvent::StreamEnded, TransactionStage::Included,) .is_err() ); - let timeout_err = - describe_watched_tx_event(WatchedTxEvent::StreamTimedOut, TransactionStage::Included) - .expect_err("silent subscription must time out instead of waiting forever"); + let inactivity_err = describe_watched_tx_event( + WatchedTxEvent::InactivityTimedOut { + timeout_secs: TX_STATUS_INACTIVITY_TIMEOUT_SECS, + }, + TransactionStage::Included, + ) + .expect_err("silent subscription must time out instead of waiting forever"); + assert!( + inactivity_err.to_string().contains("without updates") && + inactivity_err + .to_string() + .contains(&TX_STATUS_INACTIVITY_TIMEOUT_SECS.to_string()), + "unexpected inactivity error: {inactivity_err}" + ); + + let deadline_err = describe_watched_tx_event( + WatchedTxEvent::WatchDeadlineTimedOut { elapsed_secs: TX_STATUS_FINALIZED_TIMEOUT_SECS }, + TransactionStage::Finalized, + ) + .expect_err("overall finalized deadline must be an error"); + let deadline_msg = deadline_err.to_string(); assert!( - timeout_err.to_string().contains("timed out"), - "unexpected timeout error: {timeout_err}" + deadline_msg.contains("Timed out after waiting") && + deadline_msg.contains(&TX_STATUS_FINALIZED_TIMEOUT_SECS.to_string()) && + !deadline_msg.contains("without updates"), + "overall deadline must not be reported as the inactivity window: {deadline_msg}" ); } @@ -1034,6 +1102,23 @@ mod tests { } } + #[test] + fn finalization_wait_does_not_use_short_inactivity_timeout() { + // Before inclusion, keep the short inactivity cap. + assert_eq!( + next_status_wait_secs(TX_STATUS_FINALIZED_TIMEOUT_SECS, true), + TX_STATUS_INACTIVITY_TIMEOUT_SECS + ); + // After best-block inclusion while waiting for PoW finalization, allow the + // full remaining overall deadline so silent finality gaps do not abort early. + assert_eq!( + next_status_wait_secs(TX_STATUS_FINALIZED_TIMEOUT_SECS, false), + TX_STATUS_FINALIZED_TIMEOUT_SECS + ); + assert_eq!(next_status_wait_secs(12, false), 12); + assert_eq!(next_status_wait_secs(0, false), 0); + } + #[test] fn inclusion_and_finalization_have_distinct_success_states() { assert_eq!( From ea9318e42579b66b2f8731ccd0b08c9632253591 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 17:50:26 +0800 Subject: [PATCH 38/39] ci: remove CodeQL workflow Co-authored-by: Cursor --- .github/workflows/codeql.yml | 50 ------------------------------------ 1 file changed, 50 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 44f9d50..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: CodeQL - -on: - push: - branches: [main] - pull_request: - branches: [main] - -# No scheduled scans by design: every code change reaches main via push or PR, -# both of which trigger this workflow. Security advisories for Rust dependencies -# are independently caught by `cargo audit` in ci.yml. - -permissions: - contents: read - security-events: write - actions: read - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - # `actions` covers GitHub Actions workflow hygiene (e.g. the - # `actions/missing-workflow-permissions` rule). - - language: actions - build-mode: none - # `rust` is GA since Oct 2025 and supports build-mode `none`, - # so we get source-level analysis without compiling the crate. - # Note: `cargo audit` in ci.yml stays as the authoritative source - # for known CVEs in dependencies; CodeQL adds taint/quality checks - # on our own source. - - language: rust - build-mode: none - steps: - - uses: actions/checkout@v5 - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - queries: security-and-quality - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{ matrix.language }}" From 3278801fbebc7987c4a089fe20ac97e0478d8f40 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 17:54:16 +0800 Subject: [PATCH 39/39] fmt --- src/cli/common.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 52c112b..31f1aa9 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -117,9 +117,13 @@ enum WatchedTxEvent { StreamError(String), StreamEnded, /// No status updates within the short inactivity window. - InactivityTimedOut { timeout_secs: u64 }, + InactivityTimedOut { + timeout_secs: u64, + }, /// Overall inclusion/finalization deadline elapsed. - WatchDeadlineTimedOut { elapsed_secs: u64 }, + WatchDeadlineTimedOut { + elapsed_secs: u64, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -711,11 +715,9 @@ async fn wait_tx_inclusion( elapsed_before_wait, ) } else { - let next_status = tokio::time::timeout( - std::time::Duration::from_secs(wait_secs), - tx_progress.next(), - ) - .await; + let next_status = + tokio::time::timeout(std::time::Duration::from_secs(wait_secs), tx_progress.next()) + .await; let elapsed_secs = start_time.elapsed().as_secs(); let next_event = match next_status { Ok(Some(Ok(status))) => { @@ -1057,9 +1059,7 @@ mod tests { .is_err() ); let inactivity_err = describe_watched_tx_event( - WatchedTxEvent::InactivityTimedOut { - timeout_secs: TX_STATUS_INACTIVITY_TIMEOUT_SECS, - }, + WatchedTxEvent::InactivityTimedOut { timeout_secs: TX_STATUS_INACTIVITY_TIMEOUT_SECS }, TransactionStage::Included, ) .expect_err("silent subscription must time out instead of waiting forever"); @@ -1072,7 +1072,9 @@ mod tests { ); let deadline_err = describe_watched_tx_event( - WatchedTxEvent::WatchDeadlineTimedOut { elapsed_secs: TX_STATUS_FINALIZED_TIMEOUT_SECS }, + WatchedTxEvent::WatchDeadlineTimedOut { + elapsed_secs: TX_STATUS_FINALIZED_TIMEOUT_SECS, + }, TransactionStage::Finalized, ) .expect_err("overall finalized deadline must be an error");