From 600707d399c9e6b67578afe674d609b815de0273 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 17:40:02 +0300 Subject: [PATCH 1/5] fix(platform-wallet): act on swept transactions at the persistence seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the rust-dashcore pin to dev and projects the `TransactionsSwept` event the bump brings with it. The two halves are one commit by construction: `WalletEvent` is not `#[non_exhaustive]` and platform has four exhaustive matches over it, so new-pin code cannot compile without the arms — and arms that did nothing would be worse than none, because upstream's removal is unconditional. The wallet drops the losing rows in memory; a store that keeps them replays them at the next load and re-creates the phantom balance the upstream fix exists to kill. The projection is one `SweepBatch` per event, and a sweep-only round is counted in `is_empty_no_records` so a round carrying nothing but a sweep still reaches the persister. The gate is what makes every intermediate host state safe. A backend that has not attested `CORE_SWEEP_REMOVAL` is not known to have applied the round's subtractive half, so its watermark is stripped BEFORE the store and the wallet faults exactly as it would on a rejection — reporting the height durable first and faulting after cannot retract a height a legacy backend already committed. Such a host freezes its sync watermark on the first sweep it meets instead of diverging: fail-closed, funds-safe, and unfrozen the moment its persister ships. A record arriving after a sweep of the same txid retracts that txid from the folded sweep, since persisters write records before replaying sweeps and would otherwise delete a row the wallet has brought back. The asset-lock half mirrors it: a sweep removes the tracked entry its funding transaction created, and `AssetLockChangeSet::merge` now cancels a folded tombstone against a reinstating upsert (and vice versa), so no store ever sees an upsert/tombstone pair for one outpoint whose outcome depends on which it applies first. The pin also carries rust-dashcore#981, which collapses BIP-39 parsing onto one auto-detecting path. Platform's four hand-rolled "try every wordlist" helpers are now that function, and the call sites drop their `Language` argument. It is unrelated to sweeps and rides here only because the sweep chain and the payload-finalization seam this branch's base already depends on both sit above it on dev. `spend_observer`'s two projections gain sweep arms that report no observed spend: a sweep's released outpoints are coins that came back free, and the inputs it kept spent are precisely the ones it does not name, so the held set cannot be derived from the event at all. --- Cargo.lock | 24 +- Cargo.toml | 17 +- .../rs-platform-wallet-ffi/src/derivation.rs | 25 +- .../src/identity_keys_from_mnemonic.rs | 25 +- .../rs-platform-wallet-ffi/src/persistence.rs | 6 +- .../src/changeset/changeset.rs | 100 +- .../src/changeset/core_bridge.rs | 1035 ++++++++++++++++- .../src/manager/accessors.rs | 5 +- .../src/manager/dashpay_sync.rs | 5 +- .../rs-platform-wallet/src/manager/startup.rs | 4 +- .../src/manager/wallet_lifecycle.rs | 48 +- .../rs-platform-wallet/src/test_support.rs | 9 +- .../wallet/asset_lock/sync/reconstruction.rs | 67 ++ .../src/wallet/core/balance_handler.rs | 21 +- .../src/wallet/core/spend_observer.rs | 14 +- .../identity/network/contact_requests.rs | 11 +- .../src/wallet/identity/network/discovery.rs | 4 +- .../identity/network/identity_handle.rs | 8 +- .../src/wallet/identity/network/invitation.rs | 5 +- .../src/wallet/identity/network/loading.rs | 8 +- .../identity/network/payment_handler.rs | 48 +- .../src/wallet/identity/network/payments.rs | 65 +- .../wallet/identity/network/seed_binding.rs | 4 +- .../src/wallet/provider_key_at_index.rs | 8 +- .../src/mnemonic_resolver_core_signer.rs | 16 +- packages/rs-sdk-ffi/src/signer_simple.rs | 27 +- 26 files changed, 1354 insertions(+), 255 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5558a5e9169..392b352192e 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 8227dcec755..22bd563d144 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -579,6 +579,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 (dashpay/platform#4387). // // The event bridge already folded each event's per-account @@ -1338,32 +1361,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); } } @@ -2374,10 +2412,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 f9b7f491977..0768867c4dc 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; @@ -77,10 +78,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 @@ -587,13 +602,39 @@ 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`]. +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 @@ -616,11 +657,40 @@ 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. + core.synced_height = None; + } + let cs = PlatformWalletChangeSet { core: Some(core), // Tracked-asset-lock rows reconstructed from this drain's @@ -639,6 +709,38 @@ 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 next scan + // re-emits the sweep and the idempotent removal is retried + // against (hopefully, by then) a capable backend. + if fault_and_freeze( + diag, + 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); @@ -648,19 +750,15 @@ 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) { + if fault_and_freeze( + diag, + offered_height, + 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 \ @@ -677,7 +775,35 @@ where } } } - diag +} + +/// 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, 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, + offered_height: Option, + fault: &mut AdapterFaultState, + sync_fault: &AtomicBool, + wallet_id: WalletId, + entered_faulted: bool, + freeze_logged: &AtomicBool, +) -> bool { + if let Some(h) = offered_height { + diag.record_rejected(h); + } + 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 for dashpay/platform#4069. @@ -769,6 +895,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() { @@ -938,6 +1081,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() @@ -1407,6 +1598,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() @@ -1419,6 +1611,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 @@ -2859,6 +3334,7 @@ mod tests { last_processed_height: Option, n_records: usize, n_asset_locks: usize, + n_asset_locks_removed: usize, rejected: bool, } @@ -2880,6 +3356,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 { @@ -2890,6 +3367,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 @@ -2908,6 +3398,10 @@ mod tests { } impl PlatformWalletPersistence for ProbePersister { + fn persistence_capabilities(&self) -> crate::changeset::PersistenceCapabilities { + self.capabilities + } + fn store( &self, wallet_id: WalletId, @@ -2936,6 +3430,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 { @@ -3075,7 +3574,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), @@ -3618,6 +4120,212 @@ mod tests { } } + /// 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 _; + 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(), + } + } + + /// 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(), + )); + + 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" + ); + 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 @@ -3747,6 +4455,277 @@ 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: the removal must ride an + // ordinary round, and the flip's overlay is only staged for a + // payment-durable backend. + 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 diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 0f77a3a4b70..880675fffb0 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -1198,7 +1198,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; @@ -1245,8 +1245,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 168512f4d40..8d62dbcfae1 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -1378,8 +1378,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 270d9ff6aa2..da69f8f777d 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; @@ -31,24 +31,11 @@ use super::PlatformWalletManager; /// "invalid English". BIP-39 wordlists are mutually exclusive per /// phrase, so the first match is unambiguous. 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") } /// Test-only rendezvous fired inside [`PlatformWalletManager::remove_wallet_with_teardown`], @@ -926,7 +913,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; @@ -937,8 +924,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"); @@ -953,8 +939,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"); @@ -1037,7 +1022,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; @@ -1103,8 +1088,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 @@ -1164,7 +1148,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(""); @@ -1242,7 +1226,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(""); @@ -1309,7 +1293,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; @@ -1428,8 +1412,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 abc626d55b5..146826c2e3f 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 ba9151db56a..271b6590fbc 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -138,7 +138,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, } } @@ -173,6 +174,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 a9a6fa64e6c..afcc152ea3a 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 @@ -5462,7 +5462,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. @@ -5478,7 +5478,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; @@ -5561,7 +5561,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; @@ -5663,7 +5663,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; @@ -5699,8 +5699,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 93dbfe1ff5b..3e3d7be8a01 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -850,7 +850,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; @@ -858,7 +858,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 c44eecb6685..0df02566704 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 d59c3250390..4abcec29e97 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -1643,7 +1643,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; @@ -1880,8 +1880,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( @@ -1913,8 +1912,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( @@ -1949,8 +1947,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( @@ -1976,7 +1973,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( @@ -2596,8 +2593,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( @@ -4772,7 +4768,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( @@ -5078,8 +5074,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("") }; @@ -5223,7 +5218,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(""); @@ -5350,7 +5345,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 @@ -5495,7 +5490,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, @@ -5571,7 +5566,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); @@ -5718,7 +5713,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 @@ -5949,7 +5944,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); @@ -6057,7 +6052,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(""); @@ -6162,7 +6157,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( @@ -6195,7 +6190,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); @@ -6273,7 +6268,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 = { @@ -6358,7 +6353,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( @@ -6391,7 +6386,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); @@ -6472,7 +6467,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); @@ -6520,7 +6515,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); @@ -6695,7 +6690,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( @@ -6738,7 +6733,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); @@ -6824,7 +6819,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); @@ -6923,7 +6918,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); @@ -7034,7 +7029,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); @@ -7331,7 +7326,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( @@ -7391,7 +7386,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); @@ -7446,7 +7441,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 6634264f848..3dd327a702b 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 629a7c574cc..d1487dc7254 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 @@ -710,7 +710,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; @@ -727,15 +727,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..6ae682c2bca 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; @@ -961,7 +961,7 @@ mod tests { // Old route: resident-seed wallet from the same mnemonic. let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid 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 +999,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; @@ -1013,7 +1013,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"); + 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 +1053,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; @@ -1066,7 +1066,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"); + 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 +1117,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; @@ -1154,7 +1154,7 @@ mod tests { // Parity: encToUserId equals a resident wallet's derive+encrypt. let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE, Language::English).expect("valid 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. From 9afbb8904d42f29ccc4e73d0e0f82db8d38737d9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:42:41 +0300 Subject: [PATCH 2/5] style(sdk-ffi): rustfmt the mnemonic call sites the #981 adaptation touched `cargo fmt --check --all` is a CI gate and the collapsed `Mnemonic::from_phrase` calls left two of them wrapped. --- .../rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 6ae682c2bca..e6a9367c4ab 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -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).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) @@ -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).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) @@ -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).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) @@ -1153,8 +1150,7 @@ mod tests { ); // Parity: encToUserId equals a resident wallet's derive+encrypt. - let mnemonic = - Mnemonic::from_phrase(ENGLISH_PHRASE).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) From 1cb7db2502072f0259e661d21b0e27655b6797c2 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:24:28 +0300 Subject: [PATCH 3/5] docs(platform-wallet): correct four statements the #981 bump left stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nits, all documentation. `parse_mnemonic_any_language`'s doc still said `key_wallet::Mnemonic` "only exposes language-tagged constructors" and that callers "must walk the language list themselves" — precisely what rust-dashcore#981 removed, and it contradicted the inline comment three lines below. The wrapper is kept: 20 call sites narrow upstream's error to the `&'static str` they report, and that narrowing is now what the doc says it does. The sweep gate's recovery note read as if a capable backend might appear mid-session. It cannot: the persister does not change under a running adapter, so a host without the slot stays frozen until it ships one and relaunches. Freezing is the point. `last_processed_height` is now documented as deliberately NOT stripped beside `synced_height`, matching the #4069 guard: `synced_height` is the durable "scanned AND persisted" claim that must not outrun an unapplied removal, while `last_processed_height` is the adapter's own progress marker whose retention makes nothing safer. And the asset-lock test's `DASHPAY_PAYMENTS` attestation no longer describes an overlay this PR writes — nothing here stages `dashpay_payments_overlay`; the bit is declared so the fixture still describes a fully capable backend once #4442 lands. Not taken: de-indenting the vestigial block in `commit_wallet`. It spans 152 lines, so removing it would bury the reviewable diff under a whitespace-only change and force another rebase of the four PRs stacked above this one. --- .../src/changeset/core_bridge.rs | 29 +++++++++++++++---- .../src/manager/wallet_lifecycle.rs | 17 ++++------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 0768867c4dc..9c4ca74c0ce 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -688,6 +688,15 @@ fn commit_wallet

( // 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; } @@ -714,9 +723,15 @@ fn commit_wallet

( // 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 next scan - // re-emits the sweep and the idempotent removal is retried - // against (hopefully, by then) a capable backend. + // `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. if fault_and_freeze( diag, offered_height, @@ -4528,9 +4543,11 @@ mod tests { ); let (obs_tx, mut obs_rx) = unbounded_channel(); - // Attested for sweeps AND payments: the removal must ride an - // ordinary round, and the flip's overlay is only staged for a - // payment-durable backend. + // 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 diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index da69f8f777d..ede53f51d79 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -22,19 +22,14 @@ 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 { - // 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") } From 66a7c74b7c73c49c5bbeec9de28a1102c8913be5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:44:17 +0300 Subject: [PATCH 4/5] fix(platform-wallet): allow commit_wallet's argument count, with the reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI lints these crates with `-D warnings`, so clippy's seven-argument threshold is an error, and the #4370 merge gave `commit_wallet` an eighth: the `settled` set the panic arm in `run_wallet_event_adapter` reads back to decide which wallets have an unknown outcome. Every parameter is a distinct piece of drain state this function reads and writes, and the borrow split is what keeps them separately mutable — bundling them would rename the same eight. --- packages/rs-platform-wallet/src/changeset/core_bridge.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 9c4ca74c0ce..df7a5d1b386 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -619,6 +619,14 @@ where /// 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, From ec97473a5b4688dac893a643d3bfc5e8c36d7d9a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:59:53 +0300 Subject: [PATCH 5/5] fix(platform-wallet): report a sweep-guard withheld watermark as frozen, not rejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BatchDiagnostics` keeps the two ways a proposed `synced_height` fails to land apart on purpose: `rejected` means the store was offered the height and said no, `frozen` means the adapter stripped it before the store ever saw it. The sweep guard is the second kind — it clears `core.synced_height` for a backend that never attested `CORE_SWEEP_REMOVAL` — but the shared `fault_and_freeze` unconditionally filed the height under `rejected`, so logcat read `synced_height_rejected=Some(h)` for a round whose store() returned `Ok`, sending an operator to the persister instead of to the host's missing sweep slot. `fault_and_freeze` now takes the reason (`WithheldHeight::Frozen` / `::Rejected`) and files the height accordingly; a store error on a round the guard had already stripped stays frozen, since the store never saw the height either way. Pinned by `undeclared_sweep_capability_reports_the_watermark_as_frozen_not_rejected`. --- .../src/changeset/core_bridge.rs | 99 +++++++++++++++++-- 1 file changed, 92 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 7862f9f1e2d..83421a9d831 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -736,9 +736,12 @@ fn commit_wallet

( // 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, - offered_height, + WithheldHeight::Frozen(offered_height), fault, sync_fault, wallet_id, @@ -769,9 +772,17 @@ fn commit_wallet

( // 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. + // 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, - offered_height, + withheld, fault, sync_fault, wallet_id, @@ -796,10 +807,26 @@ fn commit_wallet

( } } +/// 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, faults the wallet (counting it once per drain: a wallet that +/// 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 @@ -807,15 +834,17 @@ fn commit_wallet

( /// to logcat; `tracing` may not). fn fault_and_freeze( diag: &mut BatchDiagnostics, - offered_height: Option, + withheld: WithheldHeight, fault: &mut AdapterFaultState, sync_fault: &AtomicBool, wallet_id: WalletId, entered_faulted: bool, freeze_logged: &AtomicBool, ) -> bool { - if let Some(h) = offered_height { - diag.record_rejected(h); + 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 { @@ -4989,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 @@ -5017,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]