From 5d97bd6e0b47e02e050a0ec3e1e70f03d864fe69 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 11 Aug 2026 20:05:13 +0800 Subject: [PATCH 1/5] runtime: warn on newer-than-table specs instead of refusing Exact COMPATIBLE_RUNTIMES matches still succeed; wrong spec names and older unknown pairs still error. Specs above the tested max connect with a warning so a slightly ahead node remains usable. Co-authored-by: Cursor --- src/chain/client.rs | 4 +- src/cli/exercise/scenarios/reads.rs | 7 +++ src/cli/mod.rs | 8 ++++ src/config/mod.rs | 72 +++++++++++++++++++++++------ 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/src/chain/client.rs b/src/chain/client.rs index 319a8ae..168353d 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -140,8 +140,8 @@ 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. + // Reject non-Quantus / older-unsupported runtimes before encode/sign. Newer-than-table + // Quantus specs are allowed with a warning (see validate_runtime_identity). if enforce_runtime_identity { use jsonrpsee::core::client::ClientT; let runtime_version: serde_json::Value = ws_client diff --git a/src/cli/exercise/scenarios/reads.rs b/src/cli/exercise/scenarios/reads.rs index 73e59f5..6e1fe41 100644 --- a/src/cli/exercise/scenarios/reads.rs +++ b/src/cli/exercise/scenarios/reads.rs @@ -26,6 +26,13 @@ async fn runtime_version(ctx: &ExerciseCtx, post_upgrade: bool) -> Result crate::error::Result<()> crate::config::EXPECTED_RUNTIME_SPEC_NAME ); log_print!(" • All other CLI commands will refuse to talk to this node"); + } else if crate::config::is_newer_unlisted_runtime(spec_version) { + log_print!( + "⚠️ NEWER RUNTIME - Spec {} is ahead of this CLI's tested list (up to {})", + spec_version.to_string().bright_yellow(), + crate::config::max_compatible_spec_version() + ); + log_print!(" • Commands are allowed, but some may not work correctly"); + log_print!(" • Consider updating the CLI when a release for this runtime is available"); } 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"); diff --git a/src/config/mod.rs b/src/config/mod.rs index 8332abc..35a7b96 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -25,38 +25,69 @@ pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = &[ CompatibleRuntime { spec_version: 143, transaction_version: 3, supports_ml_dsa_65: true }, ]; -/// Check whether a runtime version pair is supported by this CLI. +/// Highest `spec_version` listed in [`COMPATIBLE_RUNTIMES`]. +pub fn max_compatible_spec_version() -> u32 { + COMPATIBLE_RUNTIMES.iter().map(|runtime| runtime.spec_version).max().unwrap_or(0) +} + +/// Check whether a runtime version pair is an exact match in [`COMPATIBLE_RUNTIMES`]. pub fn is_runtime_compatible(spec_version: u32, transaction_version: u32) -> bool { COMPATIBLE_RUNTIMES.iter().any(|runtime| { runtime.spec_version == spec_version && runtime.transaction_version == transaction_version }) } -/// Whether a compatible runtime can decode ML-DSA-65 extrinsic signatures. +/// True when the node reports a Quantus `spec_version` newer than any pair this CLI has been +/// tested against. Such runtimes are allowed with a warning rather than a hard reject. +pub fn is_newer_unlisted_runtime(spec_version: u32) -> bool { + spec_version > max_compatible_spec_version() +} + +/// Whether a runtime can decode ML-DSA-65 extrinsic signatures. +/// +/// Exact table matches use [`CompatibleRuntime::supports_ml_dsa_65`]. Newer unlisted specs are +/// assumed to keep Dilithium65 support (introduced at spec 142). pub fn runtime_supports_ml_dsa_65(spec_version: u32, transaction_version: u32) -> bool { - COMPATIBLE_RUNTIMES.iter().any(|runtime| { + if COMPATIBLE_RUNTIMES.iter().any(|runtime| { runtime.spec_version == spec_version && runtime.transaction_version == transaction_version && runtime.supports_ml_dsa_65 - }) + }) { + return true; + } + is_newer_unlisted_runtime(spec_version) && + COMPATIBLE_RUNTIMES.iter().any(|runtime| runtime.supports_ml_dsa_65) } -/// Validate that a connected node's runtime identity is a supported Quantus runtime. +/// Validate that a connected node's runtime identity is a Quantus runtime this CLI can talk to. /// -/// Rejects wrong `specName` values and version pairs outside [`COMPATIBLE_RUNTIMES`]. +/// Rejects wrong `specName` values and older/unknown version pairs outside +/// [`COMPATIBLE_RUNTIMES`]. A `spec_version` newer than the compatibility table is accepted with +/// a warning — extrinsics may still fail if the runtime has moved on. 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) - { + if spec_name != EXPECTED_RUNTIME_SPEC_NAME { return Err(QuantusError::NetworkError(format!( "Unsupported Quantus runtime: specName={spec_name}, specVersion={spec_version}, transactionVersion={transaction_version}" ))); } - Ok(()) + if is_runtime_compatible(spec_version, transaction_version) { + return Ok(()); + } + if is_newer_unlisted_runtime(spec_version) { + crate::log_status!( + "⚠️ Runtime specVersion={spec_version} / transactionVersion={transaction_version} \ + is newer than this CLI's tested list (up to spec {}); some commands may not work.", + max_compatible_spec_version() + ); + return Ok(()); + } + Err(QuantusError::NetworkError(format!( + "Unsupported Quantus runtime: specName={spec_name}, specVersion={spec_version}, transactionVersion={transaction_version}" + ))) } /// Reject ML-DSA-65 signing against runtimes that only understand ML-DSA-87. @@ -122,15 +153,22 @@ 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(); + fn validate_runtime_identity_warns_but_accepts_newer_unlisted_spec() { + validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 999_999, 999_999) + .expect("newer-than-table Quantus specs must be allowed with a warning"); + assert!(is_newer_unlisted_runtime(999_999)); + assert!(!is_runtime_compatible(999_999, 999_999)); + } + + #[test] + fn validate_runtime_identity_rejects_older_unlisted_runtime_versions() { + let err = validate_runtime_identity(EXPECTED_RUNTIME_SPEC_NAME, 1, 1).unwrap_err(); let msg = err.to_string(); assert!( msg.contains("Unsupported Quantus runtime") && - msg.contains("999999") && + msg.contains("specVersion=1") && msg.contains(EXPECTED_RUNTIME_SPEC_NAME), - "expected incompatible-version rejection, got: {msg}" + "expected older/unknown-version rejection, got: {msg}" ); } @@ -167,6 +205,10 @@ mod tests { assert!(runtime_supports_ml_dsa_65(142, 3)); assert!(runtime_supports_ml_dsa_65(143, 3)); assert!(!runtime_supports_ml_dsa_65(142, 2), "unknown tx version must not match"); + assert!( + runtime_supports_ml_dsa_65(max_compatible_spec_version() + 1, 3), + "newer unlisted specs are assumed to keep ML-DSA-65" + ); } #[test] From abb746fad7176050cd7f661aa49a6bcd42603474 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 11 Aug 2026 20:12:02 +0800 Subject: [PATCH 2/5] wormhole: fix multiround fee math and enforce funding debits Use VOLUME_FEE_BPS for expected round amounts, require the funding batch to actually reduce free balance by the partitioned total, and hard-fail final balance checks against on-chain minted exits so a missing debit can no longer look like a soft fee mismatch. Co-authored-by: Cursor --- src/cli/wormhole.rs | 237 ++++++++++++++++++++++++++++++-------------- 1 file changed, 163 insertions(+), 74 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index e0b69e4..1cb2da7 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -479,10 +479,9 @@ pub fn compute_random_output_assignments( let total_output: u64 = proof_outputs.iter().map(|&x| x as u64).sum(); // Step 2: Randomly partition total output across target accounts - // Minimum 3 quantized units (0.03 DEV) per target. After fee deduction in the - // next round: compute_output_amount(3, 10) = 3 * 9990 / 10000 = 2, which is safe. - // With 2: compute_output_amount(2, 10) = 1, borderline. - // With 1: compute_output_amount(1, 10) = 0, causes circuit failure. + // Minimum 3 quantized units (0.03 DEV) per target. After a VOLUME_FEE_BPS + // haircut in the next round: compute_output_amount(3, 4) = 2, which is safe. + // With 1: compute_output_amount(1, 4) = 0, which drops the transfer event. let min_per_target = 3u128; let target_amounts_u128 = random_partition(total_output as u128, num_targets, min_per_target); let target_amounts: Vec = target_amounts_u128.iter().map(|&x| x as u32).collect(); @@ -2120,14 +2119,15 @@ fn derive_wormhole_secret( .map_err(|e| crate::error::QuantusError::Generic(format!("HD derivation failed: {:?}", e))) } -/// Calculate the amount for a given round, accounting for fees -/// Each round deducts the on-chain volume fee (`VOLUME_FEE_BPS`) -/// Round 1: fee applied once, Round 2: fee applied twice, etc. +/// Approximate total still in the circuit after `round` volume-fee haircuts on +/// `initial_amount` (applied in planck). Real per-proof fees are computed on +/// quantized amounts, so this is for display / coarse checks only — final +/// verification uses the on-chain minted exit totals. fn calculate_round_amount(initial_amount: u128, round: usize) -> u128 { + let keep_bps = 10000u128.saturating_sub(VOLUME_FEE_BPS as u128); let mut amount = initial_amount; for _ in 0..round { - // Output = Input * (10000 - 10) / 10000 - amount = amount * 9990 / 10000; + amount = amount.saturating_mul(keep_bps) / 10000; } amount } @@ -2290,41 +2290,44 @@ async fn execute_initial_transfers( execution_mode: ExecutionMode, ) -> crate::error::Result> { use colored::Colorize; - use quantus_node::api::runtime_types::{ - pallet_balances::pallet::Call as BalancesCall, quantus_runtime::RuntimeCall, - }; log_print!("{}", "Step 1: Sending batched transfer to wormhole addresses...".bright_yellow()); // Randomly partition the total amount among proofs // Each partition must meet the on-chain minimum transfer amount - // Minimum per partition is 0.02 DEV (2 quantized units) to ensure non-trivial amounts + // Minimum per partition is 0.03 DEV (3 quantized units) to ensure non-trivial amounts let partition_amounts = random_partition(amount, num_proofs, 3 * SCALE_DOWN_FACTOR); + let partitioned_total: u128 = partition_amounts.iter().sum(); + if partitioned_total != amount { + return Err(crate::error::QuantusError::Generic(format!( + "internal error: random_partition summed to {partitioned_total}, expected {amount}" + ))); + } log_print!(" Random partition of {} ({}):", amount, format_balance(amount)); for (i, &amt) in partition_amounts.iter().enumerate() { log_print!(" Proof {}: {} ({})", i + 1, amt, format_balance(amt)); } - // Build batch of transfer calls - let mut calls = Vec::with_capacity(num_proofs); - for (i, secret) in secrets.iter().enumerate() { - let wormhole_address = SubxtAccountId(*secret.address()); - let transfer_call = RuntimeCall::Balances(BalancesCall::transfer_allow_death { - dest: subxt::ext::subxt_core::utils::MultiAddress::Id(wormhole_address), - value: partition_amounts[i], - }); - calls.push(transfer_call); - } + let transfers: Vec<(String, u128)> = secrets + .iter() + .enumerate() + .map(|(i, secret)| (bytes_to_quantus_ss58(secret.address()), partition_amounts[i])) + .collect(); - // 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); + // Same atomic batch builder as `quantus send --batch` / exercise funding. + let batch_tx = crate::cli::send::build_batch_transfer_call(&transfers)?; - let quantum_keypair = QuantumKeyPair { - public_key: wallet.keypair.public_key.clone(), - private_key: wallet.keypair.private_key.clone(), - scheme: wallet.keypair.scheme, - }; + let balance_before = get_balance(quantus_client, &wallet.wallet_address).await?; + crate::cli::send::ensure_balance_covers_call( + quantus_client, + &wallet.keypair, + &batch_tx, + balance_before, + amount, + None, + "wormhole multiround funding batch", + ) + .await?; log_print!(" Submitting batch of {} transfers...", num_proofs); @@ -2363,7 +2366,7 @@ async fn execute_initial_transfers( let wait_mode = wormhole_inclusion_mode(execution_mode); let (_tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, - &quantum_keypair, + &wallet.keypair, batch_tx, None, wait_mode, @@ -2401,17 +2404,41 @@ async fn execute_initial_transfers( leaf_index: None, }) .collect(); - let transfers = + let parsed = parse_expected_transfer_events(&transfer_events, &expected_transfers, block_hash)?; + // Event matching alone is not enough: a wrong/no-op batch can still succeed while + // NativeTransferred is matched incorrectly. The free balance must show the debit. + let balance_after = get_balance(quantus_client, &wallet.wallet_address).await?; + let deducted = balance_before.saturating_sub(balance_after); + if deducted < amount { + return Err(crate::error::QuantusError::Generic(format!( + "Funding batch did not debit the wallet: free balance dropped by {} ({}) but \ + {} ({}) was transferred. Have {}, now {}. Refusing to prove against a \ + funding that did not leave this account.", + deducted, + format_balance(deducted), + amount, + format_balance(amount), + format_balance(balance_before), + format_balance(balance_after), + ))); + } + log_success!( " {} transfers submitted in a single batch ({} block {})", num_proofs, wait_mode.transaction_stage().status_label(), hex::encode(block_hash.0) ); + log_print!( + " Balance after funding: {} ({}) [deducted: {} planck]", + balance_after, + format_balance(balance_after), + deducted + ); - Ok(transfers) + Ok(parsed) } /// Generate proofs for a round with random output partitioning @@ -2581,22 +2608,22 @@ fn derive_round_secrets( Ok(secrets) } -/// Verify final balance and print summary +/// Verify the wallet's free-balance delta matches funding out + final minted exits, +/// allowing only extrinsic-fee slack (not a missing 100-DEV debit). fn verify_final_balance( initial_balance: u128, final_balance: u128, total_sent: u128, + total_received: u128, rounds: usize, num_proofs: usize, -) { +) -> crate::error::Result<()> { use colored::Colorize; log_print!("{}", "Balance Verification:".bright_cyan()); - // Total received in final round: apply fee deduction for each round - let total_received = calculate_round_amount(total_sent, rounds); - - // Expected net change (may be negative due to fees) + // Closed loop: −funding + final exits. Volume fees make this slightly negative; + // extrinsic fees make the actual drop a bit larger. let expected_change = total_received as i128 - total_sent as i128; let actual_change = final_balance as i128 - initial_balance as i128; @@ -2612,24 +2639,26 @@ fn verify_final_balance( ); log_print!(""); - // Format signed amounts for display let expected_change_str = if expected_change >= 0 { - format!("+{}", expected_change) + format!("+{expected_change}") } else { - format!("{}", expected_change) + format!("{expected_change}") }; let actual_change_str = if actual_change >= 0 { - format!("+{}", actual_change) + format!("+{actual_change}") } else { - format!("{}", actual_change) + format!("{actual_change}") }; - log_print!(" Expected change: {} planck", expected_change_str); - log_print!(" Actual change: {} planck", actual_change_str); + log_print!(" Expected change: {expected_change_str} planck"); + log_print!(" Actual change: {actual_change_str} planck"); log_print!(""); - // Allow some tolerance for transaction fees - let tolerance = (total_sent / 100).max(1_000_000_000_000); // 1% or 1 QNT minimum + // Extrinsic fees for the funding batch + one verify per round. Keep this tight so a + // missed funding debit (~total_sent) cannot hide inside the tolerance. + let tolerance = 1_000_000_000_000u128 // 1 DEV + .saturating_mul((1 + rounds as u128).max(2)) + .max(total_sent / 1000); // 0.1% of principal, whichever is larger let diff = (actual_change - expected_change).unsigned_abs(); if diff <= tolerance { @@ -2638,19 +2667,19 @@ fn verify_final_balance( "✓".bright_green(), tolerance ); + log_print!(""); + Ok(()) } else { - log_print!( - " {} Balance verification: difference of {} planck (tolerance: {} planck)", - "!".bright_yellow(), - diff, - tolerance - ); - log_print!( - " Note: Transaction fees for {} initial transfers may account for the difference", - num_proofs - ); + log_print!(""); + Err(crate::error::QuantusError::Generic(format!( + "Balance verification failed: actual change {actual_change_str} planck vs expected \ + {expected_change_str} planck (diff {diff}, tolerance {tolerance}). \ + Funding should debit ~{} and the final round should mint ~{} back \ + ({num_proofs} proofs, {rounds} rounds).", + format_balance(total_sent), + format_balance(total_received), + ))) } - log_print!(""); } /// Run the multi-round wormhole flow @@ -2730,6 +2759,8 @@ async fn run_multiround( // Track transfer info for the current round let mut current_transfers: Vec = Vec::new(); + // Sum of NativeTransferred amounts minted to the wallet on the final round. + let mut final_exit_total: Option = None; for round in 1..=rounds { let is_final = round == rounds; @@ -2784,17 +2815,6 @@ async fn run_multiround( execution_mode, ) .await?; - - // Log balance immediately after funding transfers - let balance_after_funding = - get_balance(&quantus_client, &wallet.wallet_address).await?; - let funding_deducted = initial_balance.saturating_sub(balance_after_funding); - log_print!( - " Balance after funding: {} ({}) [deducted: {} planck]", - balance_after_funding, - format_balance(balance_after_funding), - funding_deducted - ); } else { log_print!("{}", "Step 1: Using transfer info from previous round...".bright_yellow()); log_print!(" Found {} transfer(s) from previous round", current_transfers.len()); @@ -2857,7 +2877,8 @@ async fn run_multiround( hex::encode(extrinsic_hash.0) ); - // If not final round, prepare transfer info for next round + // If not final round, prepare transfer info for next round; on the final + // round record what was minted back to the funding wallet. if !is_final { log_print!("{}", "Step 5: Capturing transfer info for next round...".bright_yellow()); @@ -2896,6 +2917,21 @@ async fn run_multiround( current_transfers.len(), round + 1 ); + } else { + let minted: u128 = transfer_events.iter().map(|e| e.amount).sum(); + if minted == 0 { + return Err(crate::error::QuantusError::Generic( + "Final round produced no NativeTransferred mint events to sum for \ + balance verification" + .to_string(), + )); + } + final_exit_total = Some(minted); + log_print!( + " Final-round exits to wallet: {} ({})", + minted, + format_balance(minted) + ); } // Log balance after this round @@ -2925,9 +2961,22 @@ async fn run_multiround( log_print!("=================================================="); log_print!(""); - // Final balance verification + // Final balance verification against on-chain minted exits (not the approximate + // planck haircut used for the pre-run "Expected amounts" table). let final_balance = get_balance(&quantus_client, &wallet.wallet_address).await?; - verify_final_balance(initial_balance, final_balance, amount, rounds, num_proofs); + let total_received = final_exit_total.ok_or_else(|| { + crate::error::QuantusError::Generic( + "internal error: final-round exit total was not recorded".to_string(), + ) + })?; + verify_final_balance( + initial_balance, + final_balance, + amount, + total_received, + rounds, + num_proofs, + )?; if keep_files { log_print!("Proof files preserved in: {}", output_dir); @@ -4564,6 +4613,46 @@ mod tests { assert_eq!(compute_output_amount(0, 10), 0); assert_eq!(compute_output_amount(1, 10), 0); // rounds down assert_eq!(compute_output_amount(100, 10), 99); + + // On-chain volume fee + assert_eq!(compute_output_amount(10_000, VOLUME_FEE_BPS), 9_996); + } + + #[test] + fn calculate_round_amount_uses_on_chain_volume_fee_bps() { + let one = 100_000_000_000_000u128; + let keep = 10000u128 - VOLUME_FEE_BPS as u128; + assert_eq!(calculate_round_amount(one, 0), one); + assert_eq!(calculate_round_amount(one, 1), one * keep / 10000); + assert_eq!(calculate_round_amount(one, 2), one * keep / 10000 * keep / 10000); + // Must not silently keep the old hardcoded 10 bps haircut. + assert_ne!(calculate_round_amount(one, 2), one * 9990 / 10000 * 9990 / 10000); + } + + #[test] + fn verify_final_balance_accepts_closed_loop_within_fee_tolerance() { + let sent = 100_000_000_000_000u128; + let received = calculate_round_amount(sent, 2); + let initial = 150_000_000_000_000u128; + // Exact closed loop (no extrinsic fees): final = initial - sent + received + let final_bal = initial - sent + received; + verify_final_balance(initial, final_bal, sent, received, 2, 2) + .expect("exact closed loop must pass"); + } + + #[test] + fn verify_final_balance_rejects_missing_funding_debit() { + let sent = 100_000_000_000_000u128; + let received = 99_890_000_000_000u128; + let initial = 10_990_000_000_000u128; + // Same pathology as the broken run: exits credited, funding never left. + let final_bal = initial + received; + let err = verify_final_balance(initial, final_bal, sent, received, 2, 2) + .expect_err("missing funding debit must fail verification"); + assert!( + err.to_string().contains("Balance verification failed"), + "unexpected error: {err}" + ); } #[test] From 4ed2de4dc00b0471ce9533dce35076d1d8e87e11 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 11 Aug 2026 20:31:26 +0800 Subject: [PATCH 3/5] fmt: rustfmt wormhole and runtime config after accounting fixes Co-authored-by: Cursor --- src/cli/wormhole.rs | 21 +++++---------------- src/config/mod.rs | 6 +++++- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index 1cb2da7..c4a7082 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -2404,8 +2404,7 @@ async fn execute_initial_transfers( leaf_index: None, }) .collect(); - let parsed = - parse_expected_transfer_events(&transfer_events, &expected_transfers, block_hash)?; + let parsed = parse_expected_transfer_events(&transfer_events, &expected_transfers, block_hash)?; // Event matching alone is not enough: a wrong/no-op batch can still succeed while // NativeTransferred is matched incorrectly. The free balance must show the debit. @@ -2644,11 +2643,8 @@ fn verify_final_balance( } else { format!("{expected_change}") }; - let actual_change_str = if actual_change >= 0 { - format!("+{actual_change}") - } else { - format!("{actual_change}") - }; + let actual_change_str = + if actual_change >= 0 { format!("+{actual_change}") } else { format!("{actual_change}") }; log_print!(" Expected change: {expected_change_str} planck"); log_print!(" Actual change: {actual_change_str} planck"); @@ -2927,11 +2923,7 @@ async fn run_multiround( )); } final_exit_total = Some(minted); - log_print!( - " Final-round exits to wallet: {} ({})", - minted, - format_balance(minted) - ); + log_print!(" Final-round exits to wallet: {} ({})", minted, format_balance(minted)); } // Log balance after this round @@ -4649,10 +4641,7 @@ mod tests { let final_bal = initial + received; let err = verify_final_balance(initial, final_bal, sent, received, 2, 2) .expect_err("missing funding debit must fail verification"); - assert!( - err.to_string().contains("Balance verification failed"), - "unexpected error: {err}" - ); + assert!(err.to_string().contains("Balance verification failed"), "unexpected error: {err}"); } #[test] diff --git a/src/config/mod.rs b/src/config/mod.rs index 35a7b96..88185cf 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -27,7 +27,11 @@ pub const COMPATIBLE_RUNTIMES: &[CompatibleRuntime] = &[ /// Highest `spec_version` listed in [`COMPATIBLE_RUNTIMES`]. pub fn max_compatible_spec_version() -> u32 { - COMPATIBLE_RUNTIMES.iter().map(|runtime| runtime.spec_version).max().unwrap_or(0) + COMPATIBLE_RUNTIMES + .iter() + .map(|runtime| runtime.spec_version) + .max() + .unwrap_or(0) } /// Check whether a runtime version pair is an exact match in [`COMPATIBLE_RUNTIMES`]. From a0a455f74e243c79541dee4d3803f0532ee88045 Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 11 Aug 2026 20:51:13 +0800 Subject: [PATCH 4/5] cleanup --- .github/workflows/create-release-proposal.yml | 4 +- .../create-release-tag-and-publish.yml | 18 +-- LIBRARY_USAGE.md | 2 +- README.md | 28 +++-- src/cli/wormhole.rs | 116 ++++++------------ src/collect_rewards_lib.rs | 23 +--- 6 files changed, 71 insertions(+), 120 deletions(-) diff --git a/.github/workflows/create-release-proposal.yml b/.github/workflows/create-release-proposal.yml index 99f8422..7c5a697 100644 --- a/.github/workflows/create-release-proposal.yml +++ b/.github/workflows/create-release-proposal.yml @@ -40,7 +40,7 @@ jobs: source_branch: ${{ steps.vars.outputs.source_branch }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 fetch-tags: true @@ -118,7 +118,7 @@ jobs: pull-requests: write steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 diff --git a/.github/workflows/create-release-tag-and-publish.yml b/.github/workflows/create-release-tag-and-publish.yml index 27bbf83..4358f21 100644 --- a/.github/workflows/create-release-tag-and-publish.yml +++ b/.github/workflows/create-release-tag-and-publish.yml @@ -29,7 +29,7 @@ jobs: is_draft: ${{ steps.extract_version.outputs.is_draft }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -65,7 +65,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.create-tag.outputs.version }} @@ -92,7 +92,7 @@ jobs: - macos-latest steps: - name: Checkout code at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.create-tag.outputs.version }} @@ -105,7 +105,7 @@ jobs: uses: ./.github/actions/macos - name: Cache cargo registry - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -141,7 +141,7 @@ jobs: timeout-minutes: 10 steps: - name: Checkout code at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.create-tag.outputs.version }} @@ -174,7 +174,7 @@ jobs: archive: zip steps: - name: Checkout code at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.create-tag.outputs.version }} @@ -203,7 +203,7 @@ jobs: run: rustup target add ${{ matrix.target }} - name: Cache cargo registry and target - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: | ~/.cargo/registry @@ -286,7 +286,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: ref: ${{ needs.create-tag.outputs.version }} fetch-depth: 0 @@ -339,7 +339,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code at tag - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ needs.create-tag.outputs.version }} diff --git a/LIBRARY_USAGE.md b/LIBRARY_USAGE.md index 071da0d..2907514 100644 --- a/LIBRARY_USAGE.md +++ b/LIBRARY_USAGE.md @@ -757,7 +757,7 @@ cargo run --example multisig_library_usage ## Key Features -- **Quantum-safe cryptography**: Uses Dilithium ML-DSA-87 for all cryptographic operations +- **Quantum-safe cryptography**: Dilithium ML-DSA signatures — default **ML-DSA-65**, optional **ML-DSA-87** (`DilithiumScheme`) - **Wallet management**: Create, import, export, and manage multiple wallets - **Blockchain interaction**: Query balances, send transactions, get system info - **Thread-safe**: Safe to use in multi-threaded applications diff --git a/README.md b/README.md index 7fc7bbe..66bc921 100644 --- a/README.md +++ b/README.md @@ -368,15 +368,21 @@ quantus developer create-test-wallets ### Wallet Management ```bash -# Create a new quantum-safe wallet +# Create a new quantum-safe wallet (default scheme: ml-dsa-65, HD path …/1') quantus wallet create --name my_wallet -# Create with explicit derivation path -quantus wallet create --name my_wallet --derivation-path "m/44'/189189'/0'/0/0" +# ML-DSA-87 (HD path defaults to …/0' when --derivation-path is omitted) +quantus wallet create --name my_wallet_87 --scheme ml-dsa-87 + +# Create with an explicit derivation path +quantus wallet create --name my_wallet --derivation-path "m/44'/189189'/0'/0'/1'" # Import from mnemonic quantus wallet import --name recovered_wallet --mnemonic "word1 word2 ... word24" +# Import as ML-DSA-87 +quantus wallet import --name recovered_87 --scheme ml-dsa-87 --mnemonic "word1 word2 ... word24" + # Create from raw 32-byte seed quantus wallet from-seed --name raw_wallet --seed <64-hex-chars> @@ -595,6 +601,12 @@ quantus exercise --skip wormhole # Reproduce a fuzz failure from its seed; emit the report as JSON quantus exercise --seed 12345 --json + +# Runtime upgrade smoke (fast-governance node only). Mutually exclusive: +# --self-upgrade re-installs the current on-chain :code (no WASM file; no post-upgrade re-run) +# --upgrade-wasm installs a candidate WASM, then re-runs the other phases against it +quantus exercise --phases upgrade --self-upgrade +quantus exercise --phases upgrade --upgrade-wasm path/to/runtime.wasm ``` Key flags: @@ -608,7 +620,9 @@ Key flags: | `--phases ` / `--skip ` | all | Comma-separated phases to run / skip. | | `--seed ` | random | Reproducible fuzz seed. | | `--fuzz-iterations ` | `25` | Number of fuzz iterations. | -| `--upgrade-wasm ` | — | Enable the runtime-upgrade phase with the given WASM (fast-governance node only). | +| `--upgrade-wasm ` | — | Enable the runtime-upgrade phase with the given WASM (fast-governance node only). Re-runs other phases after a successful upgrade. | +| `--self-upgrade` | off | No-WASM upgrade smoke test: authorize/apply the current on-chain runtime blob via tech-referenda (fast-governance node only). Conflicts with `--upgrade-wasm`. Does not re-run other phases (runtime unchanged). Not the same as `quantus update` (CLI binary self-update). | +| `--upgrade-timeout-secs ` | `900` | How long to wait for the upgrade referendum / code write. | | `--fail-fast` | off | Stop at the first failed step. | | `--json` | off | Emit the final report as JSON. | @@ -889,7 +903,7 @@ For more details, see `quantus multisig --help` and explore subcommands with `-- ## 🏗️ Architecture ### Quantum-Safe Cryptography -- **Dilithium (ML-DSA-87)**: Post-quantum digital signatures +- **Dilithium (ML-DSA)**: Post-quantum digital signatures — default **ML-DSA-65** (`--scheme ml-dsa-65`), with **ML-DSA-87** available (`--scheme ml-dsa-87`). Each scheme has its own default HD path (`…/1'` vs `…/0'`) so the same mnemonic does not collide across schemes. - **Secure Storage**: AES-256-GCM + Argon2 encryption for wallet files - **Future-Proof**: Ready for ML-KEM key encapsulation @@ -964,7 +978,7 @@ The project includes a script to regenerate SubXT types and metadata when the bl 1. **Updates metadata**: Downloads the latest chain metadata to `src/quantus_metadata.scale` 2. **Generates types**: Creates type-safe Rust code in `src/chain/quantus_subxt.rs` 3. **Formats code**: Automatically formats the generated code with `cargo fmt` -4. **Prompts compatibility update**: Reminds you to update the supported runtime/transaction pair in `src/config/mod.rs` +4. **Prompts compatibility update**: Reminds you to add the new runtime/transaction pair to the allowlist in `src/config/mod.rs` (newer unlisted specs warn rather than hard-fail) **When to use:** - After updating the Quantus runtime @@ -1005,4 +1019,4 @@ After regeneration, re-run: quantus compatibility-check --node-url ``` -The checked-in compatibility gate now requires both the runtime `spec_version` and `transaction_version` to match a supported pair. +The compatibility gate accepts exact `spec_version` / `transaction_version` pairs listed in `src/config/mod.rs`. A Quantus node whose `spec_version` is **newer** than the highest listed pair connects with a warning (extrinsics may still fail if the runtime has moved on). Wrong `specName` values and older/unknown pairs outside the table are still rejected. diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c4a7082..e31ffb2 100644 --- a/src/cli/wormhole.rs +++ b/src/cli/wormhole.rs @@ -53,9 +53,8 @@ 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)] -#[allow(dead_code)] // Fields used for deserialization and future use when ZK trie is deployed pub struct ZkMerkleProofRpc { - /// Index of the leaf + /// Index of the leaf (checked against the requested index in [`get_zk_merkle_proof`]). pub leaf_index: u64, /// The leaf data (SCALE-encoded ZkLeaf) pub leaf_data: Vec, @@ -66,7 +65,7 @@ pub struct ZkMerkleProofRpc { pub siblings: Vec<[Hash256; 3]>, /// Current tree root pub root: Hash256, - /// Current tree depth + /// Current tree depth (must equal `siblings.len()`; enforced in Deserialize). pub depth: u8, } @@ -200,7 +199,6 @@ mod siblings_format { /// The `at_block` parameter is critical for ZK proof generation. The tree root changes /// with each block, so the Merkle proof MUST be fetched at the same block whose header /// you're including in the ZK proof. -#[allow(dead_code)] // Will be used when ZK tree is deployed to production pub async fn get_zk_merkle_proof( quantus_client: &QuantusClient, leaf_index: u64, @@ -218,12 +216,24 @@ pub async fn get_zk_merkle_proof( )) })?; - proof.ok_or_else(|| { + let proof = proof.ok_or_else(|| { crate::error::QuantusError::Generic(format!( "Leaf index {} not found in ZK tree at block {:?}", leaf_index, at_block )) - }) + })?; + + if proof.leaf_index != leaf_index { + return Err(crate::error::QuantusError::Generic(format!( + "ZK Merkle proof leaf_index mismatch: requested {}, got {}", + leaf_index, proof.leaf_index + ))); + } + // `depth` is part of the RPC surface for SDK callers; Deserialize already + // enforces depth == siblings.len(). + debug_assert_eq!(proof.depth as usize, proof.siblings.len()); + + Ok(proof) } /// Compute sorted siblings and position hints from unsorted siblings. @@ -920,24 +930,6 @@ pub enum WormholeCommands { #[arg(short, long, default_value = "/tmp/wormhole_dissolve")] output_dir: String, }, - /// Fuzz test the leaf verification by attempting invalid proofs - Fuzz { - /// Wallet name to use for funding - #[arg(short, long)] - wallet: String, - - /// Password for the wallet - #[arg(short, long, hide = true)] - password: Option, - - /// Read password from file - #[arg(long)] - password_file: Option, - - /// Amount in DEV to use for the test transfer (default: 1.0) - #[arg(short, long, default_value = "1.0")] - amount: f64, - }, /// Collect miner rewards from a wormhole address. /// /// This command queries Subsquid for pending transfers to your wormhole address, @@ -1188,18 +1180,6 @@ pub async fn handle_wormhole_command( ) .await }, - WormholeCommands::Fuzz { wallet: _, password: _, password_file: _, amount: _ } => { - // TODO: Re-enable fuzz tests once ZK tree is deployed to a test chain. - // The fuzz tests need to be rewritten to use zkTree_getMerkleProof RPC - // instead of the old state_getReadProof storage proofs. - // See run_fuzz_test() and try_generate_fuzz_proof() below for the old implementation. - Err(crate::error::QuantusError::Generic( - "Fuzz testing is temporarily disabled during the migration to ZK tree proofs. \ - The fuzz tests require a chain with pallet-zk-tree deployed and the \ - zkTree_getMerkleProof RPC endpoint available." - .to_string(), - )) - }, WormholeCommands::CollectRewards { wallet, mnemonic_file, @@ -3019,27 +2999,9 @@ async fn generate_proof( crate::error::QuantusError::Generic(format!("Failed to get block: {}", e)) })?; - // Fetch ZK Merkle proof from chain via RPC using the leaf_index - // CRITICAL: We MUST fetch the proof at the same block we're proving against. + // CRITICAL: fetch the ZK Merkle proof at the same block we're proving against. // The tree root changes with each block, so proof must match header.zk_tree_root. - let proof_params = rpc_params![leaf_index, block_hash]; - let zk_proof: Option = quantus_client - .rpc_client() - .request("zkTree_getMerkleProof", proof_params) - .await - .map_err(|e| { - crate::error::QuantusError::Generic(format!( - "Failed to get ZK Merkle proof at block {:?}: {}", - block_hash, e - )) - })?; - - let zk_proof = zk_proof.ok_or_else(|| { - crate::error::QuantusError::Generic(format!( - "No ZK Merkle proof found for leaf_index {}", - leaf_index - )) - })?; + let zk_proof = get_zk_merkle_proof(quantus_client, leaf_index, block_hash).await?; // Decode the input amount from the leaf data // The leaf data is SCALE-encoded ZkLeaf: (to: AccountId, transfer_count: u64, asset_id: @@ -4294,36 +4256,28 @@ fn aggregate_proofs_to_file(proof_files: &[String], output_file: &str) -> crate: } // ============================================================================= -// FUZZ TEST FUNCTIONS - TEMPORARILY DISABLED +// Wormhole negative-proof fuzz (not implemented) // ============================================================================= // -// The fuzz tests below are temporarily disabled during the migration from MPT -// storage proofs to ZK tree Merkle proofs. To re-enable: +// There used to be a `wormhole fuzz` command that mutated leaf / Merkle fields and +// checked the verifier rejected them. That path targeted the pre-migration MPT +// storage proofs (`state_getReadProof`) and was deleted rather than left as a +// stub that always failed. // -// 1. Deploy pallet-zk-tree to a test chain -// 2. Update run_fuzz_test() to use zkTree_getMerkleProof RPC instead of state_getReadProof -// 3. Update try_generate_fuzz_proof() to use the new PrivateCircuitInputs fields: -// - zk_tree_root: [u8; 32] -// - zk_merkle_siblings: Vec<[[u8; 32]; 3]> -// - zk_merkle_positions: Vec -// 4. Note: The ZK leaf no longer contains `from` (funding_account) - it's now: (to: AccountId, -// transfer_count: u64, asset_id: u32, amount: u32) -// 5. Update generate_fuzz_cases() to remove from-address fuzzing since it's no longer in the leaf +// Happy-path proving already uses the live ZK tree (`zkTree_getMerkleProof` via +// [`get_zk_merkle_proof`], then [`compute_merkle_positions`]). To bring negative +// fuzzing back: // -// See qp-zk-circuits/wormhole/tests/src/prover/prover_tests.rs for examples of -// how to construct ZK Merkle proofs for testing. -// ============================================================================= - -// TODO: Re-enable fuzz tests once ZK tree is deployed -// The old implementation used: -// - state_getReadProof RPC to fetch MPT storage proofs -// - prepare_proof_for_circuit() to process proofs -// - PrivateCircuitInputs with funding_account and storage_proof fields +// 1. Fund a real transfer and fetch a valid `ZkMerkleProofRpc` at a fixed block. +// 2. Mutate circuit inputs that the verifier must reject (wrong siblings / positions, wrong +// `zk_tree_root`, wrong leaf amount / transfer_count, spent nullifier, etc.). The leaf shape is +// `(to, transfer_count, asset_id, amount)` — there is no funding `from` field to fuzz. +// 3. Assert local verification fails (and optionally that an on-chain `verify_*_batch` extrinsic is +// rejected). // -// The new implementation should: -// - Use zkTree_getMerkleProof RPC -// - Directly use ZkMerkleProofRpc response (siblings, positions) -// - Use PrivateCircuitInputs with zk_tree_root, zk_merkle_siblings, zk_merkle_positions +// Circuit construction examples live in +// `qp-zk-circuits/wormhole/tests/src/prover/prover_tests.rs`. +// ============================================================================= /// Check if nullifiers have been spent by querying Subsquid. /// diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index 7b1031b..d1d09ff 100644 --- a/src/collect_rewards_lib.rs +++ b/src/collect_rewards_lib.rs @@ -17,7 +17,7 @@ use crate::{ quantus_subxt::{self as quantus_node, api::wormhole}, }, cli::wormhole::{ - compute_merkle_positions, parse_secret_hex as parse_secret_hex_str, ZkMerkleProofRpc, + compute_merkle_positions, get_zk_merkle_proof, parse_secret_hex as parse_secret_hex_str, }, subsquid::{ compute_address_hash, get_hash_prefix, SubsquidClient, Transfer, TransferQueryParams, @@ -34,13 +34,7 @@ use qp_wormhole_aggregator::{ use qp_zk_circuits_common::circuit::{C, D, F}; use sp_core::crypto::{AccountId32, Ss58Codec}; use std::path::Path; -use subxt::{ - ext::{ - codec::Encode, - jsonrpsee::{core::client::ClientT, rpc_params}, - }, - tx::TxStatus, -}; +use subxt::{ext::codec::Encode, tx::TxStatus}; /// Result type for collect rewards operations pub type Result = std::result::Result; @@ -398,11 +392,7 @@ pub async fn collect_rewards( CollectRewardsError::from(format!("Invalid leaf_index: {}", transfer.leaf_index)) })?; - // Fetch ZK Merkle proof from chain - let proof_params = rpc_params![leaf_index, proof_block_hash]; - let zk_proof: Option = quantus_client - .rpc_client() - .request("zkTree_getMerkleProof", proof_params) + let zk_proof = get_zk_merkle_proof(&quantus_client, leaf_index, proof_block_hash) .await .map_err(|e| { CollectRewardsError::from(format!( @@ -411,13 +401,6 @@ pub async fn collect_rewards( )) })?; - let zk_proof = zk_proof.ok_or_else(|| { - CollectRewardsError::from(format!( - "No ZK Merkle proof found for leaf_index {}", - leaf_index - )) - })?; - // Decode transfer data from leaf let (leaf_to_account, transfer_count, _leaf_asset_id, _leaf_raw_amount) = decode_full_leaf_data(&zk_proof.leaf_data)?; From e05318b60f74cedff852db70e654cf31979c7bef Mon Sep 17 00:00:00 2001 From: illuzen Date: Tue, 11 Aug 2026 22:27:04 +0800 Subject: [PATCH 5/5] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 66bc921..91c530f 100644 --- a/README.md +++ b/README.md @@ -377,11 +377,11 @@ quantus wallet create --name my_wallet_87 --scheme ml-dsa-87 # Create with an explicit derivation path quantus wallet create --name my_wallet --derivation-path "m/44'/189189'/0'/0'/1'" -# Import from mnemonic -quantus wallet import --name recovered_wallet --mnemonic "word1 word2 ... word24" +# Import from mnemonic (phrase is read from a hidden prompt — never pass it on the CLI) +quantus wallet import --name recovered_wallet -# Import as ML-DSA-87 -quantus wallet import --name recovered_87 --scheme ml-dsa-87 --mnemonic "word1 word2 ... word24" +# Import as ML-DSA-87 (same secure prompt) +quantus wallet import --name recovered_87 --scheme ml-dsa-87 # Create from raw 32-byte seed quantus wallet from-seed --name raw_wallet --seed <64-hex-chars>