diff --git a/README.md b/README.md index 35e91fa..d54577a 100644 --- a/README.md +++ b/README.md @@ -584,6 +584,12 @@ quantus wallet import --name recovered_wallet --mnemonic-file ./mnemonic.txt # Import as ML-DSA-87 (same secure prompt) quantus wallet import --name recovered_87 --scheme ml-dsa-87 +# Recovering an existing wallet? Show every address the phrase maps to and pick one +quantus wallet import --name recovered_wallet --scan + +# Air-gapped machine: never contact a node +quantus wallet import --name recovered_wallet --scan --offline + # Create from raw 32-byte seed quantus wallet from-seed --name raw_wallet --seed <64-hex-chars> @@ -597,6 +603,29 @@ quantus wallet view --name my_wallet quantus wallet export --name my_wallet --format mnemonic ``` +#### Recovering a wallet that shows no funds + +The same recovery phrase maps to a different address for each signature scheme +and derivation path, and the CLI's defaults have changed: + +| Created with | Scheme | HD path | +|---|---|---| +| CLI v2.0 and later (current default) | ML-DSA-65 | `m/44'/189189'/0'/0'/1'` | +| CLI v1.x | ML-DSA-87 | `m/44'/189189'/0'/0'/0'` | +| `--no-derivation` (either scheme) | — | master seed | + +A plain `wallet import` uses the current default, so a phrase from an older +wallet imports "successfully" onto a brand-new, empty address. After a plain +import the CLI checks the address on-chain (when a node is reachable) and warns +if it has no current balance or nonce. `--scan` derives all of the combinations +above, shows the current balance and nonce of each, and lets you pick; it also prints the +explicit `--scheme`/`--derivation-path` flags for next time. For even older +testnet addresses, use `quantus airdrop check`. + +Wallet commands never require a node. The on-chain check waits at most a few +seconds and is skipped when nothing answers; pass `--offline` to not attempt it +at all. + --- ### Cold Wallets (Keystone / Quantus Cold Wallet App) diff --git a/src/chain/client.rs b/src/chain/client.rs index d671f31..c6a8dd3 100644 --- a/src/chain/client.rs +++ b/src/chain/client.rs @@ -442,8 +442,9 @@ impl subxt::tx::Signer for QuantusSigner { } } +/// In-process mock node for tests in this crate that need a real `QuantusClient`. #[cfg(test)] -mod tests { +pub(crate) mod test_support { use super::*; use codec::Encode; use jsonrpsee::{ @@ -453,13 +454,13 @@ mod tests { use serde_json::json; use std::sync::Mutex; - const OLD_RUNTIME: RuntimeVersion = + pub(crate) const OLD_RUNTIME: RuntimeVersion = RuntimeVersion { spec_version: 144, transaction_version: 3 }; - const NEW_RUNTIME: RuntimeVersion = + pub(crate) const NEW_RUNTIME: RuntimeVersion = RuntimeVersion { spec_version: 148, transaction_version: 6 }; - const GENESIS: H256 = H256([0x01; 32]); - const FINALIZED: H256 = H256([0x44; 32]); - const HEAD: H256 = H256([0x48; 32]); + pub(crate) const GENESIS: H256 = H256([0x01; 32]); + pub(crate) const FINALIZED: H256 = H256([0x44; 32]); + pub(crate) const HEAD: H256 = H256([0x48; 32]); fn runtime_at(hash: H256) -> RuntimeVersion { if hash == HEAD { @@ -469,14 +470,38 @@ mod tests { } } + pub(crate) struct MockNode { + pub(crate) url: String, + pub(crate) metadata_requests: Arc>>, + _handle: ServerHandle, + } + /// A node caught mid-upgrade the way Heisenberg is for ~20 minutes after every enactment: /// the head runs the new runtime, the finalized block still runs the old one. Records the /// block named by every metadata request. - async fn mock_node() -> (String, Arc>>, ServerHandle) { + /// + /// With `stall_storage`, the node accepts connections and answers everything + /// `QuantusClient::new` needs, but never responds to `state_getStorage`: a node that + /// connects fine and then stops answering. + pub(crate) async fn mock_node(stall_storage: bool) -> MockNode { let metadata_requests = Arc::new(Mutex::new(Vec::new())); let server = Server::builder().build("127.0.0.1:0").await.expect("bind mock node"); let url = format!("ws://{}", server.local_addr().expect("mock node address")); let mut module = RpcModule::new(metadata_requests.clone()); + if stall_storage { + module + .register_async_method("state_getStorage", |_, _, _| async { + std::future::pending::, ErrorObjectOwned>>().await + }) + .expect("register"); + } else { + // Empty state: every storage read is "no value". + module + .register_method("state_getStorage", |_, _, _| { + Ok::, ErrorObjectOwned>(None) + }) + .expect("register"); + } module .register_method("chain_getBlockHash", |params, _, _| { let number: Option = params.sequence().optional_next()?; @@ -517,7 +542,22 @@ mod tests { )) }) .expect("register"); - (url, metadata_requests, server.start(module)) + MockNode { url, metadata_requests, _handle: server.start(module) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex; + use test_support::{ + mock_node as start_mock_node, MockNode, FINALIZED, GENESIS, HEAD, NEW_RUNTIME, OLD_RUNTIME, + }; + + async fn mock_node() -> (String, Arc>>, MockNode) { + let node = start_mock_node(false).await; + (node.url.clone(), node.metadata_requests.clone(), node) } #[tokio::test] diff --git a/src/cli/wallet.rs b/src/cli/wallet.rs index 4ea825d..01c9682 100644 --- a/src/cli/wallet.rs +++ b/src/cli/wallet.rs @@ -1,6 +1,6 @@ //! `quantus wallet` subcommand - wallet operations use crate::{ - chain::quantus_subxt, + chain::{client::QuantusClient, quantus_subxt}, cli::address_format::QuantusSS58, error::QuantusError, log_error, log_print, log_success, log_verbose, @@ -47,7 +47,7 @@ pub enum WalletCommands { #[arg(long)] no_derivation: bool, - /// Dilithium signature scheme (default: ml-dsa-65) + /// Dilithium signature scheme (default: ml-dsa-65; CLI v1.x defaulted to ml-dsa-87) #[arg(long, value_enum, default_value_t = DilithiumScheme::MlDsa65)] scheme: DilithiumScheme, }, @@ -115,9 +115,19 @@ pub enum WalletCommands { #[arg(long)] no_derivation: bool, - /// Dilithium signature scheme (default: ml-dsa-65) + /// Dilithium signature scheme. Wallets created with CLI v1.x defaulted to + /// ml-dsa-87 (HD path …/0'); if an import lands on an empty address, use --scan #[arg(long, value_enum, default_value_t = DilithiumScheme::MlDsa65)] scheme: DilithiumScheme, + + /// Show every address this phrase maps to under past and current CLI defaults + /// (with current balance and nonce when a node is reachable) and pick one interactively + #[arg(long, conflicts_with_all = ["derivation_path", "no_derivation", "scheme"])] + scan: bool, + + /// Never contact the node (air-gapped use); skips the on-chain history check + #[arg(long)] + offline: bool, }, /// Import a cold (hardware / air-gapped) wallet as watch-only @@ -393,6 +403,311 @@ fn checkphrase_line(address: &str) -> Option { .then(|| crate::wallet::checkphrase::checkphrase(address).bright_blue().to_string()) } +/// Upper bound on waiting for a node during best-effort wallet checks. Wallet +/// commands must stay usable on air-gapped machines, so a missing node is never +/// an error here; it only means the on-chain information is skipped. +/// +/// The deadline covers the whole probe (connect, every account read, token +/// lookup), not just the connection: a node that accepts the connection and +/// then stops answering must not hold the import for the RPC client's own +/// 30-second request timeout, once per read. +const NODE_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +/// `System::Account` fields that show whether an address was ever used. +struct AccountActivity { + nonce: u32, + free: u128, + reserved: u128, +} + +impl AccountActivity { + fn is_used(&self) -> bool { + self.nonce > 0 || self.free > 0 || self.reserved > 0 + } +} + +/// Outcome of the best-effort on-chain probe. Reads that did not finish +/// before the deadline are `None`; whatever did finish is kept. +struct OnChainProbe { + /// The node accepted a connection (so missing entries mean it stopped + /// answering, not that there is no node). + reachable: bool, + /// Every requested read finished before the deadline. + complete: bool, + token: Option<(String, u8)>, + activity: Vec>, +} + +impl OnChainProbe { + fn unreachable(len: usize) -> Self { + Self { + reachable: false, + complete: false, + token: None, + activity: (0..len).map(|_| None).collect(), + } + } +} + +/// Look up `addresses` on-chain under one overall `deadline`. Never fails: +/// a missing or stalled node only leaves entries unfilled. +async fn probe_on_chain( + node_url: &str, + addresses: &[&str], + deadline: std::time::Duration, +) -> OnChainProbe { + let mut probe = OnChainProbe::unreachable(addresses.len()); + + // The future borrows `probe` so partial results survive the timeout. + let work = async { + let quantus_client = match QuantusClient::new(node_url).await { + Ok(client) => client, + Err(e) => { + log_verbose!("Node unreachable, skipping on-chain check: {}", e); + return; + }, + }; + probe.reachable = true; + + for (i, address) in addresses.iter().enumerate() { + match fetch_account_activity(&quantus_client, address).await { + Ok(activity) => probe.activity[i] = Some(activity), + Err(e) => log_verbose!("On-chain lookup failed for {}: {}", address, e), + } + } + // Only needed to format a balance, so skip the extra round-trip when + // nothing was found. + if probe.activity.iter().flatten().any(AccountActivity::is_used) { + probe.token = chain_token(&quantus_client).await; + } + probe.complete = true; + }; + + if tokio::time::timeout(deadline, work).await.is_err() { + log_verbose!( + "On-chain check did not finish within {}s; continuing with what was read", + deadline.as_secs() + ); + } + probe +} + +async fn fetch_account_activity( + quantus_client: &QuantusClient, + address: &str, +) -> crate::error::Result { + let (account_id_sp, _) = SpAccountId32::from_ss58check_with_version(address) + .map_err(|e| QuantusError::NetworkError(format!("Invalid SS58 address: {e:?}")))?; + let account_bytes: [u8; 32] = *account_id_sp.as_ref(); + let account_id = subxt::ext::subxt_core::utils::AccountId32::from(account_bytes); + + let storage_addr = quantus_subxt::api::storage().system().account(account_id); + let latest_block_hash = quantus_client.get_latest_block().await?; + let info = quantus_client + .client() + .storage() + .at(latest_block_hash) + .fetch_or_default(&storage_addr) + .await + .map_err(|e| QuantusError::NetworkError(format!("Failed to fetch account info: {e:?}")))?; + + Ok(AccountActivity { nonce: info.nonce, free: info.data.free, reserved: info.data.reserved }) +} + +/// Token symbol and decimals, if the node can report them. +async fn chain_token(quantus_client: &QuantusClient) -> Option<(String, u8)> { + crate::cli::send::get_chain_properties(quantus_client).await.ok() +} + +fn describe_activity(activity: &AccountActivity, token: Option<&(String, u8)>) -> String { + let total = activity.free.saturating_add(activity.reserved); + let balance = match token { + Some((symbol, decimals)) => + format!("{} {symbol}", crate::cli::send::format_balance(total, *decimals)), + None => format!("{total} (raw units)"), + }; + format!("balance {balance}, nonce {}", activity.nonce) +} + +/// After a plain import, tell the user whether the derived address has ever +/// been used on-chain. Historical CLI defaults derive different addresses from +/// the same phrase, so a "successful" import can silently land on an empty +/// one. Best-effort: with no node reachable only a short note is printed. +async fn report_import_history(node_url: &str, address: &str) { + let probe = probe_on_chain(node_url, &[address], NODE_PROBE_TIMEOUT).await; + if !probe.reachable { + log_print!( + "\n{}", + "ℹ️ Node unreachable; skipped the on-chain history check. If you expected funds on \ + this wallet, re-run with --scan to compare derivations. Use --offline to silence \ + this note on air-gapped machines." + .dimmed() + ); + return; + } + + let Some(activity) = probe.activity.first().and_then(Option::as_ref) else { + log_print!( + "\n{}", + "ℹ️ The node stopped answering before the on-chain history check finished. If you \ + expected funds on this wallet, re-run with --scan to compare derivations." + .dimmed() + ); + return; + }; + + if activity.is_used() { + log_success!( + "On-chain: {}", + describe_activity(activity, probe.token.as_ref()).bright_green() + ); + return; + } + + log_print!("\n{}", "⚠️ No current balance or nonce found for this address.".bright_yellow()); + log_print!( + "{}", + " If you are recovering an existing wallet, the funds may be under a different \ + derivation: wallets created with CLI v1.x used --scheme ml-dsa-87 (HD path …/0'), and \ + some tools use --no-derivation." + .dimmed() + ); + log_print!( + " Re-run with {} to compare every derivation, or {} to find older testnet addresses.", + "quantus wallet import --scan".bright_green(), + "quantus airdrop check".bright_green() + ); +} + +/// `--scan` ends in an interactive choice, so it must be refused up front, +/// before any password or recovery-phrase prompt is shown: `rpassword` reads +/// the controlling terminal even when stdin is redirected, so checking later +/// would leave a non-interactive invocation parked at a hidden prompt. +fn ensure_scan_is_interactive() -> crate::error::Result<()> { + use std::io::IsTerminal; + + if io::stdin().is_terminal() { + return Ok(()); + } + Err(QuantusError::Generic( + "--scan needs an interactive terminal to choose a derivation; pass --scheme, \ + --derivation-path or --no-derivation explicitly instead" + .to_string(), + )) +} + +/// Derive every candidate address for a phrase, show which ones have been used +/// (when a node is reachable), and let the user choose. Works fully offline; +/// the chain lookup only adds information and is bounded by one deadline, so +/// the locally derived candidates are always shown. +async fn choose_recovery_candidate( + mnemonic: &str, + node_url: &str, + offline: bool, +) -> crate::error::Result { + ensure_scan_is_interactive()?; + + let candidates = crate::wallet::recovery_candidates(mnemonic)?; + + let probe = if offline { + OnChainProbe::unreachable(candidates.len()) + } else { + let addresses: Vec<&str> = candidates.iter().map(|c| c.address.as_str()).collect(); + probe_on_chain(node_url, &addresses, NODE_PROBE_TIMEOUT).await + }; + let token = probe.token.as_ref(); + let activity = &probe.activity; + + log_print!("\nAddresses this recovery phrase maps to:\n"); + for (i, (candidate, activity)) in candidates.iter().zip(activity).enumerate() { + log_print!( + "{}. {} {}", + (i + 1).to_string().bright_yellow(), + candidate.scheme.key_type_label().bright_green(), + format!("({})", candidate.note).dimmed() + ); + log_print!(" Path: {}", candidate.derivation_label().bright_magenta()); + log_print!(" Address: {}", candidate.address.bright_cyan()); + if let Some(checkphrase) = checkphrase_line(&candidate.address) { + log_print!(" Checkphrase: {}", checkphrase); + } + match activity { + Some(activity) if activity.is_used() => + log_print!(" On-chain: {}", describe_activity(activity, token).bright_green()), + Some(_) => log_print!(" On-chain: {}", "no current balance or nonce".dimmed()), + None if probe.reachable => + log_print!(" On-chain: {}", "unknown (no answer)".dimmed()), + None => {}, + } + log_print!(); + } + if offline { + log_print!("{}", "Offline: compare the address or checkphrase with your records.".dimmed()); + } else if !probe.reachable { + log_print!( + "{}", + "Node unreachable: on-chain history not shown. Compare the address or checkphrase \ + with your records." + .dimmed() + ); + } else if !probe.complete { + log_print!( + "{}", + "The node stopped answering before every address was checked; entries marked \ + unknown were not verified." + .dimmed() + ); + } + + let used: Vec = activity + .iter() + .enumerate() + .filter_map(|(i, a)| a.as_ref().filter(|a| a.is_used()).map(|_| i)) + .collect(); + if used.len() > 1 { + log_print!("{}", "Several of these addresses have been used.".bright_yellow()); + } + let default_choice = used.first().copied().unwrap_or(0); + + print!( + "Import which one? [1-{}, Enter = {}, q to abort]: ", + candidates.len(), + default_choice + 1 + ); + io::stdout().flush()?; + let mut input = String::new(); + if io::stdin().read_line(&mut input)? == 0 { + return Err(QuantusError::Generic("Import aborted: no selection made".to_string())); + } + let input = input.trim(); + + let index = if input.is_empty() { + default_choice + } else if input.eq_ignore_ascii_case("q") { + return Err(QuantusError::Generic("Import aborted".to_string())); + } else { + match input.parse::() { + Ok(n) if (1..=candidates.len()).contains(&n) => n - 1, + _ => + return Err(QuantusError::Generic(format!( + "'{input}' is not a choice between 1 and {}", + candidates.len() + ))), + } + }; + + let chosen = candidates[index].clone(); + log_print!( + "\n{}", + format!( + "Next time, import this directly with: quantus wallet import {}", + chosen.cli_flags() + ) + .dimmed() + ); + Ok(chosen) +} + /// Handle wallet commands pub async fn handle_wallet_command( command: WalletCommands, @@ -740,9 +1055,16 @@ pub async fn handle_wallet_command( derivation_path, no_derivation, scheme, + scan, + offline, } => { log_print!("📥 Importing wallet..."); + // Refuse a doomed --scan before any secret is read or prompted for. + if scan { + ensure_scan_is_interactive()?; + } + let wallet_manager = WalletManager::new()?; // Phrase never appears in process argv: file or hidden prompt. @@ -769,7 +1091,20 @@ pub async fn handle_wallet_command( None => get_mnemonic_from_user()?, }; - let result = if no_derivation { + let result = if scan { + match choose_recovery_candidate(&mnemonic_phrase, node_url, offline).await { + Ok(candidate) => + wallet_manager + .import_wallet_for_candidate( + &name, + &mnemonic_phrase, + Some(&final_password), + &candidate, + ) + .await, + Err(e) => Err(e), + } + } else if no_derivation { wallet_manager .import_wallet_no_derivation_with_scheme( &name, @@ -810,6 +1145,10 @@ pub async fn handle_wallet_command( wallet_info.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string().dimmed() ); log_success!("✅ Wallet imported successfully!"); + // --scan already showed on-chain history for every candidate. + if !scan && !offline { + report_import_history(node_url, &wallet_info.address).await; + } }, Err(e) => { log_error!("{}", format!("❌ Failed to import wallet: {e}").red()); @@ -1184,21 +1523,13 @@ mod tests { let mnemonic_path = home.path().join("mnemonic.txt"); write_mnemonic_to_protected_file(&mnemonic_path, &mnemonic).expect("write mnemonic file"); - handle_wallet_command( - WalletCommands::Import { - name: "imported".to_string(), - password: None, - password_file: None, - allow_empty_password: true, - mnemonic_file: Some(mnemonic_path.to_string_lossy().into_owned()), - derivation_path: None, - no_derivation: false, - scheme: DilithiumScheme::MlDsa65, - }, - "ws://127.0.0.1:9944", - ) - .await - .expect("import from mnemonic file"); + let mut args = import_args("imported", Some(mnemonic_path.to_string_lossy().into_owned())); + if let WalletCommands::Import { offline, .. } = &mut args { + *offline = true; + } + handle_wallet_command(args, "ws://127.0.0.1:9944") + .await + .expect("import from mnemonic file"); let imported = manager.load_wallet("imported", "").expect("load imported"); assert_eq!( @@ -1208,6 +1539,192 @@ mod tests { assert_eq!(imported.mnemonic.as_deref(), Some(mnemonic.trim())); } + #[test] + fn wallet_import_scan_conflicts_with_explicit_derivation_flags() { + let parsed = TestCli::try_parse_from([ + "quantus", + "wallet", + "import", + "--name", + "poc", + "--scan", + "--offline", + ]) + .expect("--scan --offline must parse"); + match parsed.command { + crate::cli::Commands::Wallet(WalletCommands::Import { scan, offline, .. }) => { + assert!(scan); + assert!(offline); + }, + other => panic!("expected Wallet(Import), got {other:?}"), + } + + for conflicting in [ + vec!["--scheme", "ml-dsa-87"], + vec!["--no-derivation"], + vec!["--derivation-path", "m/44'/189189'/0'/0'/0'"], + ] { + let mut args = vec!["quantus", "wallet", "import", "--name", "poc", "--scan"]; + args.extend(conflicting.iter()); + assert!( + TestCli::try_parse_from(&args).is_err(), + "--scan must conflict with {conflicting:?}" + ); + } + } + + const TEST_MNEMONIC: &str = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven"; + + fn import_args(name: &str, mnemonic_file: Option) -> WalletCommands { + WalletCommands::Import { + name: name.to_string(), + password: None, + password_file: None, + allow_empty_password: true, + mnemonic_file, + derivation_path: None, + no_derivation: false, + scheme: DilithiumScheme::MlDsa65, + scan: false, + offline: false, + } + } + + #[tokio::test] + #[serial] + async fn wallet_import_scan_without_terminal_fails_before_any_prompt() { + let home = temp_home(); + std::env::remove_var("QUANTUS_WALLET_PASSWORD_SCANNED"); + + // No password source at all: without the up-front terminal check this + // would park at the hidden password prompt (rpassword reads the + // controlling tty, not stdin), so a regression shows up as a timeout + // rather than a hang. The mnemonic file must not be read either, so + // point at a path that does not exist. + let missing_mnemonic = home.path().join("does-not-exist.txt"); + let args = WalletCommands::Import { + name: "scanned".to_string(), + password: None, + password_file: None, + allow_empty_password: false, + mnemonic_file: Some(missing_mnemonic.to_string_lossy().into_owned()), + derivation_path: None, + no_derivation: false, + scheme: DilithiumScheme::MlDsa65, + scan: true, + offline: true, + }; + + let result = tokio::time::timeout( + std::time::Duration::from_secs(5), + handle_wallet_command(args, "ws://127.0.0.1:9"), + ) + .await + .expect("--scan without a terminal must fail immediately, not wait at a prompt"); + let err = result.expect_err("--scan without a terminal must fail"); + assert!(err.to_string().contains("--scan needs an interactive terminal"), "{err}"); + + let manager = WalletManager::new().expect("wallet manager"); + assert_eq!(manager.wallet_type("scanned").expect("probe"), None); + } + + /// A node that accepts the connection and then never answers storage reads + /// must not hold the probe for the RPC client's per-request timeout. + #[tokio::test] + async fn probe_on_chain_is_bounded_when_node_connects_then_stalls() { + let node = crate::chain::client::test_support::mock_node(true).await; + let candidates = crate::wallet::recovery_candidates(TEST_MNEMONIC).expect("candidates"); + let addresses: Vec<&str> = candidates.iter().map(|c| c.address.as_str()).collect(); + + let deadline = std::time::Duration::from_secs(2); + let started = std::time::Instant::now(); + let probe = probe_on_chain(&node.url, &addresses, deadline).await; + let elapsed = started.elapsed(); + + assert!(probe.reachable, "connection succeeded, so the node counts as reachable"); + assert!(!probe.complete, "stalled reads must be reported as incomplete"); + assert_eq!(probe.activity.len(), 4); + assert!(probe.activity.iter().all(Option::is_none), "no read can have finished"); + assert!(probe.token.is_none()); + assert!( + elapsed < deadline + std::time::Duration::from_secs(3), + "probe must return shortly after its deadline, took {elapsed:?}" + ); + } + + #[tokio::test] + async fn probe_on_chain_reads_empty_accounts_from_a_healthy_node() { + let node = crate::chain::client::test_support::mock_node(false).await; + let candidates = crate::wallet::recovery_candidates(TEST_MNEMONIC).expect("candidates"); + let addresses: Vec<&str> = candidates.iter().map(|c| c.address.as_str()).collect(); + + let probe = probe_on_chain(&node.url, &addresses, NODE_PROBE_TIMEOUT).await; + assert!(probe.reachable); + assert!(probe.complete); + assert_eq!(probe.activity.len(), 4); + for activity in &probe.activity { + let activity = activity.as_ref().expect("healthy node answers every read"); + assert!(!activity.is_used(), "default state means no current balance or nonce"); + } + } + + #[tokio::test] + async fn probe_on_chain_marks_unreachable_node() { + let probe = probe_on_chain("ws://127.0.0.1:9", &["qzabc"], NODE_PROBE_TIMEOUT).await; + assert!(!probe.reachable); + assert!(!probe.complete); + assert_eq!(probe.activity.len(), 1); + assert!(probe.activity[0].is_none()); + } + + /// End-to-end: a plain import against a node that connects and then stops + /// answering still succeeds, and returns well inside the client's 30s + /// request timeout (previously ~31s). + #[tokio::test] + #[serial] + async fn wallet_import_completes_promptly_when_node_connects_then_stalls() { + let home = temp_home(); + std::env::remove_var("QUANTUS_WALLET_PASSWORD_STALLED"); + let node = crate::chain::client::test_support::mock_node(true).await; + + let mnemonic_path = home.path().join("mnemonic.txt"); + write_mnemonic_to_protected_file(&mnemonic_path, TEST_MNEMONIC).expect("write mnemonic"); + + let started = std::time::Instant::now(); + handle_wallet_command( + import_args("stalled", Some(mnemonic_path.to_string_lossy().into_owned())), + &node.url, + ) + .await + .expect("import must succeed even though the node stalls"); + let elapsed = started.elapsed(); + + assert!( + elapsed < NODE_PROBE_TIMEOUT + std::time::Duration::from_secs(5), + "import must be bounded by the probe deadline, took {elapsed:?}" + ); + let manager = WalletManager::new().expect("wallet manager"); + let imported = manager.load_wallet("stalled", "").expect("wallet written"); + assert_eq!(imported.keypair.scheme, DilithiumScheme::MlDsa65); + } + + #[test] + fn describe_activity_formats_with_and_without_token_info() { + let activity = + AccountActivity { nonce: 3, free: 1_500_000_000_000, reserved: 500_000_000_000 }; + assert!(activity.is_used()); + assert_eq!( + describe_activity(&activity, Some(&("QUAN".to_string(), 12))), + "balance 2 QUAN, nonce 3" + ); + assert_eq!( + describe_activity(&activity, None), + "balance 2000000000000 (raw units), nonce 3" + ); + assert!(!AccountActivity { nonce: 0, free: 0, reserved: 0 }.is_used()); + assert!(AccountActivity { nonce: 0, free: 0, reserved: 1 }.is_used()); + } + #[test] fn wallet_from_seed_rejects_seed_cli_argument() { let result = TestCli::try_parse_from([ diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index e065ba5..dc02263 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -76,6 +76,72 @@ pub fn default_derivation_path(scheme: DilithiumScheme) -> &'static str { } } +/// One scheme/path combination a recovery phrase can be imported under. +/// +/// The CLI's import defaults have changed between releases (ML-DSA-87 at +/// `…/0'` before v2.0, ML-DSA-65 at `…/1'` since), so a single phrase maps to +/// several distinct addresses. `recovery_candidates` enumerates them so a user +/// can find the one that holds their funds. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryCandidate { + pub scheme: DilithiumScheme, + /// HD path, or `None` for the master seed (`--no-derivation`). + pub derivation_path: Option, + pub address: String, + /// Which CLI default (if any) produced this combination. + pub note: &'static str, +} + +impl RecoveryCandidate { + pub fn derivation_label(&self) -> &str { + self.derivation_path.as_deref().unwrap_or("master seed (no derivation)") + } + + /// Flags that reproduce this candidate with a plain `wallet import`. + pub fn cli_flags(&self) -> String { + match &self.derivation_path { + Some(path) => format!("--scheme {} --derivation-path \"{path}\"", self.scheme), + None => format!("--scheme {} --no-derivation", self.scheme), + } + } +} + +/// Every default scheme/path combination a CLI release has used, current +/// default first. Pure key derivation; no network access. +pub fn recovery_candidates(mnemonic: &str) -> Result> { + let mut candidates = Vec::with_capacity(4); + + for (scheme, note) in [ + (DilithiumScheme::MlDsa65, "current default"), + (DilithiumScheme::MlDsa87, "default before CLI v2.0"), + ] { + let path = default_derivation_path(scheme); + let keypair = derive_keypair_from_mnemonic(mnemonic, path, scheme) + .map_err(|_| WalletError::InvalidMnemonic)?; + candidates.push(RecoveryCandidate { + scheme, + derivation_path: Some(path.to_string()), + address: keypair.try_to_account_id_ss58check()?, + note, + }); + } + + let mut seed64 = SensitiveBytes64::zeroed(); + mnemonic_to_seed(mnemonic.to_string(), None, &mut seed64) + .map_err(|_| WalletError::InvalidMnemonic)?; + for scheme in [DilithiumScheme::MlDsa87, DilithiumScheme::MlDsa65] { + let keypair = pair_from_master_seed(seed64.as_bytes(), scheme)?; + candidates.push(RecoveryCandidate { + scheme, + derivation_path: None, + address: keypair.try_to_account_id_ss58check()?, + note: "--no-derivation", + }); + } + + Ok(candidates) +} + /// Wallet information structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WalletInfo { @@ -662,6 +728,29 @@ impl WalletManager { }) } + /// Import a mnemonic under the scheme/path of a [`RecoveryCandidate`]. + pub async fn import_wallet_for_candidate( + &self, + name: &str, + mnemonic: &str, + password: Option<&str>, + candidate: &RecoveryCandidate, + ) -> Result { + match &candidate.derivation_path { + Some(path) => + self.import_wallet_with_scheme(name, mnemonic, password, path, candidate.scheme) + .await, + None => + self.import_wallet_no_derivation_with_scheme( + name, + mnemonic, + password, + candidate.scheme, + ) + .await, + } + } + /// Create wallet from 32-byte seed (default scheme: ML-DSA-65). #[allow(dead_code)] // SDK/examples convenience; CLI passes an explicit `--scheme` pub async fn create_wallet_from_seed( @@ -1427,6 +1516,79 @@ mod tests { ); } + #[tokio::test] + async fn recovery_candidates_cover_every_historical_default() { + sp_core::crypto::set_default_ss58_version(sp_core::crypto::Ss58AddressFormat::custom(189)); + let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; + let mnemonic = "orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven"; + + let candidates = recovery_candidates(mnemonic).expect("candidates"); + assert_eq!(candidates.len(), 4); + assert_eq!(candidates[0].scheme, DilithiumScheme::MlDsa65); + assert_eq!( + candidates[0].derivation_path.as_deref(), + Some(DEFAULT_DERIVATION_PATH_ML_DSA_65), + "current default must be offered first" + ); + + // Pinned ML-DSA-87 vectors from `test_known_values`. + let hd_87 = candidates + .iter() + .find(|c| { + c.scheme == DilithiumScheme::MlDsa87 && + c.derivation_path.as_deref() == Some(DEFAULT_DERIVATION_PATH) + }) + .expect("pre-v2.0 default must be a candidate"); + assert_eq!(hd_87.address, "qzm5QCox8Dp5A3oSXZZYHD8YoYgPz7enykZb6RPUropdCyN5h"); + let master_87 = candidates + .iter() + .find(|c| c.scheme == DilithiumScheme::MlDsa87 && c.derivation_path.is_none()) + .expect("ML-DSA-87 master seed must be a candidate"); + assert_eq!(master_87.address, "qzmTAz3UUw1WGUuVh8nbFmPwcftomduwy6twq6NDR6y9qqtEs"); + + let mut addresses: Vec<&str> = candidates.iter().map(|c| c.address.as_str()).collect(); + addresses.sort_unstable(); + addresses.dedup(); + assert_eq!(addresses.len(), 4, "every candidate must map to a distinct address"); + + // Importing a candidate lands on exactly the address it advertised. + for (i, candidate) in candidates.iter().enumerate() { + let info = wallet_manager + .import_wallet_for_candidate(&format!("cand-{i}"), mnemonic, None, candidate) + .await + .expect("import candidate"); + assert_eq!(info.address, candidate.address); + assert_eq!(info.key_type, candidate.scheme.key_type_label()); + } + + assert!(matches!( + recovery_candidates("not a valid phrase"), + Err(crate::error::QuantusError::Wallet(WalletError::InvalidMnemonic)) + )); + } + + #[test] + fn recovery_candidate_cli_flags_round_trip_the_choice() { + let hd = RecoveryCandidate { + scheme: DilithiumScheme::MlDsa87, + derivation_path: Some(DEFAULT_DERIVATION_PATH.to_string()), + address: String::new(), + note: "", + }; + assert_eq!( + hd.cli_flags(), + "--scheme ml-dsa-87 --derivation-path \"m/44'/189189'/0'/0'/0'\"" + ); + let master = RecoveryCandidate { + scheme: DilithiumScheme::MlDsa65, + derivation_path: None, + address: String::new(), + note: "", + }; + assert_eq!(master.cli_flags(), "--scheme ml-dsa-65 --no-derivation"); + assert_eq!(master.derivation_label(), "master seed (no derivation)"); + } + #[tokio::test] async fn test_wallet_import_invalid_mnemonic() { let (wallet_manager, _temp_dir) = create_test_wallet_manager().await; diff --git a/src/wallet/password.rs b/src/wallet/password.rs index f802432..bfe856f 100644 --- a/src/wallet/password.rs +++ b/src/wallet/password.rs @@ -237,9 +237,13 @@ pub fn get_new_wallet_password( /// Get mnemonic phrase from user pub fn get_mnemonic_from_user() -> Result { - log_print!("{}", "Please enter or paste your secret phrase:".bright_yellow()); + log_print!( + "{}", + "Enter or paste your recovery phrase (the 12–24 word mnemonic, not the wallet password):" + .bright_yellow() + ); let mut mnemonic = rpassword::read_password().map_err(|e| { - crate::error::QuantusError::Generic(format!("Failed to read secret phrase: {e}")) + crate::error::QuantusError::Generic(format!("Failed to read recovery phrase: {e}")) })?; let trimmed = mnemonic.trim().to_string(); crate::wallet::keystore::zeroize_string(&mut mnemonic);