diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 44f9d50..7a0f050 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,4 +1,3 @@ ---- name: CodeQL on: @@ -32,8 +31,8 @@ jobs: # `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. + # for known CVEs in dependencies; CodeQL adds taint analysis on + # our own source. - language: rust build-mode: none steps: @@ -43,7 +42,15 @@ jobs: with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - queries: security-and-quality + # 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: 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/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 9404314..46cd125 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -103,11 +103,14 @@ 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" 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/build.rs b/build.rs index 4a4a53d..1f7dbce 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,44 @@ fn print_bin_hash(dir: &Path, filename: &str) { } } +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() { @@ -69,8 +109,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"); @@ -95,6 +136,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 +155,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/examples/basic_usage.rs b/examples/basic_usage.rs index afdf2c5..63bf126 100644 --- a/examples/basic_usage.rs +++ b/examples/basic_usage.rs @@ -35,11 +35,11 @@ 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(); + 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/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/examples/service.rs b/examples/service.rs index b58001a..6b1d5b1 100644 --- a/examples/service.rs +++ b/examples/service.rs @@ -77,8 +77,8 @@ impl WalletService { let balance = self.get_wallet_balance(name, password).await?; Ok(WalletInfo { - name: wallet_data.name, - address: wallet_data.keypair.to_account_id_ss58check(), + name: wallet_data.name.clone(), + 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 @@ -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..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 } @@ -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..b232101 100644 --- a/examples/wormhole_sdk_e2e.rs +++ b/examples/wormhole_sdk_e2e.rs @@ -149,9 +149,9 @@ 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 funder_ss58 = funder_kp.to_account_id_ss58check(); + let mut wallet = wm.load_wallet(&args.funder, &args.password)?; + let funder_kp = wallet.take_keypair(); + 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 --- @@ -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/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..becc6f2 100644 --- a/src/bins.rs +++ b/src/bins.rs @@ -10,18 +10,35 @@ //! 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}, 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"); +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"; @@ -43,21 +60,41 @@ const REQUIRED_FILES: &[&str] = &[ "config.json", ]; +#[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 /// 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. @@ -70,53 +107,265 @@ 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. +/// Safe to call multiple times. A directory attributable to a different CLI +/// version or sizing configuration (via its manifest or version marker) is +/// 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 { - let dir = resolve_bins_dir(); + 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()) { + match stale_artifact_provenance(&dir) { + Some(provenance) => { + log_print!( + "â™ģī¸ Replacing circuit artifacts in {} ({}; current CLI is {})", + dir.display(), + provenance, + env!("CARGO_PKG_VERSION") + ); + remove_stale_artifact_files(&dir)?; + }, + 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(); 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; +/// 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; + } } - // 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, + + // 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 +} + +/// 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) => { + 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 +382,63 @@ 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 +454,304 @@ 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; + // 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; + + 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] + 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() { + // #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("failed authentication"), "unexpected error: {err}"); + } + + #[cfg(unix)] + #[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}"); + } + + #[cfg(unix)] + #[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"); + } + + #[cfg(unix)] + #[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). + 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. + 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()); + } + + #[cfg(unix)] + #[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()); + } + + /// 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); + } + + /// 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() { + 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_consts.rs b/src/bins_consts.rs index 76fc4bf..f66a16b 100644 --- a/src/bins_consts.rs +++ b/src/bins_consts.rs @@ -3,6 +3,28 @@ /// 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"; + +/// 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). diff --git a/src/bins_fs.rs b/src/bins_fs.rs new file mode 100644 index 0000000..09315bd --- /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. +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)), + 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. +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())?; + 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(()) +} diff --git a/src/chain/client.rs b/src/chain/client.rs index 00f73da..5fed871 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::{ @@ -52,14 +52,53 @@ 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(['/', '?', '#']) + .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); + 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); // 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 +112,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") @@ -101,6 +140,25 @@ 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. + 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!"); Ok(QuantusClient { client, rpc_client: ws_client, node_url: node_url.to_string() }) @@ -142,6 +200,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( @@ -164,10 +233,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 @@ -255,11 +330,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(), ) } @@ -275,3 +347,64 @@ 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" + ); + } + + #[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/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/batch.rs b/src/cli/batch.rs index 523c37a..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) @@ -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); @@ -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/block.rs b/src/cli/block.rs index 54fb843..5fdc6bd 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})" + ))); + } + // 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 as u32) +} + /// 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,46 @@ 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 + ); + } + + /// 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}"); + } +} diff --git a/src/cli/common.rs b/src/cli/common.rs index efbb116..6ef8067 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -10,6 +10,17 @@ use subxt::{ pub type SubxtAccountId32 = subxt::ext::subxt_core::utils::AccountId32; +const MILLIS_PER_SECOND: u64 = 1_000; +/// 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; + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct ExecutionMode { pub finalized: bool, @@ -57,6 +68,48 @@ 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, + TransactionStage::Included => TX_STATUS_INCLUDED_TIMEOUT_SECS, + TransactionStage::Finalized => TX_STATUS_FINALIZED_TIMEOUT_SECS, + } +} + +/// 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, @@ -69,6 +122,14 @@ enum WatchedTxEvent { Dropped(String), StreamError(String), StreamEnded, + /// 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)] @@ -106,6 +167,16 @@ fn describe_watched_tx_event( "Transaction status stream ended before the transaction was {}", 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 {}. 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 {}. 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() + ))), } } @@ -116,7 +187,19 @@ fn should_check_execution_success( already_checked_for != Some(block_hash) } -type TxWatchFlow = std::ops::ControlFlow, ()>; +/// 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(), + ) + }) +} + +/// `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>, @@ -194,7 +277,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)), @@ -228,7 +311,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(()), @@ -247,19 +330,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" + ), + )), } +} - // 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" - ))) +/// 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() + ))); + }; + + 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. @@ -290,10 +417,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 @@ -330,39 +456,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 -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, _version) = - AccountId32::from_ss58check_with_version(&from_keypair.to_account_id_ss58check()).map_err( - |e| crate::error::QuantusError::NetworkError(format!("Invalid from address: {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) -} - /// Submit transaction with optional finalization check /// /// By default, returns immediately after the node accepts the transaction submission. @@ -375,6 +468,34 @@ 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, { @@ -382,151 +503,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); - 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); - // 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"); + } - 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?; - // 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); + // log_verbose!("🔍 Chain parameters:"); + // log_verbose!(" Genesis hash: {:?}", genesis_hash); + // log_verbose!(" Spec version: {}", spec_version); + // log_verbose!(" Transaction version: {}", transaction_version); - let tx_hash = tx_progress.extrinsic_hash(); + // For now, just use the default params + let params = params_builder.build(); - wait_tx_inclusion( - &mut tx_progress, - quantus_client.client(), - &tx_hash, - execution_mode.transaction_stage(), - ) - .await?; + // 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()); - 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:?}" - ))); - }, - } + 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(); + + let included_in = wait_tx_inclusion( + &mut tx_progress, + quantus_client.client(), + &tx_hash, + execution_mode.transaction_stage(), + ) + .await?; + + Ok((tx_hash, Some(included_in))) + }, + 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, None)) + }, + Err(e) => { + log_error!("❌ Failed to submit transaction: {e:?}"); + Err(e.into()) + }, } } } @@ -581,7 +656,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, @@ -592,9 +667,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 { @@ -605,9 +678,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()) }, } } @@ -618,12 +689,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(); @@ -650,76 +728,124 @@ 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); + // 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; - 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, + loop { + let elapsed_before_wait = start_time.elapsed().as_secs(); + let remaining_watch_secs = watch_timeout_secs.saturating_sub(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(wait_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; + // Reorged out of best block; resume inactivity protection until + // we see inclusion again. + waiting_for_finalization = false; + 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(()) => { + if target_stage == TransactionStage::Finalized { + waiting_for_finalization = true; + } + 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(_) => { + 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, } - }, - 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, + } else { + WatchedTxEvent::WatchDeadlineTimedOut { elapsed_secs } + } + }, + }; + (next_event, elapsed_secs) }; match describe_watched_tx_event(next_event, target_stage) { Ok(WatchDecision::Continue) | Ok(WatchDecision::WaitForFinalization) => { update_waiting_spinner(spinner.as_ref(), target_stage, elapsed_secs); }, - Ok(WatchDecision::Success) => return Ok(()), + // 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 { @@ -768,6 +894,45 @@ 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}; + + 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 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)); + + 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, @@ -776,24 +941,52 @@ 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 = 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(_) => { + 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) 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. + // 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!( - "✅ {} Preimage already exists on-chain, continuing", + "✅ {} Expected preimage already exists on-chain, continuing", "OK".bright_green().bold() ); }, - Err(e) => return Err(e), } Ok(()) } @@ -823,25 +1016,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:?}")) - })?; - - 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 - ))); - } + 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 + ))); } } } @@ -854,6 +1047,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 }; @@ -896,6 +1111,72 @@ mod tests { describe_watched_tx_event(WatchedTxEvent::StreamEnded, TransactionStage::Included,) .is_err() ); + 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!( + 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}" + ); + } + + #[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 + ); + 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); + } + } + + #[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] @@ -929,4 +1210,36 @@ 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); + } + + #[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/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 d533821..f0a9968 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 { @@ -68,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])?, }) } @@ -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}"); + } +} 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 9d718a6..f8a98ee 100644 --- a/src/cli/generic_call.rs +++ b/src/cli/generic_call.rs @@ -7,6 +7,50 @@ 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, @@ -20,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()); } @@ -142,9 +186,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) @@ -254,16 +296,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 +326,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) @@ -294,8 +346,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); @@ -327,9 +379,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:?}")))?; @@ -371,3 +421,45 @@ 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/high_security.rs b/src/cli/high_security.rs index dadded4..11b46d1 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; @@ -35,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) @@ -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/metadata.rs b/src/cli/metadata.rs index 41c1f22..d91986c 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,20 @@ 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/mod.rs b/src/cli/mod.rs index b989fb8..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) @@ -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 { @@ -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/cli/multisend.rs b/src/cli/multisend.rs index 4b5a321..1337a59 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); @@ -264,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?; @@ -390,4 +405,17 @@ 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/multisig.rs b/src/cli/multisig.rs index d7d224e..5c8b2e4 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; @@ -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 @@ -128,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 @@ -163,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 @@ -198,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 @@ -234,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) @@ -276,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 @@ -299,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 @@ -322,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 @@ -345,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 @@ -364,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 @@ -444,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 @@ -469,6 +472,59 @@ 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, +) -> crate::error::Result { + let account_id = keypair.try_to_account_id_32()?; + let account_bytes: [u8; 32] = *account_id.as_ref(); + Ok(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,9 +553,10 @@ 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( + let (tx_hash, included_in) = crate::cli::common::submit_transaction_with_inclusion_block( quantus_client, creator_keypair, create_tx, @@ -508,21 +565,39 @@ pub async fn create_multisig( ) .await?; - // If waiting, extract 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 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 @@ -1037,7 +1112,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| { @@ -1056,6 +1131,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); @@ -1087,6 +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)?; // Connect to chain let quantus_client = crate::chain::client::QuantusClient::new(node_url).await?; @@ -1104,7 +1188,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, @@ -1120,11 +1204,16 @@ 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 + // Find MultisigCreated event matching this create let multisig_events = events.find::(); @@ -1132,13 +1221,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); @@ -1191,7 +1284,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| { @@ -1210,6 +1303,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() { @@ -2945,7 +3046,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())); }; @@ -3070,3 +3171,81 @@ 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 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); + 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))); + } +} 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 b883403..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)] @@ -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 a57f96d..588e152 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, }; @@ -27,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) @@ -58,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) @@ -77,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) @@ -96,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) @@ -114,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); @@ -197,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); @@ -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..."); @@ -399,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/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/send.rs b/src/cli/send.rs index 7e7c783..6d73f32 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; @@ -271,7 +273,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( @@ -436,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); @@ -471,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( @@ -479,12 +483,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 @@ -594,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 @@ -705,47 +708,49 @@ 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(); + 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); - // 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 - - log_verbose!( - "📊 Chain limits: weight allows ~{}, size allows ~{}", - max_transfers_by_weight, - max_transfers_by_size - ); - log_verbose!("📊 Recommended batch size: {} (safe: {})", recommended_limit, safe_limit); + log_verbose!("📊 Chain batched calls limit: {} (safe: {})", batched_calls_limit, safe_limit); Ok((safe_limit, recommended_limit)) } #[cfg(test)] mod tests { - use super::{effective_tip_amount, parse_amount_with_decimals}; + use super::{ + build_batch_transfer_call, effective_tip_amount, format_balance, + 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. + 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() { @@ -793,6 +798,28 @@ 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 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/storage.rs b/src/cli/storage.rs index e379d4e..6cc0483 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,26 +456,37 @@ pub async fn count_storage_entries( )) })?; - let keys_count = keys.len() as u32; - total_count += keys_count; + total_count = accumulate_storage_key_count(total_count, keys.len())?; - log_verbose!("📊 Fetched {} keys (total: {})", keys_count, total_count); - - // 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, } } 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, @@ -447,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(), @@ -820,3 +870,56 @@ 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()); + } + + #[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/system.rs b/src/cli/system.rs index 020bd0a..5efd2c1 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,47 @@ 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 +93,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 +392,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); + } +} diff --git a/src/cli/tech_collective.rs b/src/cli/tech_collective.rs index 5db10da..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 @@ -43,12 +43,16 @@ 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, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file @@ -71,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 @@ -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/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/transfers.rs b/src/cli/transfers.rs index ca0ef6c..b7fa66f 100644 --- a/src/cli/transfers.rs +++ b/src/cli/transfers.rs @@ -5,10 +5,12 @@ //! 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 +69,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 +90,7 @@ pub async fn handle_transfers_command(cmd: TransfersCommands) -> Result<()> { limit, wallet, json, + node_url, ) .await, TransfersCommands::HashAddress { address, prefix_len } => @@ -106,6 +109,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 +190,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 +221,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 +244,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 +295,48 @@ 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/cli/update.rs b/src/cli/update.rs index 31632a6..2140218 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -4,9 +4,27 @@ //! 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. +//! +//! 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; +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 +128,76 @@ 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` +/// 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, @@ -123,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())); @@ -132,23 +220,208 @@ 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 !version_is_newer(current, &latest.version)? { + 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, 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}")) + })?; + 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, + MAX_ARCHIVE_ASSET_BYTES, + 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) +} + +/// 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( + reqwest::header::ACCEPT, + "application/octet-stream".parse().expect("static ACCEPT header"), + ) + .show_progress(show_progress); + download.download_to(&mut limited).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 +439,65 @@ 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, 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() { + 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()); + } +} diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 38f0227..542e78a 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -4,12 +4,17 @@ 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, get_new_wallet_password}, + WalletManager, DEFAULT_DERIVATION_PATH, + }, }; 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; /// Wallet management commands #[derive(Subcommand, Debug)] @@ -20,10 +25,18 @@ pub enum WalletCommands { #[arg(short, long)] name: String, - /// Password to encrypt the wallet (optional, will prompt if not provided) - #[arg(short, long)] + /// Password to encrypt the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] 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, @@ -51,12 +64,17 @@ 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 #[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 @@ -65,14 +83,18 @@ 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 to encrypt the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] 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, @@ -88,13 +110,17 @@ 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 to encrypt the wallet (unsupported on argv; use --password-file or prompt) + #[arg(short, long, hide = true)] 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 @@ -122,7 +148,7 @@ pub enum WalletCommands { wallet: Option, /// Password for the wallet - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, }, } @@ -156,14 +182,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 @@ -285,28 +320,59 @@ 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, 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..."); + 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, password.as_deref()).await + wallet_manager.create_wallet_no_derivation(&name, Some(&final_password)).await } else if derivation_path == DEFAULT_DERIVATION_PATH { - wallet_manager.create_wallet(&name, password.as_deref()).await + wallet_manager.create_wallet(&name, Some(&final_password)).await } else { wallet_manager .create_wallet_with_derivation_path( &name, - password.as_deref(), + Some(&final_password), &derivation_path, ) .await @@ -522,7 +588,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" { @@ -532,20 +598,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()); @@ -556,18 +632,31 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::Import { name, mnemonic, 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()?; - // Get mnemonic from user if not provided - let mnemonic_phrase = - if let Some(mnemonic) = mnemonic { mnemonic } else { get_mnemonic_from_user()? }; + // 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, + password_file, + allow_empty_password, + )?; - // Get password from user if not provided - let final_password = - crate::wallet::password::get_wallet_password(&name, password, None)?; + // 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 { @@ -589,6 +678,7 @@ pub async fn handle_wallet_command( ) .await }; + crate::wallet::keystore::zeroize_string(&mut mnemonic_phrase); match result { Ok(wallet_info) => { @@ -614,19 +704,34 @@ pub async fn handle_wallet_command( Ok(()) }, - WalletCommands::FromSeed { name, seed, password } => { + WalletCommands::FromSeed { name, password, password_file, allow_empty_password } => { log_print!("🌱 Creating wallet from seed..."); let wallet_manager = WalletManager::new()?; - // Get password from user if not provided - let final_password = - crate::wallet::password::get_wallet_password(&name, password, None)?; - - match wallet_manager + // 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, + password_file, + 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 - { + .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()); @@ -703,63 +808,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 + }, + }; - // 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:"); + 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() + ); + } - print!("Confirm wallet name: "); - io::stdout().flush().unwrap(); + // 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:"); - let mut input = String::new(); - io::stdin().read_line(&mut input).unwrap(); - let input = input.trim(); + print!("Confirm wallet name: "); + io::stdout().flush().unwrap(); - if input != name { - log_print!( - "{}", - "❌ Wallet name doesn't match. Deletion cancelled.".red() - ); - return Ok(()); - } - } + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); - // 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); - }, - } + 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(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); }, } @@ -784,7 +895,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` @@ -808,3 +919,113 @@ 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")] + struct TestCli { + #[command(subcommand)] + 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([ + "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"); + } +} diff --git a/src/cli/wormhole.rs b/src/cli/wormhole.rs index c1b1e82..013e9ec 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, @@ -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, @@ -54,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) } } @@ -105,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> @@ -113,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| { @@ -250,6 +301,26 @@ 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) +} + +/// 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") { @@ -504,6 +575,45 @@ 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); +} + +/// 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, + 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( @@ -523,12 +633,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:?}")) @@ -544,50 +655,48 @@ 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::() { - verification_result.success = true; - verification_result.exit_amount = Some(proof_verified.exit_amount); + log_print!(" 📝 {:?}", typed_event); } + } - // Check for ExtrinsicFailed event - 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); - } + // 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); } } } @@ -637,17 +746,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)] @@ -752,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 @@ -796,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 @@ -818,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 @@ -836,22 +945,23 @@ 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-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"], conflicts_with_all = ["wallet", "secret"])] - 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, - /// 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, + /// File containing the direct wormhole secret (32-byte hex string, alternative to --wallet + /// 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) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (only used with --wallet) @@ -862,7 +972,8 @@ 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-file or + /// --secret-file) #[arg(long)] destination: Option, @@ -870,7 +981,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, @@ -887,18 +998,18 @@ 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) - #[arg(short, long)] + #[arg(short, long, hide = true)] password: Option, /// Read password from file (only used with --wallet) @@ -924,9 +1035,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, @@ -958,6 +1069,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, @@ -1051,8 +1165,8 @@ pub async fn handle_wormhole_command( }, WormholeCommands::CollectRewards { wallet, - mnemonic, - secret, + mnemonic_file, + secret_file, password, password_file, amount, @@ -1064,8 +1178,8 @@ pub async fn handle_wormhole_command( } => run_collect_rewards( wallet, - mnemonic, - secret, + mnemonic_file, + secret_file, password, password_file, amount, @@ -1078,7 +1192,7 @@ pub async fn handle_wormhole_command( ) .await, WormholeCommands::CheckNullifier { - secret, + secret_file, wallet, password, password_file, @@ -1087,7 +1201,7 @@ pub async fn handle_wormhole_command( subsquid_url, } => run_check_nullifier( - secret, + secret_file, wallet, password, password_file, @@ -1106,9 +1220,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| { @@ -1139,11 +1255,38 @@ fn show_wormhole_address(secret_hex: 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 /// 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>> { @@ -1266,7 +1409,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, @@ -1337,7 +1480,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| { @@ -1448,7 +1594,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) ); } @@ -1478,6 +1624,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, } @@ -1515,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| { @@ -1528,33 +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(tx_in_block) => { - return Ok(( - IncludedAt::Best, - tx_in_block.block_hash(), - tx_in_block.extrinsic_hash(), - )); - }, - 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. @@ -1593,6 +1721,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); @@ -1612,6 +1741,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::() { @@ -1626,7 +1756,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<()> { @@ -1680,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| { @@ -1693,33 +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(tx_in_block) => { - return Ok(( - IncludedAt::Best, - tx_in_block.block_hash(), - tx_in_block.extrinsic_hash(), - )); - }, - 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<()> { @@ -1796,6 +1910,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().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( + 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( @@ -1833,34 +2044,31 @@ 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. +// 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], 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 @@ -1910,33 +2118,23 @@ 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 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 - }, - }; + let mut wallet_data = wallet_manager.load_wallet(wallet_name, &wallet_password)?; + 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(|| { + crate::error::QuantusError::Generic( + "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"); Ok(MultiroundWalletContext { wallet_name: wallet_name.to_string(), wallet_address, wallet_account_id, - keypair: wallet_data.keypair, + keypair: wallet_data.take_keypair(), mnemonic, }) } @@ -2017,7 +2215,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(), @@ -2030,16 +2230,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| { @@ -2053,68 +2258,51 @@ 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, 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 - 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(); + // 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(), + ) + })?; - // 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 funding_account: SubxtAccountId = + SubxtAccountId(wallet.keypair.try_to_account_id_32()?.into()); + 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) ); @@ -2128,15 +2316,16 @@ 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()); - // 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(); @@ -2153,12 +2342,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, @@ -2200,9 +2408,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)), @@ -2212,7 +2422,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()); @@ -2229,7 +2441,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 @@ -2471,11 +2683,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, ) @@ -2519,7 +2732,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 = @@ -2527,9 +2740,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 {}", @@ -2595,7 +2826,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)))? @@ -2659,8 +2890,10 @@ 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; wipe the local + // copy as soon as it has been moved into the input struct. + let mut input = wormhole_lib::ProofGenerationInput { secret, transfer_count, wormhole_address, @@ -2681,10 +2914,11 @@ 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( - &input, + &mut input, &bins_dir.join("prover.bin"), &bins_dir.join("common.bin"), ) @@ -3073,7 +3307,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); @@ -3240,7 +3474,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], @@ -3256,6 +3490,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 @@ -3318,6 +3571,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| { @@ -3338,6 +3592,29 @@ 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(finalized_block_hash) + .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()), @@ -3349,38 +3626,53 @@ 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, 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)))?; - 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)) })?; - 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)); @@ -3431,6 +3723,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; @@ -3449,11 +3742,34 @@ 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); - 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)), @@ -3463,7 +3779,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); } @@ -3487,36 +3805,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, + }); } } @@ -3563,8 +3872,8 @@ async fn run_dissolve( #[allow(clippy::too_many_arguments)] async fn run_collect_rewards( wallet_name: Option, - mnemonic_arg: Option, - secret_arg: Option, + mnemonic_file_arg: Option, + secret_file_arg: Option, password: Option, password_file: Option, amount: Option, @@ -3586,7 +3895,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 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)?; @@ -3594,26 +3903,29 @@ 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) = 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-file, or --secret-file must be provided".to_string(), )); }; - // Destination address - required when using mnemonic or secret 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".to_string(), + "--destination is required when using --mnemonic-file or --secret-file".to_string(), )); }; @@ -3799,7 +4111,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, @@ -3810,18 +4122,19 @@ 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 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 instead.".to_string(), + "Wallet does not contain a mnemonic. Use --secret-file instead.".to_string(), ) })?; @@ -3836,7 +4149,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(), )); }; @@ -3938,9 +4251,108 @@ 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 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 @@ -4465,7 +4877,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-file") || s.contains("--secret-file"), "expected missing-credential error, got: {s}" ); } @@ -4473,20 +4885,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(&[ - "--secret", - "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20", - ]) - .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", "--secret", "s"), - ("--mnemonic", "m", "--secret", "s"), + ("--wallet", "w", "--mnemonic-file", "m"), + ("--wallet", "w", "--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(); @@ -4496,4 +4904,231 @@ 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() { + 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 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 + // 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); + 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() { + 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}" + ); + }, + } + } } diff --git a/src/collect_rewards_lib.rs b/src/collect_rewards_lib.rs index c13d21d..6ed9991 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 { @@ -367,17 +368,19 @@ 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() - .await - .map_err(|e| CollectRewardsError::from(format!("Failed to get latest block: {}", e)))?; - quantus_client - .client() - .blocks() - .at(best_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 block: {}", e)))? + .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)) + })? }; let proof_block_hash = proof_block.hash(); @@ -441,7 +444,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, @@ -466,7 +471,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); @@ -496,8 +501,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); @@ -558,16 +565,21 @@ 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)?; 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, @@ -587,6 +599,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 @@ -611,35 +627,29 @@ 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 += amount; - - 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 }) } // ============================================================================ // 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| { @@ -1015,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], @@ -1072,6 +1124,14 @@ 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!( @@ -1202,4 +1262,204 @@ 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, 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, 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, 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/config/mod.rs b/src/config/mod.rs index 5ec9c21..dcc32b5 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,11 +1,17 @@ //! 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, 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] = &[ CompatibleRuntime { spec_version: 134, transaction_version: 2 }, @@ -20,3 +26,105 @@ 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"); + } + + /// 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(); + 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": EXPECTED_RUNTIME_SPEC_NAME, + "specVersion": 1, + "transactionVersion": 1, + }); + assert!(validate_runtime_version_value(&value).is_err()); + } +} diff --git a/src/error.rs b/src/error.rs index d8532c9..9d988e5 100644 --- a/src/error.rs +++ b/src/error.rs @@ -56,14 +56,26 @@ pub enum WalletError { #[error("Invalid password (or corrupted wallet file)")] InvalidPassword, + #[error("Invalid wallet address")] + InvalidAddress, + + #[error("Invalid wallet name")] + InvalidName, + #[error("Key generation failed")] KeyGeneration, + #[error("Invalid public key")] + InvalidPublicKey, + #[error("Encryption failed: {0}")] Encryption(String), #[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/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, }; diff --git a/src/subsquid/client.rs b/src/subsquid/client.rs index 7b0b705..963d4bf 100644 --- a/src/subsquid/client.rs +++ b/src/subsquid/client.rs @@ -81,8 +81,30 @@ 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 +146,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 +251,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 +278,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 +287,102 @@ 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 { + // 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; + } + + if lo != hi { + let mid = lo + (hi - lo) / 2; + stack.push((mid + 1, hi)); + stack.push((lo, mid)); + 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!( + "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?; + + // 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 {} transfers at offset {} of {} for block {}, expected {}", + page.len(), + offset, + total_count, + lo, + expected + ))); + } + + 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 +560,18 @@ impl SubsquidClient { #[cfg(test)] mod tests { use super::*; + use serde_json::{json, Value}; + use std::{ + collections::HashSet, + io::{Read, Write}, + net::{TcpListener, TcpStream}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, Mutex, + }, + thread, + time::Duration, + }; #[test] fn test_transfer_query_params_builder() { @@ -483,13 +587,247 @@ 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" + ); } } diff --git a/src/wallet/keystore.rs b/src/wallet/keystore.rs index a3af4c1..1bd8b12 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,18 +23,210 @@ use aes_gcm::{ use argon2::{Algorithm, Argon2, Params, PasswordHash, PasswordHasher, Version}; use rand::{rng, RngCore}; -use std::path::Path; +use std::{ + collections::HashSet, + fmt, + 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; +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(())) +} + +/// 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 + // 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")) +} + +#[cfg(unix)] +fn set_no_follow(options: &mut OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + // 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(unix))] +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); + + #[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) +} + +#[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 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 -#[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 { @@ -47,12 +239,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)?, }) } @@ -71,17 +262,22 @@ 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 { + // 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; - 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())) } /// Convert to subxt Signer for use @@ -93,16 +289,15 @@ 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()) } } /// 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) @@ -119,7 +314,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, @@ -128,6 +323,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, @@ -139,31 +369,142 @@ 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). + // Public keystore API; migration/create paths use specialized helpers. + #[allow(dead_code)] 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()))?; + 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)?; - // 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)?; - 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<()> { + 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)?; + // 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)) } + 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(); @@ -178,7 +519,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()); + } } } } @@ -188,14 +531,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 @@ -209,8 +575,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 @@ -219,15 +585,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 @@ -239,7 +608,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 @@ -257,6 +626,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)?; @@ -264,13 +639,28 @@ 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 decrypted_data = cipher + 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()) .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 + // wallet file is accepted as intact. + 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(), + ) + .into()); + } Ok(wallet_data) } @@ -279,37 +669,43 @@ 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 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)?; - 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)?; - 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 version != Version::V0x13 { + return Err(WalletError::Decryption.into()); + } + 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 = 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 .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` @@ -321,6 +717,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)] @@ -331,6 +753,42 @@ 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 @@ -401,8 +859,9 @@ 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!( @@ -457,7 +916,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"); @@ -489,8 +949,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() @@ -512,9 +972,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"); @@ -534,7 +994,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 @@ -543,12 +1003,36 @@ mod tests { ]; for invalid_addr in invalid_addresses { - let result = + let panicked = std::panic::catch_unwind(|| QuantumKeyPair::ss58_to_account_id(invalid_addr)); - assert!(result.is_err(), "Should panic on invalid address: {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)); @@ -575,22 +1059,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] @@ -636,22 +1113,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] @@ -694,29 +1164,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] @@ -775,7 +1231,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(); @@ -787,7 +1244,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![], @@ -817,7 +1274,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(); @@ -850,6 +1307,139 @@ 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.try_to_account_id_ss58check().expect("valid keypair"); + 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:?}" + ); + } + + 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 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, plaintext.as_ref()).expect("encrypt"); + EncryptedWallet { + name: data.name.clone(), + address: data.keypair.try_to_account_id_ss58check().expect("valid keypair"), + 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(), + } + } + + /// 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 (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); + 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, + // 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] }; + assert!( + 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_32(), + 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"); @@ -859,7 +1449,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(&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 @@ -897,4 +1487,226 @@ 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, 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_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(); + 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" + ); + } + + /// #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, 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" + ); + } + + /// 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() { + 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); + } + + /// #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() { + 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"); + + // ':' 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!( + 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 12c6b8d..8972db2 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::{ @@ -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 }) } @@ -62,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()); } @@ -73,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); @@ -81,7 +99,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(), @@ -94,7 +112,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(), @@ -107,8 +125,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()); } @@ -130,7 +148,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(), @@ -140,9 +158,11 @@ 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_wallet(&encrypted_wallet)?; + keystore.save_new_wallet(&encrypted_wallet)?; Ok(WalletInfo { name: name.to_string(), @@ -159,7 +179,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 @@ -169,17 +194,36 @@ 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 + let Some(encrypted_wallet) = (match keystore.load_wallet(&name) { + Ok(wallet) => wallet, + Err(_) => continue, + }) else { + continue; + }; + + // 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.clone(), + 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(), // Derivation path is encrypted - }; - wallets.push(wallet_info); - } + 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); } // Sort by creation date (newest first) @@ -204,8 +248,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()); } @@ -214,10 +258,12 @@ impl WalletManager { rng().fill_bytes(&mut seed); let sensitive_seed = SensitiveBytes32::from(&mut seed); let mnemonic = generate_mnemonic(sensitive_seed).map_err(|_| WalletError::KeyGeneration)?; - let seed64 = + keystore::zeroize_bytes(&mut seed); + 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 @@ -227,7 +273,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(), @@ -240,7 +286,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(), @@ -258,8 +304,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()); } @@ -279,7 +325,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(), @@ -292,7 +338,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(), @@ -311,8 +357,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()); } @@ -329,7 +375,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(), @@ -342,7 +388,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(), @@ -360,8 +406,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()); } @@ -372,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); @@ -394,7 +443,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(), @@ -407,7 +456,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(), @@ -427,35 +476,50 @@ 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, + 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(_) => { - // 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.try_to_account_id_ss58check()?; + Ok(Some(WalletInfo { + name: wallet_data.name.clone(), + 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) @@ -476,16 +540,14 @@ 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)?; + if !keystore.save_wallet_if_current(&migrated, &encrypted_wallet)? { + return Err(QuantusError::Generic( + "wallet changed during legacy migration".to_string(), + )); } } @@ -498,15 +560,46 @@ impl WalletManager { keystore.delete_wallet(name) } - /// Find wallet by name and return its address - pub fn find_wallet_address(&self, name: &str) -> Result> { + /// 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(WalletAddressLookup::Address( + wallet_data.keypair.try_to_account_id_ss58check()?, + )), + Err(crate::error::QuantusError::Wallet( + WalletError::InvalidPassword | WalletError::Integrity(_), + )) => 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, } } } @@ -518,9 +611,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)] @@ -532,13 +624,54 @@ 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; @@ -593,6 +726,28 @@ 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; @@ -677,15 +832,16 @@ 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"); 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); } @@ -914,10 +1070,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) @@ -935,14 +1096,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 @@ -972,4 +1133,176 @@ 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] + 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; + + 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, + WalletAddressLookup::Protected, + "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}; + + 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| { + w.address == "[Encrypted]" || + 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" + ); + } } diff --git a/src/wallet/password.rs b/src/wallet/password.rs index 5a613fc..10115fd 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -1,46 +1,131 @@ 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(()) +} + +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(()) +} + +fn read_password_file(file_path: &str) -> Result { + log_verbose!("🔑 Reading password from file: {}", file_path); + validate_password_file_permissions(file_path)?; + 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) +} + +/// 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"); + return Some(env_password); + } + + 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 { - // 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. + reject_raw_cli_password(&password)?; - // Option 2: Read password from file if provided if let Some(file_path) = password_file { - log_verbose!("🔑 Reading password from file: {}", 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 - 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 read_password_file(&file_path); } - // 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); + 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() { @@ -48,17 +133,50 @@ 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()); - 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 @@ -69,3 +187,102 @@ pub fn get_password_from_user(prompt: &str) -> Result { })?; Ok(password) } + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + + #[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 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::*; + use std::{fs, 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"); + } + } +} diff --git a/src/wormhole_lib.rs b/src/wormhole_lib.rs index 52a63af..fbac346 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,41 @@ 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); +} + +/// 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); + } +} + /// Input data for generating a wormhole proof. /// All fields are raw bytes - no chain client required. #[derive(Debug, Clone)] @@ -177,30 +217,78 @@ 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 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 @@ -217,66 +305,41 @@ 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(), - }; - - 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 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 prover_with_inputs = prover - .commit(&circuit_inputs) + .commit(&circuit_inputs.0) .map_err(|e| WormholeLibError::from(format!("Failed to commit inputs: {}", e)))?; let proof = prover_with_inputs @@ -322,4 +385,107 @@ 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 mut 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( + &mut 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" + ); + } + + /// 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" + ); + } }