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