From 83f7d4f4d025c6383b7a3f0ae924ae5ac640c946 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:26:09 +0000 Subject: [PATCH 01/60] feat(platform-wallet): seedless watch-only rehydration via load_from_persistor [#3692, clean on v3.1-dev] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Squashed net-diff of feat/platform-wallet-rehydration onto v3.1-dev base (1653b89107). Includes all merged commits: • changeset: CoreChangeSet, ClientWalletStartState, addresses_derived wiring • rehydrate: seedless watch-only wallet rebuild + apply_persisted_core_state • load_outcome: LoadOutcome / SkipReason / CorruptKind • manager/load: load_from_persistor implementation • manager/mod: PlatformWalletManager wiring • events: PlatformEvent + on_wallet_skipped_on_load concrete handler • error: RehydrateRowError relocated from manager::rehydrate • core_bridge: warn_if_non_default_account generalised to &[T] slice • FFI: persistence + manager bindings • Swift: PlatformWalletManager load() bridging • tests: rehydration_load integration suite • misc: .cargo/audit.toml, .gitignore fmt + clippy (-D warnings) + cargo test: all pass. Tree verified byte-for-byte identical to feat/platform-wallet-rehydration HEAD. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .cargo/audit.toml | 6 +- .gitignore | 3 + .../rs-platform-wallet-ffi/src/manager.rs | 136 +- .../rs-platform-wallet-ffi/src/persistence.rs | 68 +- .../src/changeset/changeset.rs | 8 +- .../changeset/client_wallet_start_state.rs | 70 +- .../src/changeset/core_bridge.rs | 181 ++- packages/rs-platform-wallet/src/error.rs | 61 + packages/rs-platform-wallet/src/events.rs | 77 + packages/rs-platform-wallet/src/lib.rs | 3 +- .../src/manager/identity_sync.rs | 12 +- .../rs-platform-wallet/src/manager/load.rs | 155 +- .../src/manager/load_outcome.rs | 77 + .../rs-platform-wallet/src/manager/mod.rs | 21 +- .../src/manager/rehydrate.rs | 1323 +++++++++++++++++ .../src/manager/shielded_sync.rs | 25 +- .../rs-platform-wallet/src/wallet/apply.rs | 98 +- .../wallet/identity/state/manager/apply.rs | 83 +- .../tests/rehydration_load.rs | 277 ++++ .../PlatformWalletManager.swift | 25 +- 20 files changed, 2462 insertions(+), 247 deletions(-) create mode 100644 packages/rs-platform-wallet/src/manager/load_outcome.rs create mode 100644 packages/rs-platform-wallet/src/manager/rehydrate.rs create mode 100644 packages/rs-platform-wallet/tests/rehydration_load.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 1ddc9033595..c922c98cbf8 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -1,3 +1,5 @@ [advisories] -# TODO Remove it from here -ignore = [ "RUSTSEC-2020-0071"] # advisory IDs to ignore e.g. ["RUSTSEC-2019-0001", ...] +# Advisory IDs to ignore, e.g. ["RUSTSEC-2019-0001", ...]. Each entry +# must point at a live advisory in the resolved graph and carry a dated +# rationale; an entry matching nothing trains reviewers to skim the list. +ignore = [] diff --git a/.gitignore b/.gitignore index 983cad56c38..b243acea36e 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,6 @@ __pycache__/ # Security audit reports (local-only, not committed) audits/ + +# Review scratch (grumpy-review / triage output, local-only) +.review-*/ diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 5930c1c4db6..66cf4aaa0a1 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -174,6 +174,51 @@ unsafe fn create_wallet_from_mnemonic_impl( PlatformWalletFFIResult::ok() } +/// One wallet skipped during `load_from_persistor` because its +/// persisted row was structurally corrupt (per-row decode failure). +/// The load path is seedless and watch-only, so this is the only skip +/// reason. `reason_code` is per-`CorruptKind` family — see its table. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct SkippedWalletFFI { + /// The (public) 32-byte wallet id that was skipped. + pub wallet_id: [u8; 32], + /// Structural skip reason. `100` = missing account manifest, + /// `101` = malformed account xpub, `102` = any other structural + /// decode error. No secret material is ever carried. + pub reason_code: u32, +} + +/// C-visible summary of one `load_from_persistor` pass so the host can +/// see which wallets loaded and which were skipped (and why) instead +/// of the outcome being silently discarded. +/// +/// `skipped` is a heap array of length `skipped_count`; pass this +/// struct (by pointer) to +/// [`platform_wallet_load_outcome_free`] exactly once to release it. +#[repr(C)] +#[derive(Debug)] +pub struct LoadOutcomeFFI { + /// Number of wallets fully reconstructed + registered. + pub loaded_count: usize, + /// Length of the `skipped` array. + pub skipped_count: usize, + /// Heap-allocated skipped-wallet array (null iff `skipped_count` + /// is 0). Owned by Rust until `platform_wallet_load_outcome_free`. + pub skipped: *mut SkippedWalletFFI, +} + +fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { + use platform_wallet::manager::load_outcome::CorruptKind; + match reason { + platform_wallet::SkipReason::CorruptPersistedRow { kind } => match kind { + CorruptKind::MissingManifest => 100, + CorruptKind::MalformedXpub => 101, + CorruptKind::DecodeError(_) => 102, + }, + } +} + /// Create a wallet from raw seed bytes (64 bytes). /// /// On success, `out_wallet_handle` is set to a `PlatformWallet` handle and @@ -296,23 +341,102 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit /// /// Triggers `on_load_wallet_list_fn` on the persistence callbacks to /// fetch the persisted wallet list from the client side (SwiftData), -/// reconstructs each wallet as **watch-only** via its stored root + -/// per-account xpubs, and registers them inside the manager. Does not -/// produce wallet handles — the caller should follow up with -/// [`platform_wallet_manager_get_wallet`] per `wallet_id` it knows -/// about. +/// builds a keyless reconstruction payload per wallet, then registers +/// each one as a **watch-only** wallet. No signing keys are derived +/// here — signing happens later, on demand, via the configured +/// `MnemonicResolverHandle` (`sign_with_mnemonic_resolver` and its +/// siblings), which fail-closed gate the resolver-supplied seed +/// against the loaded `wallet_id`. Does not produce wallet handles — +/// follow up with [`platform_wallet_manager_get_wallet`] per +/// `wallet_id`. +/// +/// A wallet whose persisted row is structurally corrupt is +/// **skipped**, not failed: the call still returns `Success`, every +/// skipped `(wallet_id, reason)` is logged, and — when `out_outcome` +/// is non-null — surfaced through it. +/// +/// # Safety +/// - `out_outcome` may be null (caller doesn't want the summary); +/// otherwise it must point to writable `LoadOutcomeFFI` storage and +/// the caller must later release it via +/// [`platform_wallet_load_outcome_free`]. #[no_mangle] pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( manager_handle: Handle, + out_outcome: *mut LoadOutcomeFFI, ) -> PlatformWalletFFIResult { let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { runtime().block_on(manager.load_from_persistor()) }); let result = unwrap_option_or_return!(option); - unwrap_result_or_return!(result); + let outcome = unwrap_result_or_return!(result); + + // Never silently drop the outcome: log a structured summary plus + // one line per skipped wallet (the host can inspect / clear the + // corrupt rows). + tracing::info!( + loaded = outcome.loaded.len(), + skipped = outcome.skipped.len(), + "platform_wallet_manager_load_from_persistor complete" + ); + for (wid, reason) in &outcome.skipped { + tracing::warn!( + wallet_id = %hex::encode(wid), + reason = %reason, + "load_from_persistor skipped wallet (corrupt persisted row)" + ); + } + + if !out_outcome.is_null() { + let skipped_vec: Vec = outcome + .skipped + .iter() + .map(|(wid, reason)| SkippedWalletFFI { + wallet_id: *wid, + reason_code: skip_reason_code(reason), + }) + .collect(); + let skipped_count = skipped_vec.len(); + let skipped_ptr = if skipped_count == 0 { + std::ptr::null_mut() + } else { + let boxed = skipped_vec.into_boxed_slice(); + Box::into_raw(boxed) as *mut SkippedWalletFFI + }; + std::ptr::write( + out_outcome, + LoadOutcomeFFI { + loaded_count: outcome.loaded.len(), + skipped_count, + skipped: skipped_ptr, + }, + ); + } PlatformWalletFFIResult::ok() } +/// Release the heap `skipped` array a successful +/// [`platform_wallet_manager_load_from_persistor`] wrote into a +/// `LoadOutcomeFFI`. Idempotent: nulls the pointer after freeing, and +/// a null `outcome` (or already-freed array) is a no-op. +/// +/// # Safety +/// `outcome` must point to a `LoadOutcomeFFI` previously populated by +/// `platform_wallet_manager_load_from_persistor`, not freed already. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_load_outcome_free(outcome: *mut LoadOutcomeFFI) { + if outcome.is_null() { + return; + } + let o = &mut *outcome; + if !o.skipped.is_null() && o.skipped_count > 0 { + let slice = std::slice::from_raw_parts_mut(o.skipped, o.skipped_count); + drop(Box::from_raw(slice as *mut [SkippedWalletFFI])); + } + o.skipped = std::ptr::null_mut(); + o.skipped_count = 0; +} + /// Get a `PlatformWallet` handle for a wallet registered in the /// manager. Returns `NotFound` if no wallet with the given /// id is currently held. diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 86b5561518c..9fdd545b582 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2847,12 +2847,13 @@ fn build_wallet_start_state( } // Persisted `last_applied_chain_lock` — bincode-decoded from the - // bytes Swift handed back. Restoring this before the wallet - // enters the manager means the asset-lock-resume CL-from-metadata - // fallback (`proof.rs`) can fire immediately at app launch on - // any tracked lock whose funding block height is `<= cl.block_height`, - // without waiting for SPV to re-apply a fresh CL. SPV persists - // its own `best_chainlock` independently; this is the symmetric + // bytes Swift handed back onto the local `wallet_info`. It is then + // carried into the keyless `CoreChangeSet` below and re-applied by + // `apply_persisted_core_state`, so the asset-lock-resume + // CL-from-metadata fallback (`proof.rs`) fires at app launch on any + // tracked lock whose funding block height is `<= cl.block_height`, + // without waiting for SPV to re-apply a fresh CL. SPV persists its + // own `best_chainlock` independently; this is the symmetric // wallet-side restore. // // Decode failure is treated as miss: malformed bytes here are @@ -3377,11 +3378,62 @@ fn build_wallet_start_state( // status without rebroadcasting. let unused_asset_locks = build_unused_asset_locks(entry)?; + // Project the reconstructed `wallet` + `wallet_info` into the + // keyless `ClientWalletStartState` the persister contract requires + // (SECRETS.md: no `Wallet`/seed crosses `load()`). The manager + // rebuilds a watch-only wallet from this manifest via + // `Wallet::new_watch_only` and applies this `core_state` projection. + // Signing happens later via the on-demand + // `sign_with_mnemonic_resolver` path, which fail-closed gates the + // resolver-supplied seed against the loaded `wallet_id`. The + // locally-built `wallet` is dropped — it was only needed to shape + // the account collection / UTXO routing above. + let account_manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + let new_utxos: Vec = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|acct| acct.utxos.values().cloned()) + .collect(); + let core_state = platform_wallet::changeset::CoreChangeSet { + new_utxos, + last_processed_height: (wallet_info.metadata.last_processed_height > 0) + .then_some(wallet_info.metadata.last_processed_height), + synced_height: (wallet_info.metadata.synced_height > 0) + .then_some(wallet_info.metadata.synced_height), + // Carry the decoded chainlock through the keyless projection; + // `apply_persisted_core_state` re-applies it onto the rebuilt wallet. + last_applied_chain_lock: wallet_info.metadata.last_applied_chain_lock.clone(), + ..Default::default() + }; + + // `contacts` / `identity_keys` are the PR-3 keyless feed the + // manager layers onto the managed identities via + // `apply_contacts_and_keys`. The iOS path does NOT use them: + // identity PUBLIC keys are already reconstructed straight into + // `Identity.public_keys` by `build_wallet_identity_bucket` (feeding + // the slot too would double-apply), and `WalletRestoreEntryFFI` + // carries no contacts back from Swift on load — surfacing them + // would need a new cross-boundary struct field + Swift wiring, + // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` + // a no-op for this path, preserving the established iOS behaviour. let wallet_state = ClientWalletStartState { - wallet, - wallet_info, + network, + birth_height: entry.birth_height, + account_manifest, + core_state, identity_manager, unused_asset_locks, + contacts: Default::default(), + identity_keys: Default::default(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index b4c18917c44..cc8b705df98 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -963,8 +963,12 @@ pub struct PlatformWalletChangeSet { /// the merge policy (plain `Vec::extend`, dedup is the apply-side /// caller's job). pub account_registrations: Vec, - /// Address-pool snapshots emitted at wallet create (initial - /// gap-limit population) and on any pool extension / "used" flip. + /// Full address-pool snapshots: emitted once at wallet registration. + /// Incremental derivations are delivered via `core.addresses_derived` + /// (the `WalletEvent` bus / FFI path); no per-block in-band pool + /// snapshot is written. The storage persister intentionally ignores this + /// field (UTXO attribution is hardcoded to account 0); non-storage + /// consumers (e.g. the iOS FFI address registry) may still read it. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, /// Shielded sub-wallet deltas: per-subwallet decrypted notes, diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 83b6d860742..128a38d29b3 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -1,36 +1,66 @@ //! Per-wallet portion of [`ClientStartState`](crate::changeset::ClientStartState). //! -//! Everything a single wallet contributes to the startup snapshot: the -//! key-wallet [`Wallet`] + [`ManagedWalletInfo`] pair, a lean -//! identity-manager snapshot, and still-unused asset locks bucketed by -//! account index. +//! **Keyless by type.** This carries everything needed to *reconstruct* +//! a watch-only wallet — network, birth height, the account manifest, +//! the rebuilt core-state projection, identities, filtered asset locks — +//! but **no** [`Wallet`](key_wallet::Wallet) and no seed. The persister +//! can never mint a `Wallet`; the manager rebuilds a watch-only one via +//! [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) +//! from the manifest, applies this state, and defers signing-key +//! derivation to the on-demand sign path +//! ([`sign_with_mnemonic_resolver`] and its siblings). +//! +//! [`sign_with_mnemonic_resolver`]: https://docs.rs/rs-platform-wallet-ffi/ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; +use crate::changeset::{ + AccountRegistrationEntry, ContactChangeSet, CoreChangeSet, IdentityKeysChangeSet, +}; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; -use key_wallet::wallet::ManagedWalletInfo; -use key_wallet::Wallet; +use key_wallet::Network; -/// Per-wallet slice of the startup snapshot. +/// Keyless per-wallet slice of the startup snapshot. /// -/// Used as the value type in [`ClientStartState::wallets`](crate::changeset::ClientStartState::wallets). +/// Used as the value type in +/// [`ClientStartState::wallets`](crate::changeset::ClientStartState::wallets). +/// The structural absence of a `Wallet`/seed field is the SECRETS.md +/// boundary, enforced by type rather than convention. #[derive(Debug)] pub struct ClientWalletStartState { - /// The key-wallet [`Wallet`] to rehydrate on startup. Carries the - /// HD key material and account configuration the rest of the - /// per-wallet state hangs off of. - pub wallet: Wallet, - /// Managed wallet info holding non-key-material state (balances, - /// account metadata, UTXO set, etc.) for this wallet. - pub wallet_info: ManagedWalletInfo, + /// Network the wallet is bound to (from `wallet_metadata`). + pub network: Network, + /// Best estimate of the chain tip at creation time (`0` = scan + /// from genesis / unknown). + pub birth_height: u32, + /// Keyless account manifest — the account-set oracle for building the + /// watch-only wallet (one watch-only account per entry's xpub). + pub account_manifest: Vec, + /// Keyless projection of the persisted core rows (UTXOs, tx + /// records, IS-locks, sync watermarks, `last_applied_chain_lock`). + /// The manager applies this onto a fresh + /// `ManagedWalletInfo::from_wallet` skeleton built from the + /// watch-only wallet. Rebuilt by the `core_state::load_state` reader + /// (item B). + pub core_state: CoreChangeSet, /// Lean snapshot of this wallet's - /// [`IdentityManager`](crate::wallet::identity::IdentityManager): - /// owned + watched identities, primary selection, and the - /// gap-limit scan watermark. + /// [`IdentityManager`](crate::wallet::identity::IdentityManager). pub identity_manager: IdentityManagerStartState, - /// Asset locks that have not yet been consumed by an identity - /// registration / top-up, keyed by account index → outpoint. + /// Asset locks not yet consumed by an identity registration / + /// top-up, keyed by account index → outpoint. Terminal `Consumed` + /// rows are already filtered out by the asset-lock reader. pub unused_asset_locks: BTreeMap>, + /// Persisted DashPay contact state (sent/received requests + + /// established contacts) to layer onto the rehydrated managed + /// identities. PUBLIC material — `removed_*` are always empty + /// (deletes never reach storage as rows). Routed by the manager + /// after `IdentityManager::from`, mirroring the runtime apply path. + pub contacts: ContactChangeSet, + /// Persisted per-identity PUBLIC key entries (no private key + /// material) to layer onto the rehydrated managed identities so + /// `Identity.public_keys` is populated at load time instead of + /// only after the next sync. `removed` is always empty. + pub identity_keys: IdentityKeysChangeSet, } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 46945667ef8..e477f2c6293 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -41,6 +41,40 @@ use crate::changeset::changeset::{CoreChangeSet, PlatformWalletChangeSet}; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::platform_wallet::PlatformWalletInfo; +/// Single-account observation. The storage writer hardcodes +/// `core_utxos.account_index = 0` (the product uses only the default +/// account, and that column drives only cosmetic per-account grouping). A +/// UTXO-bearing record owned by a non-default funds account is STILL +/// persisted under index 0 — never skipped, because skipping it would +/// undercount the wallet balance and lose funds. We only `warn!` so the +/// approximate grouping is visible. Identity/provider account types carry +/// no funds index (`AccountType::index() == None`) and never emit +/// `Received`/`Change` UTXOs, so they never warn. +/// +/// Accepts a slice so callers can pass a single-element +/// `std::slice::from_ref(r)` or a multi-record slice without allocation. +/// Logs once per non-default-account record. +fn warn_if_non_default_account(records: &[TransactionRecord]) { + for record in records { + if let Some(index) = non_default_account_index(record) { + tracing::warn!( + account_index = index, + txid = %record.txid, + "non-default account UTXO persisted under account_index 0; \ + per-account grouping is approximate" + ); + } + } +} + +/// The record's funds account index when it is a *non-default* (index != 0) +/// funds account, else `None`. Identity/provider account types carry no +/// funds index (`index() == None`) and never emit `Received`/`Change` +/// UTXOs, so they yield `None`. +fn non_default_account_index(record: &TransactionRecord) -> Option { + record.account_type.index().filter(|&index| index != 0) +} + /// Spawn the wallet-event subscriber task. /// /// Subscribes to `wallet_manager.subscribe_events()` from inside the @@ -129,18 +163,17 @@ async fn build_core_changeset( addresses_derived, .. } => { + // Persist regardless of account; warn on a non-default account. + warn_if_non_default_account(std::slice::from_ref(record.as_ref())); // Derive UTXO deltas before moving the record into `records` // so the per-record borrows are still live. CoreChangeSet { new_utxos: derive_new_utxos(record), spent_utxos: derive_spent_utxos(record), records: vec![(**record).clone()], - // Mirror the upstream-emitted derived addresses - // through to the persister so newly-extended pool - // rows are written transactionally with the tx that - // triggered the extension. See - // `CoreChangeSet.addresses_derived` for the cascade- - // link rationale. + // Forward the upstream-emitted derived addresses to the + // persister; the FFI layer feeds the iOS address registry + // from this delta. See `CoreChangeSet.addresses_derived`. addresses_derived: addresses_derived.clone(), ..CoreChangeSet::default() } @@ -171,11 +204,28 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. + // Inserted records bring fresh UTXOs and may consume previous + // ones — always project. Non-default-account records are tallied + // and surfaced in a single aggregated warn after the loop (rather + // than one warn per record) to keep a busy block quiet. + let mut non_default_count = 0usize; + let mut non_default_sample: Option = None; for r in inserted { + if non_default_account_index(r).is_some() { + non_default_count += 1; + non_default_sample.get_or_insert(r.txid); + } cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } + if non_default_count > 0 { + tracing::warn!( + non_default_count, + sample_txid = ?non_default_sample, + "non-default account UTXO(s) persisted under account_index 0; \ + per-account grouping is approximate" + ); + } // Updated records (re-confirmation, IS-lock applied to a known // mempool tx, etc.) don't usually change UTXO topology — the // record's content does change though, so re-emit it. @@ -357,3 +407,120 @@ impl CoreChangeSet { && self.addresses_derived.is_empty() } } + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use dashcore::blockdata::transaction::Transaction; + use dashcore::hashes::Hash; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + OutputDetail, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use key_wallet::WalletCoreBalance; + + use super::*; + + fn standard(index: u32) -> AccountType { + AccountType::Standard { + index, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + /// A throwaway testnet P2PKH address keyed off `seed`. + fn p2pkh(seed: u8) -> dashcore::Address { + use dashcore::address::Payload; + use dashcore::PubkeyHash; + dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([seed; 20])), + ) + } + + /// A confirmed `TransactionRecord` owned by `account_type` carrying a + /// single `Received` output worth `value` at `addr`, so + /// `derive_new_utxos` yields exactly one UTXO. + fn record_with_received_output( + account_type: AccountType, + addr: &dashcore::Address, + value: u64, + ) -> TransactionRecord { + let tx = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + tx, + account_type, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([3u8; 32]), + 1_735_689_600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(addr.clone()), + value, + }], + value as i64, + ) + } + + /// Project a `TransactionDetected` for `record` through the real bridge + /// path. `balance`/`account_balances` are unused by the projection. + async fn changeset_for(record: TransactionRecord) -> CoreChangeSet { + let wm = Arc::new(RwLock::new(WalletManager::::new( + key_wallet::Network::Testnet, + ))); + let event = WalletEvent::TransactionDetected { + wallet_id: [0u8; 32], + record: Box::new(record), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: Vec::new(), + }; + build_core_changeset(&wm, &event).await + } + + /// A default-account (index 0) UTXO is projected into the changeset. + #[tokio::test] + async fn default_account_utxo_persists() { + let addr = p2pkh(0x11); + let cs = changeset_for(record_with_received_output(standard(0), &addr, 500_000)).await; + assert_eq!( + cs.new_utxos.len(), + 1, + "the default-account UTXO must be projected" + ); + assert_eq!(cs.new_utxos[0].value(), 500_000); + } + + /// REGRESSION (fund-loss): a non-default-account (index != 0) UTXO is + /// STILL projected — never dropped. Storage persists it under + /// `account_index 0`; the only cost is approximate per-account grouping + /// (a `warn!` is logged). Dropping it would undercount the balance. + #[tokio::test] + async fn non_default_account_utxo_persists_under_zero() { + let addr = p2pkh(0x22); + let cs = changeset_for(record_with_received_output(standard(7), &addr, 900_000)).await; + assert_eq!( + cs.new_utxos.len(), + 1, + "a non-default-account UTXO must NOT be dropped" + ); + assert_eq!(cs.new_utxos[0].value(), 900_000, "funds preserved"); + } +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index c94cb7093d1..7e4893a41d4 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -4,12 +4,73 @@ use dpp::identifier::Identifier; use key_wallet::account::StandardAccountType; use key_wallet::Network; +use crate::manager::load_outcome::CorruptKind; + +/// Per-row failure surfacing during watch-only rehydration of a single +/// persisted wallet. Maps 1:1 to [`CorruptKind`] for the +/// [`SkipReason`](crate::manager::load_outcome::SkipReason) the load loop +/// records. +#[derive(Debug)] +pub(crate) enum RehydrateRowError { + /// Manifest was empty — no account to rebuild the wallet around. + MissingManifest, + /// Building a watch-only [`Account`](key_wallet::account::Account) from a + /// manifest entry failed (xpub structurally malformed for its + /// [`AccountType`](key_wallet::account::AccountType)). + MalformedXpub, + /// `AccountCollection::insert` rejected an account (typically a + /// duplicate `account_type` within the manifest). + DecodeError(String), +} + +impl From for CorruptKind { + fn from(e: RehydrateRowError) -> Self { + match e { + RehydrateRowError::MissingManifest => CorruptKind::MissingManifest, + RehydrateRowError::MalformedXpub => CorruptKind::MalformedXpub, + RehydrateRowError::DecodeError(s) => CorruptKind::DecodeError(s), + } + } +} + /// Errors that can occur in platform wallet operations #[derive(Debug, thiserror::Error)] pub enum PlatformWalletError { #[error("Wallet creation failed: {0}")] WalletCreation(String), + /// The persisted wallet has UTXOs to restore but no funds-bearing + /// account in its reconstructed account collection to hold them. + /// Fail-closed rather than reconstructing a silent zero balance — + /// the no-silent-zero mandate. Carries only the (public) wallet id + /// and the dropped-UTXO count, never key material. + #[error( + "rehydration topology unsupported for wallet {}: {utxo_count} persisted UTXO(s) but no funds-bearing account", + hex::encode(wallet_id) + )] + RehydrationTopologyUnsupported { + /// The wallet whose topology could not hold the persisted UTXOs. + wallet_id: [u8; 32], + /// How many persisted UTXOs would have been silently dropped. + utxo_count: usize, + }, + + /// The deep-index discovery probes did not mirror the account's real + /// address pools 1:1 during rehydration, so applying probe depths by + /// position would index the wrong pool. Fail-closed instead of risking + /// a misattributed derivation — the probes are built directly from the + /// same `address_pools()` enumeration, so a mismatch is a structural + /// invariant break, not user-reachable. + #[error( + "rehydration pool/probe mismatch: expected {expected} address pool(s) to mirror the discovery probes, found {found}" + )] + RehydrationPoolMismatch { + /// Number of discovery probes built from `address_pools()`. + expected: usize, + /// Number of real address pools from `address_pools_mut()`. + found: usize, + }, + #[error("Wallet not found: {0}")] WalletNotFound(String), diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index 9ac256e8730..f39a8126a5d 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -16,9 +16,34 @@ use arc_swap::ArcSwap; pub use dash_spv::EventHandler; pub use key_wallet_manager::WalletEvent; +use crate::manager::load_outcome::SkipReason; use crate::manager::platform_address_sync::PlatformAddressSyncSummary; #[cfg(feature = "shielded")] use crate::manager::shielded_sync::ShieldedSyncPassSummary; +use crate::wallet::platform_wallet::WalletId; + +/// Platform-wallet lifecycle event surfaced to app handlers. +/// +/// Distinct from the SPV `EventHandler` stream — these are +/// platform-specific notifications the app may react to (toast, +/// telemetry) without threading return values through every call site. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PlatformEvent { + /// A persisted wallet was skipped during + /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) + /// because its persisted row was corrupt (a structural decode / + /// projection failure). The load path is seedless, so the only + /// reason is [`SkipReason::CorruptPersistedRow`]. + /// + /// Carries the (public, non-secret) wallet id and the structural + /// [`SkipReason`]; never any secret byte. + WalletSkippedOnLoad { + /// The skipped wallet's id. + wallet_id: WalletId, + /// Why it was skipped — always a corrupt persisted row. + reason: SkipReason, + }, +} /// Extension of [`EventHandler`] for platform-wallet consumers. /// @@ -44,6 +69,33 @@ pub trait PlatformEventHandler: EventHandler { #[cfg(feature = "shielded")] fn on_shielded_sync_completed(&self, _summary: &ShieldedSyncPassSummary) {} + /// Fired once per wallet that + /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) + /// skipped because its persisted row was corrupt. + /// + /// Prefer this concrete overload over [`on_platform_event`](Self::on_platform_event) + /// — it matches the handler pattern used everywhere else on this trait and + /// removes the enum-dispatch indirection. The default implementation + /// delegates to `on_platform_event` so existing implementations that only + /// override `on_platform_event` continue to receive the event without any + /// changes. + fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) { + self.on_platform_event(&PlatformEvent::WalletSkippedOnLoad { + wallet_id, + reason: reason.clone(), + }); + } + + /// Fired once per wallet that + /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) + /// skipped because its persisted row was corrupt. + /// + /// **Deprecated in favour of [`on_wallet_skipped_on_load`](Self::on_wallet_skipped_on_load)**, + /// which follows the concrete-handler pattern used elsewhere on this trait. + /// The default implementation is a no-op; override + /// `on_wallet_skipped_on_load` for new code. + fn on_platform_event(&self, _event: &PlatformEvent) {} + /// Fired periodically during a shielded sync pass — once per /// completed chunk inside `sync_shielded_notes`. Carries the /// cumulative count of encrypted notes scanned so far in the @@ -142,6 +194,31 @@ impl PlatformEventManager { } } + /// Dispatch a wallet-skipped-on-load notification to every handler. + /// + /// Not on the SPV hot path — called at most once per wallet during + /// a single `load_from_persistor` pass. Prefer this over + /// [`on_platform_event`](Self::on_platform_event) for new call sites; + /// it avoids heap-allocating a `PlatformEvent` wrapper and follows the + /// concrete-handler pattern used throughout this manager. + pub fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) { + let handlers = self.handlers.load(); + for h in handlers.iter() { + h.on_wallet_skipped_on_load(wallet_id, reason); + } + } + + /// Dispatch a [`PlatformEvent`] to every handler. + /// + /// Not on the SPV hot path — called at most once per wallet during + /// a single `load_from_persistor` pass. + pub fn on_platform_event(&self, event: &PlatformEvent) { + let handlers = self.handlers.load(); + for h in handlers.iter() { + h.on_platform_event(event); + } + } + /// Dispatch a shielded sync progress event to every handler. /// /// Called from inside `sync_shielded_notes`'s chunk loop, once diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 289a71378fd..e9ddfb9c665 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -22,7 +22,7 @@ pub mod spv; pub mod wallet; pub use error::PlatformWalletError; -pub use events::{PlatformEventHandler, PlatformEventManager}; +pub use events::{PlatformEvent, PlatformEventHandler, PlatformEventManager}; pub use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; // Surface the upstream `DerivedAddress` event payload through this // crate so downstream FFI consumers (rs-platform-wallet-ffi) can @@ -40,6 +40,7 @@ pub use manager::identity_sync::{ DEFAULT_SYNC_INTERVAL_SECS as IDENTITY_SYNC_DEFAULT_INTERVAL_SECS, MAX_TOKENS_PER_BALANCE_BATCH as IDENTITY_SYNC_MAX_TOKENS_PER_BATCH, }; +pub use manager::load_outcome::{LoadOutcome, SkipReason}; pub use manager::platform_address_sync::{ PlatformAddressSyncManager, PlatformAddressSyncSummary, WalletSyncOutcome, DEFAULT_SYNC_INTERVAL_SECS, diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index 8730398f978..a467d910467 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -160,10 +160,6 @@ where persister: Arc

, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, - /// Monotonically increasing generation counter. Incremented each - /// time `start()` installs a new cancel token so the exiting - /// thread can tell whether its token is still current. - background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -204,7 +200,6 @@ where sdk, persister, background_cancel: StdMutex::new(None), - background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -401,7 +396,6 @@ where } let cancel = CancellationToken::new(); *guard = Some(cancel.clone()); - let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; drop(guard); let handle = tokio::runtime::Handle::current(); @@ -424,12 +418,8 @@ where } } - // Only clear the slot if no newer start() has - // installed a replacement token since we launched. if let Ok(mut guard) = this.background_cancel.lock() { - if this.background_generation.load(Ordering::Acquire) == my_gen { - *guard = None; - } + *guard = None; } }); }) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e7af9be1c7..267c2c00a04 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -3,8 +3,11 @@ use std::collections::BTreeMap; use std::sync::Arc; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; use crate::error::PlatformWalletError; +use crate::manager::load_outcome::{LoadOutcome, SkipReason}; use crate::wallet::core::WalletBalance; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -13,23 +16,42 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; impl PlatformWalletManager

{ - /// Load the full [`ClientStartState`] from the configured persister - /// and rehydrate the manager's `wallet_manager` and `wallets` maps. + /// Restore every persisted wallet as a **watch-only** entry — no + /// signing key material is derived here. The persister hands back a + /// keyless reconstruction snapshot; each wallet is rebuilt via + /// [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) + /// from its [`AccountRegistrationEntry`](crate::changeset::AccountRegistrationEntry) + /// manifest, the keyless core-state projection is applied, and the + /// result is registered into the manager. + /// + /// The load path never touches the seed, so it performs no wrong-seed + /// check. Signing happens later, on demand, via the configured + /// [`MnemonicResolverHandle`]. /// - /// For each persisted wallet this builds a `PlatformWalletInfo` from - /// the snapshot (core wallet info, identity manager, tracked asset - /// locks) and inserts the `(Wallet, PlatformWalletInfo)` pair into - /// the inner [`WalletManager`]. A matching [`PlatformWallet`] handle - /// is then constructed and registered in `self.wallets`. + /// # Skip vs hard-fail /// - /// If the snapshot includes platform-address provider state, each - /// per-wallet slice is handed to - /// [`PlatformAddressWallet::initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted); - /// wallets missing from that slice get a fresh - /// [`PlatformAddressWallet::initialize`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize). + /// - **Per-row decode/projection failure** (empty manifest, malformed + /// xpub, duplicate `account_type`, …): the wallet is **skipped** — + /// never inserted into `wallet_manager` / `self.wallets`, recorded + /// in [`LoadOutcome::skipped`] with a structural + /// [`SkipReason::CorruptPersistedRow`], and a + /// [`PlatformEvent::WalletSkippedOnLoad`] is emitted. One bad row + /// never aborts the others; the call still returns `Ok`. + /// - **Whole-load failure** (persister I/O, programmer error, the + /// no-silent-zero topology check in + /// [`apply_persisted_core_state`](super::rehydrate::apply_persisted_core_state)): + /// `Err(_)` — every wallet inserted earlier in this pass is + /// rolled back. Skipped wallets never entered the maps so the + /// rollback path never sees them. /// - /// [`WalletManager`]: key_wallet_manager::WalletManager - pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { + /// Platform-address provider state is restored per wallet via + /// [`initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted), + /// or a fresh + /// [`initialize`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize) + /// when the snapshot carries no slice for it. + /// + /// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle + pub async fn load_from_persistor(&self) -> Result { let ClientStartState { mut platform_addresses, wallets, @@ -46,47 +68,70 @@ impl PlatformWalletManager

{ let persister_dyn: Arc = Arc::clone(&self.persister) as _; - // Track every wallet successfully inserted into - // `wallet_manager` and `self.wallets` during this call so the - // batch is transactional: if any later iteration fails (id - // mismatch, `initialize_from_persisted` error), we walk back - // every prior insert before bailing. Without this, a clean - // retry would collide on `WalletManager::insert_wallet` - // returning `WalletAlreadyExists` for every previously-loaded - // wallet — half-poisoning the manager until the process - // restarts. The orphan state is observable across the FFI - // boundary with no Swift-side reset path, so transactional - // semantics matter for this hydration API. + // Transactional batch: every wallet inserted into + // `wallet_manager` / `self.wallets` is tracked so a later hard + // error walks back every prior insert. Skipped wallets never + // enter either map, so the rollback path never sees them. let mut inserted_in_manager: Vec = Vec::new(); let mut inserted_in_wallets: Vec = Vec::new(); let mut load_error: Option = None; + let mut outcome = LoadOutcome::default(); 'load: for (expected_wallet_id, wallet_state) in wallets { let ClientWalletStartState { - wallet, - wallet_info, + network, + birth_height, + account_manifest, + core_state, identity_manager, unused_asset_locks, + contacts, + identity_keys, } = wallet_state; - // Flatten the (account → outpoint → lock) map into the flat - // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` - // holds today. + // Build the watch-only wallet from the keyless manifest. A + // structural decode failure skips this row (per-row + // resilience) — it never aborts the batch and never inserts + // a degraded placeholder. + let wallet = match super::rehydrate::build_watch_only_wallet( + network, + expected_wallet_id, + &account_manifest, + ) { + Ok(w) => w, + Err(row_err) => { + let reason = SkipReason::CorruptPersistedRow { + kind: row_err.into(), + }; + outcome.skipped.push((expected_wallet_id, reason.clone())); + self.event_manager + .on_wallet_skipped_on_load(expected_wallet_id, &reason); + continue 'load; + } + }; + + // Mint the managed-info skeleton from the watch-only wallet, + // then apply the keyless persisted core state (UTXOs, sync + // watermarks, per-account balances). A wallet with persisted + // UTXOs but no funds account hard-fails here rather than + // reconstructing a silent zero balance. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); + if let Err(e) = super::rehydrate::apply_persisted_core_state( + &mut wallet_info, + &account_manifest, + &core_state, + ) { + load_error = Some(e); + break 'load; + } + + // Flatten the (account → outpoint → lock) map. let mut tracked_asset_locks = BTreeMap::new(); for (_account_index, account_locks) in unused_asset_locks { tracked_asset_locks.extend(account_locks); } let balance = Arc::new(WalletBalance::new()); - // Mirror the inner `ManagedWalletInfo.balance` (already - // recomputed from the freshly-loaded UTXO set on the FFI - // side via `update_balance`) into the lock-free `Arc` the - // UI reads. Without this, `wallet.balance()` reports zero - // for restored wallets even though the per-account totals - // and the inner `core_wallet.balance` are correct. - // `WalletBalance::set` is `pub(crate)`, which is why this - // step has to live inside `platform_wallet` rather than - // the FFI loader. let core_balance = &wallet_info.balance; balance.set( core_balance.confirmed(), @@ -94,17 +139,19 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); + // Build the identity manager from the (id, balance, + // revision) skeleton, then layer the persisted PUBLIC + // contacts + identity keys onto it — the same routing the + // runtime changeset-replay path uses. + let mut identity_manager = IdentityManager::from(identity_manager); + identity_manager.apply_contacts_and_keys(contacts, identity_keys, network); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), - identity_manager: IdentityManager::from(identity_manager), + identity_manager, tracked_asset_locks, }; - // Insert into `wallet_manager` first so we have a wallet - // handle to validate against. Track success in - // `inserted_in_manager` so the batch-rollback at the - // bottom can unwind on any later-iteration failure. let wallet_id = { let mut wm = self.wallet_manager.write().await; match wm.insert_wallet(wallet, platform_info) { @@ -120,15 +167,6 @@ impl PlatformWalletManager

{ }; inserted_in_manager.push(wallet_id); - if wallet_id != expected_wallet_id { - load_error = Some(PlatformWalletError::WalletCreation(format!( - "Persisted wallet id {} does not match recomputed id {}", - hex::encode(expected_wallet_id), - hex::encode(wallet_id) - ))); - break 'load; - } - let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( &self.spv_manager, ))); @@ -142,10 +180,6 @@ impl PlatformWalletManager

{ broadcaster, ); - // Initialize the platform-address provider. If the snapshot - // carried a slice for this wallet, restore it directly; - // otherwise do a fresh scan from the live wallet manager. - // Failures break to the rollback path below. if let Some(persisted) = platform_addresses.remove(&wallet_id) { if let Err(e) = platform_wallet .platform() @@ -167,13 +201,10 @@ impl PlatformWalletManager

{ wallets_guard.insert(wallet_id, platform_wallet); drop(wallets_guard); inserted_in_wallets.push(wallet_id); + outcome.loaded.push(wallet_id); } if let Some(err) = load_error { - // Walk back every wallet committed in this call so the - // manager state matches what it was before. Order: - // remove from `self.wallets` first (UI surface), then - // from the inner `wallet_manager`. if !inserted_in_wallets.is_empty() { let mut wallets_guard = self.wallets.write().await; for id in &inserted_in_wallets { @@ -189,6 +220,6 @@ impl PlatformWalletManager

{ return Err(err); } - Ok(()) + Ok(outcome) } } diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs new file mode 100644 index 00000000000..b71b28fe406 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -0,0 +1,77 @@ +//! Aggregate result of [`load_from_persistor`]. +//! +//! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor + +use crate::wallet::platform_wallet::WalletId; + +/// Why a persisted wallet row was skipped during a load pass. +/// +/// Load is **watch-only** (no seed material involved): signing keys are +/// derived later, on demand, via the [`MnemonicResolverHandle`] sign +/// path. A skip therefore means the persisted row itself was unusable — +/// a per-row decode/structural failure that fails one wallet without +/// aborting the batch. The only reason is +/// [`CorruptPersistedRow`](Self::CorruptPersistedRow): the load path +/// never touches the seed, so it cannot skip for a wrong or unavailable +/// seed. Variants carry no key material (SECRETS.md SEC-REQ-2.0.1). +/// +/// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SkipReason { + /// The persisted row could not be reconstructed: a structural decode + /// failure on the keyless account manifest or core-state projection. + /// `kind` distinguishes the failure mode without leaking row bytes. + #[error("persisted wallet row corrupt: {kind}")] + CorruptPersistedRow { + /// Structural family of the decode/projection failure. + kind: CorruptKind, + }, +} + +/// Structural family of [`SkipReason::CorruptPersistedRow`]. +/// +/// The variants are deliberately coarse — a finer split would require +/// the persister to round-trip backend error context that may carry +/// row-derived bytes. Apps drive their UI from the *family*, not from +/// the inner message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CorruptKind { + /// The wallet row exists but has no usable `AccountRegistrationEntry` + /// manifest to rebuild the account collection from. + MissingManifest, + /// One or more manifest `account_xpub` bytes failed to parse as a + /// well-formed extended public key. + MalformedXpub, + /// Any other structural decode / projection failure surfaced by the + /// persister. The string is a structural projection — never a raw + /// row byte slice or a hex-encoded key. + DecodeError(String), +} + +impl std::fmt::Display for CorruptKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingManifest => f.write_str("missing account manifest"), + Self::MalformedXpub => f.write_str("malformed account xpub"), + Self::DecodeError(s) => write!(f, "decode error: {s}"), + } + } +} + +/// Aggregate, synchronous view of one +/// [`load_from_persistor`](super::PlatformWalletManager::load_from_persistor) +/// pass. +/// +/// `Ok(LoadOutcome)` with a non-empty `skipped` is **success** — a +/// per-row decode failure on one wallet is recorded and the batch +/// continues. The `Err` arm is reserved for whole-load failures +/// (persister I/O, programmer error). The load path is watch-only and +/// never touches the seed, so no wrong-seed outcome appears here. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LoadOutcome { + /// Wallets fully reconstructed and registered, in load order. + pub loaded: Vec, + /// Wallets skipped because their persisted row was corrupt, in load + /// order. + pub skipped: Vec<(WalletId, SkipReason)>, +} diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3d04ca086d0..265b525277f 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -3,7 +3,9 @@ pub mod accessors; pub mod identity_sync; mod load; +pub mod load_outcome; pub mod platform_address_sync; +pub mod rehydrate; #[cfg(feature = "shielded")] pub mod shielded_sync; mod wallet_lifecycle; @@ -72,14 +74,18 @@ pub struct PlatformWalletManager { #[cfg(feature = "shielded")] pub(super) shielded_coordinator: Arc>>>, - /// Shared `PlatformEventManager` — held on the manager so - /// `configure_shielded` can install a per-chunk progress handler - /// onto the freshly-created `NetworkShieldedCoordinator` that - /// forwards into `on_shielded_sync_progress`. Sub-managers + /// Shared `PlatformEventManager`, retained on the manager for the + /// two callers that fan out platform-wallet events directly: + /// `load_from_persistor` surfaces per-wallet + /// [`PlatformEvent`](crate::events::PlatformEvent) skip + /// notifications to the app handler, and (under the `shielded` + /// feature) `configure_shielded` installs a per-chunk progress + /// handler onto the freshly-created `NetworkShieldedCoordinator` + /// that forwards into `on_shielded_sync_progress`. Sub-managers /// (`SpvRuntime`, `PlatformAddressSyncManager`, etc.) hold their - /// own clones already, so `configure_shielded` is the only reader of - /// this retained handle — hence it is `shielded`-gated. - #[cfg(feature = "shielded")] + /// own clones already. Retained unconditionally because + /// `load_from_persistor` reads it regardless of the `shielded` + /// feature. pub(super) event_manager: Arc, pub(super) persister: Arc

, /// Cancellation token + join handle for the wallet-event adapter @@ -161,7 +167,6 @@ impl PlatformWalletManager

{ shielded_sync_manager: shielded_sync, #[cfg(feature = "shielded")] shielded_coordinator, - #[cfg(feature = "shielded")] event_manager, persister, event_adapter_cancel, diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs new file mode 100644 index 00000000000..61a3694d520 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -0,0 +1,1323 @@ +//! Watch-only wallet reconstruction + persisted core-state application. +//! +//! Load is **seedless** (see [`load_from_persistor`]). For each +//! persisted wallet we build a watch-only [`Wallet`] from its keyless +//! `AccountRegistrationEntry` manifest, then apply the keyless +//! core-state projection on top. No seed, no signing-key derivation. +//! +//! Because load never touches the seed, it performs no wrong-seed check. +//! A sign-time wrong-seed gate is deferred to separate FFI work and is +//! not part of this path. +//! +//! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor + +use key_wallet::account::account_collection::AccountCollection; +use key_wallet::account::Account; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Network; + +use crate::changeset::AccountRegistrationEntry; +use crate::error::{PlatformWalletError, RehydrateRowError}; + +/// Build a watch-only [`Wallet`] from the keyless account manifest. +/// +/// Each `AccountRegistrationEntry` becomes an [`Account::from_xpub`] +/// (watch-only) keyed to `expected_wallet_id`; the assembled +/// [`AccountCollection`] is handed to [`Wallet::new_watch_only`] under +/// the same id. No key material crosses this function. +/// +/// Returns [`RehydrateRowError`] when the row is structurally unusable +/// (caller maps it onto a per-row [`SkipReason`]). +pub(super) fn build_watch_only_wallet( + network: Network, + expected_wallet_id: [u8; 32], + manifest: &[AccountRegistrationEntry], +) -> Result { + if manifest.is_empty() { + return Err(RehydrateRowError::MissingManifest); + } + let mut accounts = AccountCollection::new(); + for entry in manifest { + let account = Account::from_xpub( + Some(expected_wallet_id), + entry.account_type, + entry.account_xpub, + network, + ) + .map_err(|_| RehydrateRowError::MalformedXpub)?; + accounts + .insert(account) + .map_err(|e| RehydrateRowError::DecodeError(e.to_string()))?; + } + Ok(Wallet::new_watch_only( + network, + expected_wallet_id, + accounts, + )) +} + +/// Apply the keyless persisted core-state projection onto a +/// freshly-minted `ManagedWalletInfo` skeleton. +/// +/// # Parameters +/// +/// - `wallet_info`: the skeleton to hydrate in place. +/// - `manifest`: keyless account manifest (one entry per registered +/// account). Each entry carries an `account_type` → `account_xpub` +/// mapping used by [`extend_pools_for_restored_utxos`] to derive +/// addresses for restored UTXOs. If an account's `account_type` is +/// absent from the manifest, deep-index derivation is skipped for that +/// account (no xpub → no derivation possible); already-derived in-window +/// addresses are still marked used. +/// - `core`: the persisted core-state changeset to apply. +/// +/// # Reconstructed (safety-critical-correct) +/// +/// - **Wallet balance** (`wallet_info.balance`, the no-silent-zero +/// guarantee): every persisted UTXO is restored and the per-account +/// + wallet totals are recomputed via `update_balance()`. A UTXO +/// carrying a block height is marked confirmed so it lands in the +/// `confirmed` bucket; the wallet total is exact regardless. +/// - **UTXO set**: every unspent persisted outpoint is restored into a +/// funds-bearing account of the wallet (whatever topology it has — +/// BIP44, BIP32, CoinJoin, DashPay). +/// - **Address-pool depth**: each pool is forward-derived to cover +/// restored UTXOs at deep derivation indices, then the gap window is +/// refilled beyond the deepest restored index so the per-address view +/// reconciles with the wallet total. +/// - **Sync watermarks**: `synced_height` / `last_processed_height`. +/// +/// # Reconstructed when the persister supplies it +/// +/// - **`last_applied_chain_lock`**: restored from `core` when the +/// supplied [`CoreChangeSet`](crate::changeset::CoreChangeSet) carries +/// it (the FFI/iOS persister round-trips the value Swift held), so the +/// asset-lock-resume CL-from-metadata fallback (`proof.rs`) fires at +/// launch instead of waiting for SPV. The SQLite storage path has no +/// V001 column for it yet (dashpay/platform#3968), so there it is +/// absent from `core` and stays `None` until SPV re-applies a fresh +/// chainlock on the first post-restart sync. +/// +/// # Deferred to the first post-load `sync` (safe re-warm) +/// +/// - **Per-account UTXO attribution**: `core_utxos.account_index` is +/// written as `0` at persist time, so per-account bucketing is not +/// recoverable from disk; UTXOs are restored against the wallet's +/// first funds-bearing account and re-attributed on the next scan. +/// The *wallet total* is unaffected (it is a sum across all funds +/// accounts). +/// - **Deep-index address visibility**: each chain's pool scan stops +/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after `gap_limit` +/// consecutive non-matching indices past the deepest resolved index. +/// The horizon only advances when an unspent UTXO anchors a match, so a +/// UTXO address can be left unresolved in two distinct cases: (1) it is +/// genuinely foreign (a different account's key routed here, or corrupt), +/// and (2) it is a *legitimately-owned but deep-and-sparse* address — +/// owned by this account, yet sitting past the first `gap_limit` window +/// with no nearer unspent UTXO to walk the horizon out to it. Both cases +/// are counted and logged via `tracing::warn!` and re-warm on the next +/// full sync. The wallet *total* stays exact (every UTXO is summed +/// regardless of pool visibility); only the per-address view is +/// incomplete until that sync. This is the accepted behavior of the +/// horizon-walk algorithm — see [`extend_pools_for_restored_utxos`]. +/// - **Per-UTXO `is_coinbase` / `is_instantlocked` / `is_trusted` +/// flags**: not columns in `core_utxos`; conservatively defaulted +/// (non-coinbase, confirmed-by-height) and refreshed on the next +/// scan. Coinbase-maturity nuance re-warms on sync. +/// - **Transaction-record history**: rebuilt by the next scan; not a +/// balance input. +/// +/// # Errors +/// +/// [`PlatformWalletError::RehydrationTopologyUnsupported`] if there are +/// persisted UTXOs to restore but the reconstructed account collection +/// has **no** funds-bearing account to hold them. Fail-closed rather +/// than reconstructing a silent zero balance (the no-silent-zero +/// mandate). An empty UTXO set is always `Ok`. +/// +/// This never touches key material. +pub fn apply_persisted_core_state( + wallet_info: &mut ManagedWalletInfo, + manifest: &[AccountRegistrationEntry], + core: &crate::changeset::CoreChangeSet, +) -> Result<(), PlatformWalletError> { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + // Captured before the mutable account borrow below so it can flow into + // pool-extension diagnostics without re-borrowing `wallet_info`. + let wallet_id = wallet_info.wallet_id; + + // Sync watermarks first so `update_balance`'s maturity check sees + // the restored tip. + if let Some(h) = core.last_processed_height { + wallet_info.metadata.last_processed_height = + wallet_info.metadata.last_processed_height.max(h); + } + if let Some(h) = core.synced_height { + wallet_info.metadata.synced_height = wallet_info.metadata.synced_height.max(h); + } + + // Restore the highest applied chainlock when the persister carries it + // (FFI path) so the asset-lock proof CL-from-metadata fallback fires at launch. + if let Some(cl) = &core.last_applied_chain_lock { + wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); + } + + // Restore the UTXO set. Persisted attribution is lost at write time + // (account_index is always 0), so route every restored UTXO to the + // wallet's first funds-bearing account *of any topology* (BIP44, + // BIP32, CoinJoin, DashPay) — the wallet total is a sum across all + // funds accounts and stays exact. A wallet with persisted UTXOs but + // no funds account at all cannot be represented: fail closed rather + // than silently reconstruct a zero balance. + let unspent: Vec<&key_wallet::Utxo> = core + .new_utxos + .iter() + .filter(|u| !core.spent_utxos.iter().any(|s| s.outpoint == u.outpoint)) + .collect(); + if !unspent.is_empty() { + match wallet_info + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + { + Some(account) => { + for utxo in &unspent { + account.utxos.insert(utxo.outpoint, (*utxo).clone()); + } + // Eager derivation covers only `0..=gap_limit`; extend each + // chain to cover restored UTXOs at deeper indices. + extend_pools_for_restored_utxos(account, manifest, &unspent, wallet_id)?; + } + None => { + return Err(PlatformWalletError::RehydrationTopologyUnsupported { + wallet_id, + utxo_count: unspent.len(), + }); + } + } + } + + // Recompute per-account + wallet balance from the restored set. + // After this, a non-zero persisted balance is non-zero here — a + // silent zero would be a hard FAIL of the rehydration contract. + wallet_info.update_balance(); + Ok(()) +} + +/// Upper bound on forward derivation while resolving a restored UTXO +/// address to its derivation index. Addresses that don't resolve within +/// this many indices (e.g. they belong to a different funds account whose +/// UTXOs were routed here, or are corrupt) are left for the next full +/// rescan to re-warm — generous enough to cover any realistic per-account +/// derivation depth. The common (single funds account) path terminates at +/// the true high-water mark well before this and never reaches the cap. +const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; + +/// Soft threshold past which a single chain's discovery scan is treated as +/// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor +/// well below this; reaching it means either a corrupt / foreign-heavy UTXO +/// set walking the horizon out, or an approach toward the hard +/// [`MAX_REHYDRATION_DERIVATION_INDEX`] ceiling — both worth surfacing. +const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; + +/// Extend `account`'s address pools so every resolved UTXO address is +/// derived at its exact `(chain, index)` slot and marked used, then refill +/// the gap window beyond — following the sync path's `mark_used` → +/// `maintain_gap_limit` sequence. Each chain is scanned independently, +/// stopping once no unresolved address matches within a `gap_limit`-sized +/// window past the deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] +/// is the hard ceiling. Addresses that don't resolve from this account's +/// xpub — foreign keys, multi-account mismatch, or legitimately-owned but +/// deep-and-sparse slots with no nearer unspent UTXO to anchor the horizon — +/// are counted and logged via `tracing::warn!`; they re-warm on the next +/// full sync. Every restored address the pools *do* hold (in-window or +/// deep-resolved) is marked used so a funded address is never handed out as +/// a fresh receive address. +/// +/// Tested with Standard BIP44 topology (External + Internal pools) and +/// CoinJoin topology (single External pool). The per-chain probe loop has no +/// topology-specific branches, so the non-hardened single-pool type +/// (`Absent`) follows the same code path with a different relative derivation +/// path. `AbsentHardened` pools cannot be derived from a public xpub at all — +/// hardened child derivation needs the private key — so under watch-only +/// rehydration their addresses never resolve and always defer to the next +/// sync (shared code path, but the outcome is "unresolved"). +/// +/// # Errors +/// +/// [`PlatformWalletError::RehydrationPoolMismatch`] if the discovery probes +/// don't mirror the real pools 1:1 (a structural invariant break, not +/// user-reachable). Fail-closed rather than apply a probe depth to the wrong +/// pool by position. +/// +/// Never touches key material — the xpub is the keyless account public key. +fn extend_pools_for_restored_utxos( + account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, + manifest: &[AccountRegistrationEntry], + restored: &[&key_wallet::Utxo], + wallet_id: [u8; 32], +) -> Result<(), PlatformWalletError> { + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use std::collections::{BTreeSet, HashSet}; + + let account_type = account.managed_account_type().to_account_type(); + + // The funds account carries no key material; recover its watch-only xpub + // from the keyless manifest by account type. Without it we cannot derive + // deeper, but can still mark already-derived (in-window) addresses used. + let key_source = manifest + .iter() + .find(|e| e.account_type == account_type) + .map(|e| KeySource::Public(e.account_xpub)); + + // Probe pools mirror each real pool's chain 1:1 so the index search + // derives into throwaway state (real pools keep their own exact depth) + // and the resolved depth can be applied back by position. Re-deriving + // each probe from index 0 is an accepted, bounded one-time-load cost + // (per chain capped at MAX_REHYDRATION_DERIVATION_INDEX); rehydration + // runs once per wallet at startup, never on a hot path. + let mut probes: Vec<(AddressPool, BTreeSet)> = account + .managed_account_type() + .address_pools() + .iter() + .map(|p| { + ( + AddressPool::new_without_generation( + p.base_path.clone(), + p.pool_type, + p.gap_limit, + p.network, + ), + BTreeSet::new(), + ) + }) + .collect(); + + // Deep-index discovery (requires the xpub): resolve restored addresses the + // eager derivation didn't already cover, recording the matching index per + // chain. Each chain advances independently and stops once no unresolved + // address resolves within gap_limit indices past its deepest match + // (preventing a full scan when the UTXO set carries foreign addresses); + // MAX_REHYDRATION_DERIVATION_INDEX is the hard ceiling regardless. + if let Some(key_source) = key_source.as_ref() { + let mut unresolved: HashSet = { + let pools = account.managed_account_type().address_pools(); + restored + .iter() + .map(|u| u.address.clone()) + .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) + .collect() + }; + + for (probe, matched) in probes.iter_mut() { + if unresolved.is_empty() { + break; + } + let chain_gap = probe.gap_limit; + let mut deepest_resolved: Option = None; + let mut index: u32 = 0; + + loop { + // Horizon: gap_limit past the deepest match, or the initial + // gap_limit window when nothing has resolved yet. + let horizon = deepest_resolved + .map(|d| d.saturating_add(chain_gap)) + .unwrap_or(chain_gap); + + if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX { + break; + } + + if let Some(addr) = ensure_derived(probe, key_source, index) { + if unresolved.remove(&addr) { + matched.insert(index); + deepest_resolved = Some(index); + } + } + + if unresolved.is_empty() { + break; + } + + index = index.saturating_add(1); + } + + // Surface an abnormally deep scan once per chain (outside the loop + // — never log inside the per-index walk). + if index > REHYDRATION_DEEP_SCAN_WARN_INDEX { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?probe.pool_type, + deepest_resolved = ?deepest_resolved, + scanned_to = index.saturating_sub(1), + "rehydration: chain discovery scanned abnormally deep — \ + likely a foreign-heavy or sparse UTXO set" + ); + } + } + + // Still-unresolved addresses are either foreign (a different account's + // key routed here, or corrupt) or legitimately-owned but deep-and-sparse + // (past the first gap window with no nearer unspent UTXO to anchor the + // horizon). Either way they re-warm on the next full sync; the wallet + // total is exact regardless. + if !unresolved.is_empty() { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + unresolved_count = unresolved.len(), + "rehydration: UTXO address(es) unresolved for this account xpub \ + — will re-warm on next sync; balance total is exact" + ); + } + } + + // No explicit aggregate-derivation cap is needed: a funds account exposes + // a fixed, small number of chains (Standard = 2, others = 1), each already + // capped at MAX_REHYDRATION_DERIVATION_INDEX, so total derivation is bounded + // by chains × MAX with no unbounded growth — an aggregate cap would either + // equal that natural bound (no-op) or clip a legitimate deep multi-chain + // wallet. The per-chain ceiling plus the deep-scan warn above are the + // proportionate guard against a corrupt/foreign-heavy UTXO set. + + // Apply discovered depths and mark restored addresses used. `probes` is + // built directly from `address_pools()`, so it mirrors `address_pools_mut()` + // 1:1 and in chain order; verify that invariant before zipping by position. + let mut pools = account.managed_account_type_mut().address_pools_mut(); + if pools.len() != probes.len() { + return Err(PlatformWalletError::RehydrationPoolMismatch { + expected: probes.len(), + found: pools.len(), + }); + } + for (pool, (probe, matched)) in pools.iter_mut().zip(probes.iter()) { + // `iter_mut()` over `Vec<&mut AddressPool>` yields `&mut &mut _`; + // reborrow once so the pool flows into `ensure_derived` cleanly. + let pool: &mut AddressPool = pool; + debug_assert_eq!( + pool.pool_type, probe.pool_type, + "probe/pool chain order must match for by-position depth apply" + ); + + // Derive up to the deepest discovered index so its address exists in + // the real pool before we mark it used. + if let Some(&deepest) = matched.iter().next_back() { + if let Some(key_source) = key_source.as_ref() { + if ensure_derived(pool, key_source, deepest).is_none() { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?pool.pool_type, + index = deepest, + "rehydration: failed to derive resolved index into pool; \ + deferring its address to the next sync" + ); + } + } + } + + // Mark every restored address this pool now holds as used — covers both + // deep-resolved addresses (just derived) and in-window addresses the + // discovery scan never visits. Without this an already-derived but + // funded address keeps `used = false` and could be handed out as a fresh + // receive address. `mark_used` is a no-op for addresses not in this + // pool, so an underived (foreign / sparse) index is never marked. + let mut marked_any = false; + for u in restored { + if pool.mark_used(&u.address) { + marked_any = true; + } + } + + // Refill the gap window past the deepest used index (needs the xpub). + if marked_any { + if let Some(key_source) = key_source.as_ref() { + if let Err(e) = pool.maintain_gap_limit(key_source) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?pool.pool_type, + error = %e, + "rehydration: gap-limit maintenance failed; pool window \ + may be short until the next sync" + ); + } + } + } + } + Ok(()) +} + +/// Ensure `pool` has derived through `index` (generating only the missing +/// tail), and return that index's address. `None` only on a derivation +/// error. +fn ensure_derived( + pool: &mut key_wallet::managed_account::address_pool::AddressPool, + key_source: &key_wallet::managed_account::address_pool::KeySource, + index: u32, +) -> Option { + let needs_more = match pool.highest_generated { + Some(highest) => highest < index, + None => true, + }; + if needs_more { + let start = pool.highest_generated.map(|h| h + 1).unwrap_or(0); + pool.generate_addresses(index - start + 1, key_source, true) + .ok()?; + } + pool.address_at_index(index) +} + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + fn manifest_for(w: &Wallet) -> Vec { + w.accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect() + } + + #[test] + fn watch_only_rebuild_round_trips_manifest_and_id() { + let seed = [3u8; 64]; + let w = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id = w.compute_wallet_id(); + let manifest = manifest_for(&w); + + let restored = build_watch_only_wallet(Network::Testnet, id, &manifest).unwrap(); + assert_eq!(restored.wallet_id, id); + assert_eq!(restored.compute_wallet_id(), id); + // Every manifest account survives the round trip (count, types). + let restored_types: Vec<_> = restored + .accounts + .all_accounts() + .into_iter() + .map(|a| a.account_type) + .collect(); + let manifest_types: Vec<_> = manifest.iter().map(|e| e.account_type).collect(); + assert_eq!(restored_types.len(), manifest_types.len()); + for t in &manifest_types { + assert!(restored_types.contains(t)); + } + } + + #[test] + fn empty_manifest_is_missing_manifest() { + let err = build_watch_only_wallet(Network::Testnet, [0u8; 32], &[]) + .expect_err("empty manifest must be MissingManifest"); + assert!(matches!(err, RehydrateRowError::MissingManifest)); + } + + /// Regression: after restart-in-place the watch-only pools eagerly + /// cover only `0..=gap_limit`, but persisted UTXOs can sit at deeper + /// derivation indices. Rehydration must extend each chain's pool to its + /// deepest restored index so the per-address view reconciles with the + /// wallet total instead of undercounting. + /// + /// Index layout (gap_limit = 30): + /// - external idx 3: within eager window (not in `unresolved`), balance included + /// - external idx 30: first index past eager window; anchors the initial scan + /// window and extends it to idx 60 + /// - external idx 50: within extended window (50 < 60), resolved + /// - internal idx 30: within initial scan window, resolved + /// + /// Standard BIP44 topology (External + Internal pools) is exercised. + /// Asserts that maintain_gap_limit fills beyond the deepest resolved. + #[test] + fn rehydration_extends_pools_to_cover_deep_index_utxos() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [7u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + + // Mint the watch-only skeleton (pools cover only the eager gap + // window) and resolve the first funds account's keyless xpub. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Derive addresses on each chain from the same account xpub the + // pools use; `base_path` is record-keeping only and does not affect + // the derived address, so DerivationPath::master() is fine here. + let derive = |pool_type, index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + pool_type, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + + // idx 3: within eager window (0..=29) — covered by init, NOT in + // unresolved. Contributes to balance but needs no pool extension. + let shallow_recv = derive(AddressPoolType::External, 3); + // idx 30: first past eager window; falls in initial scan window + // (horizon = gap_limit = 30 on a chain with no prior matches). + // Anchors the external probe and extends horizon to 60. + let mid_recv = derive(AddressPoolType::External, 30); + // idx 50: within the extended window (50 < 30+30=60), resolved. + let deep_recv = derive(AddressPoolType::External, 50); + // idx 30: within the internal chain's initial scan window (<=30). + let deep_change = derive(AddressPoolType::Internal, 30); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let new_utxos = vec![ + utxo(shallow_recv, 1_000, 1), + utxo(mid_recv.clone(), 10_000, 2), + utxo(deep_recv.clone(), 20_000, 3), + utxo(deep_change.clone(), 300_000, 4), + ]; + let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); + let core = crate::changeset::CoreChangeSet { + new_utxos, + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), expected_total); + + // The per-address view joins pool addresses to UTXOs; every + // resolved UTXO address must now be derived into a pool. + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet

= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, expected_total, + "all UTXO addresses (including deep-index) must be derived into their pools" + ); + + // Each deep address resolves to its exact derivation slot. + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let internal = pools.iter().find(|p| p.is_internal()).unwrap(); + assert_eq!(external.address_at_index(30).as_ref(), Some(&mid_recv)); + assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv)); + assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change)); + + // maintain_gap_limit must refill BEYOND the deepest restored + // index so the gap window is actually exercised, not just the restore. + // Deepest external resolved = idx 50; gap window must reach >= 50+30=80. + let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT; + assert!( + external.highest_generated >= Some(expected_min_gen), + "maintain_gap_limit must extend external pool to >= {} (got {:?})", + expected_min_gen, + external.highest_generated, + ); + } + + /// A UTXO whose address is not derivable from this account's + /// xpub (foreign key, multi-account mismatch) must not cause a panic or + /// hang. The total balance is exact (the UTXO is in the set regardless), + /// but the foreign address is absent from the pool so per-address + /// visibility is reduced. `tracing::warn!` fires for the unresolved count. + #[test] + fn rehydration_unresolvable_address_is_deferred_not_panics() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [13u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Normal UTXO at external index 3 (within eager window, pool-visible). + let normal_addr = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + // Foreign address: derive from a completely different wallet seed so + // it cannot be resolved from this wallet's xpub. + let foreign_addr = { + let fw = Wallet::from_seed_bytes( + [99u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let fw_info = ManagedWalletInfo::from_wallet(&fw, 1); + fw_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap() + .managed_account_type() + .address_pools() + .first() + .unwrap() + .address_at_index(0) + .unwrap() + }; + assert_ne!( + normal_addr, foreign_addr, + "test fixture: foreign address must differ from normal" + ); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let normal_val = 100_000u64; + let foreign_val = 200_000u64; + let expected_total = normal_val + foreign_val; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![ + utxo(normal_addr, normal_val, 1), + utxo(foreign_addr, foreign_val, 2), + ], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + // Must not panic. tracing::warn! fires for the unresolved count. + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // Total balance is exact — foreign UTXO is in the set regardless. + assert_eq!( + wallet_info.balance.total(), + expected_total, + "total must include foreign UTXO even though it is unresolved" + ); + + // Per-address visible: only the normal UTXO is in the pool. + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet
= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, normal_val, + "only the non-foreign UTXO is pool-visible; foreign deferred to re-warm" + ); + assert!( + visible < expected_total, + "foreign UTXO is deferred — per-address visible < total" + ); + } + + /// CoinJoin topology (single External pool, no Internal chain). + /// Verifies that `extend_pools_for_restored_utxos` handles a single-pool + /// account at a deep derivation index (idx 30, just past the eager window). + #[test] + fn rehydration_coinjoin_single_pool_deep_index() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Utxo; + use std::collections::BTreeSet; + + // CoinJoin-only wallet: no BIP44, one CoinJoin account at index 0. + let mut cj_set = BTreeSet::new(); + cj_set.insert(0u32); + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + cj_set, + BTreeSet::new(), + BTreeSet::new(), + None, + ); + let seed = [11u8; 64]; + let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, opts).unwrap(); + assert!( + !wallet.accounts.coinjoin_accounts.is_empty(), + "fixture must have a CoinJoin account" + ); + + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // Extract pool metadata before the mutable borrow of wallet_info. + let (funds_type, pool_base_path, pool_type_val, pool_gap_limit) = { + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("CoinJoin account must be the only funds account"); + let ft = funds.managed_account_type().to_account_type(); + let pools = funds.managed_account_type().address_pools(); + // CoinJoin has a single pool (External). + assert_eq!( + pools.len(), + 1, + "CoinJoin topology: must have exactly one pool" + ); + let p = &pools[0]; + (ft, p.base_path.clone(), p.pool_type, p.gap_limit) + }; + + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("CoinJoin xpub must be in manifest"); + + // Derive the CoinJoin address at index 30 (first past the eager + // window 0..=29) using the real pool's base_path and pool_type. + let mut probe = AddressPool::new_without_generation( + pool_base_path, + pool_type_val, + pool_gap_limit, + Network::Testnet, + ); + probe + .generate_addresses(31, &KeySource::Public(xpub), true) + .unwrap(); + let deep_cj_addr = probe.address_at_index(30).unwrap(); + + let utxo_val = 7_777u64; + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from([7u8; 32]), + vout: 0, + }, + txout: TxOut { + value: utxo_val, + script_pubkey: deep_cj_addr.script_pubkey(), + }, + address: deep_cj_addr.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![utxo], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // Balance is exact. + assert_eq!( + wallet_info.balance.total(), + utxo_val, + "CoinJoin deep-index balance must be exact" + ); + + // The CoinJoin pool was extended to include the deep-index address. + let funds_post = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let cj_pool = &funds_post.managed_account_type().address_pools()[0]; + assert_eq!( + cj_pool.address_at_index(30).as_ref(), + Some(&deep_cj_addr), + "CoinJoin pool must be extended to cover deep-index address at idx 30" + ); + } + + /// In-window restored UTXO: an address already covered by the eager + /// derivation (idx 3, inside `0..=gap_limit-1`) must still be marked + /// `used` during rehydration. The discovery scan never visits in-window + /// addresses, so without an explicit mark pass a funded address would keep + /// `used = false` and could later be handed out as a fresh receive address. + #[test] + fn rehydration_marks_in_window_restored_address_used() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 3 — inside the eager window, so NOT in the discovery set. + let in_window: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([1u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 12_345, + script_pubkey: in_window.script_pubkey(), + }, + address: in_window.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let info = external + .address_info(&in_window) + .expect("in-window address must be present in the pool"); + assert!( + info.used, + "in-window restored UTXO address must be marked used" + ); + assert!( + external.used_indices.contains(&3), + "used_indices must record the in-window slot" + ); + assert_eq!( + external.highest_used, + Some(3), + "highest_used must reflect the in-window slot" + ); + } + + /// Documented limitation (solution b): a legitimately-owned but + /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx + /// <= 30 — is left unresolved because the discovery horizon (gap_limit + /// past the deepest match) never advances far enough to reach it. The + /// wallet total stays exact; only the per-address view is incomplete + /// until the next sync (a `tracing::warn!` records the deferral). + #[test] + fn rehydration_deep_sparse_utxo_left_unresolved_total_exact() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let wallet = Wallet::from_seed_bytes( + [21u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 45 — past the eager window AND past the initial scan + // window (horizon = gap_limit = 30 with no nearer match to extend it). + let sparse_deep: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(46, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(45).unwrap() + }; + + let value = 500_000u64; + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([4u8; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: sparse_deep.script_pubkey(), + }, + address: sparse_deep.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), value); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + !external.contains_address(&sparse_deep), + "deep-sparse idx 45 must be left unresolved (absent from the pool)" + ); + + // Per-address view: the deep-sparse UTXO is not pool-visible yet. + let pool_addresses: HashSet
= pools + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, 0, + "the deep-sparse UTXO is deferred — not pool-visible until next sync" + ); + assert!(visible < value, "per-address visible < exact total"); + } + + /// Topology guard: a wallet with persisted UTXOs but NO funds-bearing + /// account cannot hold them — fail closed with + /// `RehydrationTopologyUnsupported` (reporting the persisted count) rather + /// than reconstruct a silent zero balance. + #[test] + fn rehydration_utxos_without_funds_account_errors() { + use dashcore::address::Payload; + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::hashes::Hash; + use dashcore::{OutPoint, PubkeyHash, Txid}; + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::BTreeSet; + + // Keys-only wallet: a single IdentityRegistration account, no funds. + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([23u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.accounts.all_funding_accounts().is_empty(), + "fixture must have NO funds-bearing account" + ); + + let addr = Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])), + ); + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([2u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 800_000, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core) + .expect_err("must fail closed when no funds account can hold the UTXOs"); + match err { + PlatformWalletError::RehydrationTopologyUnsupported { utxo_count, .. } => { + assert_eq!(utxo_count, 1, "utxo_count must match the persisted set"); + } + other => panic!("expected RehydrationTopologyUnsupported, got {other:?}"), + } + } + + /// Companion to the topology guard: the same keys-only wallet with an + /// EMPTY persisted UTXO set is `Ok` — there is nothing to hold, so the + /// guard does not trip. + #[test] + fn rehydration_no_funds_account_empty_utxos_ok() { + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use std::collections::BTreeSet; + + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([24u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!(wallet_info.accounts.all_funding_accounts().is_empty()); + + let core = crate::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state(&mut wallet_info, &manifest, &core) + .expect("empty UTXO set must be Ok even with no funds account"); + } + + /// Regression: a `last_applied_chain_lock` carried in the persisted + /// `CoreChangeSet` must be restored onto the rehydrated wallet + /// metadata. Without it, the asset-lock-resume CL-from-metadata + /// fallback (`proof.rs`) cannot fire at app launch and a pre-restart + /// chain-locked asset lock can't produce a proof until SPV re-applies + /// a fresh chainlock. Fails (`None != Some`) if the apply step drops it. + #[test] + fn rehydration_restores_last_applied_chain_lock() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.metadata.last_applied_chain_lock.is_none(), + "fresh watch-only skeleton starts with no chain lock" + ); + + let cl = ChainLock { + block_height: 123_456, + block_hash: BlockHash::from_byte_array([7u8; 32]), + signature: [9u8; 96].into(), + }; + let core = crate::changeset::CoreChangeSet { + last_applied_chain_lock: Some(cl.clone()), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + assert_eq!( + wallet_info.metadata.last_applied_chain_lock.as_ref(), + Some(&cl), + "persisted last_applied_chain_lock must be restored onto wallet metadata" + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 482674b4322..b38812bfc50 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -141,14 +141,6 @@ pub struct ShieldedSyncManager { coordinator_slot: Arc>>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, - /// Monotonically increasing generation counter. Bumped on every - /// `start()` so the exiting thread can tell whether its - /// generation is still the active one before clearing - /// `background_cancel`. Without this, a `stop()` → `start()` - /// overlap lets the prior thread's cleanup strip the new - /// generation's token, leaving the new loop running but - /// untrackable via `is_running()`. - background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -171,7 +163,6 @@ impl ShieldedSyncManager { event_manager, coordinator_slot, background_cancel: StdMutex::new(None), - background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -228,10 +219,6 @@ impl ShieldedSyncManager { } let cancel = CancellationToken::new(); *guard = Some(cancel.clone()); - // Bump the generation while we still hold the slot lock so - // the load below in any prior thread's cleanup observes - // `current_gen != my_gen` ordered against this token swap. - let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; drop(guard); let handle = tokio::runtime::Handle::current(); @@ -261,16 +248,8 @@ impl ShieldedSyncManager { } } - // Only clear `background_cancel` if the active - // generation is still ours. Without this guard a - // tight `stop()` → `start()` reschedule has the - // exiting thread overwrite the *new* generation's - // token, leaving the new loop running but - // unreflectable via `is_running()` / `stop()`. - if this.background_generation.load(Ordering::Acquire) == my_gen { - if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; - } + if let Ok(mut guard) = this.background_cancel.lock() { + *guard = None; } }); }) diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 8c690543ee0..099b77f97fd 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -148,93 +148,17 @@ impl PlatformWalletInfo { } } - // 2b. Identity keys. Runs after the scalar identity pass so - // the owning ManagedIdentity is guaranteed to exist before - // we layer keys into it. Upserts land first, then removals, - // matching the discipline used across the rest of this - // function. Orphan entries (owner not in the wallet) are - // logged and skipped by the per-entry apply helpers. - if let Some(keys_cs) = identity_keys { - let crate::changeset::IdentityKeysChangeSet { upserts, removed } = keys_cs; - // Thread the wallet network through so the key-apply - // path can reproduce DIP-9 derivation paths for any - // entry that carries `(wallet_id, derivation_indices)`. - let network = wallet.network; - for (_key, entry) in upserts { - self.identity_manager - .apply_identity_key_entry(entry, network); - } - for (identity_id, key_id) in removed { - self.identity_manager - .apply_identity_key_removal(&identity_id, key_id); - } - } - - // 3. Contacts. Each entry routes to its owning ManagedIdentity by - // `(owner, contact)` key; orphans (owner not in the wallet) - // are logged and skipped. Trivial map ops (sent / incoming - // insert and remove) are inlined here — no helper earns its - // name for a single `insert` / `shift_remove` call. Only - // `apply_established_contact` is a method because it has - // real logic (drops both pending sides per the contract). - if let Some(contact_cs) = contacts { - let crate::changeset::ContactChangeSet { - sent_requests, - removed_sent, - incoming_requests, - removed_incoming, - established, - } = contact_cs; - - for (key, entry) in sent_requests { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed - .sent_contact_requests - .insert(entry.request.recipient_id, entry.request); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping sent contact request during apply: owner identity not in wallet" - ), - } - } - for (key, entry) in incoming_requests { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed - .incoming_contact_requests - .insert(entry.request.sender_id, entry.request); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping incoming contact request during apply: owner identity not in wallet" - ), - } - } - for key in removed_sent { - if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.sent_contact_requests.remove(&key.recipient_id); - } - } - for key in removed_incoming { - if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.incoming_contact_requests.remove(&key.sender_id); - } - } - // Established promotions — drop any matching pending - // entries on both sides per the auto-establishment contract. - for (key, established) in established { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed.apply_established_contact(established); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping established contact during apply: owner identity not in wallet" - ), - } - } + // 2b/3. Identity keys + contacts. Keys are layered before + // contacts so a contact entry never lands before its + // owner's keys; orphans are logged and skipped. Single + // source of truth shared with the persister rehydration + // path (`load_from_persistor`). + if identity_keys.is_some() || contacts.is_some() { + self.identity_manager.apply_contacts_and_keys( + contacts.unwrap_or_default(), + identity_keys.unwrap_or_default(), + wallet.network, + ); } // 3b. DashPay profile/payment overlays. Applied AFTER identities diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 7d04f29c538..09dd930c0cc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -15,7 +15,7 @@ //! [`IdentityManager::apply_identity_key_entry`]. use super::{IdentityLocation, IdentityManager}; -use crate::changeset::{IdentityEntry, IdentityKeyEntry}; +use crate::changeset::{ContactChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet}; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -181,4 +181,85 @@ impl IdentityManager { managed.identity.public_keys_mut().remove(&key_id); } } + + /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the + /// already-restored managed identities. + /// + /// Single source of truth for the contact / identity-key routing — + /// shared by the runtime changeset-replay path + /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)) + /// and the persister rehydration path + /// ([`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)). + /// Identity keys are applied first so a contact entry never lands + /// before its owner's keys; orphan entries (owner not in the + /// wallet) are logged and skipped, never fatal. `removed_*` are + /// honoured for the replay path; the rehydration feed leaves them + /// empty. + pub(crate) fn apply_contacts_and_keys( + &mut self, + contacts: ContactChangeSet, + identity_keys: IdentityKeysChangeSet, + network: Network, + ) { + let IdentityKeysChangeSet { upserts, removed } = identity_keys; + for (_key, entry) in upserts { + self.apply_identity_key_entry(entry, network); + } + for (identity_id, key_id) in removed { + self.apply_identity_key_removal(&identity_id, key_id); + } + + let ContactChangeSet { + sent_requests, + removed_sent, + incoming_requests, + removed_incoming, + established, + } = contacts; + for (key, entry) in sent_requests { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed + .sent_contact_requests + .insert(entry.request.recipient_id, entry.request); + } + None => tracing::warn!( + owner = %key.owner_id, + "skipping sent contact request: owner identity not in wallet" + ), + } + } + for (key, entry) in incoming_requests { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed + .incoming_contact_requests + .insert(entry.request.sender_id, entry.request); + } + None => tracing::warn!( + owner = %key.owner_id, + "skipping incoming contact request: owner identity not in wallet" + ), + } + } + for key in removed_sent { + if let Some(managed) = self.managed_identity_mut(&key.owner_id) { + managed.sent_contact_requests.remove(&key.recipient_id); + } + } + for key in removed_incoming { + if let Some(managed) = self.managed_identity_mut(&key.owner_id) { + managed.incoming_contact_requests.remove(&key.sender_id); + } + } + for (key, established) in established { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => managed.apply_established_contact(established), + None => tracing::warn!( + owner = %key.owner_id, + "skipping established contact: owner identity not in wallet" + ), + } + } + } } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs new file mode 100644 index 00000000000..292b6bf6f29 --- /dev/null +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -0,0 +1,277 @@ +//! Item E — `load_from_persistor` (seedless / watch-only) end-to-end +//! through a real `PlatformWalletManager`. +//! +//! Scope after the seedless rework: load reconstructs every persisted +//! wallet **watch-only** from its keyless account manifest. The load +//! path never touches the seed, so it performs no wrong-seed check; a +//! sign-time gate is deferred separate FFI work and is not part of this +//! path. Per-row decode failures surface as +//! [`SkipReason::CorruptPersistedRow`] without aborting the batch. +//! +//! RT cases here: +//! - RT-WO: round-trip — watch-only wallet is registered after reload. +//! - RT-Corrupt: a row with an empty manifest is skipped with +//! `MissingManifest`, the other row loads, a `WalletSkippedOnLoad` +//! event fires, `load` returns `Ok`. +//! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason` +//! surface (the structural-only contract). + +use std::sync::{Arc, Mutex}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, CoreChangeSet, + PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::manager::load_outcome::CorruptKind; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::{PlatformWalletManager, SkipReason}; + +// ---- test doubles ---- + +/// Persister whose `load()` returns a fixed keyless `ClientStartState`. +struct FixedLoadPersister { + state: Mutex>, +} + +impl FixedLoadPersister { + fn new() -> Self { + Self { + state: Mutex::new(None), + } + } + fn set(&self, s: ClientStartState) { + *self.state.lock().unwrap() = Some(s); + } +} + +impl PlatformWalletPersistence for FixedLoadPersister { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + // Rebuild a fresh ClientStartState each call (load may be + // called twice for the recoverability sub-case). + let guard = self.state.lock().unwrap(); + match guard.as_ref() { + None => Ok(ClientStartState::default()), + Some(s) => { + let mut out = ClientStartState::default(); + for (id, w) in &s.wallets { + out.wallets.insert( + *id, + ClientWalletStartState { + network: w.network, + birth_height: w.birth_height, + account_manifest: w.account_manifest.clone(), + core_state: w.core_state.clone(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }, + ); + } + Ok(out) + } + } + } +} + +/// Event handler that records every wallet-skipped-on-load notification. +#[derive(Default)] +struct RecordingHandler { + skipped: Mutex>, +} +impl EventHandler for RecordingHandler {} +impl PlatformEventHandler for RecordingHandler { + fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) { + self.skipped + .lock() + .unwrap() + .push((wallet_id, reason.clone())); + } +} + +// ---- harness ---- + +fn manifest_and_id(seed: [u8; 64]) -> (Vec, [u8; 32]) { + let w = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = w + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + (manifest, w.compute_wallet_id()) +} + +fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { + let (manifest, id) = manifest_and_id(seed); + ( + id, + ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest: manifest, + core_state: CoreChangeSet::default(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }, + ) +} + +async fn manager( + persister: Arc, + handler: Arc, +) -> Arc> { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + Arc::new(PlatformWalletManager::new(sdk, persister, handler)) +} + +// ---- tests ---- + +/// RT-WO: seedless watch-only round-trip — a persisted wallet loads and +/// is registered after reload (no signing material needed). +#[tokio::test] +async fn rt_wo_watch_only_roundtrip() { + let seed = [0x11; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id, s) = slice(seed); + let mut st = ClientStartState::default(); + st.wallets.insert(id, s); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + + assert_eq!(outcome.loaded, vec![id]); + assert!(outcome.skipped.is_empty()); + assert!( + mgr.get_wallet(&id).await.is_some(), + "watch-only restored wallet must be registered" + ); + assert_eq!(mgr.wallet_ids().await, vec![id]); +} + +/// RT-Corrupt: a corrupt row (empty manifest) is skipped with +/// `MissingManifest`; the other row loads cleanly; the load returns +/// `Ok`; exactly one `WalletSkippedOnLoad` event fires for the skipped +/// row. +#[tokio::test] +async fn rt_corrupt_row_skipped_and_other_loads() { + let seed_a = [0x31; 64]; + let seed_b = [0x32; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id_a, sa) = slice(seed_a); + let (id_b, _sb) = slice(seed_b); + + // B's row is structurally corrupt — empty manifest. + let sb_corrupt = ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest: Vec::new(), + core_state: CoreChangeSet::default(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }; + + let mut st = ClientStartState::default(); + st.wallets.insert(id_a, sa); + st.wallets.insert(id_b, sb_corrupt); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr + .load_from_persistor() + .await + .expect("Ok despite per-row skip"); + + assert!(outcome.loaded.contains(&id_a), "A loads fully"); + assert!(!outcome.loaded.contains(&id_b), "B is skipped, not loaded"); + assert_eq!(outcome.skipped.len(), 1); + let (skipped_id, skipped_reason) = &outcome.skipped[0]; + assert_eq!(*skipped_id, id_b); + assert!(matches!( + skipped_reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::MissingManifest + } + )); + assert!(mgr.get_wallet(&id_a).await.is_some()); + assert!( + mgr.get_wallet(&id_b).await.is_none(), + "corrupt row must be ABSENT, not a degraded placeholder" + ); + + // Exactly one on_wallet_skipped_on_load notification for B. + { + let skipped = h.skipped.lock().unwrap(); + assert_eq!(skipped.len(), 1, "exactly one skip notification expected"); + let (skipped_wallet_id, skipped_reason) = &skipped[0]; + assert_eq!(*skipped_wallet_id, id_b); + assert!(matches!( + skipped_reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::MissingManifest + } + )); + } +} + +/// RT-Z: no key/seed material leaks into `LoadOutcome` / +/// `SkipReason::CorruptPersistedRow` surfaces. The seedless load path +/// never sees seed bytes so this is mostly a sentinel guard against +/// future regression where someone embeds row contents in `DecodeError`. +#[tokio::test] +async fn rt_z_secret_hygiene_surfaces() { + let seed = [0xAB; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id, _s) = slice(seed); + + // Corrupt row to force a skip and inspect every public surface. + let corrupt = ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest: Vec::new(), + core_state: CoreChangeSet::default(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }; + let mut st = ClientStartState::default(); + st.wallets.insert(id, corrupt); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + let dbg = format!("{outcome:?}"); + // 0xAB seed bytes must not appear hex-rendered anywhere. + assert!(!dbg.to_lowercase().contains(&"ab".repeat(10))); + // The structural skip reason renders without any row bytes. + for (_, reason) in &outcome.skipped { + let rendered = format!("{reason} {reason:?}"); + assert!(!rendered.to_lowercase().contains(&"ab".repeat(10))); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368ef..2ead77cdbdb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -323,15 +323,17 @@ public class PlatformWalletManager: ObservableObject { /// /// Calls `platform_wallet_manager_load_from_persistor` which fires /// the Swift-side `on_load_wallet_list_fn` callback. For each - /// persisted wallet, Rust reconstructs a **watch-only** `Wallet` - /// plus the wallet's persisted platform-address sync snapshot. - /// After the FFI returns, we call `platform_wallet_manager_get_wallet` - /// for each restored id so Swift gets a `ManagedPlatformWallet` - /// handle. + /// persisted wallet, Rust rebuilds a **watch-only** `Wallet` from + /// its keyless account manifest (`Wallet::new_watch_only`) and + /// applies the persisted platform-address sync snapshot. After the + /// FFI returns we call `platform_wallet_manager_get_wallet` for + /// each restored id so Swift gets a `ManagedPlatformWallet` handle. /// - /// Signing operations will fail until a future unlock flow - /// upgrades a watch-only wallet to a signing wallet via the - /// mnemonic stored in Keychain. + /// Signing happens on demand via the configured + /// `MnemonicResolverHandle`: each resolver-fed sign entrypoint + /// fail-closed gates the resolved seed against the loaded + /// `wallet_id` and surfaces a structural wrong-seed error on + /// mismatch (no keys cross that surface). /// /// Idempotent: if there's no persisted state, does nothing and /// leaves `self.wallets` untouched. Safe to call before any @@ -340,7 +342,12 @@ public class PlatformWalletManager: ObservableObject { public func loadFromPersistor() throws -> [ManagedPlatformWallet] { try ensureConfigured() - try platform_wallet_manager_load_from_persistor(handle).check() + // Pass nil for `out_outcome` — Swift doesn't currently consume + // the per-wallet skip summary (corrupt persisted rows are + // logged by Rust at warn level). When Swift starts surfacing + // skipped wallets to the UI, pass a `LoadOutcomeFFI` here and + // free it with `platform_wallet_load_outcome_free`. + try platform_wallet_manager_load_from_persistor(handle, nil).check() // Ask SwiftData for the list of wallet ids we just told Rust // to load. We reuse the same container rather than shipping a From 35bd2917bbdb0dfd14c2a6ae4f3271b162f47846 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:39:21 +0000 Subject: [PATCH 02/60] refactor(platform-wallet): drop backward-compat on_platform_event; concrete handlers only (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove `PlatformEventHandler::on_platform_event` (the generic backward-compat escape hatch) and `PlatformEventManager::on_platform_event` entirely. `on_wallet_skipped_on_load` now has a plain no-op default, matching the pattern used by every other concrete handler on the trait. `PlatformEvent` is kept: it is `pub`, re-exported from `lib.rs`, and not present in the FFI or Swift layer — no dead-code warning applies to public items, and removing it would be a needless churn of the public API. Not a breaking change vs v3.1-dev: `on_platform_event` was only ever on this branch (absent from origin/v3.1-dev). Doc comments in manager/load.rs and manager/mod.rs updated to point to `on_wallet_skipped_on_load` instead of the removed method/event wrapper. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet/src/events.rs | 40 ++----------------- .../rs-platform-wallet/src/manager/load.rs | 5 ++- .../rs-platform-wallet/src/manager/mod.rs | 7 ++-- 3 files changed, 10 insertions(+), 42 deletions(-) diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index f39a8126a5d..30a9c033f8f 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -73,28 +73,8 @@ pub trait PlatformEventHandler: EventHandler { /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) /// skipped because its persisted row was corrupt. /// - /// Prefer this concrete overload over [`on_platform_event`](Self::on_platform_event) - /// — it matches the handler pattern used everywhere else on this trait and - /// removes the enum-dispatch indirection. The default implementation - /// delegates to `on_platform_event` so existing implementations that only - /// override `on_platform_event` continue to receive the event without any - /// changes. - fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) { - self.on_platform_event(&PlatformEvent::WalletSkippedOnLoad { - wallet_id, - reason: reason.clone(), - }); - } - - /// Fired once per wallet that - /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) - /// skipped because its persisted row was corrupt. - /// - /// **Deprecated in favour of [`on_wallet_skipped_on_load`](Self::on_wallet_skipped_on_load)**, - /// which follows the concrete-handler pattern used elsewhere on this trait. - /// The default implementation is a no-op; override - /// `on_wallet_skipped_on_load` for new code. - fn on_platform_event(&self, _event: &PlatformEvent) {} + /// Default impl is a no-op so existing handlers don't have to care. + fn on_wallet_skipped_on_load(&self, _wallet_id: WalletId, _reason: &SkipReason) {} /// Fired periodically during a shielded sync pass — once per /// completed chunk inside `sync_shielded_notes`. Carries the @@ -197,10 +177,7 @@ impl PlatformEventManager { /// Dispatch a wallet-skipped-on-load notification to every handler. /// /// Not on the SPV hot path — called at most once per wallet during - /// a single `load_from_persistor` pass. Prefer this over - /// [`on_platform_event`](Self::on_platform_event) for new call sites; - /// it avoids heap-allocating a `PlatformEvent` wrapper and follows the - /// concrete-handler pattern used throughout this manager. + /// a single `load_from_persistor` pass. pub fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) { let handlers = self.handlers.load(); for h in handlers.iter() { @@ -208,17 +185,6 @@ impl PlatformEventManager { } } - /// Dispatch a [`PlatformEvent`] to every handler. - /// - /// Not on the SPV hot path — called at most once per wallet during - /// a single `load_from_persistor` pass. - pub fn on_platform_event(&self, event: &PlatformEvent) { - let handlers = self.handlers.load(); - for h in handlers.iter() { - h.on_platform_event(event); - } - } - /// Dispatch a shielded sync progress event to every handler. /// /// Called from inside `sync_shielded_notes`'s chunk loop, once diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 267c2c00a04..de0debbdc1f 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -34,8 +34,9 @@ impl PlatformWalletManager

{ /// xpub, duplicate `account_type`, …): the wallet is **skipped** — /// never inserted into `wallet_manager` / `self.wallets`, recorded /// in [`LoadOutcome::skipped`] with a structural - /// [`SkipReason::CorruptPersistedRow`], and a - /// [`PlatformEvent::WalletSkippedOnLoad`] is emitted. One bad row + /// [`SkipReason::CorruptPersistedRow`], and + /// [`on_wallet_skipped_on_load`](crate::PlatformEventHandler::on_wallet_skipped_on_load) + /// is called on each registered handler. One bad row /// never aborts the others; the call still returns `Ok`. /// - **Whole-load failure** (persister I/O, programmer error, the /// no-silent-zero topology check in diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 265b525277f..57fa3784348 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -76,9 +76,10 @@ pub struct PlatformWalletManager { Arc>>>, /// Shared `PlatformEventManager`, retained on the manager for the /// two callers that fan out platform-wallet events directly: - /// `load_from_persistor` surfaces per-wallet - /// [`PlatformEvent`](crate::events::PlatformEvent) skip - /// notifications to the app handler, and (under the `shielded` + /// `load_from_persistor` surfaces per-wallet wallet-skipped-on-load + /// notifications to the app handler via + /// [`on_wallet_skipped_on_load`](crate::PlatformEventHandler::on_wallet_skipped_on_load), + /// and (under the `shielded` /// feature) `configure_shielded` installs a per-chunk progress /// handler onto the freshly-created `NetworkShieldedCoordinator` /// that forwards into `on_shielded_sync_progress`. Sub-managers From a5b7032b73fa6830c3639c256a66701840f8dee8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:11:17 +0000 Subject: [PATCH 03/60] docs(platform-wallet): point seedless-load notes at the resolver wrong-seed gate (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rehydrate module header and the rehydration_load test header both claimed the wrong-seed gate was "deferred to separate FFI work and is not part of this path." That gate now exists on the resolver-backed signing entrypoints (sign_with_mnemonic_resolver + the FFI resolver sign path). Reword to say wrong-seed validation lives there; the seedless load path never sees the seed. Docs-only, no behaviour change. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet/src/manager/rehydrate.rs | 6 ++++-- packages/rs-platform-wallet/tests/rehydration_load.rs | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 61a3694d520..fa3e5cae9df 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -6,8 +6,10 @@ //! core-state projection on top. No seed, no signing-key derivation. //! //! Because load never touches the seed, it performs no wrong-seed check. -//! A sign-time wrong-seed gate is deferred to separate FFI work and is -//! not part of this path. +//! Wrong-seed validation lives in the resolver-backed signing +//! entrypoints (`sign_with_mnemonic_resolver` and the FFI resolver sign +//! path), which fail-closed gate the resolver-supplied seed against the +//! loaded `wallet_id`; the seedless load path here never sees the seed. //! //! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 292b6bf6f29..fb22ebf9a9f 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -3,10 +3,10 @@ //! //! Scope after the seedless rework: load reconstructs every persisted //! wallet **watch-only** from its keyless account manifest. The load -//! path never touches the seed, so it performs no wrong-seed check; a -//! sign-time gate is deferred separate FFI work and is not part of this -//! path. Per-row decode failures surface as -//! [`SkipReason::CorruptPersistedRow`] without aborting the batch. +//! path never touches the seed, so it performs no wrong-seed check; +//! wrong-seed validation lives in the resolver-backed signing +//! entrypoints, not in this load path. Per-row decode failures surface +//! as [`SkipReason::CorruptPersistedRow`] without aborting the batch. //! //! RT cases here: //! - RT-WO: round-trip — watch-only wallet is registered after reload. From 22ff11c801808133140da39c74e3a551b6d4b574 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:12:04 +0000 Subject: [PATCH 04/60] perf(platform-wallet): O(n) UTXO spent-filter on rehydrate via outpoint HashSet (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apply_persisted_core_state filtered new_utxos against spent_utxos with a nested `any()`, making the unspent projection O(new × spent). Collect the spent outpoints into a HashSet once and do O(1) membership lookups — behaviour is identical (Copy OutPoint, Hash + Eq), just linear. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet/src/manager/rehydrate.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index fa3e5cae9df..e57e13ce066 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -173,10 +173,12 @@ pub fn apply_persisted_core_state( // funds accounts and stays exact. A wallet with persisted UTXOs but // no funds account at all cannot be represented: fail closed rather // than silently reconstruct a zero balance. + let spent_outpoints: std::collections::HashSet = + core.spent_utxos.iter().map(|u| u.outpoint).collect(); let unspent: Vec<&key_wallet::Utxo> = core .new_utxos .iter() - .filter(|u| !core.spent_utxos.iter().any(|s| s.outpoint == u.outpoint)) + .filter(|u| !spent_outpoints.contains(&u.outpoint)) .collect(); if !unspent.is_empty() { match wallet_info From 2e710276a7fdb7ae4c3e218755a3912330665790 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:19:04 +0000 Subject: [PATCH 05/60] fix(platform-wallet)!: restore persisted address-pool used-state on rehydrate (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FFI build_wallet_start_state decoded the persisted core address pools (used flags + derived addresses) into a temp wallet_info, but the keyless ClientWalletStartState forwarded only the account manifest + UTXO/height projection — the pool used-state was dropped. apply_persisted_core_state then marked addresses used ONLY from currently-unspent UTXOs, so a previously-used address whose funds were since spent came back marked unused and could be handed out again as a fresh receive address: an address-reuse privacy leak. Carry the used-state through: - Add ClientWalletStartState::used_core_addresses (Vec

, empty default) — a flat snapshot of every pool-marked-used address. - Populate it in the FFI projection from the already-decoded pools. - apply_persisted_core_state now marks used the UNION of unspent-UTXO addresses + used_core_addresses (new param), deriving deep slots via the existing horizon walk. Renamed extend_pools_for_restored_utxos -> extend_pools_for_restored_addresses since it now resolves both sources. Empty used_core_addresses preserves the prior unspent-only behaviour, so the native/SQLite path is unchanged until dashpay/platform#3968 wires its pool readers to populate this field (cross-PR follow-up; no regression). Also fixes the O(new x spent) unspent filter via an outpoint HashSet. Test: rehydration_restores_persisted_used_state_for_spent_out_address asserts an in-window and a deep spent-out address come back used, and that the empty-snapshot baseline does NOT mark them. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet-ffi/src/persistence.rs | 23 ++ .../changeset/client_wallet_start_state.rs | 13 +- .../rs-platform-wallet/src/manager/load.rs | 2 + .../src/manager/rehydrate.rs | 212 +++++++++++++++--- .../tests/rehydration_load.rs | 4 + 5 files changed, 227 insertions(+), 27 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 9fdd545b582..8e20e155b12 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3425,6 +3425,28 @@ fn build_wallet_start_state( // would need a new cross-boundary struct field + Swift wiring, // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` // a no-op for this path, preserving the established iOS behaviour. + // Carry the persisted pool used-state through the keyless projection. + // The pool-decode block above already merged the persisted `used` + // flags into `wallet_info`; project the used addresses out so + // `apply_persisted_core_state` can re-mark them used on rehydrate. + // Without this a previously-used address whose funds were since spent + // comes back marked unused and could be handed out again as a fresh + // receive address — an address-reuse privacy leak. + let used_core_addresses: Vec = { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let mut used = Vec::new(); + for acct in wallet_info.accounts.all_funding_accounts() { + for pool in acct.managed_account_type().address_pools() { + for info in pool.addresses.values() { + if info.used { + used.push(info.address.clone()); + } + } + } + } + used + }; + let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, @@ -3434,6 +3456,7 @@ fn build_wallet_start_state( unused_asset_locks, contacts: Default::default(), identity_keys: Default::default(), + used_core_addresses, }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 128a38d29b3..61d2cb5727b 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -20,7 +20,7 @@ use crate::changeset::{ }; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; -use key_wallet::Network; +use key_wallet::{Address, Network}; /// Keyless per-wallet slice of the startup snapshot. /// @@ -63,4 +63,15 @@ pub struct ClientWalletStartState { /// `Identity.public_keys` is populated at load time instead of /// only after the next sync. `removed` is always empty. pub identity_keys: IdentityKeysChangeSet, + /// Addresses the persisted pool snapshot marked **used**, flattened + /// across every funds account / pool. `apply_persisted_core_state` + /// derives each into its pool slot (if needed) and marks it used, in + /// union with the still-unspent UTXO addresses. This is the + /// address-reuse guard: a previously-used address whose funds were + /// since spent must never be handed back out as a fresh receive + /// address. EMPTY default = no pool used-state carried, so rehydrate + /// falls back to marking only currently-unspent UTXO addresses (the + /// native/SQLite persister until dashpay/platform#3968 wires its pool + /// readers to populate this). + pub used_core_addresses: Vec
, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index de0debbdc1f..8379affe0a4 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -88,6 +88,7 @@ impl PlatformWalletManager

{ unused_asset_locks, contacts, identity_keys, + used_core_addresses, } = wallet_state; // Build the watch-only wallet from the keyless manifest. A @@ -121,6 +122,7 @@ impl PlatformWalletManager

{ &mut wallet_info, &account_manifest, &core_state, + &used_core_addresses, ) { load_error = Some(e); break 'load; diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index e57e13ce066..cd8e10b957d 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -67,12 +67,17 @@ pub(super) fn build_watch_only_wallet( /// - `wallet_info`: the skeleton to hydrate in place. /// - `manifest`: keyless account manifest (one entry per registered /// account). Each entry carries an `account_type` → `account_xpub` -/// mapping used by [`extend_pools_for_restored_utxos`] to derive +/// mapping used by [`extend_pools_for_restored_addresses`] to derive /// addresses for restored UTXOs. If an account's `account_type` is /// absent from the manifest, deep-index derivation is skipped for that /// account (no xpub → no derivation possible); already-derived in-window /// addresses are still marked used. /// - `core`: the persisted core-state changeset to apply. +/// - `used_pool_addresses`: addresses the persisted pool snapshot marked +/// used (across all accounts/pools). Marked used in union with the +/// still-unspent UTXO addresses so a previously-used address whose funds +/// were since spent is never re-handed-out as a fresh receive address +/// (address-reuse guard). Empty = no pool used-state carried. /// /// # Reconstructed (safety-critical-correct) /// @@ -88,6 +93,9 @@ pub(super) fn build_watch_only_wallet( /// restored UTXOs at deep derivation indices, then the gap window is /// refilled beyond the deepest restored index so the per-address view /// reconciles with the wallet total. +/// - **Address-pool used-state**: every `used_pool_addresses` entry is +/// re-marked used (in union with the unspent-UTXO addresses), so an +/// address whose funds were since spent is not re-handed-out as fresh. /// - **Sync watermarks**: `synced_height` / `last_processed_height`. /// /// # Reconstructed when the persister supplies it @@ -122,7 +130,7 @@ pub(super) fn build_watch_only_wallet( /// full sync. The wallet *total* stays exact (every UTXO is summed /// regardless of pool visibility); only the per-address view is /// incomplete until that sync. This is the accepted behavior of the -/// horizon-walk algorithm — see [`extend_pools_for_restored_utxos`]. +/// horizon-walk algorithm — see [`extend_pools_for_restored_addresses`]. /// - **Per-UTXO `is_coinbase` / `is_instantlocked` / `is_trusted` /// flags**: not columns in `core_utxos`; conservatively defaulted /// (non-coinbase, confirmed-by-height) and refreshed on the next @@ -143,6 +151,7 @@ pub fn apply_persisted_core_state( wallet_info: &mut ManagedWalletInfo, manifest: &[AccountRegistrationEntry], core: &crate::changeset::CoreChangeSet, + used_pool_addresses: &[key_wallet::Address], ) -> Result<(), PlatformWalletError> { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -180,6 +189,18 @@ pub fn apply_persisted_core_state( .iter() .filter(|u| !spent_outpoints.contains(&u.outpoint)) .collect(); + + // Addresses to derive-and-mark-used: the still-unspent UTXO addresses + // PLUS the persisted pool used-state. The latter restores addresses + // whose funds were since spent — without it a previously-used address + // comes back marked unused and could be handed out again as a fresh + // receive address (address-reuse privacy leak). Empty + // `used_pool_addresses` (the native/SQLite path until + // dashpay/platform#3968) preserves the prior unspent-only behaviour. + let mut addresses_to_mark: Vec = + unspent.iter().map(|u| u.address.clone()).collect(); + addresses_to_mark.extend(used_pool_addresses.iter().cloned()); + if !unspent.is_empty() { match wallet_info .accounts @@ -192,8 +213,13 @@ pub fn apply_persisted_core_state( account.utxos.insert(utxo.outpoint, (*utxo).clone()); } // Eager derivation covers only `0..=gap_limit`; extend each - // chain to cover restored UTXOs at deeper indices. - extend_pools_for_restored_utxos(account, manifest, &unspent, wallet_id)?; + // chain to cover restored / used addresses at deeper indices. + extend_pools_for_restored_addresses( + account, + manifest, + &addresses_to_mark, + wallet_id, + )?; } None => { return Err(PlatformWalletError::RehydrationTopologyUnsupported { @@ -202,6 +228,20 @@ pub fn apply_persisted_core_state( }); } } + } else if !addresses_to_mark.is_empty() { + // No unspent UTXOs to hold, but persisted used-state still needs + // re-marking so spent-out addresses aren't re-handed-out. Apply to + // the first funds account; a funds-less wallet has no pool to mark + // (and no UTXOs at risk), so this is a no-op without the topology + // guard — that guard only fires for unspent UTXOs above. + if let Some(account) = wallet_info + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + { + extend_pools_for_restored_addresses(account, manifest, &addresses_to_mark, wallet_id)?; + } } // Recompute per-account + wallet balance from the restored set. @@ -227,19 +267,20 @@ const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; /// [`MAX_REHYDRATION_DERIVATION_INDEX`] ceiling — both worth surfacing. const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; -/// Extend `account`'s address pools so every resolved UTXO address is -/// derived at its exact `(chain, index)` slot and marked used, then refill -/// the gap window beyond — following the sync path's `mark_used` → +/// Extend `account`'s address pools so every resolved address (a +/// still-unspent UTXO address or a persisted pool used-address) is derived +/// at its exact `(chain, index)` slot and marked used, then refill the gap +/// window beyond — following the sync path's `mark_used` → /// `maintain_gap_limit` sequence. Each chain is scanned independently, /// stopping once no unresolved address matches within a `gap_limit`-sized /// window past the deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] /// is the hard ceiling. Addresses that don't resolve from this account's /// xpub — foreign keys, multi-account mismatch, or legitimately-owned but -/// deep-and-sparse slots with no nearer unspent UTXO to anchor the horizon — +/// deep-and-sparse slots with no nearer resolved address to anchor the horizon — /// are counted and logged via `tracing::warn!`; they re-warm on the next -/// full sync. Every restored address the pools *do* hold (in-window or -/// deep-resolved) is marked used so a funded address is never handed out as -/// a fresh receive address. +/// full sync. Every resolved address the pools *do* hold (in-window or +/// deep-resolved) is marked used so a funded or previously-used address is +/// never handed out as a fresh receive address. /// /// Tested with Standard BIP44 topology (External + Internal pools) and /// CoinJoin topology (single External pool). The per-chain probe loop has no @@ -258,10 +299,10 @@ const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; /// pool by position. /// /// Never touches key material — the xpub is the keyless account public key. -fn extend_pools_for_restored_utxos( +fn extend_pools_for_restored_addresses( account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, manifest: &[AccountRegistrationEntry], - restored: &[&key_wallet::Utxo], + restored_addresses: &[key_wallet::Address], wallet_id: [u8; 32], ) -> Result<(), PlatformWalletError> { use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; @@ -310,10 +351,10 @@ fn extend_pools_for_restored_utxos( if let Some(key_source) = key_source.as_ref() { let mut unresolved: HashSet = { let pools = account.managed_account_type().address_pools(); - restored + restored_addresses .iter() - .map(|u| u.address.clone()) .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) + .cloned() .collect() }; @@ -432,8 +473,8 @@ fn extend_pools_for_restored_utxos( // receive address. `mark_used` is a no-op for addresses not in this // pool, so an underived (foreign / sparse) index is never marked. let mut marked_any = false; - for u in restored { - if pool.mark_used(&u.address) { + for addr in restored_addresses { + if pool.mark_used(addr) { marked_any = true; } } @@ -639,7 +680,7 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); // The wallet total is exact regardless (a sum over the UTXO set). assert_eq!(wallet_info.balance.total(), expected_total); @@ -803,7 +844,7 @@ mod tests { }; // Must not panic. tracing::warn! fires for the unresolved count. - apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); // Total balance is exact — foreign UTXO is in the set regardless. assert_eq!( @@ -842,7 +883,7 @@ mod tests { } /// CoinJoin topology (single External pool, no Internal chain). - /// Verifies that `extend_pools_for_restored_utxos` handles a single-pool + /// Verifies that `extend_pools_for_restored_addresses` handles a single-pool /// account at a deep derivation index (idx 30, just past the eager window). #[test] fn rehydration_coinjoin_single_pool_deep_index() { @@ -940,7 +981,7 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); // Balance is exact. assert_eq!( @@ -1038,7 +1079,7 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); let funds = wallet_info .accounts @@ -1066,6 +1107,125 @@ mod tests { ); } + /// #3692 review (privacy / address-reuse): a previously-used address + /// whose funds were SINCE SPENT (so it carries no current UTXO) must + /// still come back marked `used` when the persisted pool snapshot + /// reports it via `used_pool_addresses`. Without it the address resets + /// to `used = false` and could be handed out again as a fresh receive + /// address. Covers an in-window slot (idx 5) and a deeper slot the + /// horizon walk resolves (idx 30, at the initial gap-limit boundary), + /// and asserts the baseline (empty `used_pool_addresses`) does NOT mark + /// them — so the field is demonstrably what fixes the leak. + #[test] + fn rehydration_restores_persisted_used_state_for_spent_out_address() { + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + + let wallet = Wallet::from_seed_bytes( + [42u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + + let funds_type = ManagedWalletInfo::from_wallet(&wallet, 1) + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + let derive = |index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + let in_window_used = derive(5); + let deep_used = derive(30); + + // No current UTXOs — these addresses' funds were already spent. + let core = crate::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + let used = vec![in_window_used.clone(), deep_used.clone()]; + + // Baseline: with NO persisted used-state the spent-out address is + // not marked used (the pre-fix behaviour, and the reuse hazard). + { + let mut baseline = ManagedWalletInfo::from_wallet(&wallet, 1); + apply_persisted_core_state(&mut baseline, &manifest, &core, &[]).unwrap(); + let funds = baseline + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + !external + .address_info(&in_window_used) + .map(|i| i.used) + .unwrap_or(false), + "without persisted used-state a spent-out address resets to unused" + ); + } + + // With the persisted used-state, both addresses come back used. + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &used).unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + + assert!( + external + .address_info(&in_window_used) + .expect("in-window used address must be present") + .used, + "in-window spent-out address must be restored as used" + ); + assert!(external.used_indices.contains(&5), "idx 5 recorded used"); + assert!( + external + .address_info(&deep_used) + .expect("deep used address must be derived into the pool") + .used, + "deep spent-out address must be derived + restored as used" + ); + assert!(external.used_indices.contains(&30), "idx 30 recorded used"); + assert_eq!( + external.highest_used, + Some(30), + "highest_used must reflect the deepest restored used slot" + ); + } + /// Documented limitation (solution b): a legitimately-owned but /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx /// <= 30 — is left unresolved because the discovery horizon (gap_limit @@ -1144,7 +1304,7 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); // The wallet total is exact regardless (a sum over the UTXO set). assert_eq!(wallet_info.balance.total(), value); @@ -1239,7 +1399,7 @@ mod tests { ..Default::default() }; - let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core) + let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]) .expect_err("must fail closed when no funds account can hold the UTXOs"); match err { PlatformWalletError::RehydrationTopologyUnsupported { utxo_count, .. } => { @@ -1276,7 +1436,7 @@ mod tests { synced_height: Some(1), ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core) + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]) .expect("empty UTXO set must be Ok even with no funds account"); } @@ -1316,7 +1476,7 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); assert_eq!( wallet_info.metadata.last_applied_chain_lock.as_ref(), diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index fb22ebf9a9f..2e5d928636a 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -74,6 +74,7 @@ impl PlatformWalletPersistence for FixedLoadPersister { unused_asset_locks: Default::default(), contacts: Default::default(), identity_keys: Default::default(), + used_core_addresses: w.used_core_addresses.clone(), }, ); } @@ -132,6 +133,7 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { unused_asset_locks: Default::default(), contacts: Default::default(), identity_keys: Default::default(), + used_core_addresses: Default::default(), }, ) } @@ -193,6 +195,7 @@ async fn rt_corrupt_row_skipped_and_other_loads() { unused_asset_locks: Default::default(), contacts: Default::default(), identity_keys: Default::default(), + used_core_addresses: Default::default(), }; let mut st = ClientStartState::default(); @@ -259,6 +262,7 @@ async fn rt_z_secret_hygiene_surfaces() { unused_asset_locks: Default::default(), contacts: Default::default(), identity_keys: Default::default(), + used_core_addresses: Default::default(), }; let mut st = ClientStartState::default(); st.wallets.insert(id, corrupt); From 8832f6f18fe4cead64f5b1c5dd06f7f3485470c4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:21:34 +0000 Subject: [PATCH 06/60] =?UTF-8?q?fix(platform-wallet):=20idempotent=20repe?= =?UTF-8?q?at=20restore=20=E2=80=94=20skip=20already-present=20wallet=20on?= =?UTF-8?q?=20load=20(#3692=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_from_persistor mapped EVERY insert_wallet error (including key_wallet_manager::WalletError::WalletExists) to a fatal WalletCreation + 'load break + full rollback. So a second load_from_persistor — or a load run while the wallet is already in memory — aborted the whole batch instead of being a no-op. Match WalletExists specifically and treat it as already-satisfied: record the wallet as loaded and `continue` to the next row. It was not inserted by this pass, so it stays out of the rollback set and a later hard-fail never evicts the pre-existing wallet. Mirrors the create-path idempotent handling in wallet_lifecycle. Test: rt_idempotent_repeat_restore loads the same persister twice and asserts the second call returns Ok with the wallet still present. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet/src/manager/load.rs | 16 ++++++++ .../tests/rehydration_load.rs | 41 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8379affe0a4..86724882df6 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -44,6 +44,11 @@ impl PlatformWalletManager

{ /// `Err(_)` — every wallet inserted earlier in this pass is /// rolled back. Skipped wallets never entered the maps so the /// rollback path never sees them. + /// - **Already present** (`WalletExists` from `insert_wallet`, e.g. a + /// repeat restore or a runtime-created wallet): treated as + /// already-satisfied — counted as loaded, left untouched, and kept + /// out of the rollback set so a later hard-fail never evicts it. A + /// second `load_from_persistor` is therefore idempotent. /// /// Platform-address provider state is restored per wallet via /// [`initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted), @@ -159,6 +164,17 @@ impl PlatformWalletManager

{ let mut wm = self.wallet_manager.write().await; match wm.insert_wallet(wallet, platform_info) { Ok(id) => id, + Err(key_wallet_manager::WalletError::WalletExists(_)) => { + // Idempotent restore: a prior `load_from_persistor` + // (or a runtime create) already registered this + // wallet. Re-registering must not abort the batch — + // treat it as already-satisfied: record it as loaded + // and continue. It was NOT inserted by this pass, so + // it stays out of the rollback set and a later + // hard-fail never evicts the pre-existing wallet. + outcome.loaded.push(expected_wallet_id); + continue 'load; + } Err(e) => { load_error = Some(PlatformWalletError::WalletCreation(format!( "Failed to register persisted wallet in WalletManager: {}", diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 2e5d928636a..85b5c4b30a4 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -172,6 +172,47 @@ async fn rt_wo_watch_only_roundtrip() { assert_eq!(mgr.wallet_ids().await, vec![id]); } +/// RT-Idem: a second `load_from_persistor` with the wallet already +/// registered (a repeat restore, or a wallet created at runtime) must be +/// idempotent. `WalletExists` from `insert_wallet` is treated as +/// already-satisfied — counted as loaded — not a fatal `WalletCreation` +/// that aborts the whole batch. +#[tokio::test] +async fn rt_idempotent_repeat_restore() { + let seed = [0x55; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id, s) = slice(seed); + let mut st = ClientStartState::default(); + st.wallets.insert(id, s); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + + let first = mgr.load_from_persistor().await.expect("first load Ok"); + assert_eq!(first.loaded, vec![id]); + assert!(first.skipped.is_empty()); + + // Second load: the wallet is already registered. Must NOT hard-error. + let second = mgr + .load_from_persistor() + .await + .expect("repeat load must be idempotent, not a hard error"); + assert!( + second.loaded.contains(&id), + "already-present wallet is reported loaded (already-satisfied)" + ); + assert!( + second.skipped.is_empty(), + "an idempotent re-load is not a skip" + ); + assert!( + mgr.get_wallet(&id).await.is_some(), + "wallet still present after the repeat load" + ); + assert_eq!(mgr.wallet_ids().await, vec![id]); +} + /// RT-Corrupt: a corrupt row (empty manifest) is skipped with /// `MissingManifest`; the other row loads cleanly; the load returns /// `Ok`; exactly one `WalletSkippedOnLoad` event fires for the skipped From 2c6e3e8d35b4954cb4a6e27d8e9222edfd46f796 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:26:40 +0000 Subject: [PATCH 07/60] fix(platform-wallet-ffi): skip corrupt wallet on load instead of aborting the batch (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FFIPersister::load looped `build_wallet_start_state(entry)?`, so ONE corrupt SwiftData row (e.g. a malformed account_xpub that aborts decode) failed the ENTIRE load() — every wallet, every launch. The manager already documents per-wallet skip (LoadOutcome::skipped + on_wallet_skipped_on_load, returns Ok), but the FFI never reached it. Make the FFI loop per-entry resilient: on a per-row build failure record the wallet as skipped and continue. Errors from build_wallet_start_state are inherently per-row (decode/projection of THAT entry), so this never swallows a whole-load failure. The skip travels to the manager through a new ClientStartState::skipped channel (Vec<(WalletId, SkipReason)>, empty default); load_from_persistor folds it into LoadOutcome::skipped and fires on_wallet_skipped_on_load. Reason is CorruptPersistedRow{DecodeError} — PersistenceError's Display is structural (no row bytes / key material). Cross-PR: ClientStartState derives Default so #3968's `::default()` build still compiles; a destructure there needs `skipped: _` (follow-up). Test: rt_persister_skipped_folds_into_outcome asserts a persister-rejected row surfaces in LoadOutcome::skipped + fires the event while the healthy wallet still loads and the call returns Ok. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet-ffi/src/persistence.rs | 36 ++++++++++++-- .../src/changeset/client_start_state.rs | 8 +++ .../rs-platform-wallet/src/manager/load.rs | 11 +++++ .../src/manager/wallet_lifecycle.rs | 1 + .../src/wallet/platform_wallet.rs | 1 + .../tests/rehydration_load.rs | 49 +++++++++++++++++++ 6 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 8e20e155b12..a5e519b2b44 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -23,6 +23,7 @@ use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; +use platform_wallet::manager::load_outcome::{CorruptKind, SkipReason}; use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; use std::collections::BTreeMap; @@ -1541,11 +1542,36 @@ impl PlatformWalletPersistence for FFIPersister { // fires before we leave this function. let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; for entry in entries { - let (wallet_state, platform_address_state) = build_wallet_start_state(entry)?; - out.wallets.insert(entry.wallet_id, wallet_state); - if let Some(platform_address_state) = platform_address_state { - out.platform_addresses - .insert(entry.wallet_id, platform_address_state); + match build_wallet_start_state(entry) { + Ok((wallet_state, platform_address_state)) => { + out.wallets.insert(entry.wallet_id, wallet_state); + if let Some(platform_address_state) = platform_address_state { + out.platform_addresses + .insert(entry.wallet_id, platform_address_state); + } + } + Err(e) => { + // One corrupt SwiftData row must never abort the whole + // restore. Errors from `build_wallet_start_state` are + // inherently per-row (decode / projection of THIS entry, + // e.g. a malformed account xpub), so record the wallet as + // skipped and continue — the manager folds this into + // `LoadOutcome::skipped` and fires + // `on_wallet_skipped_on_load`, and the other rows still + // load. `PersistenceError`'s Display is structural (no + // raw row bytes / key material), safe for `DecodeError`. + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + error = %e, + "load: skipping corrupt wallet restore-entry; continuing with the rest" + ); + out.skipped.push(( + entry.wallet_id, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::DecodeError(e.to_string()), + }, + )); + } } } diff --git a/packages/rs-platform-wallet/src/changeset/client_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_start_state.rs index c63e5a262be..f2ec1a141dd 100644 --- a/packages/rs-platform-wallet/src/changeset/client_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_start_state.rs @@ -13,6 +13,7 @@ use crate::changeset::client_wallet_start_state::ClientWalletStartState; use crate::changeset::platform_address_sync_start_state::PlatformAddressSyncStartState; #[cfg(feature = "shielded")] use crate::changeset::shielded_sync_start_state::ShieldedSyncStartState; +use crate::manager::load_outcome::SkipReason; use crate::wallet::platform_wallet::WalletId; /// Snapshot of everything a persister hands back on @@ -32,6 +33,13 @@ pub struct ClientStartState { /// Per-wallet startup slices (UTXOs and unused asset locks, each /// bucketed by account index). pub wallets: BTreeMap, + /// Wallets the persister itself rejected as structurally corrupt + /// before they could be reconstructed (e.g. a malformed account xpub + /// that aborts decode). They never appear in `wallets`; the manager + /// folds them into the load outcome's `skipped` set and notifies + /// handlers, so one bad persisted row never blocks the rest of the + /// batch. Empty for persisters that decode every row up front. + pub skipped: Vec<(WalletId, SkipReason)>, /// Restored shielded sub-wallet state — per-`SubwalletId` /// notes + sync watermarks. Consumed at `bind_shielded` time /// to rehydrate the in-memory `SubwalletState` so spending / diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 86724882df6..cf12ecf9624 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -61,6 +61,7 @@ impl PlatformWalletManager

{ let ClientStartState { mut platform_addresses, wallets, + skipped: persister_skipped, // Shielded restore happens lazily on `bind_shielded`, // not here — drop the snapshot at this entry point. #[cfg(feature = "shielded")] @@ -83,6 +84,16 @@ impl PlatformWalletManager

{ let mut load_error: Option = None; let mut outcome = LoadOutcome::default(); + // Rows the persister rejected as corrupt before reconstruction + // (e.g. a malformed xpub that aborts FFI decode) never reach the + // rebuild loop below — fold them into the skip set and notify, so + // one bad persisted row never blocks the batch. + for (wallet_id, reason) in persister_skipped { + self.event_manager + .on_wallet_skipped_on_load(wallet_id, &reason); + outcome.skipped.push((wallet_id, reason)); + } + 'load: for (expected_wallet_id, wallet_state) in wallets { let ClientWalletStartState { network, diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 25ee5df914a..bb406f12c73 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -362,6 +362,7 @@ impl PlatformWalletManager

{ let crate::changeset::ClientStartState { mut platform_addresses, wallets: _, + skipped: _, #[cfg(feature = "shielded")] shielded: _, } = match platform_wallet.load_persisted() { diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 7c22401f9c3..f0c75f5753c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1048,6 +1048,7 @@ impl PlatformWallet { let ClientStartState { mut platform_addresses, wallets: _, + skipped: _, #[cfg(feature = "shielded")] shielded: _, } = self.load_persisted()?; diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 85b5c4b30a4..c63600b415e 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -78,6 +78,7 @@ impl PlatformWalletPersistence for FixedLoadPersister { }, ); } + out.skipped = s.skipped.clone(); Ok(out) } } @@ -213,6 +214,54 @@ async fn rt_idempotent_repeat_restore() { assert_eq!(mgr.wallet_ids().await, vec![id]); } +/// RT-PersisterSkip: a wallet the persister itself rejected as corrupt +/// before reconstruction — surfaced via `ClientStartState::skipped` (e.g. +/// the FFI `load()` catching a malformed xpub per-row) — is folded into +/// `LoadOutcome::skipped` and fires `on_wallet_skipped_on_load`, while the +/// healthy wallet still loads. One bad persisted row never blocks the batch. +#[tokio::test] +async fn rt_persister_skipped_folds_into_outcome() { + let seed_ok = [0x71; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id_ok, s_ok) = slice(seed_ok); + + // A wallet id the persister could not decode (fabricated skip). + let bad_id: WalletId = [0x09; 32]; + let reason = SkipReason::CorruptPersistedRow { + kind: CorruptKind::DecodeError("malformed account xpub".to_string()), + }; + + let mut st = ClientStartState::default(); + st.wallets.insert(id_ok, s_ok); + st.skipped.push((bad_id, reason.clone())); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr + .load_from_persistor() + .await + .expect("Ok despite a persister-rejected row"); + + assert!( + outcome.loaded.contains(&id_ok), + "healthy wallet still loads" + ); + assert!(!outcome.loaded.contains(&bad_id)); + assert_eq!(outcome.skipped.len(), 1, "the rejected row surfaces once"); + assert_eq!(outcome.skipped[0], (bad_id, reason.clone())); + assert!(mgr.get_wallet(&id_ok).await.is_some()); + assert!( + mgr.get_wallet(&bad_id).await.is_none(), + "the rejected row is never registered" + ); + + // The skip notification fired exactly once for the bad row. + let skipped = h.skipped.lock().unwrap(); + assert_eq!(skipped.len(), 1, "exactly one skip notification"); + assert_eq!(skipped[0], (bad_id, reason)); +} + /// RT-Corrupt: a corrupt row (empty manifest) is skipped with /// `MissingManifest`; the other row loads cleanly; the load returns /// `Ok`; exactly one `WalletSkippedOnLoad` event fires for the skipped From 3876d0eeed84d4ef256c598c39361d6436aff6e0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:41:48 +0000 Subject: [PATCH 08/60] refactor(platform-wallet): mark unreleased #3692 load enums non_exhaustive (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Semver hygiene for the new, unreleased load surface so future variants don't break downstream matches: add #[non_exhaustive] to SkipReason, CorruptKind, LoadOutcome (load_outcome.rs) and PlatformEvent (events.rs). Consequence: the FFI skip_reason_code match (a downstream crate) is no longer exhaustive over the now-non_exhaustive SkipReason/CorruptKind, so add catch-all arms mapping future variants to generic codes (199 corrupt kind, 200 skip reason) until the mapping is extended. matches!() in tests is unaffected (it carries an implicit wildcard). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet-ffi/src/manager.rs | 6 ++++++ packages/rs-platform-wallet/src/events.rs | 1 + packages/rs-platform-wallet/src/manager/load_outcome.rs | 3 +++ 3 files changed, 10 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 66cf4aaa0a1..9897c09a6c1 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -215,7 +215,13 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { CorruptKind::MissingManifest => 100, CorruptKind::MalformedXpub => 101, CorruptKind::DecodeError(_) => 102, + // `CorruptKind` is #[non_exhaustive]; a future variant maps to a + // generic corrupt-row code until this mapping is extended. + _ => 199, }, + // `SkipReason` is #[non_exhaustive]; a future reason maps to a + // generic skip code until this mapping is extended. + _ => 200, } } diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index 30a9c033f8f..82ba84897b5 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -28,6 +28,7 @@ use crate::wallet::platform_wallet::WalletId; /// platform-specific notifications the app may react to (toast, /// telemetry) without threading return values through every call site. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum PlatformEvent { /// A persisted wallet was skipped during /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index b71b28fe406..53d936aa6c5 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -17,6 +17,7 @@ use crate::wallet::platform_wallet::WalletId; /// /// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] pub enum SkipReason { /// The persisted row could not be reconstructed: a structural decode /// failure on the keyless account manifest or core-state projection. @@ -35,6 +36,7 @@ pub enum SkipReason { /// row-derived bytes. Apps drive their UI from the *family*, not from /// the inner message. #[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] pub enum CorruptKind { /// The wallet row exists but has no usable `AccountRegistrationEntry` /// manifest to rebuild the account collection from. @@ -68,6 +70,7 @@ impl std::fmt::Display for CorruptKind { /// (persister I/O, programmer error). The load path is watch-only and /// never touches the seed, so no wrong-seed outcome appears here. #[derive(Debug, Clone, Default, PartialEq, Eq)] +#[non_exhaustive] pub struct LoadOutcome { /// Wallets fully reconstructed and registered, in load order. pub loaded: Vec, From 1f8d6b6029813e0cda48fd7928be151a35cad280 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:42:07 +0000 Subject: [PATCH 09/60] test(platform-wallet): cover used-state-survives-spent-UTXO + deep-index pool extension on rehydrate (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA flagged that the existing real-manager rehydration test defaults the address-pool payload and is structurally blind to #1, and that the pool-DEPTH fix (dash-evo-tool#829 Bug 2 / PR #830) had no regression guard. Add two distinct, focused tests through apply_persisted_core_state: - rehydration_used_state_survives_spent_utxo (#1, address-reuse): builds a ClientWalletStartState whose in-window address received funds that were then SPENT (new_utxos cancelled by spent_utxos → zero balance) and routes used_core_addresses through the field. Asserts the in-window + a deep (idx 30) address come back marked USED even with zero balance, and that the empty-snapshot baseline does NOT mark them. Replaces the weaker no-UTXO variant so the used flag is proven independent of a live UTXO. - rt_deep_index_utxos_extend_pools_on_rehydration (DEPTH): unspent UTXOs on walkable ladders past the eager 0..=gap_limit window (external -> idx 84, internal -> idx 90). Asserts the deep slots are derived into their pools and Sum(per-address visible) == balance.total == Sum(persisted) — no deep-index undercount. Test-only; the production fix already exists. Also: drop the stale "(from wallet_metadata)" table reference on the ClientWalletStartState::network doc (backend-agnostic now). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../changeset/client_wallet_start_state.rs | 2 +- .../src/manager/rehydrate.rs | 258 ++++++++++++++++-- 2 files changed, 237 insertions(+), 23 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 61d2cb5727b..ed059d8b06b 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -30,7 +30,7 @@ use key_wallet::{Address, Network}; /// boundary, enforced by type rather than convention. #[derive(Debug)] pub struct ClientWalletStartState { - /// Network the wallet is bound to (from `wallet_metadata`). + /// Network the wallet is bound to. pub network: Network, /// Best estimate of the chain tip at creation time (`0` = scan /// from genesis / unknown). diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index cd8e10b957d..54628495f05 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -1108,22 +1108,25 @@ mod tests { } /// #3692 review (privacy / address-reuse): a previously-used address - /// whose funds were SINCE SPENT (so it carries no current UTXO) must - /// still come back marked `used` when the persisted pool snapshot - /// reports it via `used_pool_addresses`. Without it the address resets - /// to `used = false` and could be handed out again as a fresh receive - /// address. Covers an in-window slot (idx 5) and a deeper slot the - /// horizon walk resolves (idx 30, at the initial gap-limit boundary), - /// and asserts the baseline (empty `used_pool_addresses`) does NOT mark - /// them — so the field is demonstrably what fixes the leak. + /// whose UTXO was SINCE SPENT must still come back marked `used` when the + /// snapshot carries it via `ClientWalletStartState::used_core_addresses`. + /// Without it the address resets to `used = false` and could be handed + /// out again as a fresh receive address. The used flag must survive even + /// though the UTXO is gone (`spent_utxos` cancels `new_utxos` → zero + /// balance), proving it is NOT just a side effect of a live UTXO. Covers + /// an in-window slot (idx 5) and a deeper slot the horizon walk resolves + /// (idx 30), and asserts the empty-snapshot baseline does NOT mark them. #[test] - fn rehydration_restores_persisted_used_state_for_spent_out_address() { + fn rehydration_used_state_survives_spent_utxo() { + use crate::changeset::{ClientWalletStartState, CoreChangeSet}; + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; use key_wallet::bip32::DerivationPath; use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::Address; + use key_wallet::{Address, Utxo}; let wallet = Wallet::from_seed_bytes( [42u8; 64], @@ -1160,19 +1163,59 @@ mod tests { let in_window_used = derive(5); let deep_used = derive(30); - // No current UTXOs — these addresses' funds were already spent. - let core = crate::changeset::CoreChangeSet { + // The in-window address received funds (new_utxos) that were later + // spent (spent_utxos) — so it carries NO unspent UTXO. Exactly the + // reuse hazard: zero balance, yet the address must stay `used`. + let spent = Utxo { + outpoint: OutPoint { + txid: Txid::from([1u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 50_000, + script_pubkey: in_window_used.script_pubkey(), + }, + address: in_window_used.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let core = CoreChangeSet { + new_utxos: vec![spent.clone()], + spent_utxos: vec![spent], last_processed_height: Some(1), synced_height: Some(1), ..Default::default() }; - let used = vec![in_window_used.clone(), deep_used.clone()]; - // Baseline: with NO persisted used-state the spent-out address is - // not marked used (the pre-fix behaviour, and the reuse hazard). + // The keyless slice the persister hands back, carrying the pool + // used-state for both addresses. + let state = ClientWalletStartState { + network: Network::Testnet, + birth_height: 1, + account_manifest: manifest.clone(), + core_state: core, + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + used_core_addresses: vec![in_window_used.clone(), deep_used.clone()], + }; + + // Baseline: drop the pool used-state (empty) — the spent-out address + // resets to unused (the pre-fix behaviour, and the reuse hazard). { let mut baseline = ManagedWalletInfo::from_wallet(&wallet, 1); - apply_persisted_core_state(&mut baseline, &manifest, &core, &[]).unwrap(); + apply_persisted_core_state( + &mut baseline, + &state.account_manifest, + &state.core_state, + &[], + ) + .unwrap(); let funds = baseline .accounts .all_funding_accounts() @@ -1186,13 +1229,28 @@ mod tests { .address_info(&in_window_used) .map(|i| i.used) .unwrap_or(false), - "without persisted used-state a spent-out address resets to unused" + "without pool used-state a spent-out address resets to unused" ); } - // With the persisted used-state, both addresses come back used. + // With the snapshot's used-state — routed through + // ClientWalletStartState::used_core_addresses — both come back used. let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &used).unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &state.account_manifest, + &state.core_state, + &state.used_core_addresses, + ) + .unwrap(); + + // The spent UTXO contributes no balance — the used flag is NOT a + // side effect of a live UTXO. + assert_eq!( + wallet_info.balance.total(), + 0, + "the spent UTXO must not contribute balance" + ); let funds = wallet_info .accounts @@ -1202,11 +1260,10 @@ mod tests { .unwrap(); let pools = funds.managed_account_type().address_pools(); let external = pools.iter().find(|p| p.is_external()).unwrap(); - assert!( external .address_info(&in_window_used) - .expect("in-window used address must be present") + .expect("in-window used address present") .used, "in-window spent-out address must be restored as used" ); @@ -1214,7 +1271,7 @@ mod tests { assert!( external .address_info(&deep_used) - .expect("deep used address must be derived into the pool") + .expect("deep used address derived into pool") .used, "deep spent-out address must be derived + restored as used" ); @@ -1226,6 +1283,163 @@ mod tests { ); } + /// dash-evo-tool#829 Bug 2 / PR #830 regression guard (pool DEPTH — + /// distinct from the address-reuse test above): UNSPENT UTXOs landing on + /// chain indices well past the eager `0..=gap_limit` window + /// (gap_limit = 30) must be reflected in the per-address view, not just + /// in `balance.total`. Each chain carries a walkable ladder of UTXOs so + /// the horizon walk extends the pool out to the deepest index; a wallet + /// that derived deep before restart must not undercount per-address. + #[test] + fn rt_deep_index_utxos_extend_pools_on_rehydration() { + use crate::changeset::CoreChangeSet; + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let wallet = Wallet::from_seed_bytes( + [88u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + let derive = |pool_type, index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + pool_type, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + + let mk = |addr: Address, value: u64, n: u8| -> Utxo { + Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } + }; + + // Walkable ladders past the eager window: each hop is <= gap_limit + // (30) from the previous so the horizon advances to the deepest slot. + // External climbs to idx 84; Internal climbs to idx 90. + let ext = [(30u32, 10_000u64), (60, 20_000), (84, 30_000)]; + let int = [(30u32, 40_000u64), (60, 50_000), (90, 60_000)]; + + let mut new_utxos = Vec::new(); + let mut n: u8 = 1; + let mut deepest_external = None; + let mut deepest_internal = None; + for (idx, val) in ext { + let addr = derive(AddressPoolType::External, idx); + if idx == 84 { + deepest_external = Some(addr.clone()); + } + new_utxos.push(mk(addr, val, n)); + n += 1; + } + for (idx, val) in int { + let addr = derive(AddressPoolType::Internal, idx); + if idx == 90 { + deepest_internal = Some(addr.clone()); + } + new_utxos.push(mk(addr, val, n)); + n += 1; + } + let deepest_external = deepest_external.unwrap(); + let deepest_internal = deepest_internal.unwrap(); + let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); + + let core = CoreChangeSet { + new_utxos, + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); + + // (b) total is exact (a sum over the UTXO set, never undercounts). + assert_eq!(wallet_info.balance.total(), expected_total); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + + // (a) deep-index addresses were DERIVED into their exact slots. + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let internal = pools.iter().find(|p| p.is_internal()).unwrap(); + assert_eq!( + external.address_at_index(84).as_ref(), + Some(&deepest_external), + "external pool must be walked out to the deepest UTXO index (84)" + ); + assert_eq!( + internal.address_at_index(90).as_ref(), + Some(&deepest_internal), + "internal pool must be walked out to the deepest UTXO index (90)" + ); + + // (b) per-address view == total: every UTXO address resolves into a + // pool, so the monitored/per-address sum matches `balance.total` + // with no deep-index undercount. + let pool_addresses: HashSet

= pools + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, expected_total, + "all deep-index UTXO addresses must be derived into their pools" + ); + } + /// Documented limitation (solution b): a legitimately-owned but /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx /// <= 30 — is left unresolved because the discovery horizon (gap_limit From f60160e59501510a7c36c7b8bc270260c63b8812 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:46:57 +0000 Subject: [PATCH 10/60] test(platform-wallet): drop duplicate deep-index rehydration test (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove rt_deep_index_utxos_extend_pools_on_rehydration: the deep-index pool-extension scenario is already guarded by the pre-existing rehydration_extends_pools_to_cover_deep_index_utxos and rehydration_coinjoin_single_pool_deep_index. The existing 30->60 horizon extension already exercises the recursive walk, so a deeper ladder added no new code path — pure duplication. Keeps the #1 address-reuse test (rehydration_used_state_survives_spent_utxo). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../src/manager/rehydrate.rs | 157 ------------------ 1 file changed, 157 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 54628495f05..746446b151a 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -1283,163 +1283,6 @@ mod tests { ); } - /// dash-evo-tool#829 Bug 2 / PR #830 regression guard (pool DEPTH — - /// distinct from the address-reuse test above): UNSPENT UTXOs landing on - /// chain indices well past the eager `0..=gap_limit` window - /// (gap_limit = 30) must be reflected in the per-address view, not just - /// in `balance.total`. Each chain carries a walkable ladder of UTXOs so - /// the horizon walk extends the pool out to the deepest index; a wallet - /// that derived deep before restart must not undercount per-address. - #[test] - fn rt_deep_index_utxos_extend_pools_on_rehydration() { - use crate::changeset::CoreChangeSet; - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - use std::collections::HashSet; - - let wallet = Wallet::from_seed_bytes( - [88u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - - let funds_type = wallet_info - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - let derive = |pool_type, index: u32| -> Address { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - pool_type, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(index + 1, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(index).unwrap() - }; - - let mk = |addr: Address, value: u64, n: u8| -> Utxo { - Utxo { - outpoint: OutPoint { - txid: Txid::from([n; 32]), - vout: 0, - }, - txout: TxOut { - value, - script_pubkey: addr.script_pubkey(), - }, - address: addr, - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - } - }; - - // Walkable ladders past the eager window: each hop is <= gap_limit - // (30) from the previous so the horizon advances to the deepest slot. - // External climbs to idx 84; Internal climbs to idx 90. - let ext = [(30u32, 10_000u64), (60, 20_000), (84, 30_000)]; - let int = [(30u32, 40_000u64), (60, 50_000), (90, 60_000)]; - - let mut new_utxos = Vec::new(); - let mut n: u8 = 1; - let mut deepest_external = None; - let mut deepest_internal = None; - for (idx, val) in ext { - let addr = derive(AddressPoolType::External, idx); - if idx == 84 { - deepest_external = Some(addr.clone()); - } - new_utxos.push(mk(addr, val, n)); - n += 1; - } - for (idx, val) in int { - let addr = derive(AddressPoolType::Internal, idx); - if idx == 90 { - deepest_internal = Some(addr.clone()); - } - new_utxos.push(mk(addr, val, n)); - n += 1; - } - let deepest_external = deepest_external.unwrap(); - let deepest_internal = deepest_internal.unwrap(); - let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); - - let core = CoreChangeSet { - new_utxos, - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - // (b) total is exact (a sum over the UTXO set, never undercounts). - assert_eq!(wallet_info.balance.total(), expected_total); - - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pools = funds.managed_account_type().address_pools(); - - // (a) deep-index addresses were DERIVED into their exact slots. - let external = pools.iter().find(|p| p.is_external()).unwrap(); - let internal = pools.iter().find(|p| p.is_internal()).unwrap(); - assert_eq!( - external.address_at_index(84).as_ref(), - Some(&deepest_external), - "external pool must be walked out to the deepest UTXO index (84)" - ); - assert_eq!( - internal.address_at_index(90).as_ref(), - Some(&deepest_internal), - "internal pool must be walked out to the deepest UTXO index (90)" - ); - - // (b) per-address view == total: every UTXO address resolves into a - // pool, so the monitored/per-address sum matches `balance.total` - // with no deep-index undercount. - let pool_addresses: HashSet
= pools - .iter() - .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) - .collect(); - let visible: u64 = funds - .utxos - .values() - .filter(|u| pool_addresses.contains(&u.address)) - .map(|u| u.value()) - .sum(); - assert_eq!( - visible, expected_total, - "all deep-index UTXO addresses must be derived into their pools" - ); - } - /// Documented limitation (solution b): a legitimately-owned but /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx /// <= 30 — is left unresolved because the discovery horizon (gap_limit From 8d88d49426b994991e47ef1968bfa23c42c8c836 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:50:24 +0000 Subject: [PATCH 11/60] refactor(platform-wallet): drop vestigial PlatformEvent enum (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the on_platform_event removal, the `PlatformEvent` enum had zero references repo-wide — events flow through the concrete `PlatformEventHandler` methods (`on_wallet_skipped_on_load`, etc.), not a dispatched enum. Remove the enum (and the `#[non_exhaustive]` just added to it) plus its `lib.rs` re-export. Its only variant, `WalletSkippedOnLoad`, went with it; the `on_wallet_skipped_on_load(wallet_id, &SkipReason)` handler and `SkipReason` itself stay. No imports orphaned — `SkipReason` and `WalletId` are still used by `PlatformEventHandler` / `PlatformEventManager`. Verified: `git grep PlatformEvent` over rs-platform-wallet, -ffi and swift-sdk is empty (only `PlatformEventHandler` / `PlatformEventManager` remain). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet/src/events.rs | 24 ----------------------- packages/rs-platform-wallet/src/lib.rs | 2 +- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index 82ba84897b5..d24b137b76b 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -22,30 +22,6 @@ use crate::manager::platform_address_sync::PlatformAddressSyncSummary; use crate::manager::shielded_sync::ShieldedSyncPassSummary; use crate::wallet::platform_wallet::WalletId; -/// Platform-wallet lifecycle event surfaced to app handlers. -/// -/// Distinct from the SPV `EventHandler` stream — these are -/// platform-specific notifications the app may react to (toast, -/// telemetry) without threading return values through every call site. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum PlatformEvent { - /// A persisted wallet was skipped during - /// [`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor) - /// because its persisted row was corrupt (a structural decode / - /// projection failure). The load path is seedless, so the only - /// reason is [`SkipReason::CorruptPersistedRow`]. - /// - /// Carries the (public, non-secret) wallet id and the structural - /// [`SkipReason`]; never any secret byte. - WalletSkippedOnLoad { - /// The skipped wallet's id. - wallet_id: WalletId, - /// Why it was skipped — always a corrupt persisted row. - reason: SkipReason, - }, -} - /// Extension of [`EventHandler`] for platform-wallet consumers. /// /// Implementors receive all SPV events via the [`EventHandler`] supertrait, diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index e9ddfb9c665..2c899cf25d9 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -22,7 +22,7 @@ pub mod spv; pub mod wallet; pub use error::PlatformWalletError; -pub use events::{PlatformEvent, PlatformEventHandler, PlatformEventManager}; +pub use events::{PlatformEventHandler, PlatformEventManager}; pub use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; // Surface the upstream `DerivedAddress` event payload through this // crate so downstream FFI consumers (rs-platform-wallet-ffi) can From b3042680a48d264018393c817004f033c3d50644 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:33:20 +0000 Subject: [PATCH 12/60] fix(platform-wallet): restore background-sync generation guard dropped on rehydrate (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3692 inadvertently deleted the per-manager `background_generation` guard from `identity_sync` and `shielded_sync` (the change was curated from the stacked branch where it was "superseded by #3954's ThreadRegistry"). On the *independent* #3692 there is no ThreadRegistry, so the deletion reintroduces the race thepastaclaw flagged: a tight stop() -> start() lets an exiting old thread's cleanup unconditionally clear `background_cancel`, wiping the *new* loop's token. is_running() then reports false and stop()/quiesce() can no longer cancel the still-live new loop. Fix: revert both files to v3.1-dev base, so #3692 no longer touches the sync managers at all and the base guard stands. The centralized lifecycle replacement (ThreadRegistry / CoordinatorLifecycle) remains #3954's scope; in the integration branch #3954's version supersedes this. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../src/manager/identity_sync.rs | 12 ++++++++- .../src/manager/shielded_sync.rs | 25 +++++++++++++++++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/identity_sync.rs b/packages/rs-platform-wallet/src/manager/identity_sync.rs index a467d910467..8730398f978 100644 --- a/packages/rs-platform-wallet/src/manager/identity_sync.rs +++ b/packages/rs-platform-wallet/src/manager/identity_sync.rs @@ -160,6 +160,10 @@ where persister: Arc

, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, + /// Monotonically increasing generation counter. Incremented each + /// time `start()` installs a new cancel token so the exiting + /// thread can tell whether its token is still current. + background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -200,6 +204,7 @@ where sdk, persister, background_cancel: StdMutex::new(None), + background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -396,6 +401,7 @@ where } let cancel = CancellationToken::new(); *guard = Some(cancel.clone()); + let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; drop(guard); let handle = tokio::runtime::Handle::current(); @@ -418,8 +424,12 @@ where } } + // Only clear the slot if no newer start() has + // installed a replacement token since we launched. if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; + if this.background_generation.load(Ordering::Acquire) == my_gen { + *guard = None; + } } }); }) diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index b38812bfc50..482674b4322 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -141,6 +141,14 @@ pub struct ShieldedSyncManager { coordinator_slot: Arc>>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, + /// Monotonically increasing generation counter. Bumped on every + /// `start()` so the exiting thread can tell whether its + /// generation is still the active one before clearing + /// `background_cancel`. Without this, a `stop()` → `start()` + /// overlap lets the prior thread's cleanup strip the new + /// generation's token, leaving the new loop running but + /// untrackable via `is_running()`. + background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -163,6 +171,7 @@ impl ShieldedSyncManager { event_manager, coordinator_slot, background_cancel: StdMutex::new(None), + background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -219,6 +228,10 @@ impl ShieldedSyncManager { } let cancel = CancellationToken::new(); *guard = Some(cancel.clone()); + // Bump the generation while we still hold the slot lock so + // the load below in any prior thread's cleanup observes + // `current_gen != my_gen` ordered against this token swap. + let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; drop(guard); let handle = tokio::runtime::Handle::current(); @@ -248,8 +261,16 @@ impl ShieldedSyncManager { } } - if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; + // Only clear `background_cancel` if the active + // generation is still ours. Without this guard a + // tight `stop()` → `start()` reschedule has the + // exiting thread overwrite the *new* generation's + // token, leaving the new loop running but + // unreflectable via `is_running()` / `stop()`. + if this.background_generation.load(Ordering::Acquire) == my_gen { + if let Ok(mut guard) = this.background_cancel.lock() { + *guard = None; + } } }); }) From 664c6bf428593ae870093ba966bf5ae7df8b796c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:33:20 +0000 Subject: [PATCH 13/60] fix(platform-wallet): count skipped rows in ClientStartState::is_empty (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_empty()` omitted the `skipped` set, so a load that reconstructed no wallets but rejected one or more rows reported empty — which would let a skipped-only result short-circuit `LoadOutcome::skipped` and the `on_wallet_skipped_on_load` notifications. Count `skipped` so a skipped-only state is non-empty. Adds a regression test. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../src/changeset/client_start_state.rs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/changeset/client_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_start_state.rs index f2ec1a141dd..e8e1ed962b7 100644 --- a/packages/rs-platform-wallet/src/changeset/client_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_start_state.rs @@ -50,7 +50,11 @@ pub struct ClientStartState { impl ClientStartState { pub fn is_empty(&self) -> bool { - let core_empty = self.platform_addresses.is_empty() && self.wallets.is_empty(); + // A skipped-only load (rows rejected, no wallets) is NOT empty: + // the manager must still fire its skip notifications. + let core_empty = self.platform_addresses.is_empty() + && self.wallets.is_empty() + && self.skipped.is_empty(); #[cfg(feature = "shielded")] { core_empty && self.shielded.is_empty() @@ -61,3 +65,28 @@ impl ClientStartState { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::manager::load_outcome::CorruptKind; + + /// A skipped-only start state must report non-empty so the manager + /// still surfaces `LoadOutcome::skipped` and fires skip handlers. + #[test] + fn skipped_only_state_is_not_empty() { + let mut state = ClientStartState::default(); + assert!(state.is_empty(), "a freshly defaulted state is empty"); + + state.skipped.push(( + [0u8; 32], + SkipReason::CorruptPersistedRow { + kind: CorruptKind::MalformedXpub, + }, + )); + assert!( + !state.is_empty(), + "a skipped-only state must be non-empty so skip notifications fire" + ); + } +} From 61c9fd757d06e59da6fd3dc68b4614011d33f120 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 07:33:20 +0000 Subject: [PATCH 14/60] fix(platform-wallet-ffi): surface MalformedXpub skip code for bad account xpub (#3692 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FFI load path mapped every `build_wallet_start_state` failure to `CorruptKind::DecodeError` (reason_code 102), so the dedicated `MalformedXpub` family (code 101 — already wired in `skip_reason_code` and documented on `WalletSkipInfoFFI.reason_code`) was dead: Swift could never distinguish an unrecoverable malformed-xpub row from any other decode failure. Classify the error via `corrupt_kind_from_build_err`, keyed off the producer's stable `MALFORMED_XPUB_ERR_PREFIX` constant (shared between the decode site and the classifier so the two can't drift). Adds a regression test. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet-ffi/src/persistence.rs | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index a5e519b2b44..64622234fbb 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1568,7 +1568,7 @@ impl PlatformWalletPersistence for FFIPersister { out.skipped.push(( entry.wallet_id, SkipReason::CorruptPersistedRow { - kind: CorruptKind::DecodeError(e.to_string()), + kind: corrupt_kind_from_build_err(&e), }, )); } @@ -2784,6 +2784,24 @@ impl Drop for LoadGuard { /// the configured signer surface (see /// `Wallet::new_external_signable`). Earlier revisions of this code /// path produced a `WatchOnly` wallet — that has been replaced. +/// Error-message prefix emitted when an account xpub fails to decode. +/// Shared by the producer and [`corrupt_kind_from_build_err`] so the +/// `MalformedXpub` classification can't silently drift from the text. +const MALFORMED_XPUB_ERR_PREFIX: &str = "failed to decode account xpub"; + +/// Classify a [`build_wallet_start_state`] failure for the FFI +/// `reason_code`: a malformed xpub maps to [`CorruptKind::MalformedXpub`] +/// (101), anything else to [`CorruptKind::DecodeError`] (102). +/// String-matched because `PersistenceError` carries no typed discriminator. +fn corrupt_kind_from_build_err(e: &PersistenceError) -> CorruptKind { + let msg = e.to_string(); + if msg.contains(MALFORMED_XPUB_ERR_PREFIX) { + CorruptKind::MalformedXpub + } else { + CorruptKind::DecodeError(msg) + } +} + fn build_wallet_start_state( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -2835,7 +2853,7 @@ fn build_wallet_start_state( unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; let (account_xpub, _): (ExtendedPubKey, usize) = bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { - PersistenceError::backend(format!("failed to decode account xpub: {}", e)) + PersistenceError::backend(format!("{MALFORMED_XPUB_ERR_PREFIX}: {e}")) })?; let account = Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network) @@ -4139,6 +4157,30 @@ mod tests { use key_wallet::mnemonic::{Language, Mnemonic}; use key_wallet::wallet::Wallet; + /// A malformed-xpub failure must surface as `MalformedXpub` (FFI + /// `reason_code` 101), distinct from the generic `DecodeError` (102), + /// so the host can special-case unrecoverable key-material corruption. + #[test] + fn malformed_xpub_error_maps_to_dedicated_corrupt_kind() { + let xpub_err = + PersistenceError::backend(format!("{MALFORMED_XPUB_ERR_PREFIX}: invalid checksum")); + assert_eq!( + corrupt_kind_from_build_err(&xpub_err), + CorruptKind::MalformedXpub, + "an xpub-decode failure must surface as MalformedXpub (code 101)" + ); + + // Any unrelated structural failure keeps the generic family. + let other_err = PersistenceError::backend("Account::from_xpub failed: bad network"); + assert!( + matches!( + corrupt_kind_from_build_err(&other_err), + CorruptKind::DecodeError(_) + ), + "non-xpub failures must stay DecodeError (code 102)" + ); + } + /// Regression: restored pool addresses must be tagged with the /// WALLET's network, not the network the base58 string parses as. /// Devnet shares testnet's base58 prefixes, so a devnet wallet's From ef7cdf2d1840e644f7fc69ae77f306422f480f44 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:32:38 +0000 Subject: [PATCH 15/60] chore(deps): bump rust-dashcore to dev head 78d10022 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump all 8 rust-dashcore workspace dependencies (dashcore, dash-spv, key-wallet, key-wallet-ffi, key-wallet-manager, dash-network, dash-network-seeds, dashcore-rpc) from 5c0113e7 to 78d10022 (rust-dashcore `dev` head, 15 commits, 0.43.0 -> 0.45.0). key-wallet's `Signer` trait gained a required `extended_public_key` method. Implement it for MnemonicResolverCoreSigner (real BIP-32 xpub derivation with private-scalar zeroization on every return path) and add stubs to the two rs-dpp test signers. Resolver + derive boilerplate extracted into a shared `resolve_and_derive` helper (DRY); the master scalar is wiped on both the success and derive-error paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MoY6vhmqZuHzNsMfJ8wakQ 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- Cargo.lock | 70 +++++------ Cargo.toml | 16 +-- packages/rs-dpp/src/state_transition/mod.rs | 11 +- .../signing_tests.rs | 11 +- .../src/mnemonic_resolver_core_signer.rs | 115 ++++++++++++++---- 5 files changed, 151 insertions(+), 72 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8de05bacd92..512dffc0d2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1636,8 +1636,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "bincode", "bincode_derive", @@ -1647,8 +1647,8 @@ dependencies = [ [[package]] name = "dash-network-seeds" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "dash-network", ] @@ -1724,8 +1724,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "async-trait", "chrono", @@ -1753,8 +1753,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "anyhow", "base64-compat", @@ -1779,13 +1779,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" [[package]] name = "dashcore-rpc" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "dashcore-rpc-json", "hex", @@ -1797,8 +1797,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "bincode", "dashcore", @@ -1812,8 +1812,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "bincode", "dashcore-private", @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2866,8 +2866,8 @@ dependencies = [ [[package]] name = "git-state" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" [[package]] name = "glob" @@ -3803,7 +3803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4033,8 +4033,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "aes", "async-trait", @@ -4062,8 +4062,8 @@ dependencies = [ [[package]] name = "key-wallet-ffi" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4078,8 +4078,8 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.43.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5c0113e7901551450f6063023eec4be95beeb6b9#5c0113e7901551450f6063023eec4be95beeb6b9" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" dependencies = [ "async-trait", "bincode", @@ -4596,7 +4596,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5696,7 +5696,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6486,7 +6486,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6499,7 +6499,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6558,7 +6558,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7418,7 +7418,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8867,7 +8867,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5d5f2960f1d..c36a7a9c874 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5c0113e7901551450f6063023eec4be95beeb6b9" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } tokio-metrics = "0.5" diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 92bf4a4e583..74ebc13c977 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -3335,7 +3335,7 @@ mod tests { use dashcore::secp256k1::{ ecdsa, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey, }; - use key_wallet::bip32::DerivationPath; + use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory signer used only by this test. Mirrors how a @@ -3370,6 +3370,15 @@ mod tests { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } + + async fn extended_public_key( + &self, + _path: &DerivationPath, + ) -> Result { + // Test stub holds a single raw key with no chain code; extended + // public key derivation is not meaningful here. + Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) + } } // Generate a single random key. Using the same key on both sides is diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs index 67f95088409..b2c4fb67f83 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs @@ -204,7 +204,7 @@ async fn try_from_asset_lock_with_signer_and_private_key_signs_multiple_inputs() async fn try_from_asset_lock_with_signers_produces_matching_signature() { use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message}; - use key_wallet::bip32::DerivationPath; + use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how the @@ -237,6 +237,15 @@ async fn try_from_asset_lock_with_signers_produces_matching_signature() { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } + + async fn extended_public_key( + &self, + _path: &DerivationPath, + ) -> Result { + // Test stub holds a single raw key with no chain code; extended + // public key derivation is not meaningful here. + Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) + } } let secp = Secp256k1::new(); 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 ade11828594..bda1d3aa00f 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -64,7 +64,7 @@ use std::ffi::c_void; use std::os::raw::c_char; use async_trait::async_trait; -use key_wallet::bip32::{DerivationPath, ExtendedPrivKey}; +use key_wallet::bip32::{DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::secp256k1::{self, Secp256k1}; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::Network; @@ -222,18 +222,32 @@ impl MnemonicResolverCoreSigner { } } - /// Resolve the mnemonic from the Swift-side callback, then - /// derive the secp256k1 private key at `path`. Returns the raw - /// 32-byte scalar in a `Zeroizing` wrapper so the caller's last - /// drop point zeros it. + /// Resolve the mnemonic from the Swift-side callback, then derive the + /// BIP-32 extended private key at `path`. /// - /// All other intermediate buffers (mnemonic, seed) are dropped - /// (and zeroed) before this method returns — only the final - /// derived scalar leaks out, and even that is `Zeroizing`-wrapped. - fn derive_priv( + /// This is the single entry-point for all private-key material in this + /// signer. It handles the full stack: resolver FFI call → result-code + /// mapping → UTF-8 + word-list validation → BIP-39 seed → master + /// `ExtendedPrivKey` → child `ExtendedPrivKey` at `path`. + /// + /// # Zeroization contract + /// + /// - The `master` scalar is wiped inside this helper before returning. + /// - The *caller* is responsible for wiping `derived.private_key` after + /// extracting whatever it needs — `ExtendedPrivKey` carries no + /// `Drop`-based zeroization (see the `TODO(upstream)` below). + /// - All other intermediate buffers (mnemonic, seed) are `Zeroizing`- + /// wrapped and wiped on drop before this method returns. + /// + /// # Errors + /// + /// Propagates [`MnemonicResolverSignerError`] for every failure mode: + /// null handle, resolver FFI errors, encoding/parse failures, and BIP-32 + /// derivation errors. + fn resolve_and_derive( &self, path: &DerivationPath, - ) -> Result, MnemonicResolverSignerError> { + ) -> Result { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); } @@ -293,27 +307,53 @@ impl MnemonicResolverCoreSigner { let secp = Secp256k1::new(); let mut master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - let mut derived = master - .derive_priv(&secp, path) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; - - // `secret_bytes()` returns a plain `[u8; 32]`; wrap in - // `Zeroizing` so the caller (and any panic-unwind path) - // wipes it on drop. - let bytes = Zeroizing::new(derived.private_key.secret_bytes()); - // TODO(upstream): `key_wallet::bip32::ExtendedPrivKey` has no // `Drop` / `Zeroize` impl — the inner `secp256k1::SecretKey` - // scalars on `master` and `derived` would otherwise drop - // un-wiped. Mirrors the SecretKey-copy hole CodeRabbit R7 - // flagged at the sign-site. Proper fix is a `Zeroize` / - // `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s - // `key-wallet/src/bip32.rs`; until that lands, wipe the two - // SecretKey fields explicitly here. Mirrored in the sibling - // FFI at `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`. + // scalar on `master` would otherwise drop un-wiped. The `derived` + // scalar is the caller's responsibility (see zeroization contract + // above). Proper fix is a `Zeroize` / `ZeroizeOnDrop` impl in + // `dashpay/rust-dashcore`'s `key-wallet/src/bip32.rs`; until + // that lands, wipe the master scalar explicitly on BOTH paths: + // the error arm below (early return) and the success path after + // the match. Mirrored in the sibling FFI at + // `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`. + let derived = match master.derive_priv(&secp, path) { + Ok(d) => d, + Err(e) => { + // Wipe master before returning — the success path below + // never runs, so line 323's erase would be skipped without + // this explicit call. + master.private_key.non_secure_erase(); + return Err(MnemonicResolverSignerError::DerivationFailed(format!( + "path: {e}" + ))); + } + }; + + // Success path: master scalar wiped here; derived is the caller's + // responsibility (documented in the zeroization contract above). master.private_key.non_secure_erase(); - derived.private_key.non_secure_erase(); + Ok(derived) + } + + /// Resolve the mnemonic and derive the raw 32-byte scalar at `path`. + /// + /// Returns the scalar in a `Zeroizing` wrapper so the caller's last + /// drop point wipes it. All other intermediate key material is zeroed + /// before this method returns (see [`Self::resolve_and_derive`]). + fn derive_priv( + &self, + path: &DerivationPath, + ) -> Result, MnemonicResolverSignerError> { + let mut derived = self.resolve_and_derive(path)?; + // `secret_bytes()` copies the 32-byte scalar; wrap in `Zeroizing` + // so the caller's drop point wipes it. + let bytes = Zeroizing::new(derived.private_key.secret_bytes()); + // Wipe the derived extended key's scalar — the extracted `bytes` + // carry it from here. Mirrors the SecretKey-copy hole noted in + // `resolve_and_derive`'s TODO(upstream). + derived.private_key.non_secure_erase(); Ok(bytes) } } @@ -362,6 +402,27 @@ impl Signer for MnemonicResolverCoreSigner { secret.non_secure_erase(); Ok(pubkey) } + + /// Derive the BIP-32 extended public key at `path`. + /// + /// Returns the full [`ExtendedPubKey`] (public point + chain code) so + /// callers can perform non-hardened child derivation locally without + /// additional round-trips to the resolver. All intermediate private-key + /// material is zeroized before this method returns (see + /// [`Self::resolve_and_derive`]); `ExtendedPubKey` carries only public + /// information and requires no further wiping. + async fn extended_public_key( + &self, + path: &DerivationPath, + ) -> Result { + let mut derived = self.resolve_and_derive(path)?; + let secp = Secp256k1::new(); + // Capture the extended public key *before* wiping the private scalar. + // `ExtendedPubKey` carries only public material (chain code + point). + let xpub = ExtendedPubKey::from_priv(&secp, &derived); + derived.private_key.non_secure_erase(); + Ok(xpub) + } } #[cfg(test)] From 27d235fe437a99331c2b9b2812d5e5efbbfb18ec Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:24:24 +0000 Subject: [PATCH 16/60] fix(dpp): implement extended_public_key on shielded FixedKeySigner test stub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rust-dashcore bump added `Signer::extended_public_key` as a required trait method. The shielded `shield_from_asset_lock_transition` signing test's `FixedKeySigner` stub was missed: it is feature-gated behind `all(test, state-transition-signing, core_key_wallet, shielded-client)` and is not compiled under default features, so the local workspace check passed while CI (which enables those features) failed with E0046. Add the stub matching the two sibling test signers (returns Err — the fixed-key stub carries no chain code). Verified with `cargo check -p dpp --tests --features state-transition-signing,core_key_wallet,shielded-client`. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MoY6vhmqZuHzNsMfJ8wakQ 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../signing_tests.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index c31e3b1fc1e..5ea8e633b3a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -33,7 +33,7 @@ use platform_version::version::PlatformVersion; use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; -use key_wallet::bip32::DerivationPath; +use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how a @@ -77,6 +77,15 @@ impl KwSigner for FixedKeySigner { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } + + async fn extended_public_key( + &self, + _path: &DerivationPath, + ) -> Result { + // Test stub holds a single raw key with no chain code; extended + // public key derivation is not meaningful here. + Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) + } } fn make_chain_asset_lock_proof() -> AssetLockProof { From 36e2fed2289c19195a8ccc517f433321ea12be10 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:15:26 +0000 Subject: [PATCH 17/60] chore(deps): bump rust-dashcore to PR #833 (Zeroize for ExtendedPrivKey) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the 8 rust-dashcore workspace deps (dashcore, dash-spv, key-wallet, key-wallet-ffi, key-wallet-manager, dash-network, dash-network-seeds, dashcore-rpc) from rev 78d10022 to f42498e0 — the head of rust-dashcore PR #833, which adds `impl zeroize::Zeroize for ExtendedPrivKey` in key-wallet/src/bip32.rs. Package versions stay at 0.45.0; Cargo.lock change is rev-only. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 512dffc0d2d..1a0a3f2d44c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=78d10022c92384affe9d71be3266ac7bb4710291#78d10022c92384affe9d71be3266ac7bb4710291" +source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index c36a7a9c874..3561c08beb9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "78d10022c92384affe9d71be3266ac7bb4710291" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } tokio-metrics = "0.5" From 3be6885df8ed67a73e24ae8da7aeb63fb6f18b8c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:15:37 +0000 Subject: [PATCH 18/60] refactor(rs-sdk-ffi): wrap ExtendedPrivKey in Zeroizing, drop manual erase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that key-wallet's ExtendedPrivKey implements Zeroize (rust-dashcore PR #833), hold the master and derived extended keys in Zeroizing<_> and let RAII wipe them on every exit path — success, ?-early-return, and panic-unwind. This removes the 4 manual `.private_key.non_secure_erase()` calls in resolve_and_derive / derive_priv / extended_public_key that only covered the paths they were hand-placed on. resolve_and_derive now returns Zeroizing; its two callers are updated. The two `secret.non_secure_erase()` calls in sign_ecdsa / public_key stay: secp256k1::SecretKey has no Zeroize impl, so those raw SecretKey copies still need an explicit wipe. Module and per-fn zeroization docs rewritten to describe the current Zeroizing approach. Co-Authored-By: Claude Opus 4.8 --- .../src/mnemonic_resolver_core_signer.rs | 104 +++++++----------- 1 file changed, 40 insertions(+), 64 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 bda1d3aa00f..6c4f73ab924 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -42,20 +42,20 @@ //! method returns. Two mechanisms cover the different ownership //! shapes: //! -//! - **`Zeroizing` wrappers** scrub on `Drop` for the byte-buffer -//! intermediates: the resolver mnemonic buffer, the BIP-39 seed, -//! and the final derived 32-byte scalar. -//! - **Explicit `non_secure_erase` calls** scrub the -//! [`secp256k1::SecretKey`] scalars inside the two intermediate +//! - **`Zeroizing` wrappers** scrub on `Drop`. This covers the +//! byte-buffer intermediates (resolver mnemonic buffer, BIP-39 seed, +//! final derived 32-byte scalar) and the two intermediate //! [`ExtendedPrivKey`] values (master + derived). `ExtendedPrivKey` -//! has no `Drop` / `Zeroize` impl in `key-wallet`, so falling out -//! of scope alone would leave those scalars resident; the explicit -//! wipe at the bottom of `derive_priv` closes the gap. Same -//! defense is applied at the sign-site for the `SecretKey` copy -//! `from_slice` creates. A proper fix is a `Zeroize` / -//! `ZeroizeOnDrop` impl in `dashpay/rust-dashcore`'s -//! `key-wallet/src/bip32.rs`; until that ships, the local wipes -//! keep the no-residue invariant true. +//! is `Copy` with no `Drop`, so the wipe fires through its +//! `Zeroizing` wrapper on every exit path — success, `?`-early-return, +//! and panic-unwind. `ExtendedPrivKey: Zeroize` comes from +//! rust-dashcore PR #833 (rev +//! `f42498e0d04257e28b4e457c16629904a872ab61`). +//! - **Explicit `non_secure_erase` calls** scrub the raw +//! [`secp256k1::SecretKey`] copies at the two sign sites, where the +//! scalar comes back out of `SecretKey::from_slice`. `SecretKey` has +//! no `Zeroize` impl (only `non_secure_erase()`), so it can't ride a +//! `Zeroizing` wrapper. //! //! Combined, no private key bytes survive past the trait-method //! boundary. @@ -232,12 +232,12 @@ impl MnemonicResolverCoreSigner { /// /// # Zeroization contract /// - /// - The `master` scalar is wiped inside this helper before returning. - /// - The *caller* is responsible for wiping `derived.private_key` after - /// extracting whatever it needs — `ExtendedPrivKey` carries no - /// `Drop`-based zeroization (see the `TODO(upstream)` below). - /// - All other intermediate buffers (mnemonic, seed) are `Zeroizing`- - /// wrapped and wiped on drop before this method returns. + /// Both the `master` and returned `derived` extended keys are held in + /// [`Zeroizing`], so every `ExtendedPrivKey` scalar is wiped on drop: + /// `master` when this helper returns, `derived` when the caller's + /// binding drops. The mnemonic and seed buffers are likewise + /// `Zeroizing`-wrapped. `ExtendedPrivKey: Zeroize` comes from + /// rust-dashcore PR #833 (rev `f42498e0d04257e28b4e457c16629904a872ab61`). /// /// # Errors /// @@ -247,7 +247,7 @@ impl MnemonicResolverCoreSigner { fn resolve_and_derive( &self, path: &DerivationPath, - ) -> Result { + ) -> Result, MnemonicResolverSignerError> { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); } @@ -305,56 +305,33 @@ impl MnemonicResolverCoreSigner { drop(mnemonic); let secp = Secp256k1::new(); - let mut master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - // TODO(upstream): `key_wallet::bip32::ExtendedPrivKey` has no - // `Drop` / `Zeroize` impl — the inner `secp256k1::SecretKey` - // scalar on `master` would otherwise drop un-wiped. The `derived` - // scalar is the caller's responsibility (see zeroization contract - // above). Proper fix is a `Zeroize` / `ZeroizeOnDrop` impl in - // `dashpay/rust-dashcore`'s `key-wallet/src/bip32.rs`; until - // that lands, wipe the master scalar explicitly on BOTH paths: - // the error arm below (early return) and the success path after - // the match. Mirrored in the sibling FFI at - // `rs-platform-wallet-ffi/src/sign_with_mnemonic_resolver.rs`. - let derived = match master.derive_priv(&secp, path) { - Ok(d) => d, - Err(e) => { - // Wipe master before returning — the success path below - // never runs, so line 323's erase would be skipped without - // this explicit call. - master.private_key.non_secure_erase(); - return Err(MnemonicResolverSignerError::DerivationFailed(format!( - "path: {e}" - ))); - } - }; - - // Success path: master scalar wiped here; derived is the caller's - // responsibility (documented in the zeroization contract above). - master.private_key.non_secure_erase(); + let master = Zeroizing::new( + ExtendedPrivKey::new_master(self.network, seed.as_ref()).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")) + })?, + ); + let derived = master + .derive_priv(&secp, path) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; - Ok(derived) + Ok(Zeroizing::new(derived)) } /// Resolve the mnemonic and derive the raw 32-byte scalar at `path`. /// /// Returns the scalar in a `Zeroizing` wrapper so the caller's last - /// drop point wipes it. All other intermediate key material is zeroed - /// before this method returns (see [`Self::resolve_and_derive`]). + /// drop point wipes it. All other intermediate key material — the two + /// [`ExtendedPrivKey`] values, mnemonic, and seed — is `Zeroizing`- + /// wrapped and wiped before this method returns (see + /// [`Self::resolve_and_derive`]). fn derive_priv( &self, path: &DerivationPath, ) -> Result, MnemonicResolverSignerError> { - let mut derived = self.resolve_and_derive(path)?; - // `secret_bytes()` copies the 32-byte scalar; wrap in `Zeroizing` - // so the caller's drop point wipes it. - let bytes = Zeroizing::new(derived.private_key.secret_bytes()); - // Wipe the derived extended key's scalar — the extracted `bytes` - // carry it from here. Mirrors the SecretKey-copy hole noted in - // `resolve_and_derive`'s TODO(upstream). - derived.private_key.non_secure_erase(); - Ok(bytes) + let derived = self.resolve_and_derive(path)?; + // `secret_bytes()` copies the 32-byte scalar out of the `Zeroizing`- + // wrapped `derived`, which wipes on drop at end of scope. + Ok(Zeroizing::new(derived.private_key.secret_bytes())) } } @@ -415,12 +392,11 @@ impl Signer for MnemonicResolverCoreSigner { &self, path: &DerivationPath, ) -> Result { - let mut derived = self.resolve_and_derive(path)?; + let derived = self.resolve_and_derive(path)?; let secp = Secp256k1::new(); - // Capture the extended public key *before* wiping the private scalar. - // `ExtendedPubKey` carries only public material (chain code + point). + // `ExtendedPubKey` carries only public material (chain code + point); + // `derived` wipes its private scalar on drop at end of scope. let xpub = ExtendedPubKey::from_priv(&secp, &derived); - derived.private_key.non_secure_erase(); Ok(xpub) } } From b175d5083233f80a6d6e1be63f05857e5074aa44 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:40:03 +0000 Subject: [PATCH 19/60] refactor(rs-sdk-ffi): confine ExtendedPrivKey to resolve_and_derive via extract closure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolve_and_derive now takes `extract: impl FnOnce(&ExtendedPrivKey) -> T` and returns T, instead of handing back Zeroizing. The derived key is wrapped in Zeroizing the instant derive_priv returns and is only ever borrowed by the closure, so no raw or wrapped ExtendedPrivKey crosses the function boundary — closing the Copy stack-residue gap flagged in review. The one remaining by-value copy is derive_priv's own return slot, which is upstream's (key-wallet) API to own. derive_priv and extended_public_key become thin closure callers. Also add a #[tokio::test] driving extended_public_key against an independently derived ExtendedPubKey from the same BIP-39 vector / network / path, asserting full-struct equality plus explicit chain_code / depth / network spot-checks so metadata loss is caught, not just the public point (fixes codecov/patch gap). Co-Authored-By: Claude Opus 4.8 --- .../src/mnemonic_resolver_core_signer.rs | 91 +++++++++++++------ 1 file changed, 65 insertions(+), 26 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 6c4f73ab924..1078802c248 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -222,8 +222,9 @@ impl MnemonicResolverCoreSigner { } } - /// Resolve the mnemonic from the Swift-side callback, then derive the - /// BIP-32 extended private key at `path`. + /// Resolve the mnemonic from the Swift-side callback, derive the BIP-32 + /// extended private key at `path`, and hand it *by reference* to + /// `extract`, returning whatever `extract` produces. /// /// This is the single entry-point for all private-key material in this /// signer. It handles the full stack: resolver FFI call → result-code @@ -232,22 +233,25 @@ impl MnemonicResolverCoreSigner { /// /// # Zeroization contract /// - /// Both the `master` and returned `derived` extended keys are held in - /// [`Zeroizing`], so every `ExtendedPrivKey` scalar is wiped on drop: - /// `master` when this helper returns, `derived` when the caller's - /// binding drops. The mnemonic and seed buffers are likewise - /// `Zeroizing`-wrapped. `ExtendedPrivKey: Zeroize` comes from - /// rust-dashcore PR #833 (rev `f42498e0d04257e28b4e457c16629904a872ab61`). + /// Both the `master` and `derived` extended keys are held in [`Zeroizing`] + /// and wiped when this method returns. The `ExtendedPrivKey` never crosses + /// the call boundary — `extract` only borrows it — so no raw or wrapped + /// key can outlive the derivation. `extract` returns public material + /// (`ExtendedPubKey`) or a `Zeroizing` scalar copy; the caller wipes the + /// latter on its own drop. The mnemonic and seed buffers are likewise + /// `Zeroizing`-wrapped. `ExtendedPrivKey: Zeroize` comes from rust-dashcore + /// PR #833 (rev `f42498e0d04257e28b4e457c16629904a872ab61`). /// /// # Errors /// /// Propagates [`MnemonicResolverSignerError`] for every failure mode: /// null handle, resolver FFI errors, encoding/parse failures, and BIP-32 /// derivation errors. - fn resolve_and_derive( + fn resolve_and_derive( &self, path: &DerivationPath, - ) -> Result, MnemonicResolverSignerError> { + extract: impl FnOnce(&ExtendedPrivKey) -> T, + ) -> Result { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); } @@ -310,28 +314,29 @@ impl MnemonicResolverCoreSigner { MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")) })?, ); - let derived = master - .derive_priv(&secp, path) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; + let derived = + Zeroizing::new(master.derive_priv(&secp, path).map_err(|e| { + MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")) + })?); - Ok(Zeroizing::new(derived)) + Ok(extract(&derived)) } /// Resolve the mnemonic and derive the raw 32-byte scalar at `path`. /// /// Returns the scalar in a `Zeroizing` wrapper so the caller's last - /// drop point wipes it. All other intermediate key material — the two - /// [`ExtendedPrivKey`] values, mnemonic, and seed — is `Zeroizing`- - /// wrapped and wiped before this method returns (see - /// [`Self::resolve_and_derive`]). + /// drop point wipes it. The intermediate `ExtendedPrivKey` values, + /// mnemonic, and seed are wiped inside [`Self::resolve_and_derive`] + /// before this returns. fn derive_priv( &self, path: &DerivationPath, ) -> Result, MnemonicResolverSignerError> { - let derived = self.resolve_and_derive(path)?; - // `secret_bytes()` copies the 32-byte scalar out of the `Zeroizing`- - // wrapped `derived`, which wipes on drop at end of scope. - Ok(Zeroizing::new(derived.private_key.secret_bytes())) + // `secret_bytes()` copies the scalar out of the borrowed key; the + // `ExtendedPrivKey` itself never leaves `resolve_and_derive`. + self.resolve_and_derive(path, |derived| { + Zeroizing::new(derived.private_key.secret_bytes()) + }) } } @@ -392,12 +397,10 @@ impl Signer for MnemonicResolverCoreSigner { &self, path: &DerivationPath, ) -> Result { - let derived = self.resolve_and_derive(path)?; let secp = Secp256k1::new(); // `ExtendedPubKey` carries only public material (chain code + point); - // `derived` wipes its private scalar on drop at end of scope. - let xpub = ExtendedPubKey::from_priv(&secp, &derived); - Ok(xpub) + // the borrowed private key never leaves `resolve_and_derive`. + self.resolve_and_derive(path, |derived| ExtendedPubKey::from_priv(&secp, derived)) } } @@ -493,6 +496,42 @@ mod tests { unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } + #[tokio::test] + async fn extended_public_key_matches_independent_derivation() { + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let path = test_path(); + let xpub = signer + .extended_public_key(&path) + .await + .expect("extended_public_key succeeds"); + + // Independently derive the expected xpub straight from the known + // BIP-39 vector — same network + path, no resolver in the loop. + let secp = Secp256k1::new(); + let mnemonic = parse_mnemonic_any_language(ENGLISH_PHRASE).expect("valid phrase"); + let master = ExtendedPrivKey::new_master(Network::Testnet, &mnemonic.to_seed("")) + .expect("master derivation"); + let derived = master.derive_priv(&secp, &path).expect("path derivation"); + let expected = ExtendedPubKey::from_priv(&secp, &derived); + + // Full-struct equality catches any field regression; the explicit + // metadata asserts keep the guarantee even if `ExtendedPubKey`'s + // `PartialEq` ever narrows, and document that chain code / depth / + // network are deliberately covered — not just the public point. + assert_eq!(xpub, expected, "xpub must match independent derivation"); + assert_eq!( + xpub.chain_code, expected.chain_code, + "chain code must match" + ); + assert_eq!(xpub.depth, expected.depth, "depth must match"); + assert_eq!(xpub.network, expected.network, "network must match"); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + #[tokio::test] async fn missing_resolver_surfaces_not_found_error() { let resolver = make_resolver(missing_resolve); From 5a2b2b5019ba67a8156b3c9de0aafd258196dfc8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:47:29 +0000 Subject: [PATCH 20/60] test(rs-sdk-ffi): make extended_public_key metadata asserts load-bearing; doc irreducible xpriv transient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder the extended_public_key test so each field-level assert (public_key, chain_code, depth, network) runs before the full-struct assert_eq!, which otherwise short-circuits and leaves the per-field metadata checks unreachable on a regression (i.e. vacuous). Proven non-vacuous: temporarily corrupting only `depth` in the impl fails the test at "depth must match", where a pubkey-only check would have passed. Also document the one irreducible transient in resolve_and_derive: key-wallet's ExtendedPrivKey::derive_priv returns by value (Copy), so its return slot holds an un-wiped copy until the same-line Zeroizing::new takes ownership — noted so the zeroization contract doesn't over-promise "zero copies ever". Co-Authored-By: Claude Opus 4.8 --- .../src/mnemonic_resolver_core_signer.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 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 1078802c248..80bc8b3b891 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -242,6 +242,13 @@ impl MnemonicResolverCoreSigner { /// `Zeroizing`-wrapped. `ExtendedPrivKey: Zeroize` comes from rust-dashcore /// PR #833 (rev `f42498e0d04257e28b4e457c16629904a872ab61`). /// + /// One transient is irreducible: `ExtendedPrivKey::derive_priv` returns the + /// child key *by value* (`ExtendedPrivKey` is `Copy`), so its return slot + /// briefly holds an un-wiped copy until the same-line `Zeroizing::new` + /// takes ownership. Closing that would require an upstream signature change + /// in key-wallet; it is not "zero copies ever", just the tightest this + /// layer can guarantee. + /// /// # Errors /// /// Propagates [`MnemonicResolverSignerError`] for every failure mode: @@ -517,17 +524,26 @@ mod tests { let derived = master.derive_priv(&secp, &path).expect("path derivation"); let expected = ExtendedPubKey::from_priv(&secp, &derived); - // Full-struct equality catches any field regression; the explicit - // metadata asserts keep the guarantee even if `ExtendedPubKey`'s - // `PartialEq` ever narrows, and document that chain code / depth / - // network are deliberately covered — not just the public point. - assert_eq!(xpub, expected, "xpub must match independent derivation"); + // Field-level checks run first so a silently-dropped BIP-32 metadatum + // fails here with a precise message — not just the public point. The + // final full-struct assert then catches the remaining fields + // (parent_fingerprint, child_number). Ordering matters: a leading + // full-struct `assert_eq!` would short-circuit and make these + // per-field asserts unreachable (i.e. vacuous) on a metadata regression. + assert_eq!( + xpub.public_key, expected.public_key, + "public key must match" + ); assert_eq!( xpub.chain_code, expected.chain_code, "chain code must match" ); assert_eq!(xpub.depth, expected.depth, "depth must match"); assert_eq!(xpub.network, expected.network, "network must match"); + assert_eq!( + xpub, expected, + "full xpub must match independent derivation" + ); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } From 56612fd529d40268d4f290232f62bab312c2d950 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:48:28 +0000 Subject: [PATCH 21/60] chore(deps): bump rust-dashcore to a8c57fe (drop Copy, zeroize ExtendedPrivKey on Drop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Advances the 8 rust-dashcore workspace deps from rev f42498e0d04257e28b4e457c16629904a872ab61 to a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e (PR #833 head). The new commit drops `Copy` from `ExtendedPrivKey` and gives it a `Drop` impl that zeroizes its secret material on scope exit — so the key wipes itself automatically instead of relying on caller-side wrappers, and non-`Copy` moves leave no stray bitwise duplicate behind. No workspace call sites relied on `ExtendedPrivKey: Copy`: the Copy-removal impact is absorbed upstream in `bip32::derive_priv` (clones `self` instead of `*self`), so `cargo check --workspace --all-targets` is clean with no source changes. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 16 ++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a0a3f2d44c..6a71df9fafd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=f42498e0d04257e28b4e457c16629904a872ab61#f42498e0d04257e28b4e457c16629904a872ab61" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e#a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 3561c08beb9..746d23ddeb2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "f42498e0d04257e28b4e457c16629904a872ab61" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e" } tokio-metrics = "0.5" From 374df98771712ec3858dde230762fbe689f22308 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:48:43 +0000 Subject: [PATCH 22/60] refactor(rs-sdk-ffi): rely on ExtendedPrivKey Drop, drop redundant Zeroizing wrap `ExtendedPrivKey` now zeroizes on `Drop` (rust-dashcore rev a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e), so wrapping the master and derived keys in `Zeroizing` inside `resolve_and_derive` only bought a harmless double-wipe. Unwrap both and let the type's own `Drop` do the work; `Zeroizing` stays on the plain byte buffers that have no `Drop` of their own (mnemonic buffer, BIP-39 seed, final 32-byte scalar). Rewrites the module `# Zeroization` block and the `resolve_and_derive` contract to the present three-mechanism reality (self-wiping key + Zeroizing buffers + non_secure_erase on SecretKey). The previously documented "irreducible transient" caveat is fully closed: a non-`Copy` move leaves no bitwise duplicate, so there is no un-wiped return slot. Co-Authored-By: Claude Opus 4.8 --- .../src/mnemonic_resolver_core_signer.rs | 57 ++++++++----------- 1 file changed, 24 insertions(+), 33 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 80bc8b3b891..97ce91cd0a3 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -39,18 +39,18 @@ //! # Zeroization //! //! Every intermediate that carries key material is wiped before the -//! method returns. Two mechanisms cover the different ownership +//! method returns. Three mechanisms cover the different ownership //! shapes: //! -//! - **`Zeroizing` wrappers** scrub on `Drop`. This covers the -//! byte-buffer intermediates (resolver mnemonic buffer, BIP-39 seed, -//! final derived 32-byte scalar) and the two intermediate -//! [`ExtendedPrivKey`] values (master + derived). `ExtendedPrivKey` -//! is `Copy` with no `Drop`, so the wipe fires through its -//! `Zeroizing` wrapper on every exit path — success, `?`-early-return, -//! and panic-unwind. `ExtendedPrivKey: Zeroize` comes from -//! rust-dashcore PR #833 (rev -//! `f42498e0d04257e28b4e457c16629904a872ab61`). +//! - **[`ExtendedPrivKey`] self-wipes on `Drop`.** The master and +//! derived extended keys zero their secret material when they leave +//! scope, on every exit path — success, `?`-early-return, and +//! panic-unwind. The type is no longer `Copy` as of rust-dashcore rev +//! `a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e`, so each move is a real +//! move that leaves no stray bitwise duplicate behind. +//! - **`Zeroizing` wrappers** scrub the plain byte buffers that carry +//! no `Drop` of their own: the resolver mnemonic buffer, the BIP-39 +//! seed, and the final derived 32-byte scalar. //! - **Explicit `non_secure_erase` calls** scrub the raw //! [`secp256k1::SecretKey`] copies at the two sign sites, where the //! scalar comes back out of `SecretKey::from_slice`. `SecretKey` has @@ -233,21 +233,16 @@ impl MnemonicResolverCoreSigner { /// /// # Zeroization contract /// - /// Both the `master` and `derived` extended keys are held in [`Zeroizing`] - /// and wiped when this method returns. The `ExtendedPrivKey` never crosses - /// the call boundary — `extract` only borrows it — so no raw or wrapped - /// key can outlive the derivation. `extract` returns public material + /// Both the `master` and `derived` extended keys wipe their secret + /// material when they leave this scope — [`ExtendedPrivKey`] zeroizes on + /// `Drop` as of rust-dashcore rev + /// `a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e`, and is no longer `Copy`, so + /// each move is a real move that leaves no bitwise duplicate behind. The + /// key never crosses the call boundary — `extract` only borrows it — so it + /// cannot outlive the derivation. `extract` returns public material /// (`ExtendedPubKey`) or a `Zeroizing` scalar copy; the caller wipes the - /// latter on its own drop. The mnemonic and seed buffers are likewise - /// `Zeroizing`-wrapped. `ExtendedPrivKey: Zeroize` comes from rust-dashcore - /// PR #833 (rev `f42498e0d04257e28b4e457c16629904a872ab61`). - /// - /// One transient is irreducible: `ExtendedPrivKey::derive_priv` returns the - /// child key *by value* (`ExtendedPrivKey` is `Copy`), so its return slot - /// briefly holds an un-wiped copy until the same-line `Zeroizing::new` - /// takes ownership. Closing that would require an upstream signature change - /// in key-wallet; it is not "zero copies ever", just the tightest this - /// layer can guarantee. + /// latter on its own drop. The mnemonic and seed buffers are plain arrays + /// and ride [`Zeroizing`] wrappers for the same guarantee. /// /// # Errors /// @@ -316,15 +311,11 @@ impl MnemonicResolverCoreSigner { drop(mnemonic); let secp = Secp256k1::new(); - let master = Zeroizing::new( - ExtendedPrivKey::new_master(self.network, seed.as_ref()).map_err(|e| { - MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")) - })?, - ); - let derived = - Zeroizing::new(master.derive_priv(&secp, path).map_err(|e| { - MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")) - })?); + let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; + let derived = master + .derive_priv(&secp, path) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; Ok(extract(&derived)) } From 1aac44a0abfb2ca93872218d09b25640e4a38939 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:29:01 +0000 Subject: [PATCH 23/60] fix(deps): stage regenerated Cargo.lock for rust-dashcore a8a0968 bump The prior merge commit updated Cargo.toml's rust-dashcore rev pins to the current dev HEAD but left Cargo.lock staged at the pre-cargo-update state (still pinned to v0.44.0/991c6ebe), an oversight caught during PR comment verification. Stage the already-regenerated lockfile so both files agree on the a8a096838b829cf5bec3c2374a23511640a0c35c pin. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 70 +++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5148cee60c1..c7ae962cba6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -1636,8 +1636,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "bincode", "bincode_derive", @@ -1647,8 +1647,8 @@ dependencies = [ [[package]] name = "dash-network-seeds" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "dash-network", ] @@ -1724,8 +1724,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "async-trait", "chrono", @@ -1753,8 +1753,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "anyhow", "base64-compat", @@ -1779,13 +1779,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" [[package]] name = "dashcore-rpc" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "dashcore-rpc-json", "hex", @@ -1797,8 +1797,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "bincode", "dashcore", @@ -1812,8 +1812,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "bincode", "dashcore-private", @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2866,8 +2866,8 @@ dependencies = [ [[package]] name = "git-state" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" [[package]] name = "glob" @@ -3803,7 +3803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4033,8 +4033,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "aes", "async-trait", @@ -4062,8 +4062,8 @@ dependencies = [ [[package]] name = "key-wallet-ffi" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4078,8 +4078,8 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "async-trait", "bincode", @@ -4596,7 +4596,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5696,7 +5696,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6486,7 +6486,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6499,7 +6499,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6558,7 +6558,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -7418,7 +7418,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -8867,7 +8867,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] From defa2491495a5499c0d97c9dfe6f327d8fb4f6b5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:31:25 +0000 Subject: [PATCH 24/60] build: sync Cargo.lock to rust-dashcore a8a0968 (0.45.0) The committed lockfile still pinned all 12 rust-dashcore workspace crates at the stale rev 991c6eb (v0.44.0), even though root Cargo.toml was already re-pinned to a8a096838b829cf5bec3c2374a23511640a0c35c (v0.45.0). Cargo silently re-resolves the lock on the next build, so --locked builds (CI) would fail against the mismatch. Regenerate the lock to match: dashcore, dashcore-private, dashcore_hashes, dashcore-rpc, dashcore-rpc-json, dash-network, dash-network-seeds, dash-spv, git-state, key-wallet, key-wallet-ffi, key-wallet-manager all move 991c6eb -> a8a0968. No other crate churn. Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5148cee60c1..c177bf2960a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1636,8 +1636,8 @@ dependencies = [ [[package]] name = "dash-network" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "bincode", "bincode_derive", @@ -1647,8 +1647,8 @@ dependencies = [ [[package]] name = "dash-network-seeds" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "dash-network", ] @@ -1724,8 +1724,8 @@ dependencies = [ [[package]] name = "dash-spv" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "async-trait", "chrono", @@ -1753,8 +1753,8 @@ dependencies = [ [[package]] name = "dashcore" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "anyhow", "base64-compat", @@ -1779,13 +1779,13 @@ dependencies = [ [[package]] name = "dashcore-private" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" [[package]] name = "dashcore-rpc" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "dashcore-rpc-json", "hex", @@ -1797,8 +1797,8 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "bincode", "dashcore", @@ -1812,8 +1812,8 @@ dependencies = [ [[package]] name = "dashcore_hashes" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "bincode", "dashcore-private", @@ -2866,8 +2866,8 @@ dependencies = [ [[package]] name = "git-state" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" [[package]] name = "glob" @@ -4033,8 +4033,8 @@ dependencies = [ [[package]] name = "key-wallet" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "aes", "async-trait", @@ -4062,8 +4062,8 @@ dependencies = [ [[package]] name = "key-wallet-ffi" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4078,8 +4078,8 @@ dependencies = [ [[package]] name = "key-wallet-manager" -version = "0.44.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=991c6ebe24d7ea8ba7d900a052b25be8c5498409#991c6ebe24d7ea8ba7d900a052b25be8c5498409" +version = "0.45.0" +source = "git+https://github.com/dashpay/rust-dashcore?rev=a8a096838b829cf5bec3c2374a23511640a0c35c#a8a096838b829cf5bec3c2374a23511640a0c35c" dependencies = [ "async-trait", "bincode", From f669691f7a22f50f4fab7a056036a53ebf781b9b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:31:37 +0000 Subject: [PATCH 25/60] docs(rs-sdk-ffi): correct ExtendedPrivKey zeroization comments for a8a0968 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two accuracy fixes forced by the rust-dashcore pin move to a8a0968 (squashed PR #833 form), verified against key-wallet/src/bip32.rs at that rev — ExtendedPrivKey now derives Clone (not Copy) and has an explicit `impl Drop` that calls `self.zeroize()`: - signer_simple.rs: the `dash_sdk_sign_with_mnemonic_and_path` comment still claimed "the ExtendedPrivKey itself doesn't zeroize — derived falls out of scope intact". That was true when written (PR #3541, pre-Zeroize), but is now false: `derived` self-wipes on Drop. Reword to state present reality and clarify the Zeroizing wrapper is there because `secret_bytes()` copies the scalar into a fresh array with no Drop of its own. - mnemonic_resolver_core_signer.rs: re-point two rev citations from a8c57fe (rebased away upstream, no longer reachable from dev) to the current reachable pin a8a0968. The behavioral claims are unchanged and still hold. Comment-only; no functional change. Co-Authored-By: Claude Opus 4.8 --- packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs | 4 ++-- packages/rs-sdk-ffi/src/signer_simple.rs | 8 +++++--- 2 files changed, 7 insertions(+), 5 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 97ce91cd0a3..6bedd522d8f 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -46,7 +46,7 @@ //! derived extended keys zero their secret material when they leave //! scope, on every exit path — success, `?`-early-return, and //! panic-unwind. The type is no longer `Copy` as of rust-dashcore rev -//! `a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e`, so each move is a real +//! `a8a096838b829cf5bec3c2374a23511640a0c35c`, so each move is a real //! move that leaves no stray bitwise duplicate behind. //! - **`Zeroizing` wrappers** scrub the plain byte buffers that carry //! no `Drop` of their own: the resolver mnemonic buffer, the BIP-39 @@ -236,7 +236,7 @@ impl MnemonicResolverCoreSigner { /// Both the `master` and `derived` extended keys wipe their secret /// material when they leave this scope — [`ExtendedPrivKey`] zeroizes on /// `Drop` as of rust-dashcore rev - /// `a8c57fe863c96ac9c7e33833549e7a4f75ac9b5e`, and is no longer `Copy`, so + /// `a8a096838b829cf5bec3c2374a23511640a0c35c`, and is no longer `Copy`, so /// each move is a real move that leaves no bitwise duplicate behind. The /// key never crosses the call boundary — `extract` only borrows it — so it /// cannot outlive the derivation. `extract` returns public material diff --git a/packages/rs-sdk-ffi/src/signer_simple.rs b/packages/rs-sdk-ffi/src/signer_simple.rs index 9b5ecd9ac90..a93571f0fc9 100644 --- a/packages/rs-sdk-ffi/src/signer_simple.rs +++ b/packages/rs-sdk-ffi/src/signer_simple.rs @@ -414,9 +414,11 @@ pub unsafe extern "C" fn dash_sdk_sign_with_mnemonic_and_path( Ok(d) => d, Err(_) => return fail(SIGN_WITH_MNEMONIC_ERR_DERIVATION), }; - // Pull the 32 secret bytes into a `Zeroizing` so they're scrubbed - // when this function returns (the `ExtendedPrivKey` itself doesn't - // zeroize — `derived` falls out of scope intact). + // Copy the 32 secret bytes into a `Zeroizing` so this fresh array — + // which has no `Drop` of its own — is scrubbed when the function + // returns. `derived` self-wipes separately: `ExtendedPrivKey` + // zeroizes on `Drop` as of rust-dashcore rev + // a8a096838b829cf5bec3c2374a23511640a0c35c. let secret_bytes: zeroize::Zeroizing<[u8; 32]> = zeroize::Zeroizing::new(derived.private_key.secret_bytes()); From 5dfd4412e6c2bfb0963fa5653354de5fa0148db8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:03:55 +0000 Subject: [PATCH 26/60] fix(deps): bump quinn-proto 0.11.14 -> 0.11.15 (RUSTSEC-2026-0185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Silences the re-armed `cargo audit` CI gate (`.cargo/audit.toml` ignore = []) which fires on RUSTSEC-2026-0185: remote memory exhaustion in quinn-proto's out-of-order stream Assembler (CVSS 7.5, GHSA-4w2j-m93h-cj5j), fixed in >=0.11.15. quinn-proto is a lockfile-only transitive artifact of reqwest's optional `http3` feature — no workspace crate activates it, so it is never compiled (`cargo tree -i quinn-proto --all-features` prints nothing). The bump is semver-compatible (cargo resolved 0.11.15 for quinn 0.11.9) and scoped to a single package: only the quinn-proto version + checksum change; no other dependency moves. cargo audit now exits 0. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8de05bacd92..7d6221a038d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5665,9 +5665,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", From 0feb7d67cd584316666694948060796f334110db Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:07:50 +0000 Subject: [PATCH 27/60] docs(platform-wallet): fix dead doc-links and stale references in rehydration comments - load_outcome.rs / client_wallet_start_state.rs: rs-platform-wallet has no dependency on rs-sdk-ffi or rs-platform-wallet-ffi, so the MnemonicResolverHandle and sign_with_mnemonic_resolver intra-doc links could never resolve; reword as plain prose naming the owning crate/symbol instead. - client_wallet_start_state.rs: `core_state` was documented as rebuilt by a `core_state::load_state` reader that doesn't exist in the codebase, carrying a stale internal work-item label. Point at the actual PlatformWalletPersistence::load implementation instead. - rehydration_load.rs: the removed PlatformEvent::WalletSkippedOnLoad enum variant was still referenced in two doc comments; updated to describe the real on_wallet_skipped_on_load handler mechanism. Co-Authored-By: Claude Sonnet 5 --- .../src/changeset/client_wallet_start_state.rs | 11 +++++------ .../rs-platform-wallet/src/manager/load_outcome.rs | 10 ++++------ packages/rs-platform-wallet/tests/rehydration_load.rs | 8 ++++---- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index ed059d8b06b..37328335c37 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -7,10 +7,8 @@ //! can never mint a `Wallet`; the manager rebuilds a watch-only one via //! [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) //! from the manifest, applies this state, and defers signing-key -//! derivation to the on-demand sign path -//! ([`sign_with_mnemonic_resolver`] and its siblings). -//! -//! [`sign_with_mnemonic_resolver`]: https://docs.rs/rs-platform-wallet-ffi/ +//! derivation to the on-demand sign path (`rs-platform-wallet-ffi`'s +//! `dash_sdk_sign_with_mnemonic_resolver_and_path` and its siblings). use std::collections::BTreeMap; @@ -42,8 +40,9 @@ pub struct ClientWalletStartState { /// records, IS-locks, sync watermarks, `last_applied_chain_lock`). /// The manager applies this onto a fresh /// `ManagedWalletInfo::from_wallet` skeleton built from the - /// watch-only wallet. Rebuilt by the `core_state::load_state` reader - /// (item B). + /// watch-only wallet. Populated by the persister's + /// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load) + /// implementation reading the persisted core rows. pub core_state: CoreChangeSet, /// Lean snapshot of this wallet's /// [`IdentityManager`](crate::wallet::identity::IdentityManager). diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index 53d936aa6c5..8b3cc25e911 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -7,15 +7,13 @@ use crate::wallet::platform_wallet::WalletId; /// Why a persisted wallet row was skipped during a load pass. /// /// Load is **watch-only** (no seed material involved): signing keys are -/// derived later, on demand, via the [`MnemonicResolverHandle`] sign -/// path. A skip therefore means the persisted row itself was unusable — -/// a per-row decode/structural failure that fails one wallet without -/// aborting the batch. The only reason is +/// derived later, on demand, via the `MnemonicResolverHandle` +/// (`rs-sdk-ffi`) sign path. A skip therefore means the persisted row +/// itself was unusable — a per-row decode/structural failure that fails +/// one wallet without aborting the batch. The only reason is /// [`CorruptPersistedRow`](Self::CorruptPersistedRow): the load path /// never touches the seed, so it cannot skip for a wrong or unavailable /// seed. Variants carry no key material (SECRETS.md SEC-REQ-2.0.1). -/// -/// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum SkipReason { diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index c63600b415e..96a97fff48a 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -11,8 +11,8 @@ //! RT cases here: //! - RT-WO: round-trip — watch-only wallet is registered after reload. //! - RT-Corrupt: a row with an empty manifest is skipped with -//! `MissingManifest`, the other row loads, a `WalletSkippedOnLoad` -//! event fires, `load` returns `Ok`. +//! `MissingManifest`, the other row loads, `on_wallet_skipped_on_load` +//! fires on the registered handler, `load` returns `Ok`. //! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason` //! surface (the structural-only contract). @@ -264,8 +264,8 @@ async fn rt_persister_skipped_folds_into_outcome() { /// RT-Corrupt: a corrupt row (empty manifest) is skipped with /// `MissingManifest`; the other row loads cleanly; the load returns -/// `Ok`; exactly one `WalletSkippedOnLoad` event fires for the skipped -/// row. +/// `Ok`; `on_wallet_skipped_on_load` fires exactly once on the +/// registered handler for the skipped row. #[tokio::test] async fn rt_corrupt_row_skipped_and_other_loads() { let seed_a = [0x31; 64]; From 59dc04d56567ef719a2ac165684221d124561d4e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:09:16 +0000 Subject: [PATCH 28/60] fix(platform-wallet-ffi): harden load-outcome FFI surface (findings #1, #2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding #1 (SEC MED): platform_wallet_manager_load_from_persistor wrote `*out_outcome` only on the success path, so an error/early-return left a caller-supplied pointer aimed at uninitialized LoadOutcomeFFI storage. platform_wallet_load_outcome_free then Box::from_raw's a garbage `skipped` pointer — UB for any future caller that passes a real (non-nil) out-param (today's Swift call site passes nil, so it is latent). Zero-init the out-param up front (null `skipped`, zeroed counts) via ptr::write before any fallible step, matching this crate's null-init-first out-pointer idiom, so every return path leaves it releasable. Finding #2 (LOW): the five load-skip reason codes (100/101/102/199/200) were bare literals — the doc omitted 199/200 and the values were duplicated between the doc comment and the match arms. Promote them to named LOAD_SKIP_REASON_* pub constants (exported to the C header by cbindgen, same convention as SPV_SYNC_STATE_*), referenced from both the `reason_code` docs and skip_reason_code. Tests: add out-param-on-early-return, reason-code mapping, and ABI wire-value regression tests. Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet-ffi/src/manager.rs | 105 ++++++++++++++++-- 1 file changed, 97 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 9897c09a6c1..dd6430e8cd0 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -174,6 +174,22 @@ unsafe fn create_wallet_from_mnemonic_impl( PlatformWalletFFIResult::ok() } +/// `reason_code`: the persisted row had no usable account manifest to +/// rebuild the account collection from. +pub const LOAD_SKIP_REASON_MISSING_MANIFEST: u32 = 100; +/// `reason_code`: a manifest `account_xpub` failed to parse as a +/// well-formed extended public key. +pub const LOAD_SKIP_REASON_MALFORMED_XPUB: u32 = 101; +/// `reason_code`: any other structural decode / projection failure on +/// the persisted row. +pub const LOAD_SKIP_REASON_DECODE_ERROR: u32 = 102; +/// `reason_code`: an unrecognized `CorruptKind` — forward-compat +/// fallback until this crate maps a newly added corrupt-row family. +pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199; +/// `reason_code`: an unrecognized `SkipReason` — forward-compat +/// fallback until this crate maps a newly added skip reason. +pub const LOAD_SKIP_REASON_OTHER: u32 = 200; + /// One wallet skipped during `load_from_persistor` because its /// persisted row was structurally corrupt (per-row decode failure). /// The load path is seedless and watch-only, so this is the only skip @@ -183,9 +199,13 @@ unsafe fn create_wallet_from_mnemonic_impl( pub struct SkippedWalletFFI { /// The (public) 32-byte wallet id that was skipped. pub wallet_id: [u8; 32], - /// Structural skip reason. `100` = missing account manifest, - /// `101` = malformed account xpub, `102` = any other structural - /// decode error. No secret material is ever carried. + /// Structural skip reason — one of the `LOAD_SKIP_REASON_*` + /// constants: [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100), + /// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101), + /// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102), + /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), or + /// [`LOAD_SKIP_REASON_OTHER`] (200). No secret material is ever + /// carried. pub reason_code: u32, } @@ -212,16 +232,16 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { use platform_wallet::manager::load_outcome::CorruptKind; match reason { platform_wallet::SkipReason::CorruptPersistedRow { kind } => match kind { - CorruptKind::MissingManifest => 100, - CorruptKind::MalformedXpub => 101, - CorruptKind::DecodeError(_) => 102, + CorruptKind::MissingManifest => LOAD_SKIP_REASON_MISSING_MANIFEST, + CorruptKind::MalformedXpub => LOAD_SKIP_REASON_MALFORMED_XPUB, + CorruptKind::DecodeError(_) => LOAD_SKIP_REASON_DECODE_ERROR, // `CorruptKind` is #[non_exhaustive]; a future variant maps to a // generic corrupt-row code until this mapping is extended. - _ => 199, + _ => LOAD_SKIP_REASON_CORRUPT_OTHER, }, // `SkipReason` is #[non_exhaustive]; a future reason maps to a // generic skip code until this mapping is extended. - _ => 200, + _ => LOAD_SKIP_REASON_OTHER, } } @@ -371,6 +391,21 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( manager_handle: Handle, out_outcome: *mut LoadOutcomeFFI, ) -> PlatformWalletFFIResult { + // Initialize the out-param first so every early-return path below + // leaves it releasable (zeroed counts, null `skipped`) — matches this + // crate's null-init-first out-pointer idiom and keeps + // `platform_wallet_load_outcome_free` safe on the error paths too. + if !out_outcome.is_null() { + std::ptr::write( + out_outcome, + LoadOutcomeFFI { + loaded_count: 0, + skipped_count: 0, + skipped: std::ptr::null_mut(), + }, + ); + } + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |manager| { runtime().block_on(manager.load_from_persistor()) }); @@ -545,4 +580,58 @@ mod tests { assert_eq!(birth_height_override_opt(false, 0), None); assert_eq!(birth_height_override_opt(false, 99), None); } + + #[test] + fn load_skip_reason_wire_values_are_stable() { + // FFI consumers hardcode these numbers; the ABI must not drift. + assert_eq!(LOAD_SKIP_REASON_MISSING_MANIFEST, 100); + assert_eq!(LOAD_SKIP_REASON_MALFORMED_XPUB, 101); + assert_eq!(LOAD_SKIP_REASON_DECODE_ERROR, 102); + assert_eq!(LOAD_SKIP_REASON_CORRUPT_OTHER, 199); + assert_eq!(LOAD_SKIP_REASON_OTHER, 200); + } + + #[test] + fn skip_reason_code_maps_known_kinds_to_constants() { + use platform_wallet::manager::load_outcome::CorruptKind; + use platform_wallet::SkipReason; + + let corrupt = |kind| SkipReason::CorruptPersistedRow { kind }; + assert_eq!( + skip_reason_code(&corrupt(CorruptKind::MissingManifest)), + LOAD_SKIP_REASON_MISSING_MANIFEST + ); + assert_eq!( + skip_reason_code(&corrupt(CorruptKind::MalformedXpub)), + LOAD_SKIP_REASON_MALFORMED_XPUB + ); + assert_eq!( + skip_reason_code(&corrupt(CorruptKind::DecodeError("boom".into()))), + LOAD_SKIP_REASON_DECODE_ERROR + ); + } + + #[test] + fn load_from_persistor_initializes_out_param_on_early_return() { + // An unknown handle early-returns before the success block. The + // out-param must be reset to a releasable zeroed state so a caller + // that later calls `platform_wallet_load_outcome_free` never does + // `Box::from_raw` on the uninitialized `skipped` pointer. + let mut outcome = LoadOutcomeFFI { + loaded_count: 42, + skipped_count: 7, + skipped: std::ptr::NonNull::::dangling().as_ptr(), + }; + + let result = + unsafe { platform_wallet_manager_load_from_persistor(NULL_HANDLE, &mut outcome) }; + + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(outcome.loaded_count, 0); + assert_eq!(outcome.skipped_count, 0); + assert!(outcome.skipped.is_null()); + + // Null `skipped` now makes the release path a safe no-op. + unsafe { platform_wallet_load_outcome_free(&mut outcome) }; + } } From cd6807ac31244df054a18f9902669d1fe0a6d89b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:10:20 +0000 Subject: [PATCH 29/60] docs(platform-wallet): fix dead MnemonicResolverHandle doc-link in load.rs Same issue as load_outcome.rs / client_wallet_start_state.rs: this crate has no dependency on rs-sdk-ffi, so the intra-doc reference link could never resolve. Reworded as plain prose naming the owning crate. Co-Authored-By: Claude Sonnet 5 --- packages/rs-platform-wallet/src/manager/load.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index cf12ecf9624..25dd418f622 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -26,7 +26,7 @@ impl PlatformWalletManager

{ /// /// The load path never touches the seed, so it performs no wrong-seed /// check. Signing happens later, on demand, via the configured - /// [`MnemonicResolverHandle`]. + /// `MnemonicResolverHandle` (`rs-sdk-ffi`). /// /// # Skip vs hard-fail /// @@ -55,8 +55,6 @@ impl PlatformWalletManager

{ /// or a fresh /// [`initialize`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize) /// when the snapshot carries no slice for it. - /// - /// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle pub async fn load_from_persistor(&self) -> Result { let ClientStartState { mut platform_addresses, From a9d3a7613649ca9866134f15f9caca1e6a88a93f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:13:44 +0000 Subject: [PATCH 30/60] refactor(platform-wallet-ffi): typed corrupt-kind discriminator + honest restore docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three confirmed review findings on PR #3692 in the load-side persistence path. No production behaviour change (docs + one internal error-classification refactor). Finding 2 (CQ) — typed discriminator instead of Display-substring match: `corrupt_kind_from_build_err` classified malformed-xpub failures by `String::contains` on the error's Display text. Replaced with a typed `MalformedXpubError` marker boxed into `PersistenceError::Backend.source`, recovered via `downcast_ref` — a structural decision no longer routed through human-readable text. Removed the now-dead `MALFORMED_XPUB_ERR_PREFIX` constant. The regression test now also proves a generic `DecodeError` whose message happens to contain "failed to decode account xpub" is NOT misclassified — the exact false-positive the string match could produce. Finding 1 (SEC LOW) — explicit chain-lock trust boundary: `metadata.last_applied_chain_lock` is bincode-decoded from the unauthenticated local store with no BLS/quorum re-verification. Added a TRUST BOUNDARY doc note making this explicit: the value is a cache hint, not a trusted source; data integrity rests on the downstream network re-verification of the asset-lock proof (`proof.rs`). No cheap semantic check is meaningfully available here (decode already enforces struct shape; a stale/forged CL is inert-to-harmless downstream), so this stays doc-only per the LOW severity. Finding 4 (Docs) — describe the watch-only model, drop tombstones: The `build_wallet_start_state` doc was orphaned onto the removed constant and described a stale "external-signable/host-keychain-signer" model that contradicts the watch-only registration used everywhere else. Moved a corrected doc onto the function, rewrote the call-site comment, and cleaned the `on_load_wallet_list_fn` + wallet_restore_types module docs to describe present state (transient scratch wallet → manager registers watch-only, signs on demand via the mnemonic resolver). Removed three tombstone comments per the project's describe-present-state convention. Finding 3 (CQ) — DEFERRED: the FFI/manager derive-pools duplication is a deliberate consequence of the keyless-projection boundary (no Wallet/seed may cross load()); collapsing it would require redesigning that contract, out of scope for this PR. Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet-ffi/src/persistence.rs | 102 ++++++++++++------ .../src/wallet_restore_types.rs | 11 +- 2 files changed, 72 insertions(+), 41 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 64622234fbb..50eed9ade54 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -149,11 +149,10 @@ pub struct PersistenceCallbacks { ) -> i32, >, /// Invoked on [`FFIPersister::load`] to pull the persisted wallet - /// list back into Rust for external-signable reconstruction. - /// (The function name still reads "watch-only" in older docs; the - /// reconstructed `Wallet` is built via - /// `Wallet::new_external_signable` so the signer surface routes - /// back to the host's keychain.) + /// list back into Rust. Each entry is rebuilt into a transient + /// `Wallet` used only to shape the keyless start-state projection; + /// the manager then re-registers the wallet watch-only and signs on + /// demand via the host mnemonic resolver. /// /// Implementations must set `*out_entries` to a Swift-allocated /// array of `WalletRestoreEntryFFI` and `*out_count` to the @@ -2778,30 +2777,50 @@ impl Drop for LoadGuard { } } -/// Reconstruct an external-signable [`Wallet`] + matching start-state -/// bucket from a single `WalletRestoreEntryFFI`. The mnemonic / seed -/// stays in the host's keychain; signing requests route back through -/// the configured signer surface (see -/// `Wallet::new_external_signable`). Earlier revisions of this code -/// path produced a `WatchOnly` wallet — that has been replaced. -/// Error-message prefix emitted when an account xpub fails to decode. -/// Shared by the producer and [`corrupt_kind_from_build_err`] so the -/// `MalformedXpub` classification can't silently drift from the text. -const MALFORMED_XPUB_ERR_PREFIX: &str = "failed to decode account xpub"; +/// Marker error: an account xpub failed to bincode-decode into a +/// well-formed extended public key. Boxed into the +/// `PersistenceError::Backend` `source` so [`corrupt_kind_from_build_err`] +/// recovers the classification by downcast — a typed discriminator +/// rather than a `Display`-text match. +#[derive(Debug)] +struct MalformedXpubError(String); + +impl std::fmt::Display for MalformedXpubError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "failed to decode account xpub: {}", self.0) + } +} + +impl std::error::Error for MalformedXpubError {} /// Classify a [`build_wallet_start_state`] failure for the FFI -/// `reason_code`: a malformed xpub maps to [`CorruptKind::MalformedXpub`] -/// (101), anything else to [`CorruptKind::DecodeError`] (102). -/// String-matched because `PersistenceError` carries no typed discriminator. +/// `reason_code`: a boxed [`MalformedXpubError`] in the backend `source` +/// maps to [`CorruptKind::MalformedXpub`] (101), anything else to +/// [`CorruptKind::DecodeError`] (102). fn corrupt_kind_from_build_err(e: &PersistenceError) -> CorruptKind { - let msg = e.to_string(); - if msg.contains(MALFORMED_XPUB_ERR_PREFIX) { - CorruptKind::MalformedXpub - } else { - CorruptKind::DecodeError(msg) + if let PersistenceError::Backend { source, .. } = e { + if source.downcast_ref::().is_some() { + return CorruptKind::MalformedXpub; + } } + CorruptKind::DecodeError(e.to_string()) } +/// Reconstruct the keyless [`ClientWalletStartState`] (and optional +/// platform-address bucket) for one persisted `WalletRestoreEntryFFI`. +/// +/// A transient `Wallet` is built here solely to shape the account +/// manifest and core-state projection returned below; it never leaves +/// this function. The manager rehydrates each wallet **watch-only** +/// (via `Wallet::new_watch_only`) from that manifest and signs on +/// demand through the host mnemonic resolver — no seed crosses this +/// boundary. +/// +/// # Errors +/// +/// Returns [`PersistenceError`] on any per-row decode/projection +/// failure (e.g. a malformed account xpub); the caller records the +/// wallet as skipped and continues restoring the rest. fn build_wallet_start_state( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -2852,9 +2871,8 @@ fn build_wallet_start_state( let xpub_bytes = unsafe { slice_from_raw(spec.account_xpub_bytes, spec.account_xpub_bytes_len) }; let (account_xpub, _): (ExtendedPubKey, usize) = - bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { - PersistenceError::backend(format!("{MALFORMED_XPUB_ERR_PREFIX}: {e}")) - })?; + bincode::decode_from_slice(xpub_bytes, config::standard()) + .map_err(|e| PersistenceError::backend(MalformedXpubError(e.to_string())))?; let account = Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network) .map_err(|e| { @@ -2865,12 +2883,13 @@ fn build_wallet_start_state( })?; } - // External-signable wallet — the mnemonic / seed lives in the - // iOS Keychain, not in this Rust handle. Signing requests route - // back to the host through the configured signer surface; the - // host fetches the mnemonic from the Keychain on demand. The - // wallet_id is passed in directly (no recomputation from a root - // xpub the snapshot doesn't carry). + // Transient scratch wallet — used only to shape the account + // manifest and core-state projection below, then dropped; its + // `WalletType` never reaches the manager, which re-registers the + // wallet watch-only and signs on demand via the host mnemonic + // resolver (no seed crosses this boundary). The wallet_id is passed + // in directly (no recomputation from a root xpub the snapshot + // doesn't carry). let wallet = Wallet::new_external_signable(network, entry.wallet_id, accounts); // Stamp the persisted core-chain sync metadata onto the rebuilt @@ -2900,6 +2919,16 @@ fn build_wallet_start_state( // own `best_chainlock` independently; this is the symmetric // wallet-side restore. // + // TRUST BOUNDARY: this chain lock is read from the unauthenticated + // local store and is NOT re-verified here — decode enforces the + // struct shape only; no BLS/quorum signature check runs on this + // path. Treat the value as a cache hint, not a trusted source. It + // merely seeds the asset-lock-resume fallback; data integrity for + // that path rests on the DOWNSTREAM network re-verification of the + // asset-lock proof itself (`proof.rs`), which is authoritative. A + // forged/stale local CL can at most trigger an earlier resume + // attempt whose proof then fails network verification. + // // Decode failure is treated as miss: malformed bytes here are // either a serialisation-shape regression in upstream `ChainLock` // or a corrupted SwiftData row — neither is recoverable in-flight, @@ -4162,16 +4191,19 @@ mod tests { /// so the host can special-case unrecoverable key-material corruption. #[test] fn malformed_xpub_error_maps_to_dedicated_corrupt_kind() { + // A boxed `MalformedXpubError` must be recovered by downcast, + // independently of its human-readable `Display` text. let xpub_err = - PersistenceError::backend(format!("{MALFORMED_XPUB_ERR_PREFIX}: invalid checksum")); + PersistenceError::backend(MalformedXpubError("invalid checksum".to_string())); assert_eq!( corrupt_kind_from_build_err(&xpub_err), CorruptKind::MalformedXpub, "an xpub-decode failure must surface as MalformedXpub (code 101)" ); - // Any unrelated structural failure keeps the generic family. - let other_err = PersistenceError::backend("Account::from_xpub failed: bad network"); + // Any unrelated structural failure keeps the generic family — + // even when its message happens to mention "decode account xpub". + let other_err = PersistenceError::backend("failed to decode account xpub: bad network"); assert!( matches!( corrupt_kind_from_build_err(&other_err), diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index c9ef0019914..b31ebfbfa8c 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -5,12 +5,11 @@ //! On write: `on_persist_account_registrations_fn` fires with the //! `AccountSpecFFI` shape so Swift can store accounts in SwiftData. //! On load: `on_load_wallet_list_fn` returns an array of -//! `WalletRestoreEntryFFI` which Rust assembles into an -//! external-signable `Wallet` via `Wallet::new_external_signable` + -//! per-account `Account::from_xpub`. (The mnemonic stays in the -//! host's keychain; signing routes back through the configured -//! signer surface. Earlier revisions reconstructed a `WatchOnly` -//! wallet — that path has been replaced.) +//! `WalletRestoreEntryFFI` which Rust assembles into a transient +//! `Wallet` (via `Wallet::new_external_signable` + per-account +//! `Account::from_xpub`) used only to shape the keyless start-state +//! projection; the manager then re-registers the wallet watch-only and +//! signs on demand via the host mnemonic resolver. //! //! All `*const u8` pointers must stay valid for the duration of the //! load callback. Swift owns the allocation and is asked to free it From 284a3aae9391b7330f8e2edf7f3b99aede7dd45e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:15:37 +0000 Subject: [PATCH 31/60] fix(platform-wallet): harden watch-only rehydration guards + document manifest trust boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses 5 confirmed PR #3692 review findings in manager/rehydrate.rs (feat/platform-wallet-rehydration). #1 (SEC HIGH/MED) build_watch_only_wallet — manifest not bound to wallet_id. Documented the trust boundary on build_watch_only_wallet and at load_from_persistor's entry point. No cryptographic binding is possible here: the scoped wallet_id hashes the ROOT xpub, but only account-level (hardened, one-way) xpubs are persisted, so the root cannot be recovered to re-derive and verify the id — compute_wallet_id() for WalletType::WatchOnly just echoes self.wallet_id. A real MAC/commitment over {wallet_id, network, manifest} keyed to a secure-enclave secret requires a new persisted field = storage-schema change in the storage crate, out of scope for this PR. Flagged as a follow-up. (A network cross-check was rejected: ExtendedPubKey serde uses the BIP32 string form, whose version bytes collapse Devnet/Regtest to Testnet, so it would falsely skip legit Devnet/Regtest wallets.) #2 (CQ LOW) Dead-code branch on Account::from_xpub. Kept the defensive map_err (Account::from_xpub is unconditionally Ok in the pinned key-wallet rev 5c0113e) and added a NOTE explaining it guards against a future fallible signature. #3 (CQ LOW) Probe/pool chain-order invariant guarded only by debug_assert_eq!. Promoted to a runtime fail-closed guard returning a new granular PlatformWalletError::RehydrationPoolTypeMismatch { position, expected, found } so a release build cannot silently misattribute derivation depth by position. Sole caller (apply_persisted_core_state) already propagates via `?`. #4 (CQ LOW) probes Vec<(AddressPool, BTreeSet)> only ever read for its max. Simplified to Vec<(AddressPool, Option)>, unifying it with the running deepest-resolved max (removes a per-chain allocation and duplicated state). #5 (Docs LOW) Eager-window notation. Fixed `0..=gap_limit` -> `0..gap_limit` (exclusive) at two doc sites to match the index-layout table and verified test behavior. clippy (-D warnings, --all-targets) clean; 216 lib tests pass incl. all 11 rehydrate tests. Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet/src/error.rs | 19 +++++++ .../rs-platform-wallet/src/manager/load.rs | 8 +++ .../src/manager/rehydrate.rs | 55 ++++++++++++++----- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7e4893a41d4..b6de18a6709 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -2,6 +2,7 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::identifier::Identifier; use key_wallet::account::StandardAccountType; +use key_wallet::managed_account::address_pool::AddressPoolType; use key_wallet::Network; use crate::manager::load_outcome::CorruptKind; @@ -71,6 +72,24 @@ pub enum PlatformWalletError { found: usize, }, + /// During rehydration a discovery probe and the real address pool it maps + /// to **by position** disagreed on `pool_type`, so applying the probe's + /// discovered depth would target the wrong chain. Fail-closed rather than + /// misattribute a derivation depth. The probes are built from the same + /// `address_pools()` enumeration, so a mismatch is a structural invariant + /// break, not user-reachable. + #[error( + "rehydration pool/probe chain-order mismatch at position {position}: real pool is {found:?} but probe is {expected:?}" + )] + RehydrationPoolTypeMismatch { + /// Index into the account's address-pool list where the mismatch was found. + position: usize, + /// The probe's pool type (discovery order). + expected: AddressPoolType, + /// The real pool's pool type at the same position. + found: AddressPoolType, + }, + #[error("Wallet not found: {0}")] WalletNotFound(String), diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index cf12ecf9624..7b47b4d7417 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -56,6 +56,14 @@ impl PlatformWalletManager

{ /// [`initialize`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize) /// when the snapshot carries no slice for it. /// + /// # Trust boundary + /// + /// The persisted account manifest is trusted as-is — it is **not** + /// cryptographically bound to its `wallet_id` (see `build_watch_only_wallet` + /// in `rehydrate`). A corrupted or tampered store can rebuild a wallet whose + /// receive addresses derive from the wrong key under the original id; + /// authenticating the manifest on load is a tracked storage-schema follow-up. + /// /// [`MnemonicResolverHandle`]: rs_sdk_ffi::MnemonicResolverHandle pub async fn load_from_persistor(&self) -> Result { let ClientStartState { diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 746446b151a..5e30c1ee14e 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -31,6 +31,19 @@ use crate::error::{PlatformWalletError, RehydrateRowError}; /// /// Returns [`RehydrateRowError`] when the row is structurally unusable /// (caller maps it onto a per-row [`SkipReason`]). +/// +/// # Trust boundary +/// +/// `expected_wallet_id` is stamped in verbatim and is **not** cryptographically +/// bound to the manifest: the id hashes the *root* xpub, but only account-level +/// (hardened, one-way) xpubs are persisted, so the root cannot be recovered to +/// re-derive and verify it. Only structural decode runs here, so a well-formed +/// but wrong xpub (corrupted/tampered store) is accepted and yields receive +/// addresses from the wrong key under the original id — the caller must ensure +/// the persisted manifest for `expected_wallet_id` is authentic. A real binding +/// (a MAC/commitment over `{wallet_id, network, manifest}` keyed to a +/// secure-enclave secret, verified fail-closed on load) needs a storage-schema +/// change and is tracked as a follow-up. pub(super) fn build_watch_only_wallet( network: Network, expected_wallet_id: [u8; 32], @@ -41,6 +54,9 @@ pub(super) fn build_watch_only_wallet( } let mut accounts = AccountCollection::new(); for entry in manifest { + // NOTE: `Account::from_xpub` is infallible in the pinned key-wallet rev + // (unconditional `Ok`); this map_err is a defensive guard for when its + // signature becomes fallible (e.g. xpub/type validation). let account = Account::from_xpub( Some(expected_wallet_id), entry.account_type, @@ -212,7 +228,7 @@ pub fn apply_persisted_core_state( for utxo in &unspent { account.utxos.insert(utxo.outpoint, (*utxo).clone()); } - // Eager derivation covers only `0..=gap_limit`; extend each + // Eager derivation covers only `0..gap_limit`; extend each // chain to cover restored / used addresses at deeper indices. extend_pools_for_restored_addresses( account, @@ -307,7 +323,7 @@ fn extend_pools_for_restored_addresses( ) -> Result<(), PlatformWalletError> { use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use std::collections::{BTreeSet, HashSet}; + use std::collections::HashSet; let account_type = account.managed_account_type().to_account_type(); @@ -325,7 +341,7 @@ fn extend_pools_for_restored_addresses( // each probe from index 0 is an accepted, bounded one-time-load cost // (per chain capped at MAX_REHYDRATION_DERIVATION_INDEX); rehydration // runs once per wallet at startup, never on a hot path. - let mut probes: Vec<(AddressPool, BTreeSet)> = account + let mut probes: Vec<(AddressPool, Option)> = account .managed_account_type() .address_pools() .iter() @@ -337,7 +353,7 @@ fn extend_pools_for_restored_addresses( p.gap_limit, p.network, ), - BTreeSet::new(), + None, ) }) .collect(); @@ -358,12 +374,11 @@ fn extend_pools_for_restored_addresses( .collect() }; - for (probe, matched) in probes.iter_mut() { + for (probe, deepest_resolved) in probes.iter_mut() { if unresolved.is_empty() { break; } let chain_gap = probe.gap_limit; - let mut deepest_resolved: Option = None; let mut index: u32 = 0; loop { @@ -378,9 +393,10 @@ fn extend_pools_for_restored_addresses( } if let Some(addr) = ensure_derived(probe, key_source, index) { + // Indices are visited in ascending order, so the last match + // is the deepest — record it directly (no per-chain set). if unresolved.remove(&addr) { - matched.insert(index); - deepest_resolved = Some(index); + *deepest_resolved = Some(index); } } @@ -440,18 +456,27 @@ fn extend_pools_for_restored_addresses( found: pools.len(), }); } - for (pool, (probe, matched)) in pools.iter_mut().zip(probes.iter()) { + for (position, (pool, (probe, deepest_resolved))) in + pools.iter_mut().zip(probes.iter()).enumerate() + { // `iter_mut()` over `Vec<&mut AddressPool>` yields `&mut &mut _`; // reborrow once so the pool flows into `ensure_derived` cleanly. let pool: &mut AddressPool = pool; - debug_assert_eq!( - pool.pool_type, probe.pool_type, - "probe/pool chain order must match for by-position depth apply" - ); + + // Runtime fail-closed guard (a release build compiles out a + // `debug_assert!`): applying a probe's depth to a pool of a different + // chain would misattribute derivation to the wrong pool by position. + if pool.pool_type != probe.pool_type { + return Err(PlatformWalletError::RehydrationPoolTypeMismatch { + position, + expected: probe.pool_type, + found: pool.pool_type, + }); + } // Derive up to the deepest discovered index so its address exists in // the real pool before we mark it used. - if let Some(&deepest) = matched.iter().next_back() { + if let Some(deepest) = *deepest_resolved { if let Some(key_source) = key_source.as_ref() { if ensure_derived(pool, key_source, deepest).is_none() { tracing::warn!( @@ -571,7 +596,7 @@ mod tests { } /// Regression: after restart-in-place the watch-only pools eagerly - /// cover only `0..=gap_limit`, but persisted UTXOs can sit at deeper + /// cover only `0..gap_limit`, but persisted UTXOs can sit at deeper /// derivation indices. Rehydration must extend each chain's pool to its /// deepest restored index so the per-address view reconciles with the /// wallet total instead of undercounting. From 0b538665cb9238d351821f61240a900736ac54f8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:46:21 +0000 Subject: [PATCH 32/60] docs(platform-wallet): document manifest-authentication trust boundary on the persistence contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PlatformWalletPersistence::load() returns account manifests that are trusted as-is by every consumer — nothing in the trait contract binds a manifest to its wallet_id. Closing this needs a persisted commitment/MAC added at write time, which requires a storage-schema change outside what any implementation of this trait can do alone (see the matching notes on build_watch_only_wallet and load_from_persistor). Documenting the boundary at the contract level so it's visible to every current and future PlatformWalletPersistence implementor and caller, not just readers of the two call sites. No functional change. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/changeset/traits.rs | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 9ed22a5542e..0f14cc1fbf9 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -255,6 +255,26 @@ pub trait PlatformWalletPersistence: Send + Sync { /// per-wallet one, because `ClientStartState::platform_addresses` is /// already keyed by wallet id and the sub-changesets carry their own /// wallet attribution where needed. + /// + /// # Trust boundary + /// + /// The returned [`ClientStartState`] — including each wallet's + /// persisted account manifest — is trusted as-is by every consumer of + /// this method. Nothing in this contract cryptographically binds a + /// manifest to the `wallet_id` it's returned under: implementations + /// are not required to authenticate what they hand back, and callers + /// (see `PlatformWalletManager::load_from_persistor` / + /// `build_watch_only_wallet` in the `rehydrate` module) do not + /// independently verify it either. A corrupted or tampered backing + /// store can therefore return a structurally well-formed manifest + /// under the wrong `wallet_id` and it will be accepted silently. + /// + /// This is a known, currently-unaddressed gap — closing it needs a + /// persisted commitment/MAC (or the root xpub) added to the manifest + /// at write time, which is a storage-schema change outside what any + /// implementation of this trait can do on its own. Implementors + /// should not treat the absence of such a check here as a bug in + /// their backend; callers should not assume one is being performed. fn load(&self) -> Result; /// Look up a single core transaction record by `txid` for `wallet_id`. From 9dbca2f216da05c9eeb24b7992a6c54004498276 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:33:42 +0000 Subject: [PATCH 33/60] fix(platform-wallet): update CoinJoin pool-topology test for key-wallet 0.45 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3976's rust-dashcore bump (0.44.0 -> 0.45.0) changed key-wallet's CoinJoin account model from a single address pool to External + Internal pools (mirroring Standard accounts). rehydration_coinjoin_single_pool_deep_index hardcoded "CoinJoin has exactly one pool", which no longer holds. extend_pools_for_restored_addresses itself iterates address_pools() generically (no hardcoded pool count), so production reconstruction logic is unaffected -- this is a test-only fix. The test now selects the External pool explicitly via AddressPoolType::External instead of assuming index 0 is the only entry. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/manager/rehydrate.rs | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 5e30c1ee14e..613cfc3abca 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -907,14 +907,17 @@ mod tests { ); } - /// CoinJoin topology (single External pool, no Internal chain). - /// Verifies that `extend_pools_for_restored_addresses` handles a single-pool - /// account at a deep derivation index (idx 30, just past the eager window). + /// CoinJoin topology (External pool, deep index). + /// Verifies that `extend_pools_for_restored_addresses` handles the + /// CoinJoin External pool at a deep derivation index (idx 30, just past + /// the eager window). CoinJoin accounts carry both an External and an + /// Internal pool (mirroring `Standard`); this test exercises the + /// External side only. #[test] fn rehydration_coinjoin_single_pool_deep_index() { use dashcore::blockdata::transaction::txout::TxOut; use dashcore::{OutPoint, Txid}; - use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::Utxo; @@ -951,13 +954,12 @@ mod tests { .expect("CoinJoin account must be the only funds account"); let ft = funds.managed_account_type().to_account_type(); let pools = funds.managed_account_type().address_pools(); - // CoinJoin has a single pool (External). - assert_eq!( - pools.len(), - 1, - "CoinJoin topology: must have exactly one pool" - ); - let p = &pools[0]; + // CoinJoin carries both an External and an Internal pool; this + // test targets the External side specifically. + let p = pools + .iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin topology: must have an External pool"); (ft, p.base_path.clone(), p.pool_type, p.gap_limit) }; @@ -1022,7 +1024,12 @@ mod tests { .into_iter() .next() .unwrap(); - let cj_pool = &funds_post.managed_account_type().address_pools()[0]; + let cj_pool = funds_post + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin topology: must have an External pool"); assert_eq!( cj_pool.address_at_index(30).as_ref(), Some(&deep_cj_addr), From 7a2e06e939bc0727c5f20089a08c3a52c6dbcb22 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Thu, 2 Jul 2026 20:01:58 +0400 Subject: [PATCH 34/60] fix(platform-wallet): preserve persisted wallet state through seedless rehydration (#3980) Co-authored-by: Claude Fable 5 --- .../src/core_wallet_types.rs | 6 +- .../rs-platform-wallet-ffi/src/manager.rs | 7 +- .../rs-platform-wallet-ffi/src/persistence.rs | 73 ++---- packages/rs-platform-wallet/README.md | 93 ++++++++ .../changeset/client_wallet_start_state.rs | 24 +- packages/rs-platform-wallet/src/error.rs | 29 --- .../rs-platform-wallet/src/manager/load.rs | 113 +++++++-- .../src/manager/rehydrate.rs | 184 ++++++++++++--- .../tests/rehydration_load.rs | 216 ++++++++++++++++++ .../PlatformWalletManager.swift | 73 +++++- 10 files changed, 675 insertions(+), 143 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index c8ebc1348fe..1d3dc910e02 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -964,7 +964,11 @@ fn tx_record_to_ffi( } } -fn vec_to_ptr(v: Vec) -> *mut T { +/// Convert a `Vec` into a raw heap pointer for a C out-array: null for +/// empty, `Box::into_raw(boxed_slice)` otherwise. The caller owns the +/// allocation and must free it by reconstructing the boxed slice with +/// the ORIGINAL length. +pub(crate) fn vec_to_ptr(v: Vec) -> *mut T { if v.is_empty() { std::ptr::null_mut() } else { diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dd6430e8cd0..438f543a110 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -438,12 +438,7 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( }) .collect(); let skipped_count = skipped_vec.len(); - let skipped_ptr = if skipped_count == 0 { - std::ptr::null_mut() - } else { - let boxed = skipped_vec.into_boxed_slice(); - Box::into_raw(boxed) as *mut SkippedWalletFFI - }; + let skipped_ptr = crate::core_wallet_types::vec_to_ptr(skipped_vec); std::ptr::write( out_outcome, LoadOutcomeFFI { diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 50eed9ade54..ccd9875a050 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3451,16 +3451,20 @@ fn build_wallet_start_state( // status without rebroadcasting. let unused_asset_locks = build_unused_asset_locks(entry)?; - // Project the reconstructed `wallet` + `wallet_info` into the - // keyless `ClientWalletStartState` the persister contract requires - // (SECRETS.md: no `Wallet`/seed crosses `load()`). The manager - // rebuilds a watch-only wallet from this manifest via - // `Wallet::new_watch_only` and applies this `core_state` projection. - // Signing happens later via the on-demand - // `sign_with_mnemonic_resolver` path, which fail-closed gates the - // resolver-supplied seed against the loaded `wallet_id`. The - // locally-built `wallet` is dropped — it was only needed to shape - // the account collection / UTXO routing above. + // Hand the fully-restored `wallet_info` across as the keyless + // snapshot (SECRETS.md: no `Wallet`/seed crosses `load()` — + // `ManagedWalletInfo` carries balances / pools / UTXOs, never key + // material). The manager rebuilds a watch-only wallet from the + // manifest via `Wallet::new_watch_only` and consumes this snapshot + // directly, so everything the decode blocks above restored survives + // verbatim: per-account UTXO and tx-record attribution (including + // the unresolved asset-lock funding records), exact pool contents + // with per-index `used` flags (the address-reuse guard and the SPV + // watch set), and the sync metadata / chainlock. Signing happens + // later via the on-demand `sign_with_mnemonic_resolver` path, which + // fail-closed gates the resolver-supplied seed against the loaded + // `wallet_id`. The locally-built `wallet` is dropped — it was only + // needed to shape the account collection / UTXO routing above. let account_manifest: Vec = wallet .accounts .all_accounts() @@ -3470,23 +3474,6 @@ fn build_wallet_start_state( account_xpub: a.account_xpub, }) .collect(); - let new_utxos: Vec = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .flat_map(|acct| acct.utxos.values().cloned()) - .collect(); - let core_state = platform_wallet::changeset::CoreChangeSet { - new_utxos, - last_processed_height: (wallet_info.metadata.last_processed_height > 0) - .then_some(wallet_info.metadata.last_processed_height), - synced_height: (wallet_info.metadata.synced_height > 0) - .then_some(wallet_info.metadata.synced_height), - // Carry the decoded chainlock through the keyless projection; - // `apply_persisted_core_state` re-applies it onto the rebuilt wallet. - last_applied_chain_lock: wallet_info.metadata.last_applied_chain_lock.clone(), - ..Default::default() - }; // `contacts` / `identity_keys` are the PR-3 keyless feed the // manager layers onto the managed identities via @@ -3498,38 +3485,22 @@ fn build_wallet_start_state( // would need a new cross-boundary struct field + Swift wiring, // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` // a no-op for this path, preserving the established iOS behaviour. - // Carry the persisted pool used-state through the keyless projection. - // The pool-decode block above already merged the persisted `used` - // flags into `wallet_info`; project the used addresses out so - // `apply_persisted_core_state` can re-mark them used on rehydrate. - // Without this a previously-used address whose funds were since spent - // comes back marked unused and could be handed out again as a fresh - // receive address — an address-reuse privacy leak. - let used_core_addresses: Vec = { - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - let mut used = Vec::new(); - for acct in wallet_info.accounts.all_funding_accounts() { - for pool in acct.managed_account_type().address_pools() { - for info in pool.addresses.values() { - if info.used { - used.push(info.address.clone()); - } - } - } - } - used - }; - + // + // `core_state` / `used_core_addresses` stay empty: they are the + // projection fallback for persisters that cannot reconstruct a full + // snapshot (the SQLite path until dashpay/platform#3968), and the + // manager ignores them when `core_wallet_info` is `Some`. let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, account_manifest, - core_state, + core_wallet_info: Some(Box::new(wallet_info)), + core_state: Default::default(), identity_manager, unused_asset_locks, contacts: Default::default(), identity_keys: Default::default(), - used_core_addresses, + used_core_addresses: Vec::new(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index d91bafa525e..21b6c414eeb 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -85,6 +85,99 @@ The package is structured as follows: - Active/inactive status - Note: Credit balance and revision are accessed from the Identity itself +## Persistence architecture + +This section is normative: it records the agreed model for how wallet +state, the persister, and clients relate. Changes that violate these +invariants need an explicit architecture discussion first, not just a +code review. + +``` + commands (send, register, sync, …) + client ──────────────────────────────────────▶ platform-wallet + │ │ + │ reads (display) changesets │ (single writer) + ▼ ▼ + ┌─────────────────────── persisted store ──────────────────────┐ + │ wallet-state tables: written ONLY by platform-wallet │ + │ client-owned tables (UI prefs etc.): written by client │ + └───────────────────────────────────────────────────────────────┘ + ▲ + │ load(persister) at launch — verbatim + platform-wallet +``` + +### Roles + +- **platform-wallet** is the authority for state *transitions*. Every + mutation of wallet state happens here and is emitted as a changeset + to the persister. Its in-memory state is volatile — a cache that is + empty at process start. +- **The persisted store** is the authority for state *history*: it is + the only copy of the wallet that survives a restart, and it doubles + as the client's **read model** — UIs render persisted rows directly + and reactively. Display therefore never blocks on platform-wallet + being loaded, unlocked, or synced. +- **Clients** (dash-evo-tool, the iOS SDK app, …) issue commands to + platform-wallet and read the store freely. They never write + wallet-state rows. + +### Invariants + +1. **Single writer.** Only platform-wallet's changesets mutate + wallet-state tables. Clients may keep their own tables (UI + preferences, view state) in the same database; ownership is per + table family, never shared. +2. **The store schema is a versioned public contract.** Two parties + depend on it — the persister's writes and every client's reads — so + schema changes are breaking changes for clients, not private + refactors. +3. **Reads never feed back into writes** except through platform-wallet + commands. A client that computes something from persisted rows and + wants it stored must go through a platform-wallet API. +4. **`load()` is verbatim.** At launch, platform-wallet reconstructs + itself from the store through + [`PlatformWalletPersistence::load`]; the store contains exactly what + platform-wallet wrote, so the load path must consume it as-is. + Re-deriving, re-inferring, or "repairing" state during load is + forbidden — a lossy round-trip here silently diverges the wallet + from its own history (per-account attribution, address-pool + `used` flags, and SPV watch-set coverage are the historical + casualties). Anything genuinely missing from the store re-warms on + the next sync, never inside `load()`. +5. **Persist errors are hard errors.** The store is the only durable + copy, and part of it — the account manifest, address used-flags, + birth heights, identity/contact associations — is *local-only*: no + chain rescan can ever reconstruct it. A swallowed persister write + error is silent, permanent data loss discovered at the next launch. +6. **Load is seedless.** The store never carries a seed or a + `Wallet`; restore produces watch-only wallets + (`Wallet::new_watch_only`) and signing keys are derived on demand + via the resolver-backed sign paths. See the trust-boundary notes on + [`PlatformWalletPersistence::load`] for what is (and is not) + authenticated on this path. + +### What restore is for + +Because the store is the read model, restoring platform-wallet at +launch is **not** about showing balances or history — the client +already renders those from the store. It exists to refill the +operational state that only lives in platform-wallet's memory: + +- **Detection** — the SPV watch set is the address-pool contents; + without it, incoming payments to existing addresses are not seen. +- **Spending** — coin/input selection runs against the in-memory UTXO + set. +- **Resume** — sync watermarks, tracked asset locks mid-registration, + and fresh-receive-address (`used`) state. + +Persisters that can reconstruct the full keyless snapshot hand it back +as `ClientWalletStartState::core_wallet_info` (consumed verbatim, per +invariant 4). The flattened projection fields +(`core_state`/`used_core_addresses`) are a transitional fallback for +persisters that cannot build a snapshot yet, and are slated for +removal once every in-tree persister produces snapshots. + ## Key Features ### Wallet Operations (via ManagedWalletInfo) diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 37328335c37..4c1e3876b8d 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -2,7 +2,8 @@ //! //! **Keyless by type.** This carries everything needed to *reconstruct* //! a watch-only wallet — network, birth height, the account manifest, -//! the rebuilt core-state projection, identities, filtered asset locks — +//! the managed-state snapshot (or its keyless projection), identities, +//! filtered asset locks — //! but **no** [`Wallet`](key_wallet::Wallet) and no seed. The persister //! can never mint a `Wallet`; the manager rebuilds a watch-only one via //! [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) @@ -18,6 +19,7 @@ use crate::changeset::{ }; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::{Address, Network}; /// Keyless per-wallet slice of the startup snapshot. @@ -36,6 +38,23 @@ pub struct ClientWalletStartState { /// Keyless account manifest — the account-set oracle for building the /// watch-only wallet (one watch-only account per entry's xpub). pub account_manifest: Vec, + /// Full keyless managed-wallet snapshot for persisters that can + /// reconstruct one — pools with exact derivation indices and `used` + /// flags, per-account UTXO and tx-record attribution, IS-lock set, + /// and sync metadata. [`ManagedWalletInfo`] carries **no key + /// material** (see its docs: balances, account metadata, UTXO set), + /// so the SECRETS.md boundary holds: still no `Wallet`, no seed. + /// + /// When `Some`, the manager consumes it directly (after validating + /// its `wallet_id`/`network` against the row) instead of minting a + /// `ManagedWalletInfo::from_wallet` skeleton and replaying the + /// projection below — preserving per-account attribution, the full + /// SPV watch set, and pool used-state verbatim, without re-deriving + /// anything. The FFI/iOS persister populates this. When `None` (the + /// native/SQLite persister until dashpay/platform#3968), the manager + /// falls back to the skeleton + [`core_state`](Self::core_state) / + /// [`used_core_addresses`](Self::used_core_addresses) replay. + pub core_wallet_info: Option>, /// Keyless projection of the persisted core rows (UTXOs, tx /// records, IS-locks, sync watermarks, `last_applied_chain_lock`). /// The manager applies this onto a fresh @@ -43,6 +62,9 @@ pub struct ClientWalletStartState { /// watch-only wallet. Populated by the persister's /// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load) /// implementation reading the persisted core rows. + /// + /// Ignored when [`core_wallet_info`](Self::core_wallet_info) is + /// `Some` — the full snapshot supersedes the projection. pub core_state: CoreChangeSet, /// Lean snapshot of this wallet's /// [`IdentityManager`](crate::wallet::identity::IdentityManager). diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index b6de18a6709..48b4938127c 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -5,35 +5,6 @@ use key_wallet::account::StandardAccountType; use key_wallet::managed_account::address_pool::AddressPoolType; use key_wallet::Network; -use crate::manager::load_outcome::CorruptKind; - -/// Per-row failure surfacing during watch-only rehydration of a single -/// persisted wallet. Maps 1:1 to [`CorruptKind`] for the -/// [`SkipReason`](crate::manager::load_outcome::SkipReason) the load loop -/// records. -#[derive(Debug)] -pub(crate) enum RehydrateRowError { - /// Manifest was empty — no account to rebuild the wallet around. - MissingManifest, - /// Building a watch-only [`Account`](key_wallet::account::Account) from a - /// manifest entry failed (xpub structurally malformed for its - /// [`AccountType`](key_wallet::account::AccountType)). - MalformedXpub, - /// `AccountCollection::insert` rejected an account (typically a - /// duplicate `account_type` within the manifest). - DecodeError(String), -} - -impl From for CorruptKind { - fn from(e: RehydrateRowError) -> Self { - match e { - RehydrateRowError::MissingManifest => CorruptKind::MissingManifest, - RehydrateRowError::MalformedXpub => CorruptKind::MalformedXpub, - RehydrateRowError::DecodeError(s) => CorruptKind::DecodeError(s), - } - } -} - /// Errors that can occur in platform wallet operations #[derive(Debug, thiserror::Error)] pub enum PlatformWalletError { diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c8243198602..73cf040c4a2 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -7,7 +7,7 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; use crate::error::PlatformWalletError; -use crate::manager::load_outcome::{LoadOutcome, SkipReason}; +use crate::manager::load_outcome::{CorruptKind, LoadOutcome, SkipReason}; use crate::wallet::core::WalletBalance; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -21,8 +21,20 @@ impl PlatformWalletManager

{ /// keyless reconstruction snapshot; each wallet is rebuilt via /// [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) /// from its [`AccountRegistrationEntry`](crate::changeset::AccountRegistrationEntry) - /// manifest, the keyless core-state projection is applied, and the - /// result is registered into the manager. + /// manifest, the managed core state is restored, and the result is + /// registered into the manager. + /// + /// Core state comes in one of two shapes, per wallet: + /// - a full keyless snapshot + /// ([`ClientWalletStartState::core_wallet_info`]) — consumed + /// directly, preserving per-account UTXO/record attribution and + /// exact pool contents (the FFI/iOS persister); or + /// - the keyless projection + /// ([`core_state`](ClientWalletStartState::core_state) + + /// [`used_core_addresses`](ClientWalletStartState::used_core_addresses)), + /// replayed onto a fresh skeleton via + /// [`apply_persisted_core_state`](super::rehydrate::apply_persisted_core_state) + /// (persisters that cannot reconstruct the snapshot). /// /// The load path never touches the seed, so it performs no wrong-seed /// check. Signing happens later, on demand, via the configured @@ -105,6 +117,7 @@ impl PlatformWalletManager

{ network, birth_height, account_manifest, + core_wallet_info, core_state, identity_manager, unused_asset_locks, @@ -113,6 +126,20 @@ impl PlatformWalletManager

{ used_core_addresses, } = wallet_state; + // Idempotency, checked FIRST: a wallet already registered (a + // prior load pass, or a runtime create) is already-satisfied. + // Checking before any reconstruction work matters — the + // rebuild below derives eager gap windows (and possibly a + // deep discovery scan), all of which the `WalletExists` arm + // at insert time would only throw away. + { + let wm = self.wallet_manager.read().await; + if wm.get_wallet(&expected_wallet_id).is_some() { + outcome.loaded.push(expected_wallet_id); + continue 'load; + } + } + // Build the watch-only wallet from the keyless manifest. A // structural decode failure skips this row (per-row // resilience) — it never aborts the batch and never inserts @@ -123,10 +150,8 @@ impl PlatformWalletManager

{ &account_manifest, ) { Ok(w) => w, - Err(row_err) => { - let reason = SkipReason::CorruptPersistedRow { - kind: row_err.into(), - }; + Err(kind) => { + let reason = SkipReason::CorruptPersistedRow { kind }; outcome.skipped.push((expected_wallet_id, reason.clone())); self.event_manager .on_wallet_skipped_on_load(expected_wallet_id, &reason); @@ -134,21 +159,65 @@ impl PlatformWalletManager

{ } }; - // Mint the managed-info skeleton from the watch-only wallet, - // then apply the keyless persisted core state (UTXOs, sync - // watermarks, per-account balances). A wallet with persisted - // UTXOs but no funds account hard-fails here rather than - // reconstructing a silent zero balance. - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); - if let Err(e) = super::rehydrate::apply_persisted_core_state( - &mut wallet_info, - &account_manifest, - &core_state, - &used_core_addresses, - ) { - load_error = Some(e); - break 'load; - } + let wallet_info = match core_wallet_info { + // Full keyless snapshot carried by the persister (the + // FFI/iOS path): consume it directly. This preserves + // per-account UTXO/record attribution, the exact pool + // contents (derived-but-unused addresses stay in the SPV + // watch set), and per-index used flags — none of which + // the projection replay below can reconstruct — and + // skips a second eager gap-window derivation. + Some(info) => { + let mut info = *info; + // The snapshot must describe this row's wallet; a + // mismatch is a corrupt row, skipped like any other + // structural failure. + if info.wallet_id != expected_wallet_id || info.network != network { + let reason = SkipReason::CorruptPersistedRow { + kind: CorruptKind::DecodeError(format!( + "managed-info snapshot (wallet {}, network {:?}) does not \ + match its row (wallet {}, network {:?})", + hex::encode(info.wallet_id), + info.network, + hex::encode(expected_wallet_id), + network, + )), + }; + outcome.skipped.push((expected_wallet_id, reason.clone())); + self.event_manager + .on_wallet_skipped_on_load(expected_wallet_id, &reason); + continue 'load; + } + // Recompute totals from the carried UTXO set so the + // lock-free balance mirrored below can never drift + // from it (no-silent-zero holds by recomputation). + { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + info.update_balance(); + } + info + } + // No snapshot (native/SQLite persister until + // dashpay/platform#3968): mint the managed-info skeleton + // from the watch-only wallet, then replay the keyless + // projection (UTXOs, sync watermarks, used addresses). A + // wallet with persisted UTXOs but no funds account + // hard-fails here rather than reconstructing a silent + // zero balance. + None => { + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); + if let Err(e) = super::rehydrate::apply_persisted_core_state( + &mut wallet_info, + &account_manifest, + &core_state, + &used_core_addresses, + ) { + load_error = Some(e); + break 'load; + } + wallet_info + } + }; // Flatten the (account → outpoint → lock) map. let mut tracked_asset_locks = BTreeMap::new(); diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 613cfc3abca..2b7238e1cef 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -20,7 +20,8 @@ use key_wallet::wallet::Wallet; use key_wallet::Network; use crate::changeset::AccountRegistrationEntry; -use crate::error::{PlatformWalletError, RehydrateRowError}; +use crate::error::PlatformWalletError; +use crate::manager::load_outcome::CorruptKind; /// Build a watch-only [`Wallet`] from the keyless account manifest. /// @@ -29,8 +30,10 @@ use crate::error::{PlatformWalletError, RehydrateRowError}; /// [`AccountCollection`] is handed to [`Wallet::new_watch_only`] under /// the same id. No key material crosses this function. /// -/// Returns [`RehydrateRowError`] when the row is structurally unusable -/// (caller maps it onto a per-row [`SkipReason`]). +/// Returns [`CorruptKind`] when the row is structurally unusable +/// (caller wraps it in a per-row [`SkipReason`]). +/// +/// [`SkipReason`]: crate::manager::load_outcome::SkipReason /// /// # Trust boundary /// @@ -48,9 +51,9 @@ pub(super) fn build_watch_only_wallet( network: Network, expected_wallet_id: [u8; 32], manifest: &[AccountRegistrationEntry], -) -> Result { +) -> Result { if manifest.is_empty() { - return Err(RehydrateRowError::MissingManifest); + return Err(CorruptKind::MissingManifest); } let mut accounts = AccountCollection::new(); for entry in manifest { @@ -63,10 +66,10 @@ pub(super) fn build_watch_only_wallet( entry.account_xpub, network, ) - .map_err(|_| RehydrateRowError::MalformedXpub)?; + .map_err(|_| CorruptKind::MalformedXpub)?; accounts .insert(account) - .map_err(|e| RehydrateRowError::DecodeError(e.to_string()))?; + .map_err(|e| CorruptKind::DecodeError(e.to_string()))?; } Ok(Wallet::new_watch_only( network, @@ -497,27 +500,43 @@ fn extend_pools_for_restored_addresses( // funded address keeps `used = false` and could be handed out as a fresh // receive address. `mark_used` is a no-op for addresses not in this // pool, so an underived (foreign / sparse) index is never marked. - let mut marked_any = false; - for addr in restored_addresses { - if pool.mark_used(addr) { - marked_any = true; - } - } - - // Refill the gap window past the deepest used index (needs the xpub). - if marked_any { - if let Some(key_source) = key_source.as_ref() { - if let Err(e) = pool.maintain_gap_limit(key_source) { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - account_type = ?account_type, - pool_type = ?pool.pool_type, - error = %e, - "rehydration: gap-limit maintenance failed; pool window \ - may be short until the next sync" - ); + // + // Mark ↔ refill runs to a FIXPOINT: marking raises `highest_used`, + // whose gap refill can derive a deeper previously-used address that + // the discovery walk missed (e.g. used idx 45 with in-window used + // idx 20 and gap 30 — the walk's horizon stops at 30, but the refill + // reaches 50 and derives idx 45). A single mark-then-refill pass + // would leave that address in the pool with `used = false`, handing + // a previously-used address back out as fresh. Terminates: each + // round marks at least one new address from the finite restored set + // (`mark_used` returns `true` only on an unused→used flip). + loop { + let mut marked_any = false; + for addr in restored_addresses { + if pool.mark_used(addr) { + marked_any = true; } } + if !marked_any { + break; + } + // Refill the gap window past the deepest used index (needs the + // xpub); without one no deeper address can be derived, so a + // single mark pass is all that's possible. + let Some(key_source) = key_source.as_ref() else { + break; + }; + if let Err(e) = pool.maintain_gap_limit(key_source) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + account_type = ?account_type, + pool_type = ?pool.pool_type, + error = %e, + "rehydration: gap-limit maintenance failed; pool window \ + may be short until the next sync" + ); + break; + } } } Ok(()) @@ -592,7 +611,7 @@ mod tests { fn empty_manifest_is_missing_manifest() { let err = build_watch_only_wallet(Network::Testnet, [0u8; 32], &[]) .expect_err("empty manifest must be MissingManifest"); - assert!(matches!(err, RehydrateRowError::MissingManifest)); + assert!(matches!(err, CorruptKind::MissingManifest)); } /// Regression: after restart-in-place the watch-only pools eagerly @@ -1229,6 +1248,7 @@ mod tests { network: Network::Testnet, birth_height: 1, account_manifest: manifest.clone(), + core_wallet_info: None, core_state: core, identity_manager: Default::default(), unused_asset_locks: Default::default(), @@ -1315,6 +1335,116 @@ mod tests { ); } + /// Regression (mark↔refill fixpoint): a previously-used address in the + /// "wedge zone" — past the discovery horizon but within reach of the + /// gap refill — must come back `used`. With used addresses at idx 20 + /// (in the eager window) and idx 45 (gap 30): the discovery walk + /// excludes in-window addresses from `unresolved`, so nothing anchors + /// the horizon past 30 and idx 45 is never scanned; marking idx 20 then + /// makes `maintain_gap_limit` derive out to 20+30=50, which brings the + /// idx-45 address into the pool. A single mark-then-refill pass left it + /// there with `used = false` — pool-visible as a FRESH address, handed + /// out again, and its stale `used = false` persisted back over the + /// store's `is_used = true` on the next pool snapshot. The fixpoint + /// re-marks after every refill until nothing new resolves. + #[test] + fn rehydration_wedge_zone_used_address_marked_after_refill() { + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Address; + + let wallet = Wallet::from_seed_bytes( + [61u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + let derive = |index: u32| -> Address { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(index + 1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(index).unwrap() + }; + // Reachable multi-device state: this device saw idx 20 used; + // another device (same mnemonic) handed out and used idx 45. + let in_window_used = derive(20); + let wedge_used = derive(45); + + // No UTXOs at all — only the persisted pool used-state. + let core = crate::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &[in_window_used.clone(), wedge_used.clone()], + ) + .unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + external + .address_info(&in_window_used) + .expect("in-window used address present") + .used, + "in-window used address must be restored as used" + ); + let wedge_info = external + .address_info(&wedge_used) + .expect("wedge-zone address must be derived into the pool by the refill"); + assert!( + wedge_info.used, + "wedge-zone previously-used address must be re-marked used, \ + not left pool-visible as fresh" + ); + assert!(external.used_indices.contains(&45), "idx 45 recorded used"); + assert_eq!( + external.highest_used, + Some(45), + "highest_used must reflect the wedge-zone slot" + ); + // And the window is refilled past the re-marked slot. + assert!( + external.highest_generated >= Some(45 + DEFAULT_EXTERNAL_GAP_LIMIT), + "gap window must extend past the re-marked wedge slot (got {:?})", + external.highest_generated, + ); + } + /// Documented limitation (solution b): a legitimately-owned but /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx /// <= 30 — is left unresolved because the discovery horizon (gap_limit diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 96a97fff48a..37a3d18e8dd 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -15,6 +15,10 @@ //! fires on the registered handler, `load` returns `Ok`. //! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason` //! surface (the structural-only contract). +//! - RT-Snapshot: a carried `core_wallet_info` snapshot is consumed +//! verbatim — per-account UTXO attribution and derived-but-unused +//! deep pool addresses survive the reload; a snapshot whose +//! `wallet_id` mismatches its row is skipped as corrupt. use std::sync::{Arc, Mutex}; @@ -69,6 +73,7 @@ impl PlatformWalletPersistence for FixedLoadPersister { network: w.network, birth_height: w.birth_height, account_manifest: w.account_manifest.clone(), + core_wallet_info: w.core_wallet_info.clone(), core_state: w.core_state.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), @@ -129,6 +134,7 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest: manifest, + core_wallet_info: None, core_state: CoreChangeSet::default(), identity_manager: Default::default(), unused_asset_locks: Default::default(), @@ -280,6 +286,7 @@ async fn rt_corrupt_row_skipped_and_other_loads() { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest: Vec::new(), + core_wallet_info: None, core_state: CoreChangeSet::default(), identity_manager: Default::default(), unused_asset_locks: Default::default(), @@ -347,6 +354,7 @@ async fn rt_z_secret_hygiene_surfaces() { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest: Vec::new(), + core_wallet_info: None, core_state: CoreChangeSet::default(), identity_manager: Default::default(), unused_asset_locks: Default::default(), @@ -369,3 +377,211 @@ async fn rt_z_secret_hygiene_surfaces() { assert!(!rendered.to_lowercase().contains(&"ab".repeat(10))); } } + +/// RT-Snapshot: a carried `core_wallet_info` snapshot is consumed +/// verbatim. Two properties the projection replay could NOT provide: +/// - per-account UTXO attribution — a CoinJoin-account UTXO stays on the +/// CoinJoin account (the fallback path routed every UTXO to the first +/// funds account, zeroing non-first-account balances); +/// - derived-but-unused deep pool addresses (idx 40, past the eager gap +/// window) stay in the pool, so the SPV watch set still covers a +/// handed-out-but-unpaid receive address after restart. +#[tokio::test] +async fn rt_snapshot_preserves_attribution_and_pools() { + use key_wallet::account::AccountType; + use key_wallet::managed_account::address_pool::KeySource; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed = [0x66; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id = wallet.compute_wallet_id(); + let manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // A UTXO on the CoinJoin account's own idx-0 address, inserted where + // the persisted rows put it: on the CoinJoin account. + let cj_value = 250_000u64; + let (cj_type, cj_addr) = { + let cj = info + .accounts + .all_funding_accounts() + .into_iter() + .find(|a| { + matches!( + a.managed_account_type().to_account_type(), + AccountType::CoinJoin { .. } + ) + }) + .expect("Default creation includes a CoinJoin account"); + let addr = cj + .managed_account_type() + .address_pools() + .first() + .expect("CoinJoin account has a pool") + .address_at_index(0) + .expect("eager window covers idx 0"); + (cj.managed_account_type().to_account_type(), addr) + }; + { + let cj = info + .accounts + .all_funding_accounts_mut() + .into_iter() + .find(|a| a.managed_account_type().to_account_type() == cj_type) + .unwrap(); + cj.utxos.insert( + dashcore::OutPoint { + txid: dashcore::Txid::from([0x42u8; 32]), + vout: 0, + }, + key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from([0x42u8; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value: cj_value, + script_pubkey: cj_addr.script_pubkey(), + }, + address: cj_addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + + // Extend the FIRST funds account's first pool to idx 40 — a + // derived-but-UNUSED deep address (handed out, not yet paid). + let (first_type, deep_keys_total) = { + let first = info + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + .expect("a first funds account exists"); + let first_type = first.managed_account_type().to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == first_type) + .map(|e| e.account_xpub) + .expect("first funds account xpub in manifest"); + let pools = first.managed_account_type_mut().address_pools_mut(); + let pool = pools.into_iter().next().expect("first pool"); + let highest = pool.highest_generated.expect("eager window derived"); + assert!( + highest < 40, + "fixture: idx 40 must be past the eager window" + ); + pool.generate_addresses(40 - highest, &KeySource::Public(xpub), true) + .unwrap(); + assert!( + pool.address_at_index(40).is_some(), + "fixture: idx 40 derived" + ); + (first_type, pool.addresses.len() as u32) + }; + info.update_balance(); + + let (_, mut s) = slice(seed); + s.core_wallet_info = Some(Box::new(info)); + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let mut st = ClientStartState::default(); + st.wallets.insert(id, s); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + assert_eq!(outcome.loaded, vec![id]); + assert!(outcome.skipped.is_empty()); + + let rows = { + let mgr = Arc::clone(&mgr); + tokio::task::spawn_blocking(move || mgr.account_balances_blocking(&id)) + .await + .unwrap() + }; + let cj_row = rows + .iter() + .find(|r| r.account_type == cj_type) + .expect("CoinJoin account row"); + assert_eq!( + cj_row.balance.total(), + cj_value, + "CoinJoin UTXO must stay attributed to the CoinJoin account" + ); + let first_row = rows + .iter() + .find(|r| r.account_type == first_type) + .expect("first funds account row"); + assert!( + first_row.keys_total >= deep_keys_total, + "derived-but-unused deep addresses must survive the reload \ + (watch-set coverage): got {} keys, snapshot had {}", + first_row.keys_total, + deep_keys_total, + ); +} + +/// RT-Snapshot-Mismatch: a snapshot whose `wallet_id` does not match its +/// row key is a corrupt row — skipped with `DecodeError`, never +/// registered, and the batch continues. +#[tokio::test] +async fn rt_snapshot_wallet_id_mismatch_is_skipped() { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed = [0x77; 64]; + let other_seed = [0x78; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + + // Row keyed by wallet A, snapshot built from wallet B. + let (id_a, mut s) = slice(seed); + let wallet_b = Wallet::from_seed_bytes( + other_seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + s.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1))); + + let mut st = ClientStartState::default(); + st.wallets.insert(id_a, s); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + + assert!(outcome.loaded.is_empty(), "mismatched row must not load"); + assert_eq!(outcome.skipped.len(), 1); + let (skipped_id, reason) = &outcome.skipped[0]; + assert_eq!(*skipped_id, id_a); + assert!(matches!( + reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::DecodeError(_) + } + )); + assert!(mgr.get_wallet(&id_a).await.is_none()); + assert_eq!(h.skipped.lock().unwrap().len(), 1); +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 2ead77cdbdb..08b77bb62dd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -122,6 +122,13 @@ public class PlatformWalletManager: ObservableObject { /// Last error from a wallet operation, if any. Cleared on successful op. @Published public private(set) var lastError: Error? + /// Wallets Rust skipped during the most recent [`loadFromPersistor`] + /// because their persisted row was structurally corrupt. Empty after + /// a clean load. Rust treats these as non-fatal — the load still + /// succeeds — so they are surfaced here rather than through + /// [`lastError`], letting UI offer to inspect / clear the bad rows. + @Published public private(set) var lastLoadSkippedWallets: [SkippedWalletOnLoad] = [] + // MARK: - Internals /// FFI handle; `NULL_HANDLE` until [`configure`] is called. @@ -319,6 +326,29 @@ public class PlatformWalletManager: ObservableObject { // MARK: - Watch-only restore from persister + /// One wallet Rust skipped during `load_from_persistor` because its + /// persisted row was structurally corrupt. `reasonCode` is one of the + /// Rust-side `LOAD_SKIP_REASON_*` constants (100 missing manifest, + /// 101 malformed xpub, 102 decode error, 199 other corrupt row, + /// 200 other skip); [`reasonDescription`] renders it for display. + public struct SkippedWalletOnLoad { + public let walletId: Data + public let reasonCode: UInt32 + + /// Human-readable rendering of `reasonCode`, mirroring the Rust + /// `LOAD_SKIP_REASON_*` constants. + public var reasonDescription: String { + switch reasonCode { + case 100: return "missing account manifest" + case 101: return "malformed account xpub" + case 102: return "decode error" + case 199: return "other corrupt row" + case 200: return "other skip" + default: return "unknown skip reason (\(reasonCode))" + } + } + } + /// Rehydrate wallets from SwiftData on app launch. /// /// Calls `platform_wallet_manager_load_from_persistor` which fires @@ -342,12 +372,40 @@ public class PlatformWalletManager: ObservableObject { public func loadFromPersistor() throws -> [ManagedPlatformWallet] { try ensureConfigured() - // Pass nil for `out_outcome` — Swift doesn't currently consume - // the per-wallet skip summary (corrupt persisted rows are - // logged by Rust at warn level). When Swift starts surfacing - // skipped wallets to the UI, pass a `LoadOutcomeFFI` here and - // free it with `platform_wallet_load_outcome_free`. - try platform_wallet_manager_load_from_persistor(handle, nil).check() + // Consume the load outcome so Rust's per-wallet skip summary + // isn't discarded. Rust writes `out_outcome` on every path + // (including early errors), so freeing it is safe even if the + // `.check()` below throws — defer the free before that throwing + // call. + var outcome = LoadOutcomeFFI(loaded_count: 0, skipped_count: 0, skipped: nil) + let loadResult = platform_wallet_manager_load_from_persistor(handle, &outcome) + defer { platform_wallet_load_outcome_free(&outcome) } + try loadResult.check() + + // Collect the ids Rust skipped as structurally corrupt. These + // are non-fatal on the Rust side, so they must not reach + // `lastError`: they are surfaced through `lastLoadSkippedWallets` + // and their ids are excluded from the per-id restore loop below + // (a skipped id is still in SwiftData, so `get_wallet` would + // return NotFound for it). + var skippedIds = Set() + var skippedWallets: [SkippedWalletOnLoad] = [] + if let skipped = outcome.skipped { + skippedWallets.reserveCapacity(Int(outcome.skipped_count)) + for i in 0.. Date: Thu, 2 Jul 2026 18:19:44 +0200 Subject: [PATCH 35/60] fix(platform-wallet): snapshot identity cross-check + typed persister-load errors (#3980 follow-ups) (#3984) Co-authored-by: Quantum Explorer Co-authored-by: Claude Fable 5 Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> --- .../rs-platform-wallet-ffi/src/manager.rs | 11 + packages/rs-platform-wallet/src/error.rs | 12 + .../rs-platform-wallet/src/manager/load.rs | 72 ++++-- .../src/manager/load_outcome.rs | 11 + .../tests/rehydration_load.rs | 211 +++++++++++++++++- .../PlatformWalletManager.swift | 11 +- 6 files changed, 303 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 438f543a110..65a110dca70 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -183,6 +183,10 @@ pub const LOAD_SKIP_REASON_MALFORMED_XPUB: u32 = 101; /// `reason_code`: any other structural decode / projection failure on /// the persisted row. pub const LOAD_SKIP_REASON_DECODE_ERROR: u32 = 102; +/// `reason_code`: the carried managed-info snapshot does not describe its +/// persisted row (wallet_id/network differ, or its account set diverges +/// from the row's account manifest) — a wrong-row snapshot. +pub const LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH: u32 = 103; /// `reason_code`: an unrecognized `CorruptKind` — forward-compat /// fallback until this crate maps a newly added corrupt-row family. pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199; @@ -203,6 +207,7 @@ pub struct SkippedWalletFFI { /// constants: [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100), /// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101), /// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102), + /// [`LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH`] (103), /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), or /// [`LOAD_SKIP_REASON_OTHER`] (200). No secret material is ever /// carried. @@ -234,6 +239,7 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { platform_wallet::SkipReason::CorruptPersistedRow { kind } => match kind { CorruptKind::MissingManifest => LOAD_SKIP_REASON_MISSING_MANIFEST, CorruptKind::MalformedXpub => LOAD_SKIP_REASON_MALFORMED_XPUB, + CorruptKind::SnapshotIdentityMismatch => LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH, CorruptKind::DecodeError(_) => LOAD_SKIP_REASON_DECODE_ERROR, // `CorruptKind` is #[non_exhaustive]; a future variant maps to a // generic corrupt-row code until this mapping is extended. @@ -582,6 +588,7 @@ mod tests { assert_eq!(LOAD_SKIP_REASON_MISSING_MANIFEST, 100); assert_eq!(LOAD_SKIP_REASON_MALFORMED_XPUB, 101); assert_eq!(LOAD_SKIP_REASON_DECODE_ERROR, 102); + assert_eq!(LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH, 103); assert_eq!(LOAD_SKIP_REASON_CORRUPT_OTHER, 199); assert_eq!(LOAD_SKIP_REASON_OTHER, 200); } @@ -600,6 +607,10 @@ mod tests { skip_reason_code(&corrupt(CorruptKind::MalformedXpub)), LOAD_SKIP_REASON_MALFORMED_XPUB ); + assert_eq!( + skip_reason_code(&corrupt(CorruptKind::SnapshotIdentityMismatch)), + LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH + ); assert_eq!( skip_reason_code(&corrupt(CorruptKind::DecodeError("boom".into()))), LOAD_SKIP_REASON_DECODE_ERROR diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 48b4938127c..f7097690a3c 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -11,6 +11,18 @@ pub enum PlatformWalletError { #[error("Wallet creation failed: {0}")] WalletCreation(String), + /// The persister failed to load the client start state during + /// rehydration. Carries the typed [`PersistenceError`] so callers keep + /// its retry classification (`is_transient()` / + /// [`PersistenceErrorKind`]) instead of a flattened string — a + /// transient backend hiccup (e.g. `SQLITE_BUSY`) stays distinguishable + /// from a permanent failure and can be retried. + /// + /// [`PersistenceError`]: crate::changeset::PersistenceError + /// [`PersistenceErrorKind`]: crate::changeset::PersistenceErrorKind + #[error("failed to load persisted client state: {0}")] + PersisterLoad(#[from] crate::changeset::PersistenceError), + /// The persisted wallet has UTXOs to restore but no funds-bearing /// account in its reconstructed account collection to hold them. /// Fail-closed rather than reconstructing a silent zero balance — diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 73cf040c4a2..d51b62759aa 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -84,12 +84,7 @@ impl PlatformWalletManager

{ // not here — drop the snapshot at this entry point. #[cfg(feature = "shielded")] shielded: _, - } = self.persister.load().map_err(|e| { - PlatformWalletError::WalletCreation(format!( - "Failed to load persisted client state: {}", - e - )) - })?; + } = self.persister.load()?; let persister_dyn: Arc = Arc::clone(&self.persister) as _; @@ -169,19 +164,17 @@ impl PlatformWalletManager

{ // skips a second eager gap-window derivation. Some(info) => { let mut info = *info; - // The snapshot must describe this row's wallet; a - // mismatch is a corrupt row, skipped like any other - // structural failure. - if info.wallet_id != expected_wallet_id || info.network != network { + // The snapshot must describe this row's wallet and its + // account set must agree with the manifest that built + // the watch-only wallet above. Either mismatch is a + // wrong-row snapshot — skipped like any structural + // failure, kept distinct from unreadable bytes. + if info.wallet_id != expected_wallet_id + || info.network != network + || !snapshot_accounts_match_manifest(&info, &account_manifest) + { let reason = SkipReason::CorruptPersistedRow { - kind: CorruptKind::DecodeError(format!( - "managed-info snapshot (wallet {}, network {:?}) does not \ - match its row (wallet {}, network {:?})", - hex::encode(info.wallet_id), - info.network, - hex::encode(expected_wallet_id), - network, - )), + kind: CorruptKind::SnapshotIdentityMismatch, }; outcome.skipped.push((expected_wallet_id, reason.clone())); self.event_manager @@ -328,3 +321,46 @@ impl PlatformWalletManager

{ Ok(outcome) } } + +/// Whether the snapshot's account set matches the row's account manifest. +/// +/// The manifest is the account-set oracle used to build the watch-only +/// wallet; a snapshot carrying a different set of account types describes +/// a different wallet and must not be consumed. +/// +/// The manifest is enumerated from `Wallet::all_accounts` (ECDSA-only: +/// carries `PlatformPayment`, omits the BLS `ProviderOperatorKeys` / +/// EdDSA `ProviderPlatformKeys`); the snapshot from +/// `ManagedWalletInfo::all_managed_accounts` (the mirror: carries the +/// BLS/EdDSA provider keys, omits `PlatformPayment`). Comparison is +/// restricted to the families both enumerations can carry so this known +/// asymmetry never rejects a legitimate snapshot. +fn snapshot_accounts_match_manifest( + info: &ManagedWalletInfo, + manifest: &[crate::changeset::AccountRegistrationEntry], +) -> bool { + use key_wallet::account::AccountType; + use std::collections::BTreeSet; + + fn comparable(t: &AccountType) -> bool { + !matches!( + t, + AccountType::ProviderOperatorKeys + | AccountType::ProviderPlatformKeys + | AccountType::PlatformPayment { .. } + ) + } + + let manifest_types: BTreeSet = manifest + .iter() + .map(|e| e.account_type) + .filter(comparable) + .collect(); + let snapshot_types: BTreeSet = info + .all_managed_accounts() + .iter() + .map(|a| a.managed_account_type().to_account_type()) + .filter(comparable) + .collect(); + manifest_types == snapshot_types +} diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index 8b3cc25e911..8c0e869c2b8 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -42,6 +42,14 @@ pub enum CorruptKind { /// One or more manifest `account_xpub` bytes failed to parse as a /// well-formed extended public key. MalformedXpub, + /// The carried [`ManagedWalletInfo`] snapshot does not describe the + /// persisted row it is attached to: its `wallet_id`/`network` differ + /// from the row, or its account set diverges from the row's account + /// manifest. This is a wrong-row/structurally-inconsistent snapshot — + /// distinct from unreadable bytes ([`Self::DecodeError`]). + /// + /// [`ManagedWalletInfo`]: key_wallet::wallet::managed_wallet_info::ManagedWalletInfo + SnapshotIdentityMismatch, /// Any other structural decode / projection failure surfaced by the /// persister. The string is a structural projection — never a raw /// row byte slice or a hex-encoded key. @@ -53,6 +61,9 @@ impl std::fmt::Display for CorruptKind { match self { Self::MissingManifest => f.write_str("missing account manifest"), Self::MalformedXpub => f.write_str("malformed account xpub"), + Self::SnapshotIdentityMismatch => { + f.write_str("snapshot does not match its persisted row") + } Self::DecodeError(s) => write!(f, "decode error: {s}"), } } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 37a3d18e8dd..7068b05a62c 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -26,8 +26,9 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use platform_wallet::changeset::{ AccountRegistrationEntry, ClientStartState, ClientWalletStartState, CoreChangeSet, - PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, }; +use platform_wallet::error::PlatformWalletError; use platform_wallet::events::{EventHandler, PlatformEventHandler}; use platform_wallet::manager::load_outcome::CorruptKind; use platform_wallet::wallet::platform_wallet::WalletId; @@ -90,6 +91,31 @@ impl PlatformWalletPersistence for FixedLoadPersister { } } +/// Persister whose `load()` always fails with a chosen [`PersistenceError`], +/// to exercise the typed error propagation out of `load_from_persistor`. +struct FailingLoadPersister { + transient: bool, +} + +impl PlatformWalletPersistence for FailingLoadPersister { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + if self.transient { + Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + "backend busy", + )) + } else { + Err(PersistenceError::backend("schema corrupt")) + } + } +} + /// Event handler that records every wallet-skipped-on-load notification. #[derive(Default)] struct RecordingHandler { @@ -544,8 +570,8 @@ async fn rt_snapshot_preserves_attribution_and_pools() { } /// RT-Snapshot-Mismatch: a snapshot whose `wallet_id` does not match its -/// row key is a corrupt row — skipped with `DecodeError`, never -/// registered, and the batch continues. +/// row key is a corrupt row — skipped with `SnapshotIdentityMismatch`, +/// never registered, and the batch continues. #[tokio::test] async fn rt_snapshot_wallet_id_mismatch_is_skipped() { use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -579,9 +605,186 @@ async fn rt_snapshot_wallet_id_mismatch_is_skipped() { assert!(matches!( reason, SkipReason::CorruptPersistedRow { - kind: CorruptKind::DecodeError(_) + kind: CorruptKind::SnapshotIdentityMismatch } )); assert!(mgr.get_wallet(&id_a).await.is_none()); assert_eq!(h.skipped.lock().unwrap().len(), 1); } + +/// RT-Snapshot-AccountMismatch: a snapshot whose `wallet_id`/`network` +/// agree with the row but whose account set diverges from the row's +/// account manifest is a wrong-row snapshot — skipped with +/// `SnapshotIdentityMismatch`, never registered. +#[tokio::test] +async fn rt_snapshot_account_set_mismatch_is_skipped() { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed = [0x79; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + + // Row keyed by wallet A with a full snapshot of A, but the row's + // manifest is truncated to a single account — the account sets diverge + // even though wallet_id and network match. + let wallet_a = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id_a = wallet_a.compute_wallet_id(); + let (full_manifest, _) = manifest_and_id(seed); + assert!( + full_manifest.len() > 1, + "fixture: Default creation yields more than one account" + ); + let truncated_manifest = vec![full_manifest[0].clone()]; + + let (_, mut s) = slice(seed); + s.account_manifest = truncated_manifest; + s.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1))); + + let mut st = ClientStartState::default(); + st.wallets.insert(id_a, s); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + + assert!( + outcome.loaded.is_empty(), + "account-set mismatch must not load" + ); + assert_eq!(outcome.skipped.len(), 1); + let (skipped_id, reason) = &outcome.skipped[0]; + assert_eq!(*skipped_id, id_a); + assert!(matches!( + reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::SnapshotIdentityMismatch + } + )); + assert!(mgr.get_wallet(&id_a).await.is_none()); + assert_eq!(h.skipped.lock().unwrap().len(), 1); +} + +/// RT-Snapshot-Mismatch-Combined: a snapshot-identity-mismatch skip and a +/// healthy snapshot load in the SAME batch. The mismatched row is skipped +/// with `SnapshotIdentityMismatch`; the healthy row loads fully; the batch +/// returns `Ok` and notifies the handler exactly once. Mirrors +/// `rt_corrupt_row_skipped_and_other_loads` for the snapshot path. +#[tokio::test] +async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let seed_ok = [0x81; 64]; + let seed_bad = [0x82; 64]; + let seed_other = [0x83; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + + // Healthy row: snapshot built from its own wallet, matching its row. + let wallet_ok = Wallet::from_seed_bytes( + seed_ok, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id_ok = wallet_ok.compute_wallet_id(); + let (_, mut s_ok) = slice(seed_ok); + s_ok.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1))); + + // Mismatched row: keyed by wallet BAD, snapshot built from wallet OTHER. + let (id_bad, mut s_bad) = slice(seed_bad); + let wallet_other = Wallet::from_seed_bytes( + seed_other, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + s_bad.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1))); + + let mut st = ClientStartState::default(); + st.wallets.insert(id_ok, s_ok); + st.wallets.insert(id_bad, s_bad); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr + .load_from_persistor() + .await + .expect("Ok despite the per-row snapshot mismatch"); + + assert_eq!(outcome.loaded, vec![id_ok], "only the healthy row loads"); + assert_eq!(outcome.skipped.len(), 1); + let (skipped_id, reason) = &outcome.skipped[0]; + assert_eq!(*skipped_id, id_bad); + assert!(matches!( + reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::SnapshotIdentityMismatch + } + )); + assert!(mgr.get_wallet(&id_ok).await.is_some()); + assert!( + mgr.get_wallet(&id_bad).await.is_none(), + "mismatched row must be absent, not a degraded placeholder" + ); + + let skipped = h.skipped.lock().unwrap(); + assert_eq!(skipped.len(), 1, "exactly one skip notification"); + assert_eq!(skipped[0].0, id_bad); +} + +/// RT-PersisterLoad-Transient: a transient persister load failure +/// propagates as a typed `PersisterLoad` error whose retry classification +/// survives — `is_transient()` is `true` so callers may back off and retry. +#[tokio::test] +async fn rt_persister_load_transient_error_is_typed_and_retryable() { + let p = Arc::new(FailingLoadPersister { transient: true }); + let h = Arc::new(RecordingHandler::default()); + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let mgr = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&p), h)); + + let err = mgr + .load_from_persistor() + .await + .expect_err("transient backend failure must surface"); + match err { + PlatformWalletError::PersisterLoad(inner) => { + assert!( + inner.is_transient(), + "transient classification must survive propagation" + ); + assert_eq!(inner.kind(), Some(PersistenceErrorKind::Transient)); + } + other => panic!("expected PersisterLoad, got {other:?}"), + } +} + +/// RT-PersisterLoad-Permanent: a fatal persister load failure propagates as +/// a typed `PersisterLoad` error classified non-transient, so callers do +/// not retry a permanent failure. +#[tokio::test] +async fn rt_persister_load_permanent_error_is_typed_and_not_retryable() { + let p = Arc::new(FailingLoadPersister { transient: false }); + let h = Arc::new(RecordingHandler::default()); + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let mgr = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&p), h)); + + let err = mgr + .load_from_persistor() + .await + .expect_err("fatal backend failure must surface"); + match err { + PlatformWalletError::PersisterLoad(inner) => { + assert!( + !inner.is_transient(), + "fatal failure must not read as retryable" + ); + assert_eq!(inner.kind(), Some(PersistenceErrorKind::Fatal)); + } + other => panic!("expected PersisterLoad, got {other:?}"), + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 08b77bb62dd..df0098c918a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -329,19 +329,24 @@ public class PlatformWalletManager: ObservableObject { /// One wallet Rust skipped during `load_from_persistor` because its /// persisted row was structurally corrupt. `reasonCode` is one of the /// Rust-side `LOAD_SKIP_REASON_*` constants (100 missing manifest, - /// 101 malformed xpub, 102 decode error, 199 other corrupt row, - /// 200 other skip); [`reasonDescription`] renders it for display. + /// 101 malformed xpub, 102 decode error, 103 snapshot identity + /// mismatch, 199 other corrupt row, 200 other skip); + /// [`reasonDescription`] renders it for display. public struct SkippedWalletOnLoad { public let walletId: Data public let reasonCode: UInt32 /// Human-readable rendering of `reasonCode`, mirroring the Rust - /// `LOAD_SKIP_REASON_*` constants. + /// `LOAD_SKIP_REASON_*` constants. These numbers are the wire + /// contract defined in `rs-platform-wallet-ffi/src/manager.rs`; + /// they are not surfaced as named symbols in the generated C + /// header, so the cases are matched by value against that source. public var reasonDescription: String { switch reasonCode { case 100: return "missing account manifest" case 101: return "malformed account xpub" case 102: return "decode error" + case 103: return "snapshot does not match its persisted row" case 199: return "other corrupt row" case 200: return "other skip" default: return "unknown skip reason (\(reasonCode))" From 8bcb19294b55fac7f1f065e82cc824231eb4c7b4 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:26:37 +0200 Subject: [PATCH 36/60] Apply suggestions from code review Co-authored-by: Claudius the Magnificent AI, on behalf of lklimek <8431764+Claudius-Maginificent@users.noreply.github.com> --- packages/rs-platform-wallet/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 21b6c414eeb..f5fde97c067 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -115,16 +115,16 @@ code review. empty at process start. - **The persisted store** is the authority for state *history*: it is the only copy of the wallet that survives a restart, and it doubles - as the client's **read model** — UIs render persisted rows directly + as the client's **read model** — UIs *may* render persisted rows directly and reactively. Display therefore never blocks on platform-wallet - being loaded, unlocked, or synced. + being unlocked or synced; the local seedless restore is still a startup gate. - **Clients** (dash-evo-tool, the iOS SDK app, …) issue commands to platform-wallet and read the store freely. They never write wallet-state rows. ### Invariants -1. **Single writer.** Only platform-wallet's changesets mutate +1. **Single writer** (enforced by review, not the storage layer). Only platform-wallet's changesets mutate wallet-state tables. Clients may keep their own tables (UI preferences, view state) in the same database; ownership is per table family, never shared. From 87f4b4263c086267f999aea8f3f4aac8f2a10b8f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:10:48 +0000 Subject: [PATCH 37/60] refactor(platform-wallet)!: require core_wallet_info, drop keyless-projection fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ClientWalletStartState now always carries a full ManagedWalletInfo snapshot. Make core_wallet_info non-optional (Box) and remove the core_state / used_core_addresses projection fields. The FFI/iOS persister already always supplied a snapshot, so the None branch in load_from_persistor and the projection-replay path (apply_persisted_core_state / extend_pools_for_restored_addresses in rehydrate) were dead in production. Remove them and the unit tests that exercised only that removed logic. build_watch_only_wallet and its tests stay — they still feed the snapshot path. BREAKING CHANGE: ClientWalletStartState loses core_state and used_core_addresses; core_wallet_info is no longer Option. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/persistence.rs | 14 +- .../changeset/client_wallet_start_state.rs | 61 +- .../rs-platform-wallet/src/manager/load.rs | 119 +- .../src/manager/rehydrate.rs | 1583 +---------------- .../tests/rehydration_load.rs | 75 +- 5 files changed, 101 insertions(+), 1751 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index ccd9875a050..4988f968044 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2910,9 +2910,8 @@ fn build_wallet_start_state( } // Persisted `last_applied_chain_lock` — bincode-decoded from the - // bytes Swift handed back onto the local `wallet_info`. It is then - // carried into the keyless `CoreChangeSet` below and re-applied by - // `apply_persisted_core_state`, so the asset-lock-resume + // bytes Swift handed back onto the local `wallet_info` metadata. The + // manager consumes this snapshot verbatim, so the asset-lock-resume // CL-from-metadata fallback (`proof.rs`) fires at app launch on any // tracked lock whose funding block height is `<= cl.block_height`, // without waiting for SPV to re-apply a fresh CL. SPV persists its @@ -3485,22 +3484,15 @@ fn build_wallet_start_state( // would need a new cross-boundary struct field + Swift wiring, // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` // a no-op for this path, preserving the established iOS behaviour. - // - // `core_state` / `used_core_addresses` stay empty: they are the - // projection fallback for persisters that cannot reconstruct a full - // snapshot (the SQLite path until dashpay/platform#3968), and the - // manager ignores them when `core_wallet_info` is `Some`. let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, account_manifest, - core_wallet_info: Some(Box::new(wallet_info)), - core_state: Default::default(), + core_wallet_info: Box::new(wallet_info), identity_manager, unused_asset_locks, contacts: Default::default(), identity_keys: Default::default(), - used_core_addresses: Vec::new(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 4c1e3876b8d..df2e63c0381 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -2,9 +2,8 @@ //! //! **Keyless by type.** This carries everything needed to *reconstruct* //! a watch-only wallet — network, birth height, the account manifest, -//! the managed-state snapshot (or its keyless projection), identities, -//! filtered asset locks — -//! but **no** [`Wallet`](key_wallet::Wallet) and no seed. The persister +//! the managed-state snapshot, identities, filtered asset locks — but +//! **no** [`Wallet`](key_wallet::Wallet) and no seed. The persister //! can never mint a `Wallet`; the manager rebuilds a watch-only one via //! [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) //! from the manifest, applies this state, and defers signing-key @@ -14,13 +13,11 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; -use crate::changeset::{ - AccountRegistrationEntry, ContactChangeSet, CoreChangeSet, IdentityKeysChangeSet, -}; +use crate::changeset::{AccountRegistrationEntry, ContactChangeSet, IdentityKeysChangeSet}; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; -use key_wallet::{Address, Network}; +use key_wallet::Network; /// Keyless per-wallet slice of the startup snapshot. /// @@ -38,34 +35,19 @@ pub struct ClientWalletStartState { /// Keyless account manifest — the account-set oracle for building the /// watch-only wallet (one watch-only account per entry's xpub). pub account_manifest: Vec, - /// Full keyless managed-wallet snapshot for persisters that can - /// reconstruct one — pools with exact derivation indices and `used` - /// flags, per-account UTXO and tx-record attribution, IS-lock set, - /// and sync metadata. [`ManagedWalletInfo`] carries **no key - /// material** (see its docs: balances, account metadata, UTXO set), - /// so the SECRETS.md boundary holds: still no `Wallet`, no seed. + /// Full keyless managed-wallet snapshot: pools with exact derivation + /// indices and `used` flags, per-account UTXO and tx-record + /// attribution, IS-lock set, and sync metadata. [`ManagedWalletInfo`] + /// carries **no key material** (see its docs: balances, account + /// metadata, UTXO set), so the SECRETS.md boundary holds: still no + /// `Wallet`, no seed. /// - /// When `Some`, the manager consumes it directly (after validating - /// its `wallet_id`/`network` against the row) instead of minting a - /// `ManagedWalletInfo::from_wallet` skeleton and replaying the - /// projection below — preserving per-account attribution, the full - /// SPV watch set, and pool used-state verbatim, without re-deriving - /// anything. The FFI/iOS persister populates this. When `None` (the - /// native/SQLite persister until dashpay/platform#3968), the manager - /// falls back to the skeleton + [`core_state`](Self::core_state) / - /// [`used_core_addresses`](Self::used_core_addresses) replay. - pub core_wallet_info: Option>, - /// Keyless projection of the persisted core rows (UTXOs, tx - /// records, IS-locks, sync watermarks, `last_applied_chain_lock`). - /// The manager applies this onto a fresh - /// `ManagedWalletInfo::from_wallet` skeleton built from the - /// watch-only wallet. Populated by the persister's - /// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load) - /// implementation reading the persisted core rows. - /// - /// Ignored when [`core_wallet_info`](Self::core_wallet_info) is - /// `Some` — the full snapshot supersedes the projection. - pub core_state: CoreChangeSet, + /// The manager consumes it directly after validating its + /// `wallet_id`/`network` against the row and its account set against + /// the manifest — preserving per-account attribution, the full SPV + /// watch set, and pool used-state verbatim, without re-deriving + /// anything. The FFI/iOS persister populates this. + pub core_wallet_info: Box, /// Lean snapshot of this wallet's /// [`IdentityManager`](crate::wallet::identity::IdentityManager). pub identity_manager: IdentityManagerStartState, @@ -84,15 +66,4 @@ pub struct ClientWalletStartState { /// `Identity.public_keys` is populated at load time instead of /// only after the next sync. `removed` is always empty. pub identity_keys: IdentityKeysChangeSet, - /// Addresses the persisted pool snapshot marked **used**, flattened - /// across every funds account / pool. `apply_persisted_core_state` - /// derives each into its pool slot (if needed) and marks it used, in - /// union with the still-unspent UTXO addresses. This is the - /// address-reuse guard: a previously-used address whose funds were - /// since spent must never be handed back out as a fresh receive - /// address. EMPTY default = no pool used-state carried, so rehydrate - /// falls back to marking only currently-unspent UTXO addresses (the - /// native/SQLite persister until dashpay/platform#3968 wires its pool - /// readers to populate this). - pub used_core_addresses: Vec

, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index d51b62759aa..48f5ee66367 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -24,17 +24,11 @@ impl PlatformWalletManager

{ /// manifest, the managed core state is restored, and the result is /// registered into the manager. /// - /// Core state comes in one of two shapes, per wallet: - /// - a full keyless snapshot - /// ([`ClientWalletStartState::core_wallet_info`]) — consumed - /// directly, preserving per-account UTXO/record attribution and - /// exact pool contents (the FFI/iOS persister); or - /// - the keyless projection - /// ([`core_state`](ClientWalletStartState::core_state) + - /// [`used_core_addresses`](ClientWalletStartState::used_core_addresses)), - /// replayed onto a fresh skeleton via - /// [`apply_persisted_core_state`](super::rehydrate::apply_persisted_core_state) - /// (persisters that cannot reconstruct the snapshot). + /// Core state arrives as a full keyless snapshot + /// ([`ClientWalletStartState::core_wallet_info`]) — consumed directly, + /// preserving per-account UTXO/record attribution and exact pool + /// contents — after its `wallet_id`/`network`/account-set are + /// validated against the row. /// /// The load path never touches the seed, so it performs no wrong-seed /// check. Signing happens later, on demand, via the configured @@ -50,9 +44,8 @@ impl PlatformWalletManager

{ /// [`on_wallet_skipped_on_load`](crate::PlatformEventHandler::on_wallet_skipped_on_load) /// is called on each registered handler. One bad row /// never aborts the others; the call still returns `Ok`. - /// - **Whole-load failure** (persister I/O, programmer error, the - /// no-silent-zero topology check in - /// [`apply_persisted_core_state`](super::rehydrate::apply_persisted_core_state)): + /// - **Whole-load failure** (persister I/O, programmer error, + /// registering a persisted wallet in `WalletManager`): /// `Err(_)` — every wallet inserted earlier in this pass is /// rolled back. Skipped wallets never entered the maps so the /// rollback path never sees them. @@ -110,15 +103,15 @@ impl PlatformWalletManager

{ 'load: for (expected_wallet_id, wallet_state) in wallets { let ClientWalletStartState { network, - birth_height, + // The carried snapshot supplies its own sync metadata, so + // the row's birth height is not needed on this path. + birth_height: _, account_manifest, core_wallet_info, - core_state, identity_manager, unused_asset_locks, contacts, identity_keys, - used_core_addresses, } = wallet_state; // Idempotency, checked FIRST: a wallet already registered (a @@ -154,62 +147,37 @@ impl PlatformWalletManager

{ } }; - let wallet_info = match core_wallet_info { - // Full keyless snapshot carried by the persister (the - // FFI/iOS path): consume it directly. This preserves - // per-account UTXO/record attribution, the exact pool - // contents (derived-but-unused addresses stay in the SPV - // watch set), and per-index used flags — none of which - // the projection replay below can reconstruct — and - // skips a second eager gap-window derivation. - Some(info) => { - let mut info = *info; - // The snapshot must describe this row's wallet and its - // account set must agree with the manifest that built - // the watch-only wallet above. Either mismatch is a - // wrong-row snapshot — skipped like any structural - // failure, kept distinct from unreadable bytes. - if info.wallet_id != expected_wallet_id - || info.network != network - || !snapshot_accounts_match_manifest(&info, &account_manifest) - { - let reason = SkipReason::CorruptPersistedRow { - kind: CorruptKind::SnapshotIdentityMismatch, - }; - outcome.skipped.push((expected_wallet_id, reason.clone())); - self.event_manager - .on_wallet_skipped_on_load(expected_wallet_id, &reason); - continue 'load; - } - // Recompute totals from the carried UTXO set so the - // lock-free balance mirrored below can never drift - // from it (no-silent-zero holds by recomputation). - { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - info.update_balance(); - } - info + // Full keyless snapshot carried by the persister: consume it + // directly. This preserves per-account UTXO/record attribution, + // the exact pool contents (derived-but-unused addresses stay in + // the SPV watch set), and per-index used flags. + let wallet_info = { + let mut info = *core_wallet_info; + // The snapshot must describe this row's wallet and its + // account set must agree with the manifest that built the + // watch-only wallet above. Either mismatch is a wrong-row + // snapshot — skipped like any structural failure, kept + // distinct from unreadable bytes. + if info.wallet_id != expected_wallet_id + || info.network != network + || !snapshot_accounts_match_manifest(&info, &account_manifest) + { + let reason = SkipReason::CorruptPersistedRow { + kind: CorruptKind::SnapshotIdentityMismatch, + }; + outcome.skipped.push((expected_wallet_id, reason.clone())); + self.event_manager + .on_wallet_skipped_on_load(expected_wallet_id, &reason); + continue 'load; } - // No snapshot (native/SQLite persister until - // dashpay/platform#3968): mint the managed-info skeleton - // from the watch-only wallet, then replay the keyless - // projection (UTXOs, sync watermarks, used addresses). A - // wallet with persisted UTXOs but no funds account - // hard-fails here rather than reconstructing a silent - // zero balance. - None => { - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, birth_height); - if let Err(e) = super::rehydrate::apply_persisted_core_state( - &mut wallet_info, - &account_manifest, - &core_state, - &used_core_addresses, - ) { - load_error = Some(e); - break 'load; - } - wallet_info + // Recompute totals from the carried UTXO set so the + // lock-free balance mirrored below can never drift from it + // (no-silent-zero holds by recomputation). + { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + info.update_balance(); } + info }; // Flatten the (account → outpoint → lock) map. @@ -324,9 +292,12 @@ impl PlatformWalletManager

{ /// Whether the snapshot's account set matches the row's account manifest. /// -/// The manifest is the account-set oracle used to build the watch-only -/// wallet; a snapshot carrying a different set of account types describes -/// a different wallet and must not be consumed. +/// A **self-consistency** check between two pieces of the same persisted +/// row, not an authenticity guard: the manifest is the account-set oracle +/// used to build the watch-only wallet, and a snapshot carrying a +/// different set of account types is internally inconsistent with it and +/// must not be consumed. It does not attest that the manifest itself is +/// genuine — that trust boundary lives in `build_watch_only_wallet`. /// /// The manifest is enumerated from `Wallet::all_accounts` (ECDSA-only: /// carries `PlatformPayment`, omits the BLS `ProviderOperatorKeys` / diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 2b7238e1cef..19ea3849775 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -1,9 +1,10 @@ -//! Watch-only wallet reconstruction + persisted core-state application. +//! Watch-only wallet reconstruction from the keyless account manifest. //! //! Load is **seedless** (see [`load_from_persistor`]). For each //! persisted wallet we build a watch-only [`Wallet`] from its keyless -//! `AccountRegistrationEntry` manifest, then apply the keyless -//! core-state projection on top. No seed, no signing-key derivation. +//! `AccountRegistrationEntry` manifest; the manager then consumes the +//! carried [`ManagedWalletInfo`](key_wallet::wallet::managed_wallet_info::ManagedWalletInfo) +//! snapshot directly. No seed, no signing-key derivation. //! //! Because load never touches the seed, it performs no wrong-seed check. //! Wrong-seed validation lives in the resolver-backed signing @@ -15,12 +16,10 @@ use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::Account; -use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; use crate::changeset::AccountRegistrationEntry; -use crate::error::PlatformWalletError; use crate::manager::load_outcome::CorruptKind; /// Build a watch-only [`Wallet`] from the keyless account manifest. @@ -78,490 +77,6 @@ pub(super) fn build_watch_only_wallet( )) } -/// Apply the keyless persisted core-state projection onto a -/// freshly-minted `ManagedWalletInfo` skeleton. -/// -/// # Parameters -/// -/// - `wallet_info`: the skeleton to hydrate in place. -/// - `manifest`: keyless account manifest (one entry per registered -/// account). Each entry carries an `account_type` → `account_xpub` -/// mapping used by [`extend_pools_for_restored_addresses`] to derive -/// addresses for restored UTXOs. If an account's `account_type` is -/// absent from the manifest, deep-index derivation is skipped for that -/// account (no xpub → no derivation possible); already-derived in-window -/// addresses are still marked used. -/// - `core`: the persisted core-state changeset to apply. -/// - `used_pool_addresses`: addresses the persisted pool snapshot marked -/// used (across all accounts/pools). Marked used in union with the -/// still-unspent UTXO addresses so a previously-used address whose funds -/// were since spent is never re-handed-out as a fresh receive address -/// (address-reuse guard). Empty = no pool used-state carried. -/// -/// # Reconstructed (safety-critical-correct) -/// -/// - **Wallet balance** (`wallet_info.balance`, the no-silent-zero -/// guarantee): every persisted UTXO is restored and the per-account -/// + wallet totals are recomputed via `update_balance()`. A UTXO -/// carrying a block height is marked confirmed so it lands in the -/// `confirmed` bucket; the wallet total is exact regardless. -/// - **UTXO set**: every unspent persisted outpoint is restored into a -/// funds-bearing account of the wallet (whatever topology it has — -/// BIP44, BIP32, CoinJoin, DashPay). -/// - **Address-pool depth**: each pool is forward-derived to cover -/// restored UTXOs at deep derivation indices, then the gap window is -/// refilled beyond the deepest restored index so the per-address view -/// reconciles with the wallet total. -/// - **Address-pool used-state**: every `used_pool_addresses` entry is -/// re-marked used (in union with the unspent-UTXO addresses), so an -/// address whose funds were since spent is not re-handed-out as fresh. -/// - **Sync watermarks**: `synced_height` / `last_processed_height`. -/// -/// # Reconstructed when the persister supplies it -/// -/// - **`last_applied_chain_lock`**: restored from `core` when the -/// supplied [`CoreChangeSet`](crate::changeset::CoreChangeSet) carries -/// it (the FFI/iOS persister round-trips the value Swift held), so the -/// asset-lock-resume CL-from-metadata fallback (`proof.rs`) fires at -/// launch instead of waiting for SPV. The SQLite storage path has no -/// V001 column for it yet (dashpay/platform#3968), so there it is -/// absent from `core` and stays `None` until SPV re-applies a fresh -/// chainlock on the first post-restart sync. -/// -/// # Deferred to the first post-load `sync` (safe re-warm) -/// -/// - **Per-account UTXO attribution**: `core_utxos.account_index` is -/// written as `0` at persist time, so per-account bucketing is not -/// recoverable from disk; UTXOs are restored against the wallet's -/// first funds-bearing account and re-attributed on the next scan. -/// The *wallet total* is unaffected (it is a sum across all funds -/// accounts). -/// - **Deep-index address visibility**: each chain's pool scan stops -/// after [`MAX_REHYDRATION_DERIVATION_INDEX`] or after `gap_limit` -/// consecutive non-matching indices past the deepest resolved index. -/// The horizon only advances when an unspent UTXO anchors a match, so a -/// UTXO address can be left unresolved in two distinct cases: (1) it is -/// genuinely foreign (a different account's key routed here, or corrupt), -/// and (2) it is a *legitimately-owned but deep-and-sparse* address — -/// owned by this account, yet sitting past the first `gap_limit` window -/// with no nearer unspent UTXO to walk the horizon out to it. Both cases -/// are counted and logged via `tracing::warn!` and re-warm on the next -/// full sync. The wallet *total* stays exact (every UTXO is summed -/// regardless of pool visibility); only the per-address view is -/// incomplete until that sync. This is the accepted behavior of the -/// horizon-walk algorithm — see [`extend_pools_for_restored_addresses`]. -/// - **Per-UTXO `is_coinbase` / `is_instantlocked` / `is_trusted` -/// flags**: not columns in `core_utxos`; conservatively defaulted -/// (non-coinbase, confirmed-by-height) and refreshed on the next -/// scan. Coinbase-maturity nuance re-warms on sync. -/// - **Transaction-record history**: rebuilt by the next scan; not a -/// balance input. -/// -/// # Errors -/// -/// [`PlatformWalletError::RehydrationTopologyUnsupported`] if there are -/// persisted UTXOs to restore but the reconstructed account collection -/// has **no** funds-bearing account to hold them. Fail-closed rather -/// than reconstructing a silent zero balance (the no-silent-zero -/// mandate). An empty UTXO set is always `Ok`. -/// -/// This never touches key material. -pub fn apply_persisted_core_state( - wallet_info: &mut ManagedWalletInfo, - manifest: &[AccountRegistrationEntry], - core: &crate::changeset::CoreChangeSet, - used_pool_addresses: &[key_wallet::Address], -) -> Result<(), PlatformWalletError> { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - - // Captured before the mutable account borrow below so it can flow into - // pool-extension diagnostics without re-borrowing `wallet_info`. - let wallet_id = wallet_info.wallet_id; - - // Sync watermarks first so `update_balance`'s maturity check sees - // the restored tip. - if let Some(h) = core.last_processed_height { - wallet_info.metadata.last_processed_height = - wallet_info.metadata.last_processed_height.max(h); - } - if let Some(h) = core.synced_height { - wallet_info.metadata.synced_height = wallet_info.metadata.synced_height.max(h); - } - - // Restore the highest applied chainlock when the persister carries it - // (FFI path) so the asset-lock proof CL-from-metadata fallback fires at launch. - if let Some(cl) = &core.last_applied_chain_lock { - wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); - } - - // Restore the UTXO set. Persisted attribution is lost at write time - // (account_index is always 0), so route every restored UTXO to the - // wallet's first funds-bearing account *of any topology* (BIP44, - // BIP32, CoinJoin, DashPay) — the wallet total is a sum across all - // funds accounts and stays exact. A wallet with persisted UTXOs but - // no funds account at all cannot be represented: fail closed rather - // than silently reconstruct a zero balance. - let spent_outpoints: std::collections::HashSet = - core.spent_utxos.iter().map(|u| u.outpoint).collect(); - let unspent: Vec<&key_wallet::Utxo> = core - .new_utxos - .iter() - .filter(|u| !spent_outpoints.contains(&u.outpoint)) - .collect(); - - // Addresses to derive-and-mark-used: the still-unspent UTXO addresses - // PLUS the persisted pool used-state. The latter restores addresses - // whose funds were since spent — without it a previously-used address - // comes back marked unused and could be handed out again as a fresh - // receive address (address-reuse privacy leak). Empty - // `used_pool_addresses` (the native/SQLite path until - // dashpay/platform#3968) preserves the prior unspent-only behaviour. - let mut addresses_to_mark: Vec = - unspent.iter().map(|u| u.address.clone()).collect(); - addresses_to_mark.extend(used_pool_addresses.iter().cloned()); - - if !unspent.is_empty() { - match wallet_info - .accounts - .all_funding_accounts_mut() - .into_iter() - .next() - { - Some(account) => { - for utxo in &unspent { - account.utxos.insert(utxo.outpoint, (*utxo).clone()); - } - // Eager derivation covers only `0..gap_limit`; extend each - // chain to cover restored / used addresses at deeper indices. - extend_pools_for_restored_addresses( - account, - manifest, - &addresses_to_mark, - wallet_id, - )?; - } - None => { - return Err(PlatformWalletError::RehydrationTopologyUnsupported { - wallet_id, - utxo_count: unspent.len(), - }); - } - } - } else if !addresses_to_mark.is_empty() { - // No unspent UTXOs to hold, but persisted used-state still needs - // re-marking so spent-out addresses aren't re-handed-out. Apply to - // the first funds account; a funds-less wallet has no pool to mark - // (and no UTXOs at risk), so this is a no-op without the topology - // guard — that guard only fires for unspent UTXOs above. - if let Some(account) = wallet_info - .accounts - .all_funding_accounts_mut() - .into_iter() - .next() - { - extend_pools_for_restored_addresses(account, manifest, &addresses_to_mark, wallet_id)?; - } - } - - // Recompute per-account + wallet balance from the restored set. - // After this, a non-zero persisted balance is non-zero here — a - // silent zero would be a hard FAIL of the rehydration contract. - wallet_info.update_balance(); - Ok(()) -} - -/// Upper bound on forward derivation while resolving a restored UTXO -/// address to its derivation index. Addresses that don't resolve within -/// this many indices (e.g. they belong to a different funds account whose -/// UTXOs were routed here, or are corrupt) are left for the next full -/// rescan to re-warm — generous enough to cover any realistic per-account -/// derivation depth. The common (single funds account) path terminates at -/// the true high-water mark well before this and never reaches the cap. -const MAX_REHYDRATION_DERIVATION_INDEX: u32 = 10_000; - -/// Soft threshold past which a single chain's discovery scan is treated as -/// abnormally deep and worth a `tracing::warn!`. Real funds chains anchor -/// well below this; reaching it means either a corrupt / foreign-heavy UTXO -/// set walking the horizon out, or an approach toward the hard -/// [`MAX_REHYDRATION_DERIVATION_INDEX`] ceiling — both worth surfacing. -const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; - -/// Extend `account`'s address pools so every resolved address (a -/// still-unspent UTXO address or a persisted pool used-address) is derived -/// at its exact `(chain, index)` slot and marked used, then refill the gap -/// window beyond — following the sync path's `mark_used` → -/// `maintain_gap_limit` sequence. Each chain is scanned independently, -/// stopping once no unresolved address matches within a `gap_limit`-sized -/// window past the deepest resolved index; [`MAX_REHYDRATION_DERIVATION_INDEX`] -/// is the hard ceiling. Addresses that don't resolve from this account's -/// xpub — foreign keys, multi-account mismatch, or legitimately-owned but -/// deep-and-sparse slots with no nearer resolved address to anchor the horizon — -/// are counted and logged via `tracing::warn!`; they re-warm on the next -/// full sync. Every resolved address the pools *do* hold (in-window or -/// deep-resolved) is marked used so a funded or previously-used address is -/// never handed out as a fresh receive address. -/// -/// Tested with Standard BIP44 topology (External + Internal pools) and -/// CoinJoin topology (single External pool). The per-chain probe loop has no -/// topology-specific branches, so the non-hardened single-pool type -/// (`Absent`) follows the same code path with a different relative derivation -/// path. `AbsentHardened` pools cannot be derived from a public xpub at all — -/// hardened child derivation needs the private key — so under watch-only -/// rehydration their addresses never resolve and always defer to the next -/// sync (shared code path, but the outcome is "unresolved"). -/// -/// # Errors -/// -/// [`PlatformWalletError::RehydrationPoolMismatch`] if the discovery probes -/// don't mirror the real pools 1:1 (a structural invariant break, not -/// user-reachable). Fail-closed rather than apply a probe depth to the wrong -/// pool by position. -/// -/// Never touches key material — the xpub is the keyless account public key. -fn extend_pools_for_restored_addresses( - account: &mut key_wallet::managed_account::ManagedCoreFundsAccount, - manifest: &[AccountRegistrationEntry], - restored_addresses: &[key_wallet::Address], - wallet_id: [u8; 32], -) -> Result<(), PlatformWalletError> { - use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use std::collections::HashSet; - - let account_type = account.managed_account_type().to_account_type(); - - // The funds account carries no key material; recover its watch-only xpub - // from the keyless manifest by account type. Without it we cannot derive - // deeper, but can still mark already-derived (in-window) addresses used. - let key_source = manifest - .iter() - .find(|e| e.account_type == account_type) - .map(|e| KeySource::Public(e.account_xpub)); - - // Probe pools mirror each real pool's chain 1:1 so the index search - // derives into throwaway state (real pools keep their own exact depth) - // and the resolved depth can be applied back by position. Re-deriving - // each probe from index 0 is an accepted, bounded one-time-load cost - // (per chain capped at MAX_REHYDRATION_DERIVATION_INDEX); rehydration - // runs once per wallet at startup, never on a hot path. - let mut probes: Vec<(AddressPool, Option)> = account - .managed_account_type() - .address_pools() - .iter() - .map(|p| { - ( - AddressPool::new_without_generation( - p.base_path.clone(), - p.pool_type, - p.gap_limit, - p.network, - ), - None, - ) - }) - .collect(); - - // Deep-index discovery (requires the xpub): resolve restored addresses the - // eager derivation didn't already cover, recording the matching index per - // chain. Each chain advances independently and stops once no unresolved - // address resolves within gap_limit indices past its deepest match - // (preventing a full scan when the UTXO set carries foreign addresses); - // MAX_REHYDRATION_DERIVATION_INDEX is the hard ceiling regardless. - if let Some(key_source) = key_source.as_ref() { - let mut unresolved: HashSet = { - let pools = account.managed_account_type().address_pools(); - restored_addresses - .iter() - .filter(|addr| !pools.iter().any(|p| p.contains_address(addr))) - .cloned() - .collect() - }; - - for (probe, deepest_resolved) in probes.iter_mut() { - if unresolved.is_empty() { - break; - } - let chain_gap = probe.gap_limit; - let mut index: u32 = 0; - - loop { - // Horizon: gap_limit past the deepest match, or the initial - // gap_limit window when nothing has resolved yet. - let horizon = deepest_resolved - .map(|d| d.saturating_add(chain_gap)) - .unwrap_or(chain_gap); - - if index > horizon || index > MAX_REHYDRATION_DERIVATION_INDEX { - break; - } - - if let Some(addr) = ensure_derived(probe, key_source, index) { - // Indices are visited in ascending order, so the last match - // is the deepest — record it directly (no per-chain set). - if unresolved.remove(&addr) { - *deepest_resolved = Some(index); - } - } - - if unresolved.is_empty() { - break; - } - - index = index.saturating_add(1); - } - - // Surface an abnormally deep scan once per chain (outside the loop - // — never log inside the per-index walk). - if index > REHYDRATION_DEEP_SCAN_WARN_INDEX { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - account_type = ?account_type, - pool_type = ?probe.pool_type, - deepest_resolved = ?deepest_resolved, - scanned_to = index.saturating_sub(1), - "rehydration: chain discovery scanned abnormally deep — \ - likely a foreign-heavy or sparse UTXO set" - ); - } - } - - // Still-unresolved addresses are either foreign (a different account's - // key routed here, or corrupt) or legitimately-owned but deep-and-sparse - // (past the first gap window with no nearer unspent UTXO to anchor the - // horizon). Either way they re-warm on the next full sync; the wallet - // total is exact regardless. - if !unresolved.is_empty() { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - account_type = ?account_type, - unresolved_count = unresolved.len(), - "rehydration: UTXO address(es) unresolved for this account xpub \ - — will re-warm on next sync; balance total is exact" - ); - } - } - - // No explicit aggregate-derivation cap is needed: a funds account exposes - // a fixed, small number of chains (Standard = 2, others = 1), each already - // capped at MAX_REHYDRATION_DERIVATION_INDEX, so total derivation is bounded - // by chains × MAX with no unbounded growth — an aggregate cap would either - // equal that natural bound (no-op) or clip a legitimate deep multi-chain - // wallet. The per-chain ceiling plus the deep-scan warn above are the - // proportionate guard against a corrupt/foreign-heavy UTXO set. - - // Apply discovered depths and mark restored addresses used. `probes` is - // built directly from `address_pools()`, so it mirrors `address_pools_mut()` - // 1:1 and in chain order; verify that invariant before zipping by position. - let mut pools = account.managed_account_type_mut().address_pools_mut(); - if pools.len() != probes.len() { - return Err(PlatformWalletError::RehydrationPoolMismatch { - expected: probes.len(), - found: pools.len(), - }); - } - for (position, (pool, (probe, deepest_resolved))) in - pools.iter_mut().zip(probes.iter()).enumerate() - { - // `iter_mut()` over `Vec<&mut AddressPool>` yields `&mut &mut _`; - // reborrow once so the pool flows into `ensure_derived` cleanly. - let pool: &mut AddressPool = pool; - - // Runtime fail-closed guard (a release build compiles out a - // `debug_assert!`): applying a probe's depth to a pool of a different - // chain would misattribute derivation to the wrong pool by position. - if pool.pool_type != probe.pool_type { - return Err(PlatformWalletError::RehydrationPoolTypeMismatch { - position, - expected: probe.pool_type, - found: pool.pool_type, - }); - } - - // Derive up to the deepest discovered index so its address exists in - // the real pool before we mark it used. - if let Some(deepest) = *deepest_resolved { - if let Some(key_source) = key_source.as_ref() { - if ensure_derived(pool, key_source, deepest).is_none() { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - account_type = ?account_type, - pool_type = ?pool.pool_type, - index = deepest, - "rehydration: failed to derive resolved index into pool; \ - deferring its address to the next sync" - ); - } - } - } - - // Mark every restored address this pool now holds as used — covers both - // deep-resolved addresses (just derived) and in-window addresses the - // discovery scan never visits. Without this an already-derived but - // funded address keeps `used = false` and could be handed out as a fresh - // receive address. `mark_used` is a no-op for addresses not in this - // pool, so an underived (foreign / sparse) index is never marked. - // - // Mark ↔ refill runs to a FIXPOINT: marking raises `highest_used`, - // whose gap refill can derive a deeper previously-used address that - // the discovery walk missed (e.g. used idx 45 with in-window used - // idx 20 and gap 30 — the walk's horizon stops at 30, but the refill - // reaches 50 and derives idx 45). A single mark-then-refill pass - // would leave that address in the pool with `used = false`, handing - // a previously-used address back out as fresh. Terminates: each - // round marks at least one new address from the finite restored set - // (`mark_used` returns `true` only on an unused→used flip). - loop { - let mut marked_any = false; - for addr in restored_addresses { - if pool.mark_used(addr) { - marked_any = true; - } - } - if !marked_any { - break; - } - // Refill the gap window past the deepest used index (needs the - // xpub); without one no deeper address can be derived, so a - // single mark pass is all that's possible. - let Some(key_source) = key_source.as_ref() else { - break; - }; - if let Err(e) = pool.maintain_gap_limit(key_source) { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - account_type = ?account_type, - pool_type = ?pool.pool_type, - error = %e, - "rehydration: gap-limit maintenance failed; pool window \ - may be short until the next sync" - ); - break; - } - } - } - Ok(()) -} - -/// Ensure `pool` has derived through `index` (generating only the missing -/// tail), and return that index's address. `None` only on a derivation -/// error. -fn ensure_derived( - pool: &mut key_wallet::managed_account::address_pool::AddressPool, - key_source: &key_wallet::managed_account::address_pool::KeySource, - index: u32, -) -> Option { - let needs_more = match pool.highest_generated { - Some(highest) => highest < index, - None => true, - }; - if needs_more { - let start = pool.highest_generated.map(|h| h + 1).unwrap_or(0); - pool.generate_addresses(index - start + 1, key_source, true) - .ok()?; - } - pool.address_at_index(index) -} - #[cfg(test)] mod tests { use super::*; @@ -613,1094 +128,4 @@ mod tests { .expect_err("empty manifest must be MissingManifest"); assert!(matches!(err, CorruptKind::MissingManifest)); } - - /// Regression: after restart-in-place the watch-only pools eagerly - /// cover only `0..gap_limit`, but persisted UTXOs can sit at deeper - /// derivation indices. Rehydration must extend each chain's pool to its - /// deepest restored index so the per-address view reconciles with the - /// wallet total instead of undercounting. - /// - /// Index layout (gap_limit = 30): - /// - external idx 3: within eager window (not in `unresolved`), balance included - /// - external idx 30: first index past eager window; anchors the initial scan - /// window and extends it to idx 60 - /// - external idx 50: within extended window (50 < 60), resolved - /// - internal idx 30: within initial scan window, resolved - /// - /// Standard BIP44 topology (External + Internal pools) is exercised. - /// Asserts that maintain_gap_limit fills beyond the deepest resolved. - #[test] - fn rehydration_extends_pools_to_cover_deep_index_utxos() { - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - use std::collections::HashSet; - - let seed = [7u8; 64]; - let wallet = Wallet::from_seed_bytes( - seed, - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - - // Mint the watch-only skeleton (pools cover only the eager gap - // window) and resolve the first funds account's keyless xpub. - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - let funds_type = wallet_info - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - // Derive addresses on each chain from the same account xpub the - // pools use; `base_path` is record-keeping only and does not affect - // the derived address, so DerivationPath::master() is fine here. - let derive = |pool_type, index: u32| -> Address { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - pool_type, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(index + 1, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(index).unwrap() - }; - - // idx 3: within eager window (0..=29) — covered by init, NOT in - // unresolved. Contributes to balance but needs no pool extension. - let shallow_recv = derive(AddressPoolType::External, 3); - // idx 30: first past eager window; falls in initial scan window - // (horizon = gap_limit = 30 on a chain with no prior matches). - // Anchors the external probe and extends horizon to 60. - let mid_recv = derive(AddressPoolType::External, 30); - // idx 50: within the extended window (50 < 30+30=60), resolved. - let deep_recv = derive(AddressPoolType::External, 50); - // idx 30: within the internal chain's initial scan window (<=30). - let deep_change = derive(AddressPoolType::Internal, 30); - - let utxo = |addr: Address, value: u64, n: u8| Utxo { - outpoint: OutPoint { - txid: Txid::from([n; 32]), - vout: 0, - }, - txout: TxOut { - value, - script_pubkey: addr.script_pubkey(), - }, - address: addr, - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }; - let new_utxos = vec![ - utxo(shallow_recv, 1_000, 1), - utxo(mid_recv.clone(), 10_000, 2), - utxo(deep_recv.clone(), 20_000, 3), - utxo(deep_change.clone(), 300_000, 4), - ]; - let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); - let core = crate::changeset::CoreChangeSet { - new_utxos, - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - // The wallet total is exact regardless (a sum over the UTXO set). - assert_eq!(wallet_info.balance.total(), expected_total); - - // The per-address view joins pool addresses to UTXOs; every - // resolved UTXO address must now be derived into a pool. - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pool_addresses: HashSet

= funds - .managed_account_type() - .address_pools() - .iter() - .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) - .collect(); - let visible: u64 = funds - .utxos - .values() - .filter(|u| pool_addresses.contains(&u.address)) - .map(|u| u.value()) - .sum(); - assert_eq!( - visible, expected_total, - "all UTXO addresses (including deep-index) must be derived into their pools" - ); - - // Each deep address resolves to its exact derivation slot. - let pools = funds.managed_account_type().address_pools(); - let external = pools.iter().find(|p| p.is_external()).unwrap(); - let internal = pools.iter().find(|p| p.is_internal()).unwrap(); - assert_eq!(external.address_at_index(30).as_ref(), Some(&mid_recv)); - assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv)); - assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change)); - - // maintain_gap_limit must refill BEYOND the deepest restored - // index so the gap window is actually exercised, not just the restore. - // Deepest external resolved = idx 50; gap window must reach >= 50+30=80. - let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT; - assert!( - external.highest_generated >= Some(expected_min_gen), - "maintain_gap_limit must extend external pool to >= {} (got {:?})", - expected_min_gen, - external.highest_generated, - ); - } - - /// A UTXO whose address is not derivable from this account's - /// xpub (foreign key, multi-account mismatch) must not cause a panic or - /// hang. The total balance is exact (the UTXO is in the set regardless), - /// but the foreign address is absent from the pool so per-address - /// visibility is reduced. `tracing::warn!` fires for the unresolved count. - #[test] - fn rehydration_unresolvable_address_is_deferred_not_panics() { - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - use std::collections::HashSet; - - let seed = [13u8; 64]; - let wallet = Wallet::from_seed_bytes( - seed, - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - - let funds_type = wallet_info - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - // Normal UTXO at external index 3 (within eager window, pool-visible). - let normal_addr = { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - AddressPoolType::External, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(4, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(3).unwrap() - }; - - // Foreign address: derive from a completely different wallet seed so - // it cannot be resolved from this wallet's xpub. - let foreign_addr = { - let fw = Wallet::from_seed_bytes( - [99u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let fw_info = ManagedWalletInfo::from_wallet(&fw, 1); - fw_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap() - .managed_account_type() - .address_pools() - .first() - .unwrap() - .address_at_index(0) - .unwrap() - }; - assert_ne!( - normal_addr, foreign_addr, - "test fixture: foreign address must differ from normal" - ); - - let utxo = |addr: Address, value: u64, n: u8| Utxo { - outpoint: OutPoint { - txid: Txid::from([n; 32]), - vout: 0, - }, - txout: TxOut { - value, - script_pubkey: addr.script_pubkey(), - }, - address: addr, - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }; - - let normal_val = 100_000u64; - let foreign_val = 200_000u64; - let expected_total = normal_val + foreign_val; - - let core = crate::changeset::CoreChangeSet { - new_utxos: vec![ - utxo(normal_addr, normal_val, 1), - utxo(foreign_addr, foreign_val, 2), - ], - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - // Must not panic. tracing::warn! fires for the unresolved count. - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - // Total balance is exact — foreign UTXO is in the set regardless. - assert_eq!( - wallet_info.balance.total(), - expected_total, - "total must include foreign UTXO even though it is unresolved" - ); - - // Per-address visible: only the normal UTXO is in the pool. - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pool_addresses: HashSet
= funds - .managed_account_type() - .address_pools() - .iter() - .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) - .collect(); - let visible: u64 = funds - .utxos - .values() - .filter(|u| pool_addresses.contains(&u.address)) - .map(|u| u.value()) - .sum(); - assert_eq!( - visible, normal_val, - "only the non-foreign UTXO is pool-visible; foreign deferred to re-warm" - ); - assert!( - visible < expected_total, - "foreign UTXO is deferred — per-address visible < total" - ); - } - - /// CoinJoin topology (External pool, deep index). - /// Verifies that `extend_pools_for_restored_addresses` handles the - /// CoinJoin External pool at a deep derivation index (idx 30, just past - /// the eager window). CoinJoin accounts carry both an External and an - /// Internal pool (mirroring `Standard`); this test exercises the - /// External side only. - #[test] - fn rehydration_coinjoin_single_pool_deep_index() { - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::Utxo; - use std::collections::BTreeSet; - - // CoinJoin-only wallet: no BIP44, one CoinJoin account at index 0. - let mut cj_set = BTreeSet::new(); - cj_set.insert(0u32); - let opts = WalletAccountCreationOptions::SpecificAccounts( - BTreeSet::new(), - BTreeSet::new(), - cj_set, - BTreeSet::new(), - BTreeSet::new(), - None, - ); - let seed = [11u8; 64]; - let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, opts).unwrap(); - assert!( - !wallet.accounts.coinjoin_accounts.is_empty(), - "fixture must have a CoinJoin account" - ); - - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - - // Extract pool metadata before the mutable borrow of wallet_info. - let (funds_type, pool_base_path, pool_type_val, pool_gap_limit) = { - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .expect("CoinJoin account must be the only funds account"); - let ft = funds.managed_account_type().to_account_type(); - let pools = funds.managed_account_type().address_pools(); - // CoinJoin carries both an External and an Internal pool; this - // test targets the External side specifically. - let p = pools - .iter() - .find(|p| p.pool_type == AddressPoolType::External) - .expect("CoinJoin topology: must have an External pool"); - (ft, p.base_path.clone(), p.pool_type, p.gap_limit) - }; - - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("CoinJoin xpub must be in manifest"); - - // Derive the CoinJoin address at index 30 (first past the eager - // window 0..=29) using the real pool's base_path and pool_type. - let mut probe = AddressPool::new_without_generation( - pool_base_path, - pool_type_val, - pool_gap_limit, - Network::Testnet, - ); - probe - .generate_addresses(31, &KeySource::Public(xpub), true) - .unwrap(); - let deep_cj_addr = probe.address_at_index(30).unwrap(); - - let utxo_val = 7_777u64; - let utxo = Utxo { - outpoint: OutPoint { - txid: Txid::from([7u8; 32]), - vout: 0, - }, - txout: TxOut { - value: utxo_val, - script_pubkey: deep_cj_addr.script_pubkey(), - }, - address: deep_cj_addr.clone(), - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }; - - let core = crate::changeset::CoreChangeSet { - new_utxos: vec![utxo], - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - // Balance is exact. - assert_eq!( - wallet_info.balance.total(), - utxo_val, - "CoinJoin deep-index balance must be exact" - ); - - // The CoinJoin pool was extended to include the deep-index address. - let funds_post = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let cj_pool = funds_post - .managed_account_type() - .address_pools() - .into_iter() - .find(|p| p.pool_type == AddressPoolType::External) - .expect("CoinJoin topology: must have an External pool"); - assert_eq!( - cj_pool.address_at_index(30).as_ref(), - Some(&deep_cj_addr), - "CoinJoin pool must be extended to cover deep-index address at idx 30" - ); - } - - /// In-window restored UTXO: an address already covered by the eager - /// derivation (idx 3, inside `0..=gap_limit-1`) must still be marked - /// `used` during rehydration. The discovery scan never visits in-window - /// addresses, so without an explicit mark pass a funded address would keep - /// `used = false` and could later be handed out as a fresh receive address. - #[test] - fn rehydration_marks_in_window_restored_address_used() { - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - - let wallet = Wallet::from_seed_bytes( - [5u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - - let funds_type = wallet_info - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - // External idx 3 — inside the eager window, so NOT in the discovery set. - let in_window: Address = { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - AddressPoolType::External, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(4, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(3).unwrap() - }; - - let core = crate::changeset::CoreChangeSet { - new_utxos: vec![Utxo { - outpoint: OutPoint { - txid: Txid::from([1u8; 32]), - vout: 0, - }, - txout: TxOut { - value: 12_345, - script_pubkey: in_window.script_pubkey(), - }, - address: in_window.clone(), - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }], - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pools = funds.managed_account_type().address_pools(); - let external = pools.iter().find(|p| p.is_external()).unwrap(); - let info = external - .address_info(&in_window) - .expect("in-window address must be present in the pool"); - assert!( - info.used, - "in-window restored UTXO address must be marked used" - ); - assert!( - external.used_indices.contains(&3), - "used_indices must record the in-window slot" - ); - assert_eq!( - external.highest_used, - Some(3), - "highest_used must reflect the in-window slot" - ); - } - - /// #3692 review (privacy / address-reuse): a previously-used address - /// whose UTXO was SINCE SPENT must still come back marked `used` when the - /// snapshot carries it via `ClientWalletStartState::used_core_addresses`. - /// Without it the address resets to `used = false` and could be handed - /// out again as a fresh receive address. The used flag must survive even - /// though the UTXO is gone (`spent_utxos` cancels `new_utxos` → zero - /// balance), proving it is NOT just a side effect of a live UTXO. Covers - /// an in-window slot (idx 5) and a deeper slot the horizon walk resolves - /// (idx 30), and asserts the empty-snapshot baseline does NOT mark them. - #[test] - fn rehydration_used_state_survives_spent_utxo() { - use crate::changeset::{ClientWalletStartState, CoreChangeSet}; - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - - let wallet = Wallet::from_seed_bytes( - [42u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - - let funds_type = ManagedWalletInfo::from_wallet(&wallet, 1) - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - let derive = |index: u32| -> Address { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - AddressPoolType::External, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(index + 1, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(index).unwrap() - }; - let in_window_used = derive(5); - let deep_used = derive(30); - - // The in-window address received funds (new_utxos) that were later - // spent (spent_utxos) — so it carries NO unspent UTXO. Exactly the - // reuse hazard: zero balance, yet the address must stay `used`. - let spent = Utxo { - outpoint: OutPoint { - txid: Txid::from([1u8; 32]), - vout: 0, - }, - txout: TxOut { - value: 50_000, - script_pubkey: in_window_used.script_pubkey(), - }, - address: in_window_used.clone(), - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }; - let core = CoreChangeSet { - new_utxos: vec![spent.clone()], - spent_utxos: vec![spent], - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - // The keyless slice the persister hands back, carrying the pool - // used-state for both addresses. - let state = ClientWalletStartState { - network: Network::Testnet, - birth_height: 1, - account_manifest: manifest.clone(), - core_wallet_info: None, - core_state: core, - identity_manager: Default::default(), - unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), - used_core_addresses: vec![in_window_used.clone(), deep_used.clone()], - }; - - // Baseline: drop the pool used-state (empty) — the spent-out address - // resets to unused (the pre-fix behaviour, and the reuse hazard). - { - let mut baseline = ManagedWalletInfo::from_wallet(&wallet, 1); - apply_persisted_core_state( - &mut baseline, - &state.account_manifest, - &state.core_state, - &[], - ) - .unwrap(); - let funds = baseline - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pools = funds.managed_account_type().address_pools(); - let external = pools.iter().find(|p| p.is_external()).unwrap(); - assert!( - !external - .address_info(&in_window_used) - .map(|i| i.used) - .unwrap_or(false), - "without pool used-state a spent-out address resets to unused" - ); - } - - // With the snapshot's used-state — routed through - // ClientWalletStartState::used_core_addresses — both come back used. - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - apply_persisted_core_state( - &mut wallet_info, - &state.account_manifest, - &state.core_state, - &state.used_core_addresses, - ) - .unwrap(); - - // The spent UTXO contributes no balance — the used flag is NOT a - // side effect of a live UTXO. - assert_eq!( - wallet_info.balance.total(), - 0, - "the spent UTXO must not contribute balance" - ); - - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pools = funds.managed_account_type().address_pools(); - let external = pools.iter().find(|p| p.is_external()).unwrap(); - assert!( - external - .address_info(&in_window_used) - .expect("in-window used address present") - .used, - "in-window spent-out address must be restored as used" - ); - assert!(external.used_indices.contains(&5), "idx 5 recorded used"); - assert!( - external - .address_info(&deep_used) - .expect("deep used address derived into pool") - .used, - "deep spent-out address must be derived + restored as used" - ); - assert!(external.used_indices.contains(&30), "idx 30 recorded used"); - assert_eq!( - external.highest_used, - Some(30), - "highest_used must reflect the deepest restored used slot" - ); - } - - /// Regression (mark↔refill fixpoint): a previously-used address in the - /// "wedge zone" — past the discovery horizon but within reach of the - /// gap refill — must come back `used`. With used addresses at idx 20 - /// (in the eager window) and idx 45 (gap 30): the discovery walk - /// excludes in-window addresses from `unresolved`, so nothing anchors - /// the horizon past 30 and idx 45 is never scanned; marking idx 20 then - /// makes `maintain_gap_limit` derive out to 20+30=50, which brings the - /// idx-45 address into the pool. A single mark-then-refill pass left it - /// there with `used = false` — pool-visible as a FRESH address, handed - /// out again, and its stale `used = false` persisted back over the - /// store's `is_used = true` on the next pool snapshot. The fixpoint - /// re-marks after every refill until nothing new resolves. - #[test] - fn rehydration_wedge_zone_used_address_marked_after_refill() { - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::Address; - - let wallet = Wallet::from_seed_bytes( - [61u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - - let funds_type = wallet_info - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - let derive = |index: u32| -> Address { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - AddressPoolType::External, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(index + 1, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(index).unwrap() - }; - // Reachable multi-device state: this device saw idx 20 used; - // another device (same mnemonic) handed out and used idx 45. - let in_window_used = derive(20); - let wedge_used = derive(45); - - // No UTXOs at all — only the persisted pool used-state. - let core = crate::changeset::CoreChangeSet { - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - apply_persisted_core_state( - &mut wallet_info, - &manifest, - &core, - &[in_window_used.clone(), wedge_used.clone()], - ) - .unwrap(); - - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pools = funds.managed_account_type().address_pools(); - let external = pools.iter().find(|p| p.is_external()).unwrap(); - assert!( - external - .address_info(&in_window_used) - .expect("in-window used address present") - .used, - "in-window used address must be restored as used" - ); - let wedge_info = external - .address_info(&wedge_used) - .expect("wedge-zone address must be derived into the pool by the refill"); - assert!( - wedge_info.used, - "wedge-zone previously-used address must be re-marked used, \ - not left pool-visible as fresh" - ); - assert!(external.used_indices.contains(&45), "idx 45 recorded used"); - assert_eq!( - external.highest_used, - Some(45), - "highest_used must reflect the wedge-zone slot" - ); - // And the window is refilled past the re-marked slot. - assert!( - external.highest_generated >= Some(45 + DEFAULT_EXTERNAL_GAP_LIMIT), - "gap window must extend past the re-marked wedge slot (got {:?})", - external.highest_generated, - ); - } - - /// Documented limitation (solution b): a legitimately-owned but - /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx - /// <= 30 — is left unresolved because the discovery horizon (gap_limit - /// past the deepest match) never advances far enough to reach it. The - /// wallet total stays exact; only the per-address view is incomplete - /// until the next sync (a `tracing::warn!` records the deferral). - #[test] - fn rehydration_deep_sparse_utxo_left_unresolved_total_exact() { - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::{OutPoint, Txid}; - use key_wallet::bip32::DerivationPath; - use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; - use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - use std::collections::HashSet; - - let wallet = Wallet::from_seed_bytes( - [21u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - - let funds_type = wallet_info - .accounts - .all_funding_accounts() - .first() - .unwrap() - .managed_account_type() - .to_account_type(); - let xpub = manifest - .iter() - .find(|e| e.account_type == funds_type) - .map(|e| e.account_xpub) - .expect("funds account xpub"); - - // External idx 45 — past the eager window AND past the initial scan - // window (horizon = gap_limit = 30 with no nearer match to extend it). - let sparse_deep: Address = { - let mut p = AddressPool::new_without_generation( - DerivationPath::master(), - AddressPoolType::External, - DEFAULT_EXTERNAL_GAP_LIMIT, - Network::Testnet, - ); - p.generate_addresses(46, &KeySource::Public(xpub), true) - .unwrap(); - p.address_at_index(45).unwrap() - }; - - let value = 500_000u64; - let core = crate::changeset::CoreChangeSet { - new_utxos: vec![Utxo { - outpoint: OutPoint { - txid: Txid::from([4u8; 32]), - vout: 0, - }, - txout: TxOut { - value, - script_pubkey: sparse_deep.script_pubkey(), - }, - address: sparse_deep.clone(), - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }], - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - // The wallet total is exact regardless (a sum over the UTXO set). - assert_eq!(wallet_info.balance.total(), value); - - let funds = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .next() - .unwrap(); - let pools = funds.managed_account_type().address_pools(); - let external = pools.iter().find(|p| p.is_external()).unwrap(); - assert!( - !external.contains_address(&sparse_deep), - "deep-sparse idx 45 must be left unresolved (absent from the pool)" - ); - - // Per-address view: the deep-sparse UTXO is not pool-visible yet. - let pool_addresses: HashSet
= pools - .iter() - .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) - .collect(); - let visible: u64 = funds - .utxos - .values() - .filter(|u| pool_addresses.contains(&u.address)) - .map(|u| u.value()) - .sum(); - assert_eq!( - visible, 0, - "the deep-sparse UTXO is deferred — not pool-visible until next sync" - ); - assert!(visible < value, "per-address visible < exact total"); - } - - /// Topology guard: a wallet with persisted UTXOs but NO funds-bearing - /// account cannot hold them — fail closed with - /// `RehydrationTopologyUnsupported` (reporting the persisted count) rather - /// than reconstruct a silent zero balance. - #[test] - fn rehydration_utxos_without_funds_account_errors() { - use dashcore::address::Payload; - use dashcore::blockdata::transaction::txout::TxOut; - use dashcore::hashes::Hash; - use dashcore::{OutPoint, PubkeyHash, Txid}; - use key_wallet::account::AccountType; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::{Address, Utxo}; - use std::collections::BTreeSet; - - // Keys-only wallet: a single IdentityRegistration account, no funds. - let opts = WalletAccountCreationOptions::SpecificAccounts( - BTreeSet::new(), - BTreeSet::new(), - BTreeSet::new(), - BTreeSet::new(), - BTreeSet::new(), - Some(vec![AccountType::IdentityRegistration]), - ); - let wallet = Wallet::from_seed_bytes([23u8; 64], Network::Testnet, opts).unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - assert!( - wallet_info.accounts.all_funding_accounts().is_empty(), - "fixture must have NO funds-bearing account" - ); - - let addr = Address::new( - Network::Testnet, - Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])), - ); - let core = crate::changeset::CoreChangeSet { - new_utxos: vec![Utxo { - outpoint: OutPoint { - txid: Txid::from([2u8; 32]), - vout: 0, - }, - txout: TxOut { - value: 800_000, - script_pubkey: addr.script_pubkey(), - }, - address: addr, - height: 1, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }], - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - - let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]) - .expect_err("must fail closed when no funds account can hold the UTXOs"); - match err { - PlatformWalletError::RehydrationTopologyUnsupported { utxo_count, .. } => { - assert_eq!(utxo_count, 1, "utxo_count must match the persisted set"); - } - other => panic!("expected RehydrationTopologyUnsupported, got {other:?}"), - } - } - - /// Companion to the topology guard: the same keys-only wallet with an - /// EMPTY persisted UTXO set is `Ok` — there is nothing to hold, so the - /// guard does not trip. - #[test] - fn rehydration_no_funds_account_empty_utxos_ok() { - use key_wallet::account::AccountType; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use std::collections::BTreeSet; - - let opts = WalletAccountCreationOptions::SpecificAccounts( - BTreeSet::new(), - BTreeSet::new(), - BTreeSet::new(), - BTreeSet::new(), - BTreeSet::new(), - Some(vec![AccountType::IdentityRegistration]), - ); - let wallet = Wallet::from_seed_bytes([24u8; 64], Network::Testnet, opts).unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - assert!(wallet_info.accounts.all_funding_accounts().is_empty()); - - let core = crate::changeset::CoreChangeSet { - last_processed_height: Some(1), - synced_height: Some(1), - ..Default::default() - }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]) - .expect("empty UTXO set must be Ok even with no funds account"); - } - - /// Regression: a `last_applied_chain_lock` carried in the persisted - /// `CoreChangeSet` must be restored onto the rehydrated wallet - /// metadata. Without it, the asset-lock-resume CL-from-metadata - /// fallback (`proof.rs`) cannot fire at app launch and a pre-restart - /// chain-locked asset lock can't produce a proof until SPV re-applies - /// a fresh chainlock. Fails (`None != Some`) if the apply step drops it. - #[test] - fn rehydration_restores_last_applied_chain_lock() { - use dashcore::ephemerealdata::chain_lock::ChainLock; - use dashcore::hashes::Hash; - use dashcore::BlockHash; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - - let wallet = Wallet::from_seed_bytes( - [5u8; 64], - Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let manifest = manifest_for(&wallet); - let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - assert!( - wallet_info.metadata.last_applied_chain_lock.is_none(), - "fresh watch-only skeleton starts with no chain lock" - ); - - let cl = ChainLock { - block_height: 123_456, - block_hash: BlockHash::from_byte_array([7u8; 32]), - signature: [9u8; 96].into(), - }; - let core = crate::changeset::CoreChangeSet { - last_applied_chain_lock: Some(cl.clone()), - ..Default::default() - }; - - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); - - assert_eq!( - wallet_info.metadata.last_applied_chain_lock.as_ref(), - Some(&cl), - "persisted last_applied_chain_lock must be restored onto wallet metadata" - ); - } } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 7068b05a62c..4269ed9f576 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -25,8 +25,8 @@ use std::sync::{Arc, Mutex}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use platform_wallet::changeset::{ - AccountRegistrationEntry, ClientStartState, ClientWalletStartState, CoreChangeSet, - PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, PersistenceError, + PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::error::PlatformWalletError; use platform_wallet::events::{EventHandler, PlatformEventHandler}; @@ -75,12 +75,10 @@ impl PlatformWalletPersistence for FixedLoadPersister { birth_height: w.birth_height, account_manifest: w.account_manifest.clone(), core_wallet_info: w.core_wallet_info.clone(), - core_state: w.core_state.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), contacts: Default::default(), identity_keys: Default::default(), - used_core_addresses: w.used_core_addresses.clone(), }, ); } @@ -153,20 +151,34 @@ fn manifest_and_id(seed: [u8; 64]) -> (Vec, [u8; 32]) } fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { - let (manifest, id) = manifest_and_id(seed); + let wallet = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id = wallet.compute_wallet_id(); + let account_manifest = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + let info = key_wallet::wallet::managed_wallet_info::ManagedWalletInfo::from_wallet(&wallet, 1); ( id, ClientWalletStartState { network: key_wallet::Network::Testnet, birth_height: 1, - account_manifest: manifest, - core_wallet_info: None, - core_state: CoreChangeSet::default(), + account_manifest, + core_wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), contacts: Default::default(), identity_keys: Default::default(), - used_core_addresses: Default::default(), }, ) } @@ -305,21 +317,11 @@ async fn rt_corrupt_row_skipped_and_other_loads() { let p = Arc::new(FixedLoadPersister::new()); let h = Arc::new(RecordingHandler::default()); let (id_a, sa) = slice(seed_a); - let (id_b, _sb) = slice(seed_b); - - // B's row is structurally corrupt — empty manifest. - let sb_corrupt = ClientWalletStartState { - network: key_wallet::Network::Testnet, - birth_height: 1, - account_manifest: Vec::new(), - core_wallet_info: None, - core_state: CoreChangeSet::default(), - identity_manager: Default::default(), - unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), - used_core_addresses: Default::default(), - }; + let (id_b, mut sb_corrupt) = slice(seed_b); + + // B's row is structurally corrupt — empty manifest. The empty manifest + // aborts the watch-only rebuild before the snapshot is ever consulted. + sb_corrupt.account_manifest = Vec::new(); let mut st = ClientStartState::default(); st.wallets.insert(id_a, sa); @@ -373,21 +375,10 @@ async fn rt_z_secret_hygiene_surfaces() { let seed = [0xAB; 64]; let p = Arc::new(FixedLoadPersister::new()); let h = Arc::new(RecordingHandler::default()); - let (id, _s) = slice(seed); + let (id, mut corrupt) = slice(seed); // Corrupt row to force a skip and inspect every public surface. - let corrupt = ClientWalletStartState { - network: key_wallet::Network::Testnet, - birth_height: 1, - account_manifest: Vec::new(), - core_wallet_info: None, - core_state: CoreChangeSet::default(), - identity_manager: Default::default(), - unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), - used_core_addresses: Default::default(), - }; + corrupt.account_manifest = Vec::new(); let mut st = ClientStartState::default(); st.wallets.insert(id, corrupt); p.set(st); @@ -529,7 +520,7 @@ async fn rt_snapshot_preserves_attribution_and_pools() { info.update_balance(); let (_, mut s) = slice(seed); - s.core_wallet_info = Some(Box::new(info)); + s.core_wallet_info = Box::new(info); let p = Arc::new(FixedLoadPersister::new()); let h = Arc::new(RecordingHandler::default()); let mut st = ClientStartState::default(); @@ -589,7 +580,7 @@ async fn rt_snapshot_wallet_id_mismatch_is_skipped() { WalletAccountCreationOptions::Default, ) .unwrap(); - s.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1))); + s.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_a, s); @@ -643,7 +634,7 @@ async fn rt_snapshot_account_set_mismatch_is_skipped() { let (_, mut s) = slice(seed); s.account_manifest = truncated_manifest; - s.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1))); + s.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_a, s); @@ -693,7 +684,7 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { .unwrap(); let id_ok = wallet_ok.compute_wallet_id(); let (_, mut s_ok) = slice(seed_ok); - s_ok.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1))); + s_ok.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1)); // Mismatched row: keyed by wallet BAD, snapshot built from wallet OTHER. let (id_bad, mut s_bad) = slice(seed_bad); @@ -703,7 +694,7 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { WalletAccountCreationOptions::Default, ) .unwrap(); - s_bad.core_wallet_info = Some(Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1))); + s_bad.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_ok, s_ok); From f36e19180cad90a775029bd0f32bb10f7902685a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:13:34 +0000 Subject: [PATCH 38/60] refactor(platform-wallet): drop obsolete non-default-account warn compensator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage layer now resolves per-UTXO account attribution from the core_derived_addresses table (address→account_index lookup over the addresses_derived delta), so core_utxos.account_index is no longer hardcoded to 0. Remove warn_if_non_default_account, non_default_account_index, and the inline aggregated-warn block in the BlockProcessed arm — the real index is already threaded through addresses_derived, which both event arms forward. Co-Authored-By: Claude Opus 4.8 --- .../src/changeset/core_bridge.rs | 64 ++----------------- 1 file changed, 7 insertions(+), 57 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index e477f2c6293..4f7c565dcf6 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -41,40 +41,6 @@ use crate::changeset::changeset::{CoreChangeSet, PlatformWalletChangeSet}; use crate::changeset::traits::PlatformWalletPersistence; use crate::wallet::platform_wallet::PlatformWalletInfo; -/// Single-account observation. The storage writer hardcodes -/// `core_utxos.account_index = 0` (the product uses only the default -/// account, and that column drives only cosmetic per-account grouping). A -/// UTXO-bearing record owned by a non-default funds account is STILL -/// persisted under index 0 — never skipped, because skipping it would -/// undercount the wallet balance and lose funds. We only `warn!` so the -/// approximate grouping is visible. Identity/provider account types carry -/// no funds index (`AccountType::index() == None`) and never emit -/// `Received`/`Change` UTXOs, so they never warn. -/// -/// Accepts a slice so callers can pass a single-element -/// `std::slice::from_ref(r)` or a multi-record slice without allocation. -/// Logs once per non-default-account record. -fn warn_if_non_default_account(records: &[TransactionRecord]) { - for record in records { - if let Some(index) = non_default_account_index(record) { - tracing::warn!( - account_index = index, - txid = %record.txid, - "non-default account UTXO persisted under account_index 0; \ - per-account grouping is approximate" - ); - } - } -} - -/// The record's funds account index when it is a *non-default* (index != 0) -/// funds account, else `None`. Identity/provider account types carry no -/// funds index (`index() == None`) and never emit `Received`/`Change` -/// UTXOs, so they yield `None`. -fn non_default_account_index(record: &TransactionRecord) -> Option { - record.account_type.index().filter(|&index| index != 0) -} - /// Spawn the wallet-event subscriber task. /// /// Subscribes to `wallet_manager.subscribe_events()` from inside the @@ -163,8 +129,6 @@ async fn build_core_changeset( addresses_derived, .. } => { - // Persist regardless of account; warn on a non-default account. - warn_if_non_default_account(std::slice::from_ref(record.as_ref())); // Derive UTXO deltas before moving the record into `records` // so the per-record borrows are still live. CoreChangeSet { @@ -205,27 +169,13 @@ async fn build_core_changeset( } => { let mut cs = CoreChangeSet::default(); // Inserted records bring fresh UTXOs and may consume previous - // ones — always project. Non-default-account records are tallied - // and surfaced in a single aggregated warn after the loop (rather - // than one warn per record) to keep a busy block quiet. - let mut non_default_count = 0usize; - let mut non_default_sample: Option = None; + // ones — always project. Per-account attribution is resolved by + // the storage layer via the address→account_index lookup over + // `addresses_derived` (forwarded below). for r in inserted { - if non_default_account_index(r).is_some() { - non_default_count += 1; - non_default_sample.get_or_insert(r.txid); - } cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - if non_default_count > 0 { - tracing::warn!( - non_default_count, - sample_txid = ?non_default_sample, - "non-default account UTXO(s) persisted under account_index 0; \ - per-account grouping is approximate" - ); - } // Updated records (re-confirmation, IS-lock applied to a known // mempool tx, etc.) don't usually change UTXO topology — the // record's content does change though, so re-emit it. @@ -509,11 +459,11 @@ mod tests { } /// REGRESSION (fund-loss): a non-default-account (index != 0) UTXO is - /// STILL projected — never dropped. Storage persists it under - /// `account_index 0`; the only cost is approximate per-account grouping - /// (a `warn!` is logged). Dropping it would undercount the balance. + /// projected — never dropped. Storage resolves its account attribution + /// from the derived-address table; dropping it would undercount the + /// balance. #[tokio::test] - async fn non_default_account_utxo_persists_under_zero() { + async fn non_default_account_utxo_persists() { let addr = p2pkh(0x22); let cs = changeset_for(record_with_received_output(standard(7), &addr, 900_000)).await; assert_eq!( From ed5fb7863dbb642950af50f401c834dcf16bc302 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:26:18 +0000 Subject: [PATCH 39/60] feat(platform-wallet)!: 3-state LoadOutcome, report already-registered as skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn LoadOutcome from a struct into a 3-state enum — Loaded (full), Partial (some loaded, some skipped), NoneUsable (rows present, all skipped) — so callers can tell a clean load from one that left wallets behind. Add `impl From for Result` (partial and nothing-usable map to the new PlatformWalletError::LoadIncomplete) per reviewer request, plus loaded()/skipped() accessors. An already-registered wallet (idempotency check, or WalletExists at insert) is now reported as a SkipReason::AlreadyRegistered skip instead of being silently folded into loaded, so the caller can distinguish "not freshly loaded" from a genuine load. FFI: adapt manager.rs to the accessor API and add reason code 300 (already-registered); LoadOutcomeFFI count pair encodes the 3-state. Swift: fix stale-skip-state bug — assign lastLoadSkippedWallets before the throwing check() so a failed load clears prior skip state; map reason code 300. BREAKING CHANGE: LoadOutcome is now an enum; its loaded/skipped fields become accessor methods. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/manager.rs | 37 ++++-- packages/rs-platform-wallet/src/error.rs | 16 +++ .../rs-platform-wallet/src/manager/load.rs | 79 +++++++----- .../src/manager/load_outcome.rs | 119 ++++++++++++++---- .../tests/rehydration_load.rs | 83 +++++++----- .../PlatformWalletManager.swift | 31 +++-- 6 files changed, 256 insertions(+), 109 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 65a110dca70..8e165d2ea18 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -193,6 +193,10 @@ pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199; /// `reason_code`: an unrecognized `SkipReason` — forward-compat /// fallback until this crate maps a newly added skip reason. pub const LOAD_SKIP_REASON_OTHER: u32 = 200; +/// `reason_code`: the wallet was already registered before this load +/// pass reached it (a prior load, or a runtime-created wallet), so its +/// persisted row was not freshly loaded. Not corruption. +pub const LOAD_SKIP_REASON_ALREADY_REGISTERED: u32 = 300; /// One wallet skipped during `load_from_persistor` because its /// persisted row was structurally corrupt (per-row decode failure). @@ -203,14 +207,15 @@ pub const LOAD_SKIP_REASON_OTHER: u32 = 200; pub struct SkippedWalletFFI { /// The (public) 32-byte wallet id that was skipped. pub wallet_id: [u8; 32], - /// Structural skip reason — one of the `LOAD_SKIP_REASON_*` - /// constants: [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100), + /// Skip reason — one of the `LOAD_SKIP_REASON_*` constants: + /// [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100), /// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101), /// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102), /// [`LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH`] (103), - /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), or - /// [`LOAD_SKIP_REASON_OTHER`] (200). No secret material is ever - /// carried. + /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), + /// [`LOAD_SKIP_REASON_OTHER`] (200), or + /// [`LOAD_SKIP_REASON_ALREADY_REGISTERED`] (300). No secret material + /// is ever carried. pub reason_code: u32, } @@ -218,6 +223,10 @@ pub struct SkippedWalletFFI { /// see which wallets loaded and which were skipped (and why) instead /// of the outcome being silently discarded. /// +/// The count pair encodes the Rust `LoadOutcome` 3-state: `skipped_count +/// == 0` is a full load, `loaded_count == 0` with skips is +/// nothing-usable, and both non-zero is a partial load. +/// /// `skipped` is a heap array of length `skipped_count`; pass this /// struct (by pointer) to /// [`platform_wallet_load_outcome_free`] exactly once to release it. @@ -245,6 +254,7 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { // generic corrupt-row code until this mapping is extended. _ => LOAD_SKIP_REASON_CORRUPT_OTHER, }, + platform_wallet::SkipReason::AlreadyRegistered => LOAD_SKIP_REASON_ALREADY_REGISTERED, // `SkipReason` is #[non_exhaustive]; a future reason maps to a // generic skip code until this mapping is extended. _ => LOAD_SKIP_REASON_OTHER, @@ -420,23 +430,26 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( // Never silently drop the outcome: log a structured summary plus // one line per skipped wallet (the host can inspect / clear the - // corrupt rows). + // corrupt rows). The `loaded_count`/`skipped_count` pair below + // encodes the Rust `LoadOutcome` 3-state for the host: skipped == 0 + // is a full load, loaded == 0 with skips is nothing-usable, and both + // non-zero is a partial load. tracing::info!( - loaded = outcome.loaded.len(), - skipped = outcome.skipped.len(), + loaded = outcome.loaded().len(), + skipped = outcome.skipped().len(), "platform_wallet_manager_load_from_persistor complete" ); - for (wid, reason) in &outcome.skipped { + for (wid, reason) in outcome.skipped() { tracing::warn!( wallet_id = %hex::encode(wid), reason = %reason, - "load_from_persistor skipped wallet (corrupt persisted row)" + "load_from_persistor skipped a persisted wallet" ); } if !out_outcome.is_null() { let skipped_vec: Vec = outcome - .skipped + .skipped() .iter() .map(|(wid, reason)| SkippedWalletFFI { wallet_id: *wid, @@ -448,7 +461,7 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( std::ptr::write( out_outcome, LoadOutcomeFFI { - loaded_count: outcome.loaded.len(), + loaded_count: outcome.loaded().len(), skipped_count, skipped: skipped_ptr, }, diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index f7097690a3c..0c6a04142d2 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -23,6 +23,22 @@ pub enum PlatformWalletError { #[error("failed to load persisted client state: {0}")] PersisterLoad(#[from] crate::changeset::PersistenceError), + /// `load_from_persistor` finished without loading every persisted + /// wallet: `loaded_count` loaded and `skipped` were passed over (a + /// corrupt row, or one already registered). Produced only by + /// converting a non-`Loaded` [`LoadOutcome`] via `Result::from`; the + /// load call itself returns the richer [`LoadOutcome`] directly so + /// callers that tolerate a partial load keep the per-wallet detail. + /// + /// [`LoadOutcome`]: crate::manager::load_outcome::LoadOutcome + #[error("persisted wallet load incomplete: {loaded_count} loaded, {} skipped", skipped.len())] + LoadIncomplete { + /// How many wallets loaded before the skips. + loaded_count: usize, + /// The skipped `(wallet_id, reason)` set, in load order. + skipped: Vec<([u8; 32], crate::manager::load_outcome::SkipReason)>, + }, + /// The persisted wallet has UTXOs to restore but no funds-bearing /// account in its reconstructed account collection to hold them. /// Fail-closed rather than reconstructing a silent zero balance — diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 48f5ee66367..b88701c056b 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -36,24 +36,31 @@ impl PlatformWalletManager

{ /// /// # Skip vs hard-fail /// - /// - **Per-row decode/projection failure** (empty manifest, malformed - /// xpub, duplicate `account_type`, …): the wallet is **skipped** — - /// never inserted into `wallet_manager` / `self.wallets`, recorded - /// in [`LoadOutcome::skipped`] with a structural + /// Returns a [`LoadOutcome`] describing the pass: + /// [`Loaded`](LoadOutcome::Loaded) (all wallets loaded), + /// [`Partial`](LoadOutcome::Partial) (some loaded, some skipped), or + /// [`NoneUsable`](LoadOutcome::NoneUsable) (rows present, all skipped). + /// + /// - **Per-row decode failure** (empty manifest, malformed xpub, + /// snapshot/row mismatch, …): the wallet is **skipped** — never + /// inserted into `wallet_manager` / `self.wallets`, recorded in the + /// outcome's skip set with a structural /// [`SkipReason::CorruptPersistedRow`], and /// [`on_wallet_skipped_on_load`](crate::PlatformEventHandler::on_wallet_skipped_on_load) - /// is called on each registered handler. One bad row - /// never aborts the others; the call still returns `Ok`. + /// fires on each registered handler. One bad row never aborts the + /// others; the call still returns `Ok`. + /// - **Already present** (a repeat restore or a runtime-created + /// wallet, detected either at the pre-reconstruction idempotency + /// check or as `WalletExists` at insert): the wallet is **skipped** + /// with [`SkipReason::AlreadyRegistered`] and left untouched — kept + /// out of the rollback set so a later hard-fail never evicts it. A + /// second `load_from_persistor` is therefore idempotent, and the + /// caller can tell an already-present wallet from one freshly loaded. /// - **Whole-load failure** (persister I/O, programmer error, /// registering a persisted wallet in `WalletManager`): /// `Err(_)` — every wallet inserted earlier in this pass is /// rolled back. Skipped wallets never entered the maps so the /// rollback path never sees them. - /// - **Already present** (`WalletExists` from `insert_wallet`, e.g. a - /// repeat restore or a runtime-created wallet): treated as - /// already-satisfied — counted as loaded, left untouched, and kept - /// out of the rollback set so a later hard-fail never evicts it. A - /// second `load_from_persistor` is therefore idempotent. /// /// Platform-address provider state is restored per wallet via /// [`initialize_from_persisted`](crate::wallet::platform_addresses::PlatformAddressWallet::initialize_from_persisted), @@ -88,7 +95,8 @@ impl PlatformWalletManager

{ let mut inserted_in_manager: Vec = Vec::new(); let mut inserted_in_wallets: Vec = Vec::new(); let mut load_error: Option = None; - let mut outcome = LoadOutcome::default(); + let mut loaded: Vec = Vec::new(); + let mut skipped: Vec<(WalletId, SkipReason)> = Vec::new(); // Rows the persister rejected as corrupt before reconstruction // (e.g. a malformed xpub that aborts FFI decode) never reach the @@ -97,7 +105,7 @@ impl PlatformWalletManager

{ for (wallet_id, reason) in persister_skipped { self.event_manager .on_wallet_skipped_on_load(wallet_id, &reason); - outcome.skipped.push((wallet_id, reason)); + skipped.push((wallet_id, reason)); } 'load: for (expected_wallet_id, wallet_state) in wallets { @@ -115,15 +123,20 @@ impl PlatformWalletManager

{ } = wallet_state; // Idempotency, checked FIRST: a wallet already registered (a - // prior load pass, or a runtime create) is already-satisfied. - // Checking before any reconstruction work matters — the - // rebuild below derives eager gap windows (and possibly a - // deep discovery scan), all of which the `WalletExists` arm - // at insert time would only throw away. + // prior load pass, or a runtime create) is not freshly loaded + // by this pass — report it as an `AlreadyRegistered` skip so + // the caller can tell it apart from a genuine load. Checking + // before any reconstruction work matters — the rebuild below + // derives eager gap windows (and possibly a deep discovery + // scan), all of which the `WalletExists` arm at insert time + // would only throw away. { let wm = self.wallet_manager.read().await; if wm.get_wallet(&expected_wallet_id).is_some() { - outcome.loaded.push(expected_wallet_id); + let reason = SkipReason::AlreadyRegistered; + self.event_manager + .on_wallet_skipped_on_load(expected_wallet_id, &reason); + skipped.push((expected_wallet_id, reason)); continue 'load; } } @@ -140,7 +153,7 @@ impl PlatformWalletManager

{ Ok(w) => w, Err(kind) => { let reason = SkipReason::CorruptPersistedRow { kind }; - outcome.skipped.push((expected_wallet_id, reason.clone())); + skipped.push((expected_wallet_id, reason.clone())); self.event_manager .on_wallet_skipped_on_load(expected_wallet_id, &reason); continue 'load; @@ -165,7 +178,7 @@ impl PlatformWalletManager

{ let reason = SkipReason::CorruptPersistedRow { kind: CorruptKind::SnapshotIdentityMismatch, }; - outcome.skipped.push((expected_wallet_id, reason.clone())); + skipped.push((expected_wallet_id, reason.clone())); self.event_manager .on_wallet_skipped_on_load(expected_wallet_id, &reason); continue 'load; @@ -212,14 +225,18 @@ impl PlatformWalletManager

{ match wm.insert_wallet(wallet, platform_info) { Ok(id) => id, Err(key_wallet_manager::WalletError::WalletExists(_)) => { - // Idempotent restore: a prior `load_from_persistor` - // (or a runtime create) already registered this - // wallet. Re-registering must not abort the batch — - // treat it as already-satisfied: record it as loaded - // and continue. It was NOT inserted by this pass, so - // it stays out of the rollback set and a later - // hard-fail never evicts the pre-existing wallet. - outcome.loaded.push(expected_wallet_id); + // Idempotent restore, lost the insert race: a + // concurrent pass (or a runtime create) registered + // this wallet after our pre-reconstruction check. + // Re-registering must not abort the batch — record + // it as an `AlreadyRegistered` skip and continue. It + // was NOT inserted by this pass, so it stays out of + // the rollback set and a later hard-fail never evicts + // the pre-existing wallet. + let reason = SkipReason::AlreadyRegistered; + self.event_manager + .on_wallet_skipped_on_load(expected_wallet_id, &reason); + skipped.push((expected_wallet_id, reason)); continue 'load; } Err(e) => { @@ -267,7 +284,7 @@ impl PlatformWalletManager

{ wallets_guard.insert(wallet_id, platform_wallet); drop(wallets_guard); inserted_in_wallets.push(wallet_id); - outcome.loaded.push(wallet_id); + loaded.push(wallet_id); } if let Some(err) = load_error { @@ -286,7 +303,7 @@ impl PlatformWalletManager

{ return Err(err); } - Ok(outcome) + Ok(LoadOutcome::from_parts(loaded, skipped)) } } diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index 8c0e869c2b8..a2fea9e29e1 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -2,29 +2,38 @@ //! //! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor +use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; -/// Why a persisted wallet row was skipped during a load pass. +/// Why a persisted wallet row was passed over during a load pass. /// /// Load is **watch-only** (no seed material involved): signing keys are /// derived later, on demand, via the `MnemonicResolverHandle` -/// (`rs-sdk-ffi`) sign path. A skip therefore means the persisted row -/// itself was unusable — a per-row decode/structural failure that fails -/// one wallet without aborting the batch. The only reason is -/// [`CorruptPersistedRow`](Self::CorruptPersistedRow): the load path -/// never touches the seed, so it cannot skip for a wrong or unavailable -/// seed. Variants carry no key material (SECRETS.md SEC-REQ-2.0.1). +/// (`rs-sdk-ffi`) sign path. A skip therefore never means a wrong or +/// unavailable seed — the load path never touches one. Either the row +/// itself was unusable ([`CorruptPersistedRow`](Self::CorruptPersistedRow), +/// a per-row decode/structural failure) or the wallet was already +/// registered before this pass reached it +/// ([`AlreadyRegistered`](Self::AlreadyRegistered)). Variants carry no +/// key material (SECRETS.md SEC-REQ-2.0.1). #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum SkipReason { /// The persisted row could not be reconstructed: a structural decode - /// failure on the keyless account manifest or core-state projection. + /// failure on the keyless account manifest or carried snapshot. /// `kind` distinguishes the failure mode without leaking row bytes. #[error("persisted wallet row corrupt: {kind}")] CorruptPersistedRow { /// Structural family of the decode/projection failure. kind: CorruptKind, }, + /// The wallet was already registered before this load pass reached it + /// (a prior load, or a wallet created at runtime), so its persisted + /// row was not freshly loaded. Not corruption — it lets the caller + /// tell an already-present wallet apart from one that genuinely loaded + /// this pass. + #[error("wallet already registered before this load pass")] + AlreadyRegistered, } /// Structural family of [`SkipReason::CorruptPersistedRow`]. @@ -71,19 +80,87 @@ impl std::fmt::Display for CorruptKind { /// Aggregate, synchronous view of one /// [`load_from_persistor`](super::PlatformWalletManager::load_from_persistor) -/// pass. +/// pass that did not hard-fail. /// -/// `Ok(LoadOutcome)` with a non-empty `skipped` is **success** — a -/// per-row decode failure on one wallet is recorded and the batch -/// continues. The `Err` arm is reserved for whole-load failures -/// (persister I/O, programmer error). The load path is watch-only and -/// never touches the seed, so no wrong-seed outcome appears here. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// Three states, so the caller can tell a clean load from one that left +/// wallets behind without conflating either with a whole-load `Err` +/// (persister I/O, programmer error — that stays the `Err` arm of the +/// call). The load path is watch-only and never touches the seed, so no +/// wrong-seed outcome appears here. +/// +/// Convert into a [`Result`] with `Result::from` / `.into()` when an +/// incomplete load should read as a failure: [`Loaded`](Self::Loaded) +/// maps to `Ok`, while [`Partial`](Self::Partial) and +/// [`NoneUsable`](Self::NoneUsable) map to +/// [`PlatformWalletError::LoadIncomplete`]. +#[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] -pub struct LoadOutcome { - /// Wallets fully reconstructed and registered, in load order. - pub loaded: Vec, - /// Wallets skipped because their persisted row was corrupt, in load - /// order. - pub skipped: Vec<(WalletId, SkipReason)>, +pub enum LoadOutcome { + /// Full success: every persisted wallet was reconstructed and + /// registered. Also the empty-store first run — `loaded` is then + /// empty and nothing was skipped. + Loaded { + /// Wallets reconstructed and registered, in load order. + loaded: Vec, + }, + /// Some wallets loaded, at least one was skipped (a corrupt row, or + /// one already registered). The batch did not abort. + Partial { + /// Wallets reconstructed and registered, in load order. + loaded: Vec, + /// Wallets skipped, in load order. + skipped: Vec<(WalletId, SkipReason)>, + }, + /// Nothing usable: the persister returned rows but every one was + /// skipped, so no wallet loaded even though the persister succeeded. + NoneUsable { + /// Wallets skipped, in load order. + skipped: Vec<(WalletId, SkipReason)>, + }, +} + +impl LoadOutcome { + /// Pick the variant matching the `(loaded, skipped)` tallies of a pass. + pub(crate) fn from_parts(loaded: Vec, skipped: Vec<(WalletId, SkipReason)>) -> Self { + match (loaded.is_empty(), skipped.is_empty()) { + (_, true) => Self::Loaded { loaded }, + (true, false) => Self::NoneUsable { skipped }, + (false, false) => Self::Partial { loaded, skipped }, + } + } + + /// Wallets reconstructed and registered this pass, in load order. + pub fn loaded(&self) -> &[WalletId] { + match self { + Self::Loaded { loaded } | Self::Partial { loaded, .. } => loaded, + Self::NoneUsable { .. } => &[], + } + } + + /// Wallets skipped this pass (corrupt row, or already registered). + pub fn skipped(&self) -> &[(WalletId, SkipReason)] { + match self { + Self::Partial { skipped, .. } | Self::NoneUsable { skipped } => skipped, + Self::Loaded { .. } => &[], + } + } +} + +impl From for Result { + /// A partial or nothing-usable load converts to `Err` so a caller + /// using `?` treats an incomplete load as a failure; a full load is + /// `Ok`. + fn from(outcome: LoadOutcome) -> Self { + match outcome { + LoadOutcome::Loaded { .. } => Ok(outcome), + LoadOutcome::Partial { loaded, skipped } => Err(PlatformWalletError::LoadIncomplete { + loaded_count: loaded.len(), + skipped, + }), + LoadOutcome::NoneUsable { skipped } => Err(PlatformWalletError::LoadIncomplete { + loaded_count: 0, + skipped, + }), + } + } } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 4269ed9f576..91abec75491 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -32,7 +32,7 @@ use platform_wallet::error::PlatformWalletError; use platform_wallet::events::{EventHandler, PlatformEventHandler}; use platform_wallet::manager::load_outcome::CorruptKind; use platform_wallet::wallet::platform_wallet::WalletId; -use platform_wallet::{PlatformWalletManager, SkipReason}; +use platform_wallet::{LoadOutcome, PlatformWalletManager, SkipReason}; // ---- test doubles ---- @@ -208,8 +208,8 @@ async fn rt_wo_watch_only_roundtrip() { let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; let outcome = mgr.load_from_persistor().await.expect("Ok"); - assert_eq!(outcome.loaded, vec![id]); - assert!(outcome.skipped.is_empty()); + assert_eq!(outcome.loaded(), vec![id].as_slice()); + assert!(outcome.skipped().is_empty()); assert!( mgr.get_wallet(&id).await.is_some(), "watch-only restored wallet must be registered" @@ -219,9 +219,9 @@ async fn rt_wo_watch_only_roundtrip() { /// RT-Idem: a second `load_from_persistor` with the wallet already /// registered (a repeat restore, or a wallet created at runtime) must be -/// idempotent. `WalletExists` from `insert_wallet` is treated as -/// already-satisfied — counted as loaded — not a fatal `WalletCreation` -/// that aborts the whole batch. +/// idempotent — never a hard error. The already-present wallet is +/// reported as an `AlreadyRegistered` skip (not a fresh load), so the +/// caller can tell it apart from one this pass genuinely loaded. #[tokio::test] async fn rt_idempotent_repeat_restore() { let seed = [0x55; 64]; @@ -235,21 +235,31 @@ async fn rt_idempotent_repeat_restore() { let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; let first = mgr.load_from_persistor().await.expect("first load Ok"); - assert_eq!(first.loaded, vec![id]); - assert!(first.skipped.is_empty()); + assert_eq!(first.loaded(), vec![id].as_slice()); + assert!(first.skipped().is_empty()); + assert!( + matches!(first, LoadOutcome::Loaded { .. }), + "a clean first load is the Loaded variant" + ); - // Second load: the wallet is already registered. Must NOT hard-error. + // Second load: the wallet is already registered. Must NOT hard-error; + // the row is reported as an AlreadyRegistered skip, not freshly loaded. let second = mgr .load_from_persistor() .await .expect("repeat load must be idempotent, not a hard error"); assert!( - second.loaded.contains(&id), - "already-present wallet is reported loaded (already-satisfied)" + !second.loaded().contains(&id), + "an already-registered wallet is not reported as freshly loaded" + ); + assert_eq!( + second.skipped(), + [(id, SkipReason::AlreadyRegistered)].as_slice(), + "the already-present wallet surfaces as an AlreadyRegistered skip" ); assert!( - second.skipped.is_empty(), - "an idempotent re-load is not a skip" + matches!(second, LoadOutcome::NoneUsable { .. }), + "nothing freshly loaded on the repeat pass" ); assert!( mgr.get_wallet(&id).await.is_some(), @@ -288,12 +298,12 @@ async fn rt_persister_skipped_folds_into_outcome() { .expect("Ok despite a persister-rejected row"); assert!( - outcome.loaded.contains(&id_ok), + outcome.loaded().contains(&id_ok), "healthy wallet still loads" ); - assert!(!outcome.loaded.contains(&bad_id)); - assert_eq!(outcome.skipped.len(), 1, "the rejected row surfaces once"); - assert_eq!(outcome.skipped[0], (bad_id, reason.clone())); + assert!(!outcome.loaded().contains(&bad_id)); + assert_eq!(outcome.skipped().len(), 1, "the rejected row surfaces once"); + assert_eq!(outcome.skipped()[0], (bad_id, reason.clone())); assert!(mgr.get_wallet(&id_ok).await.is_some()); assert!( mgr.get_wallet(&bad_id).await.is_none(), @@ -334,10 +344,13 @@ async fn rt_corrupt_row_skipped_and_other_loads() { .await .expect("Ok despite per-row skip"); - assert!(outcome.loaded.contains(&id_a), "A loads fully"); - assert!(!outcome.loaded.contains(&id_b), "B is skipped, not loaded"); - assert_eq!(outcome.skipped.len(), 1); - let (skipped_id, skipped_reason) = &outcome.skipped[0]; + assert!(outcome.loaded().contains(&id_a), "A loads fully"); + assert!( + !outcome.loaded().contains(&id_b), + "B is skipped, not loaded" + ); + assert_eq!(outcome.skipped().len(), 1); + let (skipped_id, skipped_reason) = &outcome.skipped()[0]; assert_eq!(*skipped_id, id_b); assert!(matches!( skipped_reason, @@ -389,7 +402,7 @@ async fn rt_z_secret_hygiene_surfaces() { // 0xAB seed bytes must not appear hex-rendered anywhere. assert!(!dbg.to_lowercase().contains(&"ab".repeat(10))); // The structural skip reason renders without any row bytes. - for (_, reason) in &outcome.skipped { + for (_, reason) in outcome.skipped() { let rendered = format!("{reason} {reason:?}"); assert!(!rendered.to_lowercase().contains(&"ab".repeat(10))); } @@ -529,8 +542,8 @@ async fn rt_snapshot_preserves_attribution_and_pools() { let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; let outcome = mgr.load_from_persistor().await.expect("Ok"); - assert_eq!(outcome.loaded, vec![id]); - assert!(outcome.skipped.is_empty()); + assert_eq!(outcome.loaded(), vec![id].as_slice()); + assert!(outcome.skipped().is_empty()); let rows = { let mgr = Arc::clone(&mgr); @@ -589,9 +602,9 @@ async fn rt_snapshot_wallet_id_mismatch_is_skipped() { let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; let outcome = mgr.load_from_persistor().await.expect("Ok"); - assert!(outcome.loaded.is_empty(), "mismatched row must not load"); - assert_eq!(outcome.skipped.len(), 1); - let (skipped_id, reason) = &outcome.skipped[0]; + assert!(outcome.loaded().is_empty(), "mismatched row must not load"); + assert_eq!(outcome.skipped().len(), 1); + let (skipped_id, reason) = &outcome.skipped()[0]; assert_eq!(*skipped_id, id_a); assert!(matches!( reason, @@ -644,11 +657,11 @@ async fn rt_snapshot_account_set_mismatch_is_skipped() { let outcome = mgr.load_from_persistor().await.expect("Ok"); assert!( - outcome.loaded.is_empty(), + outcome.loaded().is_empty(), "account-set mismatch must not load" ); - assert_eq!(outcome.skipped.len(), 1); - let (skipped_id, reason) = &outcome.skipped[0]; + assert_eq!(outcome.skipped().len(), 1); + let (skipped_id, reason) = &outcome.skipped()[0]; assert_eq!(*skipped_id, id_a); assert!(matches!( reason, @@ -707,9 +720,13 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { .await .expect("Ok despite the per-row snapshot mismatch"); - assert_eq!(outcome.loaded, vec![id_ok], "only the healthy row loads"); - assert_eq!(outcome.skipped.len(), 1); - let (skipped_id, reason) = &outcome.skipped[0]; + assert_eq!( + outcome.loaded(), + vec![id_ok].as_slice(), + "only the healthy row loads" + ); + assert_eq!(outcome.skipped().len(), 1); + let (skipped_id, reason) = &outcome.skipped()[0]; assert_eq!(*skipped_id, id_bad); assert!(matches!( reason, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index df0098c918a..c0b1ff0b2ff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -327,10 +327,10 @@ public class PlatformWalletManager: ObservableObject { // MARK: - Watch-only restore from persister /// One wallet Rust skipped during `load_from_persistor` because its - /// persisted row was structurally corrupt. `reasonCode` is one of the - /// Rust-side `LOAD_SKIP_REASON_*` constants (100 missing manifest, - /// 101 malformed xpub, 102 decode error, 103 snapshot identity - /// mismatch, 199 other corrupt row, 200 other skip); + /// persisted row was skipped. `reasonCode` is one of the Rust-side + /// `LOAD_SKIP_REASON_*` constants (100 missing manifest, 101 malformed + /// xpub, 102 decode error, 103 snapshot identity mismatch, 199 other + /// corrupt row, 200 other skip, 300 already registered); /// [`reasonDescription`] renders it for display. public struct SkippedWalletOnLoad { public let walletId: Data @@ -349,6 +349,7 @@ public class PlatformWalletManager: ObservableObject { case 103: return "snapshot does not match its persisted row" case 199: return "other corrupt row" case 200: return "other skip" + case 300: return "already registered" default: return "unknown skip reason (\(reasonCode))" } } @@ -385,14 +386,18 @@ public class PlatformWalletManager: ObservableObject { var outcome = LoadOutcomeFFI(loaded_count: 0, skipped_count: 0, skipped: nil) let loadResult = platform_wallet_manager_load_from_persistor(handle, &outcome) defer { platform_wallet_load_outcome_free(&outcome) } - try loadResult.check() - // Collect the ids Rust skipped as structurally corrupt. These - // are non-fatal on the Rust side, so they must not reach - // `lastError`: they are surfaced through `lastLoadSkippedWallets` - // and their ids are excluded from the per-id restore loop below - // (a skipped id is still in SwiftData, so `get_wallet` would - // return NotFound for it). + // Collect the ids Rust skipped (a corrupt row, or one already + // registered). These are non-fatal on the Rust side, so they must + // not reach `lastError`: they are surfaced through + // `lastLoadSkippedWallets` and their ids are excluded from the + // per-id restore loop below (a skipped id is still in SwiftData, so + // `get_wallet` would return NotFound for it). + // + // Rust writes `out_outcome` on every path (zeroed on a hard + // failure), so assign `lastLoadSkippedWallets` BEFORE the throwing + // `.check()` — a failed load then clears stale skip state from a + // prior load instead of leaving it behind. var skippedIds = Set() var skippedWallets: [SkippedWalletOnLoad] = [] if let skipped = outcome.skipped { @@ -404,7 +409,7 @@ public class PlatformWalletManager: ObservableObject { let skip = SkippedWalletOnLoad(walletId: walletId, reasonCode: entry.reason_code) skippedWallets.append(skip) NSLog( - "[load-from-persistor] skipped corrupt wallet %@ — %@", + "[load-from-persistor] skipped wallet %@ — %@", walletId.prefix(4).map { String(format: "%02x", $0) }.joined(), skip.reasonDescription ) @@ -412,6 +417,8 @@ public class PlatformWalletManager: ObservableObject { } self.lastLoadSkippedWallets = skippedWallets + try loadResult.check() + // Ask SwiftData for the list of wallet ids we just told Rust // to load. We reuse the same container rather than shipping a // separate FFI "list ids" entry, because SwiftData already is From f8504f62f55f6e95a30ac9d6b02b39b699a9c6ad Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:27:30 +0000 Subject: [PATCH 40/60] docs(platform-wallet): reframe manifest trust boundary, detail the attack Condense the load() trust-boundary note in traits.rs: drop the "known, currently-unaddressed gap" framing and state plainly that manifest- integrity verification is the storage layer's responsibility, out of this trait's scope. Point to the platform-wallet-storage manifest-integrity work rather than re-explaining the mechanism. Expand build_watch_only_wallet's doc with the concrete key-substitution attack: a tampered/untrusted-backup store swaps a valid account_xpub while keeping expected_wallet_id, so the rebuilt wallet derives receive addresses from the attacker's key under the original id and silently redirects incoming funds. State that this crate does not defend against it and cross-reference the traits.rs note. Co-Authored-By: Claude Opus 4.8 --- .../src/changeset/traits.rs | 28 +++++++---------- .../src/manager/rehydrate.rs | 30 ++++++++++++------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 0f14cc1fbf9..dbd2e374c90 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -258,23 +258,17 @@ pub trait PlatformWalletPersistence: Send + Sync { /// /// # Trust boundary /// - /// The returned [`ClientStartState`] — including each wallet's - /// persisted account manifest — is trusted as-is by every consumer of - /// this method. Nothing in this contract cryptographically binds a - /// manifest to the `wallet_id` it's returned under: implementations - /// are not required to authenticate what they hand back, and callers - /// (see `PlatformWalletManager::load_from_persistor` / - /// `build_watch_only_wallet` in the `rehydrate` module) do not - /// independently verify it either. A corrupted or tampered backing - /// store can therefore return a structurally well-formed manifest - /// under the wrong `wallet_id` and it will be accepted silently. - /// - /// This is a known, currently-unaddressed gap — closing it needs a - /// persisted commitment/MAC (or the root xpub) added to the manifest - /// at write time, which is a storage-schema change outside what any - /// implementation of this trait can do on its own. Implementors - /// should not treat the absence of such a check here as a bug in - /// their backend; callers should not assume one is being performed. + /// This trait does not authenticate the [`ClientStartState`] it + /// returns: nothing here binds a wallet's account manifest to the + /// `wallet_id` it is returned under, and this contract neither + /// mandates nor performs such a check. Verifying manifest integrity + /// against a tampered or untrusted-backup store is the storage + /// layer's responsibility (a persisted commitment over the manifest, + /// keyed to the store) — see the manifest-integrity work in the + /// `platform-wallet-storage` crate. Implementors and callers must not + /// assume a manifest handed back here has been verified authentic; + /// `build_watch_only_wallet` in the `rehydrate` module documents the + /// concrete key-substitution risk this leaves open. fn load(&self) -> Result; /// Look up a single core transaction record by `txid` for `wallet_id`. diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 19ea3849775..1d71791d8b8 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -36,16 +36,26 @@ use crate::manager::load_outcome::CorruptKind; /// /// # Trust boundary /// -/// `expected_wallet_id` is stamped in verbatim and is **not** cryptographically -/// bound to the manifest: the id hashes the *root* xpub, but only account-level -/// (hardened, one-way) xpubs are persisted, so the root cannot be recovered to -/// re-derive and verify it. Only structural decode runs here, so a well-formed -/// but wrong xpub (corrupted/tampered store) is accepted and yields receive -/// addresses from the wrong key under the original id — the caller must ensure -/// the persisted manifest for `expected_wallet_id` is authentic. A real binding -/// (a MAC/commitment over `{wallet_id, network, manifest}` keyed to a -/// secure-enclave secret, verified fail-closed on load) needs a storage-schema -/// change and is tracked as a follow-up. +/// `expected_wallet_id` is stamped onto the reconstructed [`Wallet`] +/// verbatim and is **not** cryptographically bound to the manifest: the +/// id hashes the *root* xpub, but only account-level (hardened, one-way) +/// xpubs are persisted, so the root cannot be recovered here to re-derive +/// and verify the id. Only a structural decode runs, so a well-formed but +/// **wrong** `account_xpub` is accepted. +/// +/// Concretely, the attack this leaves open: an attacker who can write to +/// the backing store (or a malicious/rolled-back backup restored into it) +/// substitutes a valid xpub of their own for a wallet's `account_xpub`, +/// leaving `expected_wallet_id` unchanged. The wallet is rebuilt under the +/// original id but now derives its receive addresses from the attacker's +/// key, so future incoming funds are silently redirected — the id looks +/// unchanged to the user while the money flows elsewhere. This crate +/// **does not** defend against it: closing the gap requires the storage +/// layer to authenticate the manifest (a persisted commitment/MAC over +/// `{wallet_id, network, manifest}`, verified fail-closed on load), which +/// is a storage-schema change tracked in the `platform-wallet-storage` +/// crate. See the trust-boundary note on +/// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load). pub(super) fn build_watch_only_wallet( network: Network, expected_wallet_id: [u8; 32], From 157410a7d8f4e98c478c565f27d8e4104fcca74f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:31:41 +0000 Subject: [PATCH 41/60] fix(platform-wallet-ffi): xpub-error classification, overflow guard, doc cleanups - slice_from_raw: reject a host-supplied len > isize::MAX (from_raw_parts bound) with an empty slice instead of risking UB, mirroring decode_cmx_array's guard. - build_wallet_start_state: wrap the Account::from_xpub failure in the MalformedXpubError marker so it classifies as MalformedXpub (101) like the sibling bincode-decode step, not the generic decode error (102). - Replace an iOS-specific "SwiftData" mention with technology-agnostic "corrupt persisted row" wording. - Drop the "(SECRETS.md: ...)" reference on the keyless-handoff comment and strengthen the invariant, naming private keys, unencrypted seeds, and passwords as excluded material. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/persistence.rs | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4988f968044..ef6da6c71ce 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1550,7 +1550,7 @@ impl PlatformWalletPersistence for FFIPersister { } } Err(e) => { - // One corrupt SwiftData row must never abort the whole + // One corrupt persisted row must never abort the whole // restore. Errors from `build_wallet_start_state` are // inherently per-row (decode / projection of THIS entry, // e.g. a malformed account xpub), so record the wallet as @@ -2873,10 +2873,17 @@ fn build_wallet_start_state( let (account_xpub, _): (ExtendedPubKey, usize) = bincode::decode_from_slice(xpub_bytes, config::standard()) .map_err(|e| PersistenceError::backend(MalformedXpubError(e.to_string())))?; + // Same xpub-failure family as the bincode-decode above: a + // well-decoded xpub that `Account::from_xpub` still rejects is a + // malformed key, so mark it so `corrupt_kind_from_build_err` + // classifies it as MalformedXpub (101), not the generic + // decode-error reason code (102). let account = Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network) .map_err(|e| { - PersistenceError::backend(format!("Account::from_xpub failed: {:?}", e)) + PersistenceError::backend(MalformedXpubError(format!( + "Account::from_xpub failed: {e:?}" + ))) })?; accounts.insert(account).map_err(|e| { PersistenceError::backend(format!("AccountCollection::insert failed: {}", e)) @@ -3451,9 +3458,10 @@ fn build_wallet_start_state( let unused_asset_locks = build_unused_asset_locks(entry)?; // Hand the fully-restored `wallet_info` across as the keyless - // snapshot (SECRETS.md: no `Wallet`/seed crosses `load()` — - // `ManagedWalletInfo` carries balances / pools / UTXOs, never key - // material). The manager rebuilds a watch-only wallet from the + // snapshot: no `Wallet`, seed, private key, unencrypted seed, or + // password ever crosses `load()` — `ManagedWalletInfo` carries only + // balances / pools / UTXOs, never key material. The manager rebuilds + // a watch-only wallet from the // manifest via `Wallet::new_watch_only` and consumes this snapshot // directly, so everything the decode blocks above restored survives // verbatim: per-account UTXO and tx-record attribution (including @@ -3974,13 +3982,17 @@ fn is_legacy_removed_account_tag(type_tag: u8) -> bool { /// Read `len` bytes from a Swift-owned pointer as a `&[u8]`. /// +/// A host-supplied `len` exceeding `isize::MAX` (the `from_raw_parts` +/// bound) is treated as a corrupt length and yields an empty slice rather +/// than being handed to `from_raw_parts` (UB). +/// /// # Safety /// /// `ptr` must point to at least `len` valid bytes for the duration of /// the callback. Caller holds the callback window open via /// `LoadGuard`. unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] { - if ptr.is_null() || len == 0 { + if ptr.is_null() || len == 0 || len > isize::MAX as usize { &[] } else { slice::from_raw_parts(ptr, len) From f4ab467ba678f89917e6914a15fdbfd2fd3504ec Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:43:59 +0000 Subject: [PATCH 42/60] test(platform-wallet): rollback, empty-first-run, and concurrency regressions - hard_fail_rolls_back_already_loaded_healthy_wallet (in-crate, since the mid-batch hard-fail needs pub(crate) platform-address types + bimap): a platform-address restore failure on a later wallet must evict an already-inserted healthy wallet from both self.wallets and wallet_manager and return Err. Nothing covered this before. - rt_empty_first_run_is_clean_with_no_handler_calls: a fresh install (default ClientStartState, no rows) loads as LoadOutcome::Loaded with empty loaded/skipped and zero skip-handler calls. - rt_concurrent_loads_register_each_wallet_exactly_once: two concurrent load passes over a shared multi-wallet snapshot both succeed, each wallet registers exactly once, and overlaps surface as AlreadyRegistered skips (exercising the check-then-act idempotency window). Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet/src/manager/load.rs | 170 ++++++++++++++++++ .../tests/rehydration_load.rs | 97 ++++++++++ 2 files changed, 267 insertions(+) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index b88701c056b..c7252bc29fa 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -352,3 +352,173 @@ fn snapshot_accounts_match_manifest( .collect(); manifest_types == snapshot_types } + +#[cfg(test)] +mod rollback_tests { + //! In-crate because constructing the mid-batch hard-fail needs + //! `PerAccountPlatformAddressState` + `bimap::BiBTreeMap`, which are + //! not reachable from the external integration-test crate. + + use std::sync::Arc; + + use bimap::BiBTreeMap; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::wallet::Wallet; + + use crate::changeset::{ + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::error::PlatformWalletError; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::platform_wallet::WalletId; + use crate::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; + use crate::{PlatformAddressSyncStartState, PlatformWalletManager}; + + fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { + let wallet = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let id = wallet.compute_wallet_id(); + let account_manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + ( + id, + ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest, + core_wallet_info: Box::new(info), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }, + ) + } + + /// Two healthy wallets plus a platform-address restore state for the + /// second that references an account index the wallet does not have + /// as a platform-payment account, so `initialize_from_persisted` + /// hard-fails mid-batch. Rebuilt fresh on every `load` — the platform + /// address state types are not `Clone`. + struct RollbackPersister { + healthy_seed: [u8; 64], + fail_seed: [u8; 64], + } + + impl PlatformWalletPersistence for RollbackPersister { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + let mut st = ClientStartState::default(); + let (healthy_id, healthy) = slice(self.healthy_seed); + let (fail_id, fail) = slice(self.fail_seed); + let bogus_xpub = fail.account_manifest[0].account_xpub; + st.wallets.insert(healthy_id, healthy); + st.wallets.insert(fail_id, fail); + + // A per-account restore entry keyed to an account index the + // wallet has no platform-payment account for: `from_persisted` + // returns `AddressSync`, which the manager treats as a hard + // failure (not a per-row skip). + let mut per_account = PerWalletPlatformAddressState::new(); + per_account.insert( + 9_999, + PerAccountPlatformAddressState::from_persisted( + bogus_xpub, + BiBTreeMap::new(), + std::collections::BTreeMap::new(), + ), + ); + st.platform_addresses.insert( + fail_id, + PlatformAddressSyncStartState { + per_account, + sync_height: 0, + sync_timestamp: 0, + last_known_recent_block: 0, + }, + ); + Ok(st) + } + } + + struct NoopHandler; + impl EventHandler for NoopHandler {} + impl PlatformEventHandler for NoopHandler {} + + /// A hard failure part-way through a batch must roll back every wallet + /// already inserted this pass — from BOTH `self.wallets` and + /// `wallet_manager` — and surface as `Err`. Nothing asserted this + /// before. + #[tokio::test] + async fn hard_fail_rolls_back_already_loaded_healthy_wallet() { + // Order the seeds so the healthy wallet sorts BEFORE the failing + // one (wallets load in BTreeMap key order), guaranteeing the + // healthy wallet is fully inserted before the failure aborts. + let (id_a, _) = slice([0xA1; 64]); + let (id_b, _) = slice([0xB2; 64]); + let (healthy_seed, fail_seed) = if id_a < id_b { + ([0xA1; 64], [0xB2; 64]) + } else { + ([0xB2; 64], [0xA1; 64]) + }; + let (healthy_id, _) = slice(healthy_seed); + + let persister = Arc::new(RollbackPersister { + healthy_seed, + fail_seed, + }); + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let handler: Arc = Arc::new(NoopHandler); + let mgr = Arc::new(PlatformWalletManager::new(sdk, persister, handler)); + + let err = mgr + .load_from_persistor() + .await + .expect_err("a mid-batch hard failure must surface as Err"); + assert!( + matches!(err, PlatformWalletError::WalletCreation(_)), + "a platform-address restore failure is a WalletCreation hard error, got {err:?}" + ); + + // The healthy wallet was fully inserted, then rolled back on the + // failure — gone from self.wallets... + assert!( + mgr.get_wallet(&healthy_id).await.is_none(), + "the already-loaded healthy wallet must be evicted from self.wallets" + ); + assert!( + mgr.wallet_ids().await.is_empty(), + "self.wallets must be fully rolled back" + ); + // ...and from wallet_manager (read via the blocking accessor). + let rows = { + let mgr = Arc::clone(&mgr); + tokio::task::spawn_blocking(move || mgr.account_balances_blocking(&healthy_id)) + .await + .unwrap() + }; + assert!( + rows.is_empty(), + "the already-loaded healthy wallet must be evicted from wallet_manager too" + ); + } +} diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 91abec75491..9bd81b40f2e 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -796,3 +796,100 @@ async fn rt_persister_load_permanent_error_is_typed_and_not_retryable() { other => panic!("expected PersisterLoad, got {other:?}"), } } + +/// RT-EmptyFirstRun: a genuinely fresh install — the persister returns +/// `ClientStartState::default()` with no rows at all — loads cleanly as +/// `LoadOutcome::Loaded` with empty loaded/skipped and fires zero skip +/// handlers. This is the condition every first launch hits. +#[tokio::test] +async fn rt_empty_first_run_is_clean_with_no_handler_calls() { + // `FixedLoadPersister::new()` without `set()` returns the default + // (empty) ClientStartState from `load()`. + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr + .load_from_persistor() + .await + .expect("an empty store must load cleanly"); + + assert!( + matches!(outcome, LoadOutcome::Loaded { .. }), + "an empty store is a full (Loaded) outcome, not partial/none-usable" + ); + assert!( + outcome.loaded().is_empty(), + "nothing to load on a fresh install" + ); + assert!( + outcome.skipped().is_empty(), + "nothing to skip on a fresh install" + ); + assert!(mgr.wallet_ids().await.is_empty(), "no wallets registered"); + assert!( + h.skipped.lock().unwrap().is_empty(), + "no skip handler fires on a fresh install" + ); +} + +/// RT-Concurrent: two concurrent `load_from_persistor` passes against the +/// same manager and multi-wallet snapshot. Both must complete without +/// error, every wallet must register exactly once (the idempotency check +/// releases its read lock before the write-lock insert — a check-then-act +/// window), and the outcomes must be consistent: each wallet is freshly +/// loaded by exactly one pass and reported `AlreadyRegistered` by the +/// other (never a corruption skip, never a hard error). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rt_concurrent_loads_register_each_wallet_exactly_once() { + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id1, s1) = slice([0xC1; 64]); + let (id2, s2) = slice([0xC2; 64]); + let mut st = ClientStartState::default(); + st.wallets.insert(id1, s1); + st.wallets.insert(id2, s2); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let m1 = Arc::clone(&mgr); + let m2 = Arc::clone(&mgr); + let t1 = tokio::spawn(async move { m1.load_from_persistor().await }); + let t2 = tokio::spawn(async move { m2.load_from_persistor().await }); + let o1 = t1.await.unwrap().expect("first concurrent load Ok"); + let o2 = t2.await.unwrap().expect("second concurrent load Ok"); + + // Each wallet registered exactly once, in both maps. + assert!(mgr.get_wallet(&id1).await.is_some()); + assert!(mgr.get_wallet(&id2).await.is_some()); + assert_eq!( + mgr.wallet_ids().await.len(), + 2, + "each wallet registered exactly once despite the concurrent passes" + ); + + // Every wallet is freshly loaded by exactly one of the two passes. + let mut loaded_union: std::collections::BTreeSet = + o1.loaded().iter().copied().collect(); + for id in o2.loaded() { + assert!( + loaded_union.insert(*id), + "a wallet must be freshly loaded by at most one pass" + ); + } + assert!( + loaded_union.contains(&id1) && loaded_union.contains(&id2), + "both wallets loaded across the two passes" + ); + + // Any overlap surfaces as an AlreadyRegistered skip, never corruption. + for outcome in [&o1, &o2] { + for (_, reason) in outcome.skipped() { + assert_eq!( + *reason, + SkipReason::AlreadyRegistered, + "a concurrent overlap is an AlreadyRegistered skip, not a corrupt row" + ); + } + } +} From e65c1231069f4d5f445fefdd5317b749fbcc112a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:49:16 +0000 Subject: [PATCH 43/60] test(platform-wallet-ffi): cover slice_from_raw guard and end-to-end malformed-xpub Add a unit test for slice_from_raw's isize::MAX overflow guard, asserting a non-null pointer with an over-bound length yields an empty slice without a dereference. Add an end-to-end test that drives build_wallet_start_state with a well-formed buffer that is not a decodable ExtendedPubKey and asserts the failure classifies as CorruptKind::MalformedXpub, covering the reclassification at its real call site rather than only the classification helper in isolation. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/persistence.rs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index ef6da6c71ce..3262f5f6058 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -4188,6 +4188,76 @@ mod tests { ); } + /// A `len` past `isize::MAX` is a corrupt host-supplied length — + /// handing it to `from_raw_parts` would be UB, so the guard yields an + /// empty slice. The pointer is non-null but never dereferenced: the + /// guard short-circuits before any read. + #[test] + fn slice_from_raw_rejects_overflowing_len() { + let ptr = std::ptr::NonNull::::dangling().as_ptr() as *const u8; + let slice = unsafe { slice_from_raw(ptr, isize::MAX as usize + 1) }; + assert_eq!( + slice, + &[] as &[u8], + "a len exceeding isize::MAX must yield an empty slice, not touch the pointer" + ); + } + + /// End-to-end coverage of the malformed-xpub classification at its + /// real call site: `build_wallet_start_state` fed a well-formed + /// buffer that is not a decodable `ExtendedPubKey` must surface a + /// `MalformedXpub` (code 101), not the generic decode-error family. + #[test] + fn build_wallet_start_state_malformed_xpub_classifies_as_malformed() { + let bad_xpub: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; + let spec = AccountSpecFFI { + type_tag: AccountTypeTagFFI::Standard as u8, + standard_tag: StandardAccountTypeTagFFI::Bip44 as u8, + index: 0, + registration_index: 0, + key_class: 0, + user_identity_id: [0u8; 32], + friend_identity_id: [0u8; 32], + account_xpub_bytes: bad_xpub.as_ptr(), + account_xpub_bytes_len: bad_xpub.len(), + }; + let entry = WalletRestoreEntryFFI { + wallet_id: [0u8; 32], + network: FFINetwork::Testnet, + accounts: &spec, + accounts_count: 1, + platform_address_balances: std::ptr::null(), + platform_address_balances_count: 0, + platform_sync_height: 0, + platform_sync_timestamp: 0, + platform_last_known_recent_block: 0, + identities: std::ptr::null(), + identities_count: 0, + birth_height: 0, + synced_height: 0, + last_processed_height: 0, + last_synced: 0, + utxos: std::ptr::null(), + utxos_count: 0, + tracked_asset_locks: std::ptr::null(), + tracked_asset_locks_count: 0, + unresolved_asset_lock_tx_records: std::ptr::null(), + unresolved_asset_lock_tx_records_count: 0, + core_address_pools: std::ptr::null(), + core_address_pools_count: 0, + last_applied_chain_lock_bytes: std::ptr::null(), + last_applied_chain_lock_bytes_len: 0, + }; + + let err = build_wallet_start_state(&entry) + .expect_err("a malformed account xpub must fail the rebuild"); + assert_eq!( + corrupt_kind_from_build_err(&err), + CorruptKind::MalformedXpub, + "a malformed account xpub must classify as MalformedXpub (code 101) end-to-end" + ); + } + /// Regression: restored pool addresses must be tagged with the /// WALLET's network, not the network the base58 string parses as. /// Devnet shares testnet's base58 prefixes, so a devnet wallet's From 518c8d13b06cb127cbbca41e99cfb25d2a8e47b0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:49:30 +0000 Subject: [PATCH 44/60] refactor(platform-wallet): drop latent LoadIncomplete conversion and dead rehydration variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove PlatformWalletError::LoadIncomplete and the `From for Result` impl: nothing in-tree drives the `.into()`/`?` conversion — callers read LoadOutcome via loaded()/skipped() directly — so the impl was confusing latent API surface that also erased the skip-reason distinction between a harmless already-registered repeat and genuine corruption. Reintroduce a typed incomplete-load error only when a real caller needs it. LoadOutcome/load rustdoc now point callers at the skip reasons instead of the removed conversion. Also delete the three dead rehydration error variants (RehydrationTopologyUnsupported, RehydrationPoolMismatch, RehydrationPoolTypeMismatch) — no constructors remain in the workspace — and the AddressPoolType import they were the only users of. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/error.rs | 67 ------------------- .../rs-platform-wallet/src/manager/load.rs | 8 ++- .../src/manager/load_outcome.rs | 30 ++------- 3 files changed, 11 insertions(+), 94 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 0c6a04142d2..9983890801f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -2,7 +2,6 @@ use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::identifier::Identifier; use key_wallet::account::StandardAccountType; -use key_wallet::managed_account::address_pool::AddressPoolType; use key_wallet::Network; /// Errors that can occur in platform wallet operations @@ -23,72 +22,6 @@ pub enum PlatformWalletError { #[error("failed to load persisted client state: {0}")] PersisterLoad(#[from] crate::changeset::PersistenceError), - /// `load_from_persistor` finished without loading every persisted - /// wallet: `loaded_count` loaded and `skipped` were passed over (a - /// corrupt row, or one already registered). Produced only by - /// converting a non-`Loaded` [`LoadOutcome`] via `Result::from`; the - /// load call itself returns the richer [`LoadOutcome`] directly so - /// callers that tolerate a partial load keep the per-wallet detail. - /// - /// [`LoadOutcome`]: crate::manager::load_outcome::LoadOutcome - #[error("persisted wallet load incomplete: {loaded_count} loaded, {} skipped", skipped.len())] - LoadIncomplete { - /// How many wallets loaded before the skips. - loaded_count: usize, - /// The skipped `(wallet_id, reason)` set, in load order. - skipped: Vec<([u8; 32], crate::manager::load_outcome::SkipReason)>, - }, - - /// The persisted wallet has UTXOs to restore but no funds-bearing - /// account in its reconstructed account collection to hold them. - /// Fail-closed rather than reconstructing a silent zero balance — - /// the no-silent-zero mandate. Carries only the (public) wallet id - /// and the dropped-UTXO count, never key material. - #[error( - "rehydration topology unsupported for wallet {}: {utxo_count} persisted UTXO(s) but no funds-bearing account", - hex::encode(wallet_id) - )] - RehydrationTopologyUnsupported { - /// The wallet whose topology could not hold the persisted UTXOs. - wallet_id: [u8; 32], - /// How many persisted UTXOs would have been silently dropped. - utxo_count: usize, - }, - - /// The deep-index discovery probes did not mirror the account's real - /// address pools 1:1 during rehydration, so applying probe depths by - /// position would index the wrong pool. Fail-closed instead of risking - /// a misattributed derivation — the probes are built directly from the - /// same `address_pools()` enumeration, so a mismatch is a structural - /// invariant break, not user-reachable. - #[error( - "rehydration pool/probe mismatch: expected {expected} address pool(s) to mirror the discovery probes, found {found}" - )] - RehydrationPoolMismatch { - /// Number of discovery probes built from `address_pools()`. - expected: usize, - /// Number of real address pools from `address_pools_mut()`. - found: usize, - }, - - /// During rehydration a discovery probe and the real address pool it maps - /// to **by position** disagreed on `pool_type`, so applying the probe's - /// discovered depth would target the wrong chain. Fail-closed rather than - /// misattribute a derivation depth. The probes are built from the same - /// `address_pools()` enumeration, so a mismatch is a structural invariant - /// break, not user-reachable. - #[error( - "rehydration pool/probe chain-order mismatch at position {position}: real pool is {found:?} but probe is {expected:?}" - )] - RehydrationPoolTypeMismatch { - /// Index into the account's address-pool list where the mismatch was found. - position: usize, - /// The probe's pool type (discovery order). - expected: AddressPoolType, - /// The real pool's pool type at the same position. - found: AddressPoolType, - }, - #[error("Wallet not found: {0}")] WalletNotFound(String), diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c7252bc29fa..a0d1ab20b6d 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -54,8 +54,12 @@ impl PlatformWalletManager

{ /// check or as `WalletExists` at insert): the wallet is **skipped** /// with [`SkipReason::AlreadyRegistered`] and left untouched — kept /// out of the rollback set so a later hard-fail never evicts it. A - /// second `load_from_persistor` is therefore idempotent, and the - /// caller can tell an already-present wallet from one freshly loaded. + /// second `load_from_persistor` therefore mutates no state and returns + /// `Ok(LoadOutcome)` — a repeat where every wallet is already + /// registered is a [`NoneUsable`](LoadOutcome::NoneUsable) no-op, not a + /// failure — and the caller can tell an already-present wallet from one + /// freshly loaded via [`loaded`](LoadOutcome::loaded) / + /// [`skipped`](LoadOutcome::skipped). /// - **Whole-load failure** (persister I/O, programmer error, /// registering a persisted wallet in `WalletManager`): /// `Err(_)` — every wallet inserted earlier in this pass is diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index a2fea9e29e1..6c284544e72 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -2,7 +2,6 @@ //! //! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor -use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; /// Why a persisted wallet row was passed over during a load pass. @@ -88,11 +87,11 @@ impl std::fmt::Display for CorruptKind { /// call). The load path is watch-only and never touches the seed, so no /// wrong-seed outcome appears here. /// -/// Convert into a [`Result`] with `Result::from` / `.into()` when an -/// incomplete load should read as a failure: [`Loaded`](Self::Loaded) -/// maps to `Ok`, while [`Partial`](Self::Partial) and -/// [`NoneUsable`](Self::NoneUsable) map to -/// [`PlatformWalletError::LoadIncomplete`]. +/// Inspect the outcome via [`loaded`](Self::loaded) / [`skipped`](Self::skipped): +/// the skip reasons are what separate a harmless repeat +/// ([`AlreadyRegistered`](SkipReason::AlreadyRegistered)) from genuine +/// corruption ([`CorruptPersistedRow`](SkipReason::CorruptPersistedRow)) — a +/// distinction a flat `Result` shape would erase. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum LoadOutcome { @@ -145,22 +144,3 @@ impl LoadOutcome { } } } - -impl From for Result { - /// A partial or nothing-usable load converts to `Err` so a caller - /// using `?` treats an incomplete load as a failure; a full load is - /// `Ok`. - fn from(outcome: LoadOutcome) -> Self { - match outcome { - LoadOutcome::Loaded { .. } => Ok(outcome), - LoadOutcome::Partial { loaded, skipped } => Err(PlatformWalletError::LoadIncomplete { - loaded_count: loaded.len(), - skipped, - }), - LoadOutcome::NoneUsable { skipped } => Err(PlatformWalletError::LoadIncomplete { - loaded_count: 0, - skipped, - }), - } - } -} From 3abc3858f0b49871b22e64c86b0f81e21560aa9f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:49:40 +0000 Subject: [PATCH 45/60] refactor(platform-wallet): make manager::rehydrate module private The module's only non-test item is `pub(super) fn build_watch_only_wallet`, used solely from `manager::load`, so it exposes nothing externally usable. Narrow the declaration to `mod rehydrate;`, matching the private sibling `mod load;` / `mod wallet_lifecycle;`. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/manager/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 57fa3784348..4438cd8934a 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -5,7 +5,7 @@ pub mod identity_sync; mod load; pub mod load_outcome; pub mod platform_address_sync; -pub mod rehydrate; +mod rehydrate; #[cfg(feature = "shielded")] pub mod shielded_sync; mod wallet_lifecycle; From 83d9effc555d1646195da071fa083dfb6922eb44 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:06:06 +0000 Subject: [PATCH 46/60] fix(dpp): drop stale extended_public_key impls broken by rust-dashcore afcff156 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three FixedKeySigner test mocks implemented key_wallet::signer::Signer's extended_public_key, which afcff156 moved into a separate ExtendedPubKeySigner subtrait — the same break already fixed in rs-sdk-ffi's mnemonic_resolver_core_signer.rs, missed here because the post-merge verification was scoped to rs-platform-wallet/-ffi/rs-sdk-ffi and never ran a workspace-wide check. Nothing in rs-dpp calls .extended_public_key() on these mocks or requires ExtendedPubKeySigner, so the method is dead weight, not a missing impl — deleted outright along with the now-unused ExtendedPubKey imports. Co-Authored-By: Claude Opus 4.5 --- packages/rs-dpp/src/state_transition/mod.rs | 11 +---------- .../signing_tests.rs | 11 +---------- .../signing_tests.rs | 11 +---------- 3 files changed, 3 insertions(+), 30 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 74ebc13c977..92bf4a4e583 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -3335,7 +3335,7 @@ mod tests { use dashcore::secp256k1::{ ecdsa, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey, }; - use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; + use key_wallet::bip32::DerivationPath; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory signer used only by this test. Mirrors how a @@ -3370,15 +3370,6 @@ mod tests { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } - - async fn extended_public_key( - &self, - _path: &DerivationPath, - ) -> Result { - // Test stub holds a single raw key with no chain code; extended - // public key derivation is not meaningful here. - Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) - } } // Generate a single random key. Using the same key on both sides is diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs index b2c4fb67f83..67f95088409 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs @@ -204,7 +204,7 @@ async fn try_from_asset_lock_with_signer_and_private_key_signs_multiple_inputs() async fn try_from_asset_lock_with_signers_produces_matching_signature() { use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message}; - use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; + use key_wallet::bip32::DerivationPath; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how the @@ -237,15 +237,6 @@ async fn try_from_asset_lock_with_signers_produces_matching_signature() { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } - - async fn extended_public_key( - &self, - _path: &DerivationPath, - ) -> Result { - // Test stub holds a single raw key with no chain code; extended - // public key derivation is not meaningful here. - Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) - } } let secp = Secp256k1::new(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 5ea8e633b3a..c31e3b1fc1e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -33,7 +33,7 @@ use platform_version::version::PlatformVersion; use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; -use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; +use key_wallet::bip32::DerivationPath; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how a @@ -77,15 +77,6 @@ impl KwSigner for FixedKeySigner { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } - - async fn extended_public_key( - &self, - _path: &DerivationPath, - ) -> Result { - // Test stub holds a single raw key with no chain code; extended - // public key derivation is not meaningful here. - Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) - } } fn make_chain_asset_lock_proof() -> AssetLockProof { From 0af850ab86ab1a299ae7a145f6c7b9db9f12fac8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:31:33 +0000 Subject: [PATCH 47/60] refactor(platform-wallet): drop dead identity_keys/contacts from ClientWalletStartState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FFI/iOS load path reconstructs identity PUBLIC keys and DashPay contacts directly into the managed identities via build_wallet_identity_bucket, so ClientWalletStartState.identity_keys and .contacts were always Default::default() on that path — dead surface that fed apply_contacts_and_keys a no-op. Remove both fields together (the load-path call took them in one call): - ClientWalletStartState: drop both fields + trim the import. - manager/load.rs: drop the destructure bindings, the apply_contacts_and_keys call, and the now-redundant `mut`. - persistence.rs (FFI): drop the two Default initializers; correct the stale doc claiming iOS carries no contacts back — contacts are restored inline via restore_dashpay_contacts as of #3841. - Fill the compile-forced test literals. Keep the ContactChangeSet/IdentityKeysChangeSet types and the apply_* methods — still used by the live runtime replay path (PlatformWalletInfo::apply_changeset). Add regression test TC-3692-011: the restored identity's CRITICAL/AUTH/ ECDSA signing key is selectable via get_first_public_key_matching immediately after load with zero sync, guarding post-load Platform signing against this removal. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/persistence.rs | 16 +-- .../changeset/client_wallet_start_state.rs | 13 +- .../rs-platform-wallet/src/manager/load.rs | 14 +- .../tests/rehydration_load.rs | 122 +++++++++++++++++- 4 files changed, 125 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4ee70f488f8..52b50123cbd 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3556,16 +3556,10 @@ fn build_wallet_start_state( }) .collect(); - // `contacts` / `identity_keys` are the PR-3 keyless feed the - // manager layers onto the managed identities via - // `apply_contacts_and_keys`. The iOS path does NOT use them: - // identity PUBLIC keys are already reconstructed straight into - // `Identity.public_keys` by `build_wallet_identity_bucket` (feeding - // the slot too would double-apply), and `WalletRestoreEntryFFI` - // carries no contacts back from Swift on load — surfacing them - // would need a new cross-boundary struct field + Swift wiring, - // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` - // a no-op for this path, preserving the established iOS behaviour. + // Identity PUBLIC keys and DashPay contacts are already restored + // into `identity_manager` by `build_wallet_identity_bucket` + // (contacts inline via `restore_dashpay_contacts`), so the load + // path carries no separate keyless contact/key feed. let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, @@ -3573,8 +3567,6 @@ fn build_wallet_start_state( core_wallet_info: Box::new(wallet_info), identity_manager, unused_asset_locks, - contacts: Default::default(), - identity_keys: Default::default(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index df2e63c0381..ecb115ace8c 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; -use crate::changeset::{AccountRegistrationEntry, ContactChangeSet, IdentityKeysChangeSet}; +use crate::changeset::AccountRegistrationEntry; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -55,15 +55,4 @@ pub struct ClientWalletStartState { /// top-up, keyed by account index → outpoint. Terminal `Consumed` /// rows are already filtered out by the asset-lock reader. pub unused_asset_locks: BTreeMap>, - /// Persisted DashPay contact state (sent/received requests + - /// established contacts) to layer onto the rehydrated managed - /// identities. PUBLIC material — `removed_*` are always empty - /// (deletes never reach storage as rows). Routed by the manager - /// after `IdentityManager::from`, mirroring the runtime apply path. - pub contacts: ContactChangeSet, - /// Persisted per-identity PUBLIC key entries (no private key - /// material) to layer onto the rehydrated managed identities so - /// `Identity.public_keys` is populated at load time instead of - /// only after the next sync. `removed` is always empty. - pub identity_keys: IdentityKeysChangeSet, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index da447a8266b..91eed9fbd67 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -122,8 +122,6 @@ impl PlatformWalletManager

{ core_wallet_info, identity_manager, unused_asset_locks, - contacts, - identity_keys, } = wallet_state; // Idempotency, checked FIRST: a wallet already registered (a @@ -211,12 +209,10 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); - // Build the identity manager from the (id, balance, - // revision) skeleton, then layer the persisted PUBLIC - // contacts + identity keys onto it — the same routing the - // runtime changeset-replay path uses. - let mut identity_manager = IdentityManager::from(identity_manager); - identity_manager.apply_contacts_and_keys(contacts, identity_keys, network); + // Build the identity manager from the snapshot; public keys + // and contacts are already reconstructed into it upstream by + // the FFI persister (`build_wallet_identity_bucket`). + let identity_manager = IdentityManager::from(identity_manager); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), @@ -413,8 +409,6 @@ mod rollback_tests { core_wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), }, ) } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 9bd81b40f2e..f4fcabca2d0 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -20,18 +20,21 @@ //! deep pool addresses survive the reload; a snapshot whose //! `wallet_id` mismatches its row is skipped as corrupt. +use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; +use dpp::prelude::Identifier; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use platform_wallet::changeset::{ - AccountRegistrationEntry, ClientStartState, ClientWalletStartState, PersistenceError, - PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, IdentityManagerStartState, + PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::error::PlatformWalletError; use platform_wallet::events::{EventHandler, PlatformEventHandler}; use platform_wallet::manager::load_outcome::CorruptKind; use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::ManagedIdentity; use platform_wallet::{LoadOutcome, PlatformWalletManager, SkipReason}; // ---- test doubles ---- @@ -77,8 +80,6 @@ impl PlatformWalletPersistence for FixedLoadPersister { core_wallet_info: w.core_wallet_info.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), }, ); } @@ -177,8 +178,6 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { core_wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), }, ) } @@ -893,3 +892,114 @@ async fn rt_concurrent_loads_register_each_wallet_exactly_once() { } } } + +/// Persister for TC-3692-011. Rebuilds — fresh on each `load()`, since the +/// identity-manager snapshot is intentionally not `Clone` — a +/// `ClientStartState` carrying one wallet-owned identity whose +/// `Identity.public_keys` already holds a CRITICAL / AUTHENTICATION / +/// ECDSA_SECP256K1 key, the shape the FFI/iOS path produces via +/// `build_wallet_identity_bucket` (never through an `IdentityKeysChangeSet`). +struct IdentityKeyedLoadPersister { + seed: [u8; 64], + identity_id: Identifier, +} + +impl PlatformWalletPersistence for IdentityKeyedLoadPersister { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::{IdentityPublicKey, Purpose}; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, KeyType, SecurityLevel}; + + let (id, mut s) = slice(self.seed); + + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let mut public_keys = BTreeMap::new(); + public_keys.insert(0, key); + let identity = Identity::V0(IdentityV0 { + id: self.identity_id, + public_keys, + balance: 0, + revision: 0, + }); + + let mut inner = BTreeMap::new(); + inner.insert(0u32, ManagedIdentity::new(identity, 0)); + let mut ims = IdentityManagerStartState::default(); + ims.wallet_identities.insert(id, inner); + s.identity_manager = ims; + + let mut st = ClientStartState::default(); + st.wallets.insert(id, s); + Ok(st) + } +} + +/// TC-3692-011 [CRITICAL]: the restored identity's signing key is +/// selectable via the exact `get_first_public_key_matching` predicate the +/// real signing path uses (`contract.rs`, `dpns.rs`, ...) immediately +/// after `load_from_persistor`, with zero sync. The FFI/iOS path populates +/// `Identity.public_keys` at construction, so dropping +/// `ClientWalletStartState.identity_keys` must not change this — the key +/// must sit in the exact map slot the lookup reads, with no reliance on +/// `apply_identity_key_entry` ever running on the load path. +#[tokio::test] +async fn rt_signing_key_selectable_immediately_after_load() { + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::Purpose; + use dpp::identity::{KeyType, SecurityLevel}; + + let seed = [0x5A; 64]; + let identity_id = Identifier::from([0x11; 32]); + let p = Arc::new(IdentityKeyedLoadPersister { seed, identity_id }); + let h = Arc::new(RecordingHandler::default()); + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let mgr = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&p), h)); + + let outcome = mgr.load_from_persistor().await.expect("Ok"); + assert!( + outcome.skipped().is_empty(), + "identity row must not be skipped" + ); + let id = outcome + .loaded() + .first() + .copied() + .expect("exactly one wallet loaded"); + + // Immediately — no SPV sync, no identity refresh — drive the exact + // predicate a real signing flow uses to pick its authentication key. + let wallet = mgr.get_wallet(&id).await.expect("wallet registered"); + let wm = wallet.wallet_manager().read().await; + let info = wm.get_wallet_info(&id).expect("wallet info present"); + let managed = info + .identity_manager + .identity(&identity_id) + .expect("identity restored into the manager"); + let selected = managed + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .expect("CRITICAL/AUTH/ECDSA signing key must be selectable post-load, zero sync"); + assert_eq!(selected.id(), 0, "the exact installed key is selected"); +} From 29a3853944694df933646618deb27f447d75b79f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:45:12 +0000 Subject: [PATCH 48/60] docs(platform-wallet): correct apply_contacts_and_keys + FFI build comments post field-removal - apply_contacts_and_keys rustdoc: the method has a single caller (the runtime changeset-replay path) now that the load_from_persistor caller was removed with ClientWalletStartState.contacts/identity_keys; drop the stale "shared with the persister rehydration path" claim. - persistence.rs: trim the ClientWalletStartState build comment to the internal-comment length budget. Doc-only; no logic change. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 3 +-- .../src/wallet/identity/state/manager/apply.rs | 13 ++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 52b50123cbd..a93cb6bee2d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3558,8 +3558,7 @@ fn build_wallet_start_state( // Identity PUBLIC keys and DashPay contacts are already restored // into `identity_manager` by `build_wallet_identity_bucket` - // (contacts inline via `restore_dashpay_contacts`), so the load - // path carries no separate keyless contact/key feed. + // (contacts inline via `restore_dashpay_contacts`). let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index db2fe21d7e0..705f1863bd4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -205,18 +205,13 @@ impl IdentityManager { } /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the - /// already-restored managed identities. - /// - /// Single source of truth for the contact / identity-key routing — - /// shared by the runtime changeset-replay path - /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)) - /// and the persister rehydration path - /// ([`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)). + /// already-restored managed identities, for the runtime + /// changeset-replay path + /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)). /// Identity keys are applied first so a contact entry never lands /// before its owner's keys; orphan entries (owner not in the /// wallet) are logged and skipped, never fatal. `removed_*` and - /// `ignored`/`unignored` are honoured for the replay path; the - /// rehydration feed leaves them empty. + /// `ignored`/`unignored` are honoured. pub(crate) fn apply_contacts_and_keys( &mut self, contacts: ContactChangeSet, From d6b789e8650f6c52e5ceda23c4b1a2c60c47cf21 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:47:05 +0000 Subject: [PATCH 49/60] docs(platform-wallet-ffi): mark missing end-to-end build_wallet_identity_bucket test in source QA flagged that the end-to-end restore-coverage gap was disclosed only in prose. Per our TODO convention, mark it at the call site: no single test drives build_wallet_identity_bucket with all four restore_* categories at once; each helper is unit-tested in isolation and the calls are presence-checked. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index a93cb6bee2d..e806f875936 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3769,6 +3769,10 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { From adec4b7c488df47ea60d61524cc6dd238dd7820d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:51:06 +0000 Subject: [PATCH 50/60] docs(platform-wallet-ffi): enrich build_wallet_identity_bucket TODO with mitigation detail Spell out in the source marker why the end-to-end test is deferred (needs a heavy raw-pointer WalletRestoreEntryFFI fixture) and how the gap is mitigated today: the restore_* + build_identity_public_keys calls are grep-verified present and in order, and each restore_* has a passing per-helper unit test. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index e806f875936..98ee8fe337e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3770,9 +3770,9 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { From a814932d970f61730d32e5d991644f775476828d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:17:14 +0000 Subject: [PATCH 51/60] fix(platform-wallet): fund asset-lock test fixtures via confirmed UTXO, not mempool rust-dashcore#836 ("don't build asset locks on unconfirmed funds") added TransactionBuilder::require_final_inputs(), which retains only is_confirmed || is_instantlocked UTXOs before coin selection in build_asset_lock/build_asset_lock_with_signer. Correct behavior: funds that are only in the mempool can't have an InstantSend lock yet, so an asset lock built on them would just 300s-timeout on Platform. funded_wallet_manager funded its test wallet via TransactionContext::Mempool, so its sole UTXO was unconfirmed. Since the 647fa98 rust-dashcore bump pulled in #836, the confirmation filter empties the candidate set and 3 asset_lock::build tests fail in setup with "Coin selection error: No UTXOs available for selection" before ever reaching the broadcast-rejection paths they exist to exercise. Fund via TransactionContext::InBlock instead, matching the confirmed-funds idiom already used in rust-dashcore's own tests. No production code changed; core::broadcast's tests (the fixture's other consumer) also verified green. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/test_support.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 4b7f525cd24..33423afec0a 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -10,11 +10,11 @@ use std::sync::Arc; use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1}; -use dashcore::{Network, Transaction, Txid}; +use dashcore::{BlockHash, Network, Transaction, Txid}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::test_utils::TestWalletContext; -use key_wallet::transaction_checking::TransactionContext; +use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::{DerivationPath, Wallet}; use key_wallet_manager::WalletManager; use tokio::sync::RwLock; @@ -154,10 +154,15 @@ pub(crate) async fn funded_wallet_manager( } }; + // Funded as an already-mined (confirmed) UTXO, not mempool: since + // rust-dashcore#836, asset-lock building excludes unconfirmed/non- + // InstantSend inputs (`require_final_inputs`), so a mempool-only fixture + // would starve `build_asset_lock`'s coin selection before ever reaching + // the broadcast-rejection paths these tests exist to exercise. let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); - let result = ctx - .check_transaction(&funding_tx, TransactionContext::Mempool) - .await; + let block = + TransactionContext::InBlock(BlockInfo::new(600_000, BlockHash::dummy(0), 1_700_000_000)); + let result = ctx.check_transaction(&funding_tx, block).await; assert!( result.is_relevant, "funding tx should be relevant to {account_type:?}" From e9e98cc6784362169fa4b24f83e438a9ac08e19d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:21:12 +0000 Subject: [PATCH 52/60] feat(platform-wallet): deliver typed AddressNonceMismatch error to callers The optimistic address-nonce path submits `fetched + 1` by design and relies on the caller retrying when Platform rejects a stale/replayed value under a lagging DAPI replica read (Found-033). rs-sdk carries the typed `AddressInvalidNonceError` (consensus code 40603) end-to-end intact, but platform-wallet flattened it to a string at its error-mapping seams, so callers could not recover `expected_nonce` to honor their half of the optimistic-concurrency contract. Add a public `as_address_invalid_nonce` extractor (mirroring `as_asset_lock_proof_cl_height_too_low`, matching both the `Protocol(ConsensusError)` and `StateTransitionBroadcastError` SDK shapes), a first-class `PlatformWalletError::AddressNonceMismatch` variant carrying `{address, provided_nonce, expected_nonce}`, and a `promote_address_nonce_error` helper. Rewire the lossy string-collapse seams (shield `enrich`, `broadcast_shielded_spend`, `classify_spend_wait_failure`, identity `top_up_from_addresses`) to promote via the helper when it matches, else keep today's exact string fallback. rs-sdk is untouched. The plain transfer/withdrawal paths already preserve the typed error via `PlatformWalletError::Sdk`, from which the now-public extractor can recover the nonce. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/error.rs | 163 ++++++++++++++++++ .../identity/network/top_up_from_addresses.rs | 10 +- .../src/wallet/shielded/operations.rs | 9 +- 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index e8c3a10a594..60589a53c39 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,6 +1,8 @@ use dpp::address_funds::PlatformAddress; +use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::fee::Credits; use dpp::identifier::Identifier; +use dpp::prelude::AddressNonce; use key_wallet::account::StandardAccountType; use key_wallet::Network; @@ -114,6 +116,26 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), + /// Platform rejected an address-funds transition because a spent + /// address's provided nonce did not equal Platform's expected next value + /// (DPP consensus code 40603, `AddressInvalidNonceError`): the optimistic + /// `fetched + 1` nonce raced a lagging DAPI replica's stale read. + /// + /// The rejection is by design — the address-nonce path is optimistic and + /// the caller owns the retry. This variant delivers Platform's + /// `expected_nonce` verbatim so the caller can rebuild the transition with + /// it (no re-fetch needed) instead of parsing a flattened string. + #[error( + "Address nonce mismatch for {address}: submitted nonce {provided_nonce}, \ + Platform expected {expected_nonce}; retry the operation with the \ + expected nonce" + )] + AddressNonceMismatch { + address: PlatformAddress, + provided_nonce: AddressNonce, + expected_nonce: AddressNonce, + }, + #[error("Address sync failed: {0}")] AddressSync(String), @@ -409,3 +431,144 @@ pub fn as_asset_lock_proof_cl_height_too_low( _ => None, } } + +/// Extract the `AddressInvalidNonceError` (DPP consensus code 40603) from an +/// SDK error if Platform rejected an address-funds transition because a spent +/// address's provided nonce did not equal its expected next value. +/// +/// Returns `Some(&error)` for both `dash_sdk::Error` shapes that carry a +/// consensus verdict — `StateTransitionBroadcastError` (wait-stream rejection) +/// and `Protocol(ProtocolError::ConsensusError)` (CheckTx rejection) — +/// exposing `address()`, `provided_nonce()`, and `expected_nonce()`. Returns +/// `None` for everything else. +/// +/// The address-nonce path is optimistic by design: the client submits +/// `fetched + 1` and Platform rejects a stale/replayed value under a lagging +/// replica read. This extractor lets a caller recover `expected_nonce` and +/// retry per that contract. Mirrors [`as_asset_lock_proof_cl_height_too_low`]; +/// the same coverage caveat applies — re-audit if a future `dash_sdk::Error` +/// variant starts carrying consensus errors through a different shape. +pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + + let consensus_error = match error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + }; + match consensus_error { + Some(ConsensusError::StateError(StateError::AddressInvalidNonceError(e))) => Some(e), + _ => None, + } +} + +/// Promote a nonce-rejection SDK error to the typed +/// [`PlatformWalletError::AddressNonceMismatch`] so callers can recover +/// `expected_nonce` and retry, instead of receiving the rejection flattened +/// to a string. +/// +/// Returns `None` for any error [`as_address_invalid_nonce`] does not match, +/// leaving the caller free to keep its existing fallback mapping. +pub fn promote_address_nonce_error(error: &dash_sdk::Error) -> Option { + as_address_invalid_nonce(error).map(|e| PlatformWalletError::AddressNonceMismatch { + address: *e.address(), + provided_nonce: e.provided_nonce(), + expected_nonce: e.expected_nonce(), + }) +} + +#[cfg(test)] +mod address_nonce_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + + const ADDR_BYTES: [u8; 20] = [7u8; 20]; + + /// An `AddressInvalidNonceError` wrapped as a `ConsensusError`, plus the + /// address it names, for asserting round-trip field fidelity. + fn nonce_consensus_error( + provided: AddressNonce, + expected: AddressNonce, + ) -> (PlatformAddress, dpp::consensus::ConsensusError) { + let address = PlatformAddress::P2pkh(ADDR_BYTES); + let err = AddressInvalidNonceError::new(address, provided, expected); + (address, err.into()) + } + + /// `Protocol(ConsensusError)` — the CheckTx-rejection shape. + fn protocol_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { + let (_, cause) = nonce_consensus_error(provided, expected); + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) + } + + /// `StateTransitionBroadcastError` — the wait-stream-rejection shape. + fn broadcast_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { + let (_, cause) = nonce_consensus_error(provided, expected); + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 40603, + message: "invalid address nonce".to_string(), + cause: Some(cause), + }) + } + + #[test] + fn extracts_nonce_error_from_protocol_shape() { + let err = protocol_shape(1, 2); + let got = as_address_invalid_nonce(&err).expect("protocol shape must match"); + assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(got.provided_nonce(), 1); + assert_eq!(got.expected_nonce(), 2); + } + + #[test] + fn extracts_nonce_error_from_broadcast_shape() { + let err = broadcast_shape(5, 6); + let got = as_address_invalid_nonce(&err).expect("broadcast shape must match"); + assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(got.provided_nonce(), 5); + assert_eq!(got.expected_nonce(), 6); + } + + #[test] + fn ignores_unrelated_and_causeless_errors() { + // A plainly unrelated SDK error. + assert!(as_address_invalid_nonce(&dash_sdk::Error::Generic("boom".to_string())).is_none()); + // The DAPI wait-timeout shape: a broadcast error with no consensus + // cause must NOT be misread as a nonce rejection. + let causeless = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 0, + message: "timeout".to_string(), + cause: None, + }); + assert!(as_address_invalid_nonce(&causeless).is_none()); + } + + #[test] + fn promotes_both_shapes_to_typed_variant() { + for err in [protocol_shape(1, 2), broadcast_shape(1, 2)] { + match promote_address_nonce_error(&err) { + Some(PlatformWalletError::AddressNonceMismatch { + address, + provided_nonce, + expected_nonce, + }) => { + assert_eq!(address, PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(provided_nonce, 1); + assert_eq!(expected_nonce, 2); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } + } + + #[test] + fn promotion_leaves_unrelated_errors_for_the_fallback() { + assert!( + promote_address_nonce_error(&dash_sdk::Error::Generic("boom".to_string())).is_none() + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index da15c81cd8d..35f79e8ac37 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -76,10 +76,12 @@ impl IdentityWallet { .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity from addresses: {}", - e - )) + crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity from addresses: {}", + e + )) + }) })?; // Update the identity's balance in the local manager and diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9fe357f431e..0c0b6020d41 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -556,7 +556,8 @@ pub async fn shield, P: OrchardPr format_addresses_with_info(rich.addresses_with_info(), network), )) } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + crate::error::promote_address_nonce_error(e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) } }; @@ -2335,7 +2336,8 @@ async fn broadcast_shielded_spend( match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { - return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + return Err(crate::error::promote_address_nonce_error(&e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); } Err(e) => { warn!( @@ -2443,7 +2445,8 @@ fn classify_spend_wait_failure( wait_err: &dash_sdk::Error, ) -> PlatformWalletError { if carries_consensus_rejection(wait_err) { - PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string()) + crate::error::promote_address_nonce_error(wait_err) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) } else { warn!( operation, From 650c32a8125139783560e8508122edfad735a7a1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:48:08 +0000 Subject: [PATCH 53/60] fix(platform-wallet-ffi): deliver AddressNonceMismatch across FFI + recurse nonce extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-001 (HIGH): the AddressNonceMismatch variant added at the wallet layer had no FFI mapping, so it both regressed and under-delivered at the C boundary hosts actually consume. - Regression: a shield nonce-rejection that used to surface as `ErrorShieldedBroadcastFailed` (code 16 — definitively failed, notes released, safe to retry) fell through `map_spend_result`'s catch-all to `ErrorWalletOperation` (code 6), dropping the retry-safety contract. - Fix: add a dedicated `ErrorAddressNonceMismatch = 21` result code (an additive enum slot) carrying the SAME definitively-failed / notes- released / safe-to-retry contract, and map to it in BOTH `map_spend_result` (covers shield/unshield/transfer/withdraw) AND the blanket `From` (covers identity `top_up_from_addresses`, which propagates via `?`/`.into()`). Hosts can now recognize the self- healing nonce race and retry — the retry re-fetches the address nonce. Nonce values are NOT exposed as structured out-fields: the shared `PlatformWalletFFIResult` is returned by value and mirrored by every language binding, so adding fields (or send-function out-params) would be an ABI-breaking change for all consumers. The submitted/expected nonces still travel in the result `message` (typed Display), and an FFI retry re-fetches the nonce anyway, so the practical loss is nil. QA-002 (LOW): `as_address_invalid_nonce` now recurses through `dash_sdk::Error::NoAvailableAddressesToRetry(inner)`, matching its sibling predicate `broadcast_definitely_failed` so the two stay in lockstep (latent today — the retry envelope never wraps a ConsensusError yet). Tests: AddressNonceMismatch -> ErrorAddressNonceMismatch via both the blanket From and map_spend_result; extractor unwraps the retry envelope. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 55 +++++++++++++++++++ .../src/shielded_send.rs | 34 ++++++++++++ packages/rs-platform-wallet/src/error.rs | 26 ++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cec0966b9c2..869428b14bf 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -145,6 +145,20 @@ pub enum PlatformWalletFFIResultCode { /// observing the transaction reconciles the outcome. The host must NOT /// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`]. ErrorTransactionBroadcastUnconfirmed = 20, + /// Maps `PlatformWalletError::AddressNonceMismatch`. Platform rejected an + /// address-funds transition (shield, or identity top-up-from-addresses) + /// because the submitted address nonce raced Platform's expected next + /// value (a lagging DAPI replica stale read; consensus code 40603). Same + /// definitively-failed / notes-released / safe-to-retry contract as + /// [`Self::ErrorShieldedBroadcastFailed`] — the transition did NOT execute + /// and any note reservations were released (a shield reserves none) — but + /// as its OWN code so hosts can recognize this specific, self-healing + /// failure and retry: the retry re-fetches the address nonce, resolving + /// the mismatch without host intervention. The submitted and Platform- + /// expected nonce values travel in the result `message` (the typed + /// `Display`); they are not exposed as structured out-fields (that would + /// require an ABI-breaking change to `PlatformWalletFFIResult`). + ErrorAddressNonceMismatch = 21, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -285,6 +299,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + // A definitively-failed address-nonce race (this reaches the blanket + // impl via identity `top_up_from_addresses`, which propagates the + // error through `?`/`.into()`). Keep the dedicated code so the host + // can recognize the self-healing failure and retry; the nonce values + // survive in the Display message. + PlatformWalletError::AddressNonceMismatch { .. } => { + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -664,6 +686,39 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` + /// FFI code through the blanket `From` impl (the path identity + /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening + /// to `ErrorUnknown`. The typed Display rendering — carrying the submitted + /// and expected nonce values — survives across the boundary as the message. + #[test] + fn address_nonce_mismatch_maps_to_dedicated_code() { + let err = PlatformWalletError::AddressNonceMismatch { + address: dpp::address_funds::PlatformAddress::P2pkh([7u8; 20]), + provided_nonce: 1, + expected_nonce: 2, + }; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + "AddressNonceMismatch should map to ErrorAddressNonceMismatch (rendered: {rendered})" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary verbatim" + ); + // The nonce values the host needs must be present in the message. + assert!( + msg.contains('1') && msg.contains('2'), + "nonce values must survive in the message" + ); + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 94fbbf1269a..7e89e681557 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -516,6 +516,15 @@ fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, format!("{operation} failed: {e}"), ), + // Definitively failed on an address-nonce race (a shield spends platform + // address funds; a shield reserves no notes). Its own code carries the + // safe-to-retry contract AND lets the host recognize the self-healing + // nonce mismatch — a plain retry re-fetches the nonce. Without this arm + // it would regress to the generic `ErrorWalletOperation` below. + Err(e @ PlatformWalletError::AddressNonceMismatch { .. }) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + format!("{operation} failed: {e}"), + ), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1438,4 +1447,29 @@ mod tests { PlatformWalletFFIResultCode::Success ); } + + /// A shield (Type 15) definitively rejected on an address-nonce race must + /// map to the dedicated `ErrorAddressNonceMismatch` — NOT regress to the + /// generic `ErrorWalletOperation` — so hosts keep the safe-to-retry signal. + /// The submitted/expected nonce values must survive in the message. + #[test] + fn map_spend_result_maps_address_nonce_mismatch_to_dedicated_code() { + let mismatch: Result<(), PlatformWalletError> = + Err(PlatformWalletError::AddressNonceMismatch { + address: PlatformAddress::P2pkh([7u8; 20]), + provided_nonce: 1, + expected_nonce: 2, + }); + let result = map_spend_result(mismatch, "shielded shield"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + "shield nonce rejection must not regress to ErrorWalletOperation" + ); + let msg = message_of(&result); + assert!( + msg.contains('1') && msg.contains('2'), + "nonce values must survive in the message" + ); + } } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 60589a53c39..3c50a6eafae 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -445,9 +445,11 @@ pub fn as_asset_lock_proof_cl_height_too_low( /// The address-nonce path is optimistic by design: the client submits /// `fetched + 1` and Platform rejects a stale/replayed value under a lagging /// replica read. This extractor lets a caller recover `expected_nonce` and -/// retry per that contract. Mirrors [`as_asset_lock_proof_cl_height_too_low`]; -/// the same coverage caveat applies — re-audit if a future `dash_sdk::Error` -/// variant starts carrying consensus errors through a different shape. +/// retry per that contract. Recurses through +/// [`dash_sdk::Error::NoAvailableAddressesToRetry`] so it stays in lockstep +/// with its sibling `broadcast_definitely_failed`; re-audit if a future +/// `dash_sdk::Error` variant starts carrying consensus errors through yet +/// another shape. pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; @@ -457,6 +459,12 @@ pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInval broadcast_err.cause.as_ref() } dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + // A consensus rejection can arrive wrapped when the dapi-client + // exhausted every address mid-retry; recurse so this predicate stays + // in lockstep with `broadcast_definitely_failed`. + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { + return as_address_invalid_nonce(inner) + } _ => None, }; match consensus_error { @@ -571,4 +579,16 @@ mod address_nonce_tests { promote_address_nonce_error(&dash_sdk::Error::Generic("boom".to_string())).is_none() ); } + + #[test] + fn extracts_nonce_error_wrapped_in_no_available_addresses_to_retry() { + // The dapi-client wraps the last rejection in `NoAvailableAddressesToRetry` + // when every address is exhausted mid-retry; the extractor must recurse + // into it (lockstep with `broadcast_definitely_failed`). + let inner = Box::new(protocol_shape(9, 10)); + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(inner); + let got = as_address_invalid_nonce(&wrapped).expect("must unwrap the retry envelope"); + assert_eq!(got.provided_nonce(), 9); + assert_eq!(got.expected_nonce(), 10); + } } From 069ee6a7935f44801d3b78d899ef07444d34205a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:35 +0000 Subject: [PATCH 54/60] fix(swift-sdk): mirror ErrorAddressNonceMismatch (=21); pin FFI nonce test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-R2 #1 (MEDIUM): the Swift host binding — the only in-repo host — stopped at result code 20, so the new ErrorAddressNonceMismatch (=21) collapsed to `.errorUnknown` → `.unknown`, and the typed nonce-race fix never reached the shipped host. Mirror it in PlatformWalletResult.swift: add `case errorAddressNonceMismatch = 21` to `PlatformWalletResultCode`, its `init(ffi:)` arm (matching the cbindgen constant `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH`), a dedicated `PlatformWalletError.addressNonceMismatch(String)` case, and the `init(result:)` mapping — mirroring `errorShieldedBroadcastFailed`'s definitively-failed / notes-released / safe-to-retry semantics. Swift compilation was NOT verified: the cbindgen-generated header + DashSDKFFI module are produced by build_ios.sh (iOS toolchain, unavailable here); the change is pattern-exact against the existing arms and the `prefix_with_name + ScreamingSnakeCase` cbindgen config. QA-R2 #2 (LOW): the two new nonce-value FFI tests asserted bare digits (`contains('1') && contains('2')`), which a provided/expected transposition would pass. Pin the exact rendered substrings instead (`submitted nonce 1` / `Platform expected 2`) so a transposition fails. Per the ABI decision (keep dedicated-code-only), document at the blanket `From` mapping arm that structured provided/expected nonce out-fields are intentionally out of scope — PlatformWalletFFIResult is by-value/ABI-frozen; the values travel in the message and an FFI retry re-fetches the nonce. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 22 ++++++++++++------- .../src/shielded_send.rs | 10 +++++++-- .../PlatformWallet/PlatformWalletResult.swift | 20 +++++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 869428b14bf..5bad1aa9183 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -299,11 +299,12 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } - // A definitively-failed address-nonce race (this reaches the blanket - // impl via identity `top_up_from_addresses`, which propagates the - // error through `?`/`.into()`). Keep the dedicated code so the host - // can recognize the self-healing failure and retry; the nonce values - // survive in the Display message. + // A definitively-failed address-nonce race (reaches the blanket impl + // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing + // provided/expected nonce as structured out-fields is INTENTIONALLY + // out of scope: `PlatformWalletFFIResult` is by-value / ABI-frozen, so + // the values travel in the message string and an FFI retry re-fetches + // the nonce. PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } @@ -712,10 +713,15 @@ mod tests { msg, rendered, "Display payload must survive the FFI boundary verbatim" ); - // The nonce values the host needs must be present in the message. + // Pin the EXACT rendered substrings, not bare digits, so a + // provided/expected transposition would fail the test. assert!( - msg.contains('1') && msg.contains('2'), - "nonce values must survive in the message" + msg.contains("submitted nonce 1"), + "submitted (provided) nonce must render exactly: {msg}" + ); + assert!( + msg.contains("Platform expected 2"), + "expected nonce must render exactly: {msg}" ); } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 7e89e681557..dbdead34c63 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1467,9 +1467,15 @@ mod tests { "shield nonce rejection must not regress to ErrorWalletOperation" ); let msg = message_of(&result); + // Pin the EXACT rendered substrings, not bare digits, so a + // provided/expected transposition would fail the test. assert!( - msg.contains('1') && msg.contains('2'), - "nonce values must survive in the message" + msg.contains("submitted nonce 1"), + "submitted (provided) nonce must render exactly: {msg}" + ); + assert!( + msg.contains("Platform expected 2"), + "expected nonce must render exactly: {msg}" ); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 9676ee38143..a38ba25a027 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -53,6 +53,14 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// of double-spending; the reservation TTL or a sync reconciles the /// outcome. Do NOT auto-retry. case errorTransactionBroadcastUnconfirmed = 20 + /// Definitively-failed address-nonce race: Platform rejected an + /// address-funds transition (shield, or identity top-up-from-addresses) + /// because the submitted address nonce raced Platform's expected value + /// (a lagging DAPI replica read). The transition did NOT execute and any + /// notes were released (a shield reserves none) — safe to retry; the retry + /// re-fetches the nonce and self-heals. The submitted/expected nonce values + /// travel in the message string, not as structured fields. + case errorAddressNonceMismatch = 21 case notFound = 98 case errorUnknown = 99 @@ -100,6 +108,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedNoRecordedAnchor case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_UNCONFIRMED: self = .errorTransactionBroadcastUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: + self = .errorAddressNonceMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -208,6 +218,13 @@ public enum PlatformWalletError: LocalizedError { /// reservation TTL or a later sync reconciles the outcome. Do NOT /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. case transactionBroadcastUnconfirmed(String) + /// Definitively-failed address-nonce race (shield, or identity + /// top-up-from-addresses): Platform rejected the transition because the + /// submitted address nonce raced its expected value. The transition did + /// NOT execute and any notes were released (a shield reserves none) — safe + /// to retry, and the retry re-fetches the address nonce so the mismatch + /// self-heals. The submitted/expected nonce values are in the message. + case addressNonceMismatch(String) case notFound(String) case unknown(String) @@ -225,6 +242,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), + .addressNonceMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -258,6 +276,8 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) case .errorTransactionBroadcastUnconfirmed: self = .transactionBroadcastUnconfirmed(detail) + case .errorAddressNonceMismatch: + self = .addressNonceMismatch(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From 062caeaad99a5ef24f4027ca16a447da41333557 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:09 +0000 Subject: [PATCH 55/60] chore(deps): bump rust-dashcore pin to key-wallet asset-lock override branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the 8 rust-dashcore workspace deps at dashpay/rust-dashcore#850 (feat/key-wallet-asset-lock-explicit-utxo-override @ d42081e7), which adds an explicit-UTXO-override escape hatch for asset-lock funding. Updates the one build_asset_lock_with_signer call site in this branch for the new optional override parameter (passed as None — this branch doesn't use the override). TODO(pin) left in Cargo.toml: bump again once #850 merges to dev. Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 24 +++++++++---------- Cargo.toml | 18 +++++++------- .../src/wallet/asset_lock/build.rs | 1 + 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a46c8e326b..8a84ef48aab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 3fbbd5c2552..d82c7f08039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,16 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } +# TODO(pin): tracks dashpay/rust-dashcore#850 (draft) — asset-lock explicit +# UTXO override. Bump to the merged `dev` SHA once that PR lands. +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 5a3444a3159..89a1fa9a7c0 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -98,6 +98,7 @@ impl AssetLockManager { vec![funding], DEFAULT_FEE_PER_KB, signer, + None, ) .await .map_err(|e| { From a985d2359a3744194e9a5c074f7384ba8d1ecc95 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:33:02 +0000 Subject: [PATCH 56/60] chore(deps): switch rust-dashcore pin from PR #850 to PR #851 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #850 (asset-lock explicit UTXO override) and PR #851 (key-wallet out-of-order UTXO spend fix, #649) are disjoint branches off `dev`. Re-point the pin at #851's head, which this crate's watch-only rehydration work actually needs. The only call site depending on #850's API (`build_asset_lock_with_signer`'s `override_utxo` param) was passing `None` — a no-op default — so dropping the argument to match #851's 5-arg signature is a pure mechanical revert, no behavior change. Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 46 +++++++++---------- Cargo.toml | 21 +++++---- .../src/wallet/asset_lock/build.rs | 1 - 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a84ef48aab..2752f46c379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode", "dashcore-private", @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" [[package]] name = "glob" @@ -3803,7 +3803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "bincode", @@ -4596,7 +4596,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5698,7 +5698,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6489,7 +6489,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6502,7 +6502,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6561,7 +6561,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7421,7 +7421,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8870,7 +8870,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d82c7f08039..7fcb00ef8f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,16 +50,17 @@ members = [ ] [workspace.dependencies] -# TODO(pin): tracks dashpay/rust-dashcore#850 (draft) — asset-lock explicit -# UTXO override. Bump to the merged `dev` SHA once that PR lands. -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +# TODO(pin): tracks dashpay/rust-dashcore#851 (draft) — key-wallet +# out-of-order UTXO spend fix (#649). Bump to the merged `dev` SHA once +# that PR lands. +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 89a1fa9a7c0..5a3444a3159 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -98,7 +98,6 @@ impl AssetLockManager { vec![funding], DEFAULT_FEE_PER_KB, signer, - None, ) .await .map_err(|e| { From b70062bd4f3689857e0d3ae00da778fa36c6e543 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:17:40 +0000 Subject: [PATCH 57/60] test(swift-sdk): replace removed sendToAddresses with builder+broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ManagedCoreWallet.sendToAddresses` is gone; three integration tests still called it and would fail to compile under `-warnings-as-errors`. Route them through the canonical `CoreTransactionBuilder` → `buildSigned` → `broadcastTransaction` flow via a shared `TestWalletWrapper.send(...)` helper (BIP44 account 0), mirroring `SendViewModel`'s `.coreToCore` case. Test intent is preserved verbatim: the two `waitForNewTxid` sites keep discarding the result, and the persister-restart test derives its txid from the signed tx's consensus-serialized `data` — the same `sha256d(tx_bytes)` identity it asserted before. Co-Authored-By: Claude Opus 4.8 --- .../Core/CoreSendIntegrationTests.swift | 4 +--- ...RestartClassificationIntegrationTests.swift | 6 ++---- .../Core/SpvRestartIntegrationTests.swift | 4 +--- .../Support/TestWallet.swift | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift index 00ce6de6c4e..f22cdaea95f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift @@ -32,9 +32,7 @@ final class CoreSendIntegrationTests: IntegrationTestCase { let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress() let beforeTxids = try await readTxids() - _ = try sender.getCoreWallet().sendToAddresses( - recipients: [(address: recipientAddress, amountDuffs: amount)] - ) + try sender.send(toAddress: recipientAddress, amountDuffs: amount) guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { XCTFail("send PersistentTransaction row never appeared on iteration \(i)") return diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift index b5d264bc16f..8202ae9d403 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift @@ -19,10 +19,8 @@ final class PersisterRestartClassificationIntegrationTests: IntegrationTestCase try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) let aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress() - let sendTxData = try alice.getCoreWallet().sendToAddresses( - recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)] - ) - let sendTxid = Self.txid(ofRawTx: sendTxData) + let signedTx = try alice.send(toAddress: aliceSecondAddr, amountDuffs: sendAmount) + let sendTxid = Self.txid(ofRawTx: signedTx.data) try await waitForTxRow(sendTxid) // First sighting must already classify as Internal / -fee. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift index ed823c40681..f9ba3a80b7e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift @@ -64,9 +64,7 @@ final class SpvRestartIntegrationTests: IntegrationTestCase { let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress() let beforeTxids = try await readTxids() - _ = try sender.getCoreWallet().sendToAddresses( - recipients: [(address: recipientAddress, amountDuffs: amount)] - ) + try sender.send(toAddress: recipientAddress, amountDuffs: amount) guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { XCTFail("send PersistentTransaction row never appeared") return diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift index 7c20954cb20..c72aa154e27 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift @@ -28,6 +28,24 @@ final class TestWalletWrapper { try wallet.balance().spendable == duffs } } + + /// Build, sign, and broadcast a single-recipient send from BIP44 account 0, + /// mirroring `SendViewModel`'s `.coreToCore` flow (the SDK no longer exposes + /// a one-shot `sendToAddresses`). Returns the signed transaction so callers + /// can read its consensus-serialized `data` or derive the broadcast txid. + @discardableResult + func send(toAddress address: String, amountDuffs: UInt64) throws -> CoreTransaction { + let builder = try CoreTransactionBuilder(network: core.network()) + try builder.addOutput(address: address, amountDuffs: amountDuffs) + try builder.setFunding(wallet: wallet, accountType: .bip44, accountIndex: 0) + let signedTx = try builder.buildSigned( + wallet: wallet, + accountType: .bip44, + accountIndex: 0 + ) + _ = try core.broadcastTransaction(signedTx) + return signedTx + } } enum Wait { From dc77493086c3b1422708f51ca57e80d959a092a8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:17:51 +0000 Subject: [PATCH 58/60] fix(swift-sdk): stop funding core-to-core sends from a Platform-Payment index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For every non-`platformToPlatform` flow, `senderAccountIndex` was derived from key-class-0 Platform Payment accounts (`addressBalances.filter { keyClass == 0 }`) and then fed — for `.coreToCore` — into `CoreTransactionBuilder.setFunding(accountType: .bip44, accountIndex:)`. That crosses two distinct account namespaces: a Platform-Payment index is not a BIP44 Core account index, so the wrong Core account could fund the payment. Core-to-Core has no account picker and funds the default BIP44 account, so resolve the non-platform path to account 0. The adjacent comment claimed every other flow "ignores senderAccountIndex" — false for `.coreToCore`; rewrite it to document both consumers and their separate namespaces. Co-Authored-By: Claude Opus 4.8 --- .../Core/Views/SendTransactionView.swift | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 9f27c215072..354b1369d01 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -248,26 +248,34 @@ struct SendTransactionView: View { // be the one that was last created. let managed = walletManager.wallet(for: wallet.walletId) let platformAddressWallet = try? managed?.platformAddressWallet() - // Pick the account that will FUND a platform → - // platform transfer. The Rust Auto selector - // resolves the source via - // `platform_payment_managed_account_at_index` - // (key class 0) and selects its inputs WITHIN - // that single account — it does not span - // accounts. `canSend` only gates on the - // aggregate platform balance, so with multiple - // key-class-0 Platform Payment accounts we must - // choose an account whose OWN balance covers the - // requested amount + fee; otherwise we'd enable a - // send Rust rejects. The selection is factored - // into the pure, unit-tested - // `PlatformPaymentAccountSelection` helper. + // Resolve the account that FUNDS the send. Two + // consumers read `senderAccountIndex`, in two + // DISTINCT account namespaces: // - // Only the platform → platform path needs this - // coverage-aware pick; every other flow ignores - // `senderAccountIndex`, so the prior - // "first key-class-0 positive balance, else 0" - // behaviour is preserved for them. + // • platform → platform: a key-class-0 Platform + // Payment account. The Rust Auto selector + // resolves the source via + // `platform_payment_managed_account_at_index` + // and selects inputs WITHIN that single account + // (it does not span accounts). `canSend` gates + // only on the aggregate platform balance, so we + // must pick an account whose OWN balance covers + // amount + fee, else Rust rejects the send — + // done by the unit-tested + // `PlatformPaymentAccountSelection` helper. + // + // • core → core: a BIP44 Core account index, fed + // into `CoreTransactionBuilder.setFunding( + // accountType: .bip44, ...)`. That namespace is + // SEPARATE from key-class Platform Payment + // accounts — a Platform-Payment index must never + // leak into it. The Core send UI has no account + // picker and funds the default BIP44 account, so + // resolve to account 0. + // + // Every other flow (shielded / platform → shielded + // / core → shielded) ignores this value and + // resolves its own funding, so 0 is harmless there. let senderAccountIndex: UInt32 if viewModel.detectedFlow == .platformToPlatform { guard let resolved = resolvePlatformSenderAccountIndex() else { @@ -276,10 +284,7 @@ struct SendTransactionView: View { } senderAccountIndex = resolved } else { - senderAccountIndex = addressBalances - .filter { $0.account?.keyClass == 0 } - .first(where: { $0.balance > 0 })? - .accountIndex ?? 0 + senderAccountIndex = 0 } // Input selection and surplus handling are owned // by the Rust Auto path (surplus stays on the From eb3c7ae835b1df9388fdf5e3d0d2c7c14fea2611 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:18:02 +0000 Subject: [PATCH 59/60] fix(platform-wallet-ffi): surface persister-load retry class across the FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformWalletError::PersisterLoad(PersistenceError)` had no dedicated arm in `From for PlatformWalletFFIResult`, so it fell to `ErrorUnknown` and Swift only ever saw `.unknown(String)` — losing the transient-vs-fatal distinction the Rust side already proves (rehydration_load.rs: RT-PersisterLoad-Transient / -Permanent). Add two by-value codes — `ErrorPersisterLoadTransient` (22) and `ErrorPersisterLoadFatal` (23) — split on `PersistenceError::is_transient()` (Fatal/Constraint/LockPoisoned all read non-transient, i.e. do-not-retry). The typed Display still travels in the message string; no ABI/out-param change, matching the `AddressNonceMismatch` precedent. Mirror both codes and their `PlatformWalletError` cases into the Swift binding; cbindgen regenerates the `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_*` constants from the enum. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 76 +++++++++++++++++++ .../PlatformWallet/PlatformWalletResult.swift | 28 +++++++ 2 files changed, 104 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 5bad1aa9183..cd5b24fcc62 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -159,6 +159,26 @@ pub enum PlatformWalletFFIResultCode { /// `Display`); they are not exposed as structured out-fields (that would /// require an ABI-breaking change to `PlatformWalletFFIResult`). ErrorAddressNonceMismatch = 21, + /// Maps `PlatformWalletError::PersisterLoad` when the wrapped + /// [`PersistenceError`] classifies as transient + /// (`is_transient() == true`, e.g. `SQLITE_BUSY`). Rehydration could not + /// load the persisted client state, but the backend reports the failure + /// as recoverable, so the host MAY retry the load with backoff. Split + /// from [`Self::ErrorPersisterLoadFatal`] so the retry classification + /// proven on the Rust side survives the boundary instead of flattening + /// to `ErrorUnknown`. + /// + /// [`PersistenceError`]: platform_wallet::changeset::PersistenceError + ErrorPersisterLoadTransient = 22, + /// Maps `PlatformWalletError::PersisterLoad` when the wrapped + /// [`PersistenceError`] is non-transient (a `Fatal` or `Constraint` + /// backend failure, or a poisoned lock). Rehydration could not load the + /// persisted client state and the failure is unrecoverable, so the host + /// MUST NOT retry the same load. Sibling of + /// [`Self::ErrorPersisterLoadTransient`]. + /// + /// [`PersistenceError`]: platform_wallet::changeset::PersistenceError + ErrorPersisterLoadFatal = 23, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -308,6 +328,18 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } + // A rehydration persister-load failure. The Rust side already proves + // the transient-vs-fatal classification (rehydration_load.rs), so + // split the code on `is_transient()` — flattening to `ErrorUnknown` + // would erase the retry signal. Fatal/Constraint/LockPoisoned all + // read as non-transient, i.e. do-not-retry. + PlatformWalletError::PersisterLoad(inner) => { + if inner.is_transient() { + PlatformWalletFFIResultCode::ErrorPersisterLoadTransient + } else { + PlatformWalletFFIResultCode::ErrorPersisterLoadFatal + } + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -725,6 +757,50 @@ mod tests { ); } + /// A rehydration `PersisterLoad` failure keeps its transient-vs-fatal + /// classification across the boundary: a transient backend failure maps to + /// `ErrorPersisterLoadTransient` (retryable) and every non-transient case + /// (`Fatal`, `Constraint`, `LockPoisoned`) maps to `ErrorPersisterLoadFatal` + /// (do-not-retry), rather than both flattening to `ErrorUnknown`. The typed + /// Display rendering survives verbatim as the message. + #[test] + fn persister_load_maps_by_retry_classification() { + use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; + + let transient: Vec = vec![PlatformWalletError::PersisterLoad( + PersistenceError::backend_with_kind(PersistenceErrorKind::Transient, "database busy"), + )]; + let fatal: Vec = vec![ + PlatformWalletError::PersisterLoad(PersistenceError::backend("schema corrupt")), + PlatformWalletError::PersisterLoad(PersistenceError::backend_with_kind( + PersistenceErrorKind::Constraint, + "foreign key violation", + )), + PlatformWalletError::PersisterLoad(PersistenceError::LockPoisoned), + ]; + + for (cases, expected) in [ + ( + transient, + PlatformWalletFFIResultCode::ErrorPersisterLoadTransient, + ), + (fatal, PlatformWalletFFIResultCode::ErrorPersisterLoadFatal), + ] { + for err in cases { + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, expected, + "PersisterLoad must map by retry classification (rendered: {rendered})" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!(msg, rendered, "Display payload must survive verbatim"); + } + } + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index a38ba25a027..8a1c0681643 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -61,6 +61,17 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// re-fetches the nonce and self-heals. The submitted/expected nonce values /// travel in the message string, not as structured fields. case errorAddressNonceMismatch = 21 + /// Rehydration could not load the persisted client state, but the storage + /// backend classified the failure as transient (e.g. a busy database), so + /// the caller MAY retry the load with backoff. Distinct from + /// `errorPersisterLoadFatal` so the retry classification survives the FFI + /// boundary instead of flattening to `errorUnknown`. + case errorPersisterLoadTransient = 22 + /// Rehydration could not load the persisted client state and the failure is + /// unrecoverable (corruption, constraint violation, or a poisoned lock), so + /// the caller MUST NOT retry the same load. Sibling of + /// `errorPersisterLoadTransient`. + case errorPersisterLoadFatal = 23 case notFound = 98 case errorUnknown = 99 @@ -110,6 +121,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorTransactionBroadcastUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: self = .errorAddressNonceMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_TRANSIENT: + self = .errorPersisterLoadTransient + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_FATAL: + self = .errorPersisterLoadFatal case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -225,6 +240,14 @@ public enum PlatformWalletError: LocalizedError { /// to retry, and the retry re-fetches the address nonce so the mismatch /// self-heals. The submitted/expected nonce values are in the message. case addressNonceMismatch(String) + /// Rehydration could not load the persisted client state, but the backend + /// classified the failure as transient (e.g. a busy database). Safe to + /// retry the load with backoff. Distinct from `persisterLoadFatal`. + case persisterLoadTransient(String) + /// Rehydration could not load the persisted client state and the failure is + /// unrecoverable (corruption, constraint violation, or a poisoned lock). Do + /// NOT retry the same load. + case persisterLoadFatal(String) case notFound(String) case unknown(String) @@ -243,6 +266,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), .addressNonceMismatch(let m), + .persisterLoadTransient(let m), .persisterLoadFatal(let m), .notFound(let m), .unknown(let m): return m } @@ -278,6 +302,10 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) + case .errorPersisterLoadTransient: + self = .persisterLoadTransient(detail) + case .errorPersisterLoadFatal: + self = .persisterLoadFatal(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From c8c6a93d935efdadc024a0d9977bf6c1e58bf243 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:10:29 +0000 Subject: [PATCH 60/60] refactor(platform-wallet): apply PR #3692 self-review notes Addresses three self-review action items left on PR #3692: - Rename the `ClientWalletStartState::core_wallet_info` field back to `wallet_info` across the crate, the FFI persister, load path, tests and README. The field is a pure Rust identifier (struct derives only `Debug`, no serde, no `#[serde(rename)]`, no byte-key encoding), so the rename is cosmetic and touches no on-disk/wire format. - Condense `manager/rehydrate.rs` commentary from 53 to 22 comment lines, cutting narrative/redundant prose while preserving the load-bearing trust-boundary security rationale. - Rewrite the `tests/rehydration_load.rs` module doc to be self-contained, dropping the "Item E" / "after the seedless rework" historical framing. No behavior change. fmt/check/clippy (-D warnings) clean; the 14 rehydration_load tests and rehydrate unit tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014Vq23cSxwwL4zSimo491Dr --- .../rs-platform-wallet-ffi/src/persistence.rs | 2 +- packages/rs-platform-wallet/README.md | 2 +- .../changeset/client_wallet_start_state.rs | 2 +- .../rs-platform-wallet/src/manager/load.rs | 8 +-- .../src/manager/rehydrate.rs | 65 +++++-------------- .../tests/rehydration_load.rs | 33 +++++----- 6 files changed, 40 insertions(+), 72 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 98ee8fe337e..a6f501d1ec6 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3563,7 +3563,7 @@ fn build_wallet_start_state( network, birth_height: entry.birth_height, account_manifest, - core_wallet_info: Box::new(wallet_info), + wallet_info: Box::new(wallet_info), identity_manager, unused_asset_locks, }; diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index f5fde97c067..e873e968125 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -172,7 +172,7 @@ operational state that only lives in platform-wallet's memory: and fresh-receive-address (`used`) state. Persisters that can reconstruct the full keyless snapshot hand it back -as `ClientWalletStartState::core_wallet_info` (consumed verbatim, per +as `ClientWalletStartState::wallet_info` (consumed verbatim, per invariant 4). The flattened projection fields (`core_state`/`used_core_addresses`) are a transitional fallback for persisters that cannot build a snapshot yet, and are slated for diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index ecb115ace8c..28f23124935 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -47,7 +47,7 @@ pub struct ClientWalletStartState { /// the manifest — preserving per-account attribution, the full SPV /// watch set, and pool used-state verbatim, without re-deriving /// anything. The FFI/iOS persister populates this. - pub core_wallet_info: Box, + pub wallet_info: Box, /// Lean snapshot of this wallet's /// [`IdentityManager`](crate::wallet::identity::IdentityManager). pub identity_manager: IdentityManagerStartState, diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 91eed9fbd67..7cfdfad2f53 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -25,7 +25,7 @@ impl PlatformWalletManager

{ /// registered into the manager. /// /// Core state arrives as a full keyless snapshot - /// ([`ClientWalletStartState::core_wallet_info`]) — consumed directly, + /// ([`ClientWalletStartState::wallet_info`]) — consumed directly, /// preserving per-account UTXO/record attribution and exact pool /// contents — after its `wallet_id`/`network`/account-set are /// validated against the row. @@ -119,7 +119,7 @@ impl PlatformWalletManager

{ // the row's birth height is not needed on this path. birth_height: _, account_manifest, - core_wallet_info, + wallet_info, identity_manager, unused_asset_locks, } = wallet_state; @@ -167,7 +167,7 @@ impl PlatformWalletManager

{ // the exact pool contents (derived-but-unused addresses stay in // the SPV watch set), and per-index used flags. let wallet_info = { - let mut info = *core_wallet_info; + let mut info = *wallet_info; // The snapshot must describe this row's wallet and its // account set must agree with the manifest that built the // watch-only wallet above. Either mismatch is a wrong-row @@ -406,7 +406,7 @@ mod rollback_tests { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest, - core_wallet_info: Box::new(info), + wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), }, diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 1d71791d8b8..578710d3dca 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -1,18 +1,8 @@ //! Watch-only wallet reconstruction from the keyless account manifest. //! -//! Load is **seedless** (see [`load_from_persistor`]). For each -//! persisted wallet we build a watch-only [`Wallet`] from its keyless -//! `AccountRegistrationEntry` manifest; the manager then consumes the -//! carried [`ManagedWalletInfo`](key_wallet::wallet::managed_wallet_info::ManagedWalletInfo) -//! snapshot directly. No seed, no signing-key derivation. -//! -//! Because load never touches the seed, it performs no wrong-seed check. -//! Wrong-seed validation lives in the resolver-backed signing -//! entrypoints (`sign_with_mnemonic_resolver` and the FFI resolver sign -//! path), which fail-closed gate the resolver-supplied seed against the -//! loaded `wallet_id`; the seedless load path here never sees the seed. -//! -//! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor +//! Load is seedless — each wallet is rebuilt watch-only from its manifest and +//! the manager consumes the carried snapshot directly, so no wrong-seed check +//! runs here; that gate lives in the resolver-backed signing entrypoints. use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::Account; @@ -22,40 +12,21 @@ use key_wallet::Network; use crate::changeset::AccountRegistrationEntry; use crate::manager::load_outcome::CorruptKind; -/// Build a watch-only [`Wallet`] from the keyless account manifest. -/// -/// Each `AccountRegistrationEntry` becomes an [`Account::from_xpub`] -/// (watch-only) keyed to `expected_wallet_id`; the assembled -/// [`AccountCollection`] is handed to [`Wallet::new_watch_only`] under -/// the same id. No key material crosses this function. -/// -/// Returns [`CorruptKind`] when the row is structurally unusable -/// (caller wraps it in a per-row [`SkipReason`]). -/// -/// [`SkipReason`]: crate::manager::load_outcome::SkipReason +/// Build a watch-only [`Wallet`] from the keyless account manifest, stamping +/// `expected_wallet_id` onto the reconstructed [`AccountCollection`]. Returns +/// [`CorruptKind`] when the row is structurally unusable; no key material +/// crosses this function. /// /// # Trust boundary /// -/// `expected_wallet_id` is stamped onto the reconstructed [`Wallet`] -/// verbatim and is **not** cryptographically bound to the manifest: the -/// id hashes the *root* xpub, but only account-level (hardened, one-way) -/// xpubs are persisted, so the root cannot be recovered here to re-derive -/// and verify the id. Only a structural decode runs, so a well-formed but -/// **wrong** `account_xpub` is accepted. -/// -/// Concretely, the attack this leaves open: an attacker who can write to -/// the backing store (or a malicious/rolled-back backup restored into it) -/// substitutes a valid xpub of their own for a wallet's `account_xpub`, -/// leaving `expected_wallet_id` unchanged. The wallet is rebuilt under the -/// original id but now derives its receive addresses from the attacker's -/// key, so future incoming funds are silently redirected — the id looks -/// unchanged to the user while the money flows elsewhere. This crate -/// **does not** defend against it: closing the gap requires the storage -/// layer to authenticate the manifest (a persisted commitment/MAC over -/// `{wallet_id, network, manifest}`, verified fail-closed on load), which -/// is a storage-schema change tracked in the `platform-wallet-storage` -/// crate. See the trust-boundary note on -/// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load). +/// `expected_wallet_id` is **not** cryptographically bound to the manifest: the +/// id hashes the *root* xpub, but only account-level xpubs are persisted, so the +/// root cannot be recovered here to re-verify it. A well-formed but **wrong** +/// `account_xpub` is therefore accepted — anyone able to write the backing store +/// can swap in their own xpub under the unchanged id and silently redirect +/// incoming funds. Closing this needs storage-layer manifest authentication (a +/// MAC over `{wallet_id, network, manifest}`, verified fail-closed on load), +/// tracked in the `platform-wallet-storage` crate. pub(super) fn build_watch_only_wallet( network: Network, expected_wallet_id: [u8; 32], @@ -66,9 +37,8 @@ pub(super) fn build_watch_only_wallet( } let mut accounts = AccountCollection::new(); for entry in manifest { - // NOTE: `Account::from_xpub` is infallible in the pinned key-wallet rev - // (unconditional `Ok`); this map_err is a defensive guard for when its - // signature becomes fallible (e.g. xpub/type validation). + // `Account::from_xpub` is infallible in the pinned key-wallet rev; this + // map_err is a defensive guard for when that signature becomes fallible. let account = Account::from_xpub( Some(expected_wallet_id), entry.account_type, @@ -118,7 +88,6 @@ mod tests { let restored = build_watch_only_wallet(Network::Testnet, id, &manifest).unwrap(); assert_eq!(restored.wallet_id, id); assert_eq!(restored.compute_wallet_id(), id); - // Every manifest account survives the round trip (count, types). let restored_types: Vec<_> = restored .accounts .all_accounts() diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index f4fcabca2d0..000d17a31e5 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -1,12 +1,11 @@ -//! Item E — `load_from_persistor` (seedless / watch-only) end-to-end -//! through a real `PlatformWalletManager`. +//! End-to-end coverage of `PlatformWalletManager::load_from_persistor`, the +//! seedless / watch-only load path, through a real `PlatformWalletManager`. //! -//! Scope after the seedless rework: load reconstructs every persisted -//! wallet **watch-only** from its keyless account manifest. The load -//! path never touches the seed, so it performs no wrong-seed check; -//! wrong-seed validation lives in the resolver-backed signing -//! entrypoints, not in this load path. Per-row decode failures surface -//! as [`SkipReason::CorruptPersistedRow`] without aborting the batch. +//! Load reconstructs every persisted wallet **watch-only** from its keyless +//! account manifest. The load path never touches the seed, so it performs no +//! wrong-seed check; wrong-seed validation lives in the resolver-backed signing +//! entrypoints, not here. Per-row decode failures surface as +//! [`SkipReason::CorruptPersistedRow`] without aborting the batch. //! //! RT cases here: //! - RT-WO: round-trip — watch-only wallet is registered after reload. @@ -15,7 +14,7 @@ //! fires on the registered handler, `load` returns `Ok`. //! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason` //! surface (the structural-only contract). -//! - RT-Snapshot: a carried `core_wallet_info` snapshot is consumed +//! - RT-Snapshot: a carried `wallet_info` snapshot is consumed //! verbatim — per-account UTXO attribution and derived-but-unused //! deep pool addresses survive the reload; a snapshot whose //! `wallet_id` mismatches its row is skipped as corrupt. @@ -77,7 +76,7 @@ impl PlatformWalletPersistence for FixedLoadPersister { network: w.network, birth_height: w.birth_height, account_manifest: w.account_manifest.clone(), - core_wallet_info: w.core_wallet_info.clone(), + wallet_info: w.wallet_info.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), }, @@ -175,7 +174,7 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest, - core_wallet_info: Box::new(info), + wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), }, @@ -407,7 +406,7 @@ async fn rt_z_secret_hygiene_surfaces() { } } -/// RT-Snapshot: a carried `core_wallet_info` snapshot is consumed +/// RT-Snapshot: a carried `wallet_info` snapshot is consumed /// verbatim. Two properties the projection replay could NOT provide: /// - per-account UTXO attribution — a CoinJoin-account UTXO stays on the /// CoinJoin account (the fallback path routed every UTXO to the first @@ -532,7 +531,7 @@ async fn rt_snapshot_preserves_attribution_and_pools() { info.update_balance(); let (_, mut s) = slice(seed); - s.core_wallet_info = Box::new(info); + s.wallet_info = Box::new(info); let p = Arc::new(FixedLoadPersister::new()); let h = Arc::new(RecordingHandler::default()); let mut st = ClientStartState::default(); @@ -592,7 +591,7 @@ async fn rt_snapshot_wallet_id_mismatch_is_skipped() { WalletAccountCreationOptions::Default, ) .unwrap(); - s.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1)); + s.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_a, s); @@ -646,7 +645,7 @@ async fn rt_snapshot_account_set_mismatch_is_skipped() { let (_, mut s) = slice(seed); s.account_manifest = truncated_manifest; - s.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1)); + s.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_a, s); @@ -696,7 +695,7 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { .unwrap(); let id_ok = wallet_ok.compute_wallet_id(); let (_, mut s_ok) = slice(seed_ok); - s_ok.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1)); + s_ok.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1)); // Mismatched row: keyed by wallet BAD, snapshot built from wallet OTHER. let (id_bad, mut s_bad) = slice(seed_bad); @@ -706,7 +705,7 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { WalletAccountCreationOptions::Default, ) .unwrap(); - s_bad.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1)); + s_bad.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_ok, s_ok);