diff --git a/Cargo.lock b/Cargo.lock index 8ad8efa2be1..dfcf96c827e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1662,7 +1662,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "bincode_derive", @@ -1673,7 +1673,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dash-network", ] @@ -1768,7 +1768,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "chrono", @@ -1797,7 +1797,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "anyhow", "base64-compat", @@ -1823,12 +1823,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "dashcore-rpc-json", "hex", @@ -1841,7 +1841,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "dashcore", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "bincode", "dashcore-private", @@ -2925,7 +2925,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" [[package]] name = "glob" @@ -4137,7 +4137,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "aes", "async-trait", @@ -4166,7 +4166,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4182,7 +4182,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=393b612269c158925451235a5d9c0ffa5e2eeed2#393b612269c158925451235a5d9c0ffa5e2eeed2" +source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 74e08d20530..5fc7a2a957c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,15 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "393b612269c158925451235a5d9c0ffa5e2eeed2" } - +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which diff --git a/packages/rs-platform-wallet-ffi/src/derivation.rs b/packages/rs-platform-wallet-ffi/src/derivation.rs index bf34953c3ec..81b1020276d 100644 --- a/packages/rs-platform-wallet-ffi/src/derivation.rs +++ b/packages/rs-platform-wallet-ffi/src/derivation.rs @@ -6,7 +6,7 @@ use std::str::FromStr; use dashcore::secp256k1::Secp256k1; use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -14,24 +14,11 @@ use crate::types::{FFINetwork, Network}; use crate::{check_ptr, unwrap_result_or_return}; fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Derive a 32-byte ECDSA private key at a BIP-32 derivation path from diff --git a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs index ef4ad2bf93d..2afc08840fc 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_keys_from_mnemonic.rs @@ -8,7 +8,7 @@ use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPu use key_wallet::dip9::{ IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, }; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use zeroize::Zeroizing; use crate::error::*; @@ -55,24 +55,11 @@ pub(crate) unsafe fn zeroize_and_free_row(row: &mut IdentityKeyPreviewFFI) { /// Parse a BIP-39 mnemonic against every supported wordlist. pub(crate) fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Resolve a wallet's BIP-39 mnemonic via a Swift-owned diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 2ba409b0eb8..2b0b23dbd49 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -7774,7 +7774,7 @@ mod tests { use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::bip32::{ExtendedPrivKey, ExtendedPubKey}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::Wallet; /// Regression: restored pool addresses must be tagged with the @@ -7923,7 +7923,6 @@ mod tests { // `account_collection_test.rs` uses. let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -7957,7 +7956,6 @@ mod tests { fn test_managed_wallet_info_with_account(account_type: AccountType) -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8073,7 +8071,6 @@ mod tests { fn test_managed_wallet_info_with_provider_owner() -> ManagedWalletInfo { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); @@ -8516,7 +8513,6 @@ mod tests { fn account_xpub_survives_persist_restore_round_trip() { let mnemonic = Mnemonic::from_phrase( "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", - Language::English, ) .expect("static BIP-39 vector must parse"); let seed = mnemonic.to_seed(""); diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index a0b82da5e8e..b95ff429398 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -578,6 +578,29 @@ fn context_rank(context: &key_wallet::transaction_checking::TransactionContext) impl Merge for CoreChangeSet { fn merge(&mut self, other: Self) { + // A record arriving after a sweep that removed the same transaction + // reinstates it, and every persister writes records before replaying + // sweeps — so without this the sweep would delete a row the wallet + // has since brought back. Reachable through IS-lock precedence: an + // unconfirmed transaction is swept when an IS-locked conflict lands, + // then returns chainlocked and sweeps that conflict in turn. + // + // The release set stays as it is. It is the aggregate for every loser + // in the batch, so dropping it when one of them is reinstated would + // discard coins freed by the losers that are still going. Entries + // belonging to the reinstated transaction are inert on every backend: + // each scopes its release to the remaining losers' own inputs, or + // withholds any outpoint a surviving record claims — and the + // reinstating record is exactly such a claim. + if !other.records.is_empty() && !self.sweeps.is_empty() { + let reinstated: std::collections::HashSet = + other.records.iter().map(|record| record.txid).collect(); + for batch in &mut self.sweeps { + batch.txids.retain(|txid| !reinstated.contains(txid)); + } + self.sweeps.retain(|batch| !batch.txids.is_empty()); + } + // Records: coalesce by txid, NEWEST-WINS. // // The event bridge already folded each event's per-account @@ -1337,32 +1360,47 @@ impl Merge for AssetLockChangeSet { // swift-sdk `persistAssetLocks`), making the store order of // racing snapshots immaterial. for (out_point, entry) in other.asset_locks { - if entry.status == AssetLockStatus::Consumed { - // A Consumed write supersedes any earlier-folded - // tombstone for the outpoint — Consumed rows are - // deliberately retained for historical lookup (see the - // variant doc), so the terminal write wins over a stale - // removal exactly as it wins over a stale status. - self.removed.remove(&out_point); - } else if let Some(existing) = self.asset_locks.get(&out_point) { - if existing.status == AssetLockStatus::Consumed { - continue; + if entry.status != AssetLockStatus::Consumed { + if let Some(existing) = self.asset_locks.get(&out_point) { + if existing.status == AssetLockStatus::Consumed { + continue; + } } } + // Every ACCEPTED upsert supersedes an earlier-folded tombstone + // for its outpoint, not just a Consumed one. Sweeps are a + // removal producer now (`remove_tracked_asset_locks_for_swept`), + // and a swept funding transaction can return chainlocked in the + // same folded drain — the reinstating record re-inserts the + // entry through reconstruction at a non-Consumed status, and + // letting the sweep's tombstone ride along would have the store + // delete the row it just reinstated (SQLite applies upserts + // before removals) while the in-memory wallet keeps it. This is + // the asset-lock mirror of `CoreChangeSet::merge`'s + // reinstated-txid retraction. For Consumed the same line also + // covers the historical rule: the terminal write wins over a + // stale removal exactly as it wins over a stale status. + self.removed.remove(&out_point); self.asset_locks.insert(out_point, entry); } - // Tombstones folded after a Consumed upsert are dropped for the - // same reason. The only removal emitter (`untrack_asset_lock`) - // fires exclusively for Built rows whose broadcast was - // definitively rejected, so a Consumed/removed pair for one - // outpoint has no legitimate producer — this is defense in - // depth matching the upsert guard. + // Tombstones folded after a Consumed upsert are dropped — Consumed + // rows are deliberately retained for historical lookup (see the + // variant doc). Any other pending upsert is dropped WITH the + // tombstone landing: a removal is upstream's newer word for the + // outpoint (a lock tracked and then swept, or a Built row rejected + // at broadcast, inside one fold), and carrying the dead upsert + // alongside the tombstone would make every store's correctness + // depend on applying upserts before removals. Together with the + // retraction above this keeps the invariant every backend relies + // on: a merged changeset never carries both an upsert and a + // tombstone for the same outpoint. for out_point in other.removed { let consumed = self .asset_locks .get(&out_point) .is_some_and(|entry| entry.status == AssetLockStatus::Consumed); if !consumed { + self.asset_locks.remove(&out_point); self.removed.insert(out_point); } } @@ -2373,10 +2411,38 @@ mod tests { folded.asset_locks[&outpoint].status, AssetLockStatus::Consumed ); - // …and a legitimate removal (rejected Built row) still folds. + // …and a legitimate removal (rejected Built row, or a sweep of the + // funding tx) still folds — taking the now-dead upsert with it, so + // no store ever sees an upsert/tombstone pair whose outcome would + // hinge on which it applies first. let mut folded = cs_with(AssetLockStatus::Built); folded.merge(removal()); assert!(folded.removed.contains(&outpoint)); + assert!( + !folded.asset_locks.contains_key(&outpoint), + "a tombstone folding in must not leave the dead upsert beside it" + ); + + // The coalesced sweep-then-chainlocked-reinstatement fold: the + // sweep removes the tracked entry and contributes a tombstone, then + // the reinstating record re-inserts through reconstruction at a + // non-Consumed status — in the SAME drain. The accepted upsert must + // cancel the earlier tombstone (the asset-lock mirror of + // `CoreChangeSet::merge`'s reinstated-txid retraction); otherwise + // SQLite — upserts before removals — deletes the row it just + // reinstated while the in-memory wallet keeps it, and the durable + // tracked lock is gone after restart even though its funding + // transaction survived. + let mut folded = removal(); + folded.merge(cs_with(AssetLockStatus::RecoveredFromChain)); + assert!( + folded.removed.is_empty(), + "a reinstating reconstruction must cancel the folded sweep tombstone" + ); + assert_eq!( + folded.asset_locks[&outpoint].status, + AssetLockStatus::RecoveredFromChain + ); } #[test] diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 74e97dee742..83421a9d831 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -51,9 +51,10 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ - AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, + AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, SweepBatch, }; use crate::changeset::merge::Merge; +use crate::changeset::persistence_capabilities::PersistenceCapabilities; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::asset_lock::sync::reconstruction; use crate::wallet::platform_wallet::PlatformWalletInfo; @@ -75,10 +76,24 @@ use crate::wallet::platform_wallet::PlatformWalletInfo; /// Folding every event *already buffered* in the channel into one changeset /// per wallet collapses a burst of N events into a single store, so the /// drain keeps pace with the producer at projection speed. This is -/// exactly the fold [`Merge`] was specified for — `CoreChangeSet` merging -/// is commutative and associative, and its doc comment already anticipates -/// "a flush can fold multiple events together (TransactionDetected + -/// BlockProcessed for the same wallet over a sync round)". +/// exactly the fold [`Merge`] was specified for — an ORDERED left fold in +/// channel-arrival order. `CoreChangeSet` merging is associative but NOT +/// commutative, so regrouping the fold is safe but reordering or +/// parallelizing it is not: sweep-aware merging deliberately depends on +/// operand order in two ways. A record arriving after a sweep of the same +/// txid retracts that sweep (reinstatement), while a sweep arriving after +/// the record survives the merge and deletes the row at apply time — +/// swapping the operands swaps which of those happens. And sweep batches +/// append in emission order because each release set is only true of the +/// wallet as that sweep saw it, so a later batch keeping a coin spent must +/// replay after the earlier batch that freed it. (The IS-lock map's +/// last-write-wins and the chain-lock equal-height tie-break also take the +/// later operand.) A reordered fold can therefore persist a different +/// spend decision, not just a differently-arranged changeset. The doc +/// comment on [`Merge`] states the same contract and already anticipates +/// this fold: "a flush can fold multiple events together +/// (TransactionDetected + BlockProcessed for the same wallet over a sync +/// round)". /// /// The cap bounds the worst-case size of a single merged changeset (and /// hence one Room transaction), and keeps a saturated producer from @@ -583,13 +598,47 @@ where P: PlatformWalletPersistence + ?Sized, { let mut diag = BatchDiagnostics::new(folded, batch.len()); - for ( - wallet_id, - WalletBatch { - mut core, - asset_locks, - }, - ) in batch + for (wallet_id, wallet_batch) in batch { + commit_wallet( + persister, + wallet_id, + wallet_batch, + &mut diag, + fault, + sync_fault, + freeze_logged, + settled, + ); + } + diag +} + +/// Commit one wallet's folded changeset — the per-wallet unit of +/// [`commit_batch`]. +/// +/// Eight parameters, one over clippy's threshold: every one is a distinct +/// piece of the drain's state that this function must both read and write — +/// the fault map, the sync flag, the one-shot freeze log, the diagnostics +/// and the settled set that the panic arm in `run_wallet_event_adapter` +/// reads back. Bundling them into a struct would only rename the same +/// eight, and the borrow split is what keeps them separately mutable here. +#[allow(clippy::too_many_arguments)] +fn commit_wallet

( + persister: &P, + wallet_id: WalletId, + wallet_batch: WalletBatch, + diag: &mut BatchDiagnostics, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + freeze_logged: &AtomicBool, + settled: &mut Vec, +) where + P: PlatformWalletPersistence + ?Sized, +{ + let WalletBatch { + mut core, + asset_locks, + } = wallet_batch; { // Hold this wallet's durable watermark at the last fully persisted // height once it has faulted. Records/UTXOs still persist — only the @@ -612,11 +661,49 @@ where // SyncHeightAdvanced for an unknown wallet, empty BlockProcessed, a // watermark-only batch stripped by the fault guard above, etc. — // nothing to persist. Skip the round-trip. - continue; + return; } // The height this changeset OFFERS to the store. It is counted as // persisted only in the `Ok` arm below. let offered_height = core.synced_height; + + // Sweeps reach an FFI host only through the persistence extension's + // size-negotiated sweep callback, and Rust never calls a slot the + // host's declared `struct_size` did not prove — so a persister + // predating that slot (an old C host, or a Kotlin subclass that + // never overrode `onWalletChangesetTransactionsSwept`) processes the + // rest of the round normally and returns success without ever + // seeing `core.sweeps` at all. `store()` coming back `Ok` in that + // case proves nothing about whether the removal actually happened, + // so it is checked separately from the result below rather than + // folded into it. + let sweep_removal_unsupported = !core.sweeps.is_empty() + && !persister + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + if sweep_removal_unsupported { + // Strip the watermark from THIS round, not just later ones. The + // adapter folds whatever is buffered, so a `TransactionsSwept` + // and a following `SyncHeightAdvanced` land in one changeset — + // and `synced_height` lives in the unchanged prefix such a + // persister does read. Letting it through would commit a height + // that claims blocks are scanned while the removal those blocks + // implied never landed, and the fault below cannot retract a + // watermark the backend has already made durable. `offered_height` + // keeps the original so the rejection is still diagnosed as a + // withheld advance rather than as a round that carried none. + // + // `last_processed_height` is deliberately NOT stripped, matching + // the existing fault guard (`freeze_synced_height_if_faulted`, + // dashpay/platform#4069). The two watermarks answer different + // questions: `synced_height` is the durable claim "everything up + // to here is scanned AND persisted", which is what must not + // outrun an unapplied removal, while `last_processed_height` is + // the adapter's own progress marker and holding it back would + // re-drive work without making anything safer. + core.synced_height = None; + } + let cs = PlatformWalletChangeSet { core: Some(core), // Tracked-asset-lock rows reconstructed from this drain's @@ -635,6 +722,47 @@ where // `run_wallet_event_adapter`. settled.push(wallet_id); match store_result { + Ok(()) if sweep_removal_unsupported => { + // The write nominally succeeded, but a backend that never + // attested `CORE_SWEEP_REMOVAL` is not known to have applied + // the one subtractive part of this round — reporting it + // durable would let the swept loser return at the next + // `load()`. Fault exactly like a rejection: the watermark is + // held, so the next scan re-emits the sweep and the + // idempotent removal is retried. + // + // Recovery is not in-session: the persister does not change + // under a running adapter, so a host that lacks the slot + // stays frozen until it ships one and relaunches. Freezing + // is the point — it is what keeps a height that outran an + // unapplied removal from becoming durable. + // The guard above stripped the height before the store saw + // it, so it is reported FROZEN — a `rejected` here would send + // the operator to a persister that returned `Ok`. + if fault_and_freeze( + diag, + WithheldHeight::Frozen(offered_height), + fault, + sync_fault, + wallet_id, + is_faulted, + freeze_logged, + ) { + log::error!( + "SYNC WATERMARK FROZEN: persister for wallet {} does not advertise \ + CORE_SWEEP_REMOVAL but this round swept one or more transactions; a \ + removal must never be reported durable to a backend that cannot apply \ + it, so the sync watermark is held back (dashpay/platform#4406).", + hex::encode(wallet_id) + ); + } + tracing::error!( + wallet_id = %hex::encode(wallet_id), + "Persister lacks CORE_SWEEP_REMOVAL for a changeset carrying sweeps; \ + freezing this wallet's sync watermark rather than trusting an unversioned \ + store() success" + ); + } Ok(()) => { if let Some(h) = offered_height { diag.record_persisted(h); @@ -644,19 +772,23 @@ where // A rejected changeset means these rows are not on disk. Fault // THIS wallet's watermark so it can't outrun them; the next // scan re-emits and the idempotent upserts recover the state. - if let Some(h) = offered_height { - diag.record_rejected(h); - } - fault.fault_wallet(wallet_id, sync_fault); - // Count each faulted wallet once per drain: a wallet that - // entered already faulted was counted at the top of the loop, - // and a repeat rejection must not count it again. - if !is_faulted { - diag.faulted += 1; - } - // One-shot, unambiguous logcat marker via the `log` facade - // (android_logger forwards `log` to logcat; `tracing` may not). - if !freeze_logged.swap(true, Ordering::Relaxed) { + // Rejected — unless the sweep guard had already stripped the + // height, in which case the store never saw it and it stays + // a frozen one whatever the store then said. + let withheld = if sweep_removal_unsupported { + WithheldHeight::Frozen(offered_height) + } else { + WithheldHeight::Rejected(offered_height) + }; + if fault_and_freeze( + diag, + withheld, + fault, + sync_fault, + wallet_id, + is_faulted, + freeze_logged, + ) { log::error!( "SYNC WATERMARK FROZEN: persister rejected a changeset for wallet {} ({}); \ its durable sync height is now held so the next scan re-persists the \ @@ -673,7 +805,53 @@ where } } } - diag +} + +/// How a round's proposed `synced_height` was withheld, for +/// [`BatchDiagnostics`]. The two are different answers to "where is the +/// watermark?": a REJECTED height was offered to the store and the store +/// said no, so the operator looks at the persister; a FROZEN height was +/// stripped by the adapter before the store ever saw it — the sweep guard +/// does this when the backend never attested `CORE_SWEEP_REMOVAL` — so the +/// operator looks at the host's missing capability, not at a store that +/// in fact returned `Ok`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WithheldHeight { + /// Stripped by the adapter; never offered to the store. + Frozen(Option), + /// Offered to the store, which returned an error. + Rejected(Option), +} + +/// The bookkeeping shared by the two ways a round fails to be durably +/// applied — a rejected `store()`, and a nominal success from a backend +/// that cannot have applied the round's sweeps. Records the withheld +/// advance under the field that says which of the two it was, faults the wallet (counting it once per drain: a wallet that +/// entered already faulted was counted at the top of the loop, and a +/// repeat failure must not count it again), and returns whether this is +/// the drain's first freeze — the caller owns the one-shot `log`-facade +/// line, whose wording differs per cause (android_logger forwards `log` +/// to logcat; `tracing` may not). +fn fault_and_freeze( + diag: &mut BatchDiagnostics, + withheld: WithheldHeight, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + wallet_id: WalletId, + entered_faulted: bool, + freeze_logged: &AtomicBool, +) -> bool { + match withheld { + WithheldHeight::Frozen(Some(h)) => diag.record_frozen(h), + WithheldHeight::Rejected(Some(h)) => diag.record_rejected(h), + WithheldHeight::Frozen(None) | WithheldHeight::Rejected(None) => {} + } + fault.fault_wallet(wallet_id, sync_fault); + if !entered_faulted { + diag.faulted += 1; + } + // One-shot: only the first freeze of the session logs. + !freeze_logged.swap(true, Ordering::Relaxed) } /// Durable-watermark guard. @@ -765,6 +943,23 @@ async fn reconstruct_asset_locks_for_event( ) .await; } + // The subtractive arm: a swept funding tx can never confirm, so + // every tracked lock it funds is dead. Nothing else cascades the + // sweep into this table — without this arm the entry is a zombie + // `resume_asset_lock` re-broadcasts and waits on without bound, + // mirrored forever by every store. A chainlocked return re-emits + // the funding record through the arms above, which re-insert the + // entry, so removal here is not a one-way door. + WalletEvent::TransactionsSwept { + wallet_id, txids, .. + } => { + return reconstruction::remove_tracked_asset_locks_for_swept( + wallet_manager, + wallet_id, + txids, + ) + .await; + } _ => return AssetLockChangeSet::default(), }; if candidates.is_empty() { @@ -933,6 +1128,54 @@ async fn build_core_changeset( cs.account_highest_used = account_highest_used; cs } + WalletEvent::TransactionsSwept { + txids, + superseded_by, + winner_mined_height, + released_outpoints, + .. + } => { + // The only subtractive event upstream emits. Each txid was a + // recorded spend that `superseded_by` beat to an input, so it can + // never confirm and the wallet has already dropped it. Mirroring + // the removal is not optional: every other arm here appends, so a + // persister that skipped this would keep the dead rows, hand them + // back on the next load, and re-create the balance the wallet + // just corrected — the exact bug the upstream sweep fixes. + // + // No `spent_utxos` entry for the inputs: a wallet-relevant winner + // claims them through its own record. This arm names the dead and + // the coins their removal freed — the persister holds every input + // of what it deletes, so `released_outpoints` is the only thing + // that tells it which of those to hand back. It cannot work that + // out from the txids: the transaction that took the rest may + // never appear in this wallet's stream at all. + tracing::debug!( + swept = txids.len(), + released = released_outpoints.len(), + superseded_by = %superseded_by, + winner_mined_height = ?winner_mined_height, + "Mirroring swept transactions to the persister" + ); + CoreChangeSet { + sweeps: vec![SweepBatch { + txids: txids.clone(), + superseded_by: *superseded_by, + // The winner's finality context rides with the batch: + // only the event has it (the winner may never appear in + // this wallet's records), and every persister keys the + // lifetime of a held-but-unfunded placeholder on it — + // `Some` anchors the hold at a height that chainlocks, + // `None` (IS-locked, unmined) leaves the hold unstamped + // and uncollectible, the durable stand-in for the + // `spent_outpoints` retention upstream cannot rebuild + // once the loser's record is gone. + winner_mined_height: *winner_mined_height, + released_outpoints: released_outpoints.clone(), + }], + ..CoreChangeSet::default() + } + } WalletEvent::SyncHeightAdvanced { height, .. } => CoreChangeSet { synced_height: Some(*height), ..CoreChangeSet::default() @@ -1401,6 +1644,7 @@ impl CoreChangeSet { fn is_empty_no_records(&self) -> bool { self.records.is_empty() && self.account_records.is_empty() + && self.sweeps.is_empty() && self.spent_utxos.is_empty() && self.new_utxos.is_empty() && self.instant_locks_for_non_final_records.is_empty() @@ -1413,6 +1657,289 @@ impl CoreChangeSet { } } +#[cfg(test)] +mod swept_transaction_projection_tests { + //! Coverage for the one subtractive arm of [`build_core_changeset`]. + //! + //! A sweep carries txids and no records, so it has to survive the + //! `is_empty_no_records` filter on the strength of the txids alone — + //! that filter is what decides whether the persister is called at all, + //! and a sweep that never reaches it leaves the dead rows on disk. + + use super::*; + use dashcore::hashes::Hash; + use dashcore::Txid; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletManager; + + const WALLET_ID: WalletId = [7u8; 32]; + + fn test_manager() -> Arc>> { + Arc::new(RwLock::new(WalletManager::::new( + dashcore::Network::Testnet, + ))) + } + + fn txid(byte: u8) -> Txid { + Txid::from_byte_array([byte; 32]) + } + + fn outpoint(byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: txid(byte), + vout, + } + } + + /// A minimal record for `txid` — only its identity matters here, since + /// the merge keys reinstatement on the txid alone. + fn record_for(txid: Txid) -> TransactionRecord { + let tx = dashcore::Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = txid; + record + } + + /// Mined height every block-context sweep event in these tests carries. + const WINNER_HEIGHT: u32 = 700; + + fn swept(txids: Vec) -> WalletEvent { + swept_releasing(txids, vec![]) + } + + fn swept_releasing(txids: Vec, released_outpoints: Vec) -> WalletEvent { + WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids, + superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints, + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } + + #[tokio::test] + async fn sweep_names_the_dead_transactions_and_nothing_else() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; + + assert_eq!( + cs.sweeps, + vec![SweepBatch { + txids: vec![txid(1), txid(2)], + superseded_by: txid(0xff), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + }] + ); + // A wallet-relevant winner claims the inputs through its own + // record; this arm must not invent UTXO deltas of its own. + assert!(cs.records.is_empty(), "a sweep carries no records"); + assert!(cs.spent_utxos.is_empty(), "a sweep spends nothing"); + assert!(cs.new_utxos.is_empty(), "a sweep creates nothing"); + } + + /// An IS-locked winner's sweep carries `winner_mined_height: None` + /// through to the batch untouched. Every persister keys the lifetime of + /// a held-but-unfunded placeholder on this field — a bridge that + /// fabricated a height here would hand the placeholder a finality + /// horizon the winner does not have, and one that dropped the `Some` + /// leg would make block-context holds uncollectible. + #[tokio::test] + async fn sweep_carries_the_winners_finality_context_verbatim() { + let event = WalletEvent::TransactionsSwept { + wallet_id: WALLET_ID, + txids: vec![txid(1)], + superseded_by: txid(0xff), + winner_mined_height: None, + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }; + let cs = build_core_changeset(&test_manager(), &event).await; + assert_eq!( + cs.sweeps[0].winner_mined_height, None, + "an unmined IS-locked winner must cross the bridge with no mined height" + ); + } + + #[tokio::test] + async fn sweep_reaches_the_persister() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; + + assert!( + !cs.is_empty_no_records(), + "a sweep-only round must not be filtered out as empty — that \ + filter decides whether the persister is called at all" + ); + assert!(!Merge::is_empty(&cs)); + } + + /// The released set is what a persister acts on, so it has to survive + /// the projection intact — it cannot be recovered from the txids, since + /// the transaction that took the remaining inputs may never appear here. + #[tokio::test] + async fn sweep_carries_the_outpoints_it_released() { + let cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![outpoint(9, 1)]), + ) + .await; + + assert_eq!(cs.sweeps[0].released_outpoints, vec![outpoint(9, 1)]); + } + + /// An ordinary resend frees nothing: the winner took every input the + /// removed transaction named. + #[tokio::test] + async fn a_sweep_that_freed_nothing_releases_nothing() { + let cs = build_core_changeset(&test_manager(), &swept(vec![txid(1)])).await; + + assert!(cs.sweeps[0].released_outpoints.is_empty()); + } + + /// Merging keeps every sweep as its own batch, in arrival order. + /// + /// Folding them would lose the only thing that makes a later sweep able + /// to correct an earlier one — see the ordering test below, which is the + /// case that actually breaks. + #[tokio::test] + async fn merged_sweeps_stay_separate_and_ordered() { + let mut cs = build_core_changeset(&test_manager(), &swept(vec![txid(1), txid(2)])).await; + let second = build_core_changeset(&test_manager(), &swept(vec![txid(3)])).await; + + cs.merge(second); + + assert_eq!(cs.sweeps.len(), 2); + assert_eq!(cs.sweeps[0].txids, vec![txid(1), txid(2)]); + assert_eq!(cs.sweeps[1].txids, vec![txid(3)]); + } + + /// A record arriving after a sweep of the same transaction reinstates + /// it. Every persister writes records before replaying sweeps, so a + /// buffered sweep would otherwise delete a row the wallet has since + /// brought back. + /// + /// Reachable through IS-lock precedence: an unconfirmed transaction is + /// swept when an IS-locked conflict arrives, then returns chainlocked + /// and sweeps that conflict in turn — leaving one round holding both + /// removals plus the reinstating record. + #[tokio::test] + async fn a_record_arriving_after_its_sweep_survives_the_round() { + let reinstated = txid(1); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![reinstated], vec![outpoint(9, 1)]), + ) + .await; + assert_eq!( + cs.sweeps.len(), + 1, + "sanity: the sweep is there to begin with" + ); + + // The wallet records it again, which is the newer fact. + let mut later = CoreChangeSet::default(); + later.records.push(record_for(reinstated)); + cs.merge(later); + + assert!( + cs.sweeps.is_empty(), + "the sweep must not delete a transaction the wallet brought back" + ); + assert_eq!(cs.records.len(), 1); + } + + /// Only the reinstated transaction leaves the batch; anything else it + /// removed still goes — and so does everything that batch freed. + /// + /// `released_outpoints` is the aggregate for every loser in the batch, so + /// dropping it would discard coins freed by the losers still going. The + /// entries belonging to the reinstated transaction do no harm: every + /// backend either scopes its release to the remaining losers' own inputs + /// or withholds an outpoint a surviving record claims, and the + /// reinstating record is exactly such a claim. + #[tokio::test] + async fn a_reinstated_record_only_rescues_its_own_transaction() { + let reinstated = txid(1); + let still_dead = txid(2); + let freed_by_the_survivor = outpoint(9, 2); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![reinstated, still_dead], vec![freed_by_the_survivor]), + ) + .await; + let mut later = CoreChangeSet::default(); + later.records.push(record_for(reinstated)); + cs.merge(later); + + assert_eq!(cs.sweeps.len(), 1); + assert_eq!(cs.sweeps[0].txids, vec![still_dead]); + assert_eq!( + cs.sweeps[0].released_outpoints, + vec![freed_by_the_survivor], + "a coin the still-swept loser freed must survive the reinstatement" + ); + } + + /// A release is only true of the wallet the sweep that made it saw. A + /// later sweep can remove the transaction that re-spent the freed coin + /// while keeping the coin spent, because its own winner took it — and + /// that answer has to win, since it is the later one. + /// + /// Unioning the release sets loses exactly this: the earlier "B is free" + /// outlives the later "B is spent", and every backend then persists a + /// coin the chain consumed as spendable. + #[tokio::test] + async fn a_later_sweep_that_keeps_a_coin_spent_outlives_an_earlier_release() { + let freed = outpoint(9, 1); + + let mut cs = build_core_changeset( + &test_manager(), + &swept_releasing(vec![txid(1)], vec![freed]), + ) + .await; + // The second sweep removes the transaction that took `freed` and + // releases nothing: its own winner consumed that coin. + let second = + build_core_changeset(&test_manager(), &swept_releasing(vec![txid(2)], vec![])).await; + + cs.merge(second); + + assert_eq!( + cs.sweeps.len(), + 2, + "the two answers must stay distinguishable" + ); + assert_eq!(cs.sweeps[0].released_outpoints, vec![freed]); + assert!( + cs.sweeps[1].released_outpoints.is_empty(), + "the later sweep kept the coin spent, and applying it after the \ + first is what makes that stick" + ); + } +} + #[cfg(test)] mod contact_watch_only_projection_tests { //! Regression coverage for the persist-time projection of records @@ -2853,6 +3380,7 @@ mod tests { last_processed_height: Option, n_records: usize, n_asset_locks: usize, + n_asset_locks_removed: usize, rejected: bool, } @@ -2874,6 +3402,7 @@ mod tests { /// Raised as soon as a blocked `store()` is entered, so a test can wait /// for the block to be in effect rather than sleeping and hoping. blocked: Arc, + capabilities: crate::changeset::PersistenceCapabilities, } impl ProbePersister { @@ -2884,6 +3413,19 @@ mod tests { panic_once: Mutex::new(HashSet::new()), block_until: Mutex::new(None), blocked: Arc::new(AtomicBool::new(false)), + capabilities: crate::changeset::PersistenceCapabilities::NONE, + } + } + /// A probe that additionally attests `capabilities` — used by the + /// `CORE_SWEEP_REMOVAL` gate tests, which need a persister on record + /// as (not) supporting the sweep contract. + fn with_capabilities( + obs: UnboundedSender, + capabilities: crate::changeset::PersistenceCapabilities, + ) -> Self { + Self { + capabilities, + ..Self::new(obs) } } /// Park the next `store()` until the returned sender is dropped or @@ -2902,6 +3444,10 @@ mod tests { } impl PlatformWalletPersistence for ProbePersister { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + self.capabilities + } + fn store( &self, wallet_id: WalletId, @@ -2930,6 +3476,11 @@ mod tests { .as_ref() .map(|a| a.asset_locks.len()) .unwrap_or(0), + n_asset_locks_removed: changeset + .asset_locks + .as_ref() + .map(|a| a.removed.len()) + .unwrap_or(0), rejected, }); if rejected { @@ -3069,7 +3620,10 @@ mod tests { // 3) Sentinel proving the loop moved past the watermark. tx.send(block_processed_event(wallet_id, 20)).unwrap(); - let sentinel = obs_rx.recv().await.expect("sentinel store must arrive"); + let sentinel = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the sentinel store must arrive rather than hanging the suite") + .expect("sentinel store must arrive"); assert_eq!( sentinel.last_processed_height, Some(20), @@ -3612,47 +4166,253 @@ mod tests { } } - /// End-to-end restore-scan shape through the real adapter loop: a - /// `BlockProcessed` event whose inserted record is an asset-lock tx - /// filed under a funding account must (a) repopulate the wallet's - /// in-memory `tracked_asset_locks` and (b) carry the reconstructed - /// row to the persister in the same `store()` as the core record. - /// This is the path that rebuilds the host's persisted asset-lock - /// mirror after a wipe & recover. - #[tokio::test] - async fn block_processed_asset_lock_record_reconstructs_and_persists() { + /// Mined height every block-context sweep event in this module carries. + const WINNER_HEIGHT: u32 = 700; + + /// A `TransactionsSwept` event for a helper below. + fn swept_event(wallet_id: WalletId, txid_byte: u8, superseded_by_byte: u8) -> WalletEvent { use dashcore::hashes::Hash as _; - use key_wallet::account::account_type::StandardAccountType; - use key_wallet::account::AccountType; - use key_wallet::managed_account::transaction_record::{ - TransactionDirection, TransactionRecord, - }; - use key_wallet::transaction_checking::transaction_router::TransactionType; - use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; - use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; - use tokio::sync::Notify; + WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![dashcore::Txid::from_byte_array([txid_byte; 32])], + superseded_by: dashcore::Txid::from_byte_array([superseded_by_byte; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + } + } - use super::spawn_wallet_event_adapter; - use crate::test_support::{ - funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, - }; - use crate::wallet::asset_lock::manager::AssetLockManager; - use crate::wallet::asset_lock::tracked::AssetLockStatus; - use crate::wallet::persister::WalletPersister; + /// dashpay/platform#4406 (finding 2): sweeps reach an FFI host only + /// through the persistence extension's size-negotiated sweep slot, so a + /// persister predating it processes the rest of the round and returns + /// success without ever seeing `core.sweeps`. A `store()` that comes + /// back `Ok` therefore proves nothing about whether a swept loser's + /// row was actually removed unless the persister has separately + /// attested `CORE_SWEEP_REMOVAL`. A persister that never declares it + /// (the probe's default) must be treated exactly like a rejection when + /// a round carries a sweep — even though, unlike the rejection tests + /// above, the probe's own `store()` call reports success. + #[tokio::test] + async fn sweep_without_declared_capability_freezes_the_wallet_despite_a_successful_store() { + let wallet_id = [21u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared — the pre-`CORE_SWEEP_REMOVAL` shape. + let persister = Arc::new(ProbePersister::new(obs_tx)); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); - // A wallet whose identity-registration funding account has a - // real address pool, plus an asset-lock tx whose credit output - // pays into it (built by the production builder). - let (wallet_manager, wallet_id, _generation, signer) = - funded_wallet_manager(StandardAccountType::BIP44Account).await; - let sdk = Arc::new( - dash_sdk::SdkBuilder::new_mock() - .with_network(dashcore::Network::Testnet) - .build() - .expect("mock sdk"), + tx.send(swept_event(wallet_id, 0x51, 0x52)).unwrap(); + let first = obs_rx + .recv() + .await + .expect("the round is still handed to store()"); + assert!( + !first.rejected, + "the probe's own store() must succeed — the gate lives in the \ + adapter, not in a persister that has no idea sweeps exist" ); - let asset_lock_manager = AssetLockManager::new( - sdk, + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !sync_fault.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + }) + .await + .expect( + "the fail-closed guard must trip for an undeclared sweep even \ + though store() itself reported success", + ); + + // A later watermark-only event must be stripped just like it would + // be after a real store() rejection. + tx.send(sync_height_event(wallet_id, 500)).unwrap(); + tx.send(block_processed_event(wallet_id, 40)).unwrap(); + let sentinel = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the sentinel store must arrive rather than hanging the suite") + .expect("sentinel store must arrive"); + assert_eq!(sentinel.last_processed_height, Some(40)); + assert_eq!( + sentinel.synced_height, None, + "the watermark must stay frozen: a removal must never be \ + reported durable to a backend that never attested it can apply it" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// The coalesced shape of the same gap, which is the one that actually + /// loses data. The adapter folds whatever is buffered, so a sweep and a + /// following watermark advance arrive in ONE changeset — and + /// `synced_height` sits in the unchanged prefix a pre-sweep persister + /// does read and commit. + /// + /// Faulting after `store()` returns cannot retract a watermark the + /// backend has already made durable: on the next launch the wallet + /// believes those blocks are scanned, never re-matches them, and the + /// removal that round carried is lost for good. So the height has to be + /// stripped before the changeset is handed over, not after. + #[tokio::test] + async fn a_coalesced_sweep_and_watermark_never_commits_the_height() { + let wallet_id = [23u8; 32]; + let (tx, rx) = unbounded_channel::(); + // Buffered before the adapter starts, so both events are guaranteed + // to land in the same drain rather than racing it. + tx.send(swept_event(wallet_id, 0x61, 0x62)).unwrap(); + tx.send(sync_height_event(wallet_id, 900)).unwrap(); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared — the pre-`CORE_SWEEP_REMOVAL` shape. + let persister = Arc::new(ProbePersister::new(obs_tx)); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + // Bounded like the neighbouring capability tests below: both the + // adapter and `ProbePersister` hold their own sender, so a + // regression that stops the folded round from reaching `store()` + // would otherwise hang this test instead of failing its assertion. + let observed = tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()) + .await + .expect("the folded round reaches store() within the timeout") + .expect("the folded round reaches store()"); + assert_eq!( + observed.synced_height, None, + "an unattested persister must never be handed the watermark of a \ + round whose removal it cannot apply" + ); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !sync_fault.load(Ordering::Relaxed) { + tokio::task::yield_now().await; + } + }) + .await + .expect("the fail-closed guard must still trip for the folded round"); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// The positive case for the same gate: a persister that attests + /// `CORE_SWEEP_REMOVAL` is trusted normally, and the watermark keeps + /// advancing through a sweep-bearing round exactly as it would through + /// any other. + #[tokio::test] + async fn sweep_with_declared_capability_does_not_freeze() { + let wallet_id = [22u8; 32]; + let (tx, rx) = unbounded_channel::(); + let (obs_tx, mut obs_rx) = unbounded_channel(); + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL, + )); + let sync_fault = Arc::new(AtomicBool::new(false)); + let cancel = CancellationToken::new(); + let handle = tokio::spawn(run_wallet_event_adapter( + test_manager(), + Arc::clone(&persister), + rx, + Arc::clone(&sync_fault), + cancel.clone(), + )); + + tx.send(swept_event(wallet_id, 0x61, 0x62)).unwrap(); + // A watermark-bearing event right behind it, folded or not — either + // way it must reach the store untouched while the capability holds. + tx.send(sync_height_event(wallet_id, 700)).unwrap(); + + let mut last_synced = None; + // Drain until a store carries the watermark. Each receive is bounded: + // the adapter and the probe both hold the sender alive, so a plain + // `recv()` would never report the channel quiet — a regression that + // stops the watermark would hang here until the suite's own timeout + // instead of failing on the assertion below. + for _ in 0..10 { + match tokio::time::timeout(std::time::Duration::from_secs(5), obs_rx.recv()).await { + Ok(Some(observed)) => { + assert!(!observed.rejected); + if let Some(h) = observed.synced_height { + last_synced = Some(h); + break; + } + } + Ok(None) | Err(_) => break, + } + } + assert_eq!( + last_synced, + Some(700), + "the watermark must advance normally once the backend attests \ + CORE_SWEEP_REMOVAL" + ); + assert!( + !sync_fault.load(Ordering::Relaxed), + "an attested backend must never trip the fail-closed guard" + ); + + cancel.cancel(); + drop(tx); + handle.await.unwrap(); + } + + /// End-to-end restore-scan shape through the real adapter loop: a + /// `BlockProcessed` event whose inserted record is an asset-lock tx + /// filed under a funding account must (a) repopulate the wallet's + /// in-memory `tracked_asset_locks` and (b) carry the reconstructed + /// row to the persister in the same `store()` as the core record. + /// This is the path that rebuilds the host's persisted asset-lock + /// mirror after a wipe & recover. + #[tokio::test] + async fn block_processed_asset_lock_record_reconstructs_and_persists() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + + // A wallet whose identity-registration funding account has a + // real address pool, plus an asset-lock tx whose credit output + // pays into it (built by the production builder). + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, Arc::clone(&wallet_manager), wallet_id, Arc::new(Notify::new()), @@ -3741,6 +4501,279 @@ mod tests { handle.await.expect("adapter task joins"); } + /// The `TransactionsSwept` arm end to end: a sweep naming a tracked + /// lock's funding tx must drop the in-memory entry and carry the + /// tombstone to the persister through the same `removed` channel a + /// rejected-at-broadcast `Built` row uses. A swept funding tx can + /// never confirm, so without this the entry is a zombie + /// `resume_asset_lock` re-broadcasts and waits on without bound, and + /// every store mirrors it forever. + #[tokio::test] + async fn transactions_swept_removes_the_tracked_asset_lock_it_funded() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use super::spawn_wallet_event_adapter; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + + let (obs_tx, mut obs_rx) = unbounded_channel(); + // Attested for sweeps AND payments. Only the sweep half matters + // here: nothing in this PR writes `dashpay_payments_overlay`, so + // the payments bit is inert — it is declared so this fixture keeps + // describing a fully capable backend once the payment-flip coupling + // lands (dashpay/platform#4442) and starts staging that overlay. + let persister = Arc::new(ProbePersister::with_capabilities( + obs_tx, + crate::changeset::PersistenceCapabilities::CORE_SWEEP_REMOVAL + .union(crate::changeset::PersistenceCapabilities::DASHPAY_PAYMENTS) + .union(crate::changeset::PersistenceCapabilities::ATOMIC_CHANGESETS), + )); + let (event_tx, event_rx) = unbounded_channel(); + let cancel = CancellationToken::new(); + let sync_fault = Arc::new(AtomicBool::new(false)); + let handle = spawn_wallet_event_adapter( + Arc::clone(&wallet_manager), + Arc::clone(&persister), + event_rx, + Arc::clone(&sync_fault), + cancel.clone(), + ); + + // Track the lock the same way a restore scan would. + event_tx + .send(WalletEvent::BlockProcessed { + wallet_id, + height: 4321, + chain_lock: None, + inserted: vec![record], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }) + .expect("send reconstruction event"); + let observed = obs_rx.recv().await.expect("reconstruction store"); + assert_eq!(observed.n_asset_locks, 1, "sanity: the entry is tracked"); + + // The funding tx is swept. + event_tx + .send(WalletEvent::TransactionsSwept { + wallet_id, + txids: vec![tx.txid()], + superseded_by: dashcore::Txid::from_byte_array([0x77; 32]), + winner_mined_height: Some(WINNER_HEIGHT), + released_outpoints: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + }) + .expect("send sweep event"); + + let observed = obs_rx.recv().await.expect("sweep store"); + assert_eq!( + observed.n_asset_locks_removed, 1, + "the dead lock's tombstone must ride the sweep's own store()" + ); + + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + { + let wm = wallet_manager.read().await; + assert!( + !wm.get_wallet_info(&wallet_id) + .expect("wallet") + .tracked_asset_locks + .contains_key(&out_point), + "the in-memory entry must not outlive its swept funding tx" + ); + } + + cancel.cancel(); + handle.await.expect("adapter task joins"); + } + + /// The coalesced sweep-then-chainlocked-reinstatement fold, driven + /// through the REAL producers rather than hand-built changesets: the + /// sweep arm removes the tracked entry and emits its tombstone, the + /// reinstating chainlocked record re-inserts through reconstruction at + /// a non-Consumed status, and folding the two — exactly what the + /// adapter's batched drain does — must cancel the tombstone. Before + /// `AssetLockChangeSet::merge` learned that, the merged changeset + /// carried both, and SQLite (upserts before removals) deleted the row + /// it had just reinstated while the in-memory wallet kept it: the + /// durable tracked lock vanished across a restart even though its + /// funding transaction survived. + #[tokio::test] + async fn a_reinstating_reconstruction_folded_after_a_sweep_cancels_its_tombstone() { + use dashcore::hashes::Hash as _; + use key_wallet::account::account_type::StandardAccountType; + use key_wallet::account::AccountType; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::transaction_router::TransactionType; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use tokio::sync::Notify; + + use crate::changeset::merge::Merge as _; + use crate::test_support::{ + funded_wallet_manager, AlwaysRejectedBroadcaster, NoopTestPersister, + }; + use crate::wallet::asset_lock::manager::AssetLockManager; + use crate::wallet::asset_lock::sync::reconstruction; + use crate::wallet::asset_lock::tracked::AssetLockStatus; + use crate::wallet::persister::WalletPersister; + + let (wallet_manager, wallet_id, _generation, signer) = + funded_wallet_manager(StandardAccountType::BIP44Account).await; + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_network(dashcore::Network::Testnet) + .build() + .expect("mock sdk"), + ); + let asset_lock_manager = AssetLockManager::new( + sdk, + Arc::clone(&wallet_manager), + wallet_id, + Arc::new(Notify::new()), + Arc::new(AlwaysRejectedBroadcaster), + WalletPersister::new( + wallet_id, + Arc::new(NoopTestPersister) as Arc, + ), + ); + let (tx, _path) = asset_lock_manager + .build_asset_lock_transaction( + 1_000_000, + 0, + AssetLockFundingType::IdentityRegistration, + 0, + &signer, + ) + .await + .expect("build asset lock"); + let record = TransactionRecord::new( + tx.clone(), + AccountType::IdentityRegistration, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 4321, + dashcore::BlockHash::all_zeros(), + 1_650_000_000, + )), + TransactionType::AssetLock, + TransactionDirection::Internal, + vec![], + vec![], + 0, + ); + let out_point = dashcore::OutPoint::new(tx.txid(), 0); + + // Track the lock the way a restore scan would. + let tracked = reconstruction::reconstruct_tracked_asset_locks( + &wallet_manager, + &wallet_id, + &[&record], + ) + .await; + assert_eq!(tracked.asset_locks.len(), 1, "sanity: the entry is tracked"); + + // The sweep's own changeset, then the reinstating record's — the + // two events a single folded drain can carry back to back. + let mut folded = reconstruction::remove_tracked_asset_locks_for_swept( + &wallet_manager, + &wallet_id, + &[tx.txid()], + ) + .await; + assert!( + folded.removed.contains(&out_point), + "sanity: the sweep produced the tombstone" + ); + let reinstated = reconstruction::reconstruct_tracked_asset_locks( + &wallet_manager, + &wallet_id, + &[&record], + ) + .await; + let reinstated_entry = reinstated + .asset_locks + .get(&out_point) + .expect("reconstruction must re-insert the entry the sweep removed"); + assert_ne!( + reinstated_entry.status, + AssetLockStatus::Consumed, + "sanity: the load-bearing premise — a reinstating reconstruction is non-Consumed" + ); + folded.merge(reinstated); + + assert!( + folded.removed.is_empty(), + "the reinstating upsert must cancel the folded sweep tombstone" + ); + assert!( + folded.asset_locks.contains_key(&out_point), + "and the reinstated entry rides the store round" + ); + } + /// The `ChainLockProcessed` arm end to end: a lock the scan /// reconstructed at a pre-finality status (its block wasn't /// chain-locked yet — the restore-scan norm) upgrades to @@ -3985,7 +5018,7 @@ mod tests { // including the fail-closed guard) so the assertions cover the shipped // code, not a restatement of it. - use super::{commit_batch, AssetLockChangeSet, BatchDiagnostics, WalletBatch}; + use super::{commit_batch, AssetLockChangeSet, BatchDiagnostics, SweepBatch, WalletBatch}; /// A changeset that both proposes a watermark and carries a record-bearing /// field, so it survives `is_empty_no_records()` and actually reaches @@ -4013,6 +5046,62 @@ mod tests { batch } + /// The sweep guard strips the height BEFORE the store sees it, so a + /// backend that never attested `CORE_SWEEP_REMOVAL` and returns `Ok` + /// must report the height as FROZEN, not rejected: `rejected` would + /// send an operator to a persister that in fact accepted the round, + /// when the missing piece is the host's sweep capability. + #[test] + fn undeclared_sweep_capability_reports_the_watermark_as_frozen_not_rejected() { + use dashcore::hashes::Hash as _; + let wallet_id = [9u8; 32]; + let (obs_tx, mut obs_rx) = unbounded_channel(); + // No capabilities declared, store() succeeds. + let persister = ProbePersister::new(obs_tx); + let sync_fault = AtomicBool::new(false); + let mut fault = AdapterFaultState::default(); + let freeze_logged = AtomicBool::new(false); + + let mut core = watermark_with_rows(600, 600); + core.sweeps = vec![SweepBatch { + txids: vec![dashcore::Txid::from_byte_array([0x61; 32])], + superseded_by: dashcore::Txid::from_byte_array([0x62; 32]), + winner_mined_height: Some(590), + released_outpoints: vec![], + }]; + let diag = commit_batch( + &persister, + one_wallet_batch(wallet_id, core), + 1, + &mut fault, + &sync_fault, + &freeze_logged, + &mut Vec::new(), + ); + + let observed = obs_rx.try_recv().expect("the round still reaches store()"); + assert!(!observed.rejected, "the probe's own store() succeeds"); + assert_eq!( + observed.synced_height, None, + "the guard stripped the height before the store saw it" + ); + assert_eq!(diag.persisted, None); + assert_eq!( + diag.frozen, + Some(600), + "a height the adapter withheld is reported under `frozen`" + ); + assert_eq!( + diag.rejected, None, + "…and never as rejected: the store did not reject anything" + ); + assert_eq!(diag.faulted, 1); + assert!(sync_fault.load(Ordering::Relaxed)); + let line = diag.to_string(); + assert!(line.contains("synced_height_frozen=Some(600)"), "{line}"); + assert!(line.contains("synced_height_rejected=None"), "{line}"); + } + /// Baseline: a height the store ACCEPTED is the one case that may be /// reported as persisted. #[test] diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index f63c2f3eecf..ace055e60aa 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1197,7 +1197,7 @@ fn tx_record_snapshot(rec: &TransactionRecord) -> AccountTransactionSnapshot { mod spv_rescan_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1244,8 +1244,7 @@ mod spv_rescan_tests { Arc::new(NoopPersister), event_handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 7c1b45e1d7e..ca35adb4dbc 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -525,7 +525,7 @@ impl std::fmt::Debug for DashPaySyncManager { mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -585,8 +585,7 @@ mod tests { /// registry, which is exactly the case that registry-driven DashPay /// sync would skip. async fn register_test_wallet(manager: &Arc>) -> WalletId { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index 7d19ee1a959..7a83bc4787e 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -1375,8 +1375,8 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - use key_wallet::mnemonic::{Language, Mnemonic}; - Mnemonic::from_phrase(phrase, Language::English) + use key_wallet::mnemonic::Mnemonic; + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 2de7cc831fa..1eb70a75645 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dash_spv::chain::CheckpointManager; -use key_wallet::mnemonic::{Language, Mnemonic}; +use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -22,33 +22,15 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; -/// Parse a BIP-39 mnemonic against every supported wordlist in turn, -/// returning the first language that yields a valid mnemonic. +/// Parse a BIP-39 mnemonic in any supported language. /// -/// `key_wallet::Mnemonic` only exposes language-tagged constructors, -/// so callers that take a user-supplied mnemonic must walk the -/// language list themselves to avoid rejecting non-English phrases as -/// "invalid English". BIP-39 wordlists are mutually exclusive per -/// phrase, so the first match is unambiguous. +/// Since rust-dashcore#981 `Mnemonic::from_phrase` IS the auto-detecting +/// parse — one path, with English diagnostics kept when nothing matches — +/// so there is no language list left for a caller to walk. What remains is +/// the error narrowing: callers report a `&'static str`, and this is where +/// upstream's richer error is reduced to one. fn parse_mnemonic_any_language(phrase: &str) -> Result { - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], @@ -923,7 +905,7 @@ impl PlatformWalletManager

{ #[cfg(test)] mod scoped_wallet_id_tests { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -934,8 +916,7 @@ mod scoped_wallet_id_tests { abandon abandon abandon abandon abandon about"; fn wallet_id_for(network: Network) -> [u8; 32] { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -950,8 +931,7 @@ mod scoped_wallet_id_tests { /// "Networks" section can group a seed's sibling-network wallets. /// Mirrors the `register_wallet` derivation exactly. fn wallet_group_id_for(network: Network) -> [u8; 32] { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction"); @@ -1034,7 +1014,7 @@ mod scoped_wallet_id_tests { mod register_wallet_duplicate_tests { use std::sync::Arc; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1100,8 +1080,7 @@ mod register_wallet_duplicate_tests { let manager = make_manager(); let network = Network::Testnet; - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // First registration succeeds. `Some(0)` skips the SPV-tip @@ -1161,7 +1140,7 @@ mod register_wallet_duplicate_tests { let manager = make_manager(); let network = Network::Testnet; - let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic") .to_seed(""); @@ -1239,7 +1218,7 @@ mod register_wallet_duplicate_tests { use dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, Txid, Witness}; let manager = make_manager(); - let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed_bytes = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid test mnemonic") .to_seed(""); @@ -1305,7 +1284,7 @@ mod remove_versus_recreate_tests { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1424,8 +1403,8 @@ mod remove_versus_recreate_tests { if already_fired { return; } - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid test mnemonic"); + let mnemonic = + Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // The real registration path: inner `WalletManager` first, // then `self.wallets`. `Some(0)` skips the SPV-tip lookup. diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index a9dbddba98c..854d6c59d81 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -667,7 +667,7 @@ pub async fn test_platform_wallet_manager() -> ( Arc>, WalletId, ) { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; // Canonical all-`abandon` BIP-39 test vector. @@ -684,8 +684,7 @@ pub async fn test_platform_wallet_manager() -> ( event_handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); let seed_bytes = mnemonic.to_seed(""); // `Some(0)` skips the SPV birth-height lookup so the create never hits the // network. @@ -733,9 +732,9 @@ pub(crate) async fn mnemonic_wallet_manager( ) { use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::ManagedWalletInfo; - use key_wallet::{Language, Mnemonic}; + use key_wallet::Mnemonic; - let mnemonic = Mnemonic::from_phrase(phrase, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(phrase).expect("valid test mnemonic"); let wallet = Wallet::from_mnemonic( mnemonic, Network::Testnet, diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index 64b6414c07b..a14417dba53 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -358,6 +358,73 @@ pub(crate) async fn reconstruct_tracked_asset_locks( cs } +/// `TransactionsSwept` sibling of the hooks above: drop every tracked +/// entry whose funding transaction the sweep just removed. +/// +/// A swept funding tx was provably beaten to one of its inputs, so it can +/// never confirm and its credit outputs will never be usable — but nothing +/// else ever cascades the removal into this table. Left alone, the entry +/// is a zombie the resume path re-broadcasts and then waits on without +/// bound, and the persisted mirror carries it forever. The changeset's +/// `removed` set is the same deletion channel a rejected-at-broadcast +/// `Built` row uses, and every store already applies it. +/// +/// Removal is safe against the one way the verdict can reverse: a +/// chainlocked return re-emits the funding record through +/// `TransactionDetected` / `BlockProcessed`, and reconstruction re-inserts +/// the entry from it — the same path a restore scan uses. +/// +/// The tracked map is inspected under the write lock (sweeps are rare and +/// carry few txids, so there is no hot path to protect), and untouched +/// wallets return an empty changeset without allocating. +/// +/// Deliberately NO rejection undo, unlike the sweep's payment flips: if +/// the round this changeset rides is rejected, the in-memory entry is +/// gone while the mirror row survives — a session-local divergence only. +/// The rejection faults the wallet, the frozen watermark already forces +/// the restart, and `load()` there re-syncs from the mirror while the +/// re-scan re-emits the sweep (rejected rounds keep the loser's record) +/// and re-drops the entry — or re-inserts it through reconstruction if +/// the funding tx turned out to live. An undo ledger would buy nothing +/// that restart does not already guarantee. +pub(crate) async fn remove_tracked_asset_locks_for_swept( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + swept: &[dashcore::Txid], +) -> AssetLockChangeSet { + let mut cs = AssetLockChangeSet::default(); + if swept.is_empty() { + return cs; + } + // Hashed once, before the write lock: the loser slice is sized by the + // network (the mempool alone tracks up to a thousand conflicts), and a + // linear `contains` per tracked entry would put O(entries × losers) + // work under the wallet-manager write lock. + let swept: std::collections::HashSet = swept.iter().copied().collect(); + let mut wm = wallet_manager.write().await; + let Some(info) = wm.get_wallet_info_mut(wallet_id) else { + return cs; + }; + if info.tracked_asset_locks.is_empty() { + return cs; + } + let dead: Vec = info + .tracked_asset_locks + .keys() + .filter(|out_point| swept.contains(&out_point.txid)) + .copied() + .collect(); + for out_point in dead { + info.tracked_asset_locks.remove(&out_point); + cs.removed.insert(out_point); + tracing::info!( + outpoint = %out_point, + "dropped tracked asset lock — its funding transaction was swept" + ); + } + cs +} + /// One record's full reconstruction step: insert-if-absent, then let a /// finalized record upgrade what's already tracked but still unproven /// (the inserts carry their own proof already, so enrichment only ever diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index 1fb87a0d1de..68b0b5d555f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -33,11 +33,13 @@ use crate::wallet::PlatformWallet; /// manager lifecycle write (wallet insert / remove / load) is publishing /// a new one. That infallibility is load-bearing, not a convenience. /// `on_wallet_event` is synchronous and the bus neither retries nor -/// coalesces, so a snapshot missed here is gone for good: nothing -/// guarantees a later event carries the same correction, and until one -/// does the wallet displays superseded totals. A fallible lookup (the -/// previous `RwLock::try_read`) dropped exactly that snapshot whenever -/// it raced a lifecycle write. +/// coalesces, so a snapshot missed here is gone for good — and +/// `TransactionsSwept` can be the *only* event carrying a corrected +/// (lower) balance, since the winner that settled the inputs need not be +/// wallet-relevant and so may never produce a later balance-bearing +/// event. A fallible lookup (the previous `RwLock::try_read`) dropped +/// exactly that snapshot when it raced a lifecycle write, leaving +/// removed funds on display indefinitely. pub struct BalanceUpdateHandler { wallets: Arc>>>, } @@ -59,6 +61,15 @@ impl EventHandler for BalanceUpdateHandler { } | WalletEvent::BlockProcessed { wallet_id, balance, .. + } + // A sweep is the one event that can lower the balance: the + // removed transactions' outputs are gone from the UTXO set. + // The snapshot it carries is post-removal, like every other + // variant's, so it routes identically — dropping it would + // leave the corrected-away amount on screen until the next + // balance-bearing event happened to arrive. + | WalletEvent::TransactionsSwept { + wallet_id, balance, .. } => (wallet_id, balance), // No balance on SyncHeightAdvanced — checkpoint advance only. WalletEvent::SyncHeightAdvanced { .. } => return, diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs index 57810c5dfd2..8f239a22fe5 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -136,7 +136,8 @@ fn observing_wallet(event: &WalletEvent) -> Option<&WalletId> { | WalletEvent::BlockProcessed { wallet_id, .. } => Some(wallet_id), WalletEvent::TransactionInstantLocked { .. } | WalletEvent::ChainLockProcessed { .. } - | WalletEvent::SyncHeightAdvanced { .. } => None, + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::TransactionsSwept { .. } => None, } } @@ -171,6 +172,17 @@ pub(crate) fn observed_spends(event: &WalletEvent) -> Vec { WalletEvent::TransactionInstantLocked { .. } | WalletEvent::ChainLockProcessed { .. } | WalletEvent::SyncHeightAdvanced { .. } => Vec::new(), + // A sweep names dead transactions, and the coins it DOES report — + // `released_outpoints` — are the ones that came back free, the + // opposite of a spend. The inputs it kept spent are exactly the ones + // it does not name: the event carries txids, not records, so the held + // set cannot be derived here at all. When the winner that settled + // them is wallet-relevant, its own `TransactionDetected` / + // `BlockProcessed` reports those spends and retires the fence + // through the arms above; when it is not, this wallet never observes + // the spend from any event, which is a gap this handler cannot close + // without the loser's inputs travelling on the event. + WalletEvent::TransactionsSwept { .. } => Vec::new(), } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index ca7cd2797ed..0cb6f0db279 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -5461,7 +5461,7 @@ mod contact_info_provider_tests { use crate::wallet::identity::crypto::contact_info::derive_contact_info_keys; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use key_wallet::bip32::KeyDerivationType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; // Canonical BIP-39 test mnemonic. @@ -5477,7 +5477,7 @@ mod contact_info_provider_tests { /// open round-trips. #[tokio::test] async fn contact_info_seal_open_matches_resident_derivation_at_real_auth_path() { - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -5560,7 +5560,7 @@ mod contact_info_provider_tests { async fn ecdh_shared_secret_returns_zeroizing_matching_resident_derivation() { use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; - let seed = Mnemonic::from_phrase(PHRASE, Language::English) + let seed = Mnemonic::from_phrase(PHRASE) .expect("valid mnemonic") .to_seed(""); let network = Network::Testnet; @@ -5662,7 +5662,7 @@ mod stamp_race_tests { use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use dpp::identity::v0::IdentityV0; use dpp::identity::Identity; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use std::collections::BTreeMap; @@ -5698,8 +5698,7 @@ mod stamp_race_tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index d18f1fb5cd4..8c66dbd695d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -851,7 +851,7 @@ mod tests { use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; use std::collections::BTreeMap; @@ -859,7 +859,7 @@ mod tests { abandon abandon abandon abandon abandon about"; fn test_master() -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(Network::Testnet, &seed).expect("master xpriv") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 4f0a3a51c1e..402b6930737 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -479,7 +479,7 @@ impl IdentityWallet { mod tests { use super::*; use dpp::util::hash::ripemd160_sha256; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -497,8 +497,7 @@ mod tests { /// touches — the identity-auth derivation walks the master xpriv, /// not the per-account collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") } @@ -508,8 +507,7 @@ mod tests { /// (`RootExtendedPrivKey::new_master(seed).to_extended_priv_key(network)` /// is byte-for-byte `ExtendedPrivKey::new_master(network, seed)`). fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs index 0027446e5ba..76ccd55ce9e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/invitation.rs @@ -1490,7 +1490,7 @@ mod tests { use crate::wallet::identity::network::contact_requests::SeedCryptoProvider; use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWalletError; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1561,8 +1561,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index bdbde9b0850..ecc7897ae4b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -527,7 +527,7 @@ mod tests { }; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; use key_wallet::bip32::ExtendedPrivKey; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -545,8 +545,7 @@ mod tests { /// never touches — it walks the master xpriv, not the per-account /// collection, so no accounts are needed. fn mnemonic_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::None) .expect("from_mnemonic should build a Mnemonic wallet") } @@ -554,8 +553,7 @@ mod tests { /// The BIP-32 master node for [`TEST_MNEMONIC`] on `network` — the /// same node `derive_extended_private_key` reconstructs internally. fn master_for(network: Network) -> ExtendedPrivKey { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) - .expect("valid English test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid English test mnemonic"); let seed = mnemonic.to_seed(""); ExtendedPrivKey::new_master(network, &seed).expect("master xpriv from test seed") } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs index 62d174bb650..0c3cc79d3d1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payment_handler.rs @@ -251,7 +251,16 @@ fn dashpay_payment_records(event: &WalletEvent) -> Vec<&TransactionRecord> { WalletEvent::BlockProcessed { inserted, updated, .. } => inserted.iter().chain(updated.iter()).collect(), + // `TransactionsSwept` carries txids, not records: the wallet has + // already dropped the records these name. Its payment consequence + // — failing the matching `Pending` sent payments, since a swept + // transaction can never confirm — is NOT this handler's to apply: + // a sweep never re-emits once its round is durable, so the flip + // must ride the sweep's own atomic store round, which belongs to + // the wallet-event adapter. Routing it here would persist the + // flip on a separate round with no replay if that round fails. WalletEvent::TransactionInstantLocked { .. } + | WalletEvent::TransactionsSwept { .. } | WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => Vec::new(), } @@ -274,16 +283,25 @@ fn drives_payment_hooks(event: &WalletEvent) -> bool { WalletEvent::BlockProcessed { inserted, updated, .. } => !inserted.is_empty() || !updated.is_empty(), - WalletEvent::SyncHeightAdvanced { .. } | WalletEvent::ChainLockProcessed { .. } => false, + // No records to route (see `dashpay_payment_records`), so a task + // here would take and release the wallet-manager write lock for + // nothing. The sweep's payment consequence belongs on the + // wallet-event adapter's own store round — see `dashpay_payment_records`. + WalletEvent::TransactionsSwept { .. } + | WalletEvent::SyncHeightAdvanced { .. } + | WalletEvent::ChainLockProcessed { .. } => false, } } /// Run the DashPay payment hooks for `event`: record any incoming DashPay /// payment, then advance a matching sent payment from `Pending` to /// `Confirmed` once its transaction reaches finality (mined or -/// InstantSend-locked). All paths are idempotent per txid, so re-detections -/// and repeated block-processing rounds converge without duplicating -/// entries. +/// InstantSend-locked). The opposite terminal — `Failed`, when a sweep +/// proves the transaction never can confirm — is deliberately not applied +/// here: it belongs on the sweep's own atomic store round in the +/// wallet-event adapter (see `dashpay_payment_records`). All paths are +/// idempotent per txid, so re-detections and repeated block-processing +/// rounds converge without duplicating entries. pub(crate) async fn run_dashpay_payment_hooks( wallet_manager: &Arc>>, wallet_id: &WalletId, @@ -458,6 +476,28 @@ mod tests { assert!(drives_payment_hooks(&event)); } + /// `TransactionsSwept` must NOT drive the payment hooks: its payment + /// consequence — failing the losers' `Pending` sent payments — belongs + /// on the wallet-event adapter's own atomic store round, because a + /// sweep never re-emits once its round is durable and a separately + /// persisted flip that failed its store would be lost for good. + /// Spawning a hook task here would race a second write against that + /// round. + #[test] + fn transactions_swept_does_not_drive_payment_hooks() { + let event = WalletEvent::TransactionsSwept { + wallet_id: [0u8; 32], + txids: vec![dashcore::Txid::from([0x21; 32])], + superseded_by: dashcore::Txid::from([0x22; 32]), + winner_mined_height: None, + released_outpoints: Vec::new(), + balance: WalletCoreBalance::default(), + account_balances: std::collections::BTreeMap::new(), + }; + assert!(dashpay_payment_records(&event).is_empty()); + assert!(!drives_payment_hooks(&event)); + } + /// A `BlockProcessed` that changed no records (syncing past an empty /// block) has no payment work, so it must not spawn a hook task. Pins /// the spawn-skip that keeps initial sync from taking the wallet-manager diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 43e001ba0ad..4c455aba433 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1639,7 +1639,7 @@ mod tests { use dpp::prelude::Identifier; use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -1876,8 +1876,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1909,8 +1908,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1945,8 +1943,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -1972,7 +1969,7 @@ mod tests { owner: &Identifier, contact: &Identifier, ) -> key_wallet::bip32::ExtendedPubKey { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet = key_wallet::wallet::Wallet::from_seed_bytes( @@ -2592,8 +2589,7 @@ mod tests { Arc::clone(&persister), handler, )); - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = manager .create_wallet_from_seed_bytes( @@ -4768,7 +4764,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -5074,8 +5070,7 @@ mod tests { // The signer's seed (the faithful test stand-in derives from it). let seed = { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid mnemonic"); mnemonic.to_seed("") }; @@ -5219,7 +5214,7 @@ mod tests { let watched = Identifier::from([0x42; 32]); let contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -5346,7 +5341,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5491,7 +5486,7 @@ mod tests { } let provider = SeedCryptoProvider::from_seed( - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""), Network::Testnet, @@ -5567,7 +5562,7 @@ mod tests { ) .expect("auth path at the legacy key id"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -5714,7 +5709,7 @@ mod tests { Arc::clone(&persister), handler, )); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let wallet_id = manager @@ -5945,7 +5940,7 @@ mod tests { let (manager, _persister, wallet_id) = make_watch_only_wallet().await; let iw = manager.get_wallet(&wallet_id).await.expect("wallet"); let iw = iw.identity(); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6053,7 +6048,7 @@ mod tests { // so the send fails AFTER the drain has run. let pay_contact = Identifier::from([0x22; 32]); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); @@ -6158,7 +6153,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6191,7 +6186,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6267,7 +6262,7 @@ mod tests { // The sending side, so the external-account lookup passes. let shared_key = [0x55u8; 32]; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let compact = { @@ -6352,7 +6347,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6385,7 +6380,7 @@ mod tests { .await .expect("register external account"); - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6466,7 +6461,7 @@ mod tests { .expect("register receiving account"); plant_receival_utxo(&manager, wallet_id, owner_id, contact_id, 0xC2, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6514,7 +6509,7 @@ mod tests { // broadcast (and its preceding used-flip persist). fund_bip44_account_0(&manager, wallet_id, 0xB7, 120_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6689,7 +6684,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -6732,7 +6727,7 @@ mod tests { // broadcast (a funding-build failure returns before it). fund_bip44_account_0(&manager, wallet_id, 0xA1, 60_000).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6817,7 +6812,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -6916,7 +6911,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7028,7 +7023,7 @@ mod tests { vout: 0, }; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7325,7 +7320,7 @@ mod tests { let shared_key = [0x55u8; 32]; let iv = [0x11u8; 16]; let compact = { - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("mnemonic") .to_seed(""); let w = key_wallet::wallet::Wallet::from_seed_bytes( @@ -7385,7 +7380,7 @@ mod tests { let funded = amount + 526; fund_bip44_account_0(&manager, wallet_id, 0xA1, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); @@ -7440,7 +7435,7 @@ mod tests { let funded = amount + 1226; fund_bip44_account_0(&manager, wallet_id, 0xB2, funded).await; - let seed = Mnemonic::from_phrase(TEST_MNEMONIC, Language::English) + let seed = Mnemonic::from_phrase(TEST_MNEMONIC) .expect("valid mnemonic") .to_seed(""); let provider = SeedCryptoProvider::from_seed(seed, Network::Testnet); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs index 10cc57c35c2..30cbc92a253 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/seed_binding.rs @@ -613,7 +613,7 @@ mod tests { use std::sync::Arc; use std::time::{Duration, Instant}; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -659,7 +659,7 @@ mod tests { } fn seed_for(phrase: &str) -> [u8; 64] { - Mnemonic::from_phrase(phrase, Language::English) + Mnemonic::from_phrase(phrase) .expect("valid test mnemonic") .to_seed("") } diff --git a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs index 9044fc23a5b..1ec868f5e33 100644 --- a/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs +++ b/packages/rs-platform-wallet/src/wallet/provider_key_at_index.rs @@ -715,7 +715,7 @@ impl PlatformWallet { #[cfg(test)] mod tests { use super::*; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -732,15 +732,13 @@ mod tests { "legal winner thank year wave sausage worth useful legal winner thank yellow"; fn seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = - Mnemonic::from_phrase(TEST_MNEMONIC, Language::English).expect("valid test mnemonic"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).expect("valid test mnemonic"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet construction") } fn second_seed_bearing_wallet(network: Network) -> Wallet { - let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B, Language::English) - .expect("valid test mnemonic B"); + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC_B).expect("valid test mnemonic B"); Wallet::from_mnemonic(mnemonic, network, WalletAccountCreationOptions::Default) .expect("wallet B construction") } diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index b0c1a3c4d3c..e6a9367c4ab 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -943,7 +943,7 @@ mod tests { #[tokio::test] async fn extended_public_key_matches_wallet_derivation_for_dashpay_path() { use key_wallet::account::AccountType; - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -960,8 +960,7 @@ mod tests { .expect("DashPay receiving path"); // Old route: resident-seed wallet from the same mnemonic. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -999,7 +998,7 @@ mod tests { /// this pins them equal. #[tokio::test] async fn ecdh_shared_secret_matches_wallet_derivation() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1012,8 +1011,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → ECDH through the single crypto source. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1053,7 +1051,7 @@ mod tests { /// this pins the signer route equal to `Wallet`'s and confirms the inverse. #[tokio::test] async fn account_reference_matches_wallet_derivation_and_round_trips() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1065,8 +1063,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic → derive the // scalar at `path` → mask through the single accountReference source. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) @@ -1117,7 +1114,7 @@ mod tests { /// so contactInfo the signer seals is readable by the reference clients. #[tokio::test] async fn contact_info_seal_open_round_trips_and_matches_wallet_derivation() { - use key_wallet::mnemonic::{Language, Mnemonic}; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; @@ -1153,8 +1150,7 @@ mod tests { ); // Parity: encToUserId equals a resident wallet's derive+encrypt. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid mnemonic"); + let mnemonic = Mnemonic::from_phrase(ENGLISH_PHRASE).expect("valid mnemonic"); let seed = mnemonic.to_seed(""); let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 1eb97522e5a..e3b3d34e2ba 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -28,26 +28,13 @@ use dash_async::block_on; pub(crate) fn parse_mnemonic_any_language( phrase: &str, ) -> Result { - use key_wallet::mnemonic::{Language, Mnemonic}; - - const LANGUAGES: [Language; 10] = [ - Language::English, - Language::Spanish, - Language::French, - Language::Italian, - Language::Japanese, - Language::Korean, - Language::ChineseSimplified, - Language::ChineseTraditional, - Language::Czech, - Language::Portuguese, - ]; - for lang in LANGUAGES { - if let Ok(m) = Mnemonic::from_phrase(phrase, lang) { - return Ok(m); - } - } - Err("phrase does not match any supported BIP-39 wordlist") + use key_wallet::mnemonic::Mnemonic; + + // Upstream's `from_phrase` IS the auto-detecting parse since + // rust-dashcore#981 — one path, English diagnostics preserved when + // nothing matches. This wrapper survives only to narrow the error to + // the `&'static str` its callers report. + Mnemonic::from_phrase(phrase).map_err(|_| "phrase does not match any supported BIP-39 wordlist") } /// Create a signer from a private key.