From 03dad4d658b68d0d00127e1fb81f74890dfb08c8 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 4 Aug 2026 14:30:59 +0800 Subject: [PATCH 01/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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/74] 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"); From 544de1d5ce3d7e83a29b5ff7fa2c9e07dded18eb Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:24:36 +0800 Subject: [PATCH 40/74] fix(config): expect real runtime spec name quantus-runtime The identity gate hardcoded specName "quantus" but the runtime declares "quantus-runtime", so every real node was rejected. Pin a test to the real name. Also let compatibility-check connect without the gate so its INCOMPATIBLE diagnosis is reachable again, and report the spec name. Co-authored-by: Cursor --- src/chain/client.rs | 41 +++++++++++++++++-------- src/cli/mod.rs | 75 +++++++++++++++++++++++++++------------------ src/config/mod.rs | 16 ++++++++-- 3 files changed, 88 insertions(+), 44 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index 130c968..5fed871 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -79,6 +79,19 @@ impl QuantusClient { /// Create a new QuantusClient by connecting to the specified node URL pub async fn new(node_url: &str) -> crate::error::Result { + Self::connect(node_url, true).await + } + + /// Connect without enforcing the runtime identity gate. + /// + /// Only for read-only diagnostics (e.g. `compatibility-check`) that must be able to + /// inspect nodes this CLI would otherwise reject. Never use this client to sign or + /// submit transactions. + pub async fn new_without_runtime_check(node_url: &str) -> crate::error::Result { + Self::connect(node_url, false).await + } + + async fn connect(node_url: &str, enforce_runtime_identity: bool) -> crate::error::Result { let display_node_url = Self::sanitize_url_for_diagnostics(node_url); log_verbose!("πŸ”— Connecting to Quantus node: {}", display_node_url); @@ -129,18 +142,22 @@ impl QuantusClient { // 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 {display_node_url})")), - other => other, - })?; + if enforce_runtime_identity { + 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 {display_node_url})")), + other => other, + }, + )?; + } log_verbose!("βœ… Connected to Quantus node successfully!"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4078557..12a5da2 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -591,56 +591,73 @@ async fn handle_compatibility_check(node_url: &str) -> crate::error::Result<()> log_print!("πŸ”— Connecting to: {}", node_url.bright_cyan()); log_print!(""); - // Connect to the node - let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; - - // Get runtime version - let runtime_version = runtime::get_runtime_version(quantus_client.client()).await?; - - // Get system info for additional details - let chain_info = system::get_complete_chain_info(node_url).await?; + // Connect without the runtime identity gate: this command exists precisely to + // diagnose nodes the rest of the CLI refuses to talk to. + let quantus_client = + crate::chain::client::QuantusClient::new_without_runtime_check(node_url).await?; + + // Fetch the raw runtime version so we can inspect the spec name too. + use jsonrpsee::core::client::ClientT; + let runtime_version: serde_json::Value = quantus_client + .rpc_client() + .request::("state_getRuntimeVersion", []) + .await + .map_err(|e| { + crate::error::QuantusError::NetworkError(format!( + "Failed to fetch runtime version: {e:?}" + )) + })?; + let spec_name = runtime_version["specName"].as_str().unwrap_or("").to_string(); + let spec_version = runtime_version["specVersion"].as_u64().unwrap_or(0) as u32; + let impl_version = runtime_version["implVersion"].as_u64().unwrap_or(0) as u32; + let transaction_version = runtime_version["transactionVersion"].as_u64().unwrap_or(0) as u32; + + // Chain info is best-effort: an incompatible node may not support the ChainHead API. + let chain_info = system::get_complete_chain_info(node_url).await.ok(); log_print!("πŸ“‹ Version Information:"); log_print!(" β€’ CLI Version: {}", env!("CARGO_PKG_VERSION").bright_green()); - log_print!( - " β€’ Runtime Spec Version: {}", - runtime_version.spec_version.to_string().bright_yellow() - ); - log_print!( - " β€’ Runtime Impl Version: {}", - runtime_version.impl_version.to_string().bright_blue() - ); - log_print!( - " β€’ Transaction Version: {}", - runtime_version.transaction_version.to_string().bright_magenta() - ); - - if let Some(name) = &chain_info.chain_name { + log_print!(" β€’ Runtime Spec Name: {}", spec_name.bright_cyan()); + log_print!(" β€’ Runtime Spec Version: {}", spec_version.to_string().bright_yellow()); + log_print!(" β€’ Runtime Impl Version: {}", impl_version.to_string().bright_blue()); + log_print!(" β€’ Transaction Version: {}", transaction_version.to_string().bright_magenta()); + + if let Some(name) = chain_info.as_ref().and_then(|info| info.chain_name.as_ref()) { log_print!(" β€’ Chain Name: {}", name.bright_cyan()); } log_print!(""); // Check compatibility - let is_compatible = crate::config::is_runtime_compatible( - runtime_version.spec_version, - runtime_version.transaction_version, - ); + let name_matches = spec_name == crate::config::EXPECTED_RUNTIME_SPEC_NAME; + let version_compatible = + crate::config::is_runtime_compatible(spec_version, transaction_version); log_print!("πŸ” Compatibility Analysis:"); + log_print!(" β€’ Expected spec name: {}", crate::config::EXPECTED_RUNTIME_SPEC_NAME); log_print!(" β€’ Supported runtime/transaction pairs:"); for runtime in crate::config::COMPATIBLE_RUNTIMES { log_print!(" - spec {} / tx {}", runtime.spec_version, runtime.transaction_version); } - log_print!(" β€’ Current Runtime Version: {}", runtime_version.spec_version); - log_print!(" β€’ Current Transaction Version: {}", runtime_version.transaction_version); + log_print!(" β€’ Current Spec Name: {spec_name}"); + log_print!(" β€’ Current Runtime Version: {spec_version}"); + log_print!(" β€’ Current Transaction Version: {transaction_version}"); - if is_compatible { + if name_matches && version_compatible { log_success!("βœ… COMPATIBLE - This CLI version supports the connected node"); log_print!(" β€’ All features should work correctly"); log_print!(" β€’ You can safely use all CLI commands"); + } else if !name_matches { + log_error!("❌ INCOMPATIBLE - The connected node is not running a Quantus runtime"); + log_print!( + " β€’ Runtime identifies as '{}', expected '{}'", + spec_name, + crate::config::EXPECTED_RUNTIME_SPEC_NAME + ); + log_print!(" β€’ All other CLI commands will refuse to talk to this node"); } else { log_error!("❌ INCOMPATIBLE - This CLI version may not work with the connected node"); + log_print!(" β€’ The runtime version pair is not in this CLI's supported list"); log_print!(" β€’ Some features may not work correctly"); log_print!(" β€’ Consider updating the CLI or connecting to a compatible node"); } diff --git a/src/config/mod.rs b/src/config/mod.rs index f7f67e6..dcc32b5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -8,8 +8,9 @@ pub struct CompatibleRuntime { pub transaction_version: u32, } -/// Expected runtime spec name for Quantus nodes. -pub const EXPECTED_RUNTIME_SPEC_NAME: &str = "quantus"; +/// Expected runtime spec name for Quantus nodes, as declared by the runtime's +/// `RuntimeVersion { spec_name: "quantus-runtime", .. }` in the chain repo. +pub const EXPECTED_RUNTIME_SPEC_NAME: &str = "quantus-runtime"; /// Supported runtime / transaction version pairs. pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = &[ @@ -71,6 +72,15 @@ mod tests { .expect("compatible quantus runtime must be accepted"); } + /// Pinned to the spec name the real Quantus runtime declares + /// (`spec_name: "quantus-runtime"` in the chain repo's runtime/src/lib.rs). + /// If this fails, the identity gate rejects every real node. + #[test] + fn validate_runtime_identity_accepts_real_quantus_runtime_spec_name() { + validate_runtime_identity("quantus-runtime", 136, 3) + .expect("the real runtime spec name '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(); @@ -111,7 +121,7 @@ mod tests { #[test] fn validate_runtime_version_value_rejects_incompatible_runtime() { let value = json!({ - "specName": "quantus", + "specName": EXPECTED_RUNTIME_SPEC_NAME, "specVersion": 1, "transactionVersion": 1, }); From f965ba39894184dab88b0ccdce1195c659fa5879 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:31:45 +0800 Subject: [PATCH 41/74] fix(wormhole): zeroize proof input secret without UB zeroize_input_secret wrote through a pointer derived from a shared reference, which is undefined behavior the compiler may elide, and it mutated the caller's input behind a &T signature. generate_proof now takes &mut ProofGenerationInput, wipes input.secret unconditionally on success and error, and holds internal secret copies in zeroize-on-drop guards so early ? returns can't skip cleanup. Co-authored-by: Cursor --- examples/wormhole_sdk_e2e.rs | 5 +- src/cli/wormhole.rs | 7 +- src/collect_rewards_lib.rs | 6 +- src/wormhole_lib.rs | 249 ++++++++++++++++++++++------------- 4 files changed, 171 insertions(+), 96 deletions(-) diff --git a/examples/wormhole_sdk_e2e.rs b/examples/wormhole_sdk_e2e.rs index eb11b7e..9a9bec3 100644 --- a/examples/wormhole_sdk_e2e.rs +++ b/examples/wormhole_sdk_e2e.rs @@ -247,7 +247,8 @@ async fn main() -> Result<()> { let prover_bin = bins_dir.join("prover.bin"); let common_bin = bins_dir.join("common.bin"); - let pgi = ProofGenerationInput { + // generate_proof zeroizes pgi.secret before returning. + let mut pgi = ProofGenerationInput { secret, transfer_count: event.transfer_count, wormhole_address: wh_addr, @@ -270,7 +271,7 @@ async fn main() -> Result<()> { }; let leaf_start = std::time::Instant::now(); - let leaf_result = wormhole_lib::generate_proof(&pgi, &prover_bin, &common_bin) + let leaf_result = wormhole_lib::generate_proof(&mut pgi, &prover_bin, &common_bin) .map_err(|e| QuantusError::Generic(format!("generate_proof: {}", e.message)))?; println!( " leaf proof generated in {:.2}s ({} bytes)", diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index eda9644..59c5dae 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2898,8 +2898,9 @@ async fn generate_proof( let (sorted_siblings, positions) = compute_merkle_positions(&zk_proof.siblings, zk_proof.leaf_hash); - // Build ProofGenerationInput using wormhole_lib types with ZK Merkle proof - let input = wormhole_lib::ProofGenerationInput { + // Build ProofGenerationInput using wormhole_lib types with ZK Merkle proof. + // generate_proof zeroizes input.secret before returning. + let mut input = wormhole_lib::ProofGenerationInput { secret, transfer_count, wormhole_address, @@ -2923,7 +2924,7 @@ async fn generate_proof( let bins_dir = crate::bins::ensure_bins_dir()?; let result = wormhole_lib::generate_proof( - &input, + &mut input, &bins_dir.join("prover.bin"), &bins_dir.join("common.bin"), ) diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index 0e5de2c..064fa23 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -442,7 +442,9 @@ pub async fn collect_rewards( ))); } - let input = wormhole_lib::ProofGenerationInput { + // generate_proof zeroizes input.secret before returning; each iteration + // rebuilds the input from wormhole_secret_bytes. + let mut input = wormhole_lib::ProofGenerationInput { secret: wormhole_secret_bytes, transfer_count, wormhole_address: wormhole_address_bytes, @@ -467,7 +469,7 @@ pub async fn collect_rewards( // Generate proof let prover_path = bins_dir.join("prover.bin"); let common_path = bins_dir.join("common.bin"); - let result = wormhole_lib::generate_proof(&input, &prover_path, &common_path) + let result = wormhole_lib::generate_proof(&mut input, &prover_path, &common_path) .map_err(|e| CollectRewardsError::from(e.message))?; proof_bytes_list.push(result.proof_bytes); diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index 22c474c..e5934bf 100644 --- a/src/wormhole_lib.rs +++ b/src/wormhole_lib.rs @@ -72,13 +72,24 @@ fn zeroize_bytes_digest(digest: &mut BytesDigest) { 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) }; +/// Zeroize-on-drop wrapper for a copy of the secret digest, so every exit path +/// out of proof generation (including early `?` returns) wipes the copy. +struct ZeroizingDigest(BytesDigest); + +impl Drop for ZeroizingDigest { + fn drop(&mut self) { + zeroize_bytes_digest(&mut self.0); + } +} + +/// Zeroize-on-drop wrapper for the assembled circuit inputs, which embed a copy +/// of the secret in `private.secret`. +struct ZeroizingCircuitInputs(CircuitInputs); + +impl Drop for ZeroizingCircuitInputs { + fn drop(&mut self) { + zeroize_bytes_digest(&mut self.0.private.secret); } - compiler_fence(Ordering::SeqCst); } /// Input data for generating a wormhole proof. @@ -206,36 +217,83 @@ pub fn compute_output_amount(input_amount: u32, fee_bps: u32) -> u32 { /// API compatibility with existing callers and are ignored. /// /// # Arguments -/// * `input` - All input data for proof generation (including ZK Merkle proof) +/// * `input` - All input data for proof generation (including ZK Merkle proof). +/// Borrowed mutably: `input.secret` is zeroized before this function returns, +/// on success and on every error path. Callers that retry must rebuild the +/// input with a fresh secret. /// * `prover_bin_path` - Ignored (legacy; leaf prover is built in-process) /// * `common_bin_path` - Ignored (legacy; leaf prover is built in-process) /// /// # Returns /// Proof bytes and nullifier pub fn generate_proof( - input: &ProofGenerationInput, + input: &mut ProofGenerationInput, prover_bin_path: &Path, common_bin_path: &Path, ) -> Result { - // Convert secret to BytesDigest - let mut secret_digest: BytesDigest = input - .secret + // 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 result = generate_proof_inner(input); + // Wipe the caller-visible secret unconditionally, on success and on every error path. + zeroize_bytes(&mut input.secret); + result +} + +fn generate_proof_inner(input: &ProofGenerationInput) -> Result { + // Perform every fallible conversion before the secret is copied anywhere, so an + // early `?` return can never skip zeroization of a secret copy. + let parent_hash = input + .parent_hash + .as_slice() .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?; + .map_err(|e| WormholeLibError::from(format!("Invalid parent hash: {:?}", e)))?; + let state_root = input + .state_root + .as_slice() + .try_into() + .map_err(|e| WormholeLibError::from(format!("Invalid state root: {:?}", e)))?; + let extrinsics_root = input + .extrinsics_root + .as_slice() + .try_into() + .map_err(|e| WormholeLibError::from(format!("Invalid extrinsics root: {:?}", e)))?; + let exit_account_1 = input + .exit_account_1 + .as_slice() + .try_into() + .map_err(|e| WormholeLibError::from(format!("Invalid exit account 1: {:?}", e)))?; + let exit_account_2 = input + .exit_account_2 + .as_slice() + .try_into() + .map_err(|e| WormholeLibError::from(format!("Invalid exit account 2: {:?}", e)))?; + let block_hash = input + .block_hash + .as_slice() + .try_into() + .map_err(|e| WormholeLibError::from(format!("Invalid block hash: {:?}", e)))?; + + // Convert secret to BytesDigest; the guard wipes this copy on every exit path. + let secret_digest = ZeroizingDigest( + input + .secret + .try_into() + .map_err(|e| WormholeLibError::from(format!("Invalid secret: {:?}", e)))?, + ); // Compute nullifier - let nullifier = Nullifier::from_preimage(secret_digest, input.transfer_count); + let nullifier = Nullifier::from_preimage(secret_digest.0, input.transfer_count); let nullifier_bytes = digest_to_bytes(nullifier.hash); // Compute unspendable account let unspendable = - qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret_digest); + qp_wormhole_circuit::unspendable_account::UnspendableAccount::from_secret(secret_digest.0); let unspendable_bytes = digest_to_bytes(unspendable.account_id); // 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(), @@ -248,82 +306,48 @@ pub fn generate_proof( let copy_len = input.digest.len().min(DIGEST_LOGS_SIZE); digest_padded[..copy_len].copy_from_slice(&input.digest[..copy_len]); - // Build circuit inputs with ZK Merkle proof - let private = PrivateCircuitInputs { - secret: secret_digest, - transfer_count: input.transfer_count, - unspendable_account: unspendable_bytes, - parent_hash: input - .parent_hash - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid parent hash: {:?}", e)))?, - state_root: input - .state_root - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid state root: {:?}", e)))?, - extrinsics_root: input - .extrinsics_root - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid extrinsics root: {:?}", e)))?, - digest: digest_padded, - input_amount: input.input_amount, - zk_tree_root: input.zk_tree_root, - 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, - output_amount_1: input.output_amount_1, - output_amount_2: input.output_amount_2, - volume_fee_bps: input.volume_fee_bps, - nullifier: nullifier_bytes, - exit_account_1: input - .exit_account_1 - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid exit account 1: {:?}", e)))?, - exit_account_2: input - .exit_account_2 - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid exit account 2: {:?}", e)))?, - block_hash: input - .block_hash - .as_slice() - .try_into() - .map_err(|e| WormholeLibError::from(format!("Invalid block hash: {:?}", e)))?, - block_number: input.block_number, - }; - - let mut circuit_inputs = CircuitInputs { public, private }; + // Build circuit inputs with ZK Merkle proof; the guard wipes the embedded + // secret copy on every exit path. + let circuit_inputs = ZeroizingCircuitInputs(CircuitInputs { + public: PublicCircuitInputs { + asset_id: input.asset_id, + output_amount_1: input.output_amount_1, + output_amount_2: input.output_amount_2, + volume_fee_bps: input.volume_fee_bps, + nullifier: nullifier_bytes, + exit_account_1, + exit_account_2, + block_hash, + block_number: input.block_number, + }, + private: PrivateCircuitInputs { + secret: secret_digest.0, + transfer_count: input.transfer_count, + unspendable_account: unspendable_bytes, + parent_hash, + state_root, + extrinsics_root, + digest: digest_padded, + input_amount: input.input_amount, + zk_tree_root: input.zk_tree_root, + zk_merkle_siblings: input.zk_merkle_siblings.clone(), + zk_merkle_positions: input.zk_merkle_positions.clone(), + }, + }); + drop(secret_digest); + zeroize_bytes(&mut digest_padded); - // 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 result = (|| -> Result { - let prover_with_inputs = prover - .commit(&circuit_inputs) - .map_err(|e| WormholeLibError::from(format!("Failed to commit inputs: {}", e)))?; + let prover_with_inputs = prover + .commit(&circuit_inputs.0) + .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 }) - })(); - - zeroize_bytes_digest(&mut circuit_inputs.private.secret); - zeroize_bytes(&mut digest_padded); - zeroize_input_secret(input); - - result + Ok(ProofGenerationOutput { proof_bytes: proof.to_bytes(), nullifier: *nullifier_bytes }) } #[cfg(test)] @@ -375,7 +399,7 @@ mod tests { let transfer_count = 4u64; let wormhole_address = compute_wormhole_address(&secret).expect("secret derives address"); - let input = ProofGenerationInput { + let mut input = ProofGenerationInput { secret, transfer_count, wormhole_address, @@ -406,7 +430,7 @@ mod tests { }; let output = generate_proof( - &input, + &mut input, Path::new("ignored-prover.bin"), Path::new("ignored-common.bin"), ) @@ -418,4 +442,51 @@ mod tests { "generate_proof must zeroize the caller-owned secret before returning" ); } + + /// The secret must also be wiped on error paths, e.g. a wormhole address that + /// does not match the secret. + #[test] + fn secret_is_zeroized_when_proof_generation_fails_early() { + let secret = decode_32("4c8587bd422e01d961acdc75e7d66f6761b7af7c9b1864a492f369c9d6724f05"); + + let mut input = ProofGenerationInput { + secret, + transfer_count: 0, + // Deliberately not the address derived from `secret`. + wormhole_address: [0xAAu8; 32], + input_amount: 100, + block_hash: [0u8; 32], + block_number: 0, + parent_hash: [0u8; 32], + state_root: [0u8; 32], + extrinsics_root: [0u8; 32], + digest: vec![], + 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 err = generate_proof( + &mut input, + Path::new("ignored-prover.bin"), + Path::new("ignored-common.bin"), + ) + .expect_err("mismatched wormhole address must be rejected"); + + assert!( + err.message.contains("doesn't match"), + "expected address-mismatch error, got: {}", + err.message + ); + assert_eq!( + input.secret, [0u8; 32], + "generate_proof must zeroize the caller-owned secret on error paths too" + ); + } } From bf9ce6c54e2fc6a2357bc0b60cd51579aa5a9433 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:39:15 +0800 Subject: [PATCH 42/74] fix(bins): auto-regenerate stale circuit artifacts after upgrades ensure_bins_dir hard-rejected any artifact directory whose manifest predates the current package version, bricking wormhole after every upgrade with a remediation that had no command. Directories attributable to another CLI version or sizing (via manifest or version marker) are now quarantined with remove_path_nofollow and regenerated; same-version directories that fail authentication still hard-error, with the message now naming the actual remediation. Co-authored-by: Cursor --- build.rs | 5 +- src/bins.rs | 131 ++++++++++++++++++++++++++++++++++++++++++++++--- src/bins_fs.rs | 4 +- 3 files changed, 128 insertions(+), 12 deletions(-) diff --git a/build.rs b/build.rs index 55bc5db..5c21a66 100644 --- a/build.rs +++ b/build.rs @@ -124,8 +124,9 @@ fn main() { // `include!("src/bins_consts.rs")` above creates a dependency on that file. // - Circuit crate version bumps (qp-wormhole-circuit-builder) recompile the build script, which // re-runs it. - // For installed binaries, runtime detection in bins.rs `is_ready()` handles leaf - // count mismatches by regenerating on first use. + // For installed binaries, `bins.rs::ensure_bins_dir()` quarantines artifact + // directories whose manifest records a different package version or sizing and + // regenerates them on first use. println!("cargo:rerun-if-env-changed=QP_NUM_LEAF_PROOFS"); println!("cargo:rerun-if-env-changed=QP_NUM_PRIVATE_BATCH_PROOFS"); diff --git a/src/bins.rs b/src/bins.rs index 92477be..9d3d957 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -27,6 +27,12 @@ use std::{ include!("bins_consts.rs"); +mod fs_helpers { + #![allow(dead_code)] // publish_dir_atomically is used by build.rs and tests + include!("bins_fs.rs"); +} +use fs_helpers::remove_path_nofollow; + /// Environment variable used to override the bins directory. pub const BINS_DIR_ENV: &str = "QUANTUS_BINS_DIR"; @@ -99,8 +105,10 @@ 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. Incomplete or unauthenticated directories are rejected rather than +/// Safe to call multiple times. A directory attributable to a different CLI +/// version or sizing configuration (via its manifest or version marker) is +/// quarantined and regenerated, so upgrades recover automatically. A +/// same-version directory that fails authentication is rejected rather than /// overwritten. pub fn ensure_bins_dir() -> Result { let dir = resolve_bins_dir(); @@ -111,10 +119,23 @@ pub fn ensure_bins_dir() -> Result { } 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() - ))); + match stale_artifact_provenance(&dir) { + Some(provenance) => { + log_print!( + "♻️ Replacing circuit artifacts in {} ({}; current CLI is {})", + dir.display(), + provenance, + env!("CARGO_PKG_VERSION") + ); + remove_path_nofollow(&dir).map_err(QuantusError::Generic)?; + }, + None => { + return Err(QuantusError::Generic(format!( + "Circuit artifact directory {} is incomplete or failed authentication; remove the directory and rerun this command to regenerate trusted artifacts", + dir.display() + ))); + }, + } } let num_leaf_proofs = env_num_leaf_proofs(); @@ -123,6 +144,43 @@ pub fn ensure_bins_dir() -> Result { Ok(dir) } +/// Best-effort attribution of an artifact directory to a different CLI version +/// or sizing configuration, so upgrades can quarantine-and-regenerate instead +/// of bricking wormhole commands. +/// +/// Returns a description of the stale provenance, or `None` when the directory +/// claims to belong to the current version/sizing (in which case a failed +/// manifest check means tampering or corruption and must stay a hard error). +fn stale_artifact_provenance(dir: &Path) -> Option { + // Prefer the manifest: it records the producing package version and sizing. + if let Ok(content) = fs::read_to_string(dir.join(MANIFEST_FILE)) { + if let Ok(manifest) = serde_json::from_str::(&content) { + if manifest.package_version != env!("CARGO_PKG_VERSION") { + return Some(format!("built by quantus-cli {}", manifest.package_version)); + } + if manifest.num_leaf_proofs != env_num_leaf_proofs() || + manifest.num_private_batch_proofs != env_num_private_batch_proofs() + { + return Some(format!( + "sized for num_leaf_proofs={}, num_private_batch_proofs={}", + manifest.num_leaf_proofs, manifest.num_private_batch_proofs + )); + } + return None; + } + } + + // Pre-manifest layouts from older releases only carry the version marker. + if let Ok(marker) = fs::read_to_string(dir.join(VERSION_MARKER)) { + let marker = marker.trim(); + if !marker.is_empty() && marker != env!("CARGO_PKG_VERSION") { + return Some(format!("built by quantus-cli {marker}")); + } + } + + None +} + fn ensure_safe_bins_dir(dir: &Path) -> Result<()> { match fs::symlink_metadata(dir) { Ok(meta) => { @@ -410,7 +468,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("failed authentication"), "unexpected error: {err}"); } #[test] @@ -465,7 +523,7 @@ mod tests { } // Shared publish helpers from build.rs (#160700). - include!("bins_fs.rs"); + use super::fs_helpers::publish_dir_atomically; #[test] fn publish_dir_atomically_replaces_destination_symlink_without_following() { @@ -503,4 +561,61 @@ mod tests { assert!(!link.exists()); assert!(target.join("keep.txt").exists()); } + + /// Upgrades must not brick wormhole: artifacts attributable to another CLI + /// version are stale and eligible for quarantine-and-regenerate. + #[test] + fn artifacts_from_an_older_cli_version_are_stale() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + write_valid_manifest_for_dir(dir); + + // Same version and sizing: not stale (a failed check must stay a hard error). + assert_eq!(stale_artifact_provenance(dir), None); + + // Rewrite the manifest as if produced by an older release. + let content = fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap(); + let mut manifest: ArtifactManifest = serde_json::from_str(&content).unwrap(); + manifest.package_version = "0.0.1-old".to_string(); + fs::write(dir.join(MANIFEST_FILE), serde_json::to_string(&manifest).unwrap()).unwrap(); + + let provenance = stale_artifact_provenance(dir).expect("older version is stale"); + assert!(provenance.contains("0.0.1-old"), "unexpected provenance: {provenance}"); + } + + /// Pre-manifest layouts (older releases) are attributed via the version marker. + #[test] + fn pre_manifest_artifacts_with_old_version_marker_are_stale() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + // No manifest.json at all; marker from an older release. + fs::write(dir.join(VERSION_MARKER), "0.0.1-old").unwrap(); + + let provenance = stale_artifact_provenance(dir).expect("old marker is stale"); + assert!(provenance.contains("0.0.1-old"), "unexpected provenance: {provenance}"); + + // Marker matching the current version without a manifest is NOT stale: + // that directory claims to be ours but cannot be authenticated. + fs::write(dir.join(VERSION_MARKER), env!("CARGO_PKG_VERSION")).unwrap(); + assert_eq!(stale_artifact_provenance(dir), None); + } + + /// A same-version directory with mismatched sizing regenerates instead of erroring. + #[test] + fn artifacts_with_different_sizing_are_stale() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + write_valid_manifest_for_dir(dir); + + let content = fs::read_to_string(dir.join(MANIFEST_FILE)).unwrap(); + let mut manifest: ArtifactManifest = serde_json::from_str(&content).unwrap(); + manifest.num_leaf_proofs += 1; + fs::write(dir.join(MANIFEST_FILE), serde_json::to_string(&manifest).unwrap()).unwrap(); + + let provenance = stale_artifact_provenance(dir).expect("different sizing is stale"); + assert!(provenance.contains("num_leaf_proofs"), "unexpected provenance: {provenance}"); + } } diff --git a/src/bins_fs.rs b/src/bins_fs.rs index f360401..09315bd 100644 --- a/src/bins_fs.rs +++ b/src/bins_fs.rs @@ -7,7 +7,7 @@ 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> { +pub(crate) 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)), @@ -49,7 +49,7 @@ fn remove_path_nofollow(path: &Path) -> std::result::Result<(), String> { /// 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> { +pub(crate) 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())?; From e115f2b5774484329445cfba2434192f62b96e66 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:45:14 +0800 Subject: [PATCH 43/74] fix(block): compute block-list count in u64 to avoid u32 wrap (end - start) / step + 1 wrapped to 0 for the full u32 span in release builds, passing the MAX_BLOCK_LIST_COUNT guard and entering the multi-billion-iteration loop the bound was meant to prevent. Co-authored-by: Cursor --- src/cli/block.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/cli/block.rs b/src/cli/block.rs index ce73712..5fdc6bd 100644 --- a/src/cli/block.rs +++ b/src/cli/block.rs @@ -810,13 +810,15 @@ pub(crate) fn validate_block_list_range( "Invalid block list range: start ({start}) must be <= end ({end})" ))); } - let block_count = (end - start) / step + 1; - if block_count > MAX_BLOCK_LIST_COUNT { + // Compute in u64: (end - start) / step + 1 wraps to 0 in u32 for the full + // u32 span, which would slip past the bound below. + let block_count = u64::from(end - start) / u64::from(step) + 1; + if block_count > u64::from(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) + Ok(block_count as u32) } /// Handle block list command @@ -1089,4 +1091,14 @@ mod tests { MAX_BLOCK_LIST_COUNT ); } + + /// The count must be computed without u32 overflow: `--start 0 --end 4294967295` + /// used to wrap `(end - start) / step + 1` to 0 in release builds, slipping past + /// the bound into a ~4.3-billion-iteration loop. + #[test] + fn validate_block_list_range_rejects_full_u32_span_without_overflow() { + let err = validate_block_list_range(0, u32::MAX, 1) + .expect_err("full u32 span must be rejected, not wrapped to 0"); + assert!(err.to_string().contains("too large"), "unexpected error: {err}"); + } } From e6a689757d6cafc7b7cdb4cd39849999fda612d9 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:54:01 +0800 Subject: [PATCH 44/74] fix(wallet): unlock or honestly report protected wallets in name resolution find_wallet_address collapsed password-protected wallets into "not found", so --to failed with a misleading error for essentially every wallet now that create enforces real passwords. The lookup now distinguishes NotFound/Protected/Address; resolve_address unlocks protected wallets via their env-var password or an interactive prompt, and otherwise errors naming the wallet and the password source. Co-authored-by: Cursor --- examples/multisig_library_usage.rs | 9 ++-- examples/multisig_usage.rs | 13 ++++-- src/cli/common.rs | 66 +++++++++++++++++++++++++----- src/wallet/mod.rs | 62 +++++++++++++++++++++++++--- src/wallet/password.rs | 5 +++ 5 files changed, 133 insertions(+), 22 deletions(-) diff --git a/examples/multisig_library_usage.rs b/examples/multisig_library_usage.rs index 04d5423..48f0cd3 100644 --- a/examples/multisig_library_usage.rs +++ b/examples/multisig_library_usage.rs @@ -33,13 +33,16 @@ async fn main() -> Result<()> { // Get addresses let alice_addr = wallet_manager .find_wallet_address("crystal_alice")? - .expect("Alice wallet not found"); + .address() + .expect("Alice wallet not found or password-protected"); let bob_addr = wallet_manager .find_wallet_address("crystal_bob")? - .expect("Bob wallet not found"); + .address() + .expect("Bob wallet not found or password-protected"); let charlie_addr = wallet_manager .find_wallet_address("crystal_charlie")? - .expect("Charlie wallet not found"); + .address() + .expect("Charlie wallet not found or password-protected"); println!(" Alice: {}", alice_addr); println!(" Bob: {}", bob_addr); diff --git a/examples/multisig_usage.rs b/examples/multisig_usage.rs index 06626b6..bfd0e49 100644 --- a/examples/multisig_usage.rs +++ b/examples/multisig_usage.rs @@ -38,11 +38,18 @@ async fn main() -> Result<()> { // wallet_manager.create_wallet("bob", Some("password")).await?; // wallet_manager.create_wallet("charlie", Some("password")).await?; - let alice_addr = wallet_manager.find_wallet_address("alice")?.expect("Alice wallet not found"); - let bob_addr = wallet_manager.find_wallet_address("bob")?.expect("Bob wallet not found"); + let alice_addr = wallet_manager + .find_wallet_address("alice")? + .address() + .expect("Alice wallet not found or password-protected"); + let bob_addr = wallet_manager + .find_wallet_address("bob")? + .address() + .expect("Bob wallet not found or password-protected"); let charlie_addr = wallet_manager .find_wallet_address("charlie")? - .expect("Charlie wallet not found"); + .address() + .expect("Charlie wallet not found or password-protected"); println!(" Alice: {}", alice_addr); println!(" Bob: {}", bob_addr); diff --git a/src/cli/common.rs b/src/cli/common.rs index 31f1aa9..149b384 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -322,19 +322,63 @@ pub fn resolve_address(address_or_wallet_name: &str) -> Result { // If not a valid SS58 address, try to find it as a wallet name let wallet_manager = crate::wallet::WalletManager::new()?; - if let Some(wallet_address) = wallet_manager.find_wallet_address(address_or_wallet_name)? { - log_verbose!( - "πŸ” Found wallet '{}' with address: {}", - address_or_wallet_name.bright_cyan(), - wallet_address.bright_green() - ); - return Ok(wallet_address); + match wallet_manager.find_wallet_address(address_or_wallet_name)? { + crate::wallet::WalletAddressLookup::Address(wallet_address) => { + log_verbose!( + "πŸ” Found wallet '{}' with address: {}", + address_or_wallet_name.bright_cyan(), + wallet_address.bright_green() + ); + Ok(wallet_address) + }, + crate::wallet::WalletAddressLookup::Protected => + resolve_protected_wallet_address(&wallet_manager, address_or_wallet_name), + crate::wallet::WalletAddressLookup::NotFound => Err(crate::error::QuantusError::Generic( + format!( + "Invalid destination: '{address_or_wallet_name}' is neither a valid SS58 address nor a known wallet name" + ), + )), } +} + +/// Unlock path for resolving a password-protected wallet's address by name. +/// +/// Uses the wallet's environment-variable password when set (works in +/// scripts), prompts when running on a terminal, and otherwise fails with an +/// error naming the wallet instead of pretending it does not exist. +fn resolve_protected_wallet_address( + wallet_manager: &crate::wallet::WalletManager, + wallet_name: &str, +) -> Result { + use std::io::IsTerminal; + + let password = if let Some(env_password) = + crate::wallet::password::env_wallet_password(wallet_name) + { + env_password + } else if std::io::stdin().is_terminal() { + crate::log_print!( + "πŸ”’ Wallet '{}' is password-protected; enter its password to resolve its address", + wallet_name.bright_cyan() + ); + crate::wallet::password::get_password_from_user(&format!( + "Enter password for wallet '{wallet_name}'" + ))? + } else { + return Err(crate::error::QuantusError::Generic(format!( + "Wallet '{wallet_name}' exists but is password-protected and no password source is available non-interactively. Pass the SS58 address directly, or set QUANTUS_WALLET_PASSWORD_{} to unlock it", + wallet_name.to_uppercase() + ))); + }; - // Neither a valid SS58 address nor a wallet name - Err(crate::error::QuantusError::Generic(format!( - "Invalid destination: '{address_or_wallet_name}' is neither a valid SS58 address nor a known wallet name" - ))) + let wallet_data = wallet_manager.load_wallet(wallet_name, &password)?; + let address = wallet_data.keypair.try_to_account_id_ss58check()?; + log_verbose!( + "πŸ” Unlocked wallet '{}' with address: {}", + wallet_name.bright_cyan(), + address.bright_green() + ); + Ok(address) } /// Resolve a wallet name or SS58 address and convert it into the AccountId32 type used by SubXT. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 6c911d2..3a6a9c2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -557,21 +557,45 @@ impl WalletManager { } /// Find wallet by name and return its authenticated address when available without a password - pub fn find_wallet_address(&self, name: &str) -> Result> { + 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)? { // 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.try_to_account_id_ss58check()?)), + Ok(wallet_data) => Ok(WalletAddressLookup::Address( + wallet_data.keypair.try_to_account_id_ss58check()?, + )), Err(crate::error::QuantusError::Wallet( WalletError::InvalidPassword | WalletError::Integrity(_), - )) => Ok(None), + )) => Ok(WalletAddressLookup::Protected), Err(e) => Err(e), } } else { - Ok(None) + Ok(WalletAddressLookup::NotFound) + } + } +} + +/// Result of resolving a wallet name to an address without a password. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WalletAddressLookup { + /// No wallet with that name exists. + NotFound, + /// The wallet exists but its address cannot be authenticated without its password. + Protected, + /// The wallet's authenticated address. + Address(String), +} + +impl WalletAddressLookup { + /// The authenticated address, if one was resolved without a password. + #[allow(dead_code)] // SDK/examples convenience; unused by the CLI binary + pub fn address(self) -> Option { + match self { + WalletAddressLookup::Address(address) => Some(address), + _ => None, } } } @@ -1106,6 +1130,33 @@ mod tests { assert!(result.is_none()); } + /// find_wallet_address must distinguish "no such wallet" from "wallet exists + /// but needs its password", so callers can report an honest error or unlock. + #[tokio::test] + async fn find_wallet_address_distinguishes_missing_protected_and_open_wallets() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + + assert_eq!( + wallet_manager.find_wallet_address("nope").unwrap(), + WalletAddressLookup::NotFound + ); + + let open = wallet_manager.create_wallet("open_wallet", None).await.expect("open wallet"); + assert_eq!( + wallet_manager.find_wallet_address("open_wallet").unwrap(), + WalletAddressLookup::Address(open.address) + ); + + wallet_manager + .create_wallet("locked_wallet", Some("hunter2 but longer")) + .await + .expect("locked wallet"); + assert_eq!( + wallet_manager.find_wallet_address("locked_wallet").unwrap(), + WalletAddressLookup::Protected + ); + } + #[tokio::test] async fn passwordless_paths_do_not_trust_tampered_envelope_address() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; @@ -1140,7 +1191,8 @@ mod tests { .find_wallet_address("victim_alias") .expect("passwordless resolution must not panic"); assert_eq!( - lookup, None, + lookup, + WalletAddressLookup::Protected, "password-protected wallets must refuse unauthenticated wallet-name resolution" ); diff --git a/src/wallet/password.rs b/src/wallet/password.rs index c062821..8e70f56 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -69,6 +69,11 @@ fn read_password_file(file_path: &str) -> Result { Ok(pwd) } +/// Look up a wallet password from the environment without prompting. +pub fn env_wallet_password(wallet_name: &str) -> Option { + password_from_env(wallet_name) +} + 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"); From 04a0aedc3473c7f46b31837e4d669076e262d7d7 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:57:12 +0800 Subject: [PATCH 45/74] fix(wallet): allow deleting corrupt wallet files wallet delete pre-checked the wallet by parsing it, so a corrupt file failed with "Failed to check wallet: JSON error" and could never be removed via the CLI. Parse failures now fall through to the confirmation-and-delete path with a warning; deletion itself never needed the JSON to parse. Co-authored-by: Cursor --- src/cli/wallet.rs | 100 ++++++++++++++++++++++++---------------------- src/wallet/mod.rs | 19 +++++++++ 2 files changed, 72 insertions(+), 47 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 5256fc5..20fb0ba 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -768,63 +768,69 @@ pub async fn handle_wallet_command( let wallet_manager = WalletManager::new()?; - // Check if wallet exists first - match wallet_manager.get_wallet(&name, None) { - Ok(Some(wallet_info)) => { - // Show wallet info before deletion - log_print!("Wallet to delete:"); - log_print!(" Name: {}", wallet_info.name.bright_green()); - log_print!(" Address: {}", wallet_info.address.bright_cyan()); - log_print!(" Type: {}", wallet_info.key_type.bright_yellow()); + // Check if wallet exists first. A parse error means the file exists + // but is corrupt; it must still be deletable via the CLI. + let wallet_info = match wallet_manager.get_wallet(&name, None) { + Ok(Some(wallet_info)) => Some(wallet_info), + Ok(None) => { + log_error!("{}", format!("❌ Wallet '{name}' not found").red()); log_print!( - " Created: {}", - wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed() + "Use {} to see available wallets", + "quantus wallet list".bright_green() ); + return Ok(()); + }, + Err(e) => { + log_print!( + "{}", + format!("⚠️ Wallet file for '{name}' exists but cannot be parsed: {e}") + .yellow() + ); + log_print!(" Deleting will remove the corrupt wallet file."); + None + }, + }; + + if let Some(wallet_info) = wallet_info { + // Show wallet info before deletion + log_print!("Wallet to delete:"); + log_print!(" Name: {}", wallet_info.name.bright_green()); + log_print!(" Address: {}", wallet_info.address.bright_cyan()); + log_print!(" Type: {}", wallet_info.key_type.bright_yellow()); + log_print!( + " Created: {}", + wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed() + ); + } - // Confirmation prompt unless --force is used - if !force { - log_print!("\n{}", "⚠️ This action cannot be undone!".bright_red()); - log_print!("Type the wallet name to confirm deletion:"); + // Confirmation prompt unless --force is used + if !force { + log_print!("\n{}", "⚠️ This action cannot be undone!".bright_red()); + log_print!("Type the wallet name to confirm deletion:"); - print!("Confirm wallet name: "); - io::stdout().flush().unwrap(); + print!("Confirm wallet name: "); + io::stdout().flush().unwrap(); - let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap(); - let input = input.trim(); + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); - if input != name { - log_print!( - "{}", - "❌ Wallet name doesn't match. Deletion cancelled.".red() - ); - return Ok(()); - } - } + if input != name { + log_print!("{}", "❌ Wallet name doesn't match. Deletion cancelled.".red()); + return Ok(()); + } + } - // Perform deletion - match wallet_manager.delete_wallet(&name) { - Ok(true) => { - log_success!("βœ… Wallet '{}' deleted successfully!", name); - }, - Ok(false) => { - log_error!("{}", format!("❌ Wallet '{name}' was not found").red()); - }, - Err(e) => { - log_error!("{}", format!("❌ Failed to delete wallet: {e}").red()); - return Err(e); - }, - } + // Perform deletion + match wallet_manager.delete_wallet(&name) { + Ok(true) => { + log_success!("βœ… Wallet '{}' deleted successfully!", name); }, - Ok(None) => { - log_error!("{}", format!("❌ Wallet '{name}' not found").red()); - log_print!( - "Use {} to see available wallets", - "quantus wallet list".bright_green() - ); + Ok(false) => { + log_error!("{}", format!("❌ Wallet '{name}' was not found").red()); }, Err(e) => { - log_error!("{}", format!("❌ Failed to check wallet: {e}").red()); + log_error!("{}", format!("❌ Failed to delete wallet: {e}").red()); return Err(e); }, } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3a6a9c2..5dfcb78 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -1130,6 +1130,25 @@ mod tests { assert!(result.is_none()); } + /// Corrupt wallet files must remain deletable: delete works on the file + /// itself and must not require the JSON to parse. + #[tokio::test] + async fn delete_wallet_removes_corrupt_wallet_file() { + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + + wallet_manager.create_wallet("corrupt_me", None).await.expect("create wallet"); + let wallet_file = wallet_manager.wallets_dir.join("corrupt_me.json"); + fs::write(&wallet_file, b"{ not valid json").expect("corrupt the file"); + + // The pre-check path fails to parse it... + assert!(wallet_manager.get_wallet("corrupt_me", None).is_err()); + + // ...but deletion must still succeed. + let deleted = wallet_manager.delete_wallet("corrupt_me").expect("delete must not error"); + assert!(deleted, "corrupt wallet file must be deleted"); + assert!(!wallet_file.exists()); + } + /// find_wallet_address must distinguish "no such wallet" from "wallet exists /// but needs its password", so callers can report an honest error or unlock. #[tokio::test] From fd01d698debe8a3486a2f624d4da9536593fd0ea Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 12:59:29 +0800 Subject: [PATCH 46/74] fix(wallet): apply new-password policy to import and from-seed Both commands used the unlock helper (single prompt, silent empty default, no --password-file), bypassing the policy create enforces. They now use get_new_wallet_password with confirmed prompts, --password-file support, and an explicit --allow-empty-password gate. Co-authored-by: Cursor --- src/cli/wallet.rs | 51 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 20fb0ba..32d752e 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -83,10 +83,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 encrypting the imported 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, @@ -102,9 +110,17 @@ 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 encrypting the new wallet with an empty password (development only) + #[arg(long)] + allow_empty_password: bool, }, /// List all wallets @@ -616,7 +632,14 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::Import { name, password, derivation_path, no_derivation } => { + WalletCommands::Import { + name, + password, + password_file, + allow_empty_password, + derivation_path, + no_derivation, + } => { log_print!("πŸ“₯ Importing wallet..."); let wallet_manager = WalletManager::new()?; @@ -624,9 +647,13 @@ pub async fn handle_wallet_command( // 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 = - crate::wallet::password::get_wallet_password(&name, password, None)?; + // New-wallet password policy: confirmed prompt, no silent empty default. + let final_password = crate::wallet::password::get_new_wallet_password( + &name, + password, + password_file, + allow_empty_password, + )?; // Choose import method based on flags let result = if no_derivation { @@ -673,7 +700,7 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::FromSeed { name, password } => { + WalletCommands::FromSeed { name, password, password_file, allow_empty_password } => { log_print!("🌱 Creating wallet from seed..."); let wallet_manager = WalletManager::new()?; @@ -684,9 +711,13 @@ pub async fn handle_wallet_command( .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)?; + // New-wallet password policy: confirmed prompt, no silent empty default. + let final_password = crate::wallet::password::get_new_wallet_password( + &name, + password, + password_file, + allow_empty_password, + )?; match wallet_manager .create_wallet_from_seed(&name, &seed, Some(&final_password)) From 5c1ff3d98f4faaf8bf1aece6d930c43cf838a671 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:00:58 +0800 Subject: [PATCH 47/74] fix(keystore): reject Windows-reserved characters in wallet names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wallet_filename blocked path separators but not ':' β€” on Windows, "C:evil.json" joins to a drive-relative path outside the keystore and "foo:bar" creates an NTFS alternate data stream. Reject ':' along with the other Windows-reserved filename characters and control characters. Co-authored-by: Cursor --- src/wallet/keystore.rs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index ded0f1b..facb31e 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -52,7 +52,18 @@ fn keystore_lock() -> &'static Mutex<()> { } fn wallet_filename(name: &str) -> Result { - if name.is_empty() || name.contains('/') || name.contains('\\') || name == "." || name == ".." { + // Reject path separators and traversal, plus Windows-specific escapes: + // ':' makes "C:evil.json" resolve outside the keystore (drive-relative + // path) and "foo:bar" create an NTFS alternate data stream. The remaining + // characters are reserved in Windows filenames; control characters are + // rejected everywhere. + const FORBIDDEN: &[char] = &['/', '\\', ':', '<', '>', '"', '|', '?', '*']; + if name.is_empty() || + name == "." || + name == ".." || + name.contains(FORBIDDEN) || + name.chars().any(|c| c.is_control()) + { return Err(WalletError::InvalidName.into()); } Ok(format!("{name}.json")) @@ -1627,7 +1638,27 @@ mod tests { 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", ".", "..", ""] { + // ':' escapes the keystore on Windows ("C:evil.json" is drive-relative, + // "foo:bar" creates an NTFS alternate data stream); the remaining + // characters are Windows-reserved or control characters. + for bad_name in [ + "../evil", + "foo/bar", + "foo\\bar", + ".", + "..", + "", + "C:evil", + "foo:bar", + "foobar", + "foo\"bar", + "foo|bar", + "foo?bar", + "foo*bar", + "foo\nbar", + "foo\0bar", + ] { encrypted.name = bad_name.to_string(); let save = keystore.save_wallet(&encrypted); assert!( From ae57fd52565c818c919902af7615249ef120b371 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:20:33 +0800 Subject: [PATCH 48/74] fix(wormhole): take collect-rewards mnemonic from a file --mnemonic put a full seed phrase on argv, which is strictly more sensitive than the --secret argv that was already removed. Mirror --secret-file with --mnemonic-file and reject the old flag. Co-authored-by: Cursor --- src/cli/wormhole.rs | 92 ++++++++++++++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 31 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 59c5dae..d146abd 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -310,6 +310,17 @@ fn read_secret_hex_file(path: &str) -> Result { Ok(secret_hex) } +/// Read a mnemonic phrase from a file (never from argv). +fn read_mnemonic_file(path: &str) -> Result { + let mnemonic = + std::fs::read_to_string(path).map_err(|e| format!("Failed to read mnemonic file: {}", e))?; + let mnemonic = mnemonic.trim().to_string(); + if mnemonic.is_empty() { + return Err("Mnemonic file is empty".to_string()); + } + Ok(mnemonic) +} + /// 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") { @@ -934,19 +945,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-file must be provided. - #[arg(short, long, required_unless_present_any = ["mnemonic", "secret_file"], conflicts_with_all = ["mnemonic", "secret_file"])] + /// Either --wallet, --mnemonic-file, or --secret-file must be provided. + #[arg(short, long, required_unless_present_any = ["mnemonic_file", "secret_file"], conflicts_with_all = ["mnemonic_file", "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_file"], conflicts_with_all = ["wallet", "secret_file"])] - mnemonic: Option, + /// File containing a mnemonic phrase for HD derivation (alternative to --wallet). + /// The phrase is never accepted on argv. + #[arg(long, required_unless_present_any = ["wallet", "secret_file"], conflicts_with_all = ["wallet", "secret_file"])] + mnemonic_file: 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` - #[arg(long, required_unless_present_any = ["wallet", "mnemonic"], conflicts_with_all = ["wallet", "mnemonic"])] + /// or --mnemonic-file). Use this with a secret generated by `quantus-node key quantus + /// --scheme wormhole`. + #[arg(long, required_unless_present_any = ["wallet", "mnemonic_file"], conflicts_with_all = ["wallet", "mnemonic_file"])] secret_file: Option, /// Password for the wallet (only used with --wallet) @@ -961,7 +972,7 @@ pub enum WormholeCommands { #[arg(short, long)] amount: Option, - /// Destination address for withdrawn funds (required when using --mnemonic or + /// Destination address for withdrawn funds (required when using --mnemonic-file or /// --secret-file) #[arg(long)] destination: Option, @@ -1154,7 +1165,7 @@ pub async fn handle_wormhole_command( }, WormholeCommands::CollectRewards { wallet, - mnemonic, + mnemonic_file, secret_file, password, password_file, @@ -1167,7 +1178,7 @@ pub async fn handle_wormhole_command( } => run_collect_rewards( wallet, - mnemonic, + mnemonic_file, secret_file, password, password_file, @@ -2142,7 +2153,7 @@ fn load_multiround_wallet( // Require a persisted mnemonic for deterministic wormhole HD derivation. 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(), + "Wallet does not contain a mnemonic. Use a wallet created from a mnemonic, or supply --mnemonic-file/--secret-file where supported.".to_string(), ) })?; log_verbose!("Using wallet mnemonic for HD derivation"); @@ -2232,7 +2243,9 @@ async fn execute_initial_transfers( calls.push(transfer_call); } - let batch_tx = quantus_node::api::tx().utility().batch(calls); + // batch_all is atomic: either every wormhole funding transfer lands or none + // do, so the per-secret proof bookkeeping below can't diverge from chain state. + let batch_tx = quantus_node::api::tx().utility().batch_all(calls); let quantum_keypair = QuantumKeyPair { public_key: wallet.keypair.public_key.clone(), @@ -2273,7 +2286,7 @@ async fn execute_initial_transfers( transfer_counts_before.push(count); } - submit_transaction( + let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, &quantum_keypair, batch_tx, @@ -2283,11 +2296,13 @@ 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 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(); + // Read events from the transaction's own finalized inclusion block; the + // finalized tip may already have moved past it. + let block_hash = included_in.ok_or_else(|| { + crate::error::QuantusError::Generic( + "Batch transfer watch returned no inclusion block".to_string(), + ) + })?; let events_api = quantus_client.client().events().at(block_hash).await.map_err(|e| { @@ -3851,7 +3866,7 @@ async fn run_dissolve( #[allow(clippy::too_many_arguments)] async fn run_collect_rewards( wallet_name: Option, - mnemonic_arg: Option, + mnemonic_file_arg: Option, secret_file_arg: Option, password: Option, password_file: Option, @@ -3874,7 +3889,7 @@ async fn run_collect_rewards( log_print!("=================================================="); log_print!(""); - // Get credential and wallet address from wallet, mnemonic, or secret file + // Get credential and wallet address from wallet, mnemonic file, 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)?; @@ -3882,8 +3897,9 @@ async fn run_collect_rewards( WormholeCredential::Mnemonic { phrase: wallet.mnemonic, wormhole_index }, Some(wallet.wallet_address), ) - } else if let Some(mnemonic) = mnemonic_arg { - // Use provided mnemonic directly + } else if let Some(mnemonic_file) = mnemonic_file_arg { + let mnemonic = + read_mnemonic_file(&mnemonic_file).map_err(crate::error::QuantusError::Generic)?; (WormholeCredential::Mnemonic { phrase: mnemonic, wormhole_index }, None) } else if let Some(secret_file) = secret_file_arg { // Use provided secret file directly (no HD derivation) @@ -3892,18 +3908,18 @@ async fn run_collect_rewards( (WormholeCredential::Secret { hex: secret }, None) } else { return Err(crate::error::QuantusError::Generic( - "Either --wallet, --mnemonic, or --secret-file must be provided".to_string(), + "Either --wallet, --mnemonic-file, or --secret-file must be provided".to_string(), )); }; - // Destination address - required when using mnemonic or secret file directly + // Destination address - required when using mnemonic-file 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-file".to_string(), + "--destination is required when using --mnemonic-file or --secret-file".to_string(), )); }; @@ -4828,7 +4844,9 @@ 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-file"), + s.contains("--wallet") || + s.contains("--mnemonic-file") || + s.contains("--secret-file"), "expected missing-credential error, got: {s}" ); } @@ -4836,16 +4854,16 @@ mod tests { #[test] 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(&["--mnemonic-file", "mnemonic.txt"]).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", "--mnemonic-file", "m"), ("--wallet", "w", "--secret-file", "s"), - ("--mnemonic", "m", "--secret-file", "s"), + ("--mnemonic-file", "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(); @@ -4856,6 +4874,18 @@ mod tests { } } + /// Mnemonic phrases must not be accepted on argv (use --mnemonic-file). + #[test] + fn collect_rewards_rejects_mnemonic_cli_argument() { + let err = try_parse_collect_rewards(&["--mnemonic", "word ".repeat(24).trim()]) + .unwrap_err() + .to_string(); + assert!( + err.contains("unexpected argument") || err.contains("--mnemonic"), + "expected --mnemonic to be rejected, got: {err}" + ); + } + /// #160103: wormhole secrets must not be accepted on argv (use --secret-file). #[test] fn wormhole_rejects_secret_cli_argument() { From 6e248de201f8d2bde237041657448350cd224d64 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:20:45 +0800 Subject: [PATCH 49/74] fix(wormhole): prove collect-rewards against finalized tip Defaulting to the best block lets reorgs invalidate proofs before finality. Match recursive flows and use chain_getFinalizedHead when --at-block is not set. Co-authored-by: Cursor --- src/collect_rewards_lib.rs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index 064fa23..d8bd0ac 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -368,17 +368,22 @@ pub async fn collect_rewards( .await .map_err(|e| CollectRewardsError::from(format!("Failed to get block: {}", e)))? } else { - // Use latest block - let best_block = quantus_client - .get_latest_block() + // Prove against the latest finalized block. Best-block proofs can be + // invalidated by reorgs before finality (same class as recursive flows). + use subxt::ext::jsonrpsee::{core::client::ClientT, rpc_params}; + let finalized_hash: subxt::utils::H256 = quantus_client + .rpc_client() + .request("chain_getFinalizedHead", rpc_params![]) .await - .map_err(|e| CollectRewardsError::from(format!("Failed to get latest block: {}", e)))?; + .map_err(|e| { + CollectRewardsError::from(format!("Failed to get finalized block hash: {}", e)) + })?; quantus_client .client() .blocks() - .at(best_block) + .at(finalized_hash) .await - .map_err(|e| CollectRewardsError::from(format!("Failed to get block: {}", e)))? + .map_err(|e| CollectRewardsError::from(format!("Failed to get finalized block: {}", e)))? }; let proof_block_hash = proof_block.hash(); From c86a9b0dedf324bc5388eeb9e185a70ab9644b93 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:21:43 +0800 Subject: [PATCH 50/74] fix(wormhole): bound unsigned verify finalization waits The verify_private/public_batch submitters waited on an unbounded status stream. Reuse wait_tx_inclusion so they share the same inactivity and overall-deadline limits as signed watches. Co-authored-by: Cursor --- src/cli/common.rs | 59 +++++++++++++++++++++++------ src/cli/wormhole.rs | 91 ++++++++++++++++++++++----------------------- 2 files changed, 93 insertions(+), 57 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 149b384..96983c5 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -13,7 +13,7 @@ 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; +pub(crate) const TX_STATUS_FINALIZED_TIMEOUT_SECS: u64 = 30 * 60; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ExecutionMode { @@ -191,7 +191,9 @@ fn require_extrinsic_index(our_extrinsic_index: Option) -> Result }) } -type TxWatchFlow = std::ops::ControlFlow, ()>; +/// `Break` carries the outcome of the watch: the hash of the block in which the +/// transaction reached the target stage, or the terminal error. +type TxWatchFlow = std::ops::ControlFlow, ()>; fn update_waiting_spinner( spinner: Option<&indicatif::ProgressBar>, @@ -269,7 +271,7 @@ async fn handle_in_best_block( elapsed_secs )); } - std::ops::ControlFlow::Break(Ok(())) + std::ops::ControlFlow::Break(Ok(block_hash)) }, Ok(WatchDecision::Continue) => std::ops::ControlFlow::Continue(()), Err(err) => std::ops::ControlFlow::Break(Err(err)), @@ -303,7 +305,7 @@ async fn handle_in_finalized_block( if let Some(pb) = spinner { pb.finish_with_message(format!("βœ… Transaction finalized! ({}s)", elapsed_secs)); } - std::ops::ControlFlow::Break(Ok(())) + std::ops::ControlFlow::Break(Ok(block_hash)) }, Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => std::ops::ControlFlow::Continue(()), @@ -518,6 +520,29 @@ pub async fn submit_transaction( tip: Option, execution_mode: ExecutionMode, ) -> crate::error::Result +where + Call: subxt::tx::Payload, +{ + let (tx_hash, _included_in) = + submit_transaction_with_inclusion_block(quantus_client, from_keypair, call, tip, execution_mode) + .await?; + Ok(tx_hash) +} + +/// Like [`submit_transaction`], but also returns the hash of the block in which +/// the transaction reached the requested stage (`None` when the transaction was +/// only submitted without watching). +/// +/// Callers that read events for the transaction must use this block hash rather +/// than the current best/finalized tip, which may have moved past the inclusion +/// block by the time the watch returns. +pub async fn submit_transaction_with_inclusion_block( + quantus_client: &crate::chain::client::QuantusClient, + from_keypair: &crate::wallet::QuantumKeyPair, + call: Call, + tip: Option, + execution_mode: ExecutionMode, +) -> crate::error::Result<(subxt::utils::H256, Option)> where Call: subxt::tx::Payload, { @@ -599,7 +624,7 @@ where let tx_hash = tx_progress.extrinsic_hash(); - wait_tx_inclusion( + let included_in = wait_tx_inclusion( &mut tx_progress, quantus_client.client(), &tx_hash, @@ -607,7 +632,7 @@ where ) .await?; - Ok(tx_hash) + Ok((tx_hash, Some(included_in))) }, Err(e) => { log_error!("❌ Failed to submit transaction: {e:?}"); @@ -618,7 +643,7 @@ where 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) + Ok((tx_hash, None)) }, Err(e) => { log_error!("❌ Failed to submit transaction: {e:?}"); @@ -678,7 +703,7 @@ where Ok(mut tx_progress) => { let tx_hash = tx_progress.extrinsic_hash(); crate::log_print!("βœ… Transaction submitted: {:?}", tx_hash); - wait_tx_inclusion( + let _included_in = wait_tx_inclusion( &mut tx_progress, quantus_client.client(), &tx_hash, @@ -711,12 +736,19 @@ where /// Since Quantus network is PoW, we can't use default subxt's way of waiting for finalized block as /// it may take a long time. We wait for the transaction to be included in the best block and leave /// it up to the user to check the status of the transaction. -async fn wait_tx_inclusion( +/// +/// Returns the hash of the block in which the transaction reached the target +/// stage, so callers can read events from the actual inclusion block instead of +/// racing the moving finalized tip. +/// +/// Also used by unsigned wormhole verify submitters so they share the same +/// inactivity / overall-deadline bounds as signed watches. +pub(crate) async fn wait_tx_inclusion( tx_progress: &mut TxProgress>, client: &OnlineClient, tx_hash: &subxt::utils::H256, target_stage: TransactionStage, -) -> Result<()> { +) -> Result { use indicatif::{ProgressBar, ProgressStyle}; let start_time = std::time::Instant::now(); @@ -855,7 +887,12 @@ async fn wait_tx_inclusion( Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => { update_waiting_spinner(spinner.as_ref(), target_stage, elapsed_secs); }, - Ok(WatchDecision::Success) => return Ok(()), + // In-block events are handled (and returned) above; no other event + // reports Success, so this arm is defensively unreachable. + Ok(WatchDecision::Success) => + return Err(crate::error::QuantusError::Generic( + "transaction watcher reported success without an inclusion block".to_string(), + )), Err(err) => { crate::log_error!(" {} (elapsed: {}s)", err, elapsed_secs); if let Some(pb) = spinner { diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index d146abd..48b5cbf 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -1663,8 +1663,6 @@ pub async fn submit_unsigned_verify_private_batch( quantus_client: &QuantusClient, proof_bytes: Vec, ) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> { - use subxt::tx::TxStatus; - let verify_tx = quantus_node::api::tx().wormhole().verify_private_batch(proof_bytes); let unsigned_tx = quantus_client.client().tx().create_unsigned(&verify_tx).map_err(|e| { @@ -1676,27 +1674,15 @@ pub async fn submit_unsigned_verify_private_batch( .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to submit tx: {}", e)))?; - while let Some(Ok(status)) = tx_progress.next().await { - match status { - TxStatus::InBestBlock(_) => continue, - TxStatus::InFinalizedBlock(tx_in_block) => { - return Ok(( - IncludedAt::Finalized, - tx_in_block.block_hash(), - tx_in_block.extrinsic_hash(), - )); - }, - TxStatus::Error { message } | TxStatus::Invalid { message } => { - return Err(crate::error::QuantusError::Generic(format!( - "Transaction failed: {}", - message - ))); - }, - _ => continue, - } - } - - Err(crate::error::QuantusError::Generic("Transaction stream ended unexpectedly".to_string())) + let tx_hash = tx_progress.extrinsic_hash(); + let block_hash = crate::cli::common::wait_tx_inclusion( + &mut tx_progress, + quantus_client.client(), + &tx_hash, + crate::cli::common::TransactionStage::Finalized, + ) + .await?; + Ok((IncludedAt::Finalized, block_hash, tx_hash)) } /// Collect wormhole events for our extrinsic (by tx_hash) in a given block. @@ -1828,8 +1814,6 @@ pub async fn submit_unsigned_verify_public_batch( quantus_client: &QuantusClient, proof_bytes: Vec, ) -> crate::error::Result<(IncludedAt, subxt::utils::H256, subxt::utils::H256)> { - use subxt::tx::TxStatus; - let verify_tx = quantus_node::api::tx().wormhole().verify_public_batch(proof_bytes); let unsigned_tx = quantus_client.client().tx().create_unsigned(&verify_tx).map_err(|e| { @@ -1841,27 +1825,15 @@ pub async fn submit_unsigned_verify_public_batch( .await .map_err(|e| crate::error::QuantusError::Generic(format!("Failed to submit tx: {}", e)))?; - while let Some(Ok(status)) = tx_progress.next().await { - match status { - TxStatus::InBestBlock(_) => continue, - TxStatus::InFinalizedBlock(tx_in_block) => { - return Ok(( - IncludedAt::Finalized, - tx_in_block.block_hash(), - tx_in_block.extrinsic_hash(), - )); - }, - TxStatus::Error { message } | TxStatus::Invalid { message } => { - return Err(crate::error::QuantusError::Generic(format!( - "Transaction failed: {}", - message - ))); - }, - _ => continue, - } - } - - Err(crate::error::QuantusError::Generic("Transaction stream ended unexpectedly".to_string())) + let tx_hash = tx_progress.extrinsic_hash(); + let block_hash = crate::cli::common::wait_tx_inclusion( + &mut tx_progress, + quantus_client.client(), + &tx_hash, + crate::cli::common::TransactionStage::Finalized, + ) + .await?; + Ok((IncludedAt::Finalized, block_hash, tx_hash)) } async fn verify_public_batch(proof_file: String, node_url: &str) -> crate::error::Result<()> { @@ -4320,6 +4292,33 @@ mod tests { let _: *const () = at_finalized_block as *const (); } + #[test] + fn unsigned_verify_submitters_use_bounded_finalization_wait() { + // The unbounded `while let Some(Ok(status)) = tx_progress.next()` loops + // must stay gone; unsigned verify shares wait_tx_inclusion's deadlines. + let source = include_str!("wormhole.rs"); + let private_fn = source + .split("pub async fn submit_unsigned_verify_private_batch") + .nth(1) + .and_then(|s| s.split("pub async fn ").next()) + .expect("private batch submitter"); + let public_fn = source + .split("pub async fn submit_unsigned_verify_public_batch") + .nth(1) + .and_then(|s| s.split("pub async fn ").next()) + .expect("public batch submitter"); + for body in [private_fn, public_fn] { + assert!( + body.contains("wait_tx_inclusion"), + "unsigned verify must use the bounded wait_tx_inclusion helper" + ); + assert!( + !body.contains("tx_progress.next().await"), + "unsigned verify must not wait on an unbounded status stream" + ); + } + } + #[test] fn test_compute_output_amount() { // 0.1% fee (10 bps): output = input * 9990 / 10000 From f0797f17ab43a9a946a204c76c6dbfc4b0650954 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:22:09 +0800 Subject: [PATCH 51/74] fix(wormhole): read dissolve events from inclusion block After the initial dissolve transfer, events were fetched from the moving finalized tip, which can skip the inclusion block after funds moved. Use the watched inclusion hash instead. Co-authored-by: Cursor --- src/cli/wormhole.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 48b5cbf..fde5553 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -3600,7 +3600,7 @@ async fn run_dissolve( private_key: wallet.keypair.private_key.clone(), }; - submit_transaction( + let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( &quantus_client, &quantum_keypair, transfer_tx, @@ -3610,10 +3610,13 @@ async fn run_dissolve( .await .map_err(|e| crate::error::QuantusError::Generic(format!("Initial transfer failed: {}", e)))?; - 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(); + // Read events from the transaction's own finalized inclusion block; the + // finalized tip may already have moved past it. + let block_hash = included_in.ok_or_else(|| { + crate::error::QuantusError::Generic( + "Initial transfer watch returned no inclusion block".to_string(), + ) + })?; let events_api = quantus_client.client().events().at(block_hash).await.map_err(|e| { crate::error::QuantusError::Generic(format!("Failed to get events: {}", e)) From 4cd1838d498a2726292e41ebe70fb3284801d2c3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:22:53 +0800 Subject: [PATCH 52/74] chore(wormhole): drop unused submit_transaction import Co-authored-by: Cursor --- src/cli/wormhole.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index fde5553..9216087 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -5,7 +5,7 @@ use crate::{ }, cli::{ address_format::{bytes_to_quantus_ss58, slice_to_quantus_ss58}, - common::{submit_transaction, ExecutionMode}, + common::ExecutionMode, send::get_balance, }, log_error, log_print, log_success, log_verbose, From 622190ae25c10dc1e266895eec82f9162e50572f Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:22:53 +0800 Subject: [PATCH 53/74] fix(exercise): recognize SubXT pool rejections as clean fuzz failures Submit errors now surface as QuantusError::Subxt without the old "Failed to submit transaction" wrapper, so the fuzz classifier treated valid pool rejections as unclean. Match SubXT validity strings, and delete the constant-false is_retryable_submission_error tombstone and unused get_incremented_nonce_with_client helper. Co-authored-by: Cursor --- src/cli/common.rs | 81 ------------------------------ src/cli/exercise/scenarios/fuzz.rs | 52 ++++++++++++++++--- 2 files changed, 46 insertions(+), 87 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 96983c5..98a78f7 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -450,64 +450,6 @@ pub async fn get_fresh_nonce_with_client( Ok(latest_nonce) } -/// 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. -/// 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, - base_nonce: u64, -) -> Result { - 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 - .get_account_nonce_from_best_block(&from_account_id) - .await - .map_err(|e| { - crate::error::QuantusError::NetworkError(format!( - "Failed to get account nonce from best block: {e:?}" - )) - })?; - - // Use the higher of current nonce or base_nonce + 1 - let incremented_nonce = std::cmp::max(current_nonce, base_nonce + 1); - log_verbose!( - "πŸ”’ Using incremented nonce: {} (base: {}, current from latest block: {})", - incremented_nonce, - base_nonce, - current_nonce - ); - 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. @@ -1250,29 +1192,6 @@ 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")); - } - #[test] fn submit_preimage_does_not_classify_already_noted_by_substring() { // #160718: control flow must not branch on the literal "AlreadyNoted" in diff --git a/src/cli/exercise/scenarios/fuzz.rs b/src/cli/exercise/scenarios/fuzz.rs index d533821..19f5d4f 100644 --- a/src/cli/exercise/scenarios/fuzz.rs +++ b/src/cli/exercise/scenarios/fuzz.rs @@ -21,17 +21,31 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res Ok(()) } +fn is_clean_rejection(msg: &str) -> bool { + // Submit failures now surface as QuantusError::Subxt (Display: "SubXT error: …"), + // not the old Generic wrapper that contained "Failed to submit transaction". + const NEEDLES: &[&str] = &[ + "Transaction execution failed", + "Transaction invalid", + "Transaction error", + "Transaction dropped", + "Failed to submit transaction", + "Invalid Transaction", + "Transaction has a bad signature", + "Priority is too low", + "Transaction is outdated", + "Transaction is temporarily banned", + "Inability to pay some fees", + ]; + NEEDLES.iter().any(|needle| msg.contains(needle)) +} + fn classify(result: crate::error::Result, what: &str) -> Result { match result { Ok(hash) => Ok(format!("{what}: included ({hash:?})")), Err(e) => { let msg = e.to_string(); - let clean = msg.contains("Transaction execution failed") || - msg.contains("Transaction invalid") || - msg.contains("Transaction error") || - msg.contains("Transaction dropped") || - msg.contains("Failed to submit transaction"); - if clean { + if is_clean_rejection(&msg) { let first = msg.lines().next().unwrap_or(&msg).to_string(); Ok(format!("{what}: cleanly rejected ({first})")) } else { @@ -150,3 +164,29 @@ async fn fuzz_reversible(ctx: &mut ExerciseCtx) -> Result { .await; classify(result, &format!("reversible transfer of {amount}")) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_treats_subxt_pool_rejections_as_clean() { + // Submit failures now Display as "SubXT error: …" without the old + // "Failed to submit transaction" wrapper text. + let err = QuantusError::Generic( + "SubXT error: RpcError: Invalid Transaction: Custom error: 0".to_string(), + ); + // Use NetworkError-shaped text that still contains the SubXT Display form. + let result: crate::error::Result = Err(err); + let out = classify(result, "transfer").expect("pool rejection is clean"); + assert!(out.contains("cleanly rejected"), "got: {out}"); + } + + #[test] + fn classify_treats_connection_failures_as_unclean() { + let result: crate::error::Result = + Err(QuantusError::NetworkError("connection reset by peer".to_string())); + let err = classify(result, "transfer").expect_err("network failure is unclean"); + assert!(err.to_string().contains("unclean failure"), "got: {err}"); + } +} From d0f0a9e1bf2ef4114f87a66707997ca168b1e92c Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:23:32 +0800 Subject: [PATCH 54/74] fix(bins): share MANIFESTED_FILES via bins_consts build.rs and bins.rs each defined the manifest file list, so a one-sided edit produced "file set mismatch" against freshly built artifacts. Keep the single source of truth next to the other shared bins constants. Co-authored-by: Cursor --- build.rs | 15 --------------- src/bins.rs | 15 --------------- src/bins_consts.rs | 18 ++++++++++++++++++ 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/build.rs b/build.rs index 5c21a66..1f7dbce 100644 --- a/build.rs +++ b/build.rs @@ -42,21 +42,6 @@ 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"); diff --git a/src/bins.rs b/src/bins.rs index 9d3d957..696cdf9 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -54,21 +54,6 @@ 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, diff --git a/src/bins_consts.rs b/src/bins_consts.rs index 199758b..f66a16b 100644 --- a/src/bins_consts.rs +++ b/src/bins_consts.rs @@ -7,6 +7,24 @@ const VERSION_MARKER: &str = ".quantus-cli-version"; /// Shared by `build.rs` and `crate::bins` via `include!`. const MANIFEST_FILE: &str = "manifest.json"; +/// Files hashed into `manifest.json` by `build.rs` and authenticated by +/// `crate::bins` at runtime. Defined once here so the two sides cannot drift +/// into a "file set mismatch" rejection of freshly built artifacts. +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, +]; + /// 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). From bd423e2c6f7bb68ee30526305df6b599f6145f22 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:23:45 +0800 Subject: [PATCH 55/74] docs: justify CodeQL workflow removal in the V12 PR notes Alerts were almost entirely test/example noise; cargo audit remains the dependency CVE gate. Document the decision and how to restore a narrower Actions-hygiene check later if wanted. Co-authored-by: Cursor --- PR_V12_SECURITY.md | 183 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 PR_V12_SECURITY.md diff --git a/PR_V12_SECURITY.md b/PR_V12_SECURITY.md new file mode 100644 index 0000000..0ff2d43 --- /dev/null +++ b/PR_V12_SECURITY.md @@ -0,0 +1,183 @@ +## Summary + +Addresses the V12 security audit findings in `v12-issues.md`. + +**Scope:** only findings with `Validity: Unreviewed`. V12 marks **314 Low** findings as `Validity: Invalid` (likely incorrect); those are **excluded** from this analysis and are not treated as open work. + +| Severity | Unreviewed (in scope) | Invalid (excluded) | +|----------|----------------------:|-------------------:| +| High | 20 | 0 | +| Medium | 30 | 0 | +| Low | 11 | 314 | +| Info | 2 | 0 | + +This PR remediates **all 20 High and 30 Medium** Unreviewed findings (with redβ†’green tests where applicable). The **11 Unreviewed Lows** are listed below; most remain open for follow-up. A few Invalid Lows were hardened opportunistically and are noted separately (out of audit scope). + +**34 commits** on `illuzen/v12-2`. Library suite: **211+ tests passing**. + +## High (20/20 addressed) + +| ID | Title | Outcome | +|----|-------|---------| +| #159453 | Wallet mnemonic/seed as CLI args | Fixed β€” always hidden prompt | +| #159924 | Malformed wallet aborts listing | Fixed β€” skip bad files; validate SS58 | +| #160053 | MultisigCreated mis-attribution | Fixed β€” correlate creator/signers/threshold/nonce | +| #160582 | Raw `--password` CLI credentials | Fixed β€” reject at helper boundary | +| #160592 | Keystore permissive permissions | Fixed β€” dir `0700`, files `0600` | +| #160593 | Unauthenticated address redirect | Fixed β€” integrity check on decrypt | +| #160594 | Unauthenticated metadata substitution | Fixed β€” passwordless paths stop trusting envelope | +| #160598 | Filesystem races in wallet storage | Fixed β€” locks, random temps, name checks | +| #160605 | Failed legacy migration bypass | Fixed β€” fail closed on migration save | +| #160611 | Watched txs succeed when absent | Fixed β€” missing extrinsic β†’ error | +| #160612 | Unsafe retries duplicate txs | Fixed β€” single submit, no nonce-bump retry | +| #160624 | Unverified RPC signing context | Fixed β€” Quantus runtime identity gate | +| #160655 | Batch not atomic | Fixed β€” `utility.batch_all` | +| #160708 | Password-file permission checks | Fixed β€” Unix owner-only required | +| #160716 | Legacy AES key alongside ciphertext | Already fixed (`e0be480`); residual: refuse re-persisting digests | +| #160737 | Wallet creation not atomic | Fixed β€” exclusive create / hard_link | +| #160748 | Ephemeral mnemonic strands funds | Fixed β€” require persisted mnemonic | +| #160754 | Transfer events unbound | Fixed β€” match from/amount/count | +| #160773 | Self-update without integrity check | Fixed β€” verify published SHA-256 | +| #160791 | Unvalidated RPC token properties | Fixed β€” fail-closed decimals/symbol/ss58 | + +## Medium (30/30 addressed) + +| ID | Title | Outcome | +|----|-------|---------| +| #159340 | Version/nonce panics on decrypt | Fixed | +| #159469 | Exported mnemonic on stdout | Fixed β€” require `--output` (0o600) | +| #159662 | Storage pagination loop/overflow | Fixed | +| #159890 | Spent transfers reported available | Fixed | +| #159916 | Single-block over-limit abort | Fixed β€” offset pagination | +| #160052 | Duplicate signers | Fixed β€” sort+dedup | +| #160103 | Wormhole `--secret` argv | Fixed β€” `--secret-file` | +| #160105 | Secrets not zeroized after proof | Fixed | +| #160110 | Unbounded Merkle depth | Fixed | +| #160591 | Wallet secrets retained | Fixed β€” Drop/zeroize/redacted Debug | +| #160595 | Wallet name path escape | Fixed (with #160598 name validation) | +| #160625 | Unbounded tx-status waits | Fixed β€” deadlines | +| #160640 | Malformed pubkey panic | Fixed β€” `InvalidPublicKey` | +| #160652 | Token metadata / decimal format | Fixed β€” `checked_pow` + validation | +| #160656 | Transfer data / chain decimals | Fixed | +| #160660 | Removal missing member rank | Fixed β€” required `--min-rank` | +| #160667 | Recursive wormhole unfinalized | Fixed β€” finalized snapshots | +| #160674 | Delay conversion overflow | Fixed β€” checked helpers | +| #160697 | Circuit artifacts unauthenticated | Fixed β€” `manifest.json` SHA-256 | +| #160699 | Artifact symlink redirection | Fixed β€” refuse symlinks | +| #160700 | Build artifact publish races | Fixed β€” atomic publish | +| #160715 | Exhausting Argon2 params | Fixed β€” lock to generated profile | +| #160718 | Preimage AlreadyNoted substring | Fixed β€” verify on-chain | +| #160724 | WS URL credentials in diagnostics | Fixed β€” redact userinfo | +| #160732 | Transfer total wrap | Fixed β€” `checked_add` | +| #160734 | Batch vs call-count limit | Fixed β€” runtime `batched_calls_limit` | +| #160749 | Failed extrinsic reported verified | Fixed β€” failure-dominant | +| #160776 | Missing aggregate bypasses split | Fixed | +| #160777 | Offset not global across ranges | Fixed | +| #160783 | Public helpers panic on bad input | Fixed β€” fallible APIs | + +## Low (11 Unreviewed β€” in scope) + +| ID | Title | Status in this PR | +|----|-------|-------------------| +| #159905 | Byte-indexed address truncation on remote IDs | Open | +| #159911 | CLI transfer limit accepts values above documented 1000 | Open | +| #159917 | Fragile substring matching for limit-exceeded errors | Open | +| #160136 | Distribution invariant broken by u128 overflow | Open (related hardening via checked adds elsewhere) | +| #160585 | Password-file permits symlink targets / unbounded reads | Partial β€” mode/owner checks added (#160708); symlink/size bounds still open | +| #160678 | Malformed RPC header fields can panic CLI | Open | +| #160711 | Malformed wallet nonce panics during unlock | Open | +| #160744 | Unchecked RPC string slicing can crash system inspection | Open | +| #160760 | Unavailable home directories can panic | Open | +| #160789 | WalletManager lacks sync for concurrent FS ops | Partial β€” keystore process lock / create locks from High #160598/#160737 | +| #160800 | Proposal IDs decoded from key suffix without layout validation | Open | + +### Excluded: 314 Low with `Validity: Invalid` +Out of scope per V12. No further triage required for merge of this PR. + +### Opportunistic hardening (Invalid Lows β€” not audit blockers) +Some Invalid Lows were still tightened while adjacent to High/Medium work (e.g. block-list bounds, storage iterate cap, JSON numeric parsing, multisend dupes, metadata `checked_add`). These are optional defense-in-depth, not required to close the Unreviewed set. + +## Info (2 Unreviewed) +- #160685 Bind deposits/votes to confirmed referendum index β€” informational +- #160730 Non-native leaves represented as native assets β€” informational + +## CodeQL workflow removal + +The in-repo CodeQL GitHub Actions workflow (`.github/workflows/codeql.yml`) was +removed in this PR. Rationale: + +- Nearly all alerts on this crate were noise in `#[cfg(test)]` modules and + `examples/` (hard-coded test keys, intentional diagnostic prints). +- First-party Rust security for dependencies remains covered by `cargo audit` + in `ci.yml`. Clippy (`-D warnings`) covers a large class of local correctness + issues that CodeQL's Rust queries duplicated poorly. +- Preferring a quiet CI signal over a high false-positive Actions check that + reviewers learned to ignore. + +If CodeQL is reintroduced later, prefer path exclusions (`paths-ignore` for +`examples/**`) and test-code filters rather than re-enabling the prior +`security-and-quality` sweep as a required check. Actions-hygiene rules that +lived under the `actions` language matrix are the one useful piece dropped; +those can be restored as a narrow workflow without the Rust analysis if needed. + +## Breaking / UX changes callers should know + +- `wallet import` / `from-seed`: no `--mnemonic` / `--seed` flags (stdin prompts) +- `--password` / `-p` rejected everywhere; use `--password-file`, env, or prompt +- `wallet create` / `import` / `from-seed` no longer silently use an empty password; prompt (with confirm), `--password-file`, or env; empty only via `--allow-empty-password` +- Wormhole: `--secret` β†’ `--secret-file`; `collect-rewards --mnemonic` β†’ `--mnemonic-file` +- `wallet export`: requires `--output` file (no stdout mnemonic dump) +- Tech collective remove: requires `--min-rank` +- Circuit artifacts: need a rebuild so `generated-bins/` is a real directory with `manifest.json` (symlink-style bins rejected) +- `QuantusClient::new` rejects non-Quantus / incompatible runtimes (expects `specName=quantus-runtime`, the name the real runtime declares); `compatibility-check` connects ungated so it can diagnose rejected nodes +- Batch transfers use `batch_all` (atomic fail-all) + +## Test plan + +- [x] `cargo test --lib` (211 passed) +- [ ] Manual: `quantus wallet import --name x --mnemonic '...'` fails clap parse +- [ ] Manual: `quantus wallet create --name x --password secret` errors with guidance +- [ ] Manual: wallet dir/files are `0700`/`0600` after create +- [x] Manual: connect to wrong `specName` RPC fails (verified vs `wss://rpc.polkadot.io`); real node accepted (verified vs `wss://a2-heisenberg.quantus.cat`, spec 136/tx 3); `compatibility-check` reports INCOMPATIBLE for Polkadot +- [ ] Manual: `quantus update` refuses checksum mismatch (if exercising updater) +- [ ] Full circuit rebuild without `SKIP_CIRCUIT_BUILD` once for new `generated-bins` layout +- [ ] Smoke send / multisig create / wormhole prove against local node + +## Commits + +``` +3d781aa fix(wallet): require an explicit password when creating wallets +ccc244a fix(cli): bound ranges and reject silent zero coercions +7f569f9 fix(bins): authenticate circuit artifacts and publish atomically +fdaee0c fix(cli): validate amounts delays ranks and fallible address helpers +06997c2 fix(wallet): zeroize secret material after encrypt and decrypt +8cd2d4c fix(wormhole): validate Merkle depth and prefer finalized snapshots +fbd38ed fix(wormhole): zeroize proof-generation secrets after use +fb5b36f fix(subsquid): harden exhaustive transfer queries and spent filtering +bcdab11 fix(batch): enforce runtime batched_calls_limit for batch size +12d3ff6 fix(rewards): use checked addition for indexer transfer totals +5015a2e fix(tx): bound transaction-status subscription waits +b7039e6 fix(storage): bound pagination against overflow and stuck cursors +1e9e910 fix(multisig): deduplicate signers before predict and threshold +0bec0ea fix(wallet): write exported mnemonics to a protected file +a710bf6 fix(wormhole): remove --secret argv and verify extrinsic failures +986c2cd fix(client): redact WebSocket URL credentials in diagnostics +95c1fc9 fix(wallet): return errors for malformed public keys +eeaefa7 fix(wallet): refuse to persist wallets with embedded AES key material +c5d3f2d fix(update): verify release archive SHA-256 before install +5fc7920 fix(wormhole): bind transfer events to from amount and count +304d6d5 fix(system): fail closed on invalid RPC token properties +a8051b1 fix(client): verify Quantus runtime identity at connect time +bfd228e fix(wallet): harden storage races and exclusive wallet creation +02c741a fix(wallet): authenticate address metadata and fail closed on migration +dfd96c0 fix(tx): stop unsafe nonce-bump retries on ambiguous errors +f111d9b fix(wormhole): require persisted mnemonic for HD secrets +0460883 fix(batch): use utility.batch_all for atomic transfers +27d8a9f fix(wallet): require restrictive password-file permissions +3bc368c fix(wallet): enforce owner-only keystore permissions +58b8780 fix(tx): fail when watched extrinsic is missing from block +e6dd668 fix(wallet): reject raw --password CLI credentials +d2ac243 fix(multisig): correlate MultisigCreated to creator and params +253c791 fix(wallet): skip malformed files when listing wallets +03dad4d fix(wallet): stop accepting mnemonic and seed via CLI flags +``` From 3e7d260357c0c0d794a099787f5fa5da441beafc Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 13:29:22 +0800 Subject: [PATCH 56/74] fmt --- src/cli/common.rs | 11 ++++++++--- src/cli/wormhole.rs | 8 +++----- src/collect_rewards_lib.rs | 9 +++------ src/wallet/keystore.rs | 18 ++---------------- src/wormhole_lib.rs | 7 +++---- 5 files changed, 19 insertions(+), 34 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 98a78f7..de546ac 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -465,9 +465,14 @@ pub async fn submit_transaction( where Call: subxt::tx::Payload, { - let (tx_hash, _included_in) = - submit_transaction_with_inclusion_block(quantus_client, from_keypair, call, tip, execution_mode) - .await?; + let (tx_hash, _included_in) = submit_transaction_with_inclusion_block( + quantus_client, + from_keypair, + call, + tip, + execution_mode, + ) + .await?; Ok(tx_hash) } diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 9216087..a29ef0f 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -312,8 +312,8 @@ fn read_secret_hex_file(path: &str) -> Result { /// Read a mnemonic phrase from a file (never from argv). fn read_mnemonic_file(path: &str) -> Result { - let mnemonic = - std::fs::read_to_string(path).map_err(|e| format!("Failed to read mnemonic file: {}", e))?; + let mnemonic = std::fs::read_to_string(path) + .map_err(|e| format!("Failed to read mnemonic file: {}", e))?; let mnemonic = mnemonic.trim().to_string(); if mnemonic.is_empty() { return Err("Mnemonic file is empty".to_string()); @@ -4846,9 +4846,7 @@ mod tests { let err = try_parse_collect_rewards(&[]).unwrap_err(); let s = err.to_string(); assert!( - s.contains("--wallet") || - s.contains("--mnemonic-file") || - s.contains("--secret-file"), + s.contains("--wallet") || s.contains("--mnemonic-file") || s.contains("--secret-file"), "expected missing-credential error, got: {s}" ); } diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index d8bd0ac..6ed9991 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -378,12 +378,9 @@ pub async fn collect_rewards( .map_err(|e| { CollectRewardsError::from(format!("Failed to get finalized block hash: {}", e)) })?; - quantus_client - .client() - .blocks() - .at(finalized_hash) - .await - .map_err(|e| CollectRewardsError::from(format!("Failed to get finalized block: {}", e)))? + quantus_client.client().blocks().at(finalized_hash).await.map_err(|e| { + CollectRewardsError::from(format!("Failed to get finalized block: {}", e)) + })? }; let proof_block_hash = proof_block.hash(); diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index facb31e..0dc721e 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -1642,22 +1642,8 @@ mod tests { // "foo:bar" creates an NTFS alternate data stream); the remaining // characters are Windows-reserved or control characters. for bad_name in [ - "../evil", - "foo/bar", - "foo\\bar", - ".", - "..", - "", - "C:evil", - "foo:bar", - "foobar", - "foo\"bar", - "foo|bar", - "foo?bar", - "foo*bar", - "foo\nbar", - "foo\0bar", + "../evil", "foo/bar", "foo\\bar", ".", "..", "", "C:evil", "foo:bar", "foobar", "foo\"bar", "foo|bar", "foo?bar", "foo*bar", "foo\nbar", "foo\0bar", ] { encrypted.name = bad_name.to_string(); let save = keystore.save_wallet(&encrypted); diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index e5934bf..fbac346 100644 --- a/src/wormhole_lib.rs +++ b/src/wormhole_lib.rs @@ -217,10 +217,9 @@ pub fn compute_output_amount(input_amount: u32, fee_bps: u32) -> u32 { /// API compatibility with existing callers and are ignored. /// /// # Arguments -/// * `input` - All input data for proof generation (including ZK Merkle proof). -/// Borrowed mutably: `input.secret` is zeroized before this function returns, -/// on success and on every error path. Callers that retry must rebuild the -/// input with a fresh secret. +/// * `input` - All input data for proof generation (including ZK Merkle proof). Borrowed mutably: +/// `input.secret` is zeroized before this function returns, on success and on every error path. +/// Callers that retry must rebuild the input with a fresh secret. /// * `prover_bin_path` - Ignored (legacy; leaf prover is built in-process) /// * `common_bin_path` - Ignored (legacy; leaf prover is built in-process) /// From 970428793ad848bc1bd14b0517ec2591f11e38d3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:37:25 +0800 Subject: [PATCH 57/74] fix(bins): quarantine stale artifacts by filename, never the directory remove_path_nofollow(&dir) deleted the whole resolved bins directory, so QUANTUS_BINS_DIR=~/.quantus would wipe wallets on the first upgrade. Remove only the bounded set of known artifact filenames (including the legacy prover.bin), refuse directories occupying those names, and leave every other entry untouched. Co-authored-by: Cursor --- src/bins.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 2 deletions(-) diff --git a/src/bins.rs b/src/bins.rs index 696cdf9..f82b22e 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -92,7 +92,8 @@ fn user_bins_dir() -> PathBuf { /// /// Safe to call multiple times. A directory attributable to a different CLI /// version or sizing configuration (via its manifest or version marker) is -/// quarantined and regenerated, so upgrades recover automatically. A +/// quarantined (its artifact files removed by exact filename, never the +/// directory itself) and regenerated, so upgrades recover automatically. A /// same-version directory that fails authentication is rejected rather than /// overwritten. pub fn ensure_bins_dir() -> Result { @@ -112,7 +113,7 @@ pub fn ensure_bins_dir() -> Result { provenance, env!("CARGO_PKG_VERSION") ); - remove_path_nofollow(&dir).map_err(QuantusError::Generic)?; + remove_stale_artifact_files(&dir)?; }, None => { return Err(QuantusError::Generic(format!( @@ -166,6 +167,48 @@ fn stale_artifact_provenance(dir: &Path) -> Option { None } +/// Remove stale circuit artifacts by exact filename, never recursively. +/// +/// The bins directory can be user-pointed (`QUANTUS_BINS_DIR`) at a directory +/// that also holds unrelated data β€” e.g. `~/.quantus`, which contains wallets. +/// Quarantine therefore only ever deletes the bounded, explicit set of artifact +/// filenames this CLI (or an older release) produced, and refuses to touch +/// anything else. Stale files must still be removed (not just overwritten): +/// if a newer builder stops emitting a manifested file, a leftover stale copy +/// would otherwise be silently hashed into the new manifest as trusted. +fn remove_stale_artifact_files(dir: &Path) -> Result<()> { + let mut names: std::collections::BTreeSet<&str> = REQUIRED_FILES.iter().copied().collect(); + names.extend(MANIFESTED_FILES.iter().copied()); + names.insert(MANIFEST_FILE); + names.insert(VERSION_MARKER); + // Legacy leaf prover emitted by pre-3.1.0 circuit builders. + names.insert("prover.bin"); + + for name in names { + let path = dir.join(name); + match fs::symlink_metadata(&path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => { + return Err(QuantusError::Generic(format!( + "Failed to inspect stale artifact {}: {}", + path.display(), + e + ))); + }, + Ok(meta) if meta.is_dir() => { + return Err(QuantusError::Generic(format!( + "Refusing to remove directory {} while replacing stale circuit artifacts; remove it manually and rerun", + path.display() + ))); + }, + Ok(_) => { + remove_path_nofollow(&path).map_err(QuantusError::Generic)?; + }, + } + } + Ok(()) +} + fn ensure_safe_bins_dir(dir: &Path) -> Result<()> { match fs::symlink_metadata(dir) { Ok(meta) => { @@ -587,6 +630,54 @@ mod tests { assert_eq!(stale_artifact_provenance(dir), None); } + /// Quarantine must never delete anything beyond the exact artifact + /// filenames β€” a user can point QUANTUS_BINS_DIR at a directory that also + /// holds wallets or other data. + #[test] + fn stale_quarantine_preserves_unrelated_entries() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + fs::write(dir.join("prover.bin"), b"legacy leaf prover").unwrap(); + + let wallets = dir.join("wallets"); + fs::create_dir_all(&wallets).unwrap(); + fs::write(wallets.join("alice.json"), b"precious wallet").unwrap(); + fs::write(dir.join("notes.txt"), b"user data").unwrap(); + + remove_stale_artifact_files(dir).expect("quarantine succeeds"); + + for name in REQUIRED_FILES { + assert!(!dir.join(name).exists(), "stale artifact {name} must be removed"); + } + assert!(!dir.join("prover.bin").exists(), "legacy prover must be removed"); + assert!(!dir.join(VERSION_MARKER).exists(), "version marker must be removed"); + assert!( + wallets.join("alice.json").exists(), + "unrelated wallet data must survive quarantine" + ); + assert!(dir.join("notes.txt").exists(), "unrelated files must survive quarantine"); + } + + /// A directory occupying an artifact filename is never recursively deleted. + #[test] + fn stale_quarantine_refuses_directory_named_like_artifact() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + seed_required_files(dir); + fs::remove_file(dir.join("verifier.bin")).unwrap(); + fs::create_dir_all(dir.join("verifier.bin")).unwrap(); + fs::write(dir.join("verifier.bin").join("keep.txt"), b"keep").unwrap(); + + let err = remove_stale_artifact_files(dir) + .expect_err("directory named like an artifact must abort quarantine"); + assert!(err.to_string().contains("Refusing to remove directory"), "got: {err}"); + assert!( + dir.join("verifier.bin").join("keep.txt").exists(), + "contents of the unexpected directory must be untouched" + ); + } + /// A same-version directory with mismatched sizing regenerates instead of erroring. #[test] fn artifacts_with_different_sizing_are_stale() { From ed490bf7a82e356100fbf011d7f8fccc5a268690 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:43:28 +0800 Subject: [PATCH 58/74] fix(multisig): correlate MultisigCreated in the inclusion block Both create paths read events from the moving tip after the watch returned, so with --finalized the strictly-matched event was almost never found. Use submit_transaction_with_inclusion_block like the wormhole flows. Co-authored-by: Cursor --- src/cli/multisig.rs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index d86524e..aaa60b1 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -554,7 +554,7 @@ pub async fn create_multisig( 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( + let (tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, creator_keypair, create_tx, @@ -563,10 +563,15 @@ pub async fn create_multisig( ) .await?; - // If waiting, extract the matching address from events + // If waiting, extract the matching address from the events of the + // transaction's own inclusion block; the tip may have moved past it. 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 inclusion_block_hash = included_in.ok_or_else(|| { + crate::error::QuantusError::Generic( + "Multisig creation watch returned no inclusion block".to_string(), + ) + })?; + let events = quantus_client.client().events().at(inclusion_block_hash).await?; let multisig_events = events.find::(); @@ -1181,7 +1186,7 @@ async fn handle_create_multisig( wait_for_transaction: true, // Always wait to confirm address }; - let _tx_hash = crate::cli::common::submit_transaction( + let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( &quantus_client, &keypair, create_tx, @@ -1197,9 +1202,14 @@ async fn handle_create_multisig( log_print!(""); log_print!("πŸ” Looking for MultisigCreated event..."); - // Query latest block events - let latest_block_hash = quantus_client.get_latest_block().await?; - let events = quantus_client.client().events().at(latest_block_hash).await?; + // Query events at the transaction's own inclusion block; with --finalized + // the tip is typically far past it by the time the watch returns. + let inclusion_block_hash = included_in.ok_or_else(|| { + crate::error::QuantusError::Generic( + "Multisig creation watch returned no inclusion block".to_string(), + ) + })?; + let events = quantus_client.client().events().at(inclusion_block_hash).await?; // Find MultisigCreated event matching this create let multisig_events = From a35b92acd89f9f1771494d21a84a017a2ea23893 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:43:28 +0800 Subject: [PATCH 59/74] fix(tx): widen pre-inclusion inactivity window to 120s The status stream is silent for a full PoW block interval between Broadcasted and InBestBlock; with ~exponential 10s intervals a 30s window aborted ~1 in 20 valid transactions, inviting duplicate-submit retries. Use twelve target intervals and warn in both timeout errors that the transaction may still execute. Co-authored-by: Cursor --- src/cli/common.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index de546ac..114139f 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -11,7 +11,13 @@ 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; +/// Pre-inclusion inactivity window. The status stream is legitimately silent +/// between Broadcasted and InBestBlock for a full PoW block interval, and block +/// intervals are roughly exponential around the ~10s target: a 30s window +/// aborted ~1 in 20 valid transactions (e^-3), inviting duplicate-submission +/// retries. Twelve target intervals make a spurious abort negligible (~e^-12) +/// while still catching genuinely dead streams well inside the overall deadline. +const TX_STATUS_INACTIVITY_TIMEOUT_SECS: u64 = 120; const TX_STATUS_INCLUDED_TIMEOUT_SECS: u64 = 5 * 60; pub(crate) const TX_STATUS_FINALIZED_TIMEOUT_SECS: u64 = 30 * 60; @@ -163,12 +169,12 @@ fn describe_watched_tx_event( ))), 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 {}", + "Transaction status stream timed out after {timeout_secs} seconds without updates before the transaction was {}. The transaction may still be in the pool and execute later; verify its status on chain before resubmitting, or you may duplicate it", 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 {}", + "Timed out after waiting {elapsed_secs} seconds for the transaction to be {}. The transaction may still be in the pool and execute later; verify its status on chain before resubmitting, or you may duplicate it", target_stage.status_label() ))), } @@ -1128,6 +1134,11 @@ mod tests { ); const { assert!(TX_STATUS_INACTIVITY_TIMEOUT_SECS > 0); + // Must cover many ~10s PoW block intervals: the stream is silent + // between Broadcasted and InBestBlock, and aborting a valid pending + // transaction invites duplicate-submission retries (#160612). + assert!(TX_STATUS_INACTIVITY_TIMEOUT_SECS >= 120); + assert!(TX_STATUS_INACTIVITY_TIMEOUT_SECS < TX_STATUS_INCLUDED_TIMEOUT_SECS); assert!(TX_STATUS_INCLUDED_TIMEOUT_SECS < TX_STATUS_FINALIZED_TIMEOUT_SECS); } } From 5024d72b6f6f1642808e8fff8dea7edf43e96e39 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:47:01 +0800 Subject: [PATCH 60/74] fix(wallet): freeze Argon2 wallet profile as literals Encrypt used Argon2::default() and decrypt pinned to Params::DEFAULT_*, which are crate properties that already changed between argon2 0.4 and 0.5 - a future bump would silently brick every wallet on disk while self-consistent roundtrip tests stayed green. Freeze m=19456/t=2/p=1 as literals shared by both sides and pin them with a test. Co-authored-by: Cursor --- src/wallet/keystore.rs | 68 ++++++++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 16 deletions(-) diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 0dc721e..efb8e33 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -51,6 +51,26 @@ fn keystore_lock() -> &'static Mutex<()> { LOCK.get_or_init(|| Mutex::new(())) } +/// Frozen Argon2id wallet-format profile (memory KiB, iterations, parallelism). +/// +/// Deliberately literals rather than `argon2::Params::DEFAULT_*`: the crate's +/// defaults are crate properties and already changed between argon2 0.4 and +/// 0.5. If encrypt/decrypt tracked them, a future dependency bump would +/// silently write a new profile and reject every wallet already on disk with a +/// bare Decryption error (while self-consistent roundtrip tests stayed green). +const WALLET_ARGON2_M_COST: u32 = 19_456; +const WALLET_ARGON2_T_COST: u32 = 2; +const WALLET_ARGON2_P_COST: u32 = 1; + +/// Argon2 instance for the frozen wallet profile, used by both encrypt and +/// decrypt so the written and accepted profiles cannot drift apart. +fn wallet_argon2() -> Argon2<'static> { + let params = + Params::new(WALLET_ARGON2_M_COST, WALLET_ARGON2_T_COST, WALLET_ARGON2_P_COST, None) + .expect("frozen Argon2 wallet profile is valid"); + Argon2::new(Algorithm::Argon2id, Version::V0x13, params) +} + fn wallet_filename(name: &str) -> Result { // Reject path separators and traversal, plus Windows-specific escapes: // ':' makes "C:evil.json" resolve outside the keystore (drive-relative @@ -556,8 +576,8 @@ impl Keystore { let mut argon2_salt = [0u8; 16]; rng().fill_bytes(&mut argon2_salt); - // 2. Derive encryption key from password using Argon2 (quantum-safe) - let argon2 = Argon2::default(); + // 2. Derive encryption key from password using the frozen Argon2 profile + let argon2 = wallet_argon2(); let salt_string = argon2::password_hash::SaltString::encode_b64(&argon2_salt) .map_err(|e| WalletError::Encryption(e.to_string()))?; let password_hash = argon2 @@ -652,13 +672,9 @@ impl Keystore { fn derive_aes_key(encrypted: &EncryptedWallet, password: &str) -> Result> { // 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; - + // generated wallets use Argon2id v=19 with the frozen profile + // (m=19456 KiB, t=2, p=1), and accepting higher values lets a crafted + // file force expensive memory/CPU work before password validation. let parsed = PasswordHash::new(&encrypted.argon2_params).map_err(|_| WalletError::Decryption)?; @@ -670,10 +686,13 @@ impl Keystore { 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 != SUPPORTED_M_COST || t_cost != SUPPORTED_T_COST || p_cost != SUPPORTED_P_COST { + let m_cost = parsed.params.get_decimal("m").unwrap_or(WALLET_ARGON2_M_COST); + let t_cost = parsed.params.get_decimal("t").unwrap_or(WALLET_ARGON2_T_COST); + let p_cost = parsed.params.get_decimal("p").unwrap_or(WALLET_ARGON2_P_COST); + if m_cost != WALLET_ARGON2_M_COST || + t_cost != WALLET_ARGON2_T_COST || + p_cost != WALLET_ARGON2_P_COST + { return Err(WalletError::Decryption.into()); } let params = @@ -1240,7 +1259,8 @@ mod tests { fn encrypt_legacy(data: &WalletData, password: &str) -> EncryptedWallet { let mut argon2_salt = [0u8; 16]; rng().fill_bytes(&mut argon2_salt); - let argon2 = Argon2::default(); + // Legacy files in the wild were produced with the same frozen profile. + let argon2 = wallet_argon2(); let salt_string = argon2::password_hash::SaltString::encode_b64(&argon2_salt).unwrap(); let password_hash = argon2.hash_password(password.as_bytes(), &salt_string).unwrap(); let hash_bytes = password_hash.hash.as_ref().unwrap().as_bytes(); @@ -1282,7 +1302,7 @@ mod tests { assert!(!Keystore::has_embedded_key_material(&encrypted)); // The serialized wallet file must not contain the base64 digest anywhere. - let argon2 = Argon2::default(); + let argon2 = wallet_argon2(); let salt_string = argon2::password_hash::SaltString::encode_b64(&encrypted.argon2_salt).unwrap(); let full_phc = argon2.hash_password(b"hunter2", &salt_string).unwrap().to_string(); @@ -1376,10 +1396,26 @@ mod tests { } } + /// The written profile is pinned to literals: a future argon2 crate bump + /// changing `Params::DEFAULT_*` must not silently change what we write + /// (and thereby brick every wallet already on disk at decrypt time). + #[test] + fn encrypt_writes_the_frozen_argon2_profile() { + let temp_dir = TempDir::new().expect("temp dir"); + let keystore = Keystore::new(temp_dir.path()); + let data = make_test_wallet_data("frozen-profile", 13); + let encrypted = keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + + let parsed = PasswordHash::new(&encrypted.argon2_params).expect("PHC parses"); + assert_eq!(parsed.params.get_decimal("m"), Some(19_456), "m cost must stay frozen"); + assert_eq!(parsed.params.get_decimal("t"), Some(2), "t cost must stay frozen"); + assert_eq!(parsed.params.get_decimal("p"), Some(1), "p cost must stay frozen"); + } + #[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). + // Argon2 runs (the frozen profile is m=19456, t=2, p=1). let temp_dir = TempDir::new().expect("temp dir"); let keystore = Keystore::new(temp_dir.path()); let data = make_test_wallet_data("high-cost", 11); From 38fbed01aaa97a53ca6d7559e8fd7e9762f92ea5 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:52:38 +0800 Subject: [PATCH 61/74] fix(security): wipe tractable caller-side secret copies, scope zeroization honestly Library-boundary zeroization was nullified by surviving caller copies. Wipe the ones the current type shapes allow: hex-encoded secrets in multiround/ dissolve proof loops, the parsed secret local in generate_proof, DissolveOutput secrets (zeroize-on-drop + redacted Debug), prompted mnemonic/seed strings in wallet import/from-seed, seed copies in WalletManager create paths, and raw password-file reads. Downgrade #160105/ #160591 to partially fixed in the PR doc with a scope note: Copy arrays and String reallocation make full closure impossible without non-Copy wrapper types end to end. Co-authored-by: Cursor --- PR_V12_SECURITY.md | 23 +++++++++++++++++-- src/cli/wallet.rs | 16 +++++++++----- src/cli/wormhole.rs | 50 +++++++++++++++++++++++++++++++++--------- src/wallet/mod.rs | 22 +++++++++++-------- src/wallet/password.rs | 15 ++++++------- 5 files changed, 91 insertions(+), 35 deletions(-) diff --git a/PR_V12_SECURITY.md b/PR_V12_SECURITY.md index 0ff2d43..b5813d3 100644 --- a/PR_V12_SECURITY.md +++ b/PR_V12_SECURITY.md @@ -51,9 +51,9 @@ This PR remediates **all 20 High and 30 Medium** Unreviewed findings (with red | #159916 | Single-block over-limit abort | Fixed β€” offset pagination | | #160052 | Duplicate signers | Fixed β€” sort+dedup | | #160103 | Wormhole `--secret` argv | Fixed β€” `--secret-file` | -| #160105 | Secrets not zeroized after proof | Fixed | +| #160105 | Secrets not zeroized after proof | Partially fixed β€” see zeroization scope note | | #160110 | Unbounded Merkle depth | Fixed | -| #160591 | Wallet secrets retained | Fixed β€” Drop/zeroize/redacted Debug | +| #160591 | Wallet secrets retained | Partially fixed β€” see zeroization scope note | | #160595 | Wallet name path escape | Fixed (with #160598 name validation) | | #160625 | Unbounded tx-status waits | Fixed β€” deadlines | | #160640 | Malformed pubkey panic | Fixed β€” `InvalidPublicKey` | @@ -101,6 +101,25 @@ Some Invalid Lows were still tightened while adjacent to High/Medium work (e.g. - #160685 Bind deposits/votes to confirmed referendum index β€” informational - #160730 Non-native leaves represented as native assets β€” informational +## Zeroization scope note (#160105 / #160591) + +Zeroization is enforced at the library boundary (`wormhole_lib::generate_proof` +wipes `input.secret` on all paths via drop guards; wallet decrypt buffers, +`WalletData`, and derived AES keys are wiped) and at the caller sites that were +tractable: hex-encoded secret strings in the multiround/dissolve flows, +`DissolveOutput` secrets (zeroize-on-drop, redacted `Debug`), prompted +mnemonics/seeds in `wallet import`/`from-seed`, seed copies inside +`WalletManager`, and password-file reads. + +These items are still not *fully* closed, and cannot be with the current type +shapes: secrets are `Copy` arrays (`[u8; 32]`) and plain `String`s, so every +pass-by-value and reallocation can leave untracked copies on the stack or in +freed heap blocks (e.g. the expected-event tuples in the dissolve flow, and +`String` reallocations inside prompt libraries). Fully closing them would mean +migrating to non-`Copy` zeroize-on-drop wrapper types end to end, which is out +of scope for this PR. Treat the residual risk as: secrets may persist in +process memory until overwritten; they are never persisted or printed. + ## CodeQL workflow removal The in-repo CodeQL GitHub Actions workflow (`.github/workflows/codeql.yml`) was diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 32d752e..c8db8fc 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -645,7 +645,7 @@ pub async fn handle_wallet_command( let wallet_manager = WalletManager::new()?; // Always read mnemonic from a hidden prompt so it never appears in process argv. - let mnemonic_phrase = get_mnemonic_from_user()?; + let mut mnemonic_phrase = get_mnemonic_from_user()?; // New-wallet password policy: confirmed prompt, no silent empty default. let final_password = crate::wallet::password::get_new_wallet_password( @@ -675,6 +675,7 @@ pub async fn handle_wallet_command( ) .await }; + crate::wallet::keystore::zeroize_string(&mut mnemonic_phrase); match result { Ok(wallet_info) => { @@ -707,9 +708,10 @@ pub async fn handle_wallet_command( // 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() + let mut seed_raw = rpassword::read_password() .map_err(|e| QuantusError::Generic(format!("Failed to read seed: {e}")))?; - let seed = seed.trim().to_string(); + let mut seed = seed_raw.trim().to_string(); + crate::wallet::keystore::zeroize_string(&mut seed_raw); // New-wallet password policy: confirmed prompt, no silent empty default. let final_password = crate::wallet::password::get_new_wallet_password( @@ -719,10 +721,12 @@ pub async fn handle_wallet_command( allow_empty_password, )?; - match wallet_manager + let result = wallet_manager .create_wallet_from_seed(&name, &seed, Some(&final_password)) - .await - { + .await; + crate::wallet::keystore::zeroize_string(&mut seed); + + match result { Ok(wallet_info) => { log_success!("Wallet name: {}", name.bright_green()); log_success!("Address: {}", wallet_info.address.bright_cyan()); diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index a29ef0f..c099b8a 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2407,9 +2407,11 @@ async fn generate_round_proofs( let single_start = std::time::Instant::now(); - // Generate proof with dual output assignment - generate_proof( - &hex::encode(secret.secret.as_bytes()), + // Generate proof with dual output assignment. Bind the hex-encoded + // secret so it can be wiped instead of dropping as a temporary. + let mut secret_hex = hex::encode(secret.secret.as_bytes()); + let proof_result = generate_proof( + &secret_hex, transfer.amount, // Use actual transfer amount for storage key &output_assignments[i], &format!("0x{}", hex::encode(proof_block_hash.0)), @@ -2419,7 +2421,9 @@ async fn generate_round_proofs( &proof_file, quantus_client, ) - .await?; + .await; + crate::wallet::keystore::zeroize_string(&mut secret_hex); + proof_result?; let single_elapsed = single_start.elapsed(); log_verbose!(" Proof {} generated in {:.2}s", i + 1, single_elapsed.as_secs_f64()); @@ -2821,7 +2825,7 @@ async fn generate_proof( quantus_client: &QuantusClient, ) -> crate::error::Result<()> { // Parse inputs - let secret = parse_secret_hex(secret_hex).map_err(crate::error::QuantusError::Generic)?; + let mut secret = parse_secret_hex(secret_hex).map_err(crate::error::QuantusError::Generic)?; let block_hash_bytes: [u8; 32] = hex::decode(block_hash_str.trim_start_matches("0x")) .map_err(|e| crate::error::QuantusError::Generic(format!("Invalid block hash: {}", e)))? @@ -2886,7 +2890,8 @@ async fn generate_proof( compute_merkle_positions(&zk_proof.siblings, zk_proof.leaf_hash); // Build ProofGenerationInput using wormhole_lib types with ZK Merkle proof. - // generate_proof zeroizes input.secret before returning. + // generate_proof zeroizes input.secret before returning; wipe the local + // copy as soon as it has been moved into the input struct. let mut input = wormhole_lib::ProofGenerationInput { secret, transfer_count, @@ -2908,6 +2913,7 @@ async fn generate_proof( volume_fee_bps: VOLUME_FEE_BPS, asset_id: NATIVE_ASSET_ID, }; + crate::wallet::keystore::zeroize_bytes(&mut secret); let bins_dir = crate::bins::ensure_bins_dir()?; let result = wormhole_lib::generate_proof( @@ -3467,7 +3473,7 @@ async fn parse_proof_file( } /// A pending wormhole output that can be used as input for the next dissolve layer. -#[derive(Debug, Clone)] +#[derive(Clone)] struct DissolveOutput { /// The secret used to derive the wormhole address secret: [u8; 32], @@ -3483,6 +3489,25 @@ struct DissolveOutput { leaf_index: u64, } +impl Drop for DissolveOutput { + fn drop(&mut self) { + crate::wallet::keystore::zeroize_bytes(&mut self.secret); + } +} + +impl std::fmt::Debug for DissolveOutput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DissolveOutput") + .field("secret", &"") + .field("amount", &self.amount) + .field("transfer_count", &self.transfer_count) + .field("funding_account", &self.funding_account) + .field("proof_block_hash", &self.proof_block_hash) + .field("leaf_index", &self.leaf_index) + .finish() + } +} + /// Dissolve a large wormhole deposit into many small outputs for better privacy. /// /// Creates a tree of wormhole transactions where each layer doubles the number of outputs @@ -3739,8 +3764,11 @@ async fn run_dissolve( let proof_file = format!("{}/batch{}_proof{}.hex", layer_dir, batch_idx, i); - generate_proof( - &hex::encode(input.secret), + // Bind the hex-encoded secret so it can be wiped instead of + // dropping as a temporary. + let mut secret_hex = hex::encode(input.secret); + let proof_result = generate_proof( + &secret_hex, input.amount, &assignment, &format!("0x{}", hex::encode(batch_proof_block_hash.0)), @@ -3750,7 +3778,9 @@ async fn run_dissolve( &proof_file, &quantus_client, ) - .await?; + .await; + crate::wallet::keystore::zeroize_string(&mut secret_hex); + proof_result?; proof_files.push(proof_file); } diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 5dfcb78..3728de7 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -259,10 +259,11 @@ impl WalletManager { let sensitive_seed = SensitiveBytes32::from(&mut seed); let mnemonic = generate_mnemonic(sensitive_seed).map_err(|_| WalletError::KeyGeneration)?; keystore::zeroize_bytes(&mut seed); - let seed64 = + let mut seed64 = mnemonic_to_seed(mnemonic.clone(), None).map_err(|_| WalletError::KeyGeneration)?; - let dilithium_pair = - DilithiumPair::from_seed(&seed64).map_err(|_| WalletError::KeyGeneration)?; + let dilithium_pair = DilithiumPair::from_seed(&seed64); + keystore::zeroize_bytes(&mut seed64); + let dilithium_pair = dilithium_pair.map_err(|_| WalletError::KeyGeneration)?; let quantum_keypair = QuantumKeyPair::from_resonance_pair(&dilithium_pair); // Create wallet data @@ -417,17 +418,20 @@ impl WalletManager { } // Convert hex to bytes - let seed_bytes = hex::decode(seed).map_err(|_| WalletError::InvalidMnemonic)?; + let mut seed_bytes = hex::decode(seed).map_err(|_| WalletError::InvalidMnemonic)?; if seed_bytes.len() != 32 { + keystore::zeroize_bytes(&mut seed_bytes); return Err(WalletError::InvalidMnemonic.into()); } - // Create DilithiumPair from seed - let seed_bytes_32: [u8; 32] = - seed_bytes.try_into().map_err(|_| WalletError::InvalidMnemonic)?; + // Create DilithiumPair from seed; wipe both copies of the seed after use. + let mut seed_bytes_32: [u8; 32] = + seed_bytes.as_slice().try_into().map_err(|_| WalletError::InvalidMnemonic)?; + keystore::zeroize_bytes(&mut seed_bytes); - let dilithium_pair = qp_dilithium_crypto::types::DilithiumPair::from_seed(&seed_bytes_32) - .map_err(|_| WalletError::InvalidMnemonic)?; + let dilithium_pair = qp_dilithium_crypto::types::DilithiumPair::from_seed(&seed_bytes_32); + keystore::zeroize_bytes(&mut seed_bytes_32); + let dilithium_pair = dilithium_pair.map_err(|_| WalletError::InvalidMnemonic)?; // Convert to QuantumKeyPair let quantum_keypair = QuantumKeyPair::from_resonance_pair(&dilithium_pair); diff --git a/src/wallet/password.rs b/src/wallet/password.rs index 8e70f56..10115fd 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -58,14 +58,13 @@ fn reject_raw_cli_password(password: &Option) -> Result<()> { 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(); + let mut raw = std::fs::read_to_string(file_path).map_err(|e| { + crate::error::QuantusError::Generic(format!( + "Failed to read password file '{file_path}': {e}" + )) + })?; + let pwd = raw.trim().to_string(); + crate::wallet::keystore::zeroize_string(&mut raw); Ok(pwd) } From a85b83a4c0ef81187128c8561c4050fc79e3f498 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:56:12 +0800 Subject: [PATCH 62/74] fix(bins): require explicit opt-in for CWD circuit artifacts The artifact manifest is unsigned and lives in the directory it authenticates, so an attacker-prepared checkout could ship a self-consistent ./generated-bins that passes verify_manifest, and resolve_bins_dir would silently prefer it over the per-user store. Refuse the implicit CWD source with an actionable error; local dev opts in explicitly via QUANTUS_BINS_DIR=./generated-bins. Co-authored-by: Cursor --- PR_V12_SECURITY.md | 1 + src/bins.rs | 72 +++++++++++++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/PR_V12_SECURITY.md b/PR_V12_SECURITY.md index b5813d3..db14666 100644 --- a/PR_V12_SECURITY.md +++ b/PR_V12_SECURITY.md @@ -148,6 +148,7 @@ those can be restored as a narrow workflow without the Rust analysis if needed. - `wallet export`: requires `--output` file (no stdout mnemonic dump) - Tech collective remove: requires `--min-rank` - Circuit artifacts: need a rebuild so `generated-bins/` is a real directory with `manifest.json` (symlink-style bins rejected) +- Circuit artifacts: `./generated-bins` in the current working directory is no longer trusted implicitly (the unsigned manifest lives in the directory it authenticates, so an untrusted checkout could ship a self-consistent artifact set). Local dev must opt in with `QUANTUS_BINS_DIR=./generated-bins`; installed binaries keep using `~/.quantus/generated-bins` - `QuantusClient::new` rejects non-Quantus / incompatible runtimes (expects `specName=quantus-runtime`, the name the real runtime declares); `compatibility-check` connects ungated so it can diagnose rejected nodes - Batch transfers use `batch_all` (atomic fail-all) diff --git a/src/bins.rs b/src/bins.rs index f82b22e..c70aa30 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -10,9 +10,15 @@ //! storage location and regenerates the binaries there on demand. //! //! Resolution order: -//! 1. `QUANTUS_BINS_DIR` env var (explicit override). -//! 2. `./generated-bins/` in the current directory (local dev). -//! 3. `~/.quantus/generated-bins/` (default for installed binaries). +//! 1. `QUANTUS_BINS_DIR` env var (explicit override; also how local dev opts +//! in to `./generated-bins`). +//! 2. `~/.quantus/generated-bins/` (default for installed binaries). +//! +//! `./generated-bins/` in the current working directory is detected but never +//! trusted implicitly: the manifest is unsigned and lives in the directory it +//! authenticates, so an attacker-prepared checkout could ship a +//! self-consistent artifact set that passes verification. Resolution fails +//! with an explicit opt-in instruction instead. use crate::{ error::{QuantusError, Result}, @@ -67,17 +73,28 @@ struct ArtifactManifest { /// /// This never generates anything; see [`ensure_bins_dir`] for the full /// resolve-and-generate flow. -pub fn resolve_bins_dir() -> PathBuf { - if let Ok(dir) = std::env::var(BINS_DIR_ENV) { - return PathBuf::from(dir); +/// +/// Fails if `./generated-bins` exists in the current working directory without +/// an explicit `QUANTUS_BINS_DIR` opt-in β€” see the module docs for why the CWD +/// source cannot be trusted implicitly. +pub fn resolve_bins_dir() -> Result { + resolve_bins_dir_from(std::env::var(BINS_DIR_ENV).ok(), Path::new("generated-bins")) +} + +fn resolve_bins_dir_from(env_override: Option, cwd_dir: &Path) -> Result { + if let Some(dir) = env_override { + return Ok(PathBuf::from(dir)); } - let cwd_dir = PathBuf::from("generated-bins"); if cwd_dir.join("config.json").exists() { - return cwd_dir; + return Err(QuantusError::Generic(format!( + "Found circuit artifacts in ./{dir} but refusing to trust the current working directory implicitly. Set {env}=./{dir} to use them, or run from another directory to use the per-user artifact store", + dir = cwd_dir.display(), + env = BINS_DIR_ENV, + ))); } - user_bins_dir() + Ok(user_bins_dir()) } /// Location used for auto-generated binaries on installed systems. @@ -97,7 +114,7 @@ fn user_bins_dir() -> PathBuf { /// same-version directory that fails authentication is rejected rather than /// overwritten. pub fn ensure_bins_dir() -> Result { - let dir = resolve_bins_dir(); + let dir = resolve_bins_dir()?; ensure_safe_bins_dir(&dir)?; if is_ready(&dir) { @@ -481,6 +498,41 @@ mod tests { assert!(!is_ready(dir)); } + #[test] + fn cwd_artifacts_are_refused_without_explicit_opt_in() { + // #160697 follow-up: an attacker-prepared checkout can ship a + // self-consistent ./generated-bins; it must never be trusted implicitly. + let tmp = TempDir::new().unwrap(); + let cwd_dir = tmp.path().join("generated-bins"); + fs::create_dir_all(&cwd_dir).unwrap(); + fs::write(cwd_dir.join("config.json"), b"{}").unwrap(); + + let err = resolve_bins_dir_from(None, &cwd_dir) + .expect_err("CWD artifacts must require explicit opt-in"); + assert!(err.to_string().contains(BINS_DIR_ENV), "unexpected error: {err}"); + } + + #[test] + fn env_override_wins_over_cwd_artifacts() { + let tmp = TempDir::new().unwrap(); + let cwd_dir = tmp.path().join("generated-bins"); + fs::create_dir_all(&cwd_dir).unwrap(); + fs::write(cwd_dir.join("config.json"), b"{}").unwrap(); + + let resolved = resolve_bins_dir_from(Some("/tmp/explicit-bins".to_string()), &cwd_dir) + .expect("explicit env override must resolve"); + assert_eq!(resolved, PathBuf::from("/tmp/explicit-bins")); + } + + #[test] + fn resolution_defaults_to_user_bins_dir_without_cwd_artifacts() { + let tmp = TempDir::new().unwrap(); + let cwd_dir = tmp.path().join("generated-bins"); + + let resolved = resolve_bins_dir_from(None, &cwd_dir).expect("default must resolve"); + assert_eq!(resolved, user_bins_dir()); + } + #[test] #[serial] fn ensure_bins_dir_rejects_incomplete_unauthenticated_directory() { From e0e925a2b77e7dea3198ad1d2b1d0ff290c55219 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:57:19 +0800 Subject: [PATCH 63/74] ci: reinstate CodeQL as a narrowed security-only workflow Deleting the workflow removed the repo's only first-party SAST and the Actions-hygiene checks. Bring it back with the default high-precision security suite (not security-and-quality) and examples/ path-excluded, which addresses the noise that motivated the removal while keeping source-level taint analysis. Co-authored-by: Cursor --- .github/workflows/codeql.yml | 57 ++++++++++++++++++++++++++++++++++++ PR_V12_SECURITY.md | 34 ++++++++++----------- 2 files changed, 73 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..7a0f050 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,57 @@ +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 analysis 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 }} + # Deliberately narrowed from the prior `security-and-quality` + # sweep, which was mostly quality noise in test modules and + # examples/ (hard-coded test keys, intentional prints) that + # reviewers learned to ignore. The default high-precision + # security suite plus a path exclusion for examples/ keeps the + # signal (taint analysis, Actions hygiene) without the noise. + config: | + paths-ignore: + - examples + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/PR_V12_SECURITY.md b/PR_V12_SECURITY.md index db14666..374d8c8 100644 --- a/PR_V12_SECURITY.md +++ b/PR_V12_SECURITY.md @@ -120,24 +120,22 @@ migrating to non-`Copy` zeroize-on-drop wrapper types end to end, which is out of scope for this PR. Treat the residual risk as: secrets may persist in process memory until overwritten; they are never persisted or printed. -## CodeQL workflow removal - -The in-repo CodeQL GitHub Actions workflow (`.github/workflows/codeql.yml`) was -removed in this PR. Rationale: - -- Nearly all alerts on this crate were noise in `#[cfg(test)]` modules and - `examples/` (hard-coded test keys, intentional diagnostic prints). -- First-party Rust security for dependencies remains covered by `cargo audit` - in `ci.yml`. Clippy (`-D warnings`) covers a large class of local correctness - issues that CodeQL's Rust queries duplicated poorly. -- Preferring a quiet CI signal over a high false-positive Actions check that - reviewers learned to ignore. - -If CodeQL is reintroduced later, prefer path exclusions (`paths-ignore` for -`examples/**`) and test-code filters rather than re-enabling the prior -`security-and-quality` sweep as a required check. Actions-hygiene rules that -lived under the `actions` language matrix are the one useful piece dropped; -those can be restored as a narrow workflow without the Rust analysis if needed. +## CodeQL workflow narrowed (was: removed) + +The CodeQL workflow was initially deleted in this PR because nearly all of its +alerts were noise in `#[cfg(test)]` modules and `examples/` (hard-coded test +keys, intentional diagnostic prints). Deleting it outright also removed the +repo's only first-party SAST (source-level taint analysis) and the +Actions-hygiene checks β€” `cargo audit` covers dependency CVEs only and clippy +is not a security analyzer β€” so the workflow is reinstated in narrowed form +instead: + +- Default high-precision security query suite instead of the prior + `security-and-quality` sweep (drops the quality-noise class). +- `paths-ignore: examples` so intentional example code stops generating + alerts. Inline `mod tests` blocks cannot be path-excluded; the narrowed + suite already skips most of what fired there. +- The `actions` language matrix (workflow hygiene rules) is kept as before. ## Breaking / UX changes callers should know From 73a942d6c3d45034a0abd8b5d3e28e9d7657fe33 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 15:58:22 +0800 Subject: [PATCH 64/74] docs(update): scope the self-update checksum claim to same-origin integrity The expected SHA-256 is a sibling asset of the same release fetched over the same channel, so it stops corruption and single-object substitution but not an attacker controlling release assets or the TLS channel. State the threat model in the module doc and PR notes, and point at self_update's zipsign/ed25519 signatures feature as the authenticity follow-up (needs release-pipeline signing first). Co-authored-by: Cursor --- PR_V12_SECURITY.md | 14 +++++++++++++- src/cli/update.rs | 9 +++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/PR_V12_SECURITY.md b/PR_V12_SECURITY.md index 374d8c8..49f156c 100644 --- a/PR_V12_SECURITY.md +++ b/PR_V12_SECURITY.md @@ -37,7 +37,7 @@ This PR remediates **all 20 High and 30 Medium** Unreviewed findings (with red | #160737 | Wallet creation not atomic | Fixed β€” exclusive create / hard_link | | #160748 | Ephemeral mnemonic strands funds | Fixed β€” require persisted mnemonic | | #160754 | Transfer events unbound | Fixed β€” match from/amount/count | -| #160773 | Self-update without integrity check | Fixed β€” verify published SHA-256 | +| #160773 | Self-update without integrity check | Fixed β€” same-origin SHA-256 integrity (not signing; see update note) | | #160791 | Unvalidated RPC token properties | Fixed β€” fail-closed decimals/symbol/ss58 | ## Medium (30/30 addressed) @@ -101,6 +101,18 @@ Some Invalid Lows were still tightened while adjacent to High/Medium work (e.g. - #160685 Bind deposits/votes to confirmed referendum index β€” informational - #160730 Non-native leaves represented as native assets β€” informational +## Self-update integrity scope note (#160773) + +The self-update SHA-256 check is same-origin integrity, not authenticity: the +expected hash is a sibling asset of the same GitHub release fetched over the +same TLS channel. It stops corruption and single-object substitution; it does +not stop an attacker who can write release assets or MITM TLS, since they +control both files. Upgrading to real authenticity means signing release +archives (e.g. `self_update`'s zipsign/ed25519 `signatures` feature with the +public key embedded in the binary), which requires release-pipeline changes +and is left as follow-up. The claim in the table above should not be read as +release signing. + ## Zeroization scope note (#160105 / #160591) Zeroization is enforced at the library boundary (`wormhole_lib::generate_proof` diff --git a/src/cli/update.rs b/src/cli/update.rs index 177f7af..13562b8 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -7,6 +7,15 @@ //! //! Before installing, the downloaded archive is verified against the sibling //! `sha256sums-*.txt` asset published by the release workflow. +//! +//! Threat model: this is same-origin integrity, not authenticity. The checksum +//! is a sibling asset of the same GitHub release fetched over the same TLS +//! channel, so it defends against corruption and single-object substitution, +//! but not against an attacker who can write release assets or MITM TLS (they +//! control both files). Cryptographic release signing (e.g. the `self_update` +//! crate's zipsign/ed25519 `signatures` feature, with the public key embedded +//! here) would be required for that, and needs the release pipeline to sign +//! assets first. use crate::{error::QuantusError, log_print, log_success}; use colored::Colorize; From 2bb734dd8d78722f36bb167ec192d7872cad3874 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:05:12 +0800 Subject: [PATCH 65/74] fix(subsquid): reject short indexer pages instead of silently dropping rows The offset paginator assumed every non-final page holds exactly the requested 1000 rows; a Hasura deployment with a lower API row cap would return fewer and the missing rows would be silently skipped. Require exact page sizes (full pages, remainder on the last) and verify the small-range branch returns exactly total_count rows. Co-authored-by: Cursor --- src/subsquid/client.rs | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index c4a2c11..963d4bf 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -299,6 +299,19 @@ impl SubsquidClient { .await?; if total_count <= SERVER_MAX_LIMIT as i64 { + // A Hasura deployment with an API row cap below our requested + // limit would return fewer rows than total_count and silently + // drop the rest; fail instead of returning a truncated set. + if transfers.len() as i64 != total_count { + return Err(QuantusError::Generic(format!( + "Indexer returned {} of {} transfers for blocks {}..={}; the server row cap appears lower than the requested limit of {}", + transfers.len(), + total_count, + lo, + hi, + SERVER_MAX_LIMIT + ))); + } all.extend(transfers); continue; } @@ -310,6 +323,17 @@ impl SubsquidClient { continue; } + // Single-block offset pagination: total_count > SERVER_MAX_LIMIT, + // so this first page must be exactly the server limit. + if transfers.len() != SERVER_MAX_LIMIT as usize { + return Err(QuantusError::Generic(format!( + "Indexer returned {} of {} transfers on the first page for block {}; the server row cap appears lower than the requested limit of {}", + transfers.len(), + total_count, + lo, + SERVER_MAX_LIMIT + ))); + } all.extend(transfers); let total_count = u32::try_from(total_count).map_err(|_| { QuantusError::Generic(format!( @@ -334,10 +358,17 @@ impl SubsquidClient { ) .await?; - if page.is_empty() { + // Every page must be exactly full except the last, which must + // hold the remainder; anything else means rows were dropped. + let expected = std::cmp::min(SERVER_MAX_LIMIT, total_count - offset) as usize; + if page.len() != expected { return Err(QuantusError::Generic(format!( - "Indexer returned an empty transfer page before offset {} of {} for block {}", - offset, total_count, lo + "Indexer returned {} transfers at offset {} of {} for block {}, expected {}", + page.len(), + offset, + total_count, + lo, + expected ))); } From 16ca0979224f5c277d8198088f1866254305a9ae Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:08:10 +0800 Subject: [PATCH 66/74] fix(wallet): make read-side O_NOFOLLOW portable across Unix The hardcoded 0o400000 constant is Linux's O_NOFOLLOW; on macOS the flag is 0x0100, so wallet reads there silently followed symlinks. Use libc::O_NOFOLLOW on all Unix targets and add a test that loading a wallet through a symlink fails (verified on macOS). Co-authored-by: Cursor --- Cargo.lock | 1 + Cargo.toml | 2 ++ src/wallet/keystore.rs | 40 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c19560..a91d7fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3974,6 +3974,7 @@ dependencies = [ "hex", "indicatif", "jsonrpsee", + "libc", "parity-scale-codec", "qp-dilithium-crypto", "qp-plonky2", diff --git a/Cargo.toml b/Cargo.toml index e927000..46cd125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,6 +103,8 @@ qp-wormhole-prover = { version = "3.1.0", default-features = false, features = [ qp-wormhole-verifier = { version = "3.1.0", default-features = false, features = ["std"] } qp-zk-circuits-common = { version = "3.1.0", default-features = false, features = ["std"] } +[target.'cfg(unix)'.dependencies] +libc = "0.2" [build-dependencies] hex = "0.4" diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index efb8e33..0c84077 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -89,14 +89,16 @@ fn wallet_filename(name: &str) -> Result { Ok(format!("{name}.json")) } -#[cfg(any(target_os = "linux", target_os = "android"))] +#[cfg(unix)] fn set_no_follow(options: &mut OpenOptions) { use std::os::unix::fs::OpenOptionsExt; - const O_NOFOLLOW: i32 = 0o400000; - options.custom_flags(O_NOFOLLOW); + // libc::O_NOFOLLOW carries the per-platform value; the previously + // hardcoded Linux constant (0o400000) was a silent no-op on macOS, + // where O_NOFOLLOW is 0x0100. + options.custom_flags(libc::O_NOFOLLOW); } -#[cfg(not(any(target_os = "linux", target_os = "android")))] +#[cfg(not(unix))] fn set_no_follow(_options: &mut OpenOptions) {} fn open_wallet_for_read(path: &Path) -> std::io::Result { @@ -1602,6 +1604,36 @@ mod tests { ); } + /// Read-side O_NOFOLLOW must refuse a wallet path that is a symlink on all + /// Unix platforms (the flag was previously a hardcoded Linux constant and + /// a silent no-op on macOS). + #[cfg(unix)] + #[test] + fn load_wallet_refuses_symlinked_wallet_file() { + 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"); + + // A real, valid wallet file living outside the keystore. + let outside_keystore = Keystore::new(&outside_dir); + let data = make_test_wallet_data("linked", 22); + let encrypted = outside_keystore.encrypt_wallet_data(&data, "pw").expect("encrypt"); + outside_keystore.save_wallet(&encrypted).expect("save outside wallet"); + + // Symlink it into the keystore under the queried name. + let link = wallets_dir.join("linked.json"); + symlink(outside_dir.join("linked.json"), &link).expect("plant symlink"); + + let keystore = Keystore::new(&wallets_dir); + keystore + .load_wallet("linked") + .expect_err("loading a wallet through a symlink must fail"); + } + /// #160737: exclusive create must refuse to replace an existing wallet file. #[test] fn save_new_wallet_does_not_replace_existing() { From 82f89c58948416f5de84bd3ff0c49ac987eca9b3 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:17:07 +0800 Subject: [PATCH 67/74] fix(wallet): remove infallible to_account_id_* fallback variants to_account_id_32 fell back to the all-zero account and to_account_id_ss58check to an empty string on malformed keys, turning a detectable error into a silent wrong answer that callers (including the wormhole funding-target path) could act on. Remove both and propagate errors through the try_ variants at every call site. Co-authored-by: Cursor --- examples/basic_usage.rs | 2 +- examples/service.rs | 4 +- examples/wallet_ops.rs | 2 +- examples/wormhole_sdk_e2e.rs | 2 +- src/cli/batch.rs | 2 +- src/cli/exercise/mod.rs | 2 +- src/cli/exercise/runner.rs | 6 +- src/cli/exercise/scenarios/balances.rs | 10 +- src/cli/exercise/scenarios/fuzz.rs | 6 +- src/cli/exercise/scenarios/governance.rs | 4 +- src/cli/exercise/scenarios/multisig.rs | 6 +- src/cli/exercise/scenarios/negative.rs | 18 ++-- src/cli/exercise/scenarios/reads.rs | 4 +- src/cli/exercise/scenarios/recovery.rs | 4 +- src/cli/exercise/scenarios/reversible.rs | 16 +-- src/cli/generic_call.rs | 2 +- src/cli/multisend.rs | 2 +- src/cli/multisig.rs | 12 ++- src/cli/recovery.rs | 6 +- src/cli/reversible.rs | 6 +- src/cli/send.rs | 6 +- src/cli/wallet.rs | 2 +- src/cli/wormhole.rs | 6 +- src/wallet/keystore.rs | 129 +++++++++-------------- src/wallet/mod.rs | 5 +- 25 files changed, 119 insertions(+), 145 deletions(-) diff --git a/examples/basic_usage.rs b/examples/basic_usage.rs index d12169a..63bf126 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -39,7 +39,7 @@ async fn main() -> Result<()> { let keypair = wallet_data.take_keypair(); // 5. Get account balance - let account_id = keypair.to_account_id_32(); + let account_id = keypair.try_to_account_id_32()?; let balance = get_account_balance(&client, &account_id).await?; println!("πŸ’° Balance: {balance} DEV"); diff --git a/examples/service.rs b/examples/service.rs index 2f371db..6b1d5b1 100644 --- a/examples/service.rs +++ b/examples/service.rs @@ -78,7 +78,7 @@ impl WalletService { Ok(WalletInfo { name: wallet_data.name.clone(), - address: wallet_data.keypair.to_account_id_ss58check(), + address: wallet_data.keypair.try_to_account_id_ss58check()?, balance, created_at: chrono::Utc::now().to_rfc3339(), // Could be stored in wallet data }) @@ -87,7 +87,7 @@ impl WalletService { /// Get wallet balance pub async fn get_wallet_balance(&self, name: &str, password: &str) -> Result { let wallet_data = self.wallet_manager.load_wallet(name, password)?; - let account_id = wallet_data.keypair.to_account_id_32(); + let account_id = wallet_data.keypair.try_to_account_id_32()?; let client = self.client.read().await; self.get_account_balance(&client, &account_id).await diff --git a/examples/wallet_ops.rs b/examples/wallet_ops.rs index 85cec74..534f9c8 100644 --- a/examples/wallet_ops.rs +++ b/examples/wallet_ops.rs @@ -58,7 +58,7 @@ impl QuantusApp { /// Get wallet balance pub async fn get_balance(&self, wallet_name: &str, password: &str) -> Result { let wallet_data = self.wallet_manager.load_wallet(wallet_name, password)?; - let account_id = wallet_data.keypair.to_account_id_32(); + let account_id = wallet_data.keypair.try_to_account_id_32()?; self.get_account_balance(&account_id).await } diff --git a/examples/wormhole_sdk_e2e.rs b/examples/wormhole_sdk_e2e.rs index 9a9bec3..b232101 100644 --- a/examples/wormhole_sdk_e2e.rs +++ b/examples/wormhole_sdk_e2e.rs @@ -151,7 +151,7 @@ async fn main() -> Result<()> { let wm = WalletManager::new()?; 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(); + let funder_ss58 = funder_kp.try_to_account_id_ss58check()?; println!(" wallet : {funder_ss58}"); // 2. derive wormhole address from a random secret + random exit account --- diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 25e65ed..87fefeb 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -147,7 +147,7 @@ async fn handle_batch_send_command( // Load wallet let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; - let from_account_id = keypair.to_account_id_ss58check(); + let from_account_id = keypair.try_to_account_id_ss58check()?; validate_batch_transfer_request(&quantus_client, &keypair, &transfers).await?; let effective_tip = crate::cli::send::effective_tip_amount(tip_amount); diff --git a/src/cli/exercise/mod.rs b/src/cli/exercise/mod.rs index 86b68e1..e58c46a 100644 --- a/src/cli/exercise/mod.rs +++ b/src/cli/exercise/mod.rs @@ -285,7 +285,7 @@ async fn fund_ephemeral_accounts(ctx: &mut ExerciseCtx, count: usize) -> Result< let mut addresses = Vec::with_capacity(count); for _ in 0..count { let keypair = ctx.fresh_keypair()?; - addresses.push(keypair.to_account_id_ss58check()); + addresses.push(keypair.try_to_account_id_ss58check()?); ctx.eph.push(keypair); } diff --git a/src/cli/exercise/runner.rs b/src/cli/exercise/runner.rs index b321aed..249c4cf 100644 --- a/src/cli/exercise/runner.rs +++ b/src/cli/exercise/runner.rs @@ -39,10 +39,10 @@ impl ExerciseCtx { } } -pub fn account_id_of(keypair: &QuantumKeyPair) -> SubxtAccountId32 { - let account = keypair.to_account_id_32(); +pub fn account_id_of(keypair: &QuantumKeyPair) -> Result { + let account = keypair.try_to_account_id_32()?; let bytes: [u8; 32] = *account.as_ref(); - SubxtAccountId32::from(bytes) + Ok(SubxtAccountId32::from(bytes)) } pub async fn submit_ok( diff --git a/src/cli/exercise/scenarios/balances.rs b/src/cli/exercise/scenarios/balances.rs index 66b7487..3d18596 100644 --- a/src/cli/exercise/scenarios/balances.rs +++ b/src/cli/exercise/scenarios/balances.rs @@ -21,7 +21,7 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res async fn single_transfer(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?; - let recipient_ss58 = recipient.to_account_id_ss58check(); + let recipient_ss58 = recipient.try_to_account_id_ss58check()?; let amount = ctx.unit; let sender = ctx.eph[0].clone(); @@ -48,7 +48,7 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result { let n = 3usize; let mut recipients = Vec::with_capacity(n); for _ in 0..n { - recipients.push(ctx.fresh_keypair()?.to_account_id_ss58check()); + recipients.push(ctx.fresh_keypair()?.try_to_account_id_ss58check()?); } let amount = ctx.unit / 2; let transfers: Vec<(String, u128)> = recipients.iter().map(|r| (r.clone(), amount)).collect(); @@ -69,7 +69,7 @@ async fn batch_transfer(ctx: &mut ExerciseCtx) -> Result { } async fn transfer_with_tip(ctx: &mut ExerciseCtx) -> Result { - let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); + let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; let amount = ctx.unit; let tip = ctx.unit / 10; let sender = ctx.eph[1].clone(); @@ -93,9 +93,9 @@ async fn transfer_with_tip(ctx: &mut ExerciseCtx) -> Result { async fn transfer_manual_nonce(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[1].clone(); - let account = sender.to_account_id_32(); + let account = sender.try_to_account_id_32()?; let nonce = ctx.client.get_account_nonce_from_best_block(&account).await?; - let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); + let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; crate::cli::send::transfer_with_nonce( &ctx.client, &sender, diff --git a/src/cli/exercise/scenarios/fuzz.rs b/src/cli/exercise/scenarios/fuzz.rs index 19f5d4f..f0a9968 100644 --- a/src/cli/exercise/scenarios/fuzz.rs +++ b/src/cli/exercise/scenarios/fuzz.rs @@ -82,10 +82,10 @@ fn random_recipient(ctx: &mut ExerciseCtx) -> Result { let idx = ctx.rng.random_range(0..ctx.eph.len()); - account_id_of(&ctx.eph[idx]) + account_id_of(&ctx.eph[idx])? }, - 1 => account_id_of(&ctx.fresh_keypair()?), - _ => account_id_of(&ctx.eph[0]), + 1 => account_id_of(&ctx.fresh_keypair()?)?, + _ => account_id_of(&ctx.eph[0])?, }) } diff --git a/src/cli/exercise/scenarios/governance.rs b/src/cli/exercise/scenarios/governance.rs index a80a24a..abbbaf6 100644 --- a/src/cli/exercise/scenarios/governance.rs +++ b/src/cli/exercise/scenarios/governance.rs @@ -20,7 +20,7 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res } async fn membership_reads(ctx: &mut ExerciseCtx) -> Result { - let alice_ss58 = ctx.alice.to_account_id_ss58check(); + let alice_ss58 = ctx.alice.try_to_account_id_ss58check()?; let is_member = crate::cli::tech_collective::is_member(&ctx.client, &alice_ss58).await?; if !is_member { return Err(QuantusError::Generic( @@ -133,7 +133,7 @@ async fn add_member_requires_root(ctx: &mut ExerciseCtx) -> Result { let intruder = ctx.fresh_keypair()?; let call = quantus_subxt::api::tx() .tech_collective() - .add_member(subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&intruder))); + .add_member(subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&intruder)?)); let alice = ctx.alice.clone(); submit_expect_failure(ctx, &alice, call, &["BadOrigin"]).await } diff --git a/src/cli/exercise/scenarios/multisig.rs b/src/cli/exercise/scenarios/multisig.rs index e2b3925..0883f20 100644 --- a/src/cli/exercise/scenarios/multisig.rs +++ b/src/cli/exercise/scenarios/multisig.rs @@ -21,7 +21,7 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result { let signer_b = ctx.eph[1].clone(); let signer_c = ctx.eph[2].clone(); let signers = - vec![account_id_of(&signer_a), account_id_of(&signer_b), account_id_of(&signer_c)]; + vec![account_id_of(&signer_a)?, account_id_of(&signer_b)?, account_id_of(&signer_c)?]; let threshold = 2u32; let nonce: u64 = rand::Rng::random(&mut ctx.rng); @@ -58,10 +58,10 @@ async fn lifecycle(ctx: &mut ExerciseCtx) -> Result { .await?; let recipient = ctx.fresh_keypair()?; - let recipient_ss58 = recipient.to_account_id_ss58check(); + let recipient_ss58 = recipient.try_to_account_id_ss58check()?; let amount = 2 * ctx.unit; let inner = quantus_subxt::api::tx().balances().transfer_allow_death( - subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)), + subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)?), amount, ); let call_data = inner diff --git a/src/cli/exercise/scenarios/negative.rs b/src/cli/exercise/scenarios/negative.rs index 97e8fd9..bb50ff5 100644 --- a/src/cli/exercise/scenarios/negative.rs +++ b/src/cli/exercise/scenarios/negative.rs @@ -41,8 +41,8 @@ fn transfer_call( async fn transfer_over_balance(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[0].clone(); let recipient = ctx.fresh_keypair()?; - let balance = ctx.free_balance(&sender.to_account_id_ss58check()).await?; - let call = transfer_call(account_id_of(&recipient), balance.saturating_mul(2)); + let balance = ctx.free_balance(&sender.try_to_account_id_ss58check()?).await?; + let call = transfer_call(account_id_of(&recipient)?, balance.saturating_mul(2)); submit_expect_failure(ctx, &sender, call, &["FundsUnavailable", "InsufficientBalance"]).await } @@ -52,14 +52,14 @@ async fn transfer_below_ed(ctx: &mut ExerciseCtx) -> Result { } let sender = ctx.eph[0].clone(); let recipient = ctx.fresh_keypair()?; - let call = transfer_call(account_id_of(&recipient), ctx.existential_deposit - 1); + let call = transfer_call(account_id_of(&recipient)?, ctx.existential_deposit - 1); submit_expect_failure(ctx, &sender, call, &["BelowMinimum", "ExistentialDeposit"]).await } async fn transfer_overflow_amount(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[1].clone(); let recipient = ctx.fresh_keypair()?; - let call = transfer_call(account_id_of(&recipient), u128::MAX); + let call = transfer_call(account_id_of(&recipient)?, u128::MAX); submit_expect_failure( ctx, &sender, @@ -80,7 +80,7 @@ async fn malformed_address(_ctx: &mut ExerciseCtx) -> Result { async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[0].clone(); - let account = sender.to_account_id_32(); + let account = sender.try_to_account_id_32()?; let current_nonce = ctx.client.get_account_nonce_from_best_block(&account).await?; if current_nonce == 0 { return Err(QuantusError::Generic( @@ -88,7 +88,7 @@ async fn stale_nonce(ctx: &mut ExerciseCtx) -> Result { )); } let recipient = ctx.fresh_keypair()?; - let call = transfer_call(account_id_of(&recipient), ctx.unit); + let call = transfer_call(account_id_of(&recipient)?, ctx.unit); match crate::cli::common::submit_transaction_with_nonce( &ctx.client, &sender, @@ -118,7 +118,7 @@ async fn reversible_delay_too_short(ctx: &mut ExerciseCtx) -> Result { let recipient = ctx.fresh_keypair()?; use quantus_subxt::api::reversible_transfers::calls::types::schedule_transfer_with_delay::Delay; let call = quantus_subxt::api::tx().reversible_transfers().schedule_transfer_with_delay( - subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)), + subxt::ext::subxt_core::utils::MultiAddress::Id(account_id_of(&recipient)?), ctx.unit, Delay::BlockNumber(1), ); @@ -129,7 +129,7 @@ async fn reversible_default_delay_not_hs(ctx: &mut ExerciseCtx) -> Result Result { use quantus_subxt::api::reversible_transfers::calls::types::set_high_security::Delay; let call = quantus_subxt::api::tx() .reversible_transfers() - .set_high_security(Delay::BlockNumber(10), account_id_of(&sender)); + .set_high_security(Delay::BlockNumber(10), account_id_of(&sender)?); submit_expect_failure(ctx, &sender, call, &["GuardianCannotBeSelf"]).await } diff --git a/src/cli/exercise/scenarios/reads.rs b/src/cli/exercise/scenarios/reads.rs index 4cb9358..1883713 100644 --- a/src/cli/exercise/scenarios/reads.rs +++ b/src/cli/exercise/scenarios/reads.rs @@ -100,7 +100,7 @@ async fn treasury_info(ctx: &ExerciseCtx) -> Result { } async fn high_security_status(ctx: &ExerciseCtx) -> Result { - let alice = crate::cli::exercise::runner::account_id_of(&ctx.alice); + let alice = crate::cli::exercise::runner::account_id_of(&ctx.alice)?; let addr = quantus_subxt::api::storage() .reversible_transfers() .high_security_accounts(alice); @@ -118,7 +118,7 @@ async fn scheduler_agenda(ctx: &ExerciseCtx) -> Result { } async fn account_balances(ctx: &ExerciseCtx) -> Result { - let alice_ss58 = ctx.alice.to_account_id_ss58check(); + let alice_ss58 = ctx.alice.try_to_account_id_ss58check()?; let balance = ctx.free_balance(&alice_ss58).await?; if balance == 0 { return Err(QuantusError::Generic( diff --git a/src/cli/exercise/scenarios/recovery.rs b/src/cli/exercise/scenarios/recovery.rs index 4d4c88f..cbae683 100644 --- a/src/cli/exercise/scenarios/recovery.rs +++ b/src/cli/exercise/scenarios/recovery.rs @@ -17,7 +17,7 @@ pub async fn run(ctx: &mut ExerciseCtx, report: &mut Report, phase: &str) -> Res } async fn config_reads(ctx: &mut ExerciseCtx) -> Result { - let alice = account_id_of(&ctx.alice); + let alice = account_id_of(&ctx.alice)?; let latest = ctx.client.get_latest_block().await?; let storage_at = ctx.client.client().storage().at(latest); @@ -34,7 +34,7 @@ async fn config_reads(ctx: &mut ExerciseCtx) -> Result { } async fn initiate_not_recoverable(ctx: &mut ExerciseCtx) -> Result { - let lost = account_id_of(&ctx.bob); + let lost = account_id_of(&ctx.bob)?; let call = quantus_subxt::api::tx() .recovery() .initiate_recovery(subxt::ext::subxt_core::utils::MultiAddress::Id(lost)); diff --git a/src/cli/exercise/scenarios/reversible.rs b/src/cli/exercise/scenarios/reversible.rs index 96c9a40..d9878cc 100644 --- a/src/cli/exercise/scenarios/reversible.rs +++ b/src/cli/exercise/scenarios/reversible.rs @@ -21,7 +21,7 @@ async fn pending_ids( ctx: &ExerciseCtx, sender: &crate::wallet::QuantumKeyPair, ) -> Result> { - let account = account_id_of(sender); + let account = account_id_of(sender)?; let addr = quantus_subxt::api::storage() .reversible_transfers() .pending_transfers_by_sender(account); @@ -32,7 +32,7 @@ async fn pending_ids( async fn schedule_and_cancel(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[2].clone(); - let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); + let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; let amount = ctx.unit; crate::cli::reversible::schedule_transfer_with_delay( @@ -68,7 +68,7 @@ async fn schedule_and_cancel(ctx: &mut ExerciseCtx) -> Result { async fn schedule_with_delay(ctx: &mut ExerciseCtx) -> Result { let sender = ctx.eph[2].clone(); - let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); + let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; let delay_blocks = 50u64; crate::cli::reversible::schedule_transfer_with_delay( @@ -97,7 +97,7 @@ async fn schedule_with_delay(ctx: &mut ExerciseCtx) -> Result { async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { // High-security is sticky; use a dedicated account. let account = ctx.fresh_keypair()?; - let account_ss58 = account.to_account_id_ss58check(); + let account_ss58 = account.try_to_account_id_ss58check()?; let funder = ctx.eph[3].clone(); crate::cli::send::transfer( @@ -110,7 +110,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { ) .await?; - let guardian = account_id_of(&ctx.alice); + let guardian = account_id_of(&ctx.alice)?; use quantus_subxt::api::reversible_transfers::calls::types::set_high_security::Delay; let call = quantus_subxt::api::tx() .reversible_transfers() @@ -119,12 +119,12 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { let addr = quantus_subxt::api::storage() .reversible_transfers() - .high_security_accounts(account_id_of(&account)); + .high_security_accounts(account_id_of(&account)?); let latest = ctx.client.get_latest_block().await?; let value = ctx.client.client().storage().at(latest).fetch(&addr).await?; match value { Some(data) => - if data.guardian != account_id_of(&ctx.alice) { + if data.guardian != account_id_of(&ctx.alice)? { return Err(QuantusError::Generic( "high-security guardian in storage does not match alice".to_string(), )); @@ -135,7 +135,7 @@ async fn set_high_security(ctx: &mut ExerciseCtx) -> Result { )), } - let recipient = ctx.fresh_keypair()?.to_account_id_ss58check(); + let recipient = ctx.fresh_keypair()?.try_to_account_id_ss58check()?; crate::cli::reversible::schedule_transfer( &ctx.client, &account, diff --git a/src/cli/generic_call.rs b/src/cli/generic_call.rs index 7da15f0..f8a98ee 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -64,7 +64,7 @@ pub async fn execute_generic_call( log_print!("πŸš€ Executing generic call"); log_print!("Pallet: {}", pallet.bright_green()); log_print!("Call: {}", call.bright_cyan()); - log_print!("From: {}", from_keypair.to_account_id_ss58check().bright_yellow()); + log_print!("From: {}", from_keypair.try_to_account_id_ss58check()?.bright_yellow()); if let Some(tip) = &tip { log_print!("Tip: {}", tip.bright_magenta()); } diff --git a/src/cli/multisend.rs b/src/cli/multisend.rs index 5df2ebe..1337a59 100644 --- a/src/cli/multisend.rs +++ b/src/cli/multisend.rs @@ -279,7 +279,7 @@ pub async fn handle_multisend_command( // Load wallet let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; - let from_account_id = keypair.to_account_id_ss58check(); + let from_account_id = keypair.try_to_account_id_ss58check()?; // Check balance let balance = get_balance(&quantus_client, &from_account_id).await?; diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index aaa60b1..9458ada 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -472,10 +472,12 @@ 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(); +fn keypair_to_subxt_account_id( + keypair: &crate::wallet::QuantumKeyPair, +) -> crate::error::Result { + let account_id = keypair.try_to_account_id_32()?; let account_bytes: [u8; 32] = *account_id.as_ref(); - SubxtAccountId32::from(account_bytes) + Ok(SubxtAccountId32::from(account_bytes)) } fn sorted_account_ids_equal(left: &[SubxtAccountId32], right: &[SubxtAccountId32]) -> bool { @@ -551,7 +553,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 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, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( @@ -1168,7 +1170,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); + let creator_account_id = keypair_to_subxt_account_id(&keypair)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; diff --git a/src/cli/recovery.rs b/src/cli/recovery.rs index b883403..ff33ee7 100644 --- a/src/cli/recovery.rs +++ b/src/cli/recovery.rs @@ -179,7 +179,7 @@ pub async fn handle_recovery_command( RecoveryCommands::Initiate { rescuer, lost, password, password_file } => { let rescuer_key = crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.to_account_id_ss58check(); + let rescuer_addr = rescuer_key.try_to_account_id_ss58check()?; log_print!("πŸ”‘ Rescuer: {}", rescuer); log_print!("πŸ”‘ Rescuer address: {}", rescuer_addr); let lost_id = resolve_to_subxt_account_id(&lost)?; @@ -264,7 +264,7 @@ pub async fn handle_recovery_command( let rescuer_key = crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.to_account_id_ss58check(); + let rescuer_addr = rescuer_key.try_to_account_id_ss58check()?; log_print!("πŸ”‘ Rescuer: {}", rescuer); log_print!("πŸ”‘ Rescuer address: {}", rescuer_addr); @@ -364,7 +364,7 @@ pub async fn handle_recovery_command( let rescuer_key = crate::wallet::load_keypair_from_wallet(&rescuer, password, password_file)?; - let rescuer_addr = rescuer_key.to_account_id_ss58check(); + let rescuer_addr = rescuer_key.try_to_account_id_ss58check()?; log_print!("πŸ”‘ Rescuer: {}", rescuer); log_print!("πŸ”‘ Rescuer address: {}", rescuer_addr); diff --git a/src/cli/reversible.rs b/src/cli/reversible.rs index c76514b..67992f5 100644 --- a/src/cli/reversible.rs +++ b/src/cli/reversible.rs @@ -117,7 +117,7 @@ pub async fn schedule_transfer( execution_mode: crate::cli::common::ExecutionMode, ) -> Result { log_verbose!("πŸ”„ Creating reversible transfer..."); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); @@ -200,7 +200,7 @@ pub async fn schedule_transfer_with_delay( ) -> Result { let unit_str = if unit_blocks { "blocks" } else { "seconds" }; log_verbose!("πŸ”„ Creating reversible transfer with custom delay ..."); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); log_verbose!(" Delay: {} {}", delay, unit_str); @@ -403,7 +403,7 @@ async fn list_pending_transactions( // Load wallet and get its address let keypair = crate::wallet::load_keypair_from_wallet(&wallet, password, password_file)?; - keypair.to_account_id_ss58check() + keypair.try_to_account_id_ss58check()? }, (None, None) => { return Err(crate::error::QuantusError::Generic( diff --git a/src/cli/send.rs b/src/cli/send.rs index 6b5e45d..6d73f32 100644 --- a/src/cli/send.rs +++ b/src/cli/send.rs @@ -440,7 +440,7 @@ pub async fn transfer_with_nonce( execution_mode: crate::cli::common::ExecutionMode, ) -> Result { log_verbose!("πŸš€ Creating transfer transaction..."); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); log_verbose!(" To: {}", to_address.bright_green()); log_verbose!(" Amount: {}", amount); @@ -475,7 +475,7 @@ pub(crate) async fn validate_batch_transfer_request( transfers: &[(String, u128)], ) -> Result<()> { log_verbose!("πŸš€ Preparing batch transfer transaction with {} transfers...", transfers.len()); - log_verbose!(" From: {}", from_keypair.to_account_id_ss58check().bright_cyan()); + log_verbose!(" From: {}", from_keypair.try_to_account_id_ss58check()?.bright_cyan()); if transfers.is_empty() { return Err(crate::error::QuantusError::Generic( @@ -597,7 +597,7 @@ pub async fn handle_send_command( let keypair = crate::wallet::load_keypair_from_wallet(&from_wallet, password, password_file)?; // Get account information - let from_account_id = keypair.to_account_id_ss58check(); + let from_account_id = keypair.try_to_account_id_ss58check()?; let balance = get_balance(&quantus_client, &from_account_id).await?; // Get formatted balance with proper decimals diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index c8db8fc..12a1985 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -890,7 +890,7 @@ pub async fn handle_wallet_command( // Load wallet and get its address let keypair = crate::wallet::load_keypair_from_wallet(&wallet_name, password, None)?; - keypair.to_account_id_ss58check() + keypair.try_to_account_id_ss58check()? }, (None, None) => { // This case should be prevented by clap's `required_unless_present` diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c099b8a..597c798 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2119,8 +2119,8 @@ fn load_multiround_wallet( let wallet_manager = WalletManager::new()?; let wallet_password = password::get_wallet_password(wallet_name, password, password_file)?; 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()); + let wallet_address = wallet_data.keypair.try_to_account_id_ss58check()?; + let wallet_account_id = SubxtAccountId(wallet_data.keypair.try_to_account_id_32()?.into()); // Require a persisted mnemonic for deterministic wormhole HD derivation. let mnemonic = wallet_data.take_mnemonic().ok_or_else(|| { @@ -2285,7 +2285,7 @@ async fn execute_initial_transfers( .filter_map(|e| e.ok()) .collect(); - let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.to_account_id_32().into()); + let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.try_to_account_id_32()?.into()); let expected_transfers: Vec = secrets .iter() .enumerate() diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 0c84077..75e4c04 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -269,9 +269,10 @@ impl QuantumKeyPair { Ok(resonance_public.into_account()) } - pub fn to_account_id_32(&self) -> AccountId32 { - self.try_to_account_id_32().unwrap_or_else(|_| AccountId32::from([0u8; 32])) - } + // Note: there are deliberately no infallible to_account_id_* variants. The + // old ones fell back to the all-zero account / empty string on malformed + // keys, turning a detectable error into a silent wrong answer that callers + // could send funds to. pub fn try_to_account_id_ss58check(&self) -> Result { use crate::cli::address_format::quantus_ss58_format; @@ -279,10 +280,6 @@ impl QuantumKeyPair { 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 pub fn to_subxt_signer(&self) -> Result { // Convert to DilithiumPair first - now it implements subxt::tx::Signer @@ -862,8 +859,8 @@ mod tests { let quantum_keypair = QuantumKeyPair::from_resonance_pair(&resonance_pair); // Generate address using both methods - let account_id = quantum_keypair.to_account_id_32(); - let ss58_address = quantum_keypair.to_account_id_ss58check(); + let account_id = quantum_keypair.try_to_account_id_32().expect("valid keypair"); + let ss58_address = quantum_keypair.try_to_account_id_ss58check().expect("valid keypair"); // Verify address format (Quantus SS58 prefix 189 = "qz") assert!( @@ -951,8 +948,8 @@ mod tests { let quantum_from_resonance = QuantumKeyPair::from_resonance_pair(&resonance_from_quantum); // All should generate the same address - let addr1 = quantum_from_dilithium.to_account_id_ss58check(); - let addr2 = quantum_from_resonance.to_account_id_ss58check(); + let addr1 = quantum_from_dilithium.try_to_account_id_ss58check().expect("valid keypair"); + let addr2 = quantum_from_resonance.try_to_account_id_ss58check().expect("valid keypair"); let addr3 = resonance_from_quantum .public() .into_account() @@ -974,9 +971,9 @@ mod tests { let bob_quantum = QuantumKeyPair::from_resonance_pair(&bob_pair); let charlie_quantum = QuantumKeyPair::from_resonance_pair(&charlie_pair); - let alice_addr = alice_quantum.to_account_id_ss58check(); - let bob_addr = bob_quantum.to_account_id_ss58check(); - let charlie_addr = charlie_quantum.to_account_id_ss58check(); + let alice_addr = alice_quantum.try_to_account_id_ss58check().expect("valid keypair"); + let bob_addr = bob_quantum.try_to_account_id_ss58check().expect("valid keypair"); + let charlie_addr = charlie_quantum.try_to_account_id_ss58check().expect("valid keypair"); // Addresses should be different assert_ne!(alice_addr, bob_addr, "Alice and Bob should have different addresses"); @@ -1061,22 +1058,15 @@ mod tests { }; // Test that we can generate address from the stored keypair - let result = std::panic::catch_unwind(|| wallet_data.keypair.to_account_id_ss58check()); - - match result { - Ok(address) => { - println!("βœ… Address generation successful: {address}"); - // Verify it matches the expected address - let expected = alice_pair - .public() - .into_account() - .to_ss58check_with_version(Ss58AddressFormat::custom(189)); - assert_eq!(address, expected, "Stored wallet should generate correct address"); - }, - Err(_) => { - panic!("❌ Address generation failed - this is the bug we need to fix!"); - }, - } + let address = wallet_data + .keypair + .try_to_account_id_ss58check() + .expect("stored wallet keypair should generate an address"); + let expected = alice_pair + .public() + .into_account() + .to_ss58check_with_version(Ss58AddressFormat::custom(189)); + assert_eq!(address, expected, "Stored wallet should generate correct address"); } #[test] @@ -1122,22 +1112,15 @@ mod tests { .expect("Decryption should succeed"); // Test that we can generate address from the decrypted keypair - let result = std::panic::catch_unwind(|| decrypted_data.keypair.to_account_id_ss58check()); - - match result { - Ok(address) => { - println!("βœ… Encrypted wallet address generation successful: {address}"); - // Verify it matches the expected address - let expected = alice_pair - .public() - .into_account() - .to_ss58check_with_version(Ss58AddressFormat::custom(189)); - assert_eq!(address, expected, "Decrypted wallet should generate correct address"); - }, - Err(_) => { - panic!("❌ Encrypted wallet address generation failed - this reproduces the send command bug!"); - }, - } + let address = decrypted_data + .keypair + .try_to_account_id_ss58check() + .expect("decrypted wallet keypair should generate an address"); + let expected = alice_pair + .public() + .into_account() + .to_ss58check_with_version(Ss58AddressFormat::custom(189)); + assert_eq!(address, expected, "Decrypted wallet should generate correct address"); } #[test] @@ -1180,29 +1163,15 @@ mod tests { wallet_manager.load_wallet("crystal_alice", "").expect("Should load wallet"); // 2. Try to generate address from the loaded keypair (should work now) - let result = std::panic::catch_unwind(|| { - // The keypair is already decrypted, so we can use it directly - loaded_wallet_data.keypair.to_account_id_ss58check() - }); - - match result { - Ok(address) => { - println!("βœ… Send command flow works: {address}"); - // If this passes, the bug is fixed - let expected = alice_pair - .public() - .into_account() - .to_ss58check_with_version(Ss58AddressFormat::custom(189)); - assert_eq!(address, expected, "Loaded wallet should generate correct address"); - }, - Err(_) => { - println!("❌ Send command flow failed - this reproduces the bug!"); - // This test should fail initially, proving we found the bug - panic!( - "This test reproduces the send command bug - load_wallet returns dummy data!" - ); - }, - } + let address = loaded_wallet_data + .keypair + .try_to_account_id_ss58check() + .expect("loaded wallet keypair should generate an address"); + let expected = alice_pair + .public() + .into_account() + .to_ss58check_with_version(Ss58AddressFormat::custom(189)); + assert_eq!(address, expected, "Loaded wallet should generate correct address"); } #[test] @@ -1274,7 +1243,7 @@ mod tests { EncryptedWallet { name: data.name.clone(), - address: data.keypair.to_account_id_ss58check(), + address: data.keypair.try_to_account_id_ss58check().expect("valid keypair"), encrypted_data, kyber_ciphertext: vec![], kyber_public_key: vec![], @@ -1348,7 +1317,7 @@ mod tests { .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(); + let attacker_address = attacker.keypair.try_to_account_id_ss58check().expect("valid keypair"); assert_ne!(victim_address, attacker_address); // Attacker rewrites only the plaintext envelope address; ciphertext is untouched. @@ -1386,7 +1355,7 @@ mod tests { let encrypted_data = cipher.encrypt(&nonce, plaintext.as_ref()).expect("encrypt"); EncryptedWallet { name: data.name.clone(), - address: data.keypair.to_account_id_ss58check(), + address: data.keypair.try_to_account_id_ss58check().expect("valid keypair"), encrypted_data, kyber_ciphertext: vec![], kyber_public_key: vec![], @@ -1433,18 +1402,20 @@ mod tests { #[test] fn malformed_public_key_returns_error_instead_of_panicking() { - // #160640: address derivation must not unwind on garbage public keys. + // #160640: address derivation must not unwind on garbage public keys, + // and must report an error rather than a silent fallback value (the + // removed infallible variants returned the all-zero account). 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" + matches!( + keypair.try_to_account_id_ss58check(), + Err(crate::error::QuantusError::Wallet(WalletError::InvalidPublicKey)) + ), + "fallible conversion must report InvalidPublicKey" ); assert!( matches!( - keypair.try_to_account_id_ss58check(), + keypair.try_to_account_id_32(), Err(crate::error::QuantusError::Wallet(WalletError::InvalidPublicKey)) ), "fallible conversion must report InvalidPublicKey" diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 3728de7..04b86f9 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -832,8 +832,9 @@ mod tests { let quantum_keypair = keystore::QuantumKeyPair::from_dilithium_keypair(&dilithium_keypair); // Test address generation - let account_id = quantum_keypair.to_account_id_32(); - let ss58_address = quantum_keypair.to_account_id_ss58check(); + let account_id = quantum_keypair.try_to_account_id_32().expect("valid keypair"); + let ss58_address = + quantum_keypair.try_to_account_id_ss58check().expect("valid keypair"); // Verify SS58 address format assert!(ss58_address.starts_with("qz"), "SS58 address should start with 5"); From 0447e9e66aa7de723b2b6b9d385671917bf3fcc2 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:19:12 +0800 Subject: [PATCH 68/74] fix(cli): hide rejected --password flags and stop prompting before rejection -p/--password has been rejected at runtime since the password-policy change, but every command still advertised it in --help with text inviting use. Hide the flag everywhere (still parsed, so the runtime rejection message keeps guiding existing scripts). Also resolve the password policy before the mnemonic/seed prompt in wallet import and from-seed, so a doomed invocation no longer collects the secret first. Co-authored-by: Cursor --- src/cli/batch.rs | 2 +- src/cli/high_security.rs | 2 +- src/cli/mod.rs | 6 +++--- src/cli/multisig.rs | 18 +++++++++--------- src/cli/preimage.rs | 2 +- src/cli/recovery.rs | 14 +++++++------- src/cli/reversible.rs | 8 ++++---- src/cli/runtime.rs | 2 +- src/cli/tech_collective.rs | 6 +++--- src/cli/tech_referenda.rs | 12 ++++++------ src/cli/wallet.rs | 39 +++++++++++++++++++++----------------- src/cli/wormhole.rs | 10 +++++----- 12 files changed, 63 insertions(+), 58 deletions(-) diff --git a/src/cli/batch.rs b/src/cli/batch.rs index 87fefeb..d6743cc 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -21,7 +21,7 @@ pub enum BatchCommands { from: String, /// Password for the wallet (or use environment variables) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) diff --git a/src/cli/high_security.rs b/src/cli/high_security.rs index 0af3442..11b46d1 100644 --- a/src/cli/high_security.rs +++ b/src/cli/high_security.rs @@ -36,7 +36,7 @@ pub enum HighSecurityCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 12a5da2..1ce078e 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -51,7 +51,7 @@ pub enum Commands { from: String, /// Password for the wallet (or use environment variables) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) @@ -139,7 +139,7 @@ pub enum Commands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -290,7 +290,7 @@ pub enum Commands { max: String, /// Password for the wallet (or use environment variables) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) diff --git a/src/cli/multisig.rs b/src/cli/multisig.rs index 9458ada..5c8b2e4 100644 --- a/src/cli/multisig.rs +++ b/src/cli/multisig.rs @@ -130,7 +130,7 @@ pub enum ProposeSubcommand { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -165,7 +165,7 @@ pub enum ProposeSubcommand { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -200,7 +200,7 @@ pub enum ProposeSubcommand { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -236,7 +236,7 @@ pub enum MultisigCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) @@ -278,7 +278,7 @@ pub enum MultisigCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -301,7 +301,7 @@ pub enum MultisigCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -324,7 +324,7 @@ pub enum MultisigCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -347,7 +347,7 @@ pub enum MultisigCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -366,7 +366,7 @@ pub enum MultisigCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file diff --git a/src/cli/preimage.rs b/src/cli/preimage.rs index 3a32a4d..3b736c2 100644 --- a/src/cli/preimage.rs +++ b/src/cli/preimage.rs @@ -58,7 +58,7 @@ pub enum PreimageCommands { #[arg(long)] from: String, /// Password for wallet (optional) - #[arg(long)] + #[arg(long, hide = true)] password: Option, /// Password file path (optional) #[arg(long)] diff --git a/src/cli/recovery.rs b/src/cli/recovery.rs index ff33ee7..7ac49ba 100644 --- a/src/cli/recovery.rs +++ b/src/cli/recovery.rs @@ -22,7 +22,7 @@ pub enum RecoveryCommands { #[arg(long)] lost: String, /// Password for rescuer wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) #[arg(long)] @@ -41,7 +41,7 @@ pub enum RecoveryCommands { #[arg(long)] rescuer: String, /// Password for friend wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file #[arg(long)] @@ -57,7 +57,7 @@ pub enum RecoveryCommands { #[arg(long)] lost: String, /// Password for rescuer wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file #[arg(long)] @@ -73,7 +73,7 @@ pub enum RecoveryCommands { #[arg(long)] rescuer: String, /// Password for lost wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file #[arg(long)] @@ -89,7 +89,7 @@ pub enum RecoveryCommands { #[arg(long)] lost: String, /// Password for rescuer wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file #[arg(long)] @@ -135,7 +135,7 @@ pub enum RecoveryCommands { #[arg(long, default_value_t = true)] keep_alive: bool, /// Password for rescuer wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file #[arg(long)] @@ -160,7 +160,7 @@ pub enum RecoveryCommands { #[arg(long, default_value_t = true)] keep_alive: bool, /// Password for rescuer wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file #[arg(long)] diff --git a/src/cli/reversible.rs b/src/cli/reversible.rs index 67992f5..588e152 100644 --- a/src/cli/reversible.rs +++ b/src/cli/reversible.rs @@ -30,7 +30,7 @@ pub enum ReversibleCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) @@ -61,7 +61,7 @@ pub enum ReversibleCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) @@ -80,7 +80,7 @@ pub enum ReversibleCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) @@ -99,7 +99,7 @@ pub enum ReversibleCommands { from: Option, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (for scripting) diff --git a/src/cli/runtime.rs b/src/cli/runtime.rs index 302dfd4..43c2933 100644 --- a/src/cli/runtime.rs +++ b/src/cli/runtime.rs @@ -23,7 +23,7 @@ pub enum RuntimeCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file diff --git a/src/cli/tech_collective.rs b/src/cli/tech_collective.rs index 9bd106f..d8cbe4c 100644 --- a/src/cli/tech_collective.rs +++ b/src/cli/tech_collective.rs @@ -29,7 +29,7 @@ pub enum TechCollectiveCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -52,7 +52,7 @@ pub enum TechCollectiveCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -75,7 +75,7 @@ pub enum TechCollectiveCommands { from: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file diff --git a/src/cli/tech_referenda.rs b/src/cli/tech_referenda.rs index d3e172b..b3a0e13 100644 --- a/src/cli/tech_referenda.rs +++ b/src/cli/tech_referenda.rs @@ -24,7 +24,7 @@ pub enum TechReferendaCommands { #[arg(short, long, value_name = "WALLET")] from: String, - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, #[arg(long)] @@ -42,7 +42,7 @@ pub enum TechReferendaCommands { #[arg(short, long, value_name = "WALLET")] from: String, - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, #[arg(long)] @@ -65,7 +65,7 @@ pub enum TechReferendaCommands { #[arg(short, long, value_name = "WALLET")] from: String, - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, #[arg(long)] @@ -107,7 +107,7 @@ pub enum TechReferendaCommands { #[arg(short, long, value_name = "WALLET")] from: String, - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, #[arg(long)] @@ -127,7 +127,7 @@ pub enum TechReferendaCommands { #[arg(short, long, value_name = "WALLET")] from: String, - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, #[arg(long)] @@ -147,7 +147,7 @@ pub enum TechReferendaCommands { #[arg(short, long, value_name = "WALLET")] from: String, - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, #[arg(long)] diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 12a1985..542e78a 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -26,7 +26,7 @@ pub enum WalletCommands { name: String, /// Password to encrypt the wallet (unsupported on argv; use --password-file or prompt) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read encryption password from file (owner-only on Unix) @@ -64,7 +64,7 @@ pub enum WalletCommands { name: String, /// Password to decrypt the wallet (optional, will prompt if not provided) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Export format: mnemonic, private-key @@ -84,7 +84,7 @@ pub enum WalletCommands { name: String, /// Password to encrypt the wallet (unsupported on argv; use --password-file or prompt) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read encryption password from file (owner-only on Unix) @@ -111,7 +111,7 @@ pub enum WalletCommands { name: String, /// Password to encrypt the wallet (unsupported on argv; use --password-file or prompt) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read encryption password from file (owner-only on Unix) @@ -148,7 +148,7 @@ pub enum WalletCommands { wallet: Option, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, }, } @@ -644,10 +644,10 @@ pub async fn handle_wallet_command( let wallet_manager = WalletManager::new()?; - // Always read mnemonic from a hidden prompt so it never appears in process argv. - let mut mnemonic_phrase = get_mnemonic_from_user()?; - - // New-wallet password policy: confirmed prompt, no silent empty default. + // New-wallet password policy: confirmed prompt, no silent empty + // default. Resolve (and reject rejected forms like a raw + // --password) before prompting for the mnemonic, so a doomed + // invocation doesn't collect the secret first. let final_password = crate::wallet::password::get_new_wallet_password( &name, password, @@ -655,6 +655,9 @@ pub async fn handle_wallet_command( allow_empty_password, )?; + // Always read mnemonic from a hidden prompt so it never appears in process argv. + let mut mnemonic_phrase = get_mnemonic_from_user()?; + // Choose import method based on flags let result = if no_derivation { // Use master seed directly (like quantus-node --no-derivation) @@ -706,14 +709,9 @@ pub async fn handle_wallet_command( 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 mut seed_raw = rpassword::read_password() - .map_err(|e| QuantusError::Generic(format!("Failed to read seed: {e}")))?; - let mut seed = seed_raw.trim().to_string(); - crate::wallet::keystore::zeroize_string(&mut seed_raw); - - // New-wallet password policy: confirmed prompt, no silent empty default. + // New-wallet password policy: confirmed prompt, no silent empty + // default. Resolve before prompting for the seed, so a doomed + // invocation doesn't collect the secret first. let final_password = crate::wallet::password::get_new_wallet_password( &name, password, @@ -721,6 +719,13 @@ pub async fn handle_wallet_command( allow_empty_password, )?; + // 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 mut seed_raw = rpassword::read_password() + .map_err(|e| QuantusError::Generic(format!("Failed to read seed: {e}")))?; + let mut seed = seed_raw.trim().to_string(); + crate::wallet::keystore::zeroize_string(&mut seed_raw); + let result = wallet_manager .create_wallet_from_seed(&name, &seed, Some(&final_password)) .await; diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 597c798..c972c24 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -861,7 +861,7 @@ pub enum WormholeCommands { wallet: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -905,7 +905,7 @@ pub enum WormholeCommands { wallet: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -927,7 +927,7 @@ pub enum WormholeCommands { wallet: String, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -961,7 +961,7 @@ pub enum WormholeCommands { secret_file: Option, /// Password for the wallet (only used with --wallet) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (only used with --wallet) @@ -1009,7 +1009,7 @@ pub enum WormholeCommands { wallet: Option, /// Password for the wallet (only used with --wallet) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (only used with --wallet) From 72b1f4fbfdda7e47e66c09f7106ad2d946133c41 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:20:18 +0800 Subject: [PATCH 69/74] fix(preimage): verify noted preimage in the inclusion block verify_preimage_on_chain read storage at get_latest_block() after the watch returned - the same moving-tip race fixed for multisig event correlation. Use submit_transaction_with_inclusion_block and read the preimage at the block the extrinsic landed in; the already-noted fallback path keeps reading the tip since no inclusion block exists. Co-authored-by: Cursor --- src/cli/common.rs | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 114139f..6ef8067 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -897,6 +897,7 @@ pub(crate) fn format_dispatch_error( async fn verify_preimage_on_chain( quantus_client: &crate::chain::client::QuantusClient, expected_preimage: &[u8], + at_block: subxt::utils::H256, ) -> Result<()> { use sp_runtime::traits::{BlakeTwo256, Hash}; @@ -907,8 +908,7 @@ async fn verify_preimage_on_chain( 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 storage_at = quantus_client.client().storage().at(at_block); let preimage_addr = crate::chain::quantus_subxt::api::storage() .preimage() .preimage_for((preimage_hash, preimage_len)); @@ -948,21 +948,40 @@ pub async fn submit_preimage( crate::chain::quantus_subxt::api::tx().preimage().note_preimage(bounded_bytes); let wait_mode = ExecutionMode { wait_for_transaction: true, ..execution_mode }; - match submit_transaction(quantus_client, keypair, note_preimage_tx, None, wait_mode).await { - Ok(_) => { - verify_preimage_on_chain(quantus_client, &encoded_call).await?; + match submit_transaction_with_inclusion_block( + quantus_client, + keypair, + note_preimage_tx, + None, + wait_mode, + ) + .await + { + Ok((_, included_in)) => { + // Verify in the inclusion block: the moving tip may not have + // advanced past (or even reached) the inclusion block when the + // watch returns, so reading the latest block can miss the + // just-noted preimage. + let at_block = match included_in { + Some(hash) => hash, + None => quantus_client.get_latest_block().await?, + }; + verify_preimage_on_chain(quantus_client, &encoded_call, at_block).await?; crate::log_success!("Preimage submitted"); }, 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| { + // There is no inclusion block here (the submission failed), so an + // already-noted preimage is looked up at the current tip. + let latest_block_hash = quantus_client.get_latest_block().await?; + verify_preimage_on_chain(quantus_client, &encoded_call, latest_block_hash) + .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() From c93998a784cfbb26413a383ae072becd4bb69608 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:22:36 +0800 Subject: [PATCH 70/74] fix(update): cap download sizes and error on non-semver latest tags Assets were fully downloaded before any size check, letting a rogue release asset exhaust disk/memory before verification; wrap downloads in a limiting writer (64 KiB for sha256sums, 512 MiB for the archive). Also stop reporting "already latest" when the latest tag fails semver parsing - bump_is_greater errors now surface instead of unwrap_or(false). Co-authored-by: Cursor --- src/cli/update.rs | 83 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/src/cli/update.rs b/src/cli/update.rs index 13562b8..7c9bf1a 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -128,6 +128,17 @@ pub fn latest_stable_version() -> crate::error::Result { Ok(release.version.trim_start_matches('v').to_string()) } +/// Semver comparison that surfaces unparseable release tags as errors instead +/// of silently reporting "already latest" (`unwrap_or(false)` previously +/// swallowed a non-semver `latest` tag). +fn version_is_newer(current: &str, latest: &str) -> crate::error::Result { + self_update::version::bump_is_greater(current, latest).map_err(|e| { + QuantusError::Generic(format!( + "Cannot compare current version `{current}` with latest release tag `{latest}`: {e}" + )) + }) +} + /// Verify `data` matches the expected SHA-256 hex digest (case-insensitive). /// /// Used to bind a downloaded release archive to the published `sha256sums` @@ -200,7 +211,7 @@ fn run_update( // upgrade is always one that `quantus update` can actually install. if check_only { let latest = latest_stable_version()?; - if self_update::version::bump_is_greater(current, &latest).unwrap_or(false) { + if version_is_newer(current, &latest)? { return Ok(UpdateOutcome::UpdateAvailable(latest)); } return Ok(UpdateOutcome::AlreadyLatest(current.to_string())); @@ -219,7 +230,7 @@ fn run_update( 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) { + if !version_is_newer(current, &latest.version)? { return Ok(UpdateOutcome::AlreadyLatest(latest.version)); } latest @@ -269,7 +280,7 @@ fn install_verified_release( log_print!("Downloading checksums..."); let mut sums_bytes = Vec::new(); - download_asset(&sums_asset.download_url, &mut sums_bytes, false)?; + download_asset(&sums_asset.download_url, &mut sums_bytes, MAX_SUMS_ASSET_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}")) })?; @@ -284,7 +295,7 @@ fn install_verified_release( 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)?; + download_asset(&archive_asset.download_url, &mut archive_file, MAX_ARCHIVE_ASSET_BYTES, true)?; archive_file .flush() .map_err(|e| QuantusError::Generic(format!("Failed to flush archive download: {e}")))?; @@ -337,11 +348,51 @@ fn substitute_bin_path(template: &str, version: &str, target: &str, bin: &str) - .replace("{{bin}}", bin) } +/// Upper bound for the sha256sums text asset (a handful of lines). +const MAX_SUMS_ASSET_BYTES: u64 = 64 * 1024; +/// Upper bound for the release archive. Real archives are tens of MB; this +/// exists so a rogue release asset cannot exhaust disk/memory before the +/// checksum is ever consulted. +const MAX_ARCHIVE_ASSET_BYTES: u64 = 512 * 1024 * 1024; + +/// Writer adapter that fails once more than `limit` bytes have been written. +struct LimitedWriter { + inner: W, + remaining: u64, + limit: u64, +} + +impl LimitedWriter { + fn new(inner: W, limit: u64) -> Self { + Self { inner, remaining: limit, limit } + } +} + +impl Write for LimitedWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + if buf.len() as u64 > self.remaining { + return Err(io::Error::other(format!( + "download exceeds the maximum allowed size of {} bytes", + self.limit + ))); + } + let written = self.inner.write(buf)?; + self.remaining -= written as u64; + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + fn download_asset( url: &str, dest: &mut impl Write, + max_bytes: u64, show_progress: bool, ) -> crate::error::Result<()> { + let mut limited = LimitedWriter::new(dest, max_bytes); let mut download = self_update::Download::from_url(url); download .set_header( @@ -349,7 +400,7 @@ fn download_asset( "application/octet-stream".parse().expect("static ACCEPT header"), ) .show_progress(show_progress); - download.download_to(dest).map_err(map_self_update_err) + download.download_to(&mut limited).map_err(map_self_update_err) } fn confirm_update() -> crate::error::Result<()> { @@ -386,8 +437,28 @@ fn map_self_update_err(err: self_update::errors::Error) -> QuantusError { #[cfg(test)] mod tests { - use super::{expected_hash_from_sha256sums, verify_sha256}; + use super::{expected_hash_from_sha256sums, verify_sha256, version_is_newer, LimitedWriter}; use sha2::{Digest, Sha256}; + use std::io::Write; + + #[test] + fn limited_writer_enforces_download_cap() { + let mut sink = Vec::new(); + let mut limited = LimitedWriter::new(&mut sink, 8); + limited.write_all(b"12345678").expect("within limit"); + let err = limited.write_all(b"9").expect_err("over limit must fail"); + assert!(err.to_string().contains("maximum allowed size"), "unexpected error: {err}"); + assert_eq!(sink, b"12345678"); + } + + #[test] + fn non_semver_latest_tag_is_an_error_not_already_latest() { + assert!(version_is_newer("1.6.0", "1.7.0").expect("semver compares")); + assert!(!version_is_newer("1.6.0", "1.6.0").expect("semver compares")); + let err = version_is_newer("1.6.0", "nightly-build") + .expect_err("non-semver tag must surface an error"); + assert!(err.to_string().contains("nightly-build"), "unexpected error: {err}"); + } #[test] fn verify_sha256_match_accepts_mismatch_refuses() { From 355b66bb6b84b475d6a36016c9a1800cf9a1714e Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:24:05 +0800 Subject: [PATCH 71/74] fix(bins): gate symlink tests behind cfg(unix) The tests module imported std::os::unix::fs::symlink unconditionally, so cargo test would not compile on Windows, which the release pipeline ships for. Co-authored-by: Cursor --- src/bins.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/bins.rs b/src/bins.rs index c70aa30..ddb3d07 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -469,6 +469,9 @@ fn generate(dir: &Path, num_leaf_proofs: usize, num_private_batch_proofs: usize) mod tests { use super::*; use serial_test::serial; + // Symlink-based tests are Unix-only; `cargo test` must still compile on + // Windows, which the release pipeline ships for. + #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; @@ -551,6 +554,7 @@ mod tests { assert!(err.to_string().contains("failed authentication"), "unexpected error: {err}"); } + #[cfg(unix)] #[test] #[serial] fn ensure_bins_dir_rejects_symlinked_directory() { @@ -571,6 +575,7 @@ mod tests { assert!(err.to_string().contains("symlinked bins directory"), "unexpected error: {err}"); } + #[cfg(unix)] #[test] fn version_marker_write_refuses_existing_symlink() { // #160699: marker publication must not follow a pre-existing symlink. @@ -586,6 +591,7 @@ mod tests { assert_eq!(fs::read_to_string(&victim).unwrap(), "do-not-overwrite"); } + #[cfg(unix)] #[test] fn verify_manifest_rejects_symlinked_artifact_file() { let tmp = TempDir::new().unwrap(); @@ -605,6 +611,7 @@ mod tests { // Shared publish helpers from build.rs (#160700). use super::fs_helpers::publish_dir_atomically; + #[cfg(unix)] #[test] fn publish_dir_atomically_replaces_destination_symlink_without_following() { // #160700: publishing must not write through a swapped destination symlink. @@ -628,6 +635,7 @@ mod tests { assert!(!victim_dir.join("config.json").exists()); } + #[cfg(unix)] #[test] fn remove_path_nofollow_removes_symlink_without_deleting_target() { let tmp = TempDir::new().unwrap(); From 914e616c1bd681886678364c0db0aca215b31a4e Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:25:00 +0800 Subject: [PATCH 72/74] chore: drop PR_V12_SECURITY.md from the repo The file is a copy of the PR description and references v12-issues.md which is not in the repo; it would go stale immediately after merge. Keep it as an untracked working file (gitignored, along with v12-issues.md) so it can still be pasted into the PR description. Co-authored-by: Cursor --- .gitignore | 7 +- PR_V12_SECURITY.md | 213 --------------------------------------------- 2 files changed, 6 insertions(+), 214 deletions(-) delete mode 100644 PR_V12_SECURITY.md diff --git a/.gitignore b/.gitignore index dcbb90e..fc3c701 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,9 @@ # Circuit binaries are generated at build time by build.rs /generated-bins/ -/generated-bins \ No newline at end of file +/generated-bins + +# Working files for the V12 security PR; the PR description lives on GitHub, +# not in the repo (it would go stale immediately). +/PR_V12_SECURITY.md +/v12-issues.md \ No newline at end of file diff --git a/PR_V12_SECURITY.md b/PR_V12_SECURITY.md deleted file mode 100644 index 49f156c..0000000 --- a/PR_V12_SECURITY.md +++ /dev/null @@ -1,213 +0,0 @@ -## Summary - -Addresses the V12 security audit findings in `v12-issues.md`. - -**Scope:** only findings with `Validity: Unreviewed`. V12 marks **314 Low** findings as `Validity: Invalid` (likely incorrect); those are **excluded** from this analysis and are not treated as open work. - -| Severity | Unreviewed (in scope) | Invalid (excluded) | -|----------|----------------------:|-------------------:| -| High | 20 | 0 | -| Medium | 30 | 0 | -| Low | 11 | 314 | -| Info | 2 | 0 | - -This PR remediates **all 20 High and 30 Medium** Unreviewed findings (with redβ†’green tests where applicable). The **11 Unreviewed Lows** are listed below; most remain open for follow-up. A few Invalid Lows were hardened opportunistically and are noted separately (out of audit scope). - -**34 commits** on `illuzen/v12-2`. Library suite: **211+ tests passing**. - -## High (20/20 addressed) - -| ID | Title | Outcome | -|----|-------|---------| -| #159453 | Wallet mnemonic/seed as CLI args | Fixed β€” always hidden prompt | -| #159924 | Malformed wallet aborts listing | Fixed β€” skip bad files; validate SS58 | -| #160053 | MultisigCreated mis-attribution | Fixed β€” correlate creator/signers/threshold/nonce | -| #160582 | Raw `--password` CLI credentials | Fixed β€” reject at helper boundary | -| #160592 | Keystore permissive permissions | Fixed β€” dir `0700`, files `0600` | -| #160593 | Unauthenticated address redirect | Fixed β€” integrity check on decrypt | -| #160594 | Unauthenticated metadata substitution | Fixed β€” passwordless paths stop trusting envelope | -| #160598 | Filesystem races in wallet storage | Fixed β€” locks, random temps, name checks | -| #160605 | Failed legacy migration bypass | Fixed β€” fail closed on migration save | -| #160611 | Watched txs succeed when absent | Fixed β€” missing extrinsic β†’ error | -| #160612 | Unsafe retries duplicate txs | Fixed β€” single submit, no nonce-bump retry | -| #160624 | Unverified RPC signing context | Fixed β€” Quantus runtime identity gate | -| #160655 | Batch not atomic | Fixed β€” `utility.batch_all` | -| #160708 | Password-file permission checks | Fixed β€” Unix owner-only required | -| #160716 | Legacy AES key alongside ciphertext | Already fixed (`e0be480`); residual: refuse re-persisting digests | -| #160737 | Wallet creation not atomic | Fixed β€” exclusive create / hard_link | -| #160748 | Ephemeral mnemonic strands funds | Fixed β€” require persisted mnemonic | -| #160754 | Transfer events unbound | Fixed β€” match from/amount/count | -| #160773 | Self-update without integrity check | Fixed β€” same-origin SHA-256 integrity (not signing; see update note) | -| #160791 | Unvalidated RPC token properties | Fixed β€” fail-closed decimals/symbol/ss58 | - -## Medium (30/30 addressed) - -| ID | Title | Outcome | -|----|-------|---------| -| #159340 | Version/nonce panics on decrypt | Fixed | -| #159469 | Exported mnemonic on stdout | Fixed β€” require `--output` (0o600) | -| #159662 | Storage pagination loop/overflow | Fixed | -| #159890 | Spent transfers reported available | Fixed | -| #159916 | Single-block over-limit abort | Fixed β€” offset pagination | -| #160052 | Duplicate signers | Fixed β€” sort+dedup | -| #160103 | Wormhole `--secret` argv | Fixed β€” `--secret-file` | -| #160105 | Secrets not zeroized after proof | Partially fixed β€” see zeroization scope note | -| #160110 | Unbounded Merkle depth | Fixed | -| #160591 | Wallet secrets retained | Partially fixed β€” see zeroization scope note | -| #160595 | Wallet name path escape | Fixed (with #160598 name validation) | -| #160625 | Unbounded tx-status waits | Fixed β€” deadlines | -| #160640 | Malformed pubkey panic | Fixed β€” `InvalidPublicKey` | -| #160652 | Token metadata / decimal format | Fixed β€” `checked_pow` + validation | -| #160656 | Transfer data / chain decimals | Fixed | -| #160660 | Removal missing member rank | Fixed β€” required `--min-rank` | -| #160667 | Recursive wormhole unfinalized | Fixed β€” finalized snapshots | -| #160674 | Delay conversion overflow | Fixed β€” checked helpers | -| #160697 | Circuit artifacts unauthenticated | Fixed β€” `manifest.json` SHA-256 | -| #160699 | Artifact symlink redirection | Fixed β€” refuse symlinks | -| #160700 | Build artifact publish races | Fixed β€” atomic publish | -| #160715 | Exhausting Argon2 params | Fixed β€” lock to generated profile | -| #160718 | Preimage AlreadyNoted substring | Fixed β€” verify on-chain | -| #160724 | WS URL credentials in diagnostics | Fixed β€” redact userinfo | -| #160732 | Transfer total wrap | Fixed β€” `checked_add` | -| #160734 | Batch vs call-count limit | Fixed β€” runtime `batched_calls_limit` | -| #160749 | Failed extrinsic reported verified | Fixed β€” failure-dominant | -| #160776 | Missing aggregate bypasses split | Fixed | -| #160777 | Offset not global across ranges | Fixed | -| #160783 | Public helpers panic on bad input | Fixed β€” fallible APIs | - -## Low (11 Unreviewed β€” in scope) - -| ID | Title | Status in this PR | -|----|-------|-------------------| -| #159905 | Byte-indexed address truncation on remote IDs | Open | -| #159911 | CLI transfer limit accepts values above documented 1000 | Open | -| #159917 | Fragile substring matching for limit-exceeded errors | Open | -| #160136 | Distribution invariant broken by u128 overflow | Open (related hardening via checked adds elsewhere) | -| #160585 | Password-file permits symlink targets / unbounded reads | Partial β€” mode/owner checks added (#160708); symlink/size bounds still open | -| #160678 | Malformed RPC header fields can panic CLI | Open | -| #160711 | Malformed wallet nonce panics during unlock | Open | -| #160744 | Unchecked RPC string slicing can crash system inspection | Open | -| #160760 | Unavailable home directories can panic | Open | -| #160789 | WalletManager lacks sync for concurrent FS ops | Partial β€” keystore process lock / create locks from High #160598/#160737 | -| #160800 | Proposal IDs decoded from key suffix without layout validation | Open | - -### Excluded: 314 Low with `Validity: Invalid` -Out of scope per V12. No further triage required for merge of this PR. - -### Opportunistic hardening (Invalid Lows β€” not audit blockers) -Some Invalid Lows were still tightened while adjacent to High/Medium work (e.g. block-list bounds, storage iterate cap, JSON numeric parsing, multisend dupes, metadata `checked_add`). These are optional defense-in-depth, not required to close the Unreviewed set. - -## Info (2 Unreviewed) -- #160685 Bind deposits/votes to confirmed referendum index β€” informational -- #160730 Non-native leaves represented as native assets β€” informational - -## Self-update integrity scope note (#160773) - -The self-update SHA-256 check is same-origin integrity, not authenticity: the -expected hash is a sibling asset of the same GitHub release fetched over the -same TLS channel. It stops corruption and single-object substitution; it does -not stop an attacker who can write release assets or MITM TLS, since they -control both files. Upgrading to real authenticity means signing release -archives (e.g. `self_update`'s zipsign/ed25519 `signatures` feature with the -public key embedded in the binary), which requires release-pipeline changes -and is left as follow-up. The claim in the table above should not be read as -release signing. - -## Zeroization scope note (#160105 / #160591) - -Zeroization is enforced at the library boundary (`wormhole_lib::generate_proof` -wipes `input.secret` on all paths via drop guards; wallet decrypt buffers, -`WalletData`, and derived AES keys are wiped) and at the caller sites that were -tractable: hex-encoded secret strings in the multiround/dissolve flows, -`DissolveOutput` secrets (zeroize-on-drop, redacted `Debug`), prompted -mnemonics/seeds in `wallet import`/`from-seed`, seed copies inside -`WalletManager`, and password-file reads. - -These items are still not *fully* closed, and cannot be with the current type -shapes: secrets are `Copy` arrays (`[u8; 32]`) and plain `String`s, so every -pass-by-value and reallocation can leave untracked copies on the stack or in -freed heap blocks (e.g. the expected-event tuples in the dissolve flow, and -`String` reallocations inside prompt libraries). Fully closing them would mean -migrating to non-`Copy` zeroize-on-drop wrapper types end to end, which is out -of scope for this PR. Treat the residual risk as: secrets may persist in -process memory until overwritten; they are never persisted or printed. - -## CodeQL workflow narrowed (was: removed) - -The CodeQL workflow was initially deleted in this PR because nearly all of its -alerts were noise in `#[cfg(test)]` modules and `examples/` (hard-coded test -keys, intentional diagnostic prints). Deleting it outright also removed the -repo's only first-party SAST (source-level taint analysis) and the -Actions-hygiene checks β€” `cargo audit` covers dependency CVEs only and clippy -is not a security analyzer β€” so the workflow is reinstated in narrowed form -instead: - -- Default high-precision security query suite instead of the prior - `security-and-quality` sweep (drops the quality-noise class). -- `paths-ignore: examples` so intentional example code stops generating - alerts. Inline `mod tests` blocks cannot be path-excluded; the narrowed - suite already skips most of what fired there. -- The `actions` language matrix (workflow hygiene rules) is kept as before. - -## Breaking / UX changes callers should know - -- `wallet import` / `from-seed`: no `--mnemonic` / `--seed` flags (stdin prompts) -- `--password` / `-p` rejected everywhere; use `--password-file`, env, or prompt -- `wallet create` / `import` / `from-seed` no longer silently use an empty password; prompt (with confirm), `--password-file`, or env; empty only via `--allow-empty-password` -- Wormhole: `--secret` β†’ `--secret-file`; `collect-rewards --mnemonic` β†’ `--mnemonic-file` -- `wallet export`: requires `--output` file (no stdout mnemonic dump) -- Tech collective remove: requires `--min-rank` -- Circuit artifacts: need a rebuild so `generated-bins/` is a real directory with `manifest.json` (symlink-style bins rejected) -- Circuit artifacts: `./generated-bins` in the current working directory is no longer trusted implicitly (the unsigned manifest lives in the directory it authenticates, so an untrusted checkout could ship a self-consistent artifact set). Local dev must opt in with `QUANTUS_BINS_DIR=./generated-bins`; installed binaries keep using `~/.quantus/generated-bins` -- `QuantusClient::new` rejects non-Quantus / incompatible runtimes (expects `specName=quantus-runtime`, the name the real runtime declares); `compatibility-check` connects ungated so it can diagnose rejected nodes -- Batch transfers use `batch_all` (atomic fail-all) - -## Test plan - -- [x] `cargo test --lib` (211 passed) -- [ ] Manual: `quantus wallet import --name x --mnemonic '...'` fails clap parse -- [ ] Manual: `quantus wallet create --name x --password secret` errors with guidance -- [ ] Manual: wallet dir/files are `0700`/`0600` after create -- [x] Manual: connect to wrong `specName` RPC fails (verified vs `wss://rpc.polkadot.io`); real node accepted (verified vs `wss://a2-heisenberg.quantus.cat`, spec 136/tx 3); `compatibility-check` reports INCOMPATIBLE for Polkadot -- [ ] Manual: `quantus update` refuses checksum mismatch (if exercising updater) -- [ ] Full circuit rebuild without `SKIP_CIRCUIT_BUILD` once for new `generated-bins` layout -- [ ] Smoke send / multisig create / wormhole prove against local node - -## Commits - -``` -3d781aa fix(wallet): require an explicit password when creating wallets -ccc244a fix(cli): bound ranges and reject silent zero coercions -7f569f9 fix(bins): authenticate circuit artifacts and publish atomically -fdaee0c fix(cli): validate amounts delays ranks and fallible address helpers -06997c2 fix(wallet): zeroize secret material after encrypt and decrypt -8cd2d4c fix(wormhole): validate Merkle depth and prefer finalized snapshots -fbd38ed fix(wormhole): zeroize proof-generation secrets after use -fb5b36f fix(subsquid): harden exhaustive transfer queries and spent filtering -bcdab11 fix(batch): enforce runtime batched_calls_limit for batch size -12d3ff6 fix(rewards): use checked addition for indexer transfer totals -5015a2e fix(tx): bound transaction-status subscription waits -b7039e6 fix(storage): bound pagination against overflow and stuck cursors -1e9e910 fix(multisig): deduplicate signers before predict and threshold -0bec0ea fix(wallet): write exported mnemonics to a protected file -a710bf6 fix(wormhole): remove --secret argv and verify extrinsic failures -986c2cd fix(client): redact WebSocket URL credentials in diagnostics -95c1fc9 fix(wallet): return errors for malformed public keys -eeaefa7 fix(wallet): refuse to persist wallets with embedded AES key material -c5d3f2d fix(update): verify release archive SHA-256 before install -5fc7920 fix(wormhole): bind transfer events to from amount and count -304d6d5 fix(system): fail closed on invalid RPC token properties -a8051b1 fix(client): verify Quantus runtime identity at connect time -bfd228e fix(wallet): harden storage races and exclusive wallet creation -02c741a fix(wallet): authenticate address metadata and fail closed on migration -dfd96c0 fix(tx): stop unsafe nonce-bump retries on ambiguous errors -f111d9b fix(wormhole): require persisted mnemonic for HD secrets -0460883 fix(batch): use utility.batch_all for atomic transfers -27d8a9f fix(wallet): require restrictive password-file permissions -3bc368c fix(wallet): enforce owner-only keystore permissions -58b8780 fix(tx): fail when watched extrinsic is missing from block -e6dd668 fix(wallet): reject raw --password CLI credentials -d2ac243 fix(multisig): correlate MultisigCreated to creator and params -253c791 fix(wallet): skip malformed files when listing wallets -03dad4d fix(wallet): stop accepting mnemonic and seed via CLI flags -``` From 5cfead689aa39a17b299b3b3761cad028b00e175 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 16:26:01 +0800 Subject: [PATCH 73/74] fmt --- src/bins.rs | 4 ++-- src/cli/update.rs | 7 ++++++- src/cli/wormhole.rs | 3 ++- src/wallet/keystore.rs | 6 ++++-- src/wallet/mod.rs | 3 +-- 5 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/bins.rs b/src/bins.rs index ddb3d07..becc6f2 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -10,8 +10,8 @@ //! storage location and regenerates the binaries there on demand. //! //! Resolution order: -//! 1. `QUANTUS_BINS_DIR` env var (explicit override; also how local dev opts -//! in to `./generated-bins`). +//! 1. `QUANTUS_BINS_DIR` env var (explicit override; also how local dev opts in to +//! `./generated-bins`). //! 2. `~/.quantus/generated-bins/` (default for installed binaries). //! //! `./generated-bins/` in the current working directory is detected but never diff --git a/src/cli/update.rs b/src/cli/update.rs index 7c9bf1a..2140218 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -295,7 +295,12 @@ fn install_verified_release( 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, MAX_ARCHIVE_ASSET_BYTES, true)?; + download_asset( + &archive_asset.download_url, + &mut archive_file, + MAX_ARCHIVE_ASSET_BYTES, + true, + )?; archive_file .flush() .map_err(|e| QuantusError::Generic(format!("Failed to flush archive download: {e}")))?; diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c972c24..013e9ec 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2285,7 +2285,8 @@ async fn execute_initial_transfers( .filter_map(|e| e.ok()) .collect(); - let funding_account: SubxtAccountId = SubxtAccountId(wallet.keypair.try_to_account_id_32()?.into()); + let funding_account: SubxtAccountId = + SubxtAccountId(wallet.keypair.try_to_account_id_32()?.into()); let expected_transfers: Vec = secrets .iter() .enumerate() diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index 75e4c04..1bd8b12 100644 --- a/src/wallet/keystore.rs +++ b/src/wallet/keystore.rs @@ -860,7 +860,8 @@ mod tests { // Generate address using both methods let account_id = quantum_keypair.try_to_account_id_32().expect("valid keypair"); - let ss58_address = quantum_keypair.try_to_account_id_ss58check().expect("valid keypair"); + let ss58_address = + quantum_keypair.try_to_account_id_ss58check().expect("valid keypair"); // Verify address format (Quantus SS58 prefix 189 = "qz") assert!( @@ -1317,7 +1318,8 @@ mod tests { .encrypt_wallet_data(&victim, "correct-password") .expect("Encryption should succeed"); let victim_address = encrypted.address.clone(); - let attacker_address = attacker.keypair.try_to_account_id_ss58check().expect("valid keypair"); + let attacker_address = + attacker.keypair.try_to_account_id_ss58check().expect("valid keypair"); assert_ne!(victim_address, attacker_address); // Attacker rewrites only the plaintext envelope address; ciphertext is untouched. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 04b86f9..8972db2 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -833,8 +833,7 @@ mod tests { // Test address generation let account_id = quantum_keypair.try_to_account_id_32().expect("valid keypair"); - let ss58_address = - quantum_keypair.try_to_account_id_ss58check().expect("valid keypair"); + let ss58_address = quantum_keypair.try_to_account_id_ss58check().expect("valid keypair"); // Verify SS58 address format assert!(ss58_address.starts_with("qz"), "SS58 address should start with 5"); From 5394d71d1f01c1eaca2043a06dc394e09b945db2 Mon Sep 17 00:00:00 2001 From: illuzen Date: Wed, 5 Aug 2026 17:28:26 +0800 Subject: [PATCH 74/74] ci: remove CodeQL workflow Even narrowed to the default security suite with examples/ excluded, CodeQL's Rust analysis produces noise without actionable signal on this crate. Drop the workflow; cargo audit and clippy remain in ci.yml, and the actions-hygiene rules can come back later as a narrow actions-only workflow if wanted. Co-authored-by: Cursor --- .github/workflows/codeql.yml | 57 ------------------------------------ 1 file changed, 57 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 7a0f050..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,57 +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 analysis 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 }} - # Deliberately narrowed from the prior `security-and-quality` - # sweep, which was mostly quality noise in test modules and - # examples/ (hard-coded test keys, intentional prints) that - # reviewers learned to ignore. The default high-precision - # security suite plus a path exclusion for examples/ keeps the - # signal (taint analysis, Actions hygiene) without the noise. - config: | - paths-ignore: - - examples - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{ matrix.language }}"