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

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

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

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

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

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

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

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

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

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

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

= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, expected_total, + "all UTXO addresses (including deep-index) must be derived into their pools" + ); + + // Each deep address resolves to its exact derivation slot. + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let internal = pools.iter().find(|p| p.is_internal()).unwrap(); + assert_eq!(external.address_at_index(30).as_ref(), Some(&mid_recv)); + assert_eq!(external.address_at_index(50).as_ref(), Some(&deep_recv)); + assert_eq!(internal.address_at_index(30).as_ref(), Some(&deep_change)); + + // maintain_gap_limit must refill BEYOND the deepest restored + // index so the gap window is actually exercised, not just the restore. + // Deepest external resolved = idx 50; gap window must reach >= 50+30=80. + let expected_min_gen = 50 + DEFAULT_EXTERNAL_GAP_LIMIT; + assert!( + external.highest_generated >= Some(expected_min_gen), + "maintain_gap_limit must extend external pool to >= {} (got {:?})", + expected_min_gen, + external.highest_generated, + ); + } + + /// A UTXO whose address is not derivable from this account's + /// xpub (foreign key, multi-account mismatch) must not cause a panic or + /// hang. The total balance is exact (the UTXO is in the set regardless), + /// but the foreign address is absent from the pool so per-address + /// visibility is reduced. `tracing::warn!` fires for the unresolved count. + #[test] + fn rehydration_unresolvable_address_is_deferred_not_panics() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let seed = [13u8; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // Normal UTXO at external index 3 (within eager window, pool-visible). + let normal_addr = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + // Foreign address: derive from a completely different wallet seed so + // it cannot be resolved from this wallet's xpub. + let foreign_addr = { + let fw = Wallet::from_seed_bytes( + [99u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let fw_info = ManagedWalletInfo::from_wallet(&fw, 1); + fw_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap() + .managed_account_type() + .address_pools() + .first() + .unwrap() + .address_at_index(0) + .unwrap() + }; + assert_ne!( + normal_addr, foreign_addr, + "test fixture: foreign address must differ from normal" + ); + + let utxo = |addr: Address, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from([n; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let normal_val = 100_000u64; + let foreign_val = 200_000u64; + let expected_total = normal_val + foreign_val; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![ + utxo(normal_addr, normal_val, 1), + utxo(foreign_addr, foreign_val, 2), + ], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + // Must not panic. tracing::warn! fires for the unresolved count. + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // Total balance is exact — foreign UTXO is in the set regardless. + assert_eq!( + wallet_info.balance.total(), + expected_total, + "total must include foreign UTXO even though it is unresolved" + ); + + // Per-address visible: only the normal UTXO is in the pool. + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pool_addresses: HashSet
= funds + .managed_account_type() + .address_pools() + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, normal_val, + "only the non-foreign UTXO is pool-visible; foreign deferred to re-warm" + ); + assert!( + visible < expected_total, + "foreign UTXO is deferred — per-address visible < total" + ); + } + + /// CoinJoin topology (single External pool, no Internal chain). + /// Verifies that `extend_pools_for_restored_utxos` handles a single-pool + /// account at a deep derivation index (idx 30, just past the eager window). + #[test] + fn rehydration_coinjoin_single_pool_deep_index() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::Utxo; + use std::collections::BTreeSet; + + // CoinJoin-only wallet: no BIP44, one CoinJoin account at index 0. + let mut cj_set = BTreeSet::new(); + cj_set.insert(0u32); + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + cj_set, + BTreeSet::new(), + BTreeSet::new(), + None, + ); + let seed = [11u8; 64]; + let wallet = Wallet::from_seed_bytes(seed, Network::Testnet, opts).unwrap(); + assert!( + !wallet.accounts.coinjoin_accounts.is_empty(), + "fixture must have a CoinJoin account" + ); + + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + // Extract pool metadata before the mutable borrow of wallet_info. + let (funds_type, pool_base_path, pool_type_val, pool_gap_limit) = { + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .expect("CoinJoin account must be the only funds account"); + let ft = funds.managed_account_type().to_account_type(); + let pools = funds.managed_account_type().address_pools(); + // CoinJoin has a single pool (External). + assert_eq!( + pools.len(), + 1, + "CoinJoin topology: must have exactly one pool" + ); + let p = &pools[0]; + (ft, p.base_path.clone(), p.pool_type, p.gap_limit) + }; + + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("CoinJoin xpub must be in manifest"); + + // Derive the CoinJoin address at index 30 (first past the eager + // window 0..=29) using the real pool's base_path and pool_type. + let mut probe = AddressPool::new_without_generation( + pool_base_path, + pool_type_val, + pool_gap_limit, + Network::Testnet, + ); + probe + .generate_addresses(31, &KeySource::Public(xpub), true) + .unwrap(); + let deep_cj_addr = probe.address_at_index(30).unwrap(); + + let utxo_val = 7_777u64; + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from([7u8; 32]), + vout: 0, + }, + txout: TxOut { + value: utxo_val, + script_pubkey: deep_cj_addr.script_pubkey(), + }, + address: deep_cj_addr.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![utxo], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // Balance is exact. + assert_eq!( + wallet_info.balance.total(), + utxo_val, + "CoinJoin deep-index balance must be exact" + ); + + // The CoinJoin pool was extended to include the deep-index address. + let funds_post = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let cj_pool = &funds_post.managed_account_type().address_pools()[0]; + assert_eq!( + cj_pool.address_at_index(30).as_ref(), + Some(&deep_cj_addr), + "CoinJoin pool must be extended to cover deep-index address at idx 30" + ); + } + + /// In-window restored UTXO: an address already covered by the eager + /// derivation (idx 3, inside `0..=gap_limit-1`) must still be marked + /// `used` during rehydration. The discovery scan never visits in-window + /// addresses, so without an explicit mark pass a funded address would keep + /// `used = false` and could later be handed out as a fresh receive address. + #[test] + fn rehydration_marks_in_window_restored_address_used() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 3 — inside the eager window, so NOT in the discovery set. + let in_window: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(4, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(3).unwrap() + }; + + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([1u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 12_345, + script_pubkey: in_window.script_pubkey(), + }, + address: in_window.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + let info = external + .address_info(&in_window) + .expect("in-window address must be present in the pool"); + assert!( + info.used, + "in-window restored UTXO address must be marked used" + ); + assert!( + external.used_indices.contains(&3), + "used_indices must record the in-window slot" + ); + assert_eq!( + external.highest_used, + Some(3), + "highest_used must reflect the in-window slot" + ); + } + + /// Documented limitation (solution b): a legitimately-owned but + /// deep-and-sparse UTXO — external idx 45 with nothing unspent at idx + /// <= 30 — is left unresolved because the discovery horizon (gap_limit + /// past the deepest match) never advances far enough to reach it. The + /// wallet total stays exact; only the per-address view is incomplete + /// until the next sync (a `tracing::warn!` records the deferral). + #[test] + fn rehydration_deep_sparse_utxo_left_unresolved_total_exact() { + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::{OutPoint, Txid}; + use key_wallet::bip32::DerivationPath; + use key_wallet::gap_limit::DEFAULT_EXTERNAL_GAP_LIMIT; + use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::HashSet; + + let wallet = Wallet::from_seed_bytes( + [21u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let funds_type = wallet_info + .accounts + .all_funding_accounts() + .first() + .unwrap() + .managed_account_type() + .to_account_type(); + let xpub = manifest + .iter() + .find(|e| e.account_type == funds_type) + .map(|e| e.account_xpub) + .expect("funds account xpub"); + + // External idx 45 — past the eager window AND past the initial scan + // window (horizon = gap_limit = 30 with no nearer match to extend it). + let sparse_deep: Address = { + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(46, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(45).unwrap() + }; + + let value = 500_000u64; + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([4u8; 32]), + vout: 0, + }, + txout: TxOut { + value, + script_pubkey: sparse_deep.script_pubkey(), + }, + address: sparse_deep.clone(), + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + // The wallet total is exact regardless (a sum over the UTXO set). + assert_eq!(wallet_info.balance.total(), value); + + let funds = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .next() + .unwrap(); + let pools = funds.managed_account_type().address_pools(); + let external = pools.iter().find(|p| p.is_external()).unwrap(); + assert!( + !external.contains_address(&sparse_deep), + "deep-sparse idx 45 must be left unresolved (absent from the pool)" + ); + + // Per-address view: the deep-sparse UTXO is not pool-visible yet. + let pool_addresses: HashSet
= pools + .iter() + .flat_map(|p| p.addresses.values().map(|i| i.address.clone())) + .collect(); + let visible: u64 = funds + .utxos + .values() + .filter(|u| pool_addresses.contains(&u.address)) + .map(|u| u.value()) + .sum(); + assert_eq!( + visible, 0, + "the deep-sparse UTXO is deferred — not pool-visible until next sync" + ); + assert!(visible < value, "per-address visible < exact total"); + } + + /// Topology guard: a wallet with persisted UTXOs but NO funds-bearing + /// account cannot hold them — fail closed with + /// `RehydrationTopologyUnsupported` (reporting the persisted count) rather + /// than reconstruct a silent zero balance. + #[test] + fn rehydration_utxos_without_funds_account_errors() { + use dashcore::address::Payload; + use dashcore::blockdata::transaction::txout::TxOut; + use dashcore::hashes::Hash; + use dashcore::{OutPoint, PubkeyHash, Txid}; + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::{Address, Utxo}; + use std::collections::BTreeSet; + + // Keys-only wallet: a single IdentityRegistration account, no funds. + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([23u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.accounts.all_funding_accounts().is_empty(), + "fixture must have NO funds-bearing account" + ); + + let addr = Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])), + ); + let core = crate::changeset::CoreChangeSet { + new_utxos: vec![Utxo { + outpoint: OutPoint { + txid: Txid::from([2u8; 32]), + vout: 0, + }, + txout: TxOut { + value: 800_000, + script_pubkey: addr.script_pubkey(), + }, + address: addr, + height: 1, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + let err = apply_persisted_core_state(&mut wallet_info, &manifest, &core) + .expect_err("must fail closed when no funds account can hold the UTXOs"); + match err { + PlatformWalletError::RehydrationTopologyUnsupported { utxo_count, .. } => { + assert_eq!(utxo_count, 1, "utxo_count must match the persisted set"); + } + other => panic!("expected RehydrationTopologyUnsupported, got {other:?}"), + } + } + + /// Companion to the topology guard: the same keys-only wallet with an + /// EMPTY persisted UTXO set is `Ok` — there is nothing to hold, so the + /// guard does not trip. + #[test] + fn rehydration_no_funds_account_empty_utxos_ok() { + use key_wallet::account::AccountType; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use std::collections::BTreeSet; + + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + BTreeSet::new(), + Some(vec![AccountType::IdentityRegistration]), + ); + let wallet = Wallet::from_seed_bytes([24u8; 64], Network::Testnet, opts).unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!(wallet_info.accounts.all_funding_accounts().is_empty()); + + let core = crate::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state(&mut wallet_info, &manifest, &core) + .expect("empty UTXO set must be Ok even with no funds account"); + } + + /// Regression: a `last_applied_chain_lock` carried in the persisted + /// `CoreChangeSet` must be restored onto the rehydrated wallet + /// metadata. Without it, the asset-lock-resume CL-from-metadata + /// fallback (`proof.rs`) cannot fire at app launch and a pre-restart + /// chain-locked asset lock can't produce a proof until SPV re-applies + /// a fresh chainlock. Fails (`None != Some`) if the apply step drops it. + #[test] + fn rehydration_restores_last_applied_chain_lock() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + + let wallet = Wallet::from_seed_bytes( + [5u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + wallet_info.metadata.last_applied_chain_lock.is_none(), + "fresh watch-only skeleton starts with no chain lock" + ); + + let cl = ChainLock { + block_height: 123_456, + block_hash: BlockHash::from_byte_array([7u8; 32]), + signature: [9u8; 96].into(), + }; + let core = crate::changeset::CoreChangeSet { + last_applied_chain_lock: Some(cl.clone()), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core).unwrap(); + + assert_eq!( + wallet_info.metadata.last_applied_chain_lock.as_ref(), + Some(&cl), + "persisted last_applied_chain_lock must be restored onto wallet metadata" + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/shielded_sync.rs b/packages/rs-platform-wallet/src/manager/shielded_sync.rs index 482674b4322..b38812bfc50 100644 --- a/packages/rs-platform-wallet/src/manager/shielded_sync.rs +++ b/packages/rs-platform-wallet/src/manager/shielded_sync.rs @@ -141,14 +141,6 @@ pub struct ShieldedSyncManager { coordinator_slot: Arc>>>, /// Cancel token for the background loop, if running. background_cancel: StdMutex>, - /// Monotonically increasing generation counter. Bumped on every - /// `start()` so the exiting thread can tell whether its - /// generation is still the active one before clearing - /// `background_cancel`. Without this, a `stop()` → `start()` - /// overlap lets the prior thread's cleanup strip the new - /// generation's token, leaving the new loop running but - /// untrackable via `is_running()`. - background_generation: AtomicU64, interval_secs: AtomicU64, is_syncing: AtomicBool, /// Set by [`quiesce`](Self::quiesce) to gate new passes while it @@ -171,7 +163,6 @@ impl ShieldedSyncManager { event_manager, coordinator_slot, background_cancel: StdMutex::new(None), - background_generation: AtomicU64::new(0), interval_secs: AtomicU64::new(DEFAULT_SYNC_INTERVAL_SECS), is_syncing: AtomicBool::new(false), quiescing: AtomicBool::new(false), @@ -228,10 +219,6 @@ impl ShieldedSyncManager { } let cancel = CancellationToken::new(); *guard = Some(cancel.clone()); - // Bump the generation while we still hold the slot lock so - // the load below in any prior thread's cleanup observes - // `current_gen != my_gen` ordered against this token swap. - let my_gen = self.background_generation.fetch_add(1, Ordering::AcqRel) + 1; drop(guard); let handle = tokio::runtime::Handle::current(); @@ -261,16 +248,8 @@ impl ShieldedSyncManager { } } - // Only clear `background_cancel` if the active - // generation is still ours. Without this guard a - // tight `stop()` → `start()` reschedule has the - // exiting thread overwrite the *new* generation's - // token, leaving the new loop running but - // unreflectable via `is_running()` / `stop()`. - if this.background_generation.load(Ordering::Acquire) == my_gen { - if let Ok(mut guard) = this.background_cancel.lock() { - *guard = None; - } + if let Ok(mut guard) = this.background_cancel.lock() { + *guard = None; } }); }) diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 8c690543ee0..099b77f97fd 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -148,93 +148,17 @@ impl PlatformWalletInfo { } } - // 2b. Identity keys. Runs after the scalar identity pass so - // the owning ManagedIdentity is guaranteed to exist before - // we layer keys into it. Upserts land first, then removals, - // matching the discipline used across the rest of this - // function. Orphan entries (owner not in the wallet) are - // logged and skipped by the per-entry apply helpers. - if let Some(keys_cs) = identity_keys { - let crate::changeset::IdentityKeysChangeSet { upserts, removed } = keys_cs; - // Thread the wallet network through so the key-apply - // path can reproduce DIP-9 derivation paths for any - // entry that carries `(wallet_id, derivation_indices)`. - let network = wallet.network; - for (_key, entry) in upserts { - self.identity_manager - .apply_identity_key_entry(entry, network); - } - for (identity_id, key_id) in removed { - self.identity_manager - .apply_identity_key_removal(&identity_id, key_id); - } - } - - // 3. Contacts. Each entry routes to its owning ManagedIdentity by - // `(owner, contact)` key; orphans (owner not in the wallet) - // are logged and skipped. Trivial map ops (sent / incoming - // insert and remove) are inlined here — no helper earns its - // name for a single `insert` / `shift_remove` call. Only - // `apply_established_contact` is a method because it has - // real logic (drops both pending sides per the contract). - if let Some(contact_cs) = contacts { - let crate::changeset::ContactChangeSet { - sent_requests, - removed_sent, - incoming_requests, - removed_incoming, - established, - } = contact_cs; - - for (key, entry) in sent_requests { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed - .sent_contact_requests - .insert(entry.request.recipient_id, entry.request); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping sent contact request during apply: owner identity not in wallet" - ), - } - } - for (key, entry) in incoming_requests { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed - .incoming_contact_requests - .insert(entry.request.sender_id, entry.request); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping incoming contact request during apply: owner identity not in wallet" - ), - } - } - for key in removed_sent { - if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.sent_contact_requests.remove(&key.recipient_id); - } - } - for key in removed_incoming { - if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { - managed.incoming_contact_requests.remove(&key.sender_id); - } - } - // Established promotions — drop any matching pending - // entries on both sides per the auto-establishment contract. - for (key, established) in established { - match self.identity_manager.managed_identity_mut(&key.owner_id) { - Some(managed) => { - managed.apply_established_contact(established); - } - None => tracing::warn!( - owner = %key.owner_id, - "skipping established contact during apply: owner identity not in wallet" - ), - } - } + // 2b/3. Identity keys + contacts. Keys are layered before + // contacts so a contact entry never lands before its + // owner's keys; orphans are logged and skipped. Single + // source of truth shared with the persister rehydration + // path (`load_from_persistor`). + if identity_keys.is_some() || contacts.is_some() { + self.identity_manager.apply_contacts_and_keys( + contacts.unwrap_or_default(), + identity_keys.unwrap_or_default(), + wallet.network, + ); } // 3b. DashPay profile/payment overlays. Applied AFTER identities diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 7d04f29c538..09dd930c0cc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -15,7 +15,7 @@ //! [`IdentityManager::apply_identity_key_entry`]. use super::{IdentityLocation, IdentityManager}; -use crate::changeset::{IdentityEntry, IdentityKeyEntry}; +use crate::changeset::{ContactChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet}; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -181,4 +181,85 @@ impl IdentityManager { managed.identity.public_keys_mut().remove(&key_id); } } + + /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the + /// already-restored managed identities. + /// + /// Single source of truth for the contact / identity-key routing — + /// shared by the runtime changeset-replay path + /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)) + /// and the persister rehydration path + /// ([`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)). + /// Identity keys are applied first so a contact entry never lands + /// before its owner's keys; orphan entries (owner not in the + /// wallet) are logged and skipped, never fatal. `removed_*` are + /// honoured for the replay path; the rehydration feed leaves them + /// empty. + pub(crate) fn apply_contacts_and_keys( + &mut self, + contacts: ContactChangeSet, + identity_keys: IdentityKeysChangeSet, + network: Network, + ) { + let IdentityKeysChangeSet { upserts, removed } = identity_keys; + for (_key, entry) in upserts { + self.apply_identity_key_entry(entry, network); + } + for (identity_id, key_id) in removed { + self.apply_identity_key_removal(&identity_id, key_id); + } + + let ContactChangeSet { + sent_requests, + removed_sent, + incoming_requests, + removed_incoming, + established, + } = contacts; + for (key, entry) in sent_requests { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed + .sent_contact_requests + .insert(entry.request.recipient_id, entry.request); + } + None => tracing::warn!( + owner = %key.owner_id, + "skipping sent contact request: owner identity not in wallet" + ), + } + } + for (key, entry) in incoming_requests { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed + .incoming_contact_requests + .insert(entry.request.sender_id, entry.request); + } + None => tracing::warn!( + owner = %key.owner_id, + "skipping incoming contact request: owner identity not in wallet" + ), + } + } + for key in removed_sent { + if let Some(managed) = self.managed_identity_mut(&key.owner_id) { + managed.sent_contact_requests.remove(&key.recipient_id); + } + } + for key in removed_incoming { + if let Some(managed) = self.managed_identity_mut(&key.owner_id) { + managed.incoming_contact_requests.remove(&key.sender_id); + } + } + for (key, established) in established { + match self.managed_identity_mut(&key.owner_id) { + Some(managed) => managed.apply_established_contact(established), + None => tracing::warn!( + owner = %key.owner_id, + "skipping established contact: owner identity not in wallet" + ), + } + } + } } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs new file mode 100644 index 00000000000..292b6bf6f29 --- /dev/null +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -0,0 +1,277 @@ +//! Item E — `load_from_persistor` (seedless / watch-only) end-to-end +//! through a real `PlatformWalletManager`. +//! +//! Scope after the seedless rework: load reconstructs every persisted +//! wallet **watch-only** from its keyless account manifest. The load +//! path never touches the seed, so it performs no wrong-seed check; a +//! sign-time gate is deferred separate FFI work and is not part of this +//! path. Per-row decode failures surface as +//! [`SkipReason::CorruptPersistedRow`] without aborting the batch. +//! +//! RT cases here: +//! - RT-WO: round-trip — watch-only wallet is registered after reload. +//! - RT-Corrupt: a row with an empty manifest is skipped with +//! `MissingManifest`, the other row loads, a `WalletSkippedOnLoad` +//! event fires, `load` returns `Ok`. +//! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason` +//! surface (the structural-only contract). + +use std::sync::{Arc, Mutex}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, CoreChangeSet, + PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::events::{EventHandler, PlatformEventHandler}; +use platform_wallet::manager::load_outcome::CorruptKind; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::{PlatformWalletManager, SkipReason}; + +// ---- test doubles ---- + +/// Persister whose `load()` returns a fixed keyless `ClientStartState`. +struct FixedLoadPersister { + state: Mutex>, +} + +impl FixedLoadPersister { + fn new() -> Self { + Self { + state: Mutex::new(None), + } + } + fn set(&self, s: ClientStartState) { + *self.state.lock().unwrap() = Some(s); + } +} + +impl PlatformWalletPersistence for FixedLoadPersister { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + // Rebuild a fresh ClientStartState each call (load may be + // called twice for the recoverability sub-case). + let guard = self.state.lock().unwrap(); + match guard.as_ref() { + None => Ok(ClientStartState::default()), + Some(s) => { + let mut out = ClientStartState::default(); + for (id, w) in &s.wallets { + out.wallets.insert( + *id, + ClientWalletStartState { + network: w.network, + birth_height: w.birth_height, + account_manifest: w.account_manifest.clone(), + core_state: w.core_state.clone(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }, + ); + } + Ok(out) + } + } + } +} + +/// Event handler that records every wallet-skipped-on-load notification. +#[derive(Default)] +struct RecordingHandler { + skipped: Mutex>, +} +impl EventHandler for RecordingHandler {} +impl PlatformEventHandler for RecordingHandler { + fn on_wallet_skipped_on_load(&self, wallet_id: WalletId, reason: &SkipReason) { + self.skipped + .lock() + .unwrap() + .push((wallet_id, reason.clone())); + } +} + +// ---- harness ---- + +fn manifest_and_id(seed: [u8; 64]) -> (Vec, [u8; 32]) { + let w = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = w + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + (manifest, w.compute_wallet_id()) +} + +fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { + let (manifest, id) = manifest_and_id(seed); + ( + id, + ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest: manifest, + core_state: CoreChangeSet::default(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }, + ) +} + +async fn manager( + persister: Arc, + handler: Arc, +) -> Arc> { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + Arc::new(PlatformWalletManager::new(sdk, persister, handler)) +} + +// ---- tests ---- + +/// RT-WO: seedless watch-only round-trip — a persisted wallet loads and +/// is registered after reload (no signing material needed). +#[tokio::test] +async fn rt_wo_watch_only_roundtrip() { + let seed = [0x11; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id, s) = slice(seed); + let mut st = ClientStartState::default(); + st.wallets.insert(id, s); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + + assert_eq!(outcome.loaded, vec![id]); + assert!(outcome.skipped.is_empty()); + assert!( + mgr.get_wallet(&id).await.is_some(), + "watch-only restored wallet must be registered" + ); + assert_eq!(mgr.wallet_ids().await, vec![id]); +} + +/// RT-Corrupt: a corrupt row (empty manifest) is skipped with +/// `MissingManifest`; the other row loads cleanly; the load returns +/// `Ok`; exactly one `WalletSkippedOnLoad` event fires for the skipped +/// row. +#[tokio::test] +async fn rt_corrupt_row_skipped_and_other_loads() { + let seed_a = [0x31; 64]; + let seed_b = [0x32; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id_a, sa) = slice(seed_a); + let (id_b, _sb) = slice(seed_b); + + // B's row is structurally corrupt — empty manifest. + let sb_corrupt = ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest: Vec::new(), + core_state: CoreChangeSet::default(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }; + + let mut st = ClientStartState::default(); + st.wallets.insert(id_a, sa); + st.wallets.insert(id_b, sb_corrupt); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr + .load_from_persistor() + .await + .expect("Ok despite per-row skip"); + + assert!(outcome.loaded.contains(&id_a), "A loads fully"); + assert!(!outcome.loaded.contains(&id_b), "B is skipped, not loaded"); + assert_eq!(outcome.skipped.len(), 1); + let (skipped_id, skipped_reason) = &outcome.skipped[0]; + assert_eq!(*skipped_id, id_b); + assert!(matches!( + skipped_reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::MissingManifest + } + )); + assert!(mgr.get_wallet(&id_a).await.is_some()); + assert!( + mgr.get_wallet(&id_b).await.is_none(), + "corrupt row must be ABSENT, not a degraded placeholder" + ); + + // Exactly one on_wallet_skipped_on_load notification for B. + { + let skipped = h.skipped.lock().unwrap(); + assert_eq!(skipped.len(), 1, "exactly one skip notification expected"); + let (skipped_wallet_id, skipped_reason) = &skipped[0]; + assert_eq!(*skipped_wallet_id, id_b); + assert!(matches!( + skipped_reason, + SkipReason::CorruptPersistedRow { + kind: CorruptKind::MissingManifest + } + )); + } +} + +/// RT-Z: no key/seed material leaks into `LoadOutcome` / +/// `SkipReason::CorruptPersistedRow` surfaces. The seedless load path +/// never sees seed bytes so this is mostly a sentinel guard against +/// future regression where someone embeds row contents in `DecodeError`. +#[tokio::test] +async fn rt_z_secret_hygiene_surfaces() { + let seed = [0xAB; 64]; + let p = Arc::new(FixedLoadPersister::new()); + let h = Arc::new(RecordingHandler::default()); + let (id, _s) = slice(seed); + + // Corrupt row to force a skip and inspect every public surface. + let corrupt = ClientWalletStartState { + network: key_wallet::Network::Testnet, + birth_height: 1, + account_manifest: Vec::new(), + core_state: CoreChangeSet::default(), + identity_manager: Default::default(), + unused_asset_locks: Default::default(), + contacts: Default::default(), + identity_keys: Default::default(), + }; + let mut st = ClientStartState::default(); + st.wallets.insert(id, corrupt); + p.set(st); + + let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; + let outcome = mgr.load_from_persistor().await.expect("Ok"); + let dbg = format!("{outcome:?}"); + // 0xAB seed bytes must not appear hex-rendered anywhere. + assert!(!dbg.to_lowercase().contains(&"ab".repeat(10))); + // The structural skip reason renders without any row bytes. + for (_, reason) in &outcome.skipped { + let rendered = format!("{reason} {reason:?}"); + assert!(!rendered.to_lowercase().contains(&"ab".repeat(10))); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368ef..2ead77cdbdb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -323,15 +323,17 @@ public class PlatformWalletManager: ObservableObject { /// /// Calls `platform_wallet_manager_load_from_persistor` which fires /// the Swift-side `on_load_wallet_list_fn` callback. For each - /// persisted wallet, Rust reconstructs a **watch-only** `Wallet` - /// plus the wallet's persisted platform-address sync snapshot. - /// After the FFI returns, we call `platform_wallet_manager_get_wallet` - /// for each restored id so Swift gets a `ManagedPlatformWallet` - /// handle. + /// persisted wallet, Rust rebuilds a **watch-only** `Wallet` from + /// its keyless account manifest (`Wallet::new_watch_only`) and + /// applies the persisted platform-address sync snapshot. After the + /// FFI returns we call `platform_wallet_manager_get_wallet` for + /// each restored id so Swift gets a `ManagedPlatformWallet` handle. /// - /// Signing operations will fail until a future unlock flow - /// upgrades a watch-only wallet to a signing wallet via the - /// mnemonic stored in Keychain. + /// Signing happens on demand via the configured + /// `MnemonicResolverHandle`: each resolver-fed sign entrypoint + /// fail-closed gates the resolved seed against the loaded + /// `wallet_id` and surfaces a structural wrong-seed error on + /// mismatch (no keys cross that surface). /// /// Idempotent: if there's no persisted state, does nothing and /// leaves `self.wallets` untouched. Safe to call before any @@ -340,7 +342,12 @@ public class PlatformWalletManager: ObservableObject { public func loadFromPersistor() throws -> [ManagedPlatformWallet] { try ensureConfigured() - try platform_wallet_manager_load_from_persistor(handle).check() + // Pass nil for `out_outcome` — Swift doesn't currently consume + // the per-wallet skip summary (corrupt persisted rows are + // logged by Rust at warn level). When Swift starts surfacing + // skipped wallets to the UI, pass a `LoadOutcomeFFI` here and + // free it with `platform_wallet_load_outcome_free`. + try platform_wallet_manager_load_from_persistor(handle, nil).check() // Ask SwiftData for the list of wallet ids we just told Rust // to load. We reuse the same container rather than shipping a From 2f2a74acdfcd16f35f172396cf838f576ee2dbc6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:55:19 +0000 Subject: [PATCH 002/108] feat(platform-wallet-storage): seedless load() + rehydration readers [#3968, independent on v3.1-dev] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Storage-crate half of the rehydration work, rebuilt to stand alone on v3.1-dev: SqlitePersister::load() wiring + per-area readers (accounts, core_state, identities, asset_locks, contacts, identity_keys) that reconstruct the keyless ClientWalletStartState. Independence on v3.1-dev required two deliberate stubs — the reshaped ClientWalletStartState drops wallet/wallet_info, breaking two base consumers; both are resolved by #3692 in the dash-evo-tool integration: - manager/load.rs: whole-body todo!("keyless rehydration lands in #3692") - ffi/persistence.rs: tail-only todo!("seeded FFI restore path lands in #3692") — keeps the 8 builder helpers live (no dead_code under -D warnings) and minimizes the #3692 merge conflict Cross-crate manager-apply e2e tests in sqlite_core_state_reader.rs are gated behind a new off-by-default `rehydration-apply` feature (enabled in the integrated stack); storage-level load_state assertions run standalone. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- Cargo.lock | 2 + .../rs-platform-wallet-ffi/src/persistence.rs | 34 +- .../.cargo/audit.toml | 26 + .../rs-platform-wallet-storage/Cargo.toml | 36 +- packages/rs-platform-wallet-storage/README.md | 43 +- packages/rs-platform-wallet-storage/SCHEMA.md | 205 ++- .../rs-platform-wallet-storage/SECRETS.md | 297 ++++- .../migrations/V001__initial.rs | 101 +- .../src/bin/platform-wallet-storage.rs | 53 +- packages/rs-platform-wallet-storage/src/kv.rs | 92 +- .../rs-platform-wallet-storage/src/lib.rs | 23 +- .../src/secrets/error.rs | 364 ++++-- .../src/secrets/file/crypto.rs | 97 +- .../src/secrets/file/format.rs | 363 ++++-- .../src/secrets/file/mod.rs | 1107 +++++++++++------ .../src/secrets/keyring.rs | 89 +- .../src/secrets/mod.rs | 74 +- .../src/secrets/secret.rs | 389 ++++-- .../src/secrets/store.rs | 1042 ++++++++++++++-- .../src/secrets/wire/aad.rs | 252 ++++ .../src/secrets/wire/config.rs | 43 + .../src/secrets/wire/envelope.rs | 1100 ++++++++++++++++ .../src/secrets/wire/kdf.rs | 56 + .../src/secrets/wire/mod.rs | 36 + .../src/sqlite/backup.rs | 316 ++--- .../src/sqlite/config.rs | 14 +- .../src/sqlite/conn.rs | 54 +- .../src/sqlite/error.rs | 185 +-- .../src/sqlite/kv.rs | 68 +- .../src/sqlite/migrations.rs | 66 +- .../src/sqlite/persister.rs | 598 +++++---- .../src/sqlite/schema/accounts.rs | 363 ++++-- .../src/sqlite/schema/asset_locks.rs | 221 +++- .../src/sqlite/schema/blob.rs | 77 +- .../src/sqlite/schema/contacts.rs | 145 ++- .../src/sqlite/schema/core_state.rs | 339 +++-- .../src/sqlite/schema/dashpay.rs | 38 +- .../src/sqlite/schema/identities.rs | 98 +- .../src/sqlite/schema/identity_keys.rs | 126 +- .../src/sqlite/schema/mod.rs | 44 +- .../src/sqlite/schema/platform_addrs.rs | 97 +- .../src/sqlite/schema/token_balances.rs | 29 +- .../schema/{wallet_meta.rs => wallets.rs} | 55 +- .../src/sqlite/util/safe_cast.rs | 68 +- .../tests/common/mod.rs | 28 +- .../tests/secrets_api.rs | 80 ++ .../tests/secrets_default_on_compiles.rs | 5 +- .../tests/secrets_scan.rs | 18 +- .../tests/sqlite_account_zero_attribution.rs | 121 ++ .../tests/sqlite_accounts_reader.rs | 125 ++ .../tests/sqlite_asset_locks_filter.rs | 143 +++ .../tests/sqlite_auto_backup.rs | 83 +- .../tests/sqlite_check_constraints.rs | 102 +- ..._commit_writes_lock_poison_shortcircuit.rs | 108 ++ .../tests/sqlite_compile_time.rs | 29 +- .../tests/sqlite_contacts_keys_rehydration.rs | 187 +++ .../tests/sqlite_core_state_reader.rs | 392 ++++++ .../tests/sqlite_dashpay_overlay_contract.rs | 109 ++ .../tests/sqlite_delete_buffer_reconcile.rs | 10 +- .../sqlite_delete_cross_process_exclusion.rs | 16 +- .../sqlite_delete_partial_commit_window.rs | 162 +++ .../tests/sqlite_delete_real_apply_failure.rs | 73 ++ .../tests/sqlite_delete_wallet.rs | 2 +- .../tests/sqlite_error_classification.rs | 77 +- .../tests/sqlite_fk_changeset_ordering.rs | 269 ++++ .../tests/sqlite_foreign_keys.rs | 56 +- .../tests/sqlite_identity_keys_reader.rs | 150 +++ .../tests/sqlite_load_reconstruction.rs | 244 ++-- .../tests/sqlite_load_wiring.rs | 153 +++ .../tests/sqlite_migrations.rs | 32 +- .../sqlite_money_column_overflow_on_read.rs | 119 ++ .../tests/sqlite_object_metadata.rs | 152 +-- .../tests/sqlite_open_integrity_check.rs | 4 +- .../tests/sqlite_persist_roundtrip.rs | 32 +- .../tests/sqlite_qa_identity_tombstone.rs | 290 +++++ .../tests/sqlite_second_open_guard.rs | 64 + .../tests/sqlite_structural_hardening.rs | 100 +- .../tests/sqlite_wallet_db_identity.rs | 168 +++ .../changeset/client_wallet_start_state.rs | 70 +- .../rs-platform-wallet/src/manager/load.rs | 188 +-- 80 files changed, 9711 insertions(+), 3175 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/.cargo/audit.toml create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/config.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs create mode 100644 packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs rename packages/rs-platform-wallet-storage/src/sqlite/schema/{wallet_meta.rs => wallets.rs} (65%) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs diff --git a/Cargo.lock b/Cargo.lock index e296c3aebdb..ca8c6f0e426 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,7 @@ dependencies = [ "blake2", "cpufeatures 0.2.17", "password-hash", + "zeroize", ] [[package]] @@ -5228,6 +5229,7 @@ dependencies = [ "refinery", "region", "rusqlite", + "schemars 1.2.1", "serde", "serde_json", "serial_test", diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 86b5561518c..465ec62c16c 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3377,29 +3377,17 @@ fn build_wallet_start_state( // status without rebroadcasting. let unused_asset_locks = build_unused_asset_locks(entry)?; - let wallet_state = ClientWalletStartState { - wallet, - wallet_info, - identity_manager, - unused_asset_locks, - }; - - let platform_address_state = if per_account.is_empty() - && entry.platform_sync_height == 0 - && entry.platform_sync_timestamp == 0 - && entry.platform_last_known_recent_block == 0 - { - None - } else { - Some(platform_wallet::PlatformAddressSyncStartState { - per_account, - sync_height: entry.platform_sync_height, - sync_timestamp: entry.platform_sync_timestamp, - last_known_recent_block: entry.platform_last_known_recent_block, - }) - }; - - Ok((wallet_state, platform_address_state)) + // Projecting the reconstructed `wallet`/`wallet_info` into the + // reshaped keyless `ClientWalletStartState` (account manifest + + // `CoreChangeSet` + the keyless contact / identity-key feeds) is the + // seeded FFI restore path, which lands in #3692. The storage-only + // #3968 branch keeps every reader above wired — `build_unused_asset_locks` + // and `build_wallet_identity_bucket` still run, so their `?` error + // paths and the per-account projection stay exercised — and stubs + // only this final assembly. `_` consumes the two start-state slices + // whose sole consumer was the removed struct literal. + let _ = (identity_manager, unused_asset_locks); + todo!("seeded FFI restore path lands in #3692") } /// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet diff --git a/packages/rs-platform-wallet-storage/.cargo/audit.toml b/packages/rs-platform-wallet-storage/.cargo/audit.toml new file mode 100644 index 00000000000..6e07a8c19dd --- /dev/null +++ b/packages/rs-platform-wallet-storage/.cargo/audit.toml @@ -0,0 +1,26 @@ +[advisories] +# Each entry MUST point at a live advisory in this crate's resolved graph +# and carry a dated rationale + remediation plan. Do not blanket-ignore. +ignore = [ + # RUSTSEC-2025-0141 — bincode is unmaintained (informational advisory, + # not an exploitable CVE; published 2025-12-16, no patched release). + # Acknowledged 2026-06-08. + # + # Why this is acceptable for now: + # - bincode 2.0.1 is the BLOB-codec trust boundary for every + # persisted column and backup. The known risk class for an + # unmaintained deserializer is unbounded allocation (OOM) on a + # crafted/corrupt input. + # - In-crate size bounds defang that class: MAX_VALUE_LEN / + # BLOB_SIZE_LIMIT_BYTES (kv/blob), the per-secret SecretTooLarge + # write cap, and the MAX_VAULT_SIZE_BYTES read ceiling all reject + # oversized inputs before bincode ever allocates from them. + # - load() is fail-hard: a malformed/over-large row aborts the call + # with a typed error rather than silently over-allocating. + # + # Residual risk: a future bincode defect would go unpatched upstream. + # Remediation plan: migrate the BLOB codec to a maintained equivalent + # (postcard / bitcode candidates) once the wire format is frozen at + # release; revisit this ignore at that time. + "RUSTSEC-2025-0141", +] diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 43bf9a0fb0f..d61796bc8e0 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -61,6 +61,11 @@ chrono = { version = "0.4", default-features = false, features = [ "clock", ], optional = true } sha2 = { version = "0.10", optional = true } +# Opt-in `JsonSchema` for `SecretString` (gated by `secret-schemars`). +# Reuses the workspace-locked 1.2.1. `default-features = false` drops the +# `derive` feature (we hand-write the impl), matching the crate's existing +# derive-free schemars usage so the lock gains no `schemars_derive` entry. +schemars = { version = "1", optional = true, default-features = false } # Secret-storage deps (gated by the `secrets` feature). RustSec-clean # pins (Smythe §7); `aes-gcm` is deliberately omitted. `keyring`'s @@ -80,7 +85,14 @@ keyring-core = { version = "=1.0.0", optional = true } # was removed from the sqlite arm — those tests grep for `fs2`/`fs4` # literals in this crate's source/manifest and would re-trigger on the # older crates. `fd-lock` has no such collision. -fd-lock = { version = "4.0.4", optional = true } +# LOCAL-FS ONLY: flock/LockFileEx interlock processes only on local +# filesystems; over NFS/CIFS the lock does not interlock, so a vault file +# must not be shared across hosts — steer multi-host to the OS-keyring arm. +# Exact-pinned (`=`) like the rest of the soundness-critical stack: the +# `VaultLock` unsafe drop-order argument in `secrets/file/mod.rs` is +# calibrated to fd-lock 4.0.4's guard internals; any bump must re-verify +# that the guard releases the OS lock before the backing `RwLock` frees. +fd-lock = { version = "=4.0.4", optional = true } # CLI deps (gated by the `cli` feature) clap = { version = "4", features = ["derive"], optional = true } @@ -180,6 +192,17 @@ cli = [ # crate without the crypto graph. secrets = [ "dep:argon2", + # Enable argon2's `zeroize` feature so the KDF wipes its sensitive + # intermediate state (`initial_hash`/`blockhash`) on drop. In argon2 + # 0.5.3 this does NOT cover the bulk `Block` matrix — that residual is + # documented at `derive_key` in `secrets/file/crypto.rs`. Keep it in + # the feature list (not a `default-features = false` rewrite) so + # argon2's own default features stay intact. + "argon2/zeroize", + # bincode is the producer for the Tier-2 envelope wire format and the + # three AAD encodings (`Tier2Aad`/`EntryAad`/`VerifyAad`) — see + # `secrets/wire/`. `=2.0.1` is the workspace-wide pin. + "dep:bincode", "dep:chacha20poly1305", # secrets uses serde directly (vault format + crypto envelope derive # `Serialize`/`Deserialize`); declare the dep here so @@ -199,6 +222,15 @@ secrets = [ "dep:apple-native-keyring-store", "dep:windows-native-keyring-store", ] +# Opt-in `SecretString` serde/schemars impls. Deliberately DEFAULT-OFF +# even though `secrets` (and, via it, the `serde` dep) are default-on: +# these gate the IMPLS, not the dep, so the impls are absent unless a +# consumer explicitly opts in. `secret-serde` requires `secrets` (the type +# only exists under it). NO `Serialize` is ever provided. `secret-schemars` +# implies `secret-serde`. (design §5.4 / GAP-001 names / GAP-002 satisfiable +# default-off.) +secret-serde = ["secrets", "dep:serde"] +secret-schemars = ["secret-serde", "dep:schemars"] # Per-object-type key/value metadata API # (`platform_wallet_storage::{KvStore, KvError, ObjectId}`) plus the # SQLite-backed impl. Requires `sqlite` because the only shipped backend @@ -212,3 +244,5 @@ kv = ["sqlite"] # convention for "MUST NOT enable from downstream" features # (https://doc.rust-lang.org/cargo/reference/features.html#feature-resolver-version-2). __test-helpers = ["sqlite"] +# e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool). +rehydration-apply = [] diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md index 34917c10e03..04c4d6057eb 100644 --- a/packages/rs-platform-wallet-storage/README.md +++ b/packages/rs-platform-wallet-storage/README.md @@ -103,8 +103,11 @@ flush, 5 s busy timeout, WAL journal, `NORMAL` synchronous, and an auto-backup dir at `/backups/auto/`. The trait surface is `store` / `flush` / `load` / `get_core_tx_record`. -Schema migrations are append-only Rust files under `migrations/`, applied -via [`refinery`](https://github.com/rust-db/refinery) on every `open`. +Schema migrations are versioned Rust files under `migrations/`, applied via +[`refinery`](https://github.com/rust-db/refinery) on every `open`. While the +crate is unreleased, in-place edits to the sole shipped `V001` are allowed; +the append-only guarantee (add a new versioned file, never edit a prior one) +takes effect once the schema is frozen at release. #### Flush semantics (store / flush) @@ -138,17 +141,31 @@ so one failed wallet does not hide its siblings. #### load() reconstruction -`SqlitePersister::load()` returns the base `ClientStartState` (plain struct, -two slots — no `#[non_exhaustive]`): +`SqlitePersister::load()` returns a fully-rehydrated `ClientStartState` +(plain struct — no `#[non_exhaustive]`). Both slots are populated: | Slot | Reader | Status | |---|---|---| -| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallet_meta::list_ids` wallet universe) | populated | -| `wallets` | — | empty pending upstream `Wallet::from_persisted` | - -The `identities` / `contacts` / `asset_locks` per-area readers exist as -hardened dormant helpers (`schema::::load_state`) but are not wired -into `load()` — `ClientStartState` carries no slot for them. +| `platform_addresses` | `schema::platform_addrs::load_all` (a fixed set of grouped scans over `platform_address_sync`, `platform_addresses`, and `account_registrations`, driven by the `wallets::list_ids` wallet universe) | populated | +| `wallets` | per-wallet `schema::` readers (see below) | populated | + +Each `ClientStartState::wallets` entry is a **keyless** `ClientWalletStartState` +reconstructed from these per-area readers: + +| Field | Reader | +|---|---| +| `network` / `birth_height` | `schema::wallets::fetch` | +| `account_manifest` | `schema::accounts::load_state` | +| `core_state` | `schema::core_state::load_state` | +| `identity_manager` | `schema::identities::load_state` | +| `unused_asset_locks` | `schema::asset_locks::load_unconsumed` (`Consumed`-filtered — spent locks stay on disk but are never resurrected) | +| `contacts` | `schema::contacts::load_changeset` | +| `identity_keys` | `schema::identity_keys::load_state` | + +The payload carries **no** `Wallet` and no key material — the manager +rebuilds each wallet watch-only via `Wallet::new_watch_only` from the +manifest and applies this state; signing keys are derived later on demand +via the `sign_with_mnemonic_resolver` path. Loading is **fail-hard**: any row that fails to decode, or a stored `wallet_id` that is not exactly 32 bytes, aborts the whole call with a typed @@ -158,9 +175,9 @@ corruption tolerance, no per-row skip, and no partial `Ok` — a corrupt database surfaces as an error rather than silently losing rows. The summary `tracing::info!` carries `wallets_seen`, `addresses_loaded`, -`wallets_rehydrated`, and `wallets_pending_rehydration` (the count of -wallets that *would* be rehydrated once upstream provides -`Wallet::from_persisted`). +`wallets_rehydrated` (the count actually rehydrated this call), and +`wallets_pending_rehydration` (now always `0` — every seen wallet is +rehydrated). The only deferred field is listed in `LOAD_UNIMPLEMENTED`. ### KV metadata API diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index 8149fb16e23..ee64b11df45 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -11,9 +11,9 @@ chain. ## What it stores — and the boundary The persister stores **public** wallet-state material (UTXOs, transactions, -account registrations, address pools, identities, identity public keys, -contacts, asset locks, token balances, DashPay overlays, and -platform-address sync snapshots) in a SQLite database managed by +account registrations, identities, identity public keys, contacts, asset +locks, token balances, DashPay overlays, and platform-address sync +snapshots) in a SQLite database managed by [refinery](https://crates.io/crates/refinery) migrations. **No secrets are stored here.** Mnemonics, seeds, and raw private keys never @@ -23,7 +23,7 @@ see [SECRETS.md](./SECRETS.md). ## How integrity is kept -Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallet_metadata` row cleans that wallet's metadata along two paths: +Schema evolution is version-gated by refinery. Every read-write connection turns on `PRAGMA foreign_keys = ON` at open time (`src/sqlite/conn.rs`), so every `ON DELETE CASCADE` clause is active. Deleting a `wallets` row cleans that wallet's metadata along two paths: - **`wallet_id`-scoped meta** (`meta_wallet`, `meta_contact`, `meta_platform_address`) carries a `wallet_id` column, so `cascade_meta_on_wallet_delete` brooms it directly — regardless of the lifecycle state of any typed parent and even for rows written ahead of (or without) a typed parent. - **identity-scoped meta** (`meta_identity`, `meta_token`) carries no `wallet_id` — only `identity_id` (+ `token_id`). It is cleaned by `cascade_meta_on_identity_delete` (AFTER DELETE ON `identities`), which fires for the wallet's own identities when the FK cascade removes them on a wallet delete. @@ -37,24 +37,22 @@ Any `meta_*` row whose parent object does not exist — because it was never cre A future garbage-collection pass is expected to reap orphan metadata — rows with no live parent object older than approximately one week — but no such GC is implemented yet. Callers should not rely on orphan metadata persisting forever, nor assume it will be cleaned up promptly. `meta_global` is intentionally parentless and always survives. -The 23 tables are split into five domain diagrams below. `WALLET_METADATA` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. +The 21 tables are split into five domain diagrams below. `WALLETS` is the root anchor and appears in each diagram. For full column listings see the [Tables](#tables) section. ## Diagram 1 — Core / L1 (Bitcoin/Dash layer) -Account registrations, address-pool snapshots, transactions, UTXOs, instant locks, derived addresses, and SPV sync state. +Account registrations, transactions, UTXOs, instant locks, and SPV sync state. ```mermaid erDiagram - WALLET_METADATA ||--o{ ACCOUNT_REGISTRATIONS : "registers" - WALLET_METADATA ||--o{ ACCOUNT_ADDRESS_POOLS : "snapshots" - WALLET_METADATA ||--o{ CORE_TRANSACTIONS : "records" - WALLET_METADATA ||--o{ CORE_UTXOS : "owns" - WALLET_METADATA ||--o{ CORE_INSTANT_LOCKS : "holds" - WALLET_METADATA ||--o{ CORE_DERIVED_ADDRESSES : "derives" - WALLET_METADATA ||--o| CORE_SYNC_STATE : "tracks" + WALLETS ||--o{ ACCOUNT_REGISTRATIONS : "registers" + WALLETS ||--o{ CORE_TRANSACTIONS : "records" + WALLETS ||--o{ CORE_UTXOS : "owns" + WALLETS ||--o{ CORE_INSTANT_LOCKS : "holds" + WALLETS ||--o| CORE_SYNC_STATE : "tracks" CORE_TRANSACTIONS ||--o{ CORE_UTXOS : "spends" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network "mainnet | testnet | devnet | regtest" INTEGER birth_height "SPV scan start height" @@ -62,19 +60,11 @@ erDiagram ACCOUNT_REGISTRATIONS { BLOB wallet_id PK - TEXT account_type PK "standard | coinjoin | identity_registration | ..." + TEXT account_type PK "standard_bip44 | standard_bip32 | coinjoin | identity_registration | ..." INTEGER account_index PK BLOB account_xpub_bytes "bincode-encoded AccountRegistrationEntry" } - ACCOUNT_ADDRESS_POOLS { - BLOB wallet_id PK - TEXT account_type PK - INTEGER account_index PK - TEXT pool_type PK "external | internal | absent | absent_hardened" - BLOB snapshot_blob "bincode-encoded AccountAddressPoolEntry" - } - CORE_TRANSACTIONS { BLOB wallet_id PK BLOB txid PK "32-byte Txid" @@ -102,15 +92,6 @@ erDiagram BLOB islock_blob "bincode-encoded InstantLock" } - CORE_DERIVED_ADDRESSES { - BLOB wallet_id PK - TEXT account_type PK - TEXT address PK "bech32 / Base58 address string" - INTEGER account_index - TEXT derivation_path "pool_type/derivation_index" - INTEGER used "0 | 1" - } - CORE_SYNC_STATE { BLOB wallet_id PK "one row per wallet" INTEGER last_processed_height "NULL until first block processed" @@ -125,17 +106,18 @@ erDiagram ## Diagram 2 — Identities + DashPay (Platform L2 identity tree) -Platform identities, their public keys, token balances, and DashPay profiles/payments. Identity-owned tables have no direct `wallet_id` column; cascade flows `wallet_metadata → identities → child`. +Platform identities, their public keys, token balances, and DashPay profiles/payments. Most identity-owned tables have no direct `wallet_id` column and cascade via `wallets → identities → child`; `identity_keys` is the exception — it carries its own `wallet_id` column and two `ON DELETE CASCADE` FKs (one to `wallets`, one to `identities`). ```mermaid erDiagram - WALLET_METADATA ||--o{ IDENTITIES : "parents" + WALLETS ||--o{ IDENTITIES : "parents" + WALLETS ||--o{ IDENTITY_KEYS : "owns" IDENTITIES ||--o{ IDENTITY_KEYS : "has" IDENTITIES ||--o{ TOKEN_BALANCES : "holds" IDENTITIES ||--o| DASHPAY_PROFILES : "has" IDENTITIES ||--o{ DASHPAY_PAYMENTS_OVERLAY : "overlays" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network INTEGER birth_height @@ -144,16 +126,18 @@ erDiagram IDENTITIES { BLOB identity_id PK "32-byte Platform Identifier" BLOB wallet_id FK "NULL = orphan identity (no parent wallet yet)" - INTEGER wallet_index "BIP-32 index; NULL for out-of-wallet identities" + INTEGER identity_index "BIP-32 index; NULL for out-of-wallet identities" BLOB entry_blob "bincode-encoded IdentityEntry" INTEGER tombstoned "0 | 1 (logical delete)" } IDENTITY_KEYS { + BLOB wallet_id PK "32-byte WalletId" BLOB identity_id PK INTEGER key_id PK "KeyID" BLOB public_key_blob "bincode-encoded IdentityKeyWire (public material only)" BLOB public_key_hash "20-byte HASH160 of the key" + BLOB derivation_blob "reserved typed projection; always NULL today" } TOKEN_BALANCES { @@ -181,10 +165,10 @@ One unified table for all three states of a DashPay contact relationship — the ```mermaid erDiagram - WALLET_METADATA ||--o{ CONTACTS : "has" + WALLETS ||--o{ CONTACTS : "has" IDENTITIES ||--o{ CONTACTS : "relates" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network INTEGER birth_height @@ -221,11 +205,11 @@ Platform P2PKH address pool with its sync watermark, and the asset-lock lifecycl ```mermaid erDiagram - WALLET_METADATA ||--o{ PLATFORM_ADDRESSES : "tracks" - WALLET_METADATA ||--o| PLATFORM_ADDRESS_SYNC : "syncs" - WALLET_METADATA ||--o{ ASSET_LOCKS : "issues" + WALLETS ||--o{ PLATFORM_ADDRESSES : "tracks" + WALLETS ||--o| PLATFORM_ADDRESS_SYNC : "syncs" + WALLETS ||--o{ ASSET_LOCKS : "issues" - WALLET_METADATA { + WALLETS { BLOB wallet_id PK "32-byte WalletId" TEXT network INTEGER birth_height @@ -267,7 +251,7 @@ table per [`ObjectId`](./src/kv.rs) variant. `meta_global` has no parent and survives wallet deletion. The other five carry **no foreign key**: metadata may be written before its parent object is synced into its typed table. `AFTER DELETE` triggers provide a soft cascade so metadata -never outlives its wallet. Deleting a `wallet_metadata` row brooms every +never outlives its wallet. Deleting a `wallets` row brooms every wallet-scoped `meta_*` row by `wallet_id` directly, and the FK cascade through `identities` brooms the identity-scoped `meta_*` rows by `identity_id`; both legs key on the id alone, so cleanup is independent @@ -278,9 +262,9 @@ edges below denote trigger-based cleanup, not an FK relationship. ```mermaid erDiagram - WALLET_METADATA ||..o{ META_WALLET : "trigger cleanup (by wallet_id)" - WALLET_METADATA ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)" - WALLET_METADATA ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)" + WALLETS ||..o{ META_WALLET : "trigger cleanup (by wallet_id)" + WALLETS ||..o{ META_CONTACT : "trigger cleanup (by wallet_id)" + WALLETS ||..o{ META_PLATFORM_ADDRESS : "trigger cleanup (by wallet_id)" IDENTITIES ||..o{ META_IDENTITY : "trigger cleanup (by identity_id)" IDENTITIES ||..o{ META_TOKEN : "trigger cleanup (by identity_id)" @@ -291,7 +275,7 @@ erDiagram } META_WALLET { - BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB wallet_id PK "no FK; trigger cleanup on wallets delete" TEXT key PK BLOB value INTEGER updated_at @@ -313,7 +297,7 @@ erDiagram } META_CONTACT { - BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB wallet_id PK "no FK; trigger cleanup on wallets delete" BLOB owner_id PK BLOB contact_id PK TEXT key PK @@ -322,7 +306,7 @@ erDiagram } META_PLATFORM_ADDRESS { - BLOB wallet_id PK "no FK; trigger cleanup on wallet_metadata delete" + BLOB wallet_id PK "no FK; trigger cleanup on wallets delete" BLOB address PK TEXT key PK BLOB value @@ -342,7 +326,7 @@ erDiagram ## Tables -### `wallet_metadata` +### `wallets` Root anchor for every per-wallet table. Deleting a row cascades to all direct children; identity-owned children cascade through `identities`. @@ -359,15 +343,7 @@ the typed `account_type` / `account_index` columns mirror it for SQL lookups without blob decoding. - PK: `(wallet_id, account_type, account_index)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. - -### `account_address_pools` - -Address-pool snapshot per `(wallet, account, pool_type)`. `pool_type` is -one of `external`, `internal`, `absent`, `absent_hardened`. - -- PK: `(wallet_id, account_type, account_index, pool_type)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `core_transactions` @@ -376,7 +352,7 @@ One row per transaction the wallet has seen. `height`, `block_hash`, and is `1` once block context is present. - PK: `(wallet_id, txid)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - Index: `idx_core_transactions_height(wallet_id, height)`. ### `core_utxos` @@ -387,7 +363,7 @@ by a trigger when its referenced `core_transactions` row is deleted NOT NULL `wallet_id` column). - PK: `(wallet_id, outpoint)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - Index: `idx_core_utxos_spent(wallet_id, spent)`. ### `core_instant_locks` @@ -396,25 +372,21 @@ Instant-lock blobs for transactions that are broadcast but not yet finalized. Rows are removed when the transaction becomes confirmed. - PK: `(wallet_id, txid)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. - -### `core_derived_addresses` - -Address-to-account-index map. Written before UTXOs in the same -transaction so the UTXO writer can resolve `account_index` by address. - -- PK: `(wallet_id, account_type, address)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. -- Index: `idx_core_derived_addresses_addr(wallet_id, address)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `core_sync_state` -One row per wallet, holding monotonically-advancing SPV sync watermarks. -`last_processed_height` and `synced_height` are NULL until the first -block is processed. +One row per wallet, holding monotonically-advancing SPV sync watermarks and +the last applied ChainLock. `last_processed_height` and `synced_height` are +NULL until the first block is processed. `last_applied_chain_lock` is NULL +until a ChainLock has been applied and flushed. - PK: `wallet_id` (single-row-per-wallet). -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. +- `last_applied_chain_lock BLOB` — bincode-encoded + `dashcore::ephemerealdata::chain_lock::ChainLock`; used during rehydration + to restore `WalletMetadata::last_applied_chain_lock` so asset-lock proof + generation can use the cached ChainLock from before a restart. ### `identities` @@ -424,7 +396,7 @@ NULL means the identity was written before a parent wallet was registered marks a logical delete; the row is retained for cascade integrity. - PK: `identity_id`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE` (nullable). +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE` (nullable). - Index: `idx_identities_wallet(wallet_id)`. ### `identity_keys` @@ -432,11 +404,14 @@ marks a logical delete; the row is retained for cascade integrity. Public identity keys only — no private material. The `public_key_blob` is a custom wire format (`IdentityKeyWire`) that pre-encodes the `IdentityPublicKey` via bincode 2 native `Encode/Decode` -to work around a serde-tag incompatibility. +to work around a serde-tag incompatibility. `derivation_blob` is a +reserved column for a future typed projection and is always NULL today +(derivation indices live inside `public_key_blob`). -- PK: `(identity_id, key_id)`. +- PK: `(wallet_id, identity_id, key_id)`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - FK: `identity_id → identities(identity_id) ON DELETE CASCADE`. -- Index: `idx_identity_keys_identity(identity_id)`. +- Index: `idx_identity_keys_wallet_identity(wallet_id, identity_id)`. ### `contacts` @@ -451,7 +426,7 @@ hold a bincode-encoded `ContactRequest`; `accepted_accounts` holds a bincode-encoded `Vec`. - PK: `(wallet_id, owner_id, contact_id)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. - `state` CHECK: sourced from `sqlite::schema::contacts::CONTACT_STATE_LABELS`. ### `platform_addresses` @@ -461,7 +436,7 @@ HASH160; `balance` and `nonce` are the last-synced values from the Platform layer. - PK: `(wallet_id, address)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `platform_address_sync` @@ -469,22 +444,27 @@ Per-wallet watermark for platform address sync. All three height/timestamp fields advance monotonically (new values are `max(current, incoming)`). - PK: `wallet_id` (single-row-per-wallet). -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `asset_locks` Lifecycle tracking for asset-lock outpoints. `status` is a queryable text column; `lifecycle_blob` carries the full `AssetLockEntry`. Consumed -locks are removed via `AssetLockChangeSet::removed`, not retained with a -consumed status. +locks are **retained permanently** with `status = 'consumed'` (an upsert, +never a `DELETE` — they are not routed through `AssetLockChangeSet::removed`), +so the full lifecycle history stays on disk and remains visible via the +unfiltered inspection reader (`schema::asset_locks::list_active`). The +rehydration feed reads through `schema::asset_locks::load_unconsumed`, which +filters at the SQL level (`status NOT IN ('consumed')`), so a spent one-shot +lock is never resurrected as actionable. - PK: `(wallet_id, outpoint)`. -- FK: `wallet_id → wallet_metadata(wallet_id) ON DELETE CASCADE`. +- FK: `wallet_id → wallets(wallet_id) ON DELETE CASCADE`. ### `token_balances` Per-identity token balance cache, keyed by `(identity_id, token_id)`. -Cascade flows `wallet_metadata → identities → token_balances` through the +Cascade flows `wallets → identities → token_balances` through the nullable `identities.wallet_id` link; no direct `wallet_id` column exists. - PK: `(identity_id, token_id)`. @@ -524,7 +504,7 @@ Unlike every other per-wallet table, the five typed `meta_*` tables carry host apps can attach metadata independently of sync ordering (and a global-config persister can write to typed scopes whose parent tables stay empty). Cleanup is instead a soft cascade. Deleting a -`wallet_metadata` row fires a wallet-rooted `AFTER DELETE` trigger that +`wallets` row fires a wallet-rooted `AFTER DELETE` trigger that brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`, `meta_platform_address`) by `wallet_id`, and the FK cascade through `identities` fires a per-identity trigger that brooms `meta_identity` + @@ -546,7 +526,7 @@ Global metadata with no parent — survives every wallet delete. Per-wallet metadata. Writable before the wallet exists. - PK: `(wallet_id, key)`. -- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`). +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`). #### `meta_identity` @@ -567,7 +547,7 @@ Per-token-balance metadata. Writable before the token balance exists. Per-contact metadata for any lifecycle state. Writable before the contact exists. - PK: `(wallet_id, owner_id, contact_id, key)`. -- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`) on a wallet delete, plus `cascade_meta_contact_on_contact_delete` (AFTER DELETE ON `contacts`, any state) for a direct contact delete. #### `meta_platform_address` @@ -575,30 +555,28 @@ Per-platform-address metadata. `address` is an opaque `BLOB`. Writable before the address exists. - PK: `(wallet_id, address, key)`. -- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallet_metadata`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete. +- No FK. Cleanup: `cascade_meta_on_wallet_delete` (AFTER DELETE ON `wallets`, by `wallet_id`) on a wallet delete, plus `cascade_meta_platform_address_on_address_delete` (AFTER DELETE ON `platform_addresses`) for a direct address delete. ## Enum-domain CHECK constraints -Six TEXT columns carry a `CHECK (col IN (...))` clause whose IN-list is -built at migration time from `pub(crate) const *_LABELS` arrays declared -next to each writer function. Five mirror an upstream Rust enum; the -sixth (`contacts.state`) is a synthetic lifecycle label naming which -`ContactChangeSet` slot a row came from: +Four TEXT columns carry a `CHECK (col IN (...))` across four enum +domains. The IN-list is built at migration time from +`pub(crate) const *_LABELS` arrays declared next to each writer function. +Three domains mirror an upstream Rust enum; the fourth (`contacts.state`) +is a synthetic lifecycle label naming which `ContactChangeSet` slot a row +came from: | Table | Column | Source-of-truth const | |---|---|---| -| `wallet_metadata` | `network` | `sqlite::schema::wallet_meta::NETWORK_LABELS` | +| `wallets` | `network` | `sqlite::schema::wallets::NETWORK_LABELS` | | `account_registrations` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `account_address_pools` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | -| `account_address_pools` | `pool_type` | `sqlite::schema::accounts::POOL_TYPE_LABELS` | -| `core_derived_addresses` | `account_type` | `sqlite::schema::accounts::ACCOUNT_TYPE_LABELS` | | `asset_locks` | `status` | `sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS` | | `contacts` | `state` | `sqlite::schema::contacts::CONTACT_STATE_LABELS` | The const arrays are the single source of truth shared by the writer mapping functions (`network_to_str`, `account_type_db_label`, -`pool_type_db_label`, `status_str`, `contact_state_db_label`) and the -migration's CHECK clauses. +`status_str`, `contact_state_db_label`) and the migration's CHECK +clauses. Per-module `*_labels_match_enum` unit tests enforce set-equality between each const and the writer's codomain — drift (a renamed/added upstream variant) fails the test rather than landing as silent garbage @@ -607,10 +585,9 @@ in this document; the source files are canonical. ### Upstream-enum coupling -Three of the persisted enums live in the external `rust-dashcore` -crate (`key_wallet::Network`, `key_wallet::account::AccountType`, -`key_wallet::managed_account::address_pool::AddressPoolType`); the -fourth (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) +Two of the persisted enums live in the external `rust-dashcore` +crate (`key_wallet::Network`, `key_wallet::account::AccountType`); the +third (`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`) is in-tree and carries a `# Schema coupling` rustdoc block. Because the upstream definitions cannot be edited from this repository, @@ -627,20 +604,24 @@ mechanisms working together: TODO(rust-dashcore): once the upstream `key_wallet` crate is vendored or the project gains push access there, mirror the in-tree -`AssetLockStatus` `# Schema coupling` doc block on the three upstream +`AssetLockStatus` `# Schema coupling` doc block on the two upstream enums so a developer editing them upstream sees the constraint without having to grep this repo. ## Foreign-key conventions - All direct-child `wallet_id` columns are `BLOB(32)` references to - `wallet_metadata.wallet_id` with `ON DELETE CASCADE`. + `wallets.wallet_id` with `ON DELETE CASCADE`. - `identities.wallet_id` is the single nullable FK: NULL means orphan (no parent wallet registered yet). The orphan-to-parented promotion uses `COALESCE(identities.wallet_id, excluded.wallet_id)` on upsert. -- Identity-owned tables (`identity_keys`, `token_balances`, - `dashpay_profiles`, `dashpay_payments_overlay`) have no `wallet_id` - column. Cascade reaches them via `identities(identity_id)`. +- Identity-owned tables (`token_balances`, `dashpay_profiles`, + `dashpay_payments_overlay`) have no `wallet_id` column. Cascade reaches + them via `identities(identity_id)`. +- `identity_keys` is the exception among identity-owned tables: it carries + a `wallet_id BLOB NOT NULL` column and two `ON DELETE CASCADE` FKs + (`wallet_id → wallets`, `identity_id → identities`), so a delete on + either parent cascades to it. - `core_utxos.spent_in_txid` is cleared by the `setnull_core_utxos_on_tx_delete` trigger rather than a native `ON DELETE SET NULL` FK, because SQLite would null every column of a composite FK on SET NULL — including the NOT NULL `wallet_id`. @@ -657,7 +638,7 @@ having to grep this repo. | Trigger | Fires | Action | |---|---|---| | `setnull_core_utxos_on_tx_delete` | AFTER DELETE ON `core_transactions` | NULL `core_utxos.spent_in_txid` for the deleted tx | -| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallet_metadata` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` | +| `cascade_meta_on_wallet_delete` | AFTER DELETE ON `wallets` | delete `meta_wallet`, `meta_contact`, `meta_platform_address` rows by `wallet_id` | | `cascade_meta_on_identity_delete` | AFTER DELETE ON `identities` | delete `meta_identity`, `meta_token` rows by `identity_id` | | `cascade_meta_token_on_token_balance_delete` | AFTER DELETE ON `token_balances` | delete matching `meta_token` rows (direct balance delete) | | `cascade_meta_contact_on_contact_delete` | AFTER DELETE ON `contacts` | delete matching `meta_contact` rows (any state; direct contact delete) | @@ -667,4 +648,4 @@ having to grep this repo. | Version | File | Description | |---|---|---| -| V001 | `V001__initial.rs` | Full schema: all 23 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | +| V001 | `V001__initial.rs` | Full schema: all 21 tables (including the six `meta_*` per-object metadata tables), every index, and six triggers (`setnull_core_utxos_on_tx_delete` + the five `meta_*` soft-cascade triggers) | diff --git a/packages/rs-platform-wallet-storage/SECRETS.md b/packages/rs-platform-wallet-storage/SECRETS.md index 7f983aa071c..9f1361702b1 100644 --- a/packages/rs-platform-wallet-storage/SECRETS.md +++ b/packages/rs-platform-wallet-storage/SECRETS.md @@ -30,6 +30,17 @@ The rest of this document is the technical detail behind that boundary: the `secrets` backends, the `SecretStore` API, the error surface, and the threat model. +### Exception: the KV metadata API stores caller-supplied plaintext + +The boundary above is about the persister's own domain state. The +separate `KvStore` API (`kv` feature) is a deliberate, explicit exception: +it stores **arbitrary caller-supplied `Vec` values as PLAINTEXT** in +`meta_*` BLOB columns of the same `.db` (and therefore in every backup). +There is no encryption and no runtime content guard — the safety is +**caller-policed**. Callers MUST NOT put key or signing material through +`KvStore`; that is what `SecretStore` is for. The `KvStore` / +`KvStore::put` rustdoc carries the same `# Security` warning. + ## The `secrets` submodule `platform_wallet_storage::secrets` is part of the crate's default @@ -51,8 +62,22 @@ use platform_wallet_storage::secrets::{SecretBytes, SecretStore, SecretString, W let store = SecretStore::file("/var/lib/wallet/secrets.pwsvault", SecretString::new("pw"))?; let wallet = WalletId::from(wallet_id); + +// Tier-1 only (unprotected by an object password). `set`/`get` are +// `..,None` wrappers over `set_secret`/`get_secret`. store.set(&wallet, "mnemonic", &SecretBytes::from_slice(b"abandon ability ..."))?; let plaintext: Option = store.get(&wallet, "mnemonic")?; // never a bare Vec + +// Tier-2: protect a critical object under an extra OBJECT PASSWORD that +// the backend never sees. Reading it back REQUIRES the password. +let pw = SecretString::new("a strong object password"); +store.set_secret(&wallet, "seed", &SecretBytes::from_slice(b""), Some(&pw))?; +let seed = store.get_secret(&wallet, "seed", Some(&pw))?; // Some(secret) +// Reading a protected object WITHOUT the password fails closed: +assert!(store.get_secret(&wallet, "seed", None).is_err()); // NeedsPassword + +// Add / change / remove an object password in one atomic same-slot flow: +store.reprotect(&wallet, "seed", Some(&pw), None)?; // remove → now unprotected store.delete(&wallet, "mnemonic")?; // idempotent ``` @@ -61,6 +86,202 @@ filename); the parent directory is materialized on the first write. Use `SecretStore::os()` for the platform OS keyring arm instead of `SecretStore::file(..)`. +See **Two-tier secret protection** below for the model, the envelope +format, which tier defeats which adversary, and the strict fail-closed +read that is the heart of the opt-in scheme. + +### Two-tier secret protection + +Secret protection comes in two layers. Tier-1 is always on (it is just +"which backend you opened"); Tier-2 is opt-in, per critical object, and +backend-independent. + +| Tier | Provided by | Defeats | Mechanism | +|---|---|---|---| +| **1 — backend baseline** | the *backend* | another local user, a lost laptop, the vault at rest | OS keychain ACLs **or** Argon2id + XChaCha20-Poly1305 vault under a **real** passphrase | +| **2 — per-object password** | the *library*, above `SecretStore`, over **both** arms | **backend compromise** — the keychain scraped, or the vault stolen *and* its passphrase cracked | the object's bytes are Argon2id + XChaCha20-Poly1305 **enveloped under a per-object password BEFORE they reach the backend** | + +**Why Tier-2 is more than key granularity.** Its value is not a sub-key — +it is (a) an **independent human password the backend never sees** and (b) +**envelope-before-backend ordering**, so for a protected object the backend +only ever stores ciphertext. That is the first and only control that keeps +a chosen critical object confidential across a *full* backend compromise +(the A2/A3/A6 gap Tier-1 leaves open). + +Tier-2 has two guarantees of different strength: + +- **Confidentiality** (an attacker cannot *read* a protected secret) is + **unconditional** — the object password never enters any backend, so a + full backend dump yields only ciphertext + a per-object salt to + offline-Argon2id-crack against the password's entropy. +- **Integrity / anti-downgrade** is delivered by the **strict fail-closed + read** below and is **conditional on the caller's trusted model staying + intact** (see the documented residual). + +#### The envelope (wire format) + +Every value written through `set_secret`/`set` is wrapped in a +self-describing, authenticated envelope before it reaches the backend. The +backend (file vault or OS keychain) stores only these opaque bytes. + +The canonical wire format is **bincode-encoded** under a single +`WIRE_CONFIG = standard().with_big_endian().with_no_limit()` against +two `pub(crate)` types whose shapes are the source of truth — see +[`src/secrets/wire/envelope.rs`](src/secrets/wire/envelope.rs) and +[`src/secrets/wire/mod.rs`](src/secrets/wire/mod.rs): + +```rust +struct Envelope { version: u32, payload: Payload } +enum Payload { + Unprotected(Vec), // scheme 0 + Password { // scheme 1 + kdf: KdfParamsEncoded, // id u8 ‖ m_kib u32 ‖ t u32 ‖ p u32 + salt: [u8; 32], nonce: [u8; 24], + ciphertext: Vec, // includes the 16-byte Poly1305 tag + }, +} +``` + +`ENVELOPE_VERSION = 1` is bumped only on a breaking layout change, +independent of the vault `FORMAT_VERSION`. Decoding goes through a +budget-limited `DECODE_CONFIG = WIRE_CONFIG.with_limit::()` so a +hostile blob declaring a multi-GiB length prefix is rejected before +allocation (security-positive deviation from the no-limit encoder +config). Trailing bytes after a valid decode are also refused — +`consumed == blob.len()` is a fail-closed invariant. + +- **AAD (scheme 1)** is bincode-encoded from `Tier2Aad` + ([`src/secrets/wire/aad.rs`](src/secrets/wire/aad.rs)), which binds + `domain (PWSEV-TIER2-AAD-v2) ‖ envelope_version ‖ scheme_discriminant + ‖ kdf ‖ salt ‖ wallet_id ‖ label`. The vault's own per-entry AAD goes + through `EntryAad` (`domain (PWSV-ENTRY-AAD-v2) ‖ format_version ‖ + wallet_id ‖ label`) and the vault verify-token AAD through `VerifyAad` + (`domain (PWSV-VERIFY-AAD-v2) ‖ format_version ‖ salt ‖ kdf`). All + three domain tags are pair-wise byte-disjoint by construction. A + protected blob relocated to another slot — or any in-place header + edit — fails the tag (relocation/header-tamper resistance). On the + file arm this AAD is *in addition* to the vault's own per-entry AAD + + tag; on the OS arm it is the only authentication layer. +- **KDF ceiling before derivation (anti-DoS).** The KDF params live in + the (attacker-controllable) header, so on a read the Argon2 ceiling + is enforced **before** any derivation/allocation — both the wider + `enforce_bounds` (algorithm id + floors/ceilings) AND a tighter + per-read gate that refuses any `m_kib > default_target().m_kib` OR + `t > default_target().t`. A forged header cannot inflate memory by + more than the shipped default or CPU by more than the shipped + iteration count. +- **No vault format bump.** The envelope lives *inside* the entry + bytes, identical over File and Os, so there is no vault-parser or + migration change. +- **Size cap.** The plaintext is capped at `MAX_PLAINTEXT_LEN` + (`MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD`), uniformly for both + schemes, so the enveloped bytes always fit the backend's own + `MAX_SECRET_LEN` cap and the user-visible limit is stable regardless + of scheme. Oversize → `SecretTooLarge { found, max }` with + `max = MAX_PLAINTEXT_LEN` (re-exported as `secrets::MAX_PLAINTEXT_LEN`). +- **Unknown envelope version** → `UnsupportedEnvelopeVersion` — fail + closed **regardless of the password**: an envelope tagged for a + future layout can be neither safely unwrapped nor treated as + unprotected. +- **Unparseable bytes / unknown scheme tag / trailing garbage** → + `Corruption`. There is no magic-byte peek — every blob runs through + the bincode decoder, and anything that does not round-trip cleanly + with `consumed == blob.len()` fails closed. + +#### The strict, fail-closed read + +The defining risk of any opt-in "some objects are extra-protected" scheme +is **strip / downgrade**: an attacker who can WRITE the backend replaces a +protected blob with a fresh, internally-valid *unprotected* (scheme-0) blob +carrying a chosen seed/xpriv. There is nothing in that blob alone to prove +an envelope was *expected*, so inferring protection from the stored bytes +would silently return the attacker's secret — funds redirection, password +prompt bypassed. + +The fix: **the "expected-protected" bit lives in the CALLER's trusted +model, surfaced solely by whether a password is supplied to `get_secret` — +NEVER inferred from the blob.** The library does not guess and does not +persist the expectation. A supplied password *is* the assertion "this +object must be protected": + +| `password` arg | stored blob | result | +|---|---|---| +| `Some(pw)` | valid scheme-1 | the secret, or `WrongPassword` on tag fail | +| **`Some(pw)`** | **valid scheme-0 envelope** | **`ExpectedProtectedButUnsealed` — FAIL CLOSED** | +| `Some(pw)` | scheme-1 but truncated/corrupt | `Corruption` | +| `Some/None` | unknown envelope version | `UnsupportedEnvelopeVersion` | +| `Some/None` | unparseable / non-envelope bytes / trailing garbage | `Corruption` | +| `None` | valid scheme-1 | `NeedsPassword` (never ciphertext) | +| `None` | valid scheme-0 envelope | the secret | +| any | absent entry | `Ok(None)` (deletion = DoS, never injection) | + +The load-bearing row is **`Some(pw)` + scheme-0 envelope ⇒ +`ExpectedProtectedButUnsealed`**: with a password in hand, an +unprotected envelope can only mean a strip, so it is refused and **no +bytes are returned**. A consumer bug alone — over- or under-supplying +a password — fails closed in *every* direction. + +**Arm asymmetry.** On the file arm the stored bytes are themselves sealed +under the vault key, so producing a *readable* stripped blob at a slot +requires the vault key; a cold/backup-swap actor can only corrupt +(→ DoS), not inject-to-readable. On the OS-keychain arm the stored item is +the bare envelope with no second seal, so the strip defence there leans +entirely on the `Some(pw)` strict rule plus the consumer's metadata +integrity — this is where the residual bites hardest. + +**Documented residual (out of the library's reach).** If an attacker ALSO +rewrites the consumer's trusted DB so the consumer calls `get_secret(X, +None)` for a stripped object, the `(scheme-0, None)` quadrant returns the +attacker's bytes. The library only ever sees the blob and the caller's +`Some/None`; the "should be protected" fact lives entirely in the +consumer's metadata store. **Anti-downgrade strength therefore equals the +tamper-resistance of the consumer's protection-status record** — store it +as integrity-protected, security-critical state (it is one more field +alongside the addresses/policy the wallet DB must already protect). + +**Value rollback is NOT defended.** Restoring an *older valid* scheme-1 +envelope under the *current* password decrypts cleanly. The strict read +closes the strip/downgrade injection, not value rollback; if +backup-swap/restore-old is in scope, anchor a monotonic version in +integrity-protected consumer metadata. Do not mistake the strict read for +rollback protection. + +#### Add / change / remove an object password + +`reprotect(service, label, current, new)` does it in one same-slot +unwrap→rewrap→overwrite: read under the `current` expectation (so a strip +is caught before any rewrite), then write under `new` — `None`→`Some` adds, +`Some`→`Some` changes, `Some`→`None` removes. An absent object returns +`Err(SecretStoreError::NoEntry)` — `reprotect` is operational, so absence +means the caller's protection-status record disagrees with the backend and +must not be silently dropped. The rewrite is a same-slot overwrite — atomic on the file arm, +and on the OS arm inheriting the backend's single-item-replace contract — +so a crash between the read and the commit leaves the prior value intact +and readable under `current`. **After a successful call the consumer MUST +update its own protection-status record** (the protection expectation lives +there). There is **no password recovery** — losing an object password +bricks that object (an availability trade-off the UX must state plainly). + +#### Entropy policy is the consumer's + +The library enforces only **non-blank** at enrol (and a coarse +`MIN_PASSPHRASE_LEN` floor, `1` today = merely non-blank) for both the +vault passphrase and the Tier-2 object password. It ships **no** +password-strength estimator: real entropy policy (zxcvbn-style strength, +dictionary checks, UX feedback) is locale- and threat-specific and is the +**consumer's responsibility**. For a protected object the password's +entropy is the *whole* guarantee against an offline Argon2id attacker who +already holds the backend — choose it accordingly. + +#### Greenfield only — no legacy tolerance + +The envelope is the only on-disk Tier-2 format this build understands. +A decrypted entry that does not bincode-decode to a valid `Envelope` +under `WIRE_CONFIG` (including trailing-byte extension probes) surfaces +as `Corruption` on every read — there is no magic-byte peek and no +magic-less raw legacy path. The shipped wire layer is the source of +truth; older non-enveloped stored values are out of scope. + ### Internal SPI Below `SecretStore`, `EncryptedFileStore` and `default_credential_store` @@ -118,6 +339,29 @@ unwrapped copy is allocated. One file, one passphrase, one lock — a multi-wallet store cannot lock its other wallets out by construction. Errors surface as the typed `SecretStoreError` through `SecretStore`. + On Unix the vault's parent directory must not be group/other writable + (`mode & 0o022`): directory write access governs rename/replace of the + vault, so a writable parent is refused at `open` with + `SecretStoreError::InsecureParentDir` (the A1 guarantee depends on it). + A read-only group-accessible parent (`0o750`) is accepted — it only + leaks filenames, never the 0600-protected vault contents. + Each secret is capped at `MAX_SECRET_LEN` (64 KiB) at the write + boundary — generously above any mnemonic/seed/xpriv — so a single + oversized entry cannot inflate the shared document past the read-side + 128 MiB ceiling and brick every wallet on the next open. (Through + `SecretStore::set_secret`/`set` the user-facing plaintext cap is the + slightly lower `MAX_PLAINTEXT_LEN`, leaving room for the envelope + overhead; see **Two-tier secret protection**.) + **Blank passphrase is rejected.** `open` (and `rekey`) refuse a blank + (empty / all-whitespace) passphrase with `SecretStoreError::BlankPassphrase` + — a blank passphrase derives a key from a public salt only, i.e. + obfuscation, not confidentiality. This is an **intended behavioural + break** for any caller that relied on `SecretString::empty()`. A + deliberate keyless vault uses the explicit + `EncryptedFileStore::open_unprotected(path)` / + `SecretStore::file_unprotected(path)` door instead (use it only where the + stored secrets carry their own Tier-2 object password, or as a staging + step before `rekey` to a real passphrase — the empty→real migration). - **OS keyring (`SecretStore::os` / `default_credential_store`)** — returns an `Arc` over the platform's default credential store. The backend on Linux/FreeBSD is @@ -135,6 +379,18 @@ unwrapped copy is allocated. with `NoDefaultStore`. Callers that need durable storage on a headless host should pin `SecretStore::file(...)` (encrypted-file vault) instead of relying on the OS keyring. + + **Enumerable metadata (OS arm).** Each entry is keyed by + `service = SERVICE_PREFIX + hex(wallet_id)` and `user = label`, stored + as **plaintext, enumerable** keyring metadata: same-user list-only + tooling can see which wallet ids exist and which slot kinds (labels) + each has, without unlocking any secret. This is dominated by the + already-accepted same-user (A2/A3) residual. The `keyring-core` 1.0.0 + `build` modifiers are vendor-specific creation hints, not a replacement + for the `(service, user)` identity, so there is no portable knob to + redact the pair; operators who need metadata hiding should use the file + vault, whose `(wallet_id, label)` map lives only inside the sealed + vault. Prefer non-descriptive labels on the OS arm regardless. - **Tests** — integration tests construct a tempdir-backed `EncryptedFileStore` directly via `EncryptedFileStore::open(tempfile::tempdir()?.path().join("vault.pwsvault"), SecretString::new("..."))`, @@ -150,25 +406,52 @@ automatic fallback between backends. `SecretStore` returns the typed `SecretStoreError`. For the file arm this is **lossless**: `WrongPassphrase`, `Corruption`, `AlreadyLocked`, `KdfFailure`, `VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, -`VaultTooLarge`, and `InvalidLabel` are distinct typed variants -(`VaultTooLarge` surfaces when the on-disk vault exceeds the 128 MiB -ceiling). For the OS arm, +`InsecureParentDir`, `SecretTooLarge`, `VaultTooLarge`, `Encrypt`, and +`InvalidLabel` are distinct typed variants. The Tier-2 layer adds five more: +`ExpectedProtectedButUnsealed` (the fail-closed strip refusal), +`NeedsPassword` (a protected object read with no password), `WrongPassword` +(object-password tag fail — distinct from the Tier-1 `WrongPassphrase`), +`BlankPassphrase` (a blank vault passphrase or object password), and +`UnsupportedEnvelopeVersion { found }` (a future envelope format, fail +closed regardless of the password). The four Tier-2 credential/protection +*state* variants project to a recoverable `NoStorageAccess` (boxed, +downcast-recoverable, like `WrongPassphrase`); `UnsupportedEnvelopeVersion` +joins the secret-free `BadStoreFormat` group. `VaultTooLarge` surfaces when +the on-disk vault exceeds the read-side ceiling; `SecretTooLarge` rejects an +oversized secret at the write boundary before it can inflate the shared +vault; `InsecureParentDir` refuses a vault whose parent directory is +group/other-writable (a writable parent governs rename/replace despite the +file's own `0600`); `Encrypt` is the (effectively unreachable) AEAD +encrypt-side failure, kept typed so a write failure is never mislabeled a +key-derivation error. For the OS arm, `keyring_core::Error` projects best-effort into `SecretStoreError::OsKeyring { kind: OsKeyringErrorKind }`, a payload-free discriminant — keyring variants carrying raw bytes (`BadEncoding`, `BadDataFormat`) are collapsed so their bytes never enter the error (CWE-209/CWE-532). +**`WrongPassword` on the OS arm is ambiguous.** A Tier-2 envelope AEAD tag +failure surfaces as `WrongPassword`, but on the OS-keyring arm the stored +item is the bare envelope with no second authentication layer, so a tag +failure can mean EITHER a wrong object password OR a corrupted keychain +item — one AEAD tag cannot disambiguate the two. Treat `WrongPassword` on +the OS arm as "wrong password or corrupted item." On the file arm it is +unambiguous: the vault's own per-entry tag has already authenticated the +stored bytes before the envelope is parsed. + The internal SPI projection `From for keyring_core::Error` keeps the `WrongPassphrase` / `AlreadyLocked` variants recoverable: they ride in `NoStorageAccess` with the typed `SecretStoreError` boxed as the source, so an SPI-only consumer can recover them via `err.source().and_then(|s| s.downcast_ref::())`. The `BadStoreFormat` group (`Corruption`, `KdfFailure`, -`VersionUnsupported`, `MalformedVault`, `InsecurePermissions`, -`VaultTooLarge`, `Decrypt`, `OsKeyring`) has no box slot and carries only a -secret-free string; those remain fully typed on the `SecretStore` path -(so `VaultTooLarge` is not losslessly recoverable through the SPI downcast). +`VersionUnsupported`, `UnsupportedEnvelopeVersion`, `MalformedVault`, +`InsecurePermissions`, `InsecureParentDir`, `SecretTooLarge`, +`VaultTooLarge`, `Decrypt`, `Encrypt`, `OsKeyring`) has no box slot and +carries only a secret-free +string; those remain fully typed on the `SecretStore` path (so e.g. +`VaultTooLarge` / `SecretTooLarge` are not losslessly recoverable through +the SPI downcast). `keyring_core::Error` is safe to `Display` (`{ }`-format), but `{:?}`-format embeds `BadEncoding(Vec)` / `BadDataFormat(Vec, _)` diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 59a6e45eaea..45d56406182 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -7,15 +7,17 @@ //! //! Per-wallet tables carry `wallet_id BLOB` in (or as all of) their //! primary key plus a native `FOREIGN KEY (wallet_id) REFERENCES -//! wallet_metadata(wallet_id) ON DELETE CASCADE`. Identity-owned -//! tables (`identity_keys`, `dashpay_profiles`, -//! `dashpay_payments_overlay`, `token_balances`) are keyed by -//! `identity_id` only; their FK targets `identities(identity_id)` so -//! cascade flows `wallet_metadata → identities → child` through the -//! nullable `identities.wallet_id` link. `identities.wallet_id` is -//! NULL-allowed so identity-only flows (no parent wallet, e.g. the -//! identity-sync manager populating rows before any wallet is -//! registered) work without a placeholder. +//! wallets(wallet_id) ON DELETE CASCADE`. Identity-owned +//! tables (`dashpay_profiles`, `dashpay_payments_overlay`, +//! `token_balances`) are keyed by `identity_id` only; their FK targets +//! `identities(identity_id)` so cascade flows `wallets → +//! identities → child` through the nullable `identities.wallet_id` +//! link. `identity_keys` additionally carries its own `wallet_id` +//! column (so per-wallet reads stay a direct `WHERE wallet_id = ?`) +//! and keeps the `identity_id` FK for the identity-delete cascade. +//! `identities.wallet_id` is NULL-allowed so identity-only flows (no +//! parent wallet, e.g. the identity-sync manager populating rows +//! before any wallet is registered) work without a placeholder. //! //! The one relationship that stays a trigger is //! `core_utxos.spent_in_txid` clearing to NULL on transaction delete — @@ -27,10 +29,10 @@ //! read back) at every connection open via `open_conn` //! (`src/sqlite/conn.rs`). //! -//! Enum-shaped TEXT columns (`network`, `account_type`, `pool_type`, -//! `status`, `state`) carry a `CHECK (col IN (...))` clause whose +//! Enum-shaped TEXT columns (`network`, `account_type`, `status`, +//! `state`) carry a `CHECK (col IN (...))` clause whose //! IN-list is built from the `*_LABELS` const arrays in -//! `crate::sqlite::schema::{wallet_meta, accounts, asset_locks, +//! `crate::sqlite::schema::{wallets, accounts, asset_locks, //! contacts}`. The consts are the single source of truth shared with //! the writer mapping functions; the per-module `*_labels_match_enum` //! unit tests enforce set-equality between each const and its writer's @@ -46,18 +48,25 @@ fn build_check_in(labels: &[&str]) -> String { } pub fn migration() -> String { - let network_check = build_check_in(crate::sqlite::schema::wallet_meta::NETWORK_LABELS); + let network_check = build_check_in(crate::sqlite::schema::wallets::NETWORK_LABELS); let account_type_check = build_check_in(crate::sqlite::schema::accounts::ACCOUNT_TYPE_LABELS); - let pool_type_check = build_check_in(crate::sqlite::schema::accounts::POOL_TYPE_LABELS); let asset_lock_status_check = build_check_in(crate::sqlite::schema::asset_locks::ASSET_LOCK_STATUS_LABELS); let contact_state_check = build_check_in(crate::sqlite::schema::contacts::CONTACT_STATE_LABELS); + // Stamp the header `application_id` so a foreign refinery-versioned + // SQLite DB can be told apart from a wallet-storage DB (asserted in + // `open()` pre-migration and in `restore_from`'s staged validation). + // Splice the constant in decimal — `PRAGMA` takes no bound params. + let application_id = crate::sqlite::conn::APPLICATION_ID; + format!( "\ -CREATE TABLE wallet_metadata ( +PRAGMA application_id = {application_id}; + +CREATE TABLE wallets ( wallet_id BLOB NOT NULL PRIMARY KEY, network TEXT NOT NULL CHECK (network IN {network_check}), birth_height INTEGER NOT NULL @@ -69,17 +78,7 @@ CREATE TABLE account_registrations ( account_index INTEGER NOT NULL, account_xpub_bytes BLOB NOT NULL, PRIMARY KEY (wallet_id, account_type, account_index), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE -); - -CREATE TABLE account_address_pools ( - wallet_id BLOB NOT NULL, - account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), - account_index INTEGER NOT NULL, - pool_type TEXT NOT NULL CHECK (pool_type IN {pool_type_check}), - snapshot_blob BLOB NOT NULL, - PRIMARY KEY (wallet_id, account_type, account_index, pool_type), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE TABLE core_transactions ( @@ -91,7 +90,7 @@ CREATE TABLE core_transactions ( finalized INTEGER NOT NULL, record_blob BLOB NOT NULL, PRIMARY KEY (wallet_id, txid), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE INDEX idx_core_transactions_height ON core_transactions(wallet_id, height); @@ -106,7 +105,7 @@ CREATE TABLE core_utxos ( spent INTEGER NOT NULL, spent_in_txid BLOB, PRIMARY KEY (wallet_id, outpoint), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE INDEX idx_core_utxos_spent ON core_utxos(wallet_id, spent); @@ -130,50 +129,46 @@ CREATE TABLE core_instant_locks ( txid BLOB NOT NULL, islock_blob BLOB NOT NULL, PRIMARY KEY (wallet_id, txid), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); -CREATE TABLE core_derived_addresses ( - wallet_id BLOB NOT NULL, - account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), - account_index INTEGER NOT NULL, - address TEXT NOT NULL, - derivation_path TEXT NOT NULL, - used INTEGER NOT NULL, - PRIMARY KEY (wallet_id, account_type, address), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE -); - -CREATE INDEX idx_core_derived_addresses_addr ON core_derived_addresses(wallet_id, address); - CREATE TABLE core_sync_state ( wallet_id BLOB NOT NULL PRIMARY KEY, last_processed_height INTEGER, synced_height INTEGER, - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + -- Bincode-encoded `dashcore::ephemerealdata::chain_lock::ChainLock`. + -- NULL until the first ChainLock has been applied and flushed. + last_applied_chain_lock BLOB, + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE TABLE identities ( identity_id BLOB NOT NULL PRIMARY KEY, wallet_id BLOB, - wallet_index INTEGER, + identity_index INTEGER, entry_blob BLOB NOT NULL, tombstoned INTEGER NOT NULL, - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE INDEX idx_identities_wallet ON identities(wallet_id); CREATE TABLE identity_keys ( + wallet_id BLOB NOT NULL, identity_id BLOB NOT NULL, key_id INTEGER NOT NULL, public_key_blob BLOB NOT NULL, public_key_hash BLOB NOT NULL, - PRIMARY KEY (identity_id, key_id), + -- Reserved for a future typed projection; always NULL today. + -- derivation_indices lives inside public_key_blob (the + -- IdentityKeyWire blob is the single source of truth). + derivation_blob BLOB, + PRIMARY KEY (wallet_id, identity_id, key_id), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE, FOREIGN KEY (identity_id) REFERENCES identities(identity_id) ON DELETE CASCADE ); -CREATE INDEX idx_identity_keys_identity ON identity_keys(identity_id); +CREATE INDEX idx_identity_keys_wallet_identity ON identity_keys(wallet_id, identity_id); CREATE TABLE contacts ( wallet_id BLOB NOT NULL, @@ -188,7 +183,7 @@ CREATE TABLE contacts ( accepted_accounts BLOB, updated_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (wallet_id, owner_id, contact_id), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE TABLE platform_addresses ( @@ -199,7 +194,7 @@ CREATE TABLE platform_addresses ( balance INTEGER NOT NULL, nonce INTEGER NOT NULL, PRIMARY KEY (wallet_id, address), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE TABLE platform_address_sync ( @@ -207,7 +202,7 @@ CREATE TABLE platform_address_sync ( sync_height INTEGER NOT NULL, sync_timestamp INTEGER NOT NULL, last_known_recent_block INTEGER NOT NULL, - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE TABLE asset_locks ( @@ -219,7 +214,7 @@ CREATE TABLE asset_locks ( amount_duffs INTEGER NOT NULL, lifecycle_blob BLOB NOT NULL, PRIMARY KEY (wallet_id, outpoint), - FOREIGN KEY (wallet_id) REFERENCES wallet_metadata(wallet_id) ON DELETE CASCADE + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); CREATE TABLE token_balances ( @@ -316,7 +311,7 @@ CREATE TABLE meta_platform_address ( -- Soft-cascade cleanup: drop a scope's metadata when its parent object -- is deleted. SQLite fires these for parents removed by an FK cascade --- too (e.g. wallet_metadata delete → identities cascade → identity +-- too (e.g. wallets delete → identities cascade → identity -- trigger), so deleting a wallet cleans its metadata transitively. -- -- Two root brooms key on the deleted parent's id alone so they reach @@ -330,7 +325,7 @@ CREATE TABLE meta_platform_address ( -- row, parentless included. Keys on wallet_id only, so contact state and -- whether the typed parent ever existed are both irrelevant. CREATE TRIGGER cascade_meta_on_wallet_delete -AFTER DELETE ON wallet_metadata +AFTER DELETE ON wallets FOR EACH ROW BEGIN DELETE FROM meta_wallet WHERE wallet_id = OLD.wallet_id; diff --git a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs index 17ff3d0ba52..698447fe1c9 100644 --- a/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs +++ b/packages/rs-platform-wallet-storage/src/bin/platform-wallet-storage.rs @@ -149,11 +149,8 @@ impl CliError { fn run(cli: Cli) -> Result { let auto_backup_dir: Option = cli.auto_backup_dir; - // `prune` is a pure filesystem op against the backups directory — - // `--db` is meaningless for it and must not be required. Handle the - // subcommand BEFORE extracting `cli.db` so the operator can run - // `prune --backups-dir ... --keep-last N` without - // also passing a database path. + // `prune` is a pure filesystem op; `--db` is meaningless, so handle it + // before requiring `cli.db`. if let Cmd::Prune(args) = &cli.cmd { return run_prune(args); } @@ -167,11 +164,8 @@ fn run(cli: Cli) -> Result { return run_restore(&db, args, auto_backup_dir.as_deref()); } - // For `migrate --no-auto-backup`, we must keep `auto_backup_dir = - // None` so the open-time pre-migration backup is skipped. For - // every other subcommand we leave the user-configured dir (or the - // default) in place — the library's safe-by-default semantics - // still apply. + // `migrate --no-auto-backup` clears `auto_backup_dir` so the open-time + // pre-migration backup is skipped; other subcommands keep the default. let mut config = SqlitePersisterConfig::new(&db); if let Some(dir) = auto_backup_dir.clone() { config = config.with_auto_backup_dir(Some(dir)); @@ -183,10 +177,8 @@ fn run(cli: Cli) -> Result { } } - // Migrate (idempotent): open performs it. We capture the prior - // schema version so we can print "applied: N". A transient read - // failure must surface — silently reading 0 would print a wrong - // `applied:` count. + // Migrate is done by `open`; capture pre/post versions to print + // "applied: N". A read failure must surface, not be read as 0. if let Cmd::Migrate(_) = &cli.cmd { let pre_version = peek_schema_version(&db).map_err(|e| CliError::runtime(e.to_string()))?; let _persister = SqlitePersister::open(config.clone()).map_err(map_open_err_for_cli)?; @@ -228,21 +220,16 @@ fn map_open_err_for_cli(err: WalletStorageError) -> CliError { /// transient failure for "version 0". fn peek_schema_version(db: &Path) -> Result, rusqlite::Error> { use rusqlite::{OpenFlags, OptionalExtension}; - // Open READ-ONLY (no SQLITE_OPEN_CREATE) so a typo'd --db path errors - // out at this gate rather than silently materialising a zero-byte - // SQLite file that bypasses the crate's 0o600 invariant. A genuinely - // fresh `migrate` invocation against a non-existent DB file is normal - // — surface that as `Ok(None)` so the migrate path proceeds and - // `SqlitePersister::open` creates the file under the 0o600 invariant. + // A missing path is a normal fresh `migrate`: `Ok(None)` lets + // `SqlitePersister::open` create the file under the 0o600 invariant, + // instead of materialising a stub here that bypasses it. if !db.exists() { return Ok(None); } - let conn = rusqlite::Connection::open_with_flags( - db, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI, - )?; - // Pre-migration the history table may not exist yet — that is a - // legitimate "no version" answer, not a failure. + // READ-ONLY, URI parsing off (matches the open-conn choke-point) so a + // `--db` path can't smuggle `file:` query params defeating read-only. + let conn = rusqlite::Connection::open_with_flags(db, OpenFlags::SQLITE_OPEN_READ_ONLY)?; + // Pre-migration the history table may legitimately not exist. let has_history = conn .query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'refinery_schema_history'", @@ -266,8 +253,7 @@ fn peek_schema_version(db: &Path) -> Result, rusqlite::Error> { } fn run_backup(persister: &SqlitePersister, args: BackupArgs) -> Result { - // `backup_to` is the single authority on refuse-to-overwrite — it - // returns `BackupDestinationExists` for a pre-existing file path. + // `backup_to` owns refuse-to-overwrite (`BackupDestinationExists`). let path = persister.backup_to(&args.out).map_err(|e| match e { WalletStorageError::BackupDestinationExists { path } => CliError::runtime(format!( "backup destination exists and refuses to overwrite: {}", @@ -294,9 +280,8 @@ fn run_restore( eprintln!("warning: auto-backup skipped (--no-auto-backup)"); SqlitePersister::restore_from_skip_backup(db, &args.from) } else { - // CLI default mirrors the persister config default - // (`/backups/auto/`). The CLI doesn't open a - // persister here, so we compute the default inline. + // No persister is opened here, so compute the config default + // (`/backups/auto/`) inline. let resolved_dir: PathBuf = match auto_backup_dir { None => default_auto_backup_dir(db), Some(p) => p.to_path_buf(), @@ -350,10 +335,8 @@ fn run_prune(args: &PruneArgs) -> Result { mod tests { use super::*; - /// `peek_schema_version` on a non-existent path must NOT materialise - /// a zero-byte SQLite file at that path — opening READ-ONLY (no - /// SQLITE_OPEN_CREATE) keeps a typo from being rewarded with a stub - /// file lacking the crate's 0o600 mode invariant. + /// `peek_schema_version` on a missing path must not materialise a stub + /// file (opening READ-ONLY) that would lack the 0o600 invariant. #[test] fn peek_schema_version_on_missing_db_does_not_create_stub() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/packages/rs-platform-wallet-storage/src/kv.rs b/packages/rs-platform-wallet-storage/src/kv.rs index 8dd1e3d22a1..ddfadbfcb2d 100644 --- a/packages/rs-platform-wallet-storage/src/kv.rs +++ b/packages/rs-platform-wallet-storage/src/kv.rs @@ -10,19 +10,15 @@ //! serialization (bincode, JSON, protobuf, raw bytes). Keys are //! bounded `TEXT` (1..=128 chars). //! -//! Scoping: each [`ObjectId`] variant addresses a dedicated table. The -//! [`ObjectId::Global`] slot has no parent and survives wallet deletion. -//! Every other variant names a wallet object, but a write does NOT -//! require that object to exist yet — metadata may be attached ahead of -//! sync. When the object is later deleted, an `AFTER DELETE` trigger on -//! its parent table removes the matching metadata. However, if the -//! parent object is never created, or is removed via a path the trigger -//! does not cover, the metadata row may persist as an orphan. This is an -//! accepted limitation across all scopes; a future garbage-collection pass -//! is expected to reap such orphans (no live parent, e.g. older than ~1 -//! week) — callers should not rely on orphan metadata persisting forever. -//! The same key string under different scopes is independent — the scopes -//! live in separate tables. +//! Scoping: each [`ObjectId`] variant addresses a dedicated table, so the +//! same key string under different scopes is independent. +//! [`ObjectId::Global`] has no parent and survives wallet deletion. Other +//! variants name a wallet object but a write does NOT require it to exist +//! yet (metadata may be attached ahead of sync); an `AFTER DELETE` trigger +//! reaps the metadata when the object is deleted. Rows whose parent is +//! never created, or removed via a path the trigger misses, may persist as +//! orphans — an accepted limitation; a future GC pass is expected to reap +//! them, so callers must not rely on orphans living forever. //! //! This API is **independent of [`platform_wallet::changeset::PlatformWalletPersistence`]**: //! KV is for app metadata, not wallet domain state. Reads and writes go @@ -33,16 +29,10 @@ use platform_wallet::wallet::platform_wallet::WalletId; /// Scope of a metadata entry — one variant per dedicated `meta_*` table. /// -/// [`ObjectId::Global`] has no parent and survives wallet deletion. The -/// other variants name a wallet object but carry no insert-time -/// existence requirement: metadata may be written before its parent -/// object is synced into its typed table. An `AFTER DELETE` trigger on -/// each parent removes the matching metadata when the object is deleted. -/// -/// **Orphan metadata:** if the parent object is never created, or is -/// removed via a path the trigger does not cover, the metadata row may -/// persist as an orphan. A future GC pass is expected to reap such -/// rows; do not rely on them living forever. +/// [`ObjectId::Global`] has no parent and survives wallet deletion. Other +/// variants name a wallet object but may be written before it is synced; +/// an `AFTER DELETE` trigger reaps the metadata when the object is deleted. +/// See the module docs for the orphan-metadata limitation. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ObjectId { /// Global app metadata; no parent (`meta_global`). @@ -69,25 +59,17 @@ pub enum ObjectId { }, } -/// Maximum allowed key length. Enforced in Rust as a **byte**-length -/// bound (`validate_key` rejects with `KeyTooLong`/`KeyEmpty` on -/// `key.len()`) and in SQL as a **code-point** bound -/// (`CHECK (length(key) BETWEEN 1 AND 128)`, where SQLite's `length()` -/// counts UTF-8 code points). For ASCII keys the two coincide; for -/// non-ASCII keys the Rust byte bound is the stricter of the two, so no -/// over-length key reaches SQL. +/// Maximum allowed key length, in **code points**. `validate_key` counts +/// `chars().count()`; the SQL `CHECK (length(key) BETWEEN 1 AND 128)` uses +/// the same unit (SQLite `length()` counts code points), so the two bounds +/// accept exactly the same key set. pub const MAX_KEY_LEN: usize = 128; /// Hard cap on the size of a single KV value, in bytes, so a tampered or /// corrupted backup row cannot force a multi-gigabyte allocation on the -/// next `get`. -/// -/// Kept in sync MANUALLY with the `BLOB_SIZE_LIMIT_BYTES` ceiling on -/// bincode-serde blobs in `sqlite::schema::blob`: the `sqlite` and `kv` -/// features compile independently, so a `const`-level cross-reference -/// between the two modules can't be relied on. Change both together if -/// the ceiling moves. -pub const MAX_VALUE_LEN: usize = 16 * 1024 * 1024; +/// next `get`. Shares the crate-root [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES) +/// ceiling with the bincode-serde BLOB decode cap. +pub const MAX_VALUE_LEN: usize = crate::SIZE_LIMIT_BYTES; /// Errors returned by [`KvStore`] operations. /// @@ -99,14 +81,14 @@ pub enum KvError { #[error("kv key is empty")] KeyEmpty, - /// Key exceeded [`MAX_KEY_LEN`]. - #[error("kv key too long: {len} bytes (max {})", MAX_KEY_LEN)] + /// Key exceeded [`MAX_KEY_LEN`]. `len` is the key's code-point count + /// (the same unit the SQL `length()` CHECK uses). + #[error("kv key too long: {len} code points (max {})", MAX_KEY_LEN)] KeyTooLong { len: usize }, /// A value exceeded [`MAX_VALUE_LEN`]. Raised by `put` before the - /// INSERT and by `get` before the bytes are materialised, so an - /// oversize value never lands and a tampered row never OOMs the - /// process. + /// INSERT and by `get` before materialising, so a tampered row can't + /// OOM the process. #[error("kv value too large: {found} bytes (max {max})")] ValueTooLarge { found: usize, max: usize }, @@ -124,6 +106,15 @@ pub enum KvError { /// /// See the module-level docs for scoping and value semantics. Each /// [`ObjectId`] variant addresses a dedicated table. +/// +/// # Security +/// +/// Values are stored **PLAINTEXT** in the persister `.db` and in every +/// backup copied from it. This API is the explicit, caller-policed +/// plaintext exception to the crate's no-secrets-in-the-db boundary +/// (see `SECRETS.md`). **NEVER store key or signing material here** — +/// mnemonics, seeds, private keys, or anything that could move funds. +/// Use [`SecretStore`](crate::secrets::SecretStore) for secret material. pub trait KvStore { /// Read the value bound to `(scope, key)`. Returns `Ok(None)` when /// the key is absent. Backends MUST reject values larger than @@ -140,6 +131,12 @@ pub trait KvStore { /// Backends MUST reject a `value` larger than [`MAX_VALUE_LEN`] with /// [`KvError::ValueTooLarge`] before writing, so a `put` can never /// plant a row a later `get` would refuse to materialise. + /// + /// # Security + /// + /// `value` is stored **PLAINTEXT** in the `.db` and all backups. + /// NEVER store key/signing material here — use + /// [`SecretStore`](crate::secrets::SecretStore). fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError>; /// Remove the row bound to `(scope, key)`. Idempotent — a missing @@ -156,14 +153,15 @@ pub trait KvStore { fn list_keys(&self, scope: &ObjectId, prefix: Option<&str>) -> Result, KvError>; } -/// Validate a key against the length bounds. Used by [`KvStore`] -/// implementations as a typed-error pre-check before reaching SQL. +/// Typed-error pre-check used by [`KvStore`] impls before reaching SQL. +/// Counts code points to match the SQL CHECK unit (see [`MAX_KEY_LEN`]). pub(crate) fn validate_key(key: &str) -> Result<(), KvError> { if key.is_empty() { return Err(KvError::KeyEmpty); } - if key.len() > MAX_KEY_LEN { - return Err(KvError::KeyTooLong { len: key.len() }); + let code_points = key.chars().count(); + if code_points > MAX_KEY_LEN { + return Err(KvError::KeyTooLong { len: code_points }); } Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/lib.rs b/packages/rs-platform-wallet-storage/src/lib.rs index b75ddee4658..dbfb05b9c9a 100644 --- a/packages/rs-platform-wallet-storage/src/lib.rs +++ b/packages/rs-platform-wallet-storage/src/lib.rs @@ -27,6 +27,13 @@ #![deny(rust_2018_idioms)] #![deny(unsafe_code)] +/// Shared 16 MiB ceiling for the two independent size caps in this crate: +/// the KV value cap ([`kv::MAX_VALUE_LEN`]) and the bincode-serde BLOB +/// decode cap (`sqlite::schema::blob::BLOB_SIZE_LIMIT_BYTES`). At the crate +/// root so the independently-compiled `kv` and `sqlite` features share one +/// source of truth. +pub const SIZE_LIMIT_BYTES: usize = 16 * 1024 * 1024; + #[cfg(feature = "kv")] pub mod kv; #[cfg(feature = "sqlite")] @@ -35,10 +42,8 @@ pub mod sqlite; #[cfg(feature = "secrets")] pub mod secrets; -// Convenience re-exports kept under the crate root so embedders don't -// have to spell out the `::sqlite::` middle segment for the common -// names. Adding to or trimming from this list does NOT count as a -// breaking change of the submodule API. +// Convenience re-exports so embedders can skip the `::sqlite::` segment +// for common names. #[cfg(feature = "kv")] pub use kv::{KvError, KvStore, ObjectId}; #[cfg(feature = "sqlite")] @@ -48,9 +53,8 @@ pub use sqlite::{ WalletStorageError, }; -// Compile-time assertions — `Send + Sync`, `PlatformWalletPersistence` -// object-safety, and the no-boxed-trait-object error policy. -// Lint-gated to the SQLite feature because they reference its types. +// Compile-time assertions: `Send + Sync` and `PlatformWalletPersistence` +// object-safety. Gated to `sqlite` because they reference its types. #[cfg(feature = "sqlite")] #[allow(dead_code)] const fn _send_sync_check() {} @@ -67,9 +71,8 @@ fn _object_safety_check(persister: SqlitePersister) { std::sync::Arc::new(persister); } -// The keyring SPI must be object-safe and its error `Send + Sync`, so -// a backend can be held behind `Arc` and its errors crossed between threads / FFI. +// The keyring SPI must be object-safe with `Send + Sync` errors so a +// backend can live behind `Arc`. #[cfg(feature = "secrets")] #[allow(dead_code)] const fn _secrets_send_sync_check() {} diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 506814cd49f..7057946714a 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -1,24 +1,10 @@ //! Secret-store error taxonomy and its `keyring_core::Error` projection. //! -//! One concrete `thiserror` enum shared by both -//! [`SecretStore`](crate::secrets::SecretStore) backends (the encrypted -//! file vault and the OS keyring), no `#[non_exhaustive]`, **no** secret -//! byte, passphrase, plaintext, or stringified source that could carry -//! one in any variant. `#[error]` strings are static + structural; only -//! non-secret diagnostics (POSIX mode bits, header version int, vault -//! path) are carried as typed fields (CWE-209/CWE-532). -//! -//! The `EncryptedFileStore` surfaces this enum at its construction / -//! `rekey` API; its `keyring_core::api::CredentialApi` / -//! `CredentialStoreApi` impls project it into `keyring_core::Error` via -//! [`From`] so SPI callers see a uniform error. The `WrongPassphrase` / -//! `AlreadyLocked` variants box the typed `SecretStoreError` as the -//! `NoStorageAccess` source, so an SPI consumer can recover them -//! losslessly via `source().downcast_ref::()`; the -//! `BadStoreFormat` group has no box slot and carries only a secret-free -//! string. Either way, the fully typed path is the public -//! [`SecretStore`](crate::secrets::SecretStore) API, which returns -//! `SecretStoreError` directly. +//! Variants carry only non-secret diagnostics (POSIX mode bits, header +//! version, vault path) — never a secret byte, passphrase, plaintext, or +//! stringified source (CWE-209/CWE-532). The public, fully-typed path is +//! the [`SecretStore`](crate::secrets::SecretStore) API; the SPI +//! projection into `keyring_core::Error` is lossy (see the [`From`] impl). use std::path::Path; @@ -34,16 +20,52 @@ pub enum SecretStoreError { #[error("wrong passphrase")] WrongPassphrase, - /// AEAD tag failure on a stored entry (or a rekey re-encrypt) *after* - /// the header verify-token already passed: the entry ciphertext is - /// corrupt or tampered, **not** a wrong passphrase. Carries no - /// plaintext (CWE-347). + /// Tier-2 strip/downgrade guard: the caller asserted — by supplying + /// an object password — that this object MUST be password-protected, + /// but the stored value is a well-formed UNPROTECTED envelope + /// (scheme-0), i.e. a strip/downgrade. **Fails closed:** the stored + /// bytes are NEVER returned (CWE-757/CWE-345). + #[error("expected a password-protected secret but the stored value is unprotected")] + ExpectedProtectedButUnsealed, + + /// Tier-2: a valid password-protected (scheme-1) envelope was read + /// with NO object password supplied. Never returns ciphertext. + #[error("secret is password-protected; a password is required")] + NeedsPassword, + + /// Tier-2: the object password failed the envelope's AEAD tag. Carries + /// **no** plaintext and no source (CWE-347). Distinct from + /// [`WrongPassphrase`] (the Tier-1 vault passphrase). On the + /// [`SecretStore::Os`] arm a tag failure may also indicate keychain + /// corruption rather than a wrong password — documented in + /// `SECRETS.md`; one AEAD tag cannot disambiguate the two. + /// + /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase + /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os + #[error("wrong object password")] + WrongPassword, + + /// A vault passphrase (Tier-1 `open`/`rekey`) or an object password + /// (Tier-2 enrol/unwrap) was blank — empty or all-whitespace — rejected + /// via [`SecretString::is_blank`]. CWE-521. + /// + /// Neutral wording: the variant covers both Tier-1 vault passphrases and + /// Tier-2 per-object passwords; the caller's context determines which. + /// Tier-1 callers wanting a deliberately keyless vault should use + /// [`EncryptedFileStore::open_unprotected`](crate::secrets::EncryptedFileStore::open_unprotected). + /// + /// [`SecretString::is_blank`]: crate::secrets::SecretString::is_blank + #[error("passphrase or password must not be blank")] + BlankPassphrase, + + /// AEAD tag failure on a stored entry (or rekey re-encrypt) *after* + /// the header verify-token passed: the entry ciphertext is corrupt or + /// tampered, **not** a wrong passphrase. No plaintext (CWE-347). #[error("vault entry failed integrity check (corruption or tampering)")] Corruption, - /// Argon2 key derivation failed. The upstream error carries no - /// useful non-secret diagnostic, so it is intentionally not - /// embedded. + /// Argon2 key derivation failed. The upstream error carries no useful + /// non-secret diagnostic, so it is not embedded. #[error("key derivation failed")] KdfFailure, @@ -55,6 +77,20 @@ pub enum SecretStoreError { found: u32, }, + /// A Tier-2 secret envelope decoded with a `version` this build does + /// not understand. Fails closed REGARDLESS of the password argument + /// — an unparseable future format can be neither safely unwrapped + /// nor safely treated as unprotected, so it is refused both ways. + /// Mirrors [`VersionUnsupported`] for the vault format. + /// + /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported + #[error("unsupported secret envelope version {found}")] + UnsupportedEnvelopeVersion { + /// The envelope `version` byte read from the (unauthenticated) + /// header. + found: u8, + }, + /// The vault file was malformed (bad magic, truncated header, bad /// record framing) — no plaintext was produced. #[error("malformed vault file")] @@ -65,6 +101,17 @@ pub enum SecretStoreError { #[error("invalid label")] InvalidLabel, + /// No credential exists under `(service, label)` on either arm. Returned + /// by mutators that need an entry to operate on (e.g. [`reprotect`]) so + /// absence is a signal, not a silent no-op — caller's protection-status + /// record disagreeing with the backend must not be swallowed. Surfaced + /// by the file arm when `delete_bytes` reports `Ok(false)` and by the + /// OS arm when [`keyring_core::Error::NoEntry`] bubbles out. + /// + /// [`reprotect`]: crate::secrets::SecretStore::reprotect + #[error("no entry under (service, label)")] + NoEntry, + /// A pre-existing vault file had permissions looser than `0600`. /// Refuse rather than tighten-and-trust. #[error("vault file has insecure permissions")] @@ -73,13 +120,35 @@ pub enum SecretStoreError { mode: u32, }, - /// The vault sidecar (`.lock`) is already held by - /// another `EncryptedFileStore` handle — in this process or in - /// another process. The resident-vault model requires exclusive - /// ownership of the vault file for the store's lifetime, so the - /// second `open()` fails fast (no retry, no wait budget). Drop the - /// other handle, or wait for the other process to exit, and retry. - /// A recoverable runtime state, not a logic bug. + /// The vault file's parent directory was group/other WRITABLE + /// (`mode & 0o022 != 0`). Directory write governs rename/unlink, so a + /// writable parent lets another local user swap the vault despite its + /// own `0600`. Read-only group access (`0o750`) is fine — it leaks + /// filenames, not the 0600-protected contents. + #[error("vault parent directory has insecure permissions")] + InsecureParentDir { + /// The offending POSIX mode bits on the parent directory (not + /// secret). + mode: u32, + }, + + /// A secret offered for storage exceeded the per-secret write cap + /// ([`MAX_SECRET_LEN`](crate::secrets::MAX_SECRET_LEN)). Rejected at + /// the write boundary so an oversized entry never inflates the shared + /// vault past the read-side ceiling and bricks every wallet on reopen. + #[error("secret exceeds maximum size of {max} bytes (got {found})")] + SecretTooLarge { + /// The offered secret length (bytes). + found: usize, + /// The compiled-in per-secret ceiling (bytes). + max: usize, + }, + + /// The vault sidecar (`.lock`) is already held by another + /// `EncryptedFileStore` handle in this or another process. The + /// resident-vault model needs exclusive ownership for the store's + /// lifetime, so a second `open()` fails fast (no retry). Recoverable: + /// drop the other handle and retry. #[error("vault is already locked by another store handle")] AlreadyLocked, @@ -95,28 +164,35 @@ pub enum SecretStoreError { max: u64, }, - /// Internal AEAD tag failure with no vault context yet attached. The - /// crypto seam (`crypto::open`) cannot tell *why* a tag failed, so it - /// returns this; callers translate it to [`WrongPassphrase`] (in the - /// verify-token context) or [`Corruption`] (in an entry context). - /// Never escapes to the SPI / public surface. + /// Internal AEAD tag failure with no vault context attached: + /// `crypto::open` cannot tell *why* a tag failed, so callers translate + /// this to [`WrongPassphrase`] (verify-token context) or + /// [`Corruption`] (entry context). Never escapes to the SPI surface. /// /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase /// [`Corruption`]: SecretStoreError::Corruption #[error("decryption/integrity check failed")] Decrypt, + /// AEAD encrypt-side failure (cipher construction or `encrypt`). + /// Effectively unreachable — the key is always 32 bytes and plaintext + /// never approaches XChaCha20's ~256 GiB limit — but kept typed so a + /// write failure is never mislabeled a [`KdfFailure`]. + /// + /// [`KdfFailure`]: SecretStoreError::KdfFailure + #[error("encryption failed")] + Encrypt, + /// Filesystem error (open / write / rename / fsync). The inner - /// [`IoError`] carries an OS code and, when the failing operation - /// knew it, the *non-secret* path it was operating on — a - /// caller-supplied filesystem path, never a secret byte. + /// [`IoError`] carries an OS code and, when known, the *non-secret* + /// caller-supplied path — never a secret byte. #[error("{0}")] Io(#[from] IoError), - /// An OS-keyring backend (the [`SecretStore::Os`] arm) failure, - /// projected to a non-secret discriminant. Keyring variants that - /// carry raw bytes (`BadEncoding`, `BadDataFormat`) are collapsed to - /// [`OsKeyringErrorKind::BadStoreFormat`] — their bytes never enter + /// An OS-keyring backend ([`SecretStore::Os`] arm) failure, projected + /// to a non-secret discriminant. Byte-bearing keyring variants + /// (`BadEncoding`, `BadDataFormat`) collapse to + /// [`OsKeyringErrorKind::BadStoreFormat`]; their bytes never enter /// this type (CWE-209/CWE-532). /// /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os @@ -128,11 +204,9 @@ pub enum SecretStoreError { } impl SecretStoreError { - /// Build an [`Io`](SecretStoreError::Io) error that names the - /// non-secret filesystem `path` the failing operation touched. - /// Use at the vault read / write / lock seams where the path is - /// known; the bare `?`/`From` conversion (path - /// unknown) stays available for the deep helpers. + /// Build an [`Io`](SecretStoreError::Io) error naming the non-secret + /// `path` the failing operation touched. Use at the read/write/lock + /// seams; deep helpers can still use the bare `?` (path unknown). pub(crate) fn io_at(path: &Path, source: std::io::Error) -> Self { Self::Io(IoError { path: Some(path.to_path_buf()), @@ -142,14 +216,12 @@ impl SecretStoreError { } /// Filesystem-error payload for [`SecretStoreError::Io`]. Wraps the OS -/// [`std::io::Error`] and, when the failing operation knew it, the -/// non-secret path it was operating on. `From` is -/// derived so a bare `?` still works (path defaults to `None`); the -/// path-aware seams attach it via [`SecretStoreError::io_at`]. +/// [`std::io::Error`] plus the non-secret path, when known. A bare `?` +/// works (path `None`); path-aware seams use [`SecretStoreError::io_at`]. #[derive(Debug, thiserror::Error)] pub struct IoError { - /// The non-secret filesystem path, when the failing operation knew - /// it. A caller-supplied path, never a secret. + /// The non-secret caller-supplied path, when the failing operation + /// knew it. pub path: Option, /// The underlying OS error. pub source: std::io::Error, @@ -171,14 +243,12 @@ impl From for IoError { } /// Non-secret discriminant for an OS-keyring backend failure, projected -/// from `keyring_core::Error` for the [`SecretStore::Os`] arm. Carries no -/// payload, so no secret byte, path, or attribute value can ride along. +/// from `keyring_core::Error` for the [`SecretStore::Os`] arm. Payload- +/// less, so no secret byte / path / attribute value can ride along. /// /// [`SecretStore::Os`]: crate::secrets::SecretStore::Os #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OsKeyringErrorKind { - /// `keyring_core::Error::NoEntry`. - NoEntry, /// `keyring_core::Error::NoStorageAccess` (store locked / inaccessible). NoStorageAccess, /// `keyring_core::Error::NoDefaultStore` (no reachable backend). @@ -194,7 +264,6 @@ pub enum OsKeyringErrorKind { impl std::fmt::Display for OsKeyringErrorKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let s = match self { - Self::NoEntry => "no entry", Self::NoStorageAccess => "storage inaccessible", Self::NoDefaultStore => "no default store", Self::BadStoreFormat => "bad store format", @@ -210,60 +279,71 @@ impl From for SecretStoreError { } } -/// Bare `?` on a [`std::io::Error`] inside a function returning -/// [`SecretStoreError`] threads through [`IoError`] (path `None`); the -/// path-aware seams call [`SecretStoreError::io_at`] instead. +/// Bare `?` on an [`std::io::Error`] threads through [`IoError`] with +/// path `None`; path-aware seams call [`SecretStoreError::io_at`]. impl From for SecretStoreError { fn from(source: std::io::Error) -> Self { Self::Io(IoError::from(source)) } } -/// Project a [`SecretStoreError`] into `keyring_core::Error` for the -/// `CredentialApi` / `CredentialStoreApi` SPI seam. +/// Project a [`SecretStoreError`] into `keyring_core::Error` for the SPI +/// seam. Lossy by design — the lossless typed path is the +/// [`SecretStore`](crate::secrets::SecretStore) API. /// -/// - [`WrongPassphrase`] and [`AlreadyLocked`] ride in -/// [`KeyringError::NoStorageAccess`] (operator UX: "ask the operator to -/// unlock / retry") with the typed `SecretStoreError` boxed as the -/// source, so an SPI consumer can losslessly recover the variant via +/// - [`WrongPassphrase`] / [`AlreadyLocked`] and the Tier-2 credential / +/// protection states ([`NeedsPassword`], [`WrongPassword`], +/// [`ExpectedProtectedButUnsealed`], [`BlankPassphrase`]) ride in +/// [`KeyringError::NoStorageAccess`] with the typed error boxed as the +/// source, recoverable via /// `err.source().and_then(|s| s.downcast_ref::())`. -/// - [`Corruption`], [`KdfFailure`], [`VersionUnsupported`], -/// [`MalformedVault`], [`InsecurePermissions`], the internal -/// [`Decrypt`], and [`OsKeyring`] collapse into -/// [`KeyringError::BadStoreFormat`], whose `String` payload has no box -/// slot, so they carry only a static secret-free string (never secret -/// data in a format error). They remain losslessly typed on the -/// [`SecretStore`](crate::secrets::SecretStore) path. -/// - [`InvalidLabel`] becomes `KeyringError::Invalid("user", _)`. -/// - [`Io`] becomes [`KeyringError::PlatformFailure`]. +/// These are all "the caller must act on a credential/expectation to +/// proceed" states, so lossless recovery lets an SPI consumer react +/// precisely. +/// - The format/crypto group — including [`UnsupportedEnvelopeVersion`] +/// (a fail-closed forward-format incompatibility, mirroring +/// [`VersionUnsupported`]) — collapses into +/// [`KeyringError::BadStoreFormat`] (a static secret-free string — that +/// variant has no box slot). +/// - [`InvalidLabel`] → `KeyringError::Invalid("user", _)`; +/// [`Io`] → [`KeyringError::PlatformFailure`]. /// /// [`WrongPassphrase`]: SecretStoreError::WrongPassphrase /// [`AlreadyLocked`]: SecretStoreError::AlreadyLocked -/// [`Corruption`]: SecretStoreError::Corruption -/// [`KdfFailure`]: SecretStoreError::KdfFailure +/// [`NeedsPassword`]: SecretStoreError::NeedsPassword +/// [`WrongPassword`]: SecretStoreError::WrongPassword +/// [`ExpectedProtectedButUnsealed`]: SecretStoreError::ExpectedProtectedButUnsealed +/// [`BlankPassphrase`]: SecretStoreError::BlankPassphrase +/// [`UnsupportedEnvelopeVersion`]: SecretStoreError::UnsupportedEnvelopeVersion /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported -/// [`MalformedVault`]: SecretStoreError::MalformedVault -/// [`InsecurePermissions`]: SecretStoreError::InsecurePermissions -/// [`Decrypt`]: SecretStoreError::Decrypt -/// [`OsKeyring`]: SecretStoreError::OsKeyring /// [`InvalidLabel`]: SecretStoreError::InvalidLabel /// [`Io`]: SecretStoreError::Io impl From for KeyringError { fn from(e: SecretStoreError) -> Self { use SecretStoreError as E; match e { - E::WrongPassphrase | E::AlreadyLocked => KeyringError::NoStorageAccess(Box::new(e)), + E::WrongPassphrase + | E::AlreadyLocked + | E::NeedsPassword + | E::WrongPassword + | E::ExpectedProtectedButUnsealed + | E::BlankPassphrase => KeyringError::NoStorageAccess(Box::new(e)), E::Corruption | E::KdfFailure | E::VersionUnsupported { .. } + | E::UnsupportedEnvelopeVersion { .. } | E::MalformedVault | E::InsecurePermissions { .. } + | E::InsecureParentDir { .. } + | E::SecretTooLarge { .. } | E::VaultTooLarge { .. } | E::Decrypt + | E::Encrypt | E::OsKeyring { .. } => KeyringError::BadStoreFormat(e.to_string()), E::InvalidLabel => { KeyringError::Invalid("user".to_string(), "label allowlist violation".to_string()) } + E::NoEntry => KeyringError::NoEntry, E::Io(io) => KeyringError::PlatformFailure(Box::new(io.source)), } } @@ -289,10 +369,20 @@ mod tests { for e in [ SecretStoreError::Corruption, SecretStoreError::Decrypt, + SecretStoreError::Encrypt, SecretStoreError::KdfFailure, SecretStoreError::VersionUnsupported { found: 999 }, SecretStoreError::MalformedVault, SecretStoreError::InsecurePermissions { mode: 0o644 }, + SecretStoreError::InsecureParentDir { mode: 0o777 }, + SecretStoreError::SecretTooLarge { + found: 100, + max: 10, + }, + SecretStoreError::VaultTooLarge { + found: 100, + max: 10, + }, ] { let k: KeyringError = e.into(); assert!(matches!(k, KeyringError::BadStoreFormat(_))); @@ -316,9 +406,6 @@ mod tests { #[test] fn io_at_names_path_in_display_without_leaking_secret() { - // The path-aware Io error renders the offending path so operators - // can see which file failed; the source message rides along, but - // no secret byte does (the path is caller-supplied). let err = SecretStoreError::io_at( std::path::Path::new("/var/lib/wallet/vault.pwsvault"), std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"), @@ -351,9 +438,6 @@ mod tests { #[test] fn wrong_passphrase_is_recoverable_from_no_storage_access_source() { - // WrongPassphrase / AlreadyLocked box the typed SecretStoreError - // as the NoStorageAccess source, so an SPI consumer recovers the - // variant losslessly via `source().downcast_ref::()`. use std::error::Error as _; for original in [ SecretStoreError::WrongPassphrase, @@ -382,6 +466,102 @@ mod tests { assert!(!format!("{k}").contains("plaintext")); } + /// The five new variants exist, are constructable, render + /// distinct non-empty messages, and the Tier-2 `WrongPassword` is NOT + /// the Tier-1 `WrongPassphrase` (nor is the unseal error `Corruption`). + #[test] + fn new_variants_exist_and_are_distinct() { + use SecretStoreError as E; + assert_ne!(E::WrongPassword.to_string(), E::WrongPassphrase.to_string()); + assert_ne!( + E::ExpectedProtectedButUnsealed.to_string(), + E::Corruption.to_string() + ); + let msgs: std::collections::HashSet = [ + E::NeedsPassword.to_string(), + E::WrongPassword.to_string(), + E::BlankPassphrase.to_string(), + E::ExpectedProtectedButUnsealed.to_string(), + E::UnsupportedEnvelopeVersion { found: 2 }.to_string(), + ] + .into_iter() + .collect(); + assert_eq!(msgs.len(), 5, "all five messages must be distinct"); + } + + /// Display + Debug render static, secret-free text. The + /// version variant surfaces the (non-secret) version byte and nothing + /// more. + #[test] + fn new_variants_carry_no_secret_in_display() { + use SecretStoreError as E; + assert_eq!( + E::NeedsPassword.to_string(), + "secret is password-protected; a password is required" + ); + assert_eq!(E::WrongPassword.to_string(), "wrong object password"); + assert_eq!( + E::BlankPassphrase.to_string(), + "passphrase or password must not be blank" + ); + assert_eq!( + E::ExpectedProtectedButUnsealed.to_string(), + "expected a password-protected secret but the stored value is unprotected" + ); + assert_eq!( + E::UnsupportedEnvelopeVersion { found: 7 }.to_string(), + "unsupported secret envelope version 7" + ); + // Debug is non-empty and free of plaintext-ish tokens for all. + for e in [ + E::NeedsPassword, + E::WrongPassword, + E::BlankPassphrase, + E::ExpectedProtectedButUnsealed, + E::UnsupportedEnvelopeVersion { found: 7 }, + ] { + let rendered = format!("{e} {e:?}"); + assert!(!rendered.contains("plaintext")); + } + } + + /// The four Tier-2 credential / + /// protection states project to a recoverable `NoStorageAccess` with + /// the typed error losslessly downcast-able, leaking no secret. + #[test] + fn tier2_state_errors_project_to_recoverable_no_storage_access() { + for original in [ + SecretStoreError::NeedsPassword, + SecretStoreError::WrongPassword, + SecretStoreError::ExpectedProtectedButUnsealed, + SecretStoreError::BlankPassphrase, + ] { + let want = original.to_string(); + let k: KeyringError = original.into(); + assert!(!format!("{k}").contains("plaintext")); + match &k { + KeyringError::NoStorageAccess(src) => { + let recovered = src.downcast_ref::(); + assert!( + matches!(recovered, Some(e) if e.to_string() == want), + "expected recoverable {want}, got {recovered:?}" + ); + } + other => panic!("expected NoStorageAccess for {want}, got {other:?}"), + } + } + } + + /// `UnsupportedEnvelopeVersion` projects to the + /// secret-free `BadStoreFormat` group (forward-format incompat, + /// mirroring `VersionUnsupported`). + #[test] + fn unsupported_envelope_version_projects_to_bad_store_format() { + let k: KeyringError = SecretStoreError::UnsupportedEnvelopeVersion { found: 9 }.into(); + assert!(matches!(k, KeyringError::BadStoreFormat(_))); + assert!(!format!("{k}").contains("plaintext")); + } + #[test] fn os_keyring_projects_to_bad_store_format() { let k: KeyringError = SecretStoreError::OsKeyring { diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs index 3205db672f5..bbfb6b26420 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs @@ -19,11 +19,10 @@ pub(crate) const ARGON2_MIN_T: u32 = 2; pub(crate) const ARGON2_P: u32 = 1; /// Argon2 parameter ceilings. Vault `kdf` params are attacker- -/// controllable JSON, so an oversized `m_kib`/`t` would let a crafted -/// vault force a multi-GiB allocation or an unbounded-time derivation (a -/// DoS) before any tag check. 1 GiB memory and 16 passes bound the cost -/// well above the shipped default (64 MiB, t=3) yet far below an -/// exhaustion threshold. +/// controllable JSON, so without a cap an oversized `m_kib`/`t` could +/// force a multi-GiB allocation or unbounded derivation (DoS) before any +/// tag check. 1 GiB / 16 passes is well above the default, far below +/// exhaustion. pub(crate) const ARGON2_MAX_M_KIB: u32 = 1_048_576; pub(crate) const ARGON2_MAX_T: u32 = 16; @@ -43,11 +42,10 @@ pub(crate) fn random_bytes(buf: &mut [u8]) -> Result<(), SecretStoreError> { getrandom(buf).map_err(|_| SecretStoreError::KdfFailure) } -/// Argon2id parameters as stored in / read from the vault. Serializes -/// directly to the on-disk `kdf` object — `id` discriminates the KDF -/// algorithm (only [`KDF_ID_ARGON2ID`] is accepted today), validated -/// alongside the parameter ranges in [`KdfParams::enforce_bounds`]. -/// `deny_unknown_fields` fails closed on a stray sibling (C3). +/// Argon2id parameters stored in the on-disk `kdf` object. `id` +/// discriminates the algorithm (only [`KDF_ID_ARGON2ID`] today), +/// validated with the parameter ranges in [`KdfParams::enforce_bounds`]. +/// `deny_unknown_fields` fails closed on a stray sibling. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct KdfParams { @@ -68,13 +66,11 @@ impl KdfParams { } } - /// Reject params outside the accepted bounds before any derivation - /// or allocation runs. The lower bound refuses a downgraded vault; - /// the upper bound refuses an inflated vault from an - /// attacker-controllable JSON file that would otherwise force a - /// huge allocation / unbounded derivation ahead of any tag check. - /// An unknown algorithm `id` is also a bounds failure — Argon2id is - /// the only KDF family this version supports. + /// Reject out-of-bounds params before any derivation/allocation: the + /// lower bound refuses a downgraded vault, the upper bound an inflated + /// one (huge allocation / unbounded derivation ahead of any tag + /// check). An unknown algorithm `id` also fails — Argon2id is the only + /// supported family. pub(crate) fn enforce_bounds(&self) -> Result<(), SecretStoreError> { if self.id != KDF_ID_ARGON2ID || self.m_kib < ARGON2_MIN_M_KIB @@ -89,20 +85,20 @@ impl KdfParams { } } -/// Derive a 32-byte AEAD key from `passphrase` + `salt` with Argon2id. -/// Output lands directly in a [`SecretBytes`]. +/// Derive a 32-byte AEAD key from `passphrase` + `salt` with Argon2id, +/// landing directly in a [`SecretBytes`]. Takes `&SecretString` so the +/// bare-byte passphrase view lives only inside this function. /// -/// Takes `&SecretString` directly so the bare-byte view of the -/// passphrase lives only inside this function — callers can no -/// longer accidentally hand a `&[u8]` (e.g. by holding a stray -/// `expose_secret().as_bytes()` longer than intended) into KDF input. +/// Zeroization residual: argon2 0.5.3's `zeroize` feature wipes +/// `initial_hash` / `blockhash` but NOT the bulk `Block` matrix (up to +/// `m_kib` of derived state). Accepted residual against A5 (swap / +/// core-dump while unlocked); closing it needs an upstream fix. pub(crate) fn derive_key( passphrase: &SecretString, - salt: &[u8], + salt: &[u8; SALT_LEN], params: KdfParams, ) -> Result { - // Bounds MUST gate before Params::new / hash_password_into so an - // inflated m_kib never reaches the allocator. + // Bounds MUST gate first so an inflated m_kib never reaches the allocator. params.enforce_bounds()?; let argon_params = Params::new(params.m_kib, params.t, params.p, Some(KEY_LEN)) .map_err(|_| SecretStoreError::KdfFailure)?; @@ -126,7 +122,7 @@ pub(crate) fn seal( plaintext: &[u8], ) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> { let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) - .map_err(|_| SecretStoreError::KdfFailure)?; + .map_err(|_| SecretStoreError::Encrypt)?; let mut nonce_bytes = [0u8; NONCE_LEN]; random_bytes(&mut nonce_bytes)?; let nonce = XNonce::from_slice(&nonce_bytes); @@ -138,11 +134,36 @@ pub(crate) fn seal( aad, }, ) - // Encrypt-path failure (XChaCha20-Poly1305 only fails here when - // the plaintext exceeds the construction's length limit), so it is - // not a decryption concern; keep it on the same write-oriented - // variant the cipher-construction failure above uses. - .map_err(|_| SecretStoreError::KdfFailure)?; + // AEAD write-side failure (only when plaintext exceeds the length + // limit), not a key-derivation one. + .map_err(|_| SecretStoreError::Encrypt)?; + Ok((nonce_bytes, ct)) +} + +/// Like [`seal`] but takes a caller-supplied `nonce` instead of pulling +/// from the CSPRNG. **Test-only** — golden-vector / size-budget tests +/// need byte-deterministic ciphertext output. Production code MUST use +/// [`seal`] so nonces stay unique (XChaCha20-Poly1305 nonce reuse leaks +/// the keystream). +#[cfg(test)] +pub(crate) fn seal_with_nonce( + key: &SecretBytes, + nonce_bytes: [u8; NONCE_LEN], + aad: &[u8], + plaintext: &[u8], +) -> Result<([u8; NONCE_LEN], Vec), SecretStoreError> { + let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) + .map_err(|_| SecretStoreError::Encrypt)?; + let nonce = XNonce::from_slice(&nonce_bytes); + let ct = cipher + .encrypt( + nonce, + chacha20poly1305::aead::Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| SecretStoreError::Encrypt)?; Ok((nonce_bytes, ct)) } @@ -157,7 +178,7 @@ pub(crate) fn open( ciphertext: &[u8], ) -> Result { let cipher = XChaCha20Poly1305::new_from_slice(key.expose_secret()) - .map_err(|_| SecretStoreError::KdfFailure)?; + .map_err(|_| SecretStoreError::Encrypt)?; let nonce = XNonce::from_slice(nonce); let pt = cipher .decrypt( @@ -175,6 +196,10 @@ pub(crate) fn open( mod tests { use super::*; + // Compile-time guard: argon2's `impl Zeroize for Block` is feature- + // gated, so this fails to build if `argon2/zeroize` is ever dropped. + static_assertions::assert_impl_all!(argon2::Block: zeroize::Zeroize); + /// Argon2id floor params — fast enough for unit tests; production /// runs at the default target (64 MiB). fn floor_params() -> KdfParams { @@ -253,10 +278,8 @@ mod tests { #[test] fn derive_key_rejects_inflated_m_kib_before_allocating() { - // u32::MAX m_kib must error fast (enforce_bounds) and never reach - // the multi-GiB allocator. A real allocation of ~4 TiB would OOM - // the test, so reaching here at all proves the ceiling fired - // first. + // u32::MAX m_kib must error via enforce_bounds before the ~4 TiB + // allocation — which would OOM the test if it ever ran. let err = derive_key( &SecretString::new("pw"), &[0u8; SALT_LEN], diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs index d188658dd3b..c137afb2439 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs @@ -1,10 +1,7 @@ //! Versioned, self-describing vault format + canonical AAD. //! -//! The vault is one `serde_json` document covering every wallet in the -//! store: a single passphrase / salt / KDF block at the top, and a -//! nested map keyed first by `wallet_id` (lowercase hex) and then by -//! `label`. One file, one passphrase, one lock — a multi-wallet store -//! cannot lock its other wallets out by construction. +//! The vault is one `serde_json` document: a single salt / KDF block at +//! the top, then a map keyed by `wallet_id` (lowercase hex) and `label`. //! //! ```json //! { @@ -21,22 +18,18 @@ //! } //! ``` //! -//! Entries are nested `BTreeMap`s so lookup is O(log n) and the on-disk -//! shape excludes duplicate `(wallet_id, label)` pairs by construction -//! (a JSON object cannot carry two values under the same key). +//! Nested `BTreeMap`s give O(log n) lookup and a JSON-object shape that +//! excludes duplicate `(wallet_id, label)` pairs by construction. //! //! Parsing is two-step: a lax [`VersionProbe`] reads `version` first -//! (tolerating future-version sibling fields), then — only for the -//! compiled-in [`FORMAT_VERSION`] — the strict [`Vault`] payload is -//! parsed. All byte fields are lowercase hex; Argon2 params are JSON -//! numbers. +//! (tolerating future-version siblings), then the strict [`Vault`] +//! payload is parsed only for the compiled-in [`FORMAT_VERSION`]. //! -//! KDF params/salt are store-wide. `verify_ct` is an AEAD seal of a -//! fixed constant under the header-derived key — a wrong passphrase -//! fails its tag, so a mismatched key is rejected before any entry is -//! written or read (no mixed-key corruption). The verify-token AAD is -//! NOT bound to any wallet id (the store is now multi-wallet) so the -//! token validates the store-wide passphrase exactly once per op. +//! `verify_ct` is an AEAD seal of a fixed constant under the +//! header-derived key, so a wrong passphrase fails its tag and a +//! mismatched key is rejected before any entry is touched (no mixed-key +//! corruption). The verify-token AAD is not bound to any wallet id, so it +//! validates the store-wide passphrase once per op. use std::collections::BTreeMap; @@ -44,6 +37,9 @@ use serde::{Deserialize, Serialize}; use super::crypto::{KdfParams, NONCE_LEN, SALT_LEN}; use crate::secrets::error::SecretStoreError; +use crate::secrets::wire::aad::{EntryAad, VerifyAad}; +use crate::secrets::wire::config::{ENTRY_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG}; +use crate::secrets::wire::kdf::KdfParamsEncoded; pub(crate) const FORMAT_VERSION: u32 = 1; pub(crate) const KDF_ID_ARGON2ID: u8 = 1; @@ -53,35 +49,16 @@ pub(crate) const KDF_ID_ARGON2ID: u8 = 1; /// value itself is not secret. pub(crate) const VERIFY_CONSTANT: &[u8] = b"PWSVAULT-VERIFY-v1"; -/// AAD slot label for the verification token. The leading NUL keeps it -/// disjoint from every allowlisted entry label, so the token can never -/// alias a real entry's AAD. -pub(crate) const VERIFY_LABEL: &str = "\0verify"; - -/// Sentinel wallet id used as the verify-token AAD's wallet slot. The -/// store-wide token is not bound to any real wallet; this 32-byte zero -/// id keeps the AAD shape identical to entry AAD (same length-prefixed -/// construction) without aliasing a real wallet's namespace — a real -/// wallet id `[0u8; 32]` would still produce a different AAD because -/// the label slot differs ([`VERIFY_LABEL`] vs any allowlisted label). -const VERIFY_WALLET_ID: [u8; 32] = [0u8; 32]; - /// Minimum AEAD ciphertext length: the Poly1305 tag is always present /// even for an empty plaintext, so any `verify_ct`/`ciphertext` shorter /// than this is structurally impossible and rejected. const AEAD_TAG_LEN: usize = 16; -/// The full parsed vault: format `version`, KDF parameters, salt, the -/// passphrase-verification token, and every wallet's entries. -/// Serializes directly to the on-disk wire form — `hex_array` validates -/// `salt`/`verify_nonce` widths at the serde seam, so no parallel -/// `Vec`-typed wire mirror is needed. Field order matches the -/// documented schema and `serde_json` preserves it, so the byte layout -/// is stable. -/// -/// `deny_unknown_fields` fails closed on a stray sibling for this -/// compiled-in [`FORMAT_VERSION`] (C3). Forward-compat dispatch on -/// `version` runs through [`VersionProbe`] before this strict parse. +/// The full parsed vault, serializing directly to the on-disk wire form. +/// `hex_array` validates fixed-width fields at the serde seam, and +/// `serde_json` preserves field order, so the byte layout is stable. +/// `deny_unknown_fields` fails closed on a stray sibling; forward-compat +/// dispatch runs through [`VersionProbe`] before this strict parse. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct Vault { @@ -99,12 +76,9 @@ pub(crate) struct Vault { pub wallets: BTreeMap>, } -/// One decrypted-on-demand vault entry body. The owning -/// `Vault.wallets[wallet]` `BTreeMap` keys this by `label`, so the -/// label is the map key — not a field — and the on-disk shape can't -/// carry two entries under the same label. `hex_array` validates -/// `nonce`'s fixed width at parse; `deny_unknown_fields` fails closed -/// on a stray sibling (C3). +/// One vault entry body, keyed by `label` in the owning `BTreeMap` (so +/// the label is the map key, not a field). `hex_array` validates `nonce`'s +/// width at parse; `deny_unknown_fields` fails closed on a stray sibling. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct EntryBody { @@ -114,42 +88,49 @@ pub(crate) struct EntryBody { pub ciphertext: Vec, } -/// Canonical length-prefixed AAD binding ciphertext to its slot: -/// `format_version ‖ wallet_id ‖ label`. A blob moved to another slot, -/// or a rolled-back `format_version`, fails the tag. +/// Canonical AAD binding a vault entry's ciphertext to its slot: +/// `domain ‖ format_version ‖ wallet_id ‖ label`, bincode-encoded +/// against [`WIRE_CONFIG`]. A blob moved to another slot, or one +/// version-rolled-back, fails the tag. /// -/// AAD-DETERMINISM INVARIANT (C1): AAD is built solely from the typed -/// `(format_version, wallet_id, label)` triple via this length-prefixed -/// layout — never from any serialized JSON bytes or JSON key order. The -/// `format_version` argument is always the compiled-in [`FORMAT_VERSION`] -/// constant at every call site; the JSON `version` field is used ONLY as -/// the two-step dispatch gate and is NEVER routed into AAD. +/// Determinism invariant: AAD is built solely from this typed triple, +/// never from serialized JSON bytes or key order. `format_version` is +/// always the compiled-in [`FORMAT_VERSION`]; the JSON `version` field +/// is a dispatch gate only and is never routed into AAD. pub(crate) fn aad(format_version: u32, wallet_id: &[u8; 32], label: &str) -> Vec { - let lb = label.as_bytes(); - let mut v = Vec::with_capacity(4 + 4 + 32 + 4 + lb.len()); - v.extend_from_slice(&format_version.to_le_bytes()); - v.extend_from_slice(&(wallet_id.len() as u32).to_le_bytes()); - v.extend_from_slice(wallet_id); - v.extend_from_slice(&(lb.len() as u32).to_le_bytes()); - v.extend_from_slice(lb); - v + bincode::encode_to_vec( + EntryAad { + domain: ENTRY_DOMAIN_V2, + format_version, + wallet_id: *wallet_id, + label, + }, + WIRE_CONFIG, + ) + .expect("EntryAad encode is infallible") } -/// AAD for the passphrase-verification token. Uses the same canonical -/// construction as entry AAD but with a sentinel zero wallet id and -/// [`VERIFY_LABEL`] (NUL-prefixed, disjoint from every allowlisted -/// label) so the token is cryptographically tied to this -/// `format_version` only and cannot be replayed into any real entry -/// slot. -pub(crate) fn verify_aad(format_version: u32) -> Vec { - aad(format_version, &VERIFY_WALLET_ID, VERIFY_LABEL) +/// AAD for the verify-token: bincode-encoded `VerifyAad` binding the +/// vault-wide salt + KDF header against the verify domain tag. A +/// tampered header yields a different AAD AND a different derived key, +/// so the token surfaces `WrongPassphrase`. +pub(crate) fn verify_aad(format_version: u32, salt: &[u8; SALT_LEN], kdf: &KdfParams) -> Vec { + bincode::encode_to_vec( + VerifyAad { + domain: VERIFY_DOMAIN_V2, + format_version, + salt: *salt, + kdf: KdfParamsEncoded::from(*kdf), + }, + WIRE_CONFIG, + ) + .expect("VerifyAad encode is infallible") } -/// Serde helpers encoding `Vec` as lowercase hex strings. Hex is -/// already a crate dependency (`WalletId::to_hex`), is deterministic and -/// self-validating, and avoids adding `base64`. The encoding sits wholly -/// outside the AEAD envelope and the AAD (C1), so it has no bearing on -/// any cryptographic binding. +/// Serde helpers encoding `Vec` as lowercase hex. Hex is already a +/// crate dependency, deterministic, and avoids adding `base64`. The +/// encoding sits outside the AEAD envelope and the AAD, so it has no +/// cryptographic bearing. mod hex_bytes { use serde::{Deserialize, Deserializer, Serializer}; @@ -163,12 +144,10 @@ mod hex_bytes { } } -/// Const-generic companion to [`hex_bytes`] for fixed-width byte fields. -/// Wire form is identical (lowercase hex), but the `[u8; N]` deserialize -/// target moves length validation into the serde seam — a wrong-length -/// hex blob is rejected at parse with a `serde::de::Error` naming both -/// the offending size and the expected `N`, so the field is identifiable -/// in the error message (no anonymous "invalid length"). +/// Const-generic companion to [`hex_bytes`] for fixed-width fields. The +/// `[u8; N]` target moves length validation into the serde seam: a +/// wrong-length blob is rejected at parse with an error naming the +/// offending size and the expected `N`. pub(super) mod hex_array { use serde::{de::Error as DeError, Deserialize, Deserializer, Serializer}; @@ -201,33 +180,32 @@ pub(super) mod hex_array { } } -/// Step-1 probe: read ONLY `version`, tolerating unknown sibling fields -/// so a future v-N file can be dispatched on before its payload shape is -/// committed to. MUST NOT use `deny_unknown_fields` (C3). +/// Step-1 probe: read ONLY `version`, tolerating unknown siblings so a +/// future vN file can be dispatched on. MUST NOT use `deny_unknown_fields`. #[derive(Deserialize)] struct VersionProbe { version: u32, } -/// Serialize a full vault to JSON bytes. Contains only salt/params -/// (non-secret) + ciphertext — never plaintext. +/// Serialize a vault to JSON bytes — salt/params + ciphertext only, never +/// plaintext. pub(crate) fn serialize(vault: &Vault) -> Vec { - // Vault carries only fixed-width arrays and owned Vecs that serialize - // infallibly; a serializer error would be a logic bug. + // Vault holds only fixed arrays and owned Vecs; serialization is + // infallible, so an error would be a logic bug. serde_json::to_vec(vault).expect("vault serialization is infallible") } -/// Parse a vault. Two-step: probe `version` (lax), then parse the strict -/// payload for the known version. Refuses unknown versions and any -/// malformed/short byte field — fail closed. Unknown KDF -/// algorithm ids and out-of-range Argon2 params are caught later at -/// `KdfParams::enforce_bounds` (called on every `derive_key`), so they -/// can't silently slip past. All `serde_json` errors are mapped to a -/// static [`SecretStoreError`] with the source DISCARDED so input bytes -/// can never leak into an error string or log. Salt and nonce widths -/// are validated by `hex_array` at the serde seam; the AEAD-tag-length -/// floor remains a post-parse check. +/// Parse a vault: probe `version` (lax), then parse the strict payload +/// for the known version. Fails closed on unknown versions and malformed +/// fields. `serde_json` errors are mapped to a static +/// [`SecretStoreError`] with the source DISCARDED so input bytes never +/// leak. Unknown KDF ids / out-of-range Argon2 params are caught later at +/// `KdfParams::enforce_bounds`. pub(crate) fn deserialize(buf: &[u8]) -> Result { + // INTENTIONAL: the 2x parse (probe + strict) over the 128MiB-capped, + // lock-gated local file is accepted for forward-version dispatch. + // INTENTIONAL: relies on serde_json's default recursion limit (128) + // for deep-nesting DoS safety — MUST NOT disable it or use from_reader. let probe: VersionProbe = serde_json::from_slice(buf).map_err(|_| SecretStoreError::MalformedVault)?; if probe.version != FORMAT_VERSION { @@ -242,12 +220,9 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result { return Err(SecretStoreError::MalformedVault); } - // Validate outer wallet-id keys and inner label keys at parse time. - // The serde shape allows any string for either key, so - // a malformed file (or a tampered one) could otherwise smuggle a - // bogus wallet id past parse and surface only at the first `put` / - // `get` / `delete`. Reject the whole vault on the first offender so - // a single bad key fails the file open, not a downstream op. + // Validate wallet-id and label keys at parse: the serde shape allows + // any string, so a bogus key would otherwise surface only at the + // first put/get/delete. Reject the whole vault on the first offender. for (wallet_hex, entries) in &vault.wallets { super::decode_wallet_id_hex(wallet_hex)?; for (label, body) in entries { @@ -266,33 +241,6 @@ pub(crate) fn deserialize(buf: &[u8]) -> Result { mod tests { use super::*; - #[test] - fn aad_binds_slot() { - let w = [1u8; 32]; - assert_ne!(aad(1, &w, "a"), aad(1, &w, "b")); - assert_ne!(aad(1, &w, "a"), aad(2, &w, "a")); - assert_ne!(aad(1, &w, "a"), aad(1, &[2u8; 32], "a")); - // Length-prefix defeats `"a"+"bc"` vs `"ab"+"c"` ambiguity. - assert_ne!(aad(1, &w, "ab"), { - let mut v = aad(1, &w, "a"); - v.extend_from_slice(b"b"); - v - }); - } - - #[test] - fn verify_aad_disjoint_from_every_entry_aad() { - // The verify-token's slot is `(VERIFY_WALLET_ID, VERIFY_LABEL)`. - // VERIFY_LABEL starts with NUL, which the allowlist forbids, so - // no real entry's AAD can collide with the token's AAD — even - // if a caller happens to register the all-zero wallet id. - let v = verify_aad(FORMAT_VERSION); - // A real entry on the same sentinel wallet id can never match - // because its label cannot contain NUL. - assert_ne!(v, aad(FORMAT_VERSION, &VERIFY_WALLET_ID, "seed")); - assert_ne!(v, aad(FORMAT_VERSION, &[1u8; 32], "seed")); - } - fn test_vault(wallets: BTreeMap>) -> Vault { Vault { version: FORMAT_VERSION, @@ -366,9 +314,8 @@ mod tests { #[test] fn deserialize_accepts_unknown_kdf_id_and_bounds_check_rejects_later() { - // Unknown algo ids ride through parse so the algorithm gate - // lives in one place — `KdfParams::enforce_bounds`, called on - // every `derive_key`. The format layer no longer guards it. + // Unknown algo ids ride through parse; the gate lives solely at + // `KdfParams::enforce_bounds` (called on every `derive_key`). let mut vault = test_vault(BTreeMap::new()); vault.kdf.id = 7; let bytes = serialize(&vault); @@ -618,4 +565,146 @@ mod tests { "error leaked input bytes: {rendered}" ); } + + /// A parse of mutated bytes must be a clean `Ok` or a typed error + /// variant — never a panic / abort. + fn assert_deserialize_outcome_is_typed(bytes: &[u8]) { + let res = std::panic::catch_unwind(|| deserialize(bytes)); + let parsed = res.expect("deserialize must never panic on hostile input"); + match parsed { + Ok(_) + | Err(SecretStoreError::MalformedVault) + | Err(SecretStoreError::VersionUnsupported { .. }) + | Err(SecretStoreError::InvalidLabel) => {} + Err(other) => panic!("unexpected error variant from parser: {other:?}"), + } + } + + /// Deterministic byte-level fuzz: flip bytes and truncate at every + /// offset of a valid vault, asserting the parser stays fail-closed and + /// never panics. Fixed seed, no proptest dependency. + #[test] + fn parser_is_fuzz_resistant_to_byte_mutation() { + let mut entries = BTreeMap::new(); + entries.insert( + "bip39_mnemonic".to_string(), + EntryBody { + nonce: [0x33; NONCE_LEN], + ciphertext: vec![0x44; AEAD_TAG_LEN + 16], + }, + ); + let mut wallets = BTreeMap::new(); + wallets.insert(hex::encode([0xABu8; 32]), entries); + let valid = serialize(&test_vault(wallets)); + + // The pristine vault parses. + assert!(deserialize(&valid).is_ok()); + + // xorshift32 — deterministic, std-only. + let mut state: u32 = 0x1234_5678; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + for _ in 0..2_000 { + let mut buf = valid.clone(); + // Flip 1..=4 random bytes. + let flips = 1 + (next() % 4) as usize; + for _ in 0..flips { + let idx = (next() as usize) % buf.len(); + buf[idx] ^= (next() & 0xFF) as u8; + } + assert_deserialize_outcome_is_typed(&buf); + } + + // Truncation at every offset — a short read must never panic. + for cut in 0..valid.len() { + assert_deserialize_outcome_is_typed(&valid[..cut]); + } + } + + /// Structural fuzz: hostile shapes a byte-flip rarely hits (oversized + /// KDF params, deep nesting, bad labels, wrong-width hex). Each must be + /// a typed error or a valid Ok, never a panic. Inflated KDF params + /// parse Ok by design (the bounds gate lives at `derive_key`). + #[test] + fn parser_is_fuzz_resistant_to_structural_mutation() { + let base: serde_json::Value = + serde_json::from_slice(&serialize(&test_vault(BTreeMap::new()))).unwrap(); + let wid_owned = hex::encode([1u8; 32]); + let wid = wid_owned.as_str(); + let good_nonce = "0".repeat(NONCE_LEN * 2); + let good_ct = "0".repeat((AEAD_TAG_LEN + 1) * 2); + + let mut cases: Vec = Vec::new(); + + // Oversized / absurd KDF params. + for (k, v) in [ + ("m_kib", serde_json::json!(u32::MAX)), + ("t", serde_json::json!(u32::MAX)), + ("p", serde_json::json!(u32::MAX)), + ("id", serde_json::json!(255)), + ] { + let mut c = base.clone(); + c["kdf"][k] = v; + cases.push(c); + } + + // Deep nesting in the wallets map (well past the type's depth). + { + let mut nested = serde_json::json!(0); + for _ in 0..512 { + nested = serde_json::json!([nested]); + } + let mut c = base.clone(); + c["wallets"] = nested; + cases.push(c); + } + + // Hostile labels and key shapes. + for label in ["\0null", "../escape", &"a".repeat(65), "has space"] { + let mut c = base.clone(); + c["wallets"] = serde_json::json!({ wid: { label: { "nonce": good_nonce.as_str(), "ciphertext": good_ct.as_str() } } }); + cases.push(c); + } + + // Wrong-width hex and oversized declared sizes. + for (nonce, ct) in [ + ("00", good_ct.as_str()), // short nonce + (good_nonce.as_str(), "00"), // short ciphertext + (&"0".repeat(NONCE_LEN * 4), good_ct.as_str()), // over-wide nonce + ("zz", good_ct.as_str()), // non-hex nonce + ] { + let mut c = base.clone(); + c["wallets"] = + serde_json::json!({ wid: { "seed": { "nonce": nonce, "ciphertext": ct } } }); + cases.push(c); + } + + // Non-hex / wrong-length outer wallet-id key. + for bad_wid in ["not-hex", &"aa".repeat(8), &"AB".repeat(32)] { + let mut c = base.clone(); + c["wallets"] = serde_json::json!({ bad_wid: { "seed": { "nonce": good_nonce.as_str(), "ciphertext": good_ct.as_str() } } }); + cases.push(c); + } + + // Header fields (salt / verify_nonce / verify_ct): empty / short / + // over-wide / non-hex must each be a typed error, never a panic. + let over_wide = "0".repeat(SALT_LEN * 4); + for field in ["salt", "verify_nonce", "verify_ct"] { + for bad in ["", "00", over_wide.as_str(), "zz"] { + let mut c = base.clone(); + c[field] = serde_json::json!(bad); + cases.push(c); + } + } + + for c in cases { + let bytes = serde_json::to_vec(&c).unwrap(); + assert_deserialize_outcome_is_typed(&bytes); + } + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index c2ae67e7352..68ad88d76c5 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -1,28 +1,24 @@ //! [`EncryptedFileStore`] — passphrase-encrypted on-disk vault, resident //! in memory while the store handle lives. //! -//! # Lifecycle +//! [`open`] takes the advisory lock on a sibling `.lock` sidecar (single +//! attempt), then creates or decrypts the vault and keeps the plaintext +//! entry map resident. [`get`] serves from memory (no per-op KDF/disk); +//! every mutation ([`put`], [`delete`], [`rekey`]) edits memory then +//! re-encrypts and atomically rewrites the file. [`Drop`] best-effort +//! re-syncs, re-asserts `0600` on Unix, and releases the lock. A second +//! `open()` of a held path fails fast with +//! [`SecretStoreError::AlreadyLocked`]. //! -//! - [`open`] grabs the cross-platform advisory lock on a sibling -//! `.lock` sidecar (single attempt, no retry), creates a fresh vault -//! if none exists yet, otherwise decrypts the existing one, and keeps -//! the plaintext entry map resident. -//! - Every mutation ([`put`], [`delete`], [`rekey`]) edits the in-memory -//! vault and immediately re-encrypts and atomically writes it back to -//! disk (eager sync). -//! - [`get`] reads from the in-memory map — no KDF, no disk hit per op. -//! - [`Drop`] best-effort-syncs the resident state once more, re-asserts -//! `0600` on Unix, and releases the lock when the file descriptor -//! closes. +//! **Cross-process exclusion is LOCAL-filesystem only.** `AlreadyLocked` +//! rests on `fd-lock` (`flock` / `LockFileEx`), which does NOT interlock +//! over NFS/CIFS/SMB — two hosts could each "lock" and last-writer-wins +//! would lose secrets. A vault file MUST NOT be shared across hosts; use +//! the OS keyring ([`SecretStore::Os`]) for multi-host access. The lock +//! sidecar is distinct from the vault file so the atomic `persist` rename +//! never touches the inode the open lock fd points at. //! -//! Concurrency is intentionally not supported: a second `open()` against -//! a path some other store handle (in this or another process) is -//! already holding fails fast with [`SecretStoreError::AlreadyLocked`]. -//! -//! One file, one passphrase, one lock — a multi-wallet store cannot -//! lock its other wallets out by construction. The lock sidecar -//! (`.lock`) is distinct from the vault file itself so the atomic -//! `persist` rename never touches the inode an open lock fd points at. +//! [`SecretStore::Os`]: crate::secrets::SecretStore::Os //! //! [`open`]: EncryptedFileStore::open //! [`put`]: EncryptedFileStore::put_bytes @@ -30,21 +26,21 @@ //! [`rekey`]: EncryptedFileStore::rekey //! [`get`]: EncryptedFileStore::get_bytes //! -//! ## Threat coverage -//! -//! Covers **A1** (other local user), **A4** (lost laptop / cold -//! backup), **A6** (synced backup of the vault file): the at-rest file -//! is Argon2id + AEAD, useless without the passphrase. Does **not** -//! cover **A3** (passphrase / derived key resident while unlocked), a -//! weak operator passphrase (KDF raises cost, does not eliminate the -//! risk — an accepted residual), or **A5** if the derived key / plaintext is -//! swapped or core-dumped while unlocked (best-effort mitigated by -//! zeroize + mlock, not eliminated). The derived AEAD key is held -//! resident inside a [`SecretBytes`] for the store's lifetime so reads -//! and writes do not pay the Argon2 cost per op; it is zeroized on Drop. - -mod crypto; -mod format; +//! Threat coverage: the at-rest file is Argon2id + AEAD, so it protects +//! **A1** (other local user), **A4** (lost laptop / cold backup), and +//! **A6** (synced backup). It does NOT cover **A3** (key/passphrase +//! resident while unlocked), a weak operator passphrase, or **A5** +//! (swap / core-dump while unlocked) — the last is best-effort mitigated +//! by zeroize + mlock. The derived AEAD key stays resident in a +//! [`SecretBytes`] (to avoid per-op Argon2) and is zeroized on Drop. + +// `pub(super)` (= visible within `crate::secrets`) so the Tier-2 +// `envelope` module — a sibling of `file` under `secrets` — can reuse the +// shared Argon2id/XChaCha primitives and `KDF_ID_ARGON2ID` without +// duplicating crypto. Items inside stay `pub(crate)`/`pub(in …file)`, so +// nothing escapes the secrets tree (see the crypto.rs module doc). +pub(super) mod crypto; +pub(super) mod format; use std::any::Any; use std::collections::HashMap; @@ -61,12 +57,11 @@ use format::{EntryBody, Vault}; use super::error::SecretStoreError; -use super::secret::{SecretBytes, SecretString}; +use super::secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN}; use super::validate::{validated_label, WalletId}; -/// Upstream service-prefix for vault entries. The full `service` -/// string is `SERVICE_PREFIX + hex(wallet_id)`, mapping each wallet -/// to its own keyring "service" namespace. +/// Service-prefix for vault entries: the full `service` string is +/// `SERVICE_PREFIX + hex(wallet_id)`, one namespace per wallet. pub const SERVICE_PREFIX: &str = "dash.platform-wallet-storage/"; /// Vendor / id tags published through `CredentialStoreApi`. @@ -74,113 +69,118 @@ const VENDOR: &str = "dash.platform-wallet-storage"; const STORE_ID: &str = "encrypted-file-store-v1"; /// Structural ceiling on the on-disk vault file. The vault is -/// attacker-controllable JSON; a multi-GiB file would force a huge -/// `fs::read` allocation ahead of any tag check, so refuse to even -/// allocate beyond this cap and surface -/// [`SecretStoreError::VaultTooLarge`]. +/// attacker-controllable JSON, so refuse to allocate / parse beyond this +/// cap (surfacing [`SecretStoreError::VaultTooLarge`]) ahead of any tag +/// check rather than let a multi-GiB file force a huge `fs::read`. pub const MAX_VAULT_SIZE_BYTES: u64 = 128 * 1024 * 1024; +/// Per-secret write-side ceiling. The vault is ONE shared document, so an +/// uncapped oversized entry would inflate it past [`MAX_VAULT_SIZE_BYTES`] +/// and brick every wallet on reopen. 64 KiB is far above any legitimate +/// mnemonic / seed / xpriv. Enforced with +/// [`SecretStoreError::SecretTooLarge`] at the write boundary before the +/// secret is sealed or inserted. +pub const MAX_SECRET_LEN: usize = 64 * 1024; + /// A passphrase-encrypted file-backed credential store. /// -/// One file, one passphrase, one lock — the whole store rotates -/// together via [`rekey`](Self::rekey). Every [`SecretString`] and the -/// resident derived AEAD key are zeroized when the store drops. -/// The plaintext entry map is held in -/// [`EntryBody`]-shaped form: the bytes inside `ciphertext` are -/// ciphertext, but the structure is fully populated so reads do not -/// re-touch disk. -/// -/// The handle is cheap-`Clone` — both clones share the same -/// `Arc>`, so every operation through any clone sees the same -/// resident state and serializes against every other operation. +/// One file, one passphrase, one lock; the whole store rotates together +/// via [`rekey`](Self::rekey). The cheap-`Clone` handle shares one +/// `Arc>`, so every clone sees the same resident state and +/// serializes against every other operation. All [`SecretString`]s and +/// the resident AEAD key are zeroized on drop. #[derive(Clone)] pub struct EncryptedFileStore { inner: Arc>, } -/// All resident state for the store — path, advisory file lock, -/// in-memory vault, cached AEAD key, and the store-wide passphrase — -/// coalesced behind a single [`Mutex`] at the [`Arc`] wrapper level so -/// every mutation observes a consistent triple (vault matches the key -/// it was sealed under, key matches the passphrase that derived it). -/// -/// A single lock keeps put/get/delete/rekey serialized against each -/// other so a concurrent put cannot seal under an old key while rekey -/// is swapping in a new one. The disk write happens while -/// the lock is held; the file-lock sidecar already serializes -/// cross-process so this does not introduce a new I/O contention -/// point. +/// Resident store state behind a single [`Mutex`] so every mutation sees +/// a consistent triple (vault matches the key it was sealed under, key +/// matches the passphrase that derived it). The single lock serializes +/// put/get/delete/rekey so a put cannot seal under an old key mid-rekey; +/// the disk write happens under it, and the file-lock sidecar already +/// serializes cross-process, so this adds no new I/O contention point. struct EncryptedFileStoreInner { /// Vault file path supplied by the caller at [`open`]. /// /// [`open`]: EncryptedFileStore::open path: PathBuf, - /// In-memory vault. Mutations edit this directly and then call - /// `sync_to_disk` to re-encrypt and atomically replace the - /// on-disk file. Reads return clones from here without hitting - /// disk. + /// In-memory vault. Mutations edit it then `sync_to_disk` to + /// re-encrypt and atomically replace the file; reads clone from here. vault: Vault, - /// Cached AEAD key derived once at [`open`] from the salt + KDF - /// params + passphrase. Re-derived only on [`rekey`]. Keeping the - /// key resident is what makes mutations cheap (one AEAD seal per - /// entry, no Argon2 per op) and matches the resident-vault model. - /// A3 (key resident while unlocked) is an accepted threat in the - /// module docs; the buffer zeroizes when the state drops. + /// AEAD key derived once at [`open`] (re-derived only on [`rekey`]). + /// Held resident to avoid per-op Argon2 — A3 (key resident while + /// unlocked) is an accepted threat; zeroized when the state drops. /// /// [`open`]: EncryptedFileStore::open /// [`rekey`]: EncryptedFileStore::rekey derived_key: SecretBytes, - /// The store-wide passphrase. Swapped atomically with `vault` and - /// `derived_key` under the same lock during [`rekey`]. + /// Store-wide passphrase, swapped with `vault` + `derived_key` under + /// the same lock during [`rekey`]. /// /// [`rekey`]: EncryptedFileStore::rekey passphrase: SecretString, - /// Holds the cross-platform advisory write-lock on `.lock` - /// for the entire lifetime of the store. Dropped (releasing the - /// flock / LockFileEx) when the store drops. + /// Holds the advisory write-lock on `.lock` for the store's + /// lifetime; dropped (releasing the lock) when the store drops. _lock: VaultLock, } impl EncryptedFileStore { - /// Open a vault store at `path`, unlocked by `passphrase`. `path` - /// is the vault FILE, not a directory — the operator picks the - /// filename. - /// - /// The call acquires an exclusive advisory lock on a sibling - /// `.lock` sidecar before touching the vault. If the lock is - /// already held (by another handle in this process or by another - /// process) the call returns [`SecretStoreError::AlreadyLocked`] - /// immediately — there is no retry loop. + /// Open a vault store at `path` (the vault FILE, not a directory), + /// unlocked by `passphrase`. /// - /// If `path` does not exist yet a fresh vault (random salt, default - /// Argon2 params, sealed verify token, no entries) is created at - /// `0600` on Unix. If it exists the vault is read, the passphrase - /// is verified against the header verify-token, and the plaintext - /// entry map is loaded into memory. Either way the returned store - /// is immediately usable. + /// Acquires an exclusive advisory lock on a sibling `.lock` + /// first; if already held, returns [`SecretStoreError::AlreadyLocked`] + /// immediately (no retry). A missing `path` is created fresh (random + /// salt, default Argon2 params, sealed verify token) at `0600` on + /// Unix; an existing one is read and its passphrase verified against + /// the header verify-token. Either way the returned store is usable. pub fn open( path: impl AsRef, passphrase: SecretString, ) -> Result { - let path = path.as_ref().to_path_buf(); - - // Make sure the parent directory exists so both the lock sidecar - // open and the vault create do not fail on a not-yet-materialized - // dir (canonical for first-setup operators). `Path::parent()` - // returns `Some("")` for a bare relative filename, which neither - // `create_dir_all` nor the cross-platform persist path can - // consume — normalize the empty-string parent to ".". + // Tier-1 baseline: reject a blank passphrase (empty / all-whitespace) + // BEFORE touching the filesystem. A blank passphrase derives a key + // from a public salt only — obfuscation, not confidentiality + // (obfuscation, not confidentiality). This is an INTENDED behavioural break for any caller + // that relied on `SecretString::empty()`; a deliberate keyless vault + // must use [`open_unprotected`](Self::open_unprotected). No vault + // file is created or altered for a blank passphrase. + reject_weak_passphrase(&passphrase)?; + Self::open_inner(path.as_ref(), passphrase) + } + + /// Open (or create) a **deliberately keyless** vault — the only door + /// that accepts no passphrase. The vault key is derived from an empty + /// passphrase under the public salt, so this is **obfuscation, not + /// confidentiality**: use it only where the stored secrets carry their + /// own Tier-2 object password, or as a staging step before + /// [`rekey`](Self::rekey) to a real passphrase. This is the explicit + /// keyless door, distinct from [`open`](Self::open) (which now rejects a + /// blank passphrase). + pub fn open_unprotected(path: impl AsRef) -> Result { + Self::open_inner(path.as_ref(), SecretString::empty()) + } + + /// Shared open/create core for [`open`](Self::open) and + /// [`open_unprotected`](Self::open_unprotected). Does NOT apply the + /// blank-passphrase guard — the public doors decide that. + fn open_inner(path: &Path, passphrase: SecretString) -> Result { + let path = path.to_path_buf(); + + // Materialize the parent so the lock-sidecar open and vault + // create do not fail on a not-yet-existing dir. let parent = normalized_parent(&path); create_parent_dir(parent)?; + // Refuse a group/other-WRITABLE parent: directory write governs + // rename/unlink, so a writable parent lets another local user + // replace the vault despite its own 0600 (the A1 guarantee). + check_parent_perms(parent)?; - // Acquire the lock first — every subsequent step assumes - // exclusive ownership of the vault file. + // Lock first — every subsequent step assumes exclusive ownership. let lock = VaultLock::acquire(&lock_path_for(&path))?; - // Decide between load-existing and create-fresh based on a - // single open attempt: NotFound → fresh; anything else → load - // (the perm check inside `read_existing_vault` covers loose - // perms on a real file). + // NotFound → create fresh; anything else → load. let (vault, derived_key) = match Self::load_existing_vault(&path, &passphrase)? { Some(loaded) => loaded, None => Self::create_new_vault(&path, &passphrase)?, @@ -222,32 +222,28 @@ impl EncryptedFileStore { Ok((vault, key)) } - /// Re-encrypt the whole store under `new_passphrase`: fresh salt + - /// fresh per-entry nonces for every wallet's entries, then - /// atomically replace the vault file. No `.bak` retains old key - /// material. The swap is whole-store: every - /// wallet's entries are re-keyed in one shot, so the store cannot - /// end up half-rotated. The in-memory vault, derived key, and - /// passphrase advance together under the resident-state mutex. + /// Re-encrypt the whole store under `new_passphrase` — fresh salt and + /// per-entry nonces for every wallet, atomically in one shot (no + /// half-rotated state, no `.bak` retaining old key material). Vault, + /// derived key, and passphrase advance together under the mutex. /// - /// The fresh KDF / Argon2 derivation runs OUTSIDE the lock — it - /// only touches the new passphrase + a fresh salt and never reads - /// resident state, so paying ~hundreds of ms inside the critical - /// section would just stall unrelated put/get operations. + /// The Argon2 derivation runs OUTSIDE the lock — it touches only the + /// new passphrase + fresh salt, so paying ~hundreds of ms inside the + /// critical section would needlessly stall unrelated put/get ops. pub fn rekey(&self, new_passphrase: SecretString) -> Result<(), SecretStoreError> { + // Reject a blank target passphrase: `rekey` always advances to a + // REAL passphrase (the empty→real migration uses this). The resident + // vault, key, and on-disk file are untouched on rejection. To make a + // vault keyless, use `open_unprotected` on a fresh path instead. + reject_weak_passphrase(&new_passphrase)?; let (new_vault, new_key) = build_fresh_vault(&new_passphrase)?; lock_inner(&self.inner).rekey(new_vault, new_key, new_passphrase) } /// Store `secret` under `(wallet_id, label)`, returning the typed - /// [`SecretStoreError`] (lossless — no `keyring_core::Error` seam). - /// The public [`SecretStore`](crate::secrets::SecretStore) file - /// arm delegates here so the structural error distinction - /// survives. Symmetric with [`get_bytes`]: the secret stays - /// wrapped in [`SecretBytes`] across this seam; the lone bare-buffer - /// exposure lives one layer down at the AEAD seal call. - /// - /// [`get_bytes`]: Self::get_bytes + /// [`SecretStoreError`] losslessly (no SPI seam). The secret stays + /// wrapped across this boundary; the bare-buffer exposure is one layer + /// down at the AEAD seal. pub(crate) fn put_bytes( &self, wallet_id: &WalletId, @@ -258,12 +254,9 @@ impl EncryptedFileStore { } /// Retrieve the plaintext under `(wallet_id, label)`, or `None` if - /// absent, returning the typed [`SecretStoreError`]. The plaintext - /// stays inside a zeroizing [`SecretBytes`] all the way to this - /// boundary; the single `.expose_secret().to_vec()` conversion lives - /// at the upstream `CredentialApi::get_secret` - /// SPI seam, the only point where the SPI contract demands a bare - /// `Vec`. + /// absent. The plaintext stays inside a zeroizing [`SecretBytes`] to + /// this boundary; the bare-`Vec` conversion lives only at the + /// `CredentialApi::get_secret` SPI seam, where the contract demands it. pub(crate) fn get_bytes( &self, wallet_id: &WalletId, @@ -292,11 +285,9 @@ impl EncryptedFileStore { write_vault_at(&lock_inner(&self.inner).path, vault) } - /// Drop the in-memory copy of the vault and reload it from disk - /// under the current passphrase. Useful for tests that mutate the - /// on-disk file out from under the store and want subsequent reads - /// to observe the new bytes (the resident-vault model otherwise - /// caches the loaded state). + /// Reload the vault from disk under the current passphrase, so a test + /// that patched the on-disk file sees the new bytes (the resident + /// model otherwise serves the cached state). #[cfg(test)] pub(crate) fn test_reload_from_disk(&self) -> Result<(), SecretStoreError> { let mut state = lock_inner(&self.inner); @@ -310,11 +301,9 @@ impl EncryptedFileStore { } } -/// Acquire the single coarse-grained state lock on `inner`. -/// Poisoned-mutex recovery is "log and continue": a previously-panicked -/// holder cannot have left the [`EncryptedFileStoreInner`] half-written -/// (every mutation either succeeds wholesale and writes to disk or -/// reverts), so the inner value is safe to keep using. +/// Acquire the state lock on `inner`. A poisoned mutex is recovered (not +/// propagated): every mutation either commits wholesale or reverts, so a +/// panicked holder cannot have left the inner value half-written. fn lock_inner( inner: &Arc>, ) -> std::sync::MutexGuard<'_, EncryptedFileStoreInner> { @@ -337,32 +326,38 @@ impl EncryptedFileStoreInner { secret: &SecretBytes, ) -> Result<(), SecretStoreError> { let label = validated_label(label)?.to_string(); + // Reject before sealing: the shared document would otherwise + // inflate past the read-side ceiling and brick every wallet. + if secret.len() > MAX_SECRET_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: secret.len(), + max: MAX_SECRET_LEN, + }); + } let aad = format::aad(format::FORMAT_VERSION, wallet_id.as_bytes(), &label); let (nonce, ciphertext) = crypto::seal(&self.derived_key, &aad, secret.expose_secret())?; - // Mutate in memory; remember the prior body so we can roll - // back on a disk-write failure (the resident state must - // always match what is on disk after a returned-Ok mutation). + // Remember the prior body so a disk-write failure can revert the + // resident state to match disk (Ok must imply memory == disk). let prior = { let entries = self.vault.wallets.entry(wallet_id.to_hex()).or_default(); entries.insert(label.clone(), EntryBody { nonce, ciphertext }) }; if let Err(e) = self.sync_to_disk() { - let entries = self - .vault - .wallets - .get_mut(&wallet_id.to_hex()) - .expect("entry just inserted"); - match prior { - Some(prev) => { - entries.insert(label, prev); - } - None => { - entries.remove(&label); - if entries.is_empty() { - self.vault.wallets.remove(&wallet_id.to_hex()); + // A missing bucket means the insert never landed (nothing to + // undo) — return the error rather than panic. + if let Some(entries) = self.vault.wallets.get_mut(&wallet_id.to_hex()) { + match prior { + Some(prev) => { + entries.insert(label, prev); + } + None => { + entries.remove(&label); + if entries.is_empty() { + self.vault.wallets.remove(&wallet_id.to_hex()); + } } } } @@ -457,9 +452,8 @@ impl EncryptedFileStoreInner { new_vault.wallets.insert(wallet_hex.clone(), new_entries); } - // Stage the new triple in memory, write to disk, and on - // failure restore the old triple so the live handle keeps - // serving under the still-on-disk key. + // Stage the new triple; on disk-write failure restore the old one + // so the live handle keeps serving under the still-on-disk key. let old_vault = std::mem::replace(&mut self.vault, new_vault); let old_key = std::mem::replace(&mut self.derived_key, new_key); let old_pp = std::mem::replace(&mut self.passphrase, new_passphrase); @@ -476,53 +470,60 @@ impl EncryptedFileStoreInner { impl Drop for EncryptedFileStoreInner { fn drop(&mut self) { - // Belt-and-suspenders sync of resident state. Eager-sync on - // every mutation makes this redundant in the success path, but - // a final write lets a future feature (e.g. opportunistic - // background buffering) hang off the same Drop without changing - // the contract. `&mut self` here implies unique ownership — - // the outer `Mutex` is being dropped too, so no other holder - // can be waiting. + // Best-effort final sync. Redundant in the success path (every + // mutation eager-syncs) but kept as a contract anchor. if let Err(e) = self.sync_to_disk() { tracing::warn!(error = %e, "drop-time vault sync failed"); } - // Re-assert restrictive perms on Unix. Between writes the file - // is already 0600, but this defends against a peer that - // loosened them through some other path while we held the - // lock. Best-effort: any failure is non-fatal at Drop. + // Re-assert 0600 on Unix in case a peer loosened it while we held + // the lock. Best-effort: failures are non-fatal at Drop. #[cfg(unix)] - if let Ok(file) = open_no_follow(&self.path) { - if let Err(e) = set_restrictive_perms(&file) { - tracing::warn!(error = %e, "drop-time perm re-assert failed"); + match open_no_follow(&self.path) { + Ok(file) => { + if let Err(e) = set_restrictive_perms(&file) { + tracing::warn!(error = %e, "drop-time perm re-assert failed"); + } + } + Err(e) => { + tracing::warn!( + error = %e, + "drop-time perm re-assert skipped: vault re-open refused" + ); } } - // The `VaultLock` field drops naturally after this method - // returns, releasing the OS advisory lock. + // `VaultLock` drops after this returns, releasing the OS lock. } } -/// Sidecar advisory-lock path for the store's vault file. Kept -/// distinct from the vault file itself so the cross-platform -/// `persist` swap never touches the inode an open lock fd points -/// at — the lock fd remains valid across the atomic replace. +/// Sidecar lock path (`.lock`). Distinct from the vault file so the +/// atomic `persist` swap never touches the inode the lock fd points at. fn lock_path_for(path: &Path) -> PathBuf { let mut s = path.to_path_buf().into_os_string(); s.push(".lock"); PathBuf::from(s) } -/// Build a fresh vault skeleton: random salt, default Argon2 -/// params, and a passphrase-verification token sealed under the -/// freshly derived key (the token is the mixed-key-corruption guard). -/// Returns the (entry-less) vault and the -/// derived key so the caller can seal entries against it without -/// re-deriving. +/// Reject a blank (empty / all-whitespace) or sub-floor passphrase → +/// [`SecretStoreError::BlankPassphrase`]. The floor is the coarse +/// [`MIN_PASSPHRASE_LEN`] (1 today = merely non-blank); the real entropy +/// policy is the consumer's (see `SECRETS.md`). A blank check alone closes +/// the length term keeps the floor wired for a future bump. +fn reject_weak_passphrase(passphrase: &SecretString) -> Result<(), SecretStoreError> { + if passphrase.is_blank() || passphrase.trimmed().len() < MIN_PASSPHRASE_LEN { + return Err(SecretStoreError::BlankPassphrase); + } + Ok(()) +} + +/// Build a fresh entry-less vault (random salt, default Argon2 params, +/// verify-token sealed under the derived key) plus that derived key, so +/// the caller can seal entries without re-deriving. fn build_fresh_vault(passphrase: &SecretString) -> Result<(Vault, SecretBytes), SecretStoreError> { let mut salt = [0u8; SALT_LEN]; crypto::random_bytes(&mut salt)?; let kdf = KdfParams::default_target(); let key = crypto::derive_key(passphrase, &salt, kdf)?; - let v_aad = format::verify_aad(format::FORMAT_VERSION); + let v_aad = format::verify_aad(format::FORMAT_VERSION, &salt, &kdf); let (verify_nonce, verify_ct) = crypto::seal(&key, &v_aad, format::VERIFY_CONSTANT)?; Ok(( Vault { @@ -546,7 +547,7 @@ fn derive_and_verify( passphrase: &SecretString, ) -> Result { let key = crypto::derive_key(passphrase, &vault.salt, vault.kdf)?; - let v_aad = format::verify_aad(format::FORMAT_VERSION); + let v_aad = format::verify_aad(format::FORMAT_VERSION, &vault.salt, &vault.kdf); match crypto::open(&key, &vault.verify_nonce, &v_aad, &vault.verify_ct) { Ok(_) => Ok(key), Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassphrase), @@ -554,13 +555,10 @@ fn derive_and_verify( } } -/// Read + parse the vault at `path`, or `None` if it does not exist. -/// Refuses a pre-existing file with looser-than-0600 perms and a file -/// exceeding [`MAX_VAULT_SIZE_BYTES`]. -/// -/// Eliminates the metadata→read TOCTOU: opens the file once with -/// `O_NOFOLLOW` on Unix, then derives perms / size from -/// the open handle's `metadata()` and reads from the same fd. +/// Read + parse the vault at `path`, or `None` if absent. Refuses +/// looser-than-0600 perms and a file over [`MAX_VAULT_SIZE_BYTES`]. +/// Opens once with `O_NOFOLLOW` and derives perms/size from the same fd +/// to avoid a metadata→read TOCTOU. fn read_vault_at(path: &Path) -> Result, SecretStoreError> { let file = match open_no_follow(path) { Ok(file) => file, @@ -592,16 +590,12 @@ fn read_vault_at(path: &Path) -> Result, SecretStoreError> { Ok(Some(format::deserialize(&bytes)?)) } -/// Atomically replace the vault at `path`, cross-platform. -/// -/// Stages into a `NamedTempFile` in the SAME directory (so `persist` -/// cannot fail cross-volume), tightens perms to 0600 on Unix before -/// any byte is written, then: `write_all` → `sync_all` → -/// `persist(path)` → Unix parent-dir fsync. The destination is never -/// pre-removed, so a crash leaves either the old or the new vault, -/// never an absent one. On `persist` failure the temp drops and -/// self-cleans — no manual remove racing it. The temp holds only -/// ciphertext+header, never plaintext. +/// Atomically replace the vault at `path`. Stages into a same-directory +/// `NamedTempFile` (so `persist` cannot fail cross-volume), tightens to +/// 0600 before writing, then `write_all` → `sync_all` → `persist` → Unix +/// parent-dir fsync. The destination is never pre-removed, so a crash +/// leaves the old or new vault, never none. The temp holds only +/// ciphertext + header, never plaintext. fn write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { do_write_vault_at(path, vault).inspect_err(|e| { tracing::warn!(error = %e, "failed to write vault file"); @@ -610,9 +604,14 @@ fn write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { let serialized = format::serialize(vault); - // Normalize an empty / bare-filename parent to "." so neither - // `NamedTempFile::new_in` nor the Unix parent-dir fsync sees an - // empty path. + // Defence in depth: never write a vault the read path would refuse, + // so the on-disk file is never left unopenable. + if serialized.len() as u64 > MAX_VAULT_SIZE_BYTES { + return Err(SecretStoreError::VaultTooLarge { + found: serialized.len() as u64, + max: MAX_VAULT_SIZE_BYTES, + }); + } let parent = normalized_parent(path); create_parent_dir(parent)?; let mut tmp = @@ -626,30 +625,48 @@ fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> .map_err(|e| SecretStoreError::io_at(path, e))?; tmp.persist(path) .map_err(|e| SecretStoreError::io_at(path, e.error))?; + // The vault is now committed on disk via the atomic persist() rename above. + // A subsequent parent-directory fsync failure cannot undo the already-written + // file. Propagating the error would cause callers (put/delete/rekey) to roll + // back in-memory state that already matches the on-disk vault — leaving the + // live handle diverged from disk. Log a warning instead so the caller's + // rollback guard is not triggered. #[cfg(unix)] { - let d = fs::File::open(parent).map_err(|e| SecretStoreError::io_at(parent, e))?; - d.sync_all() - .map_err(|e| SecretStoreError::io_at(parent, e))?; + match fs::File::open(parent) { + Ok(d) => { + if let Err(e) = d.sync_all() { + tracing::warn!( + error = %e, + parent = %parent.display(), + "parent-dir fsync failed after vault persist; vault is committed on disk" + ); + } + } + Err(e) => { + tracing::warn!( + error = %e, + parent = %parent.display(), + "parent-dir open for fsync failed after vault persist; vault is committed on disk" + ); + } + } } Ok(()) } -/// Normalize `path.parent()` for callers that need a directory path -/// they can pass to `fs::create_dir_all`, `NamedTempFile::new_in`, and -/// the Unix parent-dir fsync. `Path::parent()` returns `Some("")` for a -/// bare relative filename like `"vault.pwsvault"`, and the empty path -/// errors out at every one of those calls — normalize to "." so a -/// caller that supplies a bare filename in their cwd just works. +/// Normalize `path.parent()` to a usable directory: `Path::parent()` +/// returns `Some("")` for a bare filename, which errors at +/// `create_dir_all` / `NamedTempFile::new_in` / parent-dir fsync — map +/// the empty parent to "." so a bare filename in the cwd just works. fn normalized_parent(path: &Path) -> &Path { path.parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or_else(|| Path::new(".")) } -/// Create the parent directory for a vault file, applying a `0700` mode -/// on Unix so the directory created at first-setup is not -/// world-readable. Idempotent: a pre-existing directory is left alone. +/// Create the vault's parent directory at `0700` on Unix (not +/// world-readable). Idempotent — a pre-existing directory is left alone. fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> { #[cfg(unix)] { @@ -659,10 +676,8 @@ fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> { .recursive(true) .create(parent)?; } - // INTENTIONAL: Windows ACL hardening on the parent dir is deferred - // to https://github.com/dashpay/platform/issues/3754. The recursive - // create still runs so the path materializes; operators on Windows - // MUST tighten ACLs manually until the follow-up lands. + // INTENTIONAL: Windows parent-dir ACL hardening deferred to + // https://github.com/dashpay/platform/issues/3754 — tighten manually. #[cfg(not(unix))] { fs::create_dir_all(parent)?; @@ -670,28 +685,25 @@ fn create_parent_dir(parent: &Path) -> Result<(), SecretStoreError> { Ok(()) } -/// Cross-platform advisory write-lock holder. Owns a `Box>` -/// (so the address is stable) and an owned `RwLockWriteGuard` borrowing -/// from it. Dropping the holder drops the guard first (which releases -/// the OS lock via `fd-lock`'s Drop impl, calling `flock(LOCK_UN)` on -/// Unix and `UnlockFileEx` on Windows) and then frees the heap-pinned -/// `RwLock`. -/// -/// The self-reference is unavoidable: `fd-lock`'s guard borrows the -/// `RwLock`, and the resident-vault model requires the lock to stay -/// held continuously between `open` and `Drop`. Wrapped in a small -/// allow-unsafe island so the rest of the crate keeps -/// `deny(unsafe_code)`. Safety arguments: +/// Advisory write-lock holder owning a heap-pinned `Box>` +/// and a self-referential `'static` guard borrowing from it. The +/// self-reference is unavoidable: `fd-lock`'s guard borrows the `RwLock`, +/// and the resident-vault model needs the lock held continuously between +/// `open` and `Drop`. Safety: /// -/// 1. The `RwLock` lives on the heap via `Box::into_raw`, so its -/// address is stable for the holder's lifetime. -/// 2. The `'static` lifetime on the guard is a lie tolerated only -/// because the guard never outlives the holder, and the holder's -/// `Drop` impl takes the guard out (running its Drop) *before* -/// reclaiming the box. +/// 1. `Box::into_raw` gives the `RwLock` a stable address for the +/// holder's lifetime. +/// 2. The `'static` guard lifetime is a lie sound only because `Drop` +/// takes the guard out (running its Drop, releasing the OS lock) +/// BEFORE reclaiming the box. /// 3. The raw pointer never escapes this module. +/// +/// Calibrated to `fd-lock = "=4.0.4"` (exact-pinned): any bump must +/// re-verify the guard releases the OS lock before the box is reclaimed. mod vault_lock { - #![allow(unsafe_code)] + // INTENTIONAL: the crate's only unsafe island; soundness rests on the + // drop-order argument above, not a Miri test. `#![deny(unsafe_code)]` + // still applies everywhere outside the narrowed per-item allows. use std::fs; use std::path::Path; @@ -709,26 +721,30 @@ mod vault_lock { // member is a `File`/`RawFd`, both `Send + Sync`). The raw pointer // points at the heap-pinned `RwLock` this struct owns; sending the // struct moves ownership of the box address with it. + #[allow(unsafe_code)] unsafe impl Send for VaultLock {} + #[allow(unsafe_code)] unsafe impl Sync for VaultLock {} impl VaultLock { pub(super) fn acquire(lock_path: &Path) -> Result { - // INTENTIONAL: on non-unix platforms the symlink-following - // hardening is deferred to - // https://github.com/dashpay/platform/issues/3754 — Windows - // requires `FILE_FLAG_OPEN_REPARSE_POINT` via the raw API - // and is out of scope for the secrets-feature landing. + // INTENTIONAL: non-unix symlink hardening (Windows needs + // FILE_FLAG_OPEN_REPARSE_POINT) deferred to + // https://github.com/dashpay/platform/issues/3754. let mut opts = fs::OpenOptions::new(); opts.read(true).write(true).create(true).truncate(false); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.custom_flags(libc::O_NOFOLLOW); + // Restrictive from the first byte — no loose-perm window. + opts.mode(0o600); } let lock_file = opts .open(lock_path) .map_err(|e| SecretStoreError::io_at(lock_path, e))?; + // `mode()` only applies to a file this call creates; re-assert + // 0600 on a pre-existing sidecar. #[cfg(unix)] set_restrictive_perms(&lock_file)?; @@ -740,6 +756,7 @@ mod vault_lock { // at a valid `RwLock`. No other reference exists // yet, so promoting it to `&'static mut` is sound for the // borrow we hand to `try_write`. + #[allow(unsafe_code)] let static_ref: &'static mut fd_lock::RwLock = unsafe { &mut *raw }; let guard = match static_ref.try_write() { @@ -749,7 +766,10 @@ mod vault_lock { // no live borrow points at the box; reclaiming // here is sound and avoids leaking on the error // path. - unsafe { drop(Box::from_raw(raw)) }; + #[allow(unsafe_code)] + unsafe { + drop(Box::from_raw(raw)) + }; return Err(match e.kind() { std::io::ErrorKind::WouldBlock => SecretStoreError::AlreadyLocked, _ => SecretStoreError::from(e), @@ -773,19 +793,19 @@ mod vault_lock { // the guard has just been dropped (no live borrow), and we // are the only owner. Reclaiming the Box runs the // `RwLock`'s Drop, which closes the file fd. - unsafe { drop(Box::from_raw(self.rwlock)) }; + #[allow(unsafe_code)] + unsafe { + drop(Box::from_raw(self.rwlock)) + }; } } } use vault_lock::VaultLock; -/// Why a wallet-id hex string failed the canonical-form check. -/// -/// `WalletId::to_hex` only ever emits 64 lowercase hex chars, so every -/// seam that parses a wallet id back enforces exactly that shape in one -/// place via [`wallet_id_hex_to_bytes`]; this enum lets each caller map -/// the reason onto its own error type with the right message. +/// Why a wallet-id hex string failed the canonical-form check, so each +/// caller of [`wallet_id_hex_to_bytes`] can map the reason onto its own +/// error type and message. enum WalletIdHexError { /// Not exactly 64 characters. WrongLength, @@ -795,10 +815,9 @@ enum WalletIdHexError { NotHex, } -/// Decode a 64-lowercase-hex-char wallet id into its 32 bytes, enforcing -/// the canonical form `WalletId::to_hex` writes (64 chars, lowercase). -/// The single seam both the on-disk outer-key check and the SPI -/// service-string parse go through so the contract lives in one place. +/// Decode a wallet id into 32 bytes, enforcing the canonical form +/// `WalletId::to_hex` writes (64 lowercase hex chars). The single seam +/// for both the on-disk outer-key check and the SPI service-string parse. fn wallet_id_hex_to_bytes(s: &str) -> Result<[u8; 32], WalletIdHexError> { if s.len() != 64 { return Err(WalletIdHexError::WrongLength); @@ -811,13 +830,10 @@ fn wallet_id_hex_to_bytes(s: &str) -> Result<[u8; 32], WalletIdHexError> { Ok(out) } -/// Decode a wallet-id hex string (the on-disk outer key) into the -/// 32-byte form the AAD construction expects. A malformed key here is -/// an on-disk integrity failure — the format-layer parse already -/// constrains entries to JSON object semantics, but the outer key is -/// a free-form string at the type level, so the bytes-back check is a -/// defence-in-depth structural guard. Off-canonical (uppercase / wrong -/// length / non-hex) keys are all rejected as corruption. +/// Decode the on-disk outer-key wallet hex into the 32 bytes the AAD +/// expects. The outer key is a free-form string at the type level, so +/// this bytes-back check is a defence-in-depth structural guard; any +/// off-canonical key is rejected as corruption. pub(super) fn decode_wallet_id_hex(s: &str) -> Result<[u8; 32], SecretStoreError> { wallet_id_hex_to_bytes(s).map_err(|_| SecretStoreError::MalformedVault) } @@ -843,14 +859,10 @@ fn parse_service(service: &str) -> Result { Ok(WalletId::from(bytes)) } -/// A `(wallet_id, label)` row in an [`EncryptedFileStore`]. -/// -/// Holds a [`Clone`]d handle to the parent store so each credential -/// goes through the same public store API (and the same single-lock -/// critical section per operation). All four operations re-validate -/// `user` (label); the store key is resident on the inner so a -/// wrong-passphrase race cannot happen at the credential layer — the -/// open already failed if the passphrase was wrong. +/// A `(wallet_id, label)` row in an [`EncryptedFileStore`]. Holds a +/// cloned handle to the parent so each op goes through the same store API +/// and single-lock critical section. All ops re-validate the label; the +/// passphrase was already verified at open, so no wrong-pass race here. pub struct EncryptedFileCredential { store: EncryptedFileStore, wallet_id: WalletId, @@ -869,6 +881,13 @@ impl std::fmt::Debug for EncryptedFileCredential { impl CredentialApi for EncryptedFileCredential { fn set_secret(&self, secret: &[u8]) -> KeyringResult<()> { let _ = validated_label(&self.label).map_err(SecretStoreError::from)?; + // Cap before wrapping so an oversized secret is never materialized. + if secret.len() > MAX_SECRET_LEN { + return Err(KeyringError::from(SecretStoreError::SecretTooLarge { + found: secret.len(), + max: MAX_SECRET_LEN, + })); + } self.store .put_bytes( &self.wallet_id, @@ -881,6 +900,8 @@ impl CredentialApi for EncryptedFileCredential { fn get_secret(&self) -> KeyringResult> { let _ = validated_label(&self.label).map_err(SecretStoreError::from)?; match self.store.get_bytes(&self.wallet_id, &self.label) { + // SPI contract forces a bare Vec; caller owns disposal — + // prefer SecretStore::get for a zeroizing SecretBytes. Ok(Some(v)) => Ok(v.expose_secret().to_vec()), Ok(None) => Err(KeyringError::NoEntry), Err(e) => Err(e.into()), @@ -921,6 +942,11 @@ impl CredentialStoreApi for EncryptedFileStore { STORE_ID.to_string() } + /// Build a credential for `(service, user)`. SPI-direct consumers: + /// format the returned [`KeyringError`] with `Display`, never `Debug` + /// — byte-bearing variants embed raw bytes in `Debug` (CWE-209/ + /// CWE-532). Prefer the typed + /// [`SecretStore`](crate::secrets::SecretStore) path. fn build( &self, service: &str, @@ -950,10 +976,9 @@ impl CredentialStoreApi for EncryptedFileStore { impl std::fmt::Debug for EncryptedFileStore { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - // `try_lock` rather than `lock_inner` so a Debug invoked from - // a panic path while the same thread already holds the state - // lock cannot deadlock or double-panic. Poison is folded into - // the same fallback display as contention. + // `try_lock` (not `lock_inner`) so a Debug from a panic path that + // already holds the lock cannot deadlock; poison folds into the + // same fallback as contention. let path: PathBuf = match self.inner.try_lock() { Ok(guard) => guard.path.clone(), Err(_) => PathBuf::from(""), @@ -964,14 +989,11 @@ impl std::fmt::Debug for EncryptedFileStore { } } -/// Project an entry-level `crypto::open` result into the typed -/// distinction the secret backend exposes. The verify-token has already -/// passed at every caller (open / get / rekey), so a -/// `SecretStoreError::Decrypt` here is corruption or tampering of the -/// individual entry — **not** a wrong passphrase. Logs the non-secret -/// `(wallet_id, label)` pair at error level (never the secret) and -/// maps to `SecretStoreError::Corruption`. Every other variant rides -/// through unchanged. +/// Map an entry-level `crypto::open` failure to the typed distinction. +/// The verify-token has already passed at every caller, so a `Decrypt` +/// here is entry corruption/tampering, NOT a wrong passphrase — logs the +/// non-secret `(wallet_id, label)` and maps to `Corruption`; other +/// variants pass through. fn entry_decrypt_or_corruption( wallet_hex: &str, label: &str, @@ -1000,17 +1022,37 @@ fn check_perms(meta: &fs::Metadata) -> Result<(), SecretStoreError> { Ok(()) } -// INTENTIONAL: Windows ACL read-check deferred to a follow-up PR — -// tracked at https://github.com/dashpay/platform/issues/3754. Vault -// file mode hardening on Windows requires GetSecurityInfo via -// `windows-acl` or `winapi`; out of scope for the secrets-feature -// landing. Operators on Windows MUST set ACLs manually until the -// follow-up lands. +// INTENTIONAL: Windows ACL read-check (needs GetSecurityInfo) deferred to +// https://github.com/dashpay/platform/issues/3754 — set ACLs manually. #[cfg(not(unix))] fn check_perms(_meta: &fs::Metadata) -> Result<(), SecretStoreError> { Ok(()) } +/// Refuse a group/other-WRITABLE vault parent (`mode & 0o022`). The +/// threat is rename/unlink/replace, which POSIX gates on directory WRITE, +/// so this targets write bits only — a 0o755 read-only parent leaks +/// filenames but not the 0600 contents and is the common layout. +/// `DirBuilder::mode` only hardens dirs this process creates, so a +/// pre-existing loose dir must still be checked here. +#[cfg(unix)] +fn check_parent_perms(parent: &Path) -> Result<(), SecretStoreError> { + use std::os::unix::fs::MetadataExt; + let meta = fs::metadata(parent).map_err(|e| SecretStoreError::io_at(parent, e))?; + let mode = meta.mode() & 0o777; + if mode & 0o022 != 0 { + return Err(SecretStoreError::InsecureParentDir { mode }); + } + Ok(()) +} + +// INTENTIONAL: Windows parent-dir ACL check deferred to the same +// follow-up as `check_perms` — https://github.com/dashpay/platform/issues/3754. +#[cfg(not(unix))] +fn check_parent_perms(_parent: &Path) -> Result<(), SecretStoreError> { + Ok(()) +} + #[cfg(unix)] fn set_restrictive_perms(f: &fs::File) -> Result<(), SecretStoreError> { use std::os::unix::fs::PermissionsExt; @@ -1018,12 +1060,8 @@ fn set_restrictive_perms(f: &fs::File) -> Result<(), SecretStoreError> { Ok(()) } -// INTENTIONAL: Windows ACL tightening deferred to the same follow-up -// as `check_perms` above — tracked at -// https://github.com/dashpay/platform/issues/3754. Vault file mode -// hardening on Windows requires SetSecurityInfo via `windows-acl` or -// `winapi`; out of scope for the secrets-feature landing. Operators on -// Windows MUST set ACLs manually until the follow-up lands. +// INTENTIONAL: Windows ACL tightening (needs SetSecurityInfo) deferred to +// https://github.com/dashpay/platform/issues/3754 — set ACLs manually. #[cfg(not(unix))] fn set_restrictive_perms(_f: &fs::File) -> Result<(), SecretStoreError> { Ok(()) @@ -1055,6 +1093,13 @@ mod tests { } fn vault_path(dir: &Path) -> PathBuf { + // Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the + // parent-dir perm check (dedicated perm tests use a subdir). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700)); + } dir.join("vault.pwsvault") } @@ -1086,9 +1131,7 @@ mod tests { #[test] fn open_creates_vault_file_on_first_open() { - // Resident-vault model: open() creates a usable vault file even - // without any subsequent put, so a second open() of the same - // path observes a real on-disk file (modulo the lock). + // open() creates a usable vault file even without a put. let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); { @@ -1135,10 +1178,8 @@ mod tests { #[test] fn open_acquires_exclusive_lock_until_drop() { - // Resident-vault model: a second open() of the same path while - // the first store is alive returns AlreadyLocked immediately - // (no retry, no wait). Once the first store drops the lock is - // released and a fresh open() succeeds. + // A second open() while the first store is alive returns + // AlreadyLocked; once it drops, a fresh open() succeeds. let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); let s1 = store_at(&path); @@ -1376,6 +1417,62 @@ mod tests { ); } + /// A parent-dir fsync failure that occurs AFTER `persist()` has already + /// committed the vault to disk must NOT cause `put` to return an error or + /// roll back the in-memory entry. The vault is already on disk; propagating + /// the post-persist error would diverge in-memory state from the on-disk file. + /// + /// Trigger: set the parent dir to `0o300` (write + execute, no read) so + /// `persist()` (a rename — needs write) succeeds but the subsequent + /// `fs::File::open(parent)` for fsync fails (needs read). + #[cfg(unix)] + #[test] + fn put_succeeds_when_parent_dir_fsync_fails_post_persist() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); + entry(&s, wid(1), "seed") + .set_secret(b"first-value") + .unwrap(); + + // Tighten the parent dir to write+execute only (no read): + // - NamedTempFile::new_in : needs write (0o200) + execute (0o100) → OK + // - persist() (rename) : needs write on directory → OK + // - fs::File::open(parent) : needs read (0o400) — MISSING → FAILS + // This forces the post-persist parent-dir fsync to fail, which is the + // scenario under test. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o300)).unwrap(); + let result = entry(&s, wid(1), "seed").set_secret(b"second-value"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + + // Post-fix: put must succeed even though the parent-dir fsync failed — + // the vault was already committed via persist(). + assert!( + result.is_ok(), + "put must succeed even when parent-dir fsync fails post-persist; got {result:?}" + ); + + // In-memory state was NOT rolled back. + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"second-value", + "in-memory state must reflect the committed put" + ); + + // Drop `s` (releases the vault lock) before opening a second store on + // the same file — `EncryptedFileStore::open` is exclusive by design. + drop(s); + + // The vault on disk reflects the committed value. + let reopened = EncryptedFileStore::open(&path, SecretString::new("pw-correct")).unwrap(); + assert_eq!( + entry(&reopened, wid(1), "seed").get_secret().unwrap(), + b"second-value", + "vault on disk must have the committed value after post-persist fsync failure" + ); + } + #[test] fn get_corruption_after_verify_token_is_not_wrong_passphrase() { let dir = tempfile::tempdir().unwrap(); @@ -1455,6 +1552,174 @@ mod tests { ); } + /// The no-plaintext-at-rest guarantee also holds through the public + /// `SecretStore::set` path (which writes an unprotected envelope sealed + /// under the vault key), not just the raw SPI entry path. + #[test] + fn no_plaintext_in_vault_file_via_secret_store_set() { + use crate::secrets::SecretStore; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let store = SecretStore::file(&path, SecretString::new("pw-correct")).unwrap(); + store + .set( + &wid(1), + "seed", + &SecretBytes::from_slice(b"PLAINTEXTNEEDLE"), + ) + .unwrap(); + let raw = fs::read(&path).unwrap(); + assert!( + raw.windows(b"PLAINTEXTNEEDLE".len()) + .all(|w| w != b"PLAINTEXTNEEDLE"), + "plaintext leaked into vault file via SecretStore::set" + ); + } + + /// A blank passphrase is rejected at `open` → + /// `BlankPassphrase`; no vault file (or lock sidecar) is created. + #[test] + fn open_rejects_blank_passphrase() { + for blank in [ + SecretString::empty(), + SecretString::new(""), + SecretString::new(" "), + SecretString::new("\t\n"), + ] { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let err = EncryptedFileStore::open(&path, blank).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank passphrase must be rejected, got {err:?}" + ); + assert!(!path.exists(), "no vault file for a blank passphrase"); + assert!( + !lock_path_for(&path).exists(), + "no lock sidecar for a blank passphrase" + ); + } + } + + /// A blank passphrase is rejected at `rekey`; the resident + /// vault, key, and on-disk file are UNCHANGED — the original passphrase + /// still reads every entry, live and after reopen. + #[test] + fn rekey_rejects_blank_passphrase_vault_unchanged() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); // real "pw-correct" + entry(&s, wid(1), "seed").set_secret(b"v1").unwrap(); + for blank in [SecretString::empty(), SecretString::new(" ")] { + let err = s.rekey(blank).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank rekey must be rejected, got {err:?}" + ); + } + // Old passphrase still reads the entry, live… + assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"v1"); + // …and after a clean reopen under the original passphrase. + drop(s); + let s2 = store_at(&path); + assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"v1"); + } + + /// `open_unprotected` permits a deliberate keyless vault that + /// round-trips; a real-passphrase `open` of that keyless vault then + /// fails with `WrongPassphrase` (it is keyless, not real-pass). + #[test] + fn open_unprotected_permits_keyless_vault() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed") + .set_secret(b"keyless-seed") + .unwrap(); + } + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"keyless-seed" + ); + } + let err = EncryptedFileStore::open(&path, SecretString::new("real")).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "real-pass open of a keyless vault must fail, got {err:?}" + ); + } + + /// Empty→real passphrase migration via `rekey`. After rekey, + /// `open(real)` reads every entry; the keyless door no longer opens it; + /// no `.bak`/`.tmp` residue beside the vault. + #[test] + fn empty_to_real_rekey_migration() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed").set_secret(b"migrate-me").unwrap(); + s.rekey(SecretString::new("real-pass")).unwrap(); + // The live handle keeps working post-rekey. + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"migrate-me" + ); + } + // Reopen under the real passphrase reads the entry. + { + let s = EncryptedFileStore::open(&path, SecretString::new("real-pass")).unwrap(); + assert_eq!( + entry(&s, wid(1), "seed").get_secret().unwrap(), + b"migrate-me" + ); + } + // The keyless door no longer opens it. + let err = EncryptedFileStore::open_unprotected(&path).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "keyless open after migration must fail, got {err:?}" + ); + // No .bak / .tmp residue (mirrors rekey_reencrypts_and_old_passphrase_fails). + for sibling in fs::read_dir(dir.path()).unwrap().flatten() { + let name = sibling.file_name(); + let name = name.to_string_lossy(); + assert!( + !name.ends_with(".bak") && !name.ends_with(".tmp"), + "unexpected residue: {name}" + ); + } + } + + /// Crash-safety: a disk-write failure mid-rekey leaves the + /// pre-rekey keyless vault intact and readable via `open_unprotected` + /// (mirrors rekey_does_not_corrupt_on_disk_temp_failure). + #[cfg(unix)] + #[test] + fn empty_to_real_rekey_crash_safe_stays_keyless() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = EncryptedFileStore::open_unprotected(&path).unwrap(); + entry(&s, wid(1), "seed").set_secret(b"keyless").unwrap(); + + // Read-only parent → the rekey atomic temp-write fails. + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o500)).unwrap(); + let err = s.rekey(SecretString::new("real-pass")).unwrap_err(); + assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + + // The live handle still serves the pre-rekey keyless vault… + assert_eq!(entry(&s, wid(1), "seed").get_secret().unwrap(), b"keyless"); + // …and on disk it is still the keyless vault. + drop(s); + let s2 = EncryptedFileStore::open_unprotected(&path).unwrap(); + assert_eq!(entry(&s2, wid(1), "seed").get_secret().unwrap(), b"keyless"); + } + #[test] fn build_rejects_malformed_service() { let dir = tempfile::tempdir().unwrap(); @@ -1552,13 +1817,9 @@ mod tests { #[test] fn inflated_kdf_params_fail_open_with_kdf_failure() { - // A vault whose JSON declares m_kib = u32::MAX must be refused - // at open() with KdfFailure — before the verify-token is - // derived and without the ~4 TiB allocation the inflated param - // would demand. Under the resident-vault model this surfaces at - // open() rather than on first get(). Drop the store BEFORE - // patching the on-disk file so the drop-time sync cannot - // overwrite our injected corruption. + // A JSON m_kib = u32::MAX must be refused at open() with + // KdfFailure, before the ~4 TiB allocation it would demand. Drop + // the store before patching disk so the drop-sync can't undo it. let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); { @@ -1613,14 +1874,10 @@ mod tests { ); } - /// Two threads share a cloned [`EncryptedFileStore`] handle (same - /// shape [`EncryptedFileCredential`] uses internally): one hammers - /// `put_bytes` + `get_bytes`, the other calls `rekey`. The single - /// state lock serializes every put/get/rekey, so a put can never - /// capture an old key and insert under a newly-swapped vault — every - /// `get` returns either the right plaintext, `Ok(None)`, or a clean - /// typed error, NEVER garbled bytes from a mis-keyed seal (which - /// would surface as `Corruption`). + /// Two threads share a cloned handle: one hammers put/get, the other + /// rekeys. The single state lock serializes them, so a `get` only ever + /// returns the right plaintext, `Ok(None)`, or a clean typed error — + /// never garbled bytes from a put that sealed under a swapped key. #[test] fn rekey_does_not_race_put_into_corruption() { let dir = tempfile::tempdir().unwrap(); @@ -1629,19 +1886,15 @@ mod tests { let writer_store = store.clone(); let rekeyer_store = store.clone(); - // Iteration counts are tuned for cost: every rekey runs Argon2 - // at the default-target params, so the rekey loop dominates - // wall-clock. 16 rekeys overlap a 200-iter put loop reliably - // enough to hit the pre-fix race window on the test runner - // without dragging the suite out. + // Counts tuned for cost: each rekey runs Argon2, so 16 rekeys + // overlapping a 200-iter put loop hits the race window affordably. const PUT_ITERS: usize = 200; const REKEY_ITERS: usize = 16; let wallet = wid(7); let label = "racy"; - // A fixed-prefix payload byte vector — never built with - // `format!` so the in-source secrets-guard scanner does not - // flag this test as a sink/expose_secret pairing. + // Fixed prefix, never built with `format!` so the secrets-guard + // scanner does not flag this as a sink/expose_secret pairing. const PREFIX: &[u8] = b"payload-"; let writer = std::thread::spawn(move || { let mut buf = Vec::with_capacity(PREFIX.len() + 4); @@ -1654,9 +1907,8 @@ mod tests { .expect("put"); match writer_store.get_bytes(&wallet, label) { Ok(Some(bytes)) => { - // Must be one of OUR payloads — never random - // bytes from a mis-keyed seal. Compare only - // length + prefix; never log the bytes. + // Must be one of OUR payloads, never mis-keyed + // garbage. Check length + prefix; never log bytes. let got = bytes.expose_secret(); assert!(got.starts_with(PREFIX), "garbled get-after-put"); assert_eq!(got.len(), PREFIX.len() + 4); @@ -1668,10 +1920,8 @@ mod tests { }); let rekeyer = std::thread::spawn(move || { - // Alternate two passphrases so consecutive rekeys actually - // change the resident key (the salt rerolls regardless, but - // alternating distinct passphrases is the operator-facing - // model and keeps the race window real). + // Alternate passphrases so consecutive rekeys change the + // resident key, keeping the race window real. let passphrases = ["pw-A", "pw-B"]; for i in 0..REKEY_ITERS { rekeyer_store @@ -1684,20 +1934,23 @@ mod tests { rekeyer.join().expect("rekeyer thread"); } - /// A bare relative filename makes `Path::parent()` return `Some("")`, - /// which `NamedTempFile::new_in("")` and the Unix parent-dir fsync - /// both reject; the `normalized_parent` helper rewrites the empty - /// parent to ".". Switch cwd to a temp dir for the test scope so we - /// exercise the bare-filename path without scribbling in the - /// workspace. + /// A bare filename makes `Path::parent()` return `Some("")`, which + /// `normalized_parent` rewrites to "."; exercise that path in a temp + /// cwd so nothing lands in the workspace. #[test] fn open_and_put_with_bare_filename_uses_cwd() { - // A static mutex serializes cwd-changing tests so they cannot - // race each other across the suite. + // Serialize cwd-changing tests so they cannot race each other. static CWD_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(()); let _g = CWD_GUARD.lock().unwrap_or_else(|p| p.into_inner()); let dir = tempfile::tempdir().unwrap(); + // Tighten the cwd-parent so the parent-dir perm check passes (a + // umask-0002 tempdir is group-writable at 0o775). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o700)).unwrap(); + } let prior = std::env::current_dir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); // Tear-down guard so a panic still restores cwd. @@ -1720,9 +1973,8 @@ mod tests { assert!(dir.path().join("vault.pwsvault").exists()); } - /// The lock sidecar must refuse to traverse a pre-existing symlink - /// at the lock path on Unix. Without `O_NOFOLLOW` an attacker could - /// redirect the lock file's open to an unrelated inode. + /// The lock-sidecar open must refuse a pre-existing symlink + /// (`O_NOFOLLOW`) so an attacker can't redirect it to another inode. #[cfg(unix)] #[test] fn vault_lock_rejects_symlink_at_lock_path() { @@ -1731,9 +1983,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = vault_path(dir.path()); let lock = lock_path_for(&path); - // Point the lock path at /dev/null. Any successful open of the - // symlink would land on /dev/null's inode; O_NOFOLLOW makes the - // open itself fail with ELOOP. + // O_NOFOLLOW makes the open of this symlink fail with ELOOP. symlink("/dev/null", &lock).unwrap(); let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) @@ -1743,4 +1993,153 @@ mod tests { "expected an Io error from O_NOFOLLOW refusal, got {err:?}" ); } + + /// A group/other-WRITABLE parent is refused at open (it would let a + /// peer rename/replace the vault despite its 0600); a read-only 0o750 + /// parent is fine. + #[cfg(unix)] + #[test] + fn writable_parent_dir_is_refused() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("vaultdir"); + fs::create_dir(&sub).unwrap(); + // Group-writable (0o770) trips the write-bit check. Build the path + // directly — `vault_path` would tighten the dir back to 0o700. + fs::set_permissions(&sub, fs::Permissions::from_mode(0o770)).unwrap(); + let path = sub.join("vault.pwsvault"); + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("writable parent dir must be refused"); + assert!( + matches!(err, SecretStoreError::InsecureParentDir { mode } if mode & 0o022 != 0), + "got {err:?}" + ); + // Dropping the write bits (still group-readable at 0o750) lets the + // open succeed: read-only group access is not a rename threat. + fs::set_permissions(&sub, fs::Permissions::from_mode(0o750)).unwrap(); + let _s = store_at(&path); + } + + /// An oversized secret is rejected at the write boundary with + /// `SecretTooLarge`, and the vault stays openable — the per-secret + /// cap prevents the shared document from being inflated past the + /// read-side ceiling. + #[test] + fn oversized_secret_rejected_and_vault_stays_openable() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = store_at(&path); + entry(&s, wid(1), "ok").set_secret(b"small").unwrap(); + let too_big = vec![0xABu8; MAX_SECRET_LEN + 1]; + let err = entry(&s, wid(1), "huge").set_secret(&too_big).unwrap_err(); + // Surfaces through the SPI as BadStoreFormat carrying the + // secret-free SecretTooLarge message. + assert!( + matches!(&err, KeyringError::BadStoreFormat(m) + if *m == SecretStoreError::SecretTooLarge { + found: MAX_SECRET_LEN + 1, + max: MAX_SECRET_LEN, + }.to_string()), + "got {err:?}" + ); + // The earlier good entry is still readable on this handle. + assert_eq!(entry(&s, wid(1), "ok").get_secret().unwrap(), b"small"); + } + // The vault reopens cleanly — the oversized put never landed. + let s2 = store_at(&path); + assert_eq!(entry(&s2, wid(1), "ok").get_secret().unwrap(), b"small"); + assert!(matches!( + entry(&s2, wid(1), "huge").get_secret(), + Err(KeyringError::NoEntry) + )); + } + + /// An in-bounds KDF-param shift on a correct-passphrase vault is + /// rejected at open with `WrongPassphrase` — driven by the changed + /// DERIVED KEY, not the AAD binding (which `verify_aad_binds_salt_and_ + /// kdf_params` covers). + #[test] + fn header_tamper_kdf_shift_smoke_test() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = store_at(&path); + entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); + } + let mut vault = read_vault_at(&path).unwrap().unwrap(); + // Shift to still-valid-but-weaker params (defaults are 64 MiB/t=3; + // halving m_kib and dropping t stays above the 19 MiB / t=2 floor). + vault.kdf.m_kib /= 2; + vault.kdf.t -= 1; + assert!( + vault.kdf.enforce_bounds().is_ok(), + "shift must stay in bounds" + ); + write_vault_at(&path, &vault).unwrap(); + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("KDF-param shift must fail the verify-token"); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "got {err:?}" + ); + } + + /// A flipped salt byte on a correct-passphrase vault is rejected at + /// open with `WrongPassphrase` — driven by the changed DERIVED KEY + /// (salt feeds the KDF), not the AAD binding. + #[test] + fn header_tamper_flipped_salt_smoke_test() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + { + let s = store_at(&path); + entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); + } + let mut vault = read_vault_at(&path).unwrap().unwrap(); + vault.salt[0] ^= 0x01; + write_vault_at(&path, &vault).unwrap(); + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("flipped salt must fail open"); + assert!( + matches!(err, SecretStoreError::WrongPassphrase), + "got {err:?}" + ); + } + + /// A flipped entry NONCE byte (verify-token intact) surfaces as + /// `Corruption`: the per-entry AEAD-open fails its tag under the + /// correct key, mirroring the ciphertext-flip route. + #[test] + fn flipped_entry_nonce_is_corruption() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); + entry(&s, wid(1), "seed").set_secret(b"value").unwrap(); + let mut vault = s.test_read_vault_from_disk().unwrap().unwrap(); + vault + .wallets + .get_mut(&wid(1).to_hex()) + .unwrap() + .get_mut("seed") + .unwrap() + .nonce[0] ^= 0x01; + s.test_write_vault_to_disk(&vault).unwrap(); + s.test_reload_from_disk().unwrap(); + let err = entry(&s, wid(1), "seed").get_secret().unwrap_err(); + assert!(is_corruption(&err), "unexpected error: {err:?}"); + } + + /// A secret exactly at the cap is accepted (boundary is inclusive). + #[test] + fn secret_exactly_at_cap_is_accepted() { + let dir = tempfile::tempdir().unwrap(); + let s = store_at(&vault_path(dir.path())); + let at_cap = vec![0x5Au8; MAX_SECRET_LEN]; + entry(&s, wid(1), "atcap").set_secret(&at_cap).unwrap(); + assert_eq!( + entry(&s, wid(1), "atcap").get_secret().unwrap().len(), + MAX_SECRET_LEN + ); + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/keyring.rs b/packages/rs-platform-wallet-storage/src/secrets/keyring.rs index 618706276e3..4d58d407656 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/keyring.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/keyring.rs @@ -1,57 +1,49 @@ //! OS-keyring construction helper. //! -//! Built on `keyring-core 1.0.0` (the SPI library) plus the -//! per-platform credential-store crates; the `keyring` 4.x sample CLI -//! crate itself is intentionally not a dependency. +//! Built on `keyring-core 1.0.0` (the SPI) plus the per-platform +//! credential-store crates; the `keyring` 4.x sample CLI crate is +//! deliberately not a dependency. There is no crate-local wrapper — +//! [`default_credential_store`]'s return value is used directly via +//! [`keyring_core::api::CredentialStoreApi`] or installed as the process +//! default via [`keyring_core::set_default_store`]. //! -//! There is no crate-local wrapper around the per-platform store: a -//! caller takes [`default_credential_store`]'s return value and either -//! uses it directly via [`keyring_core::api::CredentialStoreApi`] or -//! installs it as the process default via -//! [`keyring_core::set_default_store`]. +//! Threat coverage: protects **A1** (other local user) and **A4** (lost +//! laptop) where the platform encrypts items at rest and scopes them to +//! the user. Does **not** cover **A2/A3** same-user malware (most OS +//! keyrings hand the secret to any same-user process) or **A5** keyring +//! scraping; headless Linux without Secret Service fails closed +//! ([`keyring_core::Error::NoDefaultStore`]), never degrading to plaintext. //! -//! ## Threat coverage +//! Metadata is enumerable plaintext: entries are keyed by `service = +//! SERVICE_PREFIX + hex(wallet_id)` and `user = label`, stored as +//! plaintext keyring metadata. Same-user list-only tooling can enumerate +//! which wallet ids and slot kinds exist without unlocking a secret — +//! dominated by the accepted A2/A3 residual, with no portable knob to +//! redact it. Operators wanting metadata hiding should prefer +//! [`EncryptedFileStore`](super::EncryptedFileStore), whose +//! `(wallet_id, label)` map lives only inside the sealed vault. //! -//! Covers **A1** (other local user) and **A4** (lost laptop) where the -//! platform encrypts keyring items at rest and scopes them to the user. -//! Does **not** cover **A2/A3** same-user malware (most OS keyrings -//! hand the secret to any same-user process that asks), **A5** if the -//! keyring daemon itself is scraped, or **headless Linux** with no -//! Secret Service — that fails closed -//! ([`keyring_core::Error::NoDefaultStore`]), never degrades to -//! plaintext. -//! -//! ### Per-OS reality -//! -//! - **Linux/FreeBSD:** Secret Service (gnome-keyring / KWallet) is the -//! sole backend. It requires a D-Bus session + unlocked collection; -//! headless / SSH / CI boxes frequently lack it, in which case the -//! store fails closed with `NoDefaultStore` and the operator selects -//! [`EncryptedFileStore`](super::EncryptedFileStore) explicitly. -//! Items persist `UntilDelete`. Callers that need durable storage on -//! a headless host should pin -//! [`EncryptedFileStore`](super::EncryptedFileStore) instead. -//! - **macOS:** Keychain ACL — a re-signed binary with the same -//! code-signing identity is an accepted residual risk. Items persist -//! `UntilDelete`. -//! - **Windows:** Credential Manager / DPAPI is user-profile scoped; a -//! same-user process can unprotect it. DPAPI is **not** a defense -//! against same-user malware, only A1/A4. Items persist -//! `UntilDelete`. +//! Per-OS: items persist `UntilDelete` everywhere. Linux/FreeBSD use +//! Secret Service (gnome-keyring / KWallet), which needs a D-Bus session +//! and an unlocked collection. macOS Keychain ACL accepts a re-signed +//! binary with the same code-signing identity (residual). Windows DPAPI +//! is user-profile scoped and defends A1/A4 only, not same-user malware. use std::sync::Arc; use keyring_core::api::CredentialStoreApi; use keyring_core::Error as KeyringError; -/// Open the platform's default credential store, failing closed -/// (typed [`KeyringError::NoDefaultStore`]) when none is reachable -/// (headless / no Secret Service / no D-Bus). Never panics, never -/// falls back to a weaker store. +/// Open the platform's default credential store, failing closed (typed +/// [`KeyringError::NoDefaultStore`]) when none is reachable (headless / no +/// Secret Service / no D-Bus). Never panics, never falls back to a weaker +/// store. The returned `Arc` works with +/// [`keyring_core::set_default_store`] or builds entries directly. /// -/// The returned `Arc` may be passed straight to -/// [`keyring_core::set_default_store`] or used directly to build -/// entries. +/// SPI-direct consumers: format the returned [`KeyringError`] with +/// `Display` (`{}`), **never** `Debug` — upstream `BadEncoding` / +/// `BadDataFormat` variants embed raw bytes in `Debug` (CWE-209/CWE-532). +/// The typed [`SecretStore`](super::SecretStore) path avoids the SPI error. pub fn default_credential_store() -> Result, KeyringError> { platform_default_store() @@ -59,10 +51,8 @@ pub fn default_credential_store() -> Result Result, KeyringError> { - // Secret Service (gnome-keyring / KWallet) is the only OS backend. - // No reachable D-Bus session / unlocked collection (headless, SSH, - // CI) is fail-closed by design — the operator selects - // EncryptedFileStore explicitly instead. + // Secret Service is the only backend; an unreachable D-Bus session + // (headless / SSH / CI) is fail-closed by design. match dbus_secret_service_keyring_store::Store::new() { Ok(s) => Ok(s), Err(_) => Err(KeyringError::NoDefaultStore), @@ -71,11 +61,8 @@ fn platform_default_store() -> Result, #[cfg(target_os = "macos")] fn platform_default_store() -> Result, KeyringError> { - // `apple-native-keyring-store` >= 1.0 with the `keychain` feature - // exposes `Store` under the `keychain` module, not at the crate - // root (sibling backends — `dbus-secret-service-keyring-store`, - // `windows-native-keyring-store` — do put `Store` at the root, hence - // the asymmetric path). + // `apple-native-keyring-store` >= 1.0 exposes `Store` under the + // `keychain` module, not the crate root like the sibling backends. match apple_native_keyring_store::keychain::Store::new() { Ok(s) => Ok(s), Err(_) => Err(KeyringError::NoDefaultStore), diff --git a/packages/rs-platform-wallet-storage/src/secrets/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/mod.rs index f44699f6ae7..6989a6e69e6 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/mod.rs @@ -1,54 +1,33 @@ //! Out-of-band storage for wallet secret material (mnemonic / seed / //! xpriv), kept entirely off the SQLite persister's data path. //! -//! # Consumer entry point: [`SecretStore`] -//! -//! [`SecretStore`] is the public, never-leaking front door. Its read -//! path ([`SecretStore::get`]) yields a zeroizing [`SecretBytes`] — a raw -//! `Vec` never crosses this boundary — and its write path -//! ([`SecretStore::set`]) takes `&SecretBytes`, so a caller cannot pass an -//! unwrapped buffer. Errors surface as the typed [`SecretStoreError`], -//! losslessly for the file arm (`WrongPassphrase` vs `Corruption` vs -//! `AlreadyLocked` stay distinct). -//! -//! - [`SecretStore::file`] — Argon2id + XChaCha20-Poly1305 vault file. -//! Recommended on **headless / server** hosts; fully self-contained. -//! - [`SecretStore::os`] — the platform OS keyring, fail-closed on -//! headless Linux. Recommended on **desktop**. -//! -//! # Internal SPI -//! -//! Below `SecretStore`, the backend SPI is upstream's -//! [`keyring_core::api::CredentialStoreApi`] / [`CredentialApi`]. -//! [`EncryptedFileStore`] and [`default_credential_store`] expose that -//! SPI directly; their `keyring_core::Error` projection is **lossy and -//! string-only** (the typed distinction lives on the `SecretStore` path). -//! Consumers should prefer `SecretStore`. -//! -//! - [`SecretBytes`] / [`SecretString`] — zeroize-on-drop wrappers. -//! - [`SecretStoreError`] — the typed error returned by `SecretStore` -//! and both backends, projected into `keyring_core::Error` for the SPI. +//! Consumers use [`SecretStore`], the public never-leaking front door: +//! reads yield a zeroizing [`SecretBytes`] (a raw `Vec` never crosses +//! the boundary), writes take `&SecretBytes`, and errors are the typed +//! [`SecretStoreError`] (lossless on the file arm). Pick a backend +//! explicitly — [`SecretStore::file`] (Argon2id + XChaCha20-Poly1305 +//! vault, headless/server) or [`SecretStore::os`] (OS keyring, desktop; +//! fail-closed on headless Linux). There is no silent fallback. +//! +//! Below `SecretStore` the backend SPI is upstream's +//! [`keyring_core::api::CredentialStoreApi`] / [`CredentialApi`], exposed +//! directly by [`EncryptedFileStore`] / [`default_credential_store`]; +//! its `keyring_core::Error` projection is lossy and string-only, so +//! consumers should prefer `SecretStore`. //! //! [`CredentialApi`]: keyring_core::api::CredentialApi //! [`CredentialStoreApi`]: keyring_core::api::CredentialStoreApi //! -//! Everything secret-bearing lives under this `src/secrets/` tree by -//! design: `tests/secrets_scan.rs` scans only `src/sqlite/schema/` + -//! `migrations/` and exempts this module, so this module owns its own -//! review discipline (`tests/secrets_guard.rs`). -//! -//! # Memory hygiene -//! -//! At the SPI seam the upstream `get_secret` returns `Vec`; -//! [`SecretStore::get`] wraps it via [`SecretBytes::new`] **immediately** -//! (no named intermediate `Vec` binding) so the bare buffer's window is -//! zero statements: `SecretBytes::new` moves the `Vec` into a -//! `Zeroizing>` without copying. -//! -//! # Backend selection +//! This `src/secrets/` tree is the sole secret-bearing module: +//! `tests/secrets_scan.rs` exempts it, so it owns its own review +//! discipline via `tests/secrets_guard.rs`. //! -//! Selection is an explicit operator decision — there is no silent -//! fallback between the file vault and the OS keyring. +//! Cryptographic wire format lives in [`mod@wire`]: the Tier-2 +//! envelope (`wire::envelope`) and the three AAD constructions +//! (`wire::aad`) are bincode-encoded against a single `WIRE_CONFIG`, so +//! a future bincode-config drift is caught by the golden-vector tests +//! in `wire::envelope::tests` rather than silently corrupting every +//! stored blob. mod error; mod file; @@ -56,10 +35,15 @@ mod keyring; mod secret; mod store; mod validate; +mod wire; pub use error::{IoError, OsKeyringErrorKind, SecretStoreError}; -pub use file::{EncryptedFileCredential, EncryptedFileStore, MAX_VAULT_SIZE_BYTES, SERVICE_PREFIX}; +pub use file::{ + EncryptedFileCredential, EncryptedFileStore, MAX_SECRET_LEN, MAX_VAULT_SIZE_BYTES, + SERVICE_PREFIX, +}; pub use keyring::default_credential_store; -pub use secret::{SecretBytes, SecretString}; +pub use secret::{SecretBytes, SecretString, MIN_PASSPHRASE_LEN}; pub use store::SecretStore; pub use validate::WalletId; +pub use wire::envelope::MAX_PLAINTEXT_LEN; diff --git a/packages/rs-platform-wallet-storage/src/secrets/secret.rs b/packages/rs-platform-wallet-storage/src/secrets/secret.rs index 6a2a593af33..87faf066efa 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/secret.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/secret.rs @@ -9,25 +9,32 @@ use std::fmt; use subtle::ConstantTimeEq; use zeroize::{Zeroize, Zeroizing}; -/// Pre-allocation capacity for [`SecretString`] buffers. -/// -/// `mlock` is page-granular, so a sub-page buffer locks a whole page -/// regardless; 4096 bytes also makes `String` reallocation (which -/// leaves an un-zeroed freed buffer the allocator owns) virtually -/// impossible for any human-entered passphrase or mnemonic. +/// Pre-allocation capacity for [`SecretString`] buffers. `mlock` is +/// page-granular (a sub-page buffer locks a whole page anyway), and 4096 +/// bytes makes a reallocation — which would leave an un-zeroed freed +/// buffer behind — virtually impossible for any human-entered secret. const DEFAULT_CAPACITY: usize = 4096; +/// Minimal post-trim length floor for a vault passphrase or a Tier-2 +/// object password, in bytes. A **coarse** guard only: `1` means "merely +/// non-blank" (the same outcome [`SecretString::is_blank`] enforces). +/// +/// The library deliberately ships **no** password-strength estimator. The +/// real entropy policy — zxcvbn-style strength, dictionary checks, UX +/// feedback — is locale- and threat-specific and therefore the +/// **consumer's** responsibility (documented in `SECRETS.md`). Baking a +/// fixed estimator into a storage crate would be both too weak for some +/// callers and too rigid for others. +pub const MIN_PASSPHRASE_LEN: usize = 1; + /// Zeroize-on-drop wrapper for secret UTF-8 strings (BIP-39 mnemonic, /// `EncryptedFileStore` passphrase). /// -/// `Display`, `Deref`, `DerefMut`, `Serialize`, `PartialEq`, `Eq` are -/// intentionally **not** implemented; read access is the explicit -/// [`expose_secret`] only, and equality goes through -/// [`subtle::ConstantTimeEq`] (`==` on secret bytes is forbidden, no -/// exception, so future bridge code cannot inherit a non-constant-time -/// path). `Debug` is redacted. `Zeroizing` -/// wipes the buffer over its full capacity on drop; the buffer is -/// best-effort `mlock`ed against swap. +/// Read access is [`expose_secret`] only; equality goes through +/// [`subtle::ConstantTimeEq`] (`==` is forbidden so bridge code cannot +/// inherit a non-constant-time path). `Display`/`Deref`/`Serialize`/`Eq` +/// are deliberately absent, `Debug` is redacted, and the buffer wipes +/// over its full capacity on drop and is best-effort `mlock`ed. /// /// [`expose_secret`]: SecretString::expose_secret /// @@ -38,9 +45,8 @@ const DEFAULT_CAPACITY: usize = 4096; /// let _ = a == b; // `==` on SecretString is forbidden; use ConstantTimeEq::ct_eq /// ``` pub struct SecretString { - // Field order is load-bearing: `inner` drops (and `Zeroizing` wipes - // it) before `_lock` releases the page, so the buffer is wiped while - // still mlock'ed. + // Field order is load-bearing: `inner` drops (Zeroizing wipes it) + // before `_lock` releases the page, so the wipe runs while mlock'ed. inner: Zeroizing, _lock: Option, } @@ -53,6 +59,10 @@ impl SecretString { let cap = source.len().max(DEFAULT_CAPACITY); let mut buf = String::with_capacity(cap); buf.push_str(&source); + // Do not remove: wipes the moved-in plaintext source before it drops. + // A direct freed-buffer scan would require `unsafe`, which this crate + // forbids; the test `secret_string_new_zeroizes_string_source` instead + // pins the `String::zeroize` primitive and this call site. source.zeroize(); let lock = region::lock(buf.as_ptr(), buf.capacity()) .map_err(|e| { @@ -93,6 +103,18 @@ impl SecretString { pub fn trimmed(&self) -> Self { Self::new(self.inner.trim().to_string()) } + + /// Whether the secret is empty or all Unicode-whitespace. + /// + /// Returns only blank-ness — never a borrowed view of the plaintext — + /// and uses [`str::trim`] (the Unicode `White_Space` property), so a + /// NBSP (`U+00A0`) trims to blank but a ZWSP (`U+200B`, not + /// `White_Space`) does not. This is the enforcement primitive behind + /// the Tier-1 blank-passphrase guard and the Tier-2 blank-object- + /// password reject. Always available — **not** feature-gated. + pub fn is_blank(&self) -> bool { + self.inner.trim().is_empty() + } } impl Default for SecretString { @@ -120,9 +142,8 @@ impl fmt::Debug for SecretString { } impl ConstantTimeEq for SecretString { - /// Constant-time compare over the equal-length region. Unequal - /// lengths return `0` without revealing where they differ; the - /// only observable is the (non-secret) length difference. + /// Constant-time compare. Unequal lengths return `0` without + /// revealing where they differ; the only leak is the non-secret length. fn ct_eq(&self, other: &Self) -> subtle::Choice { self.expose_secret() .as_bytes() @@ -130,6 +151,14 @@ impl ConstantTimeEq for SecretString { } } +impl Zeroize for SecretString { + /// Wipe the buffer in place on a live value. `Drop` runs the same + /// wipe automatically; this lets a holder zeroize early. + fn zeroize(&mut self) { + self.inner.zeroize(); + } +} + impl From for SecretString { fn from(s: String) -> Self { Self::new(s) @@ -142,18 +171,95 @@ impl From<&str> for SecretString { } } +/// Deserialize a UTF-8 secret (a vault passphrase or a Tier-2 object +/// password arriving via config), routing the owned `String` through +/// [`SecretString::new`] — which zeroizes its source — so no +/// intermediate plaintext buffer **we own** lingers (CWE-316). +/// +/// Gated behind the dedicated, default-off `secret-serde` feature, NOT the +/// crate's internal `serde` dep (which `secrets` already pulls): the gate +/// is on the IMPL, so the impl is absent unless explicitly opted in, even +/// though `serde` itself is compiled. There is deliberately **no** +/// `Serialize` companion (a secret is read-from-config, never written +/// back / round-tripped / logged), so this type cannot leak out through +/// serde under any feature combination. +/// +/// **Residual (documented, not closeable here):** the deserializer's own +/// input buffer holds the cleartext before this visitor runs and is +/// outside `SecretString`'s ownership, so it cannot be wiped here — feed +/// secrets from a zeroizing source. Mirrors the Argon2 `Block` residual +/// noted at `crypto::derive_key`. +#[cfg(feature = "secret-serde")] +impl<'de> serde::Deserialize<'de> for SecretString { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SecretStringVisitor; + + impl<'v> serde::de::Visitor<'v> for SecretStringVisitor { + type Value = SecretString; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a secret string") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + // Take ownership of the borrowed bytes, then hand the owned + // `String` to the zeroizing constructor below. + self.visit_string(v.to_owned()) + } + + fn visit_string(self, v: String) -> Result + where + E: serde::de::Error, + { + // `SecretString::new` zeroizes the moved-in `String`. + Ok(SecretString::new(v)) + } + } + + deserializer.deserialize_string(SecretStringVisitor) + } +} + +/// Render the JSON schema as a plain `string` carrying **no** length or +/// value policy: no `minLength`/`maxLength`/`pattern`/`format` (would leak +/// a length policy) and no `example`/`default` (would embed a value) +/// A short, value-free `description` marks sensitivity. +/// +/// Gated behind the default-off `secret-schemars` feature (which implies +/// `secret-serde`). Pulls in no `Serialize`/`Display` path. +#[cfg(feature = "secret-schemars")] +impl schemars::JsonSchema for SecretString { + fn schema_name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("SecretString") + } + + fn schema_id() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("platform_wallet_storage::secrets::SecretString") + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ + "type": "string", + "description": "A secret string. Write-only: never serialized, never echoed." + }) + } +} + /// Zeroize-on-drop wrapper for secret **bytes**: BIP-32 seed -/// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext, -/// ciphertext-in-flight. +/// (`[u8; 64]`), xpriv, Argon2 output, AEAD key, decrypted plaintext. /// -/// Not `Copy`; `Clone` is intentionally absent to enforce copy -/// minimization — move it, or `expose_secret()` and copy -/// deliberately into another wrapper. `Display`, `Deref`, `Serialize`, -/// `PartialEq`, `Eq` are intentionally **not** implemented; equality -/// goes through [`subtle::ConstantTimeEq`] only (`==` on secret bytes is -/// forbidden, no exception, so future bridge code cannot inherit a -/// non-constant-time path). `Debug` is redacted; the -/// buffer is wiped on drop and best-effort `mlock`ed. +/// `Clone` is absent to force deliberate copies (move it, or +/// `expose_secret()` into another wrapper). Equality goes through +/// [`subtle::ConstantTimeEq`] only (`==` is forbidden so bridge code +/// cannot inherit a non-constant-time path). `Display`/`Deref`/`Serialize` +/// /`Eq` are absent, `Debug` is redacted, and the buffer wipes on drop +/// and is best-effort `mlock`ed. /// /// ```compile_fail /// use platform_wallet_storage::secrets::SecretBytes; @@ -162,9 +268,8 @@ impl From<&str> for SecretString { /// let _ = a == b; // `==` on SecretBytes is forbidden; use ConstantTimeEq::ct_eq /// ``` pub struct SecretBytes { - // Field order is load-bearing: `inner` drops (and `Zeroizing` wipes - // it) before `_lock` releases the page, so the buffer is wiped while - // still mlock'ed. + // Field order is load-bearing: `inner` drops (Zeroizing wipes it) + // before `_lock` releases the page, so the wipe runs while mlock'ed. inner: Zeroizing>, _lock: Option, } @@ -173,8 +278,8 @@ impl SecretBytes { /// Wrap a byte vector, moving it into the wrapper and best-effort /// `mlock`ing the buffer. pub fn new(bytes: Vec) -> Self { - // Lock only a non-empty allocation: an empty `Vec`'s `as_ptr()` - // is dangling, and `region::lock` rejects a 0-length region. + // Skip an empty allocation: an empty `Vec`'s `as_ptr()` is + // dangling and `region::lock` rejects a 0-length region. let lock = if bytes.capacity() > 0 { region::lock(bytes.as_ptr(), bytes.capacity()) .map_err(|e| { @@ -187,9 +292,6 @@ impl SecretBytes { } else { None }; - // The move transfers ownership of the allocation into - // `Zeroizing`; the source buffer is not copied, so there is - // nothing left behind to wipe. Self { inner: Zeroizing::new(bytes), _lock: lock, @@ -230,15 +332,22 @@ impl SecretBytes { } impl ConstantTimeEq for SecretBytes { - /// Fixed-width constant-time compare over the byte region — no - /// length early-return. `subtle::ConstantTimeEq` on - /// unequal-length slices yields `0` without leaking *where* they - /// differ; the only observable is the (non-secret) length. + /// Constant-time compare, no length early-return. Unequal lengths + /// yield `0` without leaking *where* they differ; only the non-secret + /// length is observable. fn ct_eq(&self, other: &Self) -> subtle::Choice { self.inner.as_slice().ct_eq(other.inner.as_slice()) } } +impl Zeroize for SecretBytes { + /// Wipe the buffer in place on a live value. `Drop` runs the same + /// wipe automatically; this lets a holder zeroize early. + fn zeroize(&mut self) { + self.inner.zeroize(); + } +} + impl fmt::Debug for SecretBytes { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "SecretBytes([REDACTED; {}])", self.inner.len()) @@ -264,6 +373,21 @@ mod tests { assert_eq!(s.trimmed().expose_secret(), "abandon ability"); } + /// Two sound checks (a direct freed-buffer scan would be use-after-free, + /// and this crate forbids `unsafe`): (1) `String::zeroize` empties a + /// buffer — the primitive `new` relies on; (2) `new` copies the content + /// into the wrapper faithfully. That `new` actually calls + /// `source.zeroize()` on its moved-in source is pinned by the + /// do-not-remove comment at that call site, not asserted here. + #[test] + fn secret_string_new_zeroizes_string_source() { + let mut source = String::from("super secret seed material"); + source.zeroize(); + assert!(source.is_empty(), "String::zeroize must empty the source"); + let s = SecretString::new(String::from("super secret seed material")); + assert_eq!(s.expose_secret(), "super secret seed material"); + } + #[test] fn secret_string_ct_eq_is_value_based() { // Equality goes through `ConstantTimeEq` only. @@ -281,6 +405,112 @@ mod tests { assert_eq!(SecretString::default().len(), 0); } + /// `is_blank()` truth table. The boundary deliberately + /// exercises Unicode whitespace — `str::trim` uses the `White_Space` + /// property, so NBSP (`U+00A0`) trims to blank but ZWSP (`U+200B`, + /// not `White_Space`) does not. + #[test] + fn is_blank_truth_table() { + // Blank inputs. + assert!(SecretString::empty().is_blank()); + assert!(SecretString::new("").is_blank()); + assert!(SecretString::new(" ").is_blank()); + assert!(SecretString::new("\t\r\n ").is_blank()); + assert!( + SecretString::new("\u{00A0}").is_blank(), + "NBSP is White_Space" + ); + // Non-blank inputs. + assert!(!SecretString::new("pw").is_blank()); + assert!(!SecretString::new(" pw ").is_blank()); + assert!( + !SecretString::new("\u{200B}").is_blank(), + "ZWSP is NOT White_Space" + ); + } + + /// `is_blank` returns a `bool` and exposes no borrowed + /// plaintext, callable with only `secrets` (no serde/schemars). + #[test] + fn is_blank_signature_returns_bool_no_borrow() { + let f: fn(&SecretString) -> bool = SecretString::is_blank; + assert!(f(&SecretString::new(""))); + assert!(!f(&SecretString::new("x"))); + } + + /// `SecretString` must never implement + /// `Serialize` or `Display`, even with serde compiled in. This is a + /// compile-time `!impl` assertion — adding either impl breaks the + /// build. `serde::Serialize` is nameable here because `secrets` always + /// pulls the `serde` dep. + #[test] + fn secret_string_has_no_serialize_no_display() { + static_assertions::assert_not_impl_any!(SecretString: serde::Serialize, std::fmt::Display); + } + + /// Regression: the `serde` DEP is on under + /// `secrets`, yet the `Deserialize` IMPL stays ABSENT because it is + /// gated on the dedicated `secret-serde` feature — proving the + /// default-off gate is satisfiable even while serde is compiled. + #[cfg(not(feature = "secret-serde"))] + #[test] + fn deserialize_absent_without_secret_serde_even_though_serde_dep_on() { + static_assertions::assert_not_impl_any!( + SecretString: serde::de::DeserializeOwned + ); + } + + /// With `secret-serde` on, the `Deserialize` impl is + /// present (and `Serialize` is still absent — see the always-on test). + #[cfg(feature = "secret-serde")] + #[test] + fn deserialize_present_with_secret_serde() { + static_assertions::assert_impl_all!(SecretString: serde::de::DeserializeOwned); + static_assertions::assert_not_impl_any!(SecretString: serde::Serialize); + } + + /// `Deserialize` round-trips the value through the + /// zeroizing constructor; the result `ct_eq`s a directly-built secret + /// and has the right length. + #[cfg(feature = "secret-serde")] + #[test] + fn deserialize_routes_value_through_zeroizing_constructor() { + let s: SecretString = serde_json::from_str("\"correct horse battery staple\"").unwrap(); + assert!(bool::from( + s.ct_eq(&SecretString::new("correct horse battery staple")) + )); + assert_eq!(s.len(), 28); + } + + /// `JsonSchema` renders a plain `string` and leaks no + /// length/value policy — no `minLength`/`maxLength`/`pattern`/`format`, + /// no `example`/`default`/`enum`. + #[cfg(feature = "secret-schemars")] + #[test] + fn json_schema_is_plain_string_no_policy_leak() { + let schema = schemars::schema_for!(SecretString); + let v = serde_json::to_value(&schema).unwrap(); + assert_eq!(v["type"], serde_json::json!("string")); + for forbidden in [ + "minLength", + "maxLength", + "pattern", + "format", + "example", + "default", + "enum", + ] { + assert!( + v.get(forbidden).is_none(), + "schema leaked `{forbidden}`: {v}" + ); + } + // Any description present must carry no example/secret value. + if let Some(desc) = v.get("description").and_then(|d| d.as_str()) { + assert!(!desc.contains("horse")); + } + } + #[test] fn secret_bytes_debug_redacted() { let b = SecretBytes::from_slice(&[1, 2, 3, 4, 5]); @@ -301,8 +531,7 @@ mod tests { #[test] fn empty_secret_bytes_constructs_without_mlocking_dangling_ptr() { // A capacity-0 `Vec` has a dangling `as_ptr()`; `new` must not - // pass it to `region::lock`. Constructing must not panic and the - // wrapper must round-trip as empty. + // pass it to `region::lock` or panic. let b = SecretBytes::new(Vec::new()); assert!(b.is_empty()); assert_eq!(b.len(), 0); @@ -336,53 +565,31 @@ mod tests { assert!(std::mem::needs_drop::()); }; - /// Best-effort runtime check that `Drop` wipes the full `SecretString` - /// capacity. Reads freed memory — UB in the strict sense, flaky under - /// parallelism; run single-threaded: - /// `cargo test --features secrets -- secret_string_drop_zeroes --ignored --test-threads=1` + /// Proves zeroize wipes the buffer. Every read is on a STILL-LIVE + /// value (no post-free deref / UB); the in-place slice wipe also + /// proves the bytes go to zero with the length preserved. #[test] - #[ignore] - fn secret_string_drop_zeroes_full_capacity() { - let ptr: *const u8; - let cap: usize; - { - let s = SecretString::new("sensitive_seed_material"); - ptr = s.inner.as_ptr(); - cap = s.inner.capacity(); - // SAFETY: live allocation, read for `cap` bytes pre-drop. - #[allow(unsafe_code)] - let pre = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(pre.iter().any(|&b| b != 0)); - } - // SAFETY: best-effort post-free read; single-thread makes page - // reuse before this read unlikely. - #[allow(unsafe_code)] - let post = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(post.iter().all(|&b| b == 0), "buffer not zeroed on drop"); - } - - /// Best-effort runtime check that `Drop` wipes `SecretBytes`. Same - /// caveat as above; run single-threaded with `--ignored`. A - /// page-sized buffer is used so the allocator is unlikely to reuse - /// the freed page before the post-drop read (a tiny `Vec` would be - /// recycled immediately, making the check meaningless). - #[test] - #[ignore] - fn secret_bytes_drop_zeroes() { - let ptr: *const u8; - let cap: usize; - { - let b = SecretBytes::from_slice(&[0xAB; 4096]); - ptr = b.inner.as_ptr(); - cap = b.inner.capacity(); - // SAFETY: live allocation, read for `cap` bytes pre-drop. - #[allow(unsafe_code)] - let pre = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(pre.iter().any(|&x| x != 0)); - } - // SAFETY: best-effort post-free read; see note above. - #[allow(unsafe_code)] - let post = unsafe { std::slice::from_raw_parts(ptr, cap) }; - assert!(post.iter().all(|&x| x == 0), "buffer not zeroed on drop"); + fn manual_zeroize_wipes_live_buffer() { + let mut b = SecretBytes::from_slice(&[0xABu8; 64]); + assert!(b.expose_secret().iter().any(|&x| x != 0)); + b.expose_secret_mut().zeroize(); + assert_eq!(b.len(), 64, "in-place wipe must preserve length"); + assert!( + b.expose_secret().iter().all(|&x| x == 0), + "SecretBytes buffer not zeroed by manual zeroize" + ); + + // SecretBytes wrapper-level zeroize empties the buffer. + let mut b2 = SecretBytes::from_slice(&[0xCDu8; 32]); + b2.zeroize(); + assert!(b2.is_empty(), "SecretBytes::zeroize must empty the buffer"); + + // SecretString wrapper-level zeroize empties the buffer; the + // exposed view holds no residual plaintext. + let mut s = SecretString::new("sensitive_seed_material"); + assert!(!s.is_empty()); + s.zeroize(); + assert!(s.is_empty(), "SecretString::zeroize must empty the buffer"); + assert_eq!(s.expose_secret(), ""); } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index b4e75512e9e..7f0147f1659 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -1,17 +1,11 @@ //! [`SecretStore`] — the public, never-leaking secrets entry point. //! -//! Consumers use this enum, not the `keyring_core` SPI. Its read path -//! ([`SecretStore::get`]) yields a zeroizing [`SecretBytes`]; a raw -//! `Vec` never crosses this boundary, and the write path -//! ([`SecretStore::set`]) takes `&SecretBytes` so a caller cannot pass an -//! unwrapped buffer (M-STRONG-TYPES). -//! -//! Errors surface as the typed [`SecretStoreError`] — losslessly for the -//! [`SecretStore::File`] arm (so `WrongPassphrase` vs `Corruption` vs -//! `AlreadyLocked` stay distinct), and as a best-effort projection of -//! `keyring_core::Error` for the [`SecretStore::Os`] arm. The internal -//! `keyring_core::api::CredentialApi` / `CredentialStoreApi` impls remain -//! the backend SPI; `SecretStore` delegates through them. +//! Consumers use this enum, not the `keyring_core` SPI it delegates to. +//! Reads yield a zeroizing [`SecretBytes`] and writes take `&SecretBytes` +//! so a raw buffer never crosses the boundary. Errors are the typed +//! [`SecretStoreError`] — lossless on the [`SecretStore::File`] arm, a +//! best-effort projection of `keyring_core::Error` on the +//! [`SecretStore::Os`] arm. use std::sync::Arc; @@ -19,16 +13,19 @@ use keyring_core::api::CredentialStoreApi; use keyring_core::{Entry, Error as KeyringError}; use super::error::{OsKeyringErrorKind, SecretStoreError}; -use super::secret::SecretBytes; +use super::secret::{SecretBytes, SecretString}; use super::validate::WalletId; -use super::{default_credential_store, EncryptedFileStore, SERVICE_PREFIX}; +use super::wire::envelope; +use super::{default_credential_store, EncryptedFileStore, MAX_SECRET_LEN, SERVICE_PREFIX}; /// A passphrase-or-OS-keyring backed store for wallet secret material. /// -/// The only public read path is [`get`](SecretStore::get), which yields a -/// zeroizing [`SecretBytes`] — a raw `Vec` never crosses this -/// boundary. Backend selection is an explicit operator decision; there is -/// no silent fallback between the two arms. +/// Every read path ([`get`](SecretStore::get), +/// [`get_secret`](SecretStore::get_secret), and the read inside +/// [`reprotect`](SecretStore::reprotect)) yields a zeroizing +/// [`SecretBytes`] — a raw `Vec` never crosses this boundary. Backend +/// selection is an explicit operator decision; there is no silent fallback +/// between the two arms. pub enum SecretStore { /// Self-contained Argon2id + XChaCha20-Poly1305 vault file. /// Recommended on headless / server hosts. @@ -49,52 +46,143 @@ impl SecretStore { Ok(Self::File(EncryptedFileStore::open(path, passphrase)?)) } + /// Open (or create) a **deliberately keyless** file-backed vault — the + /// only door that takes no passphrase. Obfuscation, not confidentiality + /// (the key derives from an empty passphrase under the public salt): use + /// it where the stored secrets carry their own Tier-2 object password, + /// or as a staging step before [`EncryptedFileStore::rekey`] to a real + /// passphrase. [`file`](SecretStore::file) rejects a blank passphrase; + /// this is the explicit keyless alternative. + pub fn file_unprotected(path: impl AsRef) -> Result { + Ok(Self::File(EncryptedFileStore::open_unprotected(path)?)) + } + /// Open the platform's default OS keyring, failing closed when none /// is reachable (headless / no Secret Service). pub fn os() -> Result { Ok(Self::Os(default_credential_store().map_err(map_spi)?)) } - /// Store `secret` under `(service, label)`, overwriting any prior - /// value. Takes `&SecretBytes` so the caller cannot pass an unwrapped - /// buffer; the wrapped bytes are exposed to the SPI only at the last - /// moment. + /// Store `secret` under `(service, label)` UNPROTECTED (Tier-2 + /// scheme-0), overwriting any prior value — a `set_secret(.., None)` + /// wrapper kept for non-breaking back-compat. Takes `&SecretBytes` so + /// the caller cannot pass an unwrapped buffer. pub fn set( &self, service: &WalletId, label: &str, secret: &SecretBytes, + ) -> Result<(), SecretStoreError> { + self.set_secret(service, label, secret, None) + } + + /// Store `secret` under `(service, label)`, overwriting any prior value. + /// + /// `password` selects the protection: `None` writes an unprotected + /// envelope; `Some(pw)` seals the bytes under the object password `pw` + /// (Argon2id + XChaCha20-Poly1305) **before** they reach the backend, so + /// a protected object stays confidential even under a full backend + /// compromise. A blank `pw` is rejected + /// ([`BlankPassphrase`](SecretStoreError::BlankPassphrase)). + /// + /// **No recovery (availability):** if a protected object's password is + /// lost, the object is permanently unrecoverable — there is no reset + /// path. The UX must state this plainly. + /// + /// **Entropy is the caller's:** a protected object's confidentiality + /// rests entirely on the password's entropy against an offline Argon2id + /// attacker who already holds the backend. This crate enforces only + /// non-blank; strength estimation / policy is the caller's job. + /// + /// The write is a same-slot overwrite that leaves the prior value intact + /// on a crash: on the `File` arm via the vault's atomic replace; on the + /// `Os` arm via the backend's single-item-replace contract. + /// Add/change/remove flows go through [`reprotect`](SecretStore::reprotect). + pub fn set_secret( + &self, + service: &WalletId, + label: &str, + secret: &SecretBytes, + password: Option<&SecretString>, + ) -> Result<(), SecretStoreError> { + // Wrap above the backend: the backend only ever stores the opaque + // envelope (ciphertext for a protected object). + let blob = envelope::wrap(service, label, password, secret.expose_secret())?; + self.put_raw(service, label, &blob) + } + + /// Store the already-enveloped opaque `blob` under `(service, label)`. + /// The shared write seam under [`set`] and [`set_secret`]. + /// + /// [`set`]: SecretStore::set + fn put_raw( + &self, + service: &WalletId, + label: &str, + blob: &SecretBytes, ) -> Result<(), SecretStoreError> { match self { - // File arm: the inherent typed path — no lossy SPI seam. - // `put_bytes` takes `&SecretBytes` directly, so the - // bare-buffer view never crosses this boundary. - Self::File(s) => s.put_bytes(service, label, secret), + // Inherent typed path — no lossy SPI seam, no bare buffer. + Self::File(s) => s.put_bytes(service, label, blob), Self::Os(store) => { let entry = build_os(store, service, label)?; - entry.set_secret(secret.expose_secret()).map_err(map_spi) + entry.set_secret(blob.expose_secret()).map_err(map_spi) } } } - /// Retrieve the secret stored under `(service, label)`, or `Ok(None)` - /// if absent. The plaintext is wrapped into [`SecretBytes`] at the - /// seam with no named `Vec` intermediate, so the bare-buffer window is - /// zero statements. + /// Retrieve the UNPROTECTED secret stored under `(service, label)`, or + /// `Ok(None)` if absent — a `get_secret(.., None)` wrapper kept for + /// non-breaking back-compat. A scheme-1 (password-protected) object read + /// through this path returns + /// [`NeedsPassword`](SecretStoreError::NeedsPassword); use + /// [`get_secret`](SecretStore::get_secret) with the object password. pub fn get( &self, service: &WalletId, label: &str, + ) -> Result, SecretStoreError> { + self.get_secret(service, label, None) + } + + /// Read the opaque bytes stored under `(service, label)`, or + /// `Ok(None)` if absent — the raw backend value, always a Tier-2 + /// envelope (writes go through + /// [`set_secret`](SecretStore::set_secret)). The typed-vs-SPI + /// distinction is preserved exactly as the pre-Tier-2 path did. This + /// is the shared seam under [`get`] and [`get_secret`]; it does NOT + /// interpret the envelope. + /// + /// [`get`]: SecretStore::get + fn get_raw( + &self, + service: &WalletId, + label: &str, ) -> Result, SecretStoreError> { match self { - // File arm: the inherent typed path keeps `WrongPassphrase` - // vs `Corruption` distinct (lossless). Plaintext rides as - // `SecretBytes` all the way; no rewrap needed. + // Inherent typed path: keeps WrongPassphrase vs Corruption + // distinct; plaintext rides as SecretBytes, no rewrap. Self::File(s) => s.get_bytes(service, label), Self::Os(store) => { let entry = build_os(store, service, label)?; match entry.get_secret() { - Ok(v) => Ok(Some(SecretBytes::new(v))), + Ok(v) => { + // Defense-in-depth: reject an oversized backend blob + // before it reaches the envelope parse/derive path. + // The File arm's stored bytes are already capped at + // MAX_SECRET_LEN by `put_bytes`; the Os backend has no + // such ceiling, so cap here. A legitimate envelope + // never exceeds MAX_SECRET_LEN; the overhead is + // headroom. + let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD; + if v.len() > cap { + return Err(SecretStoreError::SecretTooLarge { + found: v.len(), + max: cap, + }); + } + Ok(Some(SecretBytes::new(v))) + } Err(KeyringError::NoEntry) => Ok(None), Err(e) => Err(map_spi(e)), } @@ -102,18 +190,95 @@ impl SecretStore { } } - /// Delete the secret stored under `(service, label)`. Absent entries - /// are a no-op (`Ok(())`), so deletion is idempotent. - pub fn delete(&self, service: &WalletId, label: &str) -> Result<(), SecretStoreError> { + /// Retrieve the secret under `(service, label)` applying the strict, + /// fail-closed read, or `Ok(None)` if absent. + /// + /// `password` IS the caller's protection assertion — supply `Some(pw)` + /// for an object the caller's trusted model says is protected, `None` + /// otherwise. The expectation lives ONLY here, never in the stored + /// blob (see [`envelope::unwrap`]): + /// + /// - `Some(pw)` + a protected blob → the secret (or + /// [`WrongPassword`](SecretStoreError::WrongPassword) on tag fail); + /// - `Some(pw)` + an unprotected blob → + /// [`ExpectedProtectedButUnsealed`](SecretStoreError::ExpectedProtectedButUnsealed) + /// — a strip/downgrade, refused, no bytes returned; + /// - `None` + a protected blob → + /// [`NeedsPassword`](SecretStoreError::NeedsPassword) (never ciphertext); + /// - `None` + an unprotected blob → the secret. + /// + /// **Documented residual:** an attacker who ALSO rewrites the + /// consumer's trusted DB so the caller passes `None` for a stripped + /// object can still downgrade — out of this library's reach by + /// construction (the protection expectation is the caller's; see + /// `SECRETS.md`). The expectation is NEVER persisted by the library. + pub fn get_secret( + &self, + service: &WalletId, + label: &str, + password: Option<&SecretString>, + ) -> Result, SecretStoreError> { + // Absence is availability-only (deletion = DoS, never injection): + // a missing entry is Ok(None) under either password argument. + let Some(stored) = self.get_raw(service, label)? else { + return Ok(None); + }; + envelope::unwrap(service, label, password, stored.expose_secret()).map(Some) + } + + /// Add / change / remove an object password in one same-slot + /// unwrap→rewrap→overwrite — the canonical re-protection flow. + /// + /// Reads the object under the `current` expectation (so a strip is + /// caught fail-closed before any rewrap), then re-writes it under + /// `new`: + /// - **add:** `current = None`, `new = Some(pw)`; + /// - **change:** `current = Some(old)`, `new = Some(pw_new)`; + /// - **remove:** `current = Some(old)`, `new = None`. + /// + /// An absent object returns [`Err(NoEntry)`][SecretStoreError::NoEntry] — + /// `reprotect` is operational; absence means the caller's protection-status + /// record disagrees with the backend, which is a signal not to be silently + /// dropped. The rewrite is the same-slot overwrite of [`set_secret`], so a + /// crash between the read and the commit leaves the prior value intact + /// and readable under `current`. After a successful call the consumer MUST + /// update its own trusted protection-status record (the protection + /// expectation lives there). + /// + /// **No recovery:** changing or removing requires the `current` + /// password; if it is lost the object cannot be re-protected or read, + /// and is permanently unrecoverable (availability trade-off). + /// + /// **Entropy is the caller's:** the `new` password's entropy is the + /// whole confidentiality guarantee for the re-protected object; this + /// crate enforces only non-blank, not strength. + pub fn reprotect( + &self, + service: &WalletId, + label: &str, + current: Option<&SecretString>, + new: Option<&SecretString>, + ) -> Result<(), SecretStoreError> { + let Some(secret) = self.get_secret(service, label, current)? else { + return Err(SecretStoreError::NoEntry); + }; + self.set_secret(service, label, &secret, new) + } + + /// Delete the secret stored under `(service, label)`. + /// + /// Returns `Ok(true)` if a credential was removed, `Ok(false)` if no + /// credential existed under `(service, label)`. Idempotent for callers + /// that don't care — `.delete(...)?;` still discards the bool; + /// race-detecting callers can `match delete()?`. + pub fn delete(&self, service: &WalletId, label: &str) -> Result { match self { - Self::File(s) => { - s.delete_bytes(service, label)?; - Ok(()) - } + Self::File(s) => s.delete_bytes(service, label), Self::Os(store) => { let entry = build_os(store, service, label)?; match entry.delete_credential() { - Ok(()) | Err(KeyringError::NoEntry) => Ok(()), + Ok(()) => Ok(true), + Err(KeyringError::NoEntry) => Ok(false), Err(e) => Err(map_spi(e)), } } @@ -123,12 +288,10 @@ impl SecretStore { /// Build the SPI [`Entry`] for `(service, label)` on the OS-keyring arm. /// -/// The reject-not-sanitize label allowlist (`^[A-Za-z0-9._-]{1,64}$`) -/// is enforced here before the call crosses into the OS backend. -/// Different OS keyrings accept, normalize, or reject non-allowlisted -/// bytes inconsistently; enforcing the allowlist at -/// this shim keeps `(service, label)` invariants identical to the -/// `File` arm and across every OS backend. +/// Enforces the label allowlist (`^[A-Za-z0-9._-]{1,64}$`) before the +/// call crosses into the OS backend, so the `(service, label)` invariant +/// stays identical to the `File` arm and across every OS keyring (each +/// accepts / normalizes / rejects non-allowlisted bytes differently). fn build_os( store: &Arc, service: &WalletId, @@ -140,12 +303,9 @@ fn build_os( } impl std::fmt::Debug for SecretStore { - /// Surfaces the backend engine/service identity without exposing any - /// secret material. The `Os` arm reports the SPI - /// `vendor()`/`id()` — non-secret backend tags (e.g. which OS keyring - /// is wired up) — rather than an opaque `Os(..)`. The `File` arm - /// delegates to [`EncryptedFileStore`]'s redacting `Debug` (path - /// only, no key/passphrase). + /// Surfaces the backend identity without any secret material: the `Os` + /// arm reports the SPI `vendor()`/`id()` tags; the `File` arm delegates + /// to [`EncryptedFileStore`]'s redacting `Debug` (path only). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::File(s) => f.debug_tuple("SecretStore::File").field(s).finish(), @@ -159,20 +319,14 @@ impl std::fmt::Debug for SecretStore { } /// Project an OS-keyring SPI [`KeyringError`] into the typed -/// [`SecretStoreError`] for the [`Os`](SecretStore::Os) arm. -/// -/// The OS keyring has no typed `SecretStoreError` origin, so its variants -/// map best-effort into [`SecretStoreError::OsKeyring`] (carrying only a -/// non-secret discriminant) or the closest existing variant. Secret- -/// bearing keyring variants (`BadEncoding`, `BadDataFormat`) are -/// collapsed to a discriminant — their raw bytes never enter -/// `SecretStoreError`. (The [`File`](SecretStore::File) arm never reaches -/// this projection: it uses the inherent typed path.) +/// [`SecretStoreError`] for the [`Os`](SecretStore::Os) arm. Best-effort: +/// variants map into [`SecretStoreError::OsKeyring`] (non-secret +/// discriminant only) or the closest existing variant; byte-bearing +/// keyring variants are collapsed so their bytes never enter the type. +/// The [`File`](SecretStore::File) arm never reaches this projection. fn map_spi(e: KeyringError) -> SecretStoreError { match e { - KeyringError::NoEntry => SecretStoreError::OsKeyring { - kind: OsKeyringErrorKind::NoEntry, - }, + KeyringError::NoEntry => SecretStoreError::NoEntry, KeyringError::NoStorageAccess(_) => SecretStoreError::OsKeyring { kind: OsKeyringErrorKind::NoStorageAccess, }, @@ -197,7 +351,18 @@ mod tests { use crate::secrets::SecretString; fn file_store(dir: &std::path::Path) -> SecretStore { - SecretStore::file(dir.join("vault.pwsvault"), SecretString::new("pw-correct")).unwrap() + SecretStore::file(secure_vault_path(dir), SecretString::new("pw-correct")).unwrap() + } + + /// Tighten the umask-0002 tempdir (0o775) to 0o700 so it passes the + /// parent-dir perm check, then return a vault path inside it. + fn secure_vault_path(dir: &std::path::Path) -> std::path::PathBuf { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)); + } + dir.join("vault.pwsvault") } fn wid(b: u8) -> WalletId { @@ -226,34 +391,43 @@ mod tests { } #[test] - fn delete_is_idempotent() { + fn delete_returns_false_on_absent_true_on_present() { let dir = tempfile::tempdir().unwrap(); let s = file_store(dir.path()); - // Absent → Ok, no error. - s.delete(&wid(1), "seed").unwrap(); + // Absent → Ok(false), no error. + assert!(!s.delete(&wid(1), "seed").unwrap()); s.set(&wid(1), "seed", &SecretBytes::from_slice(b"x")) .unwrap(); - s.delete(&wid(1), "seed").unwrap(); + // Present → Ok(true). + assert!(s.delete(&wid(1), "seed").unwrap()); assert!(s.get(&wid(1), "seed").unwrap().is_none()); - // Second delete on the now-absent entry is still Ok. - s.delete(&wid(1), "seed").unwrap(); + // Second delete on the now-absent entry is Ok(false). + assert!(!s.delete(&wid(1), "seed").unwrap()); + } + + #[test] + fn reprotect_absent_returns_no_entry() { + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let err = s + .reprotect(&wid(1), "seed", None, Some(&SecretString::new("pw"))) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::NoEntry), + "expected NoEntry on absent reprotect, got {err:?}" + ); } #[test] fn wrong_passphrase_surfaces_typed_lossless() { - // Resident-vault model: the passphrase is verified at open() - // time (header verify-token), so a wrong-pass reopen fails at - // open() rather than on the first get(). The typed distinction - // still survives losslessly on the public path. + // Resident-vault model verifies the passphrase at open() (header + // verify-token), so a wrong-pass reopen fails at open(), losslessly. let dir = tempfile::tempdir().unwrap(); file_store(dir.path()) .set(&wid(1), "seed", &SecretBytes::from_slice(b"orig")) .unwrap(); - let err = SecretStore::file( - dir.path().join("vault.pwsvault"), - SecretString::new("pw-wrong"), - ) - .expect_err("wrong pass must fail open"); + let err = SecretStore::file(secure_vault_path(dir.path()), SecretString::new("pw-wrong")) + .expect_err("wrong pass must fail open"); assert!( matches!(err, SecretStoreError::WrongPassphrase), "expected WrongPassphrase, got {err:?}" @@ -266,9 +440,8 @@ mod tests { let s = file_store(dir.path()); s.set(&wid(1), "seed", &SecretBytes::from_slice(b"value")) .unwrap(); - // Corrupt the entry ciphertext while leaving the verify-token - // intact: the passphrase is still correct, so this is corruption, - // not a wrong passphrase. The lossless typed path keeps them apart. + // Corrupt the entry ciphertext but leave the verify-token intact: + // passphrase still correct, so this is Corruption, not WrongPassphrase. let SecretStore::File(ref fs) = s else { unreachable!() }; @@ -291,11 +464,10 @@ mod tests { #[test] fn already_locked_surfaces_typed_lossless() { - // Resident-vault model: a second open() of the same path while - // the first store is alive returns AlreadyLocked. The typed - // distinction survives losslessly on the public path. + // A second open() of a path the first store still holds returns + // AlreadyLocked, losslessly on the public path. let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("vault.pwsvault"); + let path = secure_vault_path(dir.path()); let _s1 = SecretStore::file(&path, SecretString::new("pw")).unwrap(); let err = SecretStore::file(&path, SecretString::new("pw")).unwrap_err(); assert!( @@ -312,16 +484,10 @@ mod tests { assert!(!dbg.contains("pw-correct")); } - /// The OS-keyring shim must enforce the label allowlist BEFORE - /// handing the value to the OS backend. The per-backend label - /// policies (macOS Keychain vs Windows - /// Credential Manager vs Secret Service) differ in what they accept, - /// normalize, or reject; the shim must keep the `(service, label)` - /// invariant uniform across every arm. - /// - /// A mock `CredentialStoreApi` that panics if its `build()` is - /// invoked proves the bad label never crosses the SPI seam — the - /// shim rejects with `SecretStoreError::InvalidLabel` first. + /// The shim must enforce the label allowlist before reaching the OS + /// backend (per-backend policies differ). A `CredentialStoreApi` that + /// panics on `build()` proves a bad label is rejected with + /// `InvalidLabel` before it ever crosses the SPI seam. #[test] fn build_os_rejects_invalid_label_before_spi() { use std::any::Any; @@ -355,9 +521,8 @@ mod tests { let store: Arc = Arc::new(PanickingStore); let os = SecretStore::Os(store); - // Every operation on the OS arm goes through `build_os`; the - // allowlist rejection MUST fire here, so the panicking SPI is - // never reached. + // Every OS-arm op goes through `build_os`, so the allowlist + // rejection fires before the panicking SPI is reached. for bad in ["lab el", "../escape", "", "a:b", "a/b", "lab\0el"] { let err = os .set(&wid(1), bad, &SecretBytes::from_slice(b"x")) @@ -378,4 +543,675 @@ mod tests { ); } } + + // ===== Tier-2 strict fail-closed read ===== + // + // Parameterised over BOTH arms. The "attacker who can write the + // backend" is modelled per arm by `Backend::place_raw`: on File it + // re-seals the chosen blob under the resident vault key via `put_bytes` + // (a cold/backup-swap actor could only corrupt → DoS, so the strip + // requires the vault key — the File-arm asymmetry); on Os it overwrites + // the keychain item directly (the bare envelope, no second AEAD — where + // the strip residual bites hardest). The writable Os fixture is the + // upstream `keyring_core::mock::Store` (a raw SPI `set_secret` bypasses + // the envelope), so no bespoke mock is needed. + + use keyring_core::mock; + + use crate::secrets::file::crypto::{KdfParams, ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P}; + use crate::secrets::file::format::KDF_ID_ARGON2ID; + + /// Argon2id floor params — fast enough for these tests. + fn floor() -> KdfParams { + KdfParams { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MIN_M_KIB, + t: ARGON2_MIN_T, + p: ARGON2_P, + } + } + + fn protected(w: &WalletId, label: &str, pw: &str, secret: &[u8]) -> Vec { + envelope::wrap_with_params(w, label, Some(&SecretString::new(pw)), secret, floor()) + .unwrap() + .expose_secret() + .to_vec() + } + + fn unprotected(w: &WalletId, label: &str, secret: &[u8]) -> Vec { + envelope::wrap(w, label, None, secret) + .unwrap() + .expose_secret() + .to_vec() + } + + /// A backend under test plus the raw-write hook that plays the + /// backend-write attacker. + struct Backend { + store: SecretStore, + _dir: Option, + mock: Option>, + name: &'static str, + } + + impl Backend { + /// Write `blob` to `(w, label)` as opaque backend bytes (the + /// attacker's primitive / the protected-enrol setup). On Os this is + /// a raw SPI `set_secret` on the shared mock store, bypassing the + /// `SecretStore` envelope layer exactly as a breached keychain write + /// would. + fn place_raw(&self, w: &WalletId, label: &str, blob: &[u8]) { + match (&self.store, &self.mock) { + (SecretStore::File(fs), _) => fs + .put_bytes(w, label, &SecretBytes::from_slice(blob)) + .unwrap(), + (SecretStore::Os(_), Some(mock)) => { + let service = format!("{SERVICE_PREFIX}{}", w.to_hex()); + mock.build(&service, label, None) + .unwrap() + .set_secret(blob) + .unwrap(); + } + _ => unreachable!("os backend must carry its mock"), + } + } + } + + fn file_backend() -> Backend { + let dir = tempfile::tempdir().unwrap(); + let store = file_store(dir.path()); + Backend { + store, + _dir: Some(dir), + mock: None, + name: "File", + } + } + + fn os_backend() -> Backend { + // The upstream in-memory mock store. The clone handed to + // `SecretStore::Os` and the handle kept for raw attacker writes + // share the same backing credentials by `Arc`. + let mock = mock::Store::new().unwrap(); + let store = SecretStore::Os(mock.clone()); + Backend { + store, + _dir: None, + mock: Some(mock), + name: "Os", + } + } + + /// The strict-read quadrant. + fn run_quadrant(b: &Backend) { + let w = wid(1); + let pw = SecretString::new("object-pw"); + + // scheme-0 + None → bytes (the ONLY byte-returning quadrant). + b.place_raw(&w, "u0", &unprotected(&w, "u0", b"plain-seed")); + assert_eq!( + b.store + .get_secret(&w, "u0", None) + .unwrap() + .unwrap() + .expose_secret(), + b"plain-seed", + "[{}] scheme-0 + None", + b.name + ); + + // scheme-1 + None → NeedsPassword (never ciphertext). + b.place_raw(&w, "p1", &protected(&w, "p1", "object-pw", b"real-seed")); + assert!( + matches!( + b.store.get_secret(&w, "p1", None).unwrap_err(), + SecretStoreError::NeedsPassword + ), + "[{}] scheme-1 + None", + b.name + ); + + // scheme-1 + Some(correct) → secret. + assert_eq!( + b.store + .get_secret(&w, "p1", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"real-seed", + "[{}] scheme-1 + Some(correct)", + b.name + ); + + // scheme-1 + Some(wrong) → WrongPassword. + assert!( + matches!( + b.store + .get_secret(&w, "p1", Some(&SecretString::new("nope"))) + .unwrap_err(), + SecretStoreError::WrongPassword + ), + "[{}] scheme-1 + Some(wrong)", + b.name + ); + + // scheme-0 + Some(pw) → ExpectedProtectedButUnsealed (fail closed). + assert!( + matches!( + b.store.get_secret(&w, "u0", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] scheme-0 + Some", + b.name + ); + + // Truncated envelope (below the bincode minimum) → Corruption, + // both with and without a password — no magic byte to peek at. + b.place_raw(&w, "broken", &[0x01]); + for arg in [None, Some(&pw)] { + assert!( + matches!( + b.store.get_secret(&w, "broken", arg).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] truncated envelope ({:?})", + b.name, + arg.map(|_| "Some") + ); + } + + // Raw, non-envelope bytes → Corruption under either password + // arg: every read goes through the bincode decoder. + b.place_raw(&w, "raw", b"raw-bytes-not-a-valid-envelope"); + for arg in [None, Some(&pw)] { + assert!( + matches!( + b.store.get_secret(&w, "raw", arg).unwrap_err(), + SecretStoreError::Corruption + ), + "[{}] raw non-envelope bytes ({:?})", + b.name, + arg.map(|_| "Some") + ); + } + + // absent entry → Ok(None) under either arg (deletion = DoS). + assert!(b.store.get_secret(&w, "absent", None).unwrap().is_none()); + assert!(b + .store + .get_secret(&w, "absent", Some(&pw)) + .unwrap() + .is_none()); + } + + #[test] + fn l1_quadrant_file() { + run_quadrant(&file_backend()); + } + + #[test] + fn l1_quadrant_os() { + run_quadrant(&os_backend()); + } + + /// The non-vacuous strip-injection regression. The single + /// test the whole feature exists to make pass. + fn run_strip_injection(b: &Backend) { + let w = wid(2); + let pw = SecretString::new("object-pw"); + + // Enrol protected: stored = a valid scheme-1 envelope of S_real. + b.place_raw( + &w, + "seed", + &protected(&w, "seed", "object-pw", b"REAL-SEED-S_real"), + ); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL-SEED-S_real", + "[{}] legit protected read", + b.name + ); + + // Attacker overwrites the slot with a fresh, internally-valid + // scheme-0 envelope carrying a DIFFERENT seed S_evil. + let attacker_blob = unprotected(&w, "seed", b"EVIL-SEED-S_evil"); + b.place_raw(&w, "seed", &attacker_blob); + + // A password-supplied read of the stripped slot fails closed; + // S_evil is NEVER returned. + let err = b.store.get_secret(&w, "seed", Some(&pw)).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "[{}] strip must fail closed, got {err:?}", + b.name + ); + + // Non-vacuity: the attacker blob IS a valid unprotected envelope + // that WOULD decode to S_evil under `None` — so the refusal above is + // caused SOLELY by the Some(pw)+scheme-0 strict rule, not by any + // malformation (without the strict rule, S_evil would be returned). + let would_be = envelope::unwrap(&w, "seed", None, &attacker_blob).unwrap(); + assert_eq!( + would_be.expose_secret(), + b"EVIL-SEED-S_evil", + "[{}] non-vacuity: blob decodes to S_evil under None", + b.name + ); + } + + #[test] + fn l1_strip_injection_file() { + run_strip_injection(&file_backend()); + } + + #[test] + fn l1_strip_injection_os() { + run_strip_injection(&os_backend()); + } + + /// A consumer bug alone fails closed in BOTH directions. + fn run_both_det_bug_directions(b: &Backend) { + let w = wid(3); + let pw = SecretString::new("pw"); + // (a) over-supply a password on a genuinely unprotected object. + b.place_raw(&w, "u", &unprotected(&w, "u", b"x")); + assert!(matches!( + b.store.get_secret(&w, "u", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + // (b) under-supply on a genuinely protected object. + b.place_raw(&w, "p", &protected(&w, "p", "pw", b"y")); + assert!(matches!( + b.store.get_secret(&w, "p", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_both_det_bug_directions_file() { + run_both_det_bug_directions(&file_backend()); + } + + #[test] + fn l1_both_det_bug_directions_os() { + run_both_det_bug_directions(&os_backend()); + } + + /// The expectation is NEVER inferred from the blob's scheme + /// byte — identical scheme-1 blobs diverge solely on the password arg. + fn run_expectation_not_inferred(b: &Backend) { + let w = wid(4); + let pw = SecretString::new("pw"); + let blob = protected(&w, "a", "pw", b"seed"); + b.place_raw(&w, "a", &blob); + b.place_raw(&w, "b", &blob); + assert_eq!( + b.store + .get_secret(&w, "a", Some(&pw)) + .unwrap() + .unwrap() + .expose_secret(), + b"seed" + ); + assert!(matches!( + b.store.get_secret(&w, "b", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_expectation_not_inferred_file() { + run_expectation_not_inferred(&file_backend()); + } + + #[test] + fn l1_expectation_not_inferred_os() { + run_expectation_not_inferred(&os_backend()); + } + + /// Unprotected→protected upgrade confusion is availability- + /// only, fail-closed (NeedsPassword), no leak / no injection. + fn run_upgrade_confusion(b: &Backend) { + let w = wid(5); + b.place_raw(&w, "x", &protected(&w, "x", "attacker-pw", b"whatever")); + assert!(matches!( + b.store.get_secret(&w, "x", None).unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn l1_upgrade_confusion_file() { + run_upgrade_confusion(&file_backend()); + } + + #[test] + fn l1_upgrade_confusion_os() { + run_upgrade_confusion(&os_backend()); + } + + /// A scheme-flip from `Password` → `Unprotected`: `Some(pw)` is + /// caught by the strict rule regardless; `None` reads the body as + /// scheme-0 opaque bytes (never the real seed) — a known residual, + /// dominated by the consumer-DB residual; pinned, not "fixed". + fn run_scheme_flip(b: &Backend) { + use crate::secrets::wire::config::WIRE_CONFIG; + use crate::secrets::wire::envelope::{Envelope, Payload}; + + let w = wid(6); + let pw = SecretString::new("pw"); + let blob = protected(&w, "x", "pw", b"real-seed"); + let (env, _): (Envelope, usize) = bincode::decode_from_slice(&blob, WIRE_CONFIG).unwrap(); + let flipped = match env.payload { + Payload::Password { ciphertext, .. } => Envelope { + version: env.version, + payload: Payload::Unprotected(ciphertext), + }, + Payload::Unprotected(_) => panic!("protected() must yield a Password payload"), + }; + let flipped_blob = bincode::encode_to_vec(&flipped, WIRE_CONFIG).unwrap(); + b.place_raw(&w, "x", &flipped_blob); + + assert!(matches!( + b.store.get_secret(&w, "x", Some(&pw)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + let got = b.store.get_secret(&w, "x", None).unwrap().unwrap(); + assert_ne!( + got.expose_secret(), + b"real-seed", + "the real seed must never surface from a flipped scheme byte" + ); + } + + #[test] + fn l1_scheme_flip_file() { + run_scheme_flip(&file_backend()); + } + + #[test] + fn l1_scheme_flip_os() { + run_scheme_flip(&os_backend()); + } + + // ===== Add / change / remove password + arm matrix ===== + // + // These exercise the PUBLIC set_secret/get_secret/reprotect API, so the + // protected writes/reads run the real (default 64 MiB) Argon2 — kept to + // a small number of derivations per test. + + /// The full enrol → change → remove lifecycle, each + /// step verified through the strict read. + fn run_pw_lifecycle(b: &Backend) { + let w = wid(10); + let pw1 = SecretString::new("pw-one"); + let pw2 = SecretString::new("pw-two"); + + // ADD: start unprotected, enrol a password. + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"SEED")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + b.store.reprotect(&w, "seed", None, Some(&pw1)).unwrap(); + assert!( + matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + ), + "[{}] after add, None read needs a password", + b.name + ); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw1)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + + // CHANGE: rotate to a new password (unwrap-old → rewrap-new). + b.store + .reprotect(&w, "seed", Some(&pw1), Some(&pw2)) + .unwrap(); + assert_eq!( + b.store + .get_secret(&w, "seed", Some(&pw2)) + .unwrap() + .unwrap() + .expose_secret(), + b"SEED" + ); + assert!( + matches!( + b.store.get_secret(&w, "seed", Some(&pw1)).unwrap_err(), + SecretStoreError::WrongPassword + ), + "[{}] old password no longer unlocks after change", + b.name + ); + + // REMOVE: back to unprotected. + b.store.reprotect(&w, "seed", Some(&pw2), None).unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"SEED" + ); + assert!( + matches!( + b.store.get_secret(&w, "seed", Some(&pw2)).unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + ), + "[{}] after remove, a password read fails closed until the consumer updates its DB", + b.name + ); + } + + #[test] + fn pw_lifecycle_file() { + run_pw_lifecycle(&file_backend()); + } + + #[test] + fn pw_lifecycle_os() { + run_pw_lifecycle(&os_backend()); + } + + /// Losing the object password bricks the object — no recovery + /// path exists, every read fails closed. + fn run_pw_no_recovery(b: &Backend) { + let w = wid(11); + let pw = SecretString::new("the-only-pw"); + b.store + .set_secret(&w, "seed", &SecretBytes::from_slice(b"SEED"), Some(&pw)) + .unwrap(); + assert!(matches!( + b.store + .get_secret(&w, "seed", Some(&SecretString::new("guess"))) + .unwrap_err(), + SecretStoreError::WrongPassword + )); + assert!(matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::NeedsPassword + )); + } + + #[test] + fn pw_no_recovery_file() { + run_pw_no_recovery(&file_backend()); + } + + #[test] + fn pw_no_recovery_os() { + run_pw_no_recovery(&os_backend()); + } + + /// `set`/`get` are additive `..,None` wrappers — `set` + /// writes a scheme-0 envelope, `get` reads it byte-exact, and a + /// password-supplied read of that unprotected object fails closed. + fn run_set_get_wrappers(b: &Backend) { + let w = wid(12); + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"plain")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"plain" + ); + assert!(matches!( + b.store + .get_secret(&w, "seed", Some(&SecretString::new("pw"))) + .unwrap_err(), + SecretStoreError::ExpectedProtectedButUnsealed + )); + } + + #[test] + fn set_get_wrappers_file() { + run_set_get_wrappers(&file_backend()); + } + + #[test] + fn set_get_wrappers_os() { + run_set_get_wrappers(&os_backend()); + } + + /// The Os arm has no passphrase concept; the Tier-1 blank + /// guard never fires and the round-trip is byte-exact. + #[test] + fn os_arm_roundtrip_no_blank_guard() { + let b = os_backend(); + let w = wid(13); + b.store + .set(&w, "seed", &SecretBytes::from_slice(b"abc")) + .unwrap(); + assert_eq!( + b.store.get(&w, "seed").unwrap().unwrap().expose_secret(), + b"abc" + ); + b.store.delete(&w, "seed").unwrap(); + assert!(b.store.get(&w, "seed").unwrap().is_none()); + } + + /// [File]: a crash (disk-write failure) between the unwrap + /// and the overwrite-commit leaves the OLD protected value intact and + /// readable — no half-rotated / unprotected state. + #[cfg(unix)] + #[test] + fn pw_change_crash_safety_leaves_old_intact_file() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let s = file_store(dir.path()); + let w = wid(14); + let old = SecretString::new("old-pw"); + let new = SecretString::new("new-pw"); + + s.set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old)) + .unwrap(); + + // Make the vault's parent read-only so the atomic temp-write fails + // mid-change (mirrors rekey_does_not_corrupt_on_disk_temp_failure). + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); + let err = s.reprotect(&w, "seed", Some(&old), Some(&new)).unwrap_err(); + assert!(matches!(err, SecretStoreError::Io(_)), "got {err:?}"); + + // Restore write so the resident store can sync/clean up at drop. + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + + // The OLD value is still readable under the OLD password; the new + // password does not unlock it (no half-rotation). + assert_eq!( + s.get_secret(&w, "seed", Some(&old)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL" + ); + assert!(matches!( + s.get_secret(&w, "seed", Some(&new)).unwrap_err(), + SecretStoreError::WrongPassword + )); + } + + /// [Os]: a backend failure during the rewrite's write (after the read + /// succeeds) leaves the OLD value intact — no half-rotation. The mock's + /// one-shot error injection fails the next write, simulating a crash + /// mid-rewrite. `reprotect` is read-then-`set_secret`, split here so the + /// error lands on the write. + #[test] + fn os_rewrite_mid_write_failure_leaves_old_intact() { + let mock = mock::Store::new().unwrap(); + let store = SecretStore::Os(mock.clone()); + let w = wid(15); + let old = SecretString::new("old-pw"); + let new = SecretString::new("new-pw"); + store + .set_secret(&w, "seed", &SecretBytes::from_slice(b"REAL"), Some(&old)) + .unwrap(); + + // Read succeeds (the rewrite's first step) … + let secret = store.get_secret(&w, "seed", Some(&old)).unwrap().unwrap(); + // … then inject a one-shot backend error so the write fails. + let service = format!("{SERVICE_PREFIX}{}", w.to_hex()); + let entry = mock.build(&service, "seed", None).unwrap(); + let cred: &mock::Cred = entry.as_any().downcast_ref().unwrap(); + cred.set_error(KeyringError::PlatformFailure(Box::new( + std::io::Error::other("simulated backend write failure"), + ))); + let err = store + .set_secret(&w, "seed", &secret, Some(&new)) + .unwrap_err(); + assert!( + matches!(err, SecretStoreError::OsKeyring { .. }), + "got {err:?}" + ); + + // The OLD value is still readable; nothing rotated to `new`. + assert_eq!( + store + .get_secret(&w, "seed", Some(&old)) + .unwrap() + .unwrap() + .expose_secret(), + b"REAL" + ); + assert!(matches!( + store.get_secret(&w, "seed", Some(&new)).unwrap_err(), + SecretStoreError::WrongPassword + )); + } + + /// [Os]: the read-size guard rejects an oversized backend blob (a + /// malicious keychain returning more than a legitimate envelope ever + /// could) BEFORE it reaches the envelope parse/derive path. The bound is + /// `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD`; both the `get_secret` and + /// legacy `get` read paths enforce it. + #[test] + fn os_read_rejects_oversized_blob() { + let b = os_backend(); + let w = wid(16); + let cap = MAX_SECRET_LEN + envelope::MAX_ENVELOPE_OVERHEAD; + // Attacker writes a blob one byte over the cap straight to the slot. + b.place_raw(&w, "seed", &vec![0u8; cap + 1]); + let err = b.store.get_secret(&w, "seed", None).unwrap_err(); + assert!( + matches!(err, SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap), + "get_secret got {err:?}" + ); + // The legacy `get` path is bounded too. + assert!(matches!( + b.store.get(&w, "seed").unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } if found == cap + 1 && max == cap + )); + } } diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs new file mode 100644 index 00000000000..f6b750eef76 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/aad.rs @@ -0,0 +1,252 @@ +//! Bincode-encoded AAD structs for the three contexts that authenticate +//! ciphertexts under `secrets/`: Tier-2 scheme-1 envelopes, vault entry +//! bodies, and the vault passphrase-verify token. +//! +//! Each struct is `Encode`-only — AAD is producer-side; the decoder +//! re-builds it from the surrounding context and bincode-encodes again +//! against [`WIRE_CONFIG`]. Pair-wise byte disjointness is guaranteed by +//! the three domain constants declared in [`super::config`] and pinned +//! empirically by the tests `tier2_and_entry_aad_byte_disjoint`, +//! `tier2_and_verify_aad_byte_disjoint`, and +//! `entry_and_verify_aad_byte_disjoint`. + +use crate::secrets::file::crypto::SALT_LEN; +use crate::secrets::wire::kdf::KdfParamsEncoded; + +/// AAD bound into every scheme-1 (password-protected) Tier-2 envelope. +/// Binds object identity (`wallet_id` + `label`) + header +/// (`envelope_version`, `scheme_discriminant`, `kdf`, `salt`) so any +/// in-place edit of those fields fails the AEAD tag. +/// +/// `scheme_discriminant` is explicit (not inferred from a Rust enum +/// variant tag) so the AAD shape is stable under a future `Payload` +/// re-ordering. +#[derive(bincode::Encode)] +pub(crate) struct Tier2Aad<'a> { + /// Domain tag — `TIER2_DOMAIN_V2`. Length-prefixed by bincode and + /// byte-disjoint from `ENTRY_DOMAIN_V2` / `VERIFY_DOMAIN_V2` by + /// content past the common prefix; pinned by the disjointness tests + /// in [`super::aad::tests`]. + pub domain: &'static [u8], + /// Envelope wire version (`ENVELOPE_VERSION`). + pub envelope_version: u32, + /// `0 = Unprotected`, `1 = Password`. Authenticates the scheme byte + /// independently of the enum's bincode-derived tag. + pub scheme_discriminant: u8, + /// The exact bytes encoded into the envelope's `Payload::Password` + /// body — AAD == body, so a wire-edited KDF header fails the tag. + pub kdf: KdfParamsEncoded, + /// Per-wrap CSPRNG salt. + pub salt: [u8; SALT_LEN], + /// 32-byte wallet correlation id (public, not secret). + pub wallet_id: [u8; 32], + /// Caller-allowlisted slot label. + pub label: &'a str, +} + +/// AAD bound into every vault entry's AEAD seal. Replaces the +/// hand-rolled `format::aad()` byte concatenation; binds slot identity +/// (`wallet_id` + `label`) at a stable `format_version`. A relocated +/// or version-rolled-back blob fails the tag. +#[derive(bincode::Encode)] +pub(crate) struct EntryAad<'a> { + /// Domain tag — `ENTRY_DOMAIN_V2`. + pub domain: &'static [u8], + /// Vault `FORMAT_VERSION` (the compiled-in dispatch version, + /// never the parsed JSON version). + pub format_version: u32, + /// 32-byte wallet correlation id. + pub wallet_id: [u8; 32], + /// Caller-allowlisted slot label. + pub label: &'a str, +} + +/// AAD bound into the vault passphrase-verify token's AEAD seal. +/// Binds salt + KDF header so a flipped salt or KDF-param shift fails +/// the token tag (surfaces as `WrongPassphrase` — a tampered header +/// also yields a different derived key). +#[derive(bincode::Encode)] +pub(crate) struct VerifyAad { + /// Domain tag — `VERIFY_DOMAIN_V2`. + pub domain: &'static [u8], + /// Vault `FORMAT_VERSION`. + pub format_version: u32, + /// Vault-wide CSPRNG salt. + pub salt: [u8; SALT_LEN], + /// Vault-wide KDF parameters (the same wire image used by every + /// scheme-1 Tier-2 envelope). + pub kdf: KdfParamsEncoded, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::file::crypto::KdfParams; + use crate::secrets::wire::config::{ + ENTRY_DOMAIN_V2, TIER2_DOMAIN_V2, VERIFY_DOMAIN_V2, WIRE_CONFIG, + }; + + fn floor_kdf() -> KdfParamsEncoded { + KdfParamsEncoded::from(KdfParams::default_target()) + } + + fn tier2_with_domain(domain: &'static [u8]) -> Vec { + let aad = Tier2Aad { + domain, + envelope_version: 1, + scheme_discriminant: 1, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn tier2_with_version(envelope_version: u32) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version, + scheme_discriminant: 1, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn tier2_with_scheme(scheme_discriminant: u8) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version: 1, + scheme_discriminant, + kdf: floor_kdf(), + salt: [0x77u8; SALT_LEN], + wallet_id: [0x11u8; 32], + label: "seed", + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn entry(format_version: u32, wallet_id: [u8; 32], label: &str) -> Vec { + let aad = EntryAad { + domain: ENTRY_DOMAIN_V2, + format_version, + wallet_id, + label, + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + fn verify(salt: [u8; SALT_LEN], kdf: KdfParamsEncoded) -> Vec { + let aad = VerifyAad { + domain: VERIFY_DOMAIN_V2, + format_version: 1, + salt, + kdf, + }; + bincode::encode_to_vec(aad, WIRE_CONFIG).unwrap() + } + + /// Two byte strings where neither is a prefix of the other. + fn assert_prefix_disjoint(a: &[u8], b: &[u8]) { + assert!( + !a.starts_with(b) && !b.starts_with(a), + "prefix containment: a.len={} b.len={}", + a.len(), + b.len() + ); + } + + /// TC-014 — Tier2Aad.domain is bincode-encoded. + #[test] + fn tier2_aad_domain_field_binds_bytes() { + let a = tier2_with_domain(TIER2_DOMAIN_V2); + let b = tier2_with_domain(b"PWSEV-TIER2-AAD-vX"); + assert_ne!(a, b); + assert_prefix_disjoint(&a, &b); + } + + /// TC-015 — Tier2Aad.envelope_version is bincode-encoded. + #[test] + fn tier2_aad_envelope_version_field_binds_bytes() { + assert_ne!(tier2_with_version(1), tier2_with_version(2)); + } + + /// TC-016 — Tier2Aad.scheme_discriminant is bincode-encoded and + /// explicit (not inferred from a Rust enum tag). + #[test] + fn tier2_aad_scheme_discriminant_field_binds_bytes() { + assert_ne!(tier2_with_scheme(0), tier2_with_scheme(1)); + } + + /// TC-025 — Tier2Aad and EntryAad are byte-disjoint at the prefix. + #[test] + fn tier2_and_entry_aad_byte_disjoint() { + let t = tier2_with_domain(TIER2_DOMAIN_V2); + let e = entry(1, [0x11u8; 32], "seed"); + assert_prefix_disjoint(&t, &e); + } + + /// TC-026 — Tier2Aad and VerifyAad are byte-disjoint at the prefix. + #[test] + fn tier2_and_verify_aad_byte_disjoint() { + let t = tier2_with_domain(TIER2_DOMAIN_V2); + let v = verify([0x77u8; SALT_LEN], floor_kdf()); + assert_prefix_disjoint(&t, &v); + } + + /// TC-027 — EntryAad and VerifyAad are byte-disjoint at the prefix. + /// Now backed by an explicit domain constant on top of the existing + /// VERIFY_LABEL leading-NUL trick at the `format.rs` call site. + #[test] + fn entry_and_verify_aad_byte_disjoint() { + let e = entry(1, [0u8; 32], "\0verify"); + let v = verify([0x77u8; SALT_LEN], floor_kdf()); + assert_prefix_disjoint(&e, &v); + } + + /// TC-037 — EntryAad binds (format_version, wallet_id, label) and + /// the label encoding carries its length prefix (`"a"+"b"` vs + /// `"ab"` are distinct). + #[test] + fn entry_aad_binds_format_version_wallet_id_and_label() { + let base = entry(1, [1u8; 32], "a"); + assert_ne!(base, entry(2, [1u8; 32], "a")); + assert_ne!(base, entry(1, [2u8; 32], "a")); + assert_ne!(base, entry(1, [1u8; 32], "b")); + // Length-prefix sanity: "ab" must not equal the concatenation of + // the encoding of "a" with the literal byte `b`. + let ab = entry(1, [1u8; 32], "ab"); + let mut a_plus_b = base.clone(); + a_plus_b.extend_from_slice(b"b"); + assert_ne!(ab, a_plus_b); + } + + /// TC-038 — VerifyAad binds salt + KDF; identical inputs produce + /// identical bytes (determinism). + #[test] + fn verify_aad_binds_salt_and_kdf_params() { + let salt = [7u8; SALT_LEN]; + let kdf = floor_kdf(); + let base = verify(salt, kdf); + let mut salt2 = salt; + salt2[0] ^= 0x01; + assert_ne!(base, verify(salt2, kdf)); + + let kdf_mkib = KdfParamsEncoded { + m_kib: kdf.m_kib / 2, + ..kdf + }; + assert_ne!(base, verify(salt, kdf_mkib)); + let kdf_t = KdfParamsEncoded { + t: kdf.t - 1, + ..kdf + }; + assert_ne!(base, verify(salt, kdf_t)); + + // Determinism: identical inputs ⇒ identical bytes. + assert_eq!(base, verify(salt, kdf)); + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs new file mode 100644 index 00000000000..b6c6031a63f --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/config.rs @@ -0,0 +1,43 @@ +//! Single bincode configuration + domain / version constants every +//! encoder in `secrets/wire/` uses. +//! +//! `WIRE_CONFIG` matches the platform-wide +//! `bincode::config::standard().with_big_endian().with_no_limit()` +//! (`rs-platform-serialization`) — big-endian for human-readable hex +//! dumps, varint integer encoding, no decode limit. +//! +//! Changing this constant invalidates every stored Tier-2 blob; the +//! golden-vector tests in [`super::envelope::tests`] catch any drift. + +use bincode::config::{BigEndian, Configuration, NoLimit, Varint}; + +/// The one bincode config used to encode every wire byte under +/// `secrets/wire/` (envelope payload + the three AAD structs). +pub(crate) const WIRE_CONFIG: Configuration = + bincode::config::standard() + .with_big_endian() + .with_no_limit(); + +/// Tier-2 envelope wire version — bumped only on a breaking layout +/// change, independent of the vault `FORMAT_VERSION`. Bound into every +/// scheme-1 envelope's AAD so a forged version byte fails the tag. +pub(crate) const ENVELOPE_VERSION: u32 = 1; + +/// Domain-separation tag leading the Tier-2 scheme-1 AAD. `-v2` marks the +/// wire-format break from the pre-bincode hand-rolled `PWSEV-TIER2-AAD-v1`. +pub(crate) const TIER2_DOMAIN_V2: &[u8] = b"PWSEV-TIER2-AAD-v2"; + +/// Domain-separation tag leading every vault `EntryAad`. Pre-bincode +/// `aad()` had no domain tag; bound here for symmetry + cross-context +/// disjointness with [`TIER2_DOMAIN_V2`] and [`VERIFY_DOMAIN_V2`]. +pub(crate) const ENTRY_DOMAIN_V2: &[u8] = b"PWSV-ENTRY-AAD-v2"; + +/// Domain-separation tag leading every vault `VerifyAad`. Disjoint +/// from [`TIER2_DOMAIN_V2`] and [`ENTRY_DOMAIN_V2`] by **content past +/// the common prefix** (the three tags are NOT length-distinct — +/// TIER2 and VERIFY are both 18 bytes; ENTRY is 17). Pair-wise +/// byte-disjointness is pinned by the tests +/// `tier2_and_verify_aad_byte_disjoint`, +/// `tier2_and_entry_aad_byte_disjoint`, and +/// `entry_and_verify_aad_byte_disjoint`. +pub(crate) const VERIFY_DOMAIN_V2: &[u8] = b"PWSV-VERIFY-AAD-v2"; diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs new file mode 100644 index 00000000000..2aacea03291 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -0,0 +1,1100 @@ +//! Tier-2 envelope wire format — bincode-encoded `Envelope` / `Payload` +//! plus the [`wrap`] / [`wrap_with_params`] / [`unwrap`] API. +//! +//! Every byte that crosses the AEAD seam is produced by +//! `bincode::encode_to_vec` against [`WIRE_CONFIG`], so a future config +//! drift surfaces in the golden-vector tests, not in silently corrupted +//! blobs. Decoding goes through [`DECODE_CONFIG`] — the same +//! configuration with a byte limit, so a hostile blob declaring a +//! multi-GiB length prefix is rejected before any allocation. + +use bincode::config::{BigEndian, Configuration, Limit, Varint}; + +use crate::secrets::error::SecretStoreError; +use crate::secrets::file::crypto::{self, KdfParams, NONCE_LEN, SALT_LEN}; +use crate::secrets::secret::{SecretBytes, SecretString}; +use crate::secrets::validate::WalletId; +use crate::secrets::wire::aad::Tier2Aad; +use crate::secrets::wire::config::{ENVELOPE_VERSION, TIER2_DOMAIN_V2, WIRE_CONFIG}; +use crate::secrets::wire::kdf::KdfParamsEncoded; +use crate::secrets::MAX_SECRET_LEN; + +/// On-disk Tier-2 wire envelope. The whole struct is bincode-encoded +/// in one call; a wire-edited `version` is gated to +/// `SecretStoreError::UnsupportedEnvelopeVersion` before dispatch. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)] +pub(crate) struct Envelope { + /// Envelope wire version (`ENVELOPE_VERSION`). + pub version: u32, + /// Tagged payload selecting unprotected vs password-protected. + pub payload: Payload, +} + +/// Tagged payload: scheme-0 ships the plaintext as-is (the backend's +/// own at-rest crypto is the only defence); scheme-1 ships the AEAD +/// triple under an object-password-derived key. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq)] +pub(crate) enum Payload { + /// Scheme 0 — unprotected passthrough; the bytes are the secret. + Unprotected(Vec), + /// Scheme 1 — sealed under an Argon2id-derived key with + /// XChaCha20-Poly1305. The AAD bound at seal time is + /// [`crate::secrets::wire::aad::Tier2Aad`]. + Password { + /// Argon2 parameters used to derive the key. + kdf: KdfParamsEncoded, + /// Per-wrap CSPRNG salt fed into Argon2. + salt: [u8; SALT_LEN], + /// Per-wrap CSPRNG nonce fed into XChaCha20-Poly1305. + nonce: [u8; NONCE_LEN], + /// Ciphertext + 16-byte Poly1305 tag. + ciphertext: Vec, + }, +} + +/// Upper bound on the bincode-encoded envelope overhead over its +/// plaintext (header + KDF + salt + nonce + AEAD tag + bincode framing). +/// Pinned by a runtime cross-check in `tests::max_envelope_overhead_matches_runtime` +/// so any bincode-config drift surfaces immediately. The smallest +/// scheme-1 envelope (empty plaintext sealed → 16-byte tag) measures +/// 81 bytes; rounded up to the next 16-byte boundary that satisfies a +/// 16-byte safety margin (81 + 16 = 97 → 112) for headroom against a +/// future header field. +pub(crate) const MAX_ENVELOPE_OVERHEAD: usize = 112; + +/// Plaintext cap at the envelope boundary: `MAX_SECRET_LEN − +/// MAX_ENVELOPE_OVERHEAD`. Capping the plaintext (uniformly for both +/// schemes) keeps the user-visible limit stable AND guarantees the +/// enveloped bytes always fit the backend vault's own `MAX_SECRET_LEN` +/// `put_bytes` cap. +pub const MAX_PLAINTEXT_LEN: usize = MAX_SECRET_LEN - MAX_ENVELOPE_OVERHEAD; + +/// Decode-side budget: caps the bytes the bincode decoder will consume +/// from a single envelope. Equal to the on-disk cap. +const DECODE_BUDGET: usize = MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD; + +/// Bincode decode config — derived from [`WIRE_CONFIG`] but with a +/// [`DECODE_BUDGET`] byte limit applied. +/// +/// **Asymmetric on purpose, security-positive deviation from +/// design-brief NF2** (which locks the wire config to +/// `with_no_limit()`). The deviation exists for hostile-decode +/// hardening: an attacker-controlled length prefix in the blob would +/// otherwise drive `Vec::with_capacity` to a multi-GiB allocation +/// before any tag check. With `Limit`, bincode refuses the +/// allocation up front and the unwrap fails closed as `Corruption`. +/// +/// The encoder retains [`WIRE_CONFIG`] (no limit) because AAD and +/// envelope encoding are producer-only — every input is library-owned +/// and bounded by `MAX_PLAINTEXT_LEN`, so a limit there has no +/// security benefit and would be a foot-gun against legitimate +/// at-cap secrets. +const DECODE_CONFIG: Configuration> = + WIRE_CONFIG.with_limit::(); + +/// Wrap `plaintext` for `(wallet_id, label)` using the shipped default +/// Argon2 target when a password is supplied. +/// +/// `None` → an unprotected (scheme-0) envelope; `Some(pw)` → a scheme-1 +/// envelope sealed under `pw`. A blank password is rejected at enrol +/// (`SecretStoreError::BlankPassphrase`). +/// +/// Returns the envelope inside a zeroizing [`SecretBytes`]. +pub(crate) fn wrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], +) -> Result { + wrap_with_params( + wallet_id, + label, + password, + plaintext, + KdfParams::default_target(), + ) +} + +/// [`wrap`] with explicit Argon2 `params` (tests use floor params for +/// speed). `params` is ignored when `password` is `None`. +pub(crate) fn wrap_with_params( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + plaintext: &[u8], + params: KdfParams, +) -> Result { + // Cap the PLAINTEXT (before overhead) uniformly for both schemes so + // the enveloped bytes always fit the backend cap. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + + let Some(pw) = password else { + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(plaintext.to_vec()), + }; + return Ok(SecretBytes::new(encode_envelope(&envelope))); + }; + + // Reject a blank object password BEFORE any salt / derive. + if pw.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } + + let mut salt = [0u8; SALT_LEN]; + crypto::random_bytes(&mut salt)?; + let key = crypto::derive_key(pw, &salt, params)?; + let kdf = KdfParamsEncoded::from(params); + let aad = encode_tier2_aad(wallet_id, label, kdf, &salt); + let (nonce, ciphertext) = crypto::seal(&key, &aad, plaintext)?; + + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + }; + Ok(SecretBytes::new(encode_envelope(&envelope))) +} + +/// Bincode-encode the scheme-1 AAD against [`WIRE_CONFIG`]. Shared by +/// [`wrap_with_params`] and [`unwrap_password_payload`] so the encode +/// and decode AADs cannot drift apart. +pub(crate) fn encode_tier2_aad( + wallet_id: &WalletId, + label: &str, + kdf: KdfParamsEncoded, + salt: &[u8; SALT_LEN], +) -> Vec { + let aad = Tier2Aad { + domain: TIER2_DOMAIN_V2, + envelope_version: ENVELOPE_VERSION, + scheme_discriminant: 1, + kdf, + salt: *salt, + wallet_id: *wallet_id.as_bytes(), + label, + }; + // AAD encode is infallible — every field is owned/borrowed bincode- + // Encode-able. A failure would be a logic bug. + bincode::encode_to_vec(aad, WIRE_CONFIG).expect("Tier2Aad encode is infallible") +} + +/// Bincode-encode the whole envelope. Wrapping in `SecretBytes::new` +/// keeps the (possibly plaintext-bearing) scheme-0 buffer zeroizing. +fn encode_envelope(envelope: &Envelope) -> Vec { + bincode::encode_to_vec(envelope, WIRE_CONFIG).expect("Envelope encode is infallible") +} + +/// Unwrap `blob` for `(wallet_id, label)`, applying the strict +/// fail-closed read. +/// +/// `password` carries the caller's protection assertion — never the +/// blob's scheme byte. Decode errors (truncated, garbage bytes, unknown +/// enum tag) collapse to `Corruption`; an envelope version this build +/// does not recognise yields `UnsupportedEnvelopeVersion` ahead of +/// dispatch. +/// +/// | `password` | `payload` | result | +/// |---|---|---| +/// | `Some(pw)` | `Password { .. }` | the secret, or `WrongPassword` on tag fail | +/// | `Some(pw)` | `Unprotected(_)` | `ExpectedProtectedButUnsealed` (strip/downgrade) | +/// | `None` | `Password { .. }` | `NeedsPassword` (never ciphertext) | +/// | `None` | `Unprotected(pt)` | the secret | +pub(crate) fn unwrap( + wallet_id: &WalletId, + label: &str, + password: Option<&SecretString>, + blob: &[u8], +) -> Result { + let (envelope, consumed) = bincode::decode_from_slice::(blob, DECODE_CONFIG) + .map_err(|_| SecretStoreError::Corruption)?; + // Trailing bytes after a valid decode are a truncation/extension + // probe — fail closed. + if consumed != blob.len() { + return Err(SecretStoreError::Corruption); + } + + if envelope.version != ENVELOPE_VERSION { + // `found` keeps the historical u8 — the error API stayed u8 for + // back-compat; an out-of-range u32 wraps but the decoder above + // already accepts every u32 so this only narrows the diagnostic. + return Err(SecretStoreError::UnsupportedEnvelopeVersion { + found: envelope.version as u8, + }); + } + + match (envelope.payload, password) { + (Payload::Unprotected(plaintext), None) => { + // Enforce the same cap the wrap side applies. DECODE_BUDGET is + // larger than MAX_PLAINTEXT_LEN (by MAX_ENVELOPE_OVERHEAD), so a + // tampered blob can pass the bincode budget check yet exceed the + // application-level plaintext ceiling; reject it here. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + Ok(SecretBytes::new(plaintext)) + } + // Caller asserted protection but blob is unprotected: strip / + // downgrade — fail closed, never return the bytes. + (Payload::Unprotected(_), Some(_)) => Err(SecretStoreError::ExpectedProtectedButUnsealed), + (Payload::Password { .. }, None) => Err(SecretStoreError::NeedsPassword), + ( + Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + Some(pw), + ) => unwrap_password_payload(wallet_id, label, pw, kdf, salt, nonce, &ciphertext), + } +} + +/// Decrypt a `Payload::Password` body. The KDF params, salt and nonce +/// come from the (attacker-controllable) envelope; `enforce_bounds` +/// AND a stricter per-read `default_target` ceiling gate the params +/// BEFORE `derive_key` allocates. +fn unwrap_password_payload( + wallet_id: &WalletId, + label: &str, + password: &SecretString, + kdf_encoded: KdfParamsEncoded, + salt: [u8; SALT_LEN], + nonce: [u8; NONCE_LEN], + ciphertext: &[u8], +) -> Result { + // (a0) Mirror wrap's invariant: a blank object password is rejected on + // read as well as enrol, so a backend-write attacker who plants a + // scheme-1 envelope sealed under the blank password cannot inject + // plaintext into a caller that accidentally forwards Some(empty). + if password.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } + // (a) Wider Argon2 floors/ceilings — refuses an inflated header + // before any allocation. + let kdf = KdfParams::try_from(kdf_encoded)?; + // (b) Per-read ceiling tighter than `enforce_bounds`: a header + // declaring more memory OR more time than this build's shipped + // target is refused before `derive_key` allocates. Closes the gaps + // between `ARGON2_MAX_M_KIB` (1 GiB) / `ARGON2_MAX_T` (16) and the + // shipped 64 MiB / t=3 default — bounds the worst-case forged read + // at the shipped target on both axes (no headroom for an attacker + // to inflate memory by 16× or CPU by 5.3×). + let target = KdfParams::default_target(); + if kdf.m_kib > target.m_kib || kdf.t > target.t { + return Err(SecretStoreError::KdfFailure); + } + // (c) AAD binds identity + header — the same bytes the encoder + // produced, by construction. + let aad = encode_tier2_aad(wallet_id, label, kdf_encoded, &salt); + let key = crypto::derive_key(password, &salt, kdf)?; + match crypto::open(&key, &nonce, &aad, ciphertext) { + Ok(plaintext) => { + // A wrap-produced blob is always within MAX_PLAINTEXT_LEN; a + // plaintext that exceeds the cap after successful AEAD + // authentication must have been produced by a different build or + // is tampered — fail closed. + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + Ok(plaintext) + } + // Tag failure (wrong password, relocated blob, header tamper): + // no plaintext ever materialises (CWE-347). + Err(SecretStoreError::Decrypt) => Err(SecretStoreError::WrongPassword), + Err(e) => Err(e), + } +} + +/// Test-only deterministic encoder: takes pre-supplied `salt` and +/// `nonce` instead of pulling from the CSPRNG, so golden-vector tests +/// produce reproducible bytes. Production callers MUST use +/// [`wrap_with_params`]. +#[cfg(test)] +pub(crate) fn wrap_with_params_for_test( + wallet_id: &WalletId, + label: &str, + pw: &SecretString, + plaintext: &[u8], + params: KdfParams, + salt: [u8; SALT_LEN], + nonce: [u8; NONCE_LEN], +) -> Result { + if plaintext.len() > MAX_PLAINTEXT_LEN { + return Err(SecretStoreError::SecretTooLarge { + found: plaintext.len(), + max: MAX_PLAINTEXT_LEN, + }); + } + if pw.is_blank() { + return Err(SecretStoreError::BlankPassphrase); + } + let key = crypto::derive_key(pw, &salt, params)?; + let kdf = KdfParamsEncoded::from(params); + let aad = encode_tier2_aad(wallet_id, label, kdf, &salt); + let (nonce, ciphertext) = crypto::seal_with_nonce(&key, nonce, &aad, plaintext)?; + let envelope = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Password { + kdf, + salt, + nonce, + ciphertext, + }, + }; + Ok(SecretBytes::new(encode_envelope(&envelope))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::secrets::file::crypto::{ARGON2_MIN_M_KIB, ARGON2_MIN_T, ARGON2_P}; + use crate::secrets::file::format::KDF_ID_ARGON2ID; + + /// Captured once from the runtime encoder; a subsequent CI failure + /// here means a wire-format drift to investigate, NOT to "fix" by + /// re-generating the constant. + /// + /// Decoding: 0x01 envelope.version=1, 0x00 Payload::Unprotected, + /// 0x05 Vec length=5, "hello". + const SCHEME0_GOLDEN_HEX: &str = "01000568656c6c6f"; + + /// scheme-1 deterministic golden: wid=[0;32], label="seed", + /// pw="pw", plaintext="hello", floor params, salt=[0x11;32], + /// nonce=[0x22;24]. Bytes: version + Payload::Password tag + + /// kdf(id,m_kib,t,p as varints) + salt[32] + nonce[24] + + /// ciphertext-with-tag length + ciphertext+tag(21B). + const SCHEME1_GOLDEN_HEX: &str = "010101fb4c000201111111111111111111111111111111111111111111111111111111111111111122222222222222222222222222222222222222222222222215e2ffdf3f0476b6bfb99b4f71b3039ff965132b92f0"; + + fn wid(b: u8) -> WalletId { + WalletId::from([b; 32]) + } + + fn pw(s: &str) -> SecretString { + SecretString::new(s) + } + + fn floor() -> KdfParams { + KdfParams { + id: KDF_ID_ARGON2ID, + m_kib: ARGON2_MIN_M_KIB, + t: ARGON2_MIN_T, + p: ARGON2_P, + } + } + + /// TC-033 — blank object password rejected at enrol (wrap-side). + #[test] + fn blank_object_password_rejected_at_wrap() { + for blank in [SecretString::empty(), pw(""), pw(" "), pw("\t\n")] { + let err = + wrap_with_params(&wid(1), "seed", Some(&blank), b"seed", floor()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "got {err:?}" + ); + } + } + + /// Symmetric guard on the read side: a `Some(blank)` password reaching + /// `unwrap_password_payload` is refused with `BlankPassphrase` BEFORE + /// any KDF or AEAD work — never `WrongPassword`, never `Decrypt`, + /// never plaintext. Pins the contract that closes the asymmetry where + /// a backend-write attacker could plant a scheme-1 envelope sealed + /// under the blank password and have a caller that accidentally + /// forwards `Some(SecretString::empty())` accept attacker-controlled + /// plaintext. + #[test] + fn unwrap_password_payload_rejects_some_blank_password() { + let blob = scheme1_blob(&pw("good")); + let err = unwrap(&wid(1), "seed", Some(&SecretString::empty()), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::BlankPassphrase), + "blank object password must be refused before KDF/AEAD, got {err:?}" + ); + } + + /// TC-034 — plaintext cap accept at MAX_PLAINTEXT_LEN, reject at + /// +1, for both schemes. + #[test] + fn plaintext_cap_accept_then_reject() { + let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let over = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; + + // Scheme 0 + assert!(wrap(&wid(1), "seed", None, &at_cap).is_ok()); + assert!(matches!( + wrap(&wid(1), "seed", None, &over).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Scheme 1 — cap check fires before any derivation. + let p = pw("pw"); + assert!(matches!( + wrap_with_params(&wid(1), "seed", Some(&p), &over, floor()).unwrap_err(), + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + )); + + // Scheme-0 enveloped bytes for an at-cap plaintext fit the backend cap. + let enveloped = wrap(&wid(1), "seed", None, &at_cap).unwrap(); + assert!(enveloped.len() <= MAX_SECRET_LEN); + } + + /// TC-035 (size-budget half) — scheme-1 accepts plaintext at the + /// exact MAX_PLAINTEXT_LEN boundary; the enveloped bytes fit the + /// backend cap. The round-trip half is `scheme1_at_cap_round_trips_within_backend_cap`. + #[test] + fn scheme1_at_cap_envelope_fits_backend_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap(); + assert!( + blob.len() <= MAX_SECRET_LEN, + "enveloped bytes ({} B) exceed backend cap ({} B)", + blob.len(), + MAX_SECRET_LEN + ); + } + + /// TC-028 — golden hex vector for the scheme-0 wire bytes. Any + /// bincode-config drift (endianness, varint mode, limit) trips this. + #[test] + fn scheme0_golden_vector_matches_const() { + let blob = wrap(&WalletId::from([0u8; 32]), "seed", None, b"hello").unwrap(); + let actual = hex::encode(blob.expose_secret()); + assert_eq!(actual, SCHEME0_GOLDEN_HEX); + } + + /// TC-029 — golden hex vector for the scheme-1 wire bytes, produced + /// via the deterministic encoder seam. + #[test] + fn scheme1_golden_vector_matches_const() { + let blob = wrap_with_params_for_test( + &WalletId::from([0u8; 32]), + "seed", + &pw("pw"), + b"hello", + floor(), + [0x11u8; SALT_LEN], + [0x22u8; NONCE_LEN], + ) + .unwrap(); + let actual = hex::encode(blob.expose_secret()); + assert_eq!(actual, SCHEME1_GOLDEN_HEX); + } + + /// Minimum overhead within budget AND the budget not absurdly above + /// the actual encoding — bound on both sides so the constant stays + /// honest as the wire shape evolves. + const SAFETY_MARGIN: usize = 16; + + /// TC-030 — `MAX_ENVELOPE_OVERHEAD` cross-checks the runtime + /// bincode encoding of the smallest possible scheme-1 envelope + /// (empty plaintext sealed → ciphertext == 16-byte AEAD tag). + #[test] + fn max_envelope_overhead_matches_runtime() { + let blob = wrap_with_params_for_test( + &WalletId::from([0u8; 32]), + "seed", + &pw("pw"), + b"", + floor(), + [0x11u8; SALT_LEN], + [0x22u8; NONCE_LEN], + ) + .unwrap(); + let actual = blob.len(); + assert!( + actual + SAFETY_MARGIN <= MAX_ENVELOPE_OVERHEAD, + "overhead {} + margin {} exceeds const {}", + actual, + SAFETY_MARGIN, + MAX_ENVELOPE_OVERHEAD + ); + assert!( + MAX_ENVELOPE_OVERHEAD - actual < 64, + "MAX_ENVELOPE_OVERHEAD {} is more than 64 B above the runtime measurement {} — tighten it", + MAX_ENVELOPE_OVERHEAD, + actual + ); + } + + // ===== Decoder: dispatch / wire-flip / fuzz / property ===== + + use crate::secrets::file::crypto::{ARGON2_MAX_M_KIB, ARGON2_MAX_T}; + use crate::secrets::wire::config::WIRE_CONFIG; + use subtle::ConstantTimeEq; + + /// Decode a real envelope so wire-flip tests can mutate one field + /// and re-encode. + fn decode(blob: &[u8]) -> Envelope { + bincode::decode_from_slice::(blob, WIRE_CONFIG) + .unwrap() + .0 + } + + fn encode(envelope: &Envelope) -> Vec { + bincode::encode_to_vec(envelope, WIRE_CONFIG).unwrap() + } + + /// Build a fresh scheme-1 envelope (under wid(1)/"seed"/pw=`p`) and + /// hand back the bytes for mutation tests. + fn scheme1_blob(p: &SecretString) -> Vec { + wrap_with_params(&wid(1), "seed", Some(p), b"seed", floor()) + .unwrap() + .expose_secret() + .to_vec() + } + + /// TC-001 — scheme-0 round-trip preserves plaintext. + #[test] + fn scheme0_round_trip_preserves_plaintext() { + let blob = wrap(&wid(1), "seed", None, b"top secret seed bytes").unwrap(); + let got = unwrap(&wid(1), "seed", None, blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"top secret seed bytes"); + } + + /// TC-002 — scheme-1 round-trip preserves plaintext. + #[test] + fn scheme1_round_trip_preserves_plaintext() { + let p = pw("hunter2"); + let blob = wrap_with_params( + &wid(7), + "seed", + Some(&p), + b"correct horse battery staple", + floor(), + ) + .unwrap(); + assert_ne!(blob.expose_secret(), b"correct horse battery staple"); + let got = unwrap(&wid(7), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"correct horse battery staple"); + } + + /// TC-003 — scheme-1 produces a fresh salt + nonce per wrap. + #[test] + fn scheme1_uses_fresh_salt_and_nonce_per_wrap() { + let p = pw("pw"); + let a = scheme1_blob(&p); + let b = scheme1_blob(&p); + let (sa, na) = match decode(&a).payload { + Payload::Password { salt, nonce, .. } => (salt, nonce), + _ => panic!("scheme-1 wrap must yield Password"), + }; + let (sb, nb) = match decode(&b).payload { + Payload::Password { salt, nonce, .. } => (salt, nonce), + _ => panic!("scheme-1 wrap must yield Password"), + }; + assert_ne!(sa, sb, "salt must be fresh per wrap"); + assert_ne!(na, nb, "nonce must be fresh per wrap"); + } + + /// TC-004 — wrong object password yields WrongPassword. + #[test] + fn wrong_password_fails_closed() { + let blob = scheme1_blob(&pw("right")); + let err = unwrap(&wid(1), "seed", Some(&pw("wrong")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// Mutate the `Payload::Password` body in-place via decode → patch + /// → encode. Returns the new blob. + fn mutate_scheme1( + blob: &[u8], + patch: impl FnOnce(&mut KdfParamsEncoded, &mut [u8; SALT_LEN], &mut [u8; NONCE_LEN]), + ) -> Vec { + let mut env = decode(blob); + match env.payload { + Payload::Password { + ref mut kdf, + ref mut salt, + ref mut nonce, + .. + } => patch(kdf, salt, nonce), + _ => panic!("mutate_scheme1 expects a Password payload"), + } + encode(&env) + } + + /// TC-005 — wire-flip of kdf.m_kib (in-bounds shift) yields WrongPassword. + #[test] + fn wire_flip_kdf_m_kib_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = ARGON2_MIN_M_KIB + 1024; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-006 — wire-flip of kdf.t (in-bounds shift) yields WrongPassword. + #[test] + fn wire_flip_kdf_t_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.t = ARGON2_MIN_T + 1; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-007 — wire-flip of kdf.id to an unknown value is rejected by + /// `enforce_bounds` BEFORE `derive_key` allocates. + #[test] + fn wire_flip_kdf_id_unknown_rejected_pre_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.id = 7; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-008 — wire-flip of salt[0] yields WrongPassword. + #[test] + fn wire_flip_salt_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |_, salt, _| { + salt[0] ^= 0x01; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-009 — wire-flip of nonce[0] yields WrongPassword. + #[test] + fn wire_flip_nonce_fails_closed() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |_, _, nonce| { + nonce[0] ^= 0x01; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-010 — re-binding the unwrap to a different wallet_id rejects. + #[test] + fn relocation_across_wallet_id_rejected() { + let p = pw("pw"); + let blob = wrap_with_params(&wid(0xA), "seed", Some(&p), b"seed", floor()).unwrap(); + let err = unwrap(&wid(0xB), "seed", Some(&p), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-011 — re-binding the unwrap to a different label rejects. + #[test] + fn relocation_across_label_rejected() { + let p = pw("pw"); + let blob = wrap_with_params(&wid(1), "labelA", Some(&p), b"seed", floor()).unwrap(); + let err = unwrap(&wid(1), "labelB", Some(&p), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::WrongPassword), + "got {err:?}" + ); + } + + /// TC-012 — wire-flip of envelope.version (via re-encode) is gated + /// to UnsupportedEnvelopeVersion before AAD bind. + #[test] + fn wire_flip_version_rejected_pre_aad() { + let blob = scheme1_blob(&pw("pw")); + let mut env = decode(&blob); + env.version = 2; + let tampered = encode(&env); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &tampered).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + ), + "got {err:?}" + ); + } + + /// TC-013 — forged `Payload::Unprotected` with ciphertext bytes + + /// `Some(pw)` redirects to ExpectedProtectedButUnsealed. + #[test] + fn wire_flip_scheme_dispatch_redirects_safely() { + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(vec![0xDEu8; 32]), + }; + let blob = encode(&env); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "got {err:?}" + ); + } + + /// TC-017 — truncated blob (< minimum envelope length) yields + /// Corruption. + #[test] + fn truncated_blob_yields_corruption() { + let blob = scheme1_blob(&pw("pw")); + let cut = blob.len() / 2; + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), &blob[..cut]).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-018 — random-byte blob yields Corruption (both arms). + #[test] + fn random_garbage_yields_corruption() { + let garbage = b"NOTANEVELOPE........................."; + let err = unwrap(&wid(1), "seed", None, garbage).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), garbage).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-019 — a manually-built envelope at version=2 fails closed + /// regardless of password. + #[test] + fn unsupported_version_rejected_for_any_password() { + let env = Envelope { + version: 2, + payload: Payload::Unprotected(b"x".to_vec()), + }; + let blob = encode(&env); + for arg in [None, Some(&pw("pw"))] { + let err = unwrap(&wid(1), "seed", arg, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 2 } + ), + "got {err:?}" + ); + } + } + + /// TC-020 — a hand-crafted byte stream with an unknown payload + /// enum tag yields Corruption (bincode's natural fail-closed). + #[test] + fn unknown_scheme_discriminant_yields_corruption() { + // envelope.version = 1 (varint = 0x01) then a Payload enum tag + // of 7 (varint = 0x07) — the two-variant enum decode rejects. + let blob = [0x01u8, 0x07]; + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + } + + /// TC-021 — Some(pw) + scheme-0 yields ExpectedProtectedButUnsealed. + #[test] + fn some_pw_on_scheme0_fails_closed() { + let blob = wrap(&wid(1), "seed", None, b"attacker-seed").unwrap(); + let err = unwrap(&wid(1), "seed", Some(&pw("pw")), blob.expose_secret()).unwrap_err(); + assert!( + matches!(err, SecretStoreError::ExpectedProtectedButUnsealed), + "got {err:?}" + ); + } + + /// TC-022 — None + scheme-1 yields NeedsPassword. + #[test] + fn none_pw_on_scheme1_yields_needs_password() { + let blob = scheme1_blob(&pw("pw")); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!(err, SecretStoreError::NeedsPassword), + "got {err:?}" + ); + } + + /// TC-023 — inflated KDF param rejected by `enforce_bounds` before + /// `derive_key` allocates (a ~4 TiB allocation would OOM the test). + #[test] + fn kdf_enforce_bounds_rejects_before_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = u32::MAX; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.t = ARGON2_MAX_T + 1; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// TC-024 — per-read `default_target` ceiling rejects an envelope + /// whose `m_kib` exceeds the shipped target even when still inside + /// `enforce_bounds`. Catches inflated headers BEFORE `derive_key`. + #[test] + fn per_read_default_target_ceiling_rejects_inflated_header() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let bumped = KdfParams::default_target().m_kib * 2; + // Sanity: the bumped value stays inside the wider enforce_bounds + // ceiling, so only the per-read gate can refuse it. + assert!(bumped <= ARGON2_MAX_M_KIB); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + kdf.m_kib = bumped; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// Sibling to TC-024 on the `t` axis — per-read `default_target` + /// ceiling rejects an envelope whose `t` exceeds the shipped target + /// even when still inside `enforce_bounds` (`ARGON2_MAX_T = 16`). + /// Closes the CPU-axis gap that would otherwise let a forged header + /// run Argon2 at 5.3× the shipped iteration count. + #[test] + fn kdf_t_ceiling_fires_before_derive() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let target = KdfParams::default_target(); + let bumped_t = target.t + 1; + // Sanity: the bumped t stays inside the wider enforce_bounds + // ceiling, so only the per-read gate can refuse it. + assert!(bumped_t <= ARGON2_MAX_T); + let tampered = mutate_scheme1(&blob, |kdf, _, _| { + // Keep m_kib at the shipped default so the m_kib gate + // cannot fire — t must be the sole reason this rejects. + kdf.m_kib = target.m_kib; + kdf.t = bumped_t; + }); + let err = unwrap(&wid(1), "seed", Some(&p), &tampered).unwrap_err(); + assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); + } + + /// Trailing bytes appended after a valid envelope are rejected as + /// `Corruption` — defends against a truncation/extension probe. + #[test] + fn decode_rejects_trailing_garbage() { + let p = pw("pw"); + let blob = scheme1_blob(&p); + let mut extended = blob.clone(); + extended.extend_from_slice(&[0xFFu8; 16]); + let err = unwrap(&wid(1), "seed", Some(&p), &extended).unwrap_err(); + assert!(matches!(err, SecretStoreError::Corruption), "got {err:?}"); + + // The same blob without the suffix still unwraps cleanly — + // proves the rejection is on the trailing bytes, not the + // envelope itself. + let ok = unwrap(&wid(1), "seed", Some(&p), &blob).unwrap(); + assert_eq!(ok.expose_secret(), b"seed"); + } + + /// TC-031 — round-tripped secret matches the original under a + /// constant-time compare. + #[test] + fn round_trip_is_constant_time_equal() { + let p = pw("pw"); + let original = SecretBytes::from_slice(b"seed material"); + let blob = + wrap_with_params(&wid(1), "seed", Some(&p), original.expose_secret(), floor()).unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert!(bool::from(got.ct_eq(&original))); + } + + /// TC-035 (round-trip half) — scheme-1 at exact MAX_PLAINTEXT_LEN + /// round-trips and the enveloped bytes fit the backend cap. + #[test] + fn scheme1_at_cap_round_trips_within_backend_cap() { + let p = pw("pw"); + let pt = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let blob = wrap_with_params(&wid(1), "seed", Some(&p), &pt, floor()).unwrap(); + assert!(blob.len() <= MAX_SECRET_LEN); + let got = unwrap(&wid(1), "seed", Some(&p), blob.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), &pt[..]); + } + + /// TC-037 — scheme-0 decode rejects an oversize plaintext (> MAX_PLAINTEXT_LEN) + /// even though the blob fits within DECODE_BUDGET. A tampered blob that encodes + /// a plaintext between MAX_PLAINTEXT_LEN+1 and DECODE_BUDGET would otherwise + /// bypass the wrap-side cap on the decode path. + #[test] + fn scheme0_decode_rejects_oversize_plaintext() { + // Build a scheme-0 envelope with plaintext = MAX_PLAINTEXT_LEN + 1 bytes. + // encode_envelope bypasses the wrap-side cap (it is a raw encoder), so this + // creates the exact tampered-blob scenario. + let oversized = vec![0x5Au8; MAX_PLAINTEXT_LEN + 1]; + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(oversized.clone()), + }; + let blob = encode(&env); + // Confirm the blob fits within DECODE_BUDGET (otherwise the test proves nothing). + assert!( + blob.len() <= DECODE_BUDGET, + "test blob must fit within DECODE_BUDGET to prove the plaintext check fires" + ); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::SecretTooLarge { found, max } + if found == MAX_PLAINTEXT_LEN + 1 && max == MAX_PLAINTEXT_LEN + ), + "expected SecretTooLarge on oversized scheme-0 plaintext, got {err:?}" + ); + } + + /// TC-038 — scheme-0 decode accepts a plaintext at exactly MAX_PLAINTEXT_LEN. + #[test] + fn scheme0_decode_accepts_at_cap_plaintext() { + let at_cap = vec![0x5Au8; MAX_PLAINTEXT_LEN]; + let env = Envelope { + version: ENVELOPE_VERSION, + payload: Payload::Unprotected(at_cap.clone()), + }; + let blob = encode(&env); + let got = unwrap(&wid(1), "seed", None, &blob).unwrap(); + assert_eq!(got.expose_secret(), &at_cap[..]); + } + + /// TC-036 — value rollback is intentionally NOT defended. + #[test] + fn value_rollback_is_not_defended() { + let p = pw("pw"); + let old = wrap_with_params(&wid(1), "seed", Some(&p), b"OLD-VALUE", floor()).unwrap(); + let _new = wrap_with_params(&wid(1), "seed", Some(&p), b"NEW-VALUE", floor()).unwrap(); + let got = unwrap(&wid(1), "seed", Some(&p), old.expose_secret()).unwrap(); + assert_eq!(got.expose_secret(), b"OLD-VALUE"); + } + + /// TC-032 — random byte mutations and truncations never panic; + /// every outcome is a permitted typed variant. + #[test] + fn fuzz_byte_mutation_and_truncation_never_panics() { + let p = pw("fuzz-pw"); + let valid = scheme1_blob(&p); + // Pristine envelope unwraps cleanly. + assert_eq!( + unwrap(&wid(1), "seed", Some(&p), &valid) + .unwrap() + .expose_secret(), + b"seed" + ); + + let mut state: u32 = 0x9E37_79B9; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + state + }; + + let assert_typed = |arg: Option<&SecretString>, buf: &[u8]| { + let res = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + unwrap(&wid(1), "seed", arg, buf) + })) + .expect("unwrap must never panic on hostile input"); + match res { + Ok(_) + | Err(SecretStoreError::Corruption) + | Err(SecretStoreError::WrongPassword) + | Err(SecretStoreError::NeedsPassword) + | Err(SecretStoreError::ExpectedProtectedButUnsealed) + | Err(SecretStoreError::UnsupportedEnvelopeVersion { .. }) + | Err(SecretStoreError::KdfFailure) + // A mutated scheme-0 blob can decode a plaintext Vec that + // exceeds MAX_PLAINTEXT_LEN while still fitting DECODE_BUDGET. + | Err(SecretStoreError::SecretTooLarge { .. }) => {} + Err(other) => panic!("unexpected error variant: {other:?}"), + } + }; + + for i in 0..2_000 { + let mut buf = valid.clone(); + let flips = 1 + (next() % 4) as usize; + for _ in 0..flips { + let idx = (next() as usize) % buf.len(); + buf[idx] ^= (next() & 0xFF) as u8; + } + // None path every iteration (cheap, no derive). + assert_typed(None, &buf); + // Some path on a representative subset (each may derive). + if i % 16 == 0 { + assert_typed(Some(&p), &buf); + } + } + + // Truncation at every offset — a short read must never panic. + for cut in 0..valid.len() { + assert_typed(None, &valid[..cut]); + assert_typed(Some(&p), &valid[..cut]); + } + } + + // TC-040 — proptest: no single-byte flip surfaces the plaintext. + // Minimises to the offset that breaks coverage if one exists. + proptest::proptest! { + #[test] + fn prop_single_byte_flip_never_yields_plaintext( + (offset, mask) in (0usize..200usize, 1u8..=255u8), + ) { + // Re-built per case so the proptest harness can shrink + // independently of the host RNG. + let plaintext: &[u8] = b"goldfinch"; + let p = pw("pw"); + let valid = wrap_with_params(&wid(1), "seed", Some(&p), plaintext, floor()) + .unwrap() + .expose_secret() + .to_vec(); + if offset >= valid.len() { + // Out-of-bounds offset → skip via prop_assume so proptest + // shrinks toward in-bounds offsets. + proptest::prop_assume!(offset < valid.len()); + } + let mut buf = valid.clone(); + buf[offset] ^= mask; + match unwrap(&wid(1), "seed", Some(&p), &buf) { + Ok(secret) => { + proptest::prop_assert_ne!( + secret.expose_secret(), + plaintext, + "single-byte flip at offset {} surfaced the plaintext", + offset + ); + } + Err(_) => { /* any typed error is fine */ } + } + } + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs new file mode 100644 index 00000000000..e869b29159b --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/kdf.rs @@ -0,0 +1,56 @@ +//! Bincode-encoded wire image of [`KdfParams`] — the Argon2 parameter +//! header read out of every scheme-1 envelope. +//! +//! Kept as a separate type from [`KdfParams`] (the in-memory + JSON- +//! vault type) so the wire layer owns its own bincode derives and the +//! in-memory type keeps its serde derives for the human-debuggable JSON +//! vault format. + +use crate::secrets::error::SecretStoreError; +use crate::secrets::file::crypto::KdfParams; + +/// Wire image of [`KdfParams`]: `id ‖ m_kib ‖ t ‖ p`, each a fixed- +/// width integer under the bincode varint config. Encoded once into +/// every scheme-1 envelope's `Payload::Password` body AND into the +/// scheme-1 AAD, so the two cannot disagree without failing the tag. +#[derive(bincode::Encode, bincode::Decode, Debug, PartialEq, Eq, Clone, Copy)] +pub(crate) struct KdfParamsEncoded { + /// Argon2 algorithm discriminator (only `KDF_ID_ARGON2ID = 1` + /// today; enforced by [`KdfParams::enforce_bounds`]). + pub id: u8, + /// Argon2 memory cost (KiB). Bounded. + pub m_kib: u32, + /// Argon2 time cost (iterations). Bounded. + pub t: u32, + /// Argon2 parallelism. Pinned to 1. + pub p: u32, +} + +impl From for KdfParamsEncoded { + fn from(k: KdfParams) -> Self { + Self { + id: k.id, + m_kib: k.m_kib, + t: k.t, + p: k.p, + } + } +} + +impl TryFrom for KdfParams { + type Error = SecretStoreError; + + /// Convert the wire image into the in-memory [`KdfParams`], gated on + /// [`KdfParams::enforce_bounds`] so an inflated header never + /// reaches `derive_key`. + fn try_from(k: KdfParamsEncoded) -> Result { + let out = KdfParams { + id: k.id, + m_kib: k.m_kib, + t: k.t, + p: k.p, + }; + out.enforce_bounds()?; + Ok(out) + } +} diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs new file mode 100644 index 00000000000..d53ff9bbb96 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/mod.rs @@ -0,0 +1,36 @@ +//! Bincode wire format for the Tier-2 envelope and the three AAD +//! constructions used inside `secrets/`. +//! +//! Every byte that crosses the AEAD seam — the on-disk Tier-2 blob and the +//! AAD bound into each ciphertext — is produced by a `#[derive(bincode:: +//! Encode)]` (or `Encode + Decode`) struct in this module, against the +//! single [`config::WIRE_CONFIG`] constant. A future bincode-config drift +//! is then caught by the golden vector tests in [`envelope::tests`] +//! instead of silently corrupting every stored blob. +//! +//! Module is `pub(crate)` only — the Tier-2 wire format is an +//! implementation detail of [`SecretStore`](super::store::SecretStore); +//! external callers see the unchanged `set_secret` / `get_secret` API. +//! +//! Audit-readable layout: +//! +//! - [`config`] — the single bincode config + domain-tag / version +//! constants every encoder uses. +//! - [`kdf`] — `KdfParamsEncoded`, the wire image of [`KdfParams`]. +//! - [`aad`] — the three AAD structs (`Tier2Aad` / `EntryAad` / +//! `VerifyAad`). +//! - [`envelope`] — the `Envelope` + `Payload` structs plus the +//! `wrap` / `unwrap` API. +//! +//! [`KdfParams`]: super::file::crypto::KdfParams +//! +//! Domain tags include an explicit `-v2` suffix to mark the +//! wire-format break from the pre-bincode hand-rolled layout +//! (`PWSEV-TIER2-AAD-v1` and the implicitly-untagged +//! `secrets/file/format.rs::aad` / `verify_aad` outputs). +#![deny(missing_docs)] + +pub(crate) mod aad; +pub(crate) mod config; +pub(crate) mod envelope; +pub(crate) mod kdf; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs index 064ee3a23a2..7eaca6fe6aa 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -12,11 +12,9 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::persister::{PruneReport, RetentionPolicy}; use crate::sqlite::util::permissions::apply_secure_permissions; -/// Fsync the parent directory of `path` on Unix so the rename entry -/// that materialised `path` is durable across power loss. -/// `persist` only fsyncs the file inode; on most Unix filesystems the -/// dentry update is journalled separately and can be lost on crash -/// without this step. No-op on non-Unix platforms. +/// Fsync `path`'s parent dir on Unix so the rename's dentry update is +/// durable across power loss (`persist` only fsyncs the file inode; the +/// dentry is journalled separately). No-op on non-Unix. #[cfg(unix)] fn fsync_parent_dir(path: &Path) -> Result<(), WalletStorageError> { if let Some(parent) = path.parent() { @@ -69,41 +67,27 @@ pub fn auto_backup_filename(kind: BackupKind) -> String { } } -/// Take an online backup of `src` to `dest`. Uses the -/// `rusqlite::backup::Backup::run_to_completion` page-stepping API -/// so writers aren't blocked. +/// Take an online backup of `src` to `dest` via the page-stepping +/// `Backup::run_to_completion` API so writers aren't blocked. /// /// # Atomicity /// -/// The page-stepping copy runs against a `NamedTempFile` staged in -/// `dest`'s parent directory. The temp is `persist_noclobber`-ed over -/// `dest` only on success — any failure (open, chmod, backup-stream) -/// drops the temp without ever materialising a partial `.db` file at -/// the caller's path. A pre-existing `dest` is rejected atomically by -/// `persist_noclobber` (no TOCTOU window). On Unix, the parent -/// directory is `fsync`-ed after the rename so the dentry update -/// survives power loss; on non-Unix this fsync step is a no-op. +/// The copy is staged in a `NamedTempFile` next to `dest` and +/// `persist_noclobber`-ed over `dest` only on success, so a failure never +/// materialises a partial `.db`. A pre-existing `dest` is rejected +/// atomically (no TOCTOU window), and the parent dir is fsynced afterward. pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { if let Some(parent) = dest.parent() { if !parent.as_os_str().is_empty() && !parent.exists() { std::fs::create_dir_all(parent)?; } } - // Pre-existing-destination rejection happens at the - // `persist_noclobber` site below — that's atomic against the rename - // (no TOCTOU window between `dest.exists()` and persist). The - // CLI's `backup_to(file_path)` still gets the typed - // `BackupDestinationExists` error; auto-backup callers can't trip - // it because the filename carries a unique timestamp suffix. - - // Stage the backup into an unguessable temp file in the same - // directory. Same-FS guarantee makes `persist` an atomic rename. + // Stage in an unguessable temp file in the same dir; the same-FS + // guarantee makes `persist` an atomic rename. let parent = dest.parent().unwrap_or(Path::new(".")); let tmp = tempfile::NamedTempFile::new_in(parent)?; - // Tighten the temp's mode to 0o600 BEFORE persist so the - // destination inherits owner-only permissions via the atomic - // rename. Running chmod after persist would leave a brief - // umask-default window where the destination is observable. + // chmod 0o600 BEFORE persist so the destination inherits owner-only + // mode via the rename; chmod after would leave an observable window. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -111,8 +95,6 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { .set_permissions(std::fs::Permissions::from_mode(0o600))?; } - // Page-stepping copy against the temp. The dest Connection has to - // own its own file handle; rusqlite opens it from a path. let mut backup_conn = crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?; { @@ -120,15 +102,12 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { // 100 pages × 4 KiB = 400 KiB per step on default SQLite page size. backup.run_to_completion(100, Duration::from_millis(5), None)?; } - // Close the backup Connection before persisting so SQLite flushes - // its own WAL/SHM siblings against the temp path — those go away - // with the rename since `persist` atomically renames the temp file. + // Close before persisting so SQLite flushes its WAL/SHM siblings + // against the temp path; the rename then sweeps them away. drop(backup_conn); - // `persist_noclobber` is the atomic check-and-rename — SQLite-free, - // no TOCTOU window between an `exists()` probe and the rename. - // `AlreadyExists` maps to the typed `BackupDestinationExists` for - // the CLI's overwrite-refusal contract. + // Atomic check-and-rename with no TOCTOU window; `AlreadyExists` maps + // to the typed `BackupDestinationExists` overwrite-refusal contract. tmp.persist_noclobber(dest).map_err(|e| { if e.error.kind() == std::io::ErrorKind::AlreadyExists { WalletStorageError::BackupDestinationExists { @@ -138,86 +117,41 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { WalletStorageError::Io(e.error) } })?; - // Fsync the parent directory so the atomic rename's dentry update is - // durable across power loss. On non-Unix this is a no-op. fsync_parent_dir(dest)?; - // Re-tighten in case a non-Unix build (or a future platform-specific - // tweak) needs to refresh sibling perms after SQLite materialised - // them. No-op on Unix where the temp already landed at 0o600. + // Re-tighten for non-Unix builds; no-op on Unix where the temp + // already landed at 0o600. apply_secure_permissions(dest)?; Ok(()) } -/// Restore a `.db` backup over `dest_db_path`. Associated function; -/// caller must guarantee the destination is not held open by this -/// process. The caller (the persister's `restore_from_inner`) handles -/// the pre-restore auto-backup gate. +/// Restore a `.db` backup over `dest_db_path`. The caller must guarantee +/// the destination is not held open by this process and owns the +/// pre-restore auto-backup gate. /// /// # Atomicity /// -/// The restore is staged in two phases bounded by a SQLite-native -/// `BEGIN EXCLUSIVE` transaction on `dest_db_path` (kept across the -/// entire restore body): -/// -/// 1. Open the source read-only; run `PRAGMA integrity_check` + -/// schema-history + max-version sniffs. Any failure here aborts -/// before the live destination is touched. -/// 2. Open a short-lived writer connection on the destination and -/// `BEGIN EXCLUSIVE`. This blocks every other SQLite peer -/// (other `SqlitePersister` handles in this or sibling processes, -/// bare `rusqlite::Connection`s, the CLI) from writing the file -/// until restore completes. Peers waiting for the lock back off -/// via SQLite's own busy_timeout. The lock conn is DROPPED right -/// before `persist` so SQLite releases its file handle on the old -/// inode before the atomic rename takes its place. -/// 3. Stream the source into a `NamedTempFile` in `dest_db_path`'s -/// parent directory; re-run integrity + schema gates against the -/// STAGED bytes (catches a torn `io::copy`); unlink the existing -/// `-wal` / `-shm` siblings; chmod the temp to 0o600; then -/// `persist` over `dest_db_path` as an atomic rename. -/// -/// Either both the main DB and its WAL/SHM siblings are replaced, or -/// — on any pre-persist failure — none of them are touched. The -/// SQLite-native lock prevents a racing peer from committing rows -/// between the staged validation and the rename, which the prior -/// flock-based approach could not do (flock doesn't see SQLite peers). -/// -/// On Unix, the parent directory is `fsync`-ed after the rename so the -/// dentry update is durable across power loss; on non-Unix this is a -/// no-op. +/// Validation runs against the source and again against the STAGED bytes, +/// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks +/// every other SQLite peer (which advisory flock could not). The staged +/// temp is `persist`-ed as an atomic rename only after all gates pass, so +/// either the DB and its WAL/SHM siblings are replaced together or nothing +/// is touched; the parent dir is fsynced afterward. See the numbered steps +/// in the body for the per-phase rationale. /// /// # Lock-release-before-rename trade-off /// -/// The EXCLUSIVE lock is released BEFORE the atomic rename, on -/// purpose. SQLite keeps a kernel file handle on the destination's -/// (old) inode for as long as the lock conn is alive; holding that -/// handle across the rename would leave it pointing at the unlinked -/// old inode while peers opening the new path would race the rename -/// itself (on some filesystems the rename can outright fail). -/// Releasing the lock first lets SQLite drop its old-inode handle -/// before the rename swaps it. -/// -/// The trade-off: a microsecond window opens between lock release and -/// rename in which a peer can acquire its own SQLite lock on the -/// destination's old inode. Any writes it makes within that window -/// land in the old inode, which the rename immediately unlinks — the -/// peer's writes are effectively dropped on the floor (the peer keeps -/// a handle on an inode that no longer has any directory entry; once -/// it closes, the bytes are reclaimed). That is acceptable for the -/// restore contract: callers serialize their own restore intent at -/// the application layer; the window is too short for a non-malicious -/// peer to land more than a transient miss, and a malicious peer -/// cannot escalate beyond losing its own write. Correct file-handle -/// semantics across the rename matter more than absolute lock -/// coverage. +/// The EXCLUSIVE lock is dropped just BEFORE the rename: SQLite holds a +/// kernel handle on the old inode while the lock conn is alive, and +/// holding it across the rename would point it at the unlinked inode and +/// can make the rename fail on some filesystems. The cost is a microsecond +/// window where a peer could write into the old inode the rename then +/// unlinks — its own write is lost, nothing escalates. Correct file-handle +/// semantics across the rename outweigh absolute lock coverage. pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), WalletStorageError> { - // 1. Confirm the source is openable, then run cheap pre-staging - // integrity + schema-history + max-version sniffs against the - // source itself so an obviously-incompatible input fails before - // we stream the whole file into the destination's partition. - // The authoritative schema-history / version gate still re-runs - // on the STAGED copy (step 4) — that's the TOCTOU-safe check - // bound to the exact bytes about to be persisted. + // 1. Cheap early-out: sniff integrity + schema-history + version + + // wallet-identity against the source so an incompatible input fails + // before we stream the whole file. The authoritative, TOCTOU-safe + // gate re-runs on the STAGED bytes (step 4). let src = crate::sqlite::conn::open_conn(src_backup, crate::sqlite::conn::Access::ReadOnly) .map_err(map_source_open_err)?; run_integrity_check(&src, |report| WalletStorageError::IntegrityCheckFailed { @@ -227,30 +161,22 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet return Err(WalletStorageError::SchemaHistoryMissing); } crate::sqlite::migrations::assert_schema_version_supported(&src)?; + crate::sqlite::conn::assert_wallet_application_id(&src)?; + crate::sqlite::migrations::assert_schema_history_well_formed(&src)?; drop(src); - // 2. SQLite-native exclusion. `BEGIN EXCLUSIVE` against a short- - // lived writer connection on the destination blocks every other - // SQLite peer (rusqlite Connection, sibling `SqlitePersister`) - // until the tx is committed/rolled-back or the conn drops. The - // prior flock approach was a false promise: advisory locks - // don't interlock with SQLite's own locking, so a peer mid-write - // could race the swap. The lock conn is dropped (`take()` + end - // of scope) BEFORE `tmp.persist` so SQLite releases its file - // handle on the old inode before the atomic rename — otherwise - // we'd leave a dangling handle on the unlinked inode. + // 2. SQLite-native exclusion: `BEGIN EXCLUSIVE` on a short-lived + // writer conn blocks every other SQLite peer until it drops (which + // advisory flock could not — it doesn't interlock with SQLite). The + // conn is dropped before `persist` (see lock-release trade-off). let mut dest_lock_conn: Option = if dest_db_path.exists() { let conn = crate::sqlite::conn::open_conn(dest_db_path, crate::sqlite::conn::Access::ReadWrite)?; - // Reuse a sensible busy_timeout so peers don't immediately - // surface BUSY without a backoff window. The destination DB - // may not have a persister attached yet (the persister is the - // CALLER), so this conn applies its own. + // The destination has no persister yet (the persister is the + // caller), so apply our own busy_timeout for a backoff window. conn.busy_timeout(std::time::Duration::from_secs(5))?; - // Take EXCLUSIVE up-front by promoting an immediate tx. If a - // peer holds the DB, SQLite waits for busy_timeout then - // returns BUSY — we surface that as `RestoreDestinationLocked` - // so callers keep their existing branch. + // BUSY after busy_timeout becomes `RestoreDestinationLocked` so + // callers keep their existing branch. match conn.execute_batch("BEGIN EXCLUSIVE") { Ok(()) => Some(conn), Err(rusqlite::Error::SqliteFailure(err, _)) @@ -267,19 +193,19 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet None }; - // 3. Stage the source into a NamedTempFile in the destination's - // parent dir (unguessable name, no symlink-plant TOCTOU). + // 3. Stage the source into a NamedTempFile in the destination's parent + // dir (unguessable name, no symlink-plant TOCTOU). let parent = dest_db_path.parent().unwrap_or(Path::new(".")); let mut tmp = tempfile::NamedTempFile::new_in(parent)?; let mut src_file = std::fs::File::open(src_backup)?; std::io::copy(&mut src_file, tmp.as_file_mut())?; tmp.as_file().sync_all()?; - // 4. Re-run integrity_check on the STAGED file before - // persisting. A torn `std::io::copy` or transient FS error - // that escaped `sync_all`'s notice would otherwise persist a - // corrupted database. If the recheck fails, the temp file - // drops naturally and the live destination stays untouched. + // 4. Re-validate the STAGED bytes before persisting: a torn + // `io::copy` that escaped `sync_all` would otherwise persist a + // corrupt DB, and the recheck failing just drops the temp. Bound to + // the staged bytes (not the source handle) so a swap during the + // restore window can't slip a forward-version or foreign DB through. { let staged = crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadOnly) @@ -287,19 +213,17 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet run_integrity_check(&staged, |report| WalletStorageError::IntegrityCheckFailed { report, })?; - // Schema-history presence + max-version gate, bound to the - // staged bytes (not the first source handle) so a swap during - // the restore window can't slip a forward-version DB through. if !crate::sqlite::migrations::has_schema_history(&staged)? { return Err(WalletStorageError::SchemaHistoryMissing); } crate::sqlite::migrations::assert_schema_version_supported(&staged)?; + crate::sqlite::conn::assert_wallet_application_id(&staged)?; + crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?; } - // 5. chmod 600 on the temp BEFORE persist so the destination - // inherits owner-only mode via the atomic rename. Chmodding - // post-persist would leave the new DB live at the destination on - // a chmod failure, contradicting the rolled-back error. + // 5. chmod 0o600 on the temp BEFORE persist so the destination + // inherits owner-only mode via the rename (post-persist chmod could + // fail with the new DB already live). #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -307,33 +231,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet .set_permissions(std::fs::Permissions::from_mode(0o600))?; } - // 6. Release the SQLite-native EXCLUSIVE lock BEFORE touching the - // on-disk WAL/SHM siblings or running the rename. On Windows / - // some FUSE / AV-scanned mounts, `remove_file` against a file - // still held open by another handle on the same process returns - // `PermissionDenied`; on Unix the unlinked inodes remain - // reachable through the open fd but the rename window still - // benefits from a clean close. + // 6. Release the EXCLUSIVE lock before touching siblings/rename: on + // Windows / some FUSE mounts `remove_file` on a still-open file + // returns `PermissionDenied`, and the rename window wants a clean + // close (see lock-release trade-off above). if let Some(conn) = dest_lock_conn.take() { - // Best-effort rollback of the empty EXCLUSIVE tx; an error here - // means SQLite is already in trouble and `drop(conn)` covers - // the rest. Silent because the conn is about to drop anyway. let _ = conn.execute_batch("ROLLBACK"); drop(conn); } - // 7. Atomicity gate: every staged-file validation has now passed - // and our writer handle is closed, so it's safe to clear WAL/SHM - // siblings the replaced DB might have left behind. Doing this - // BEFORE persist ensures that either both the main DB and its - // siblings get replaced/cleared, or — if any earlier check - // failed — none of them are touched. - // - // Build sibling paths via `OsString::push` so non-UTF-8 bytes - // round-trip intact; `remove_file` runs unconditionally and - // `ErrorKind::NotFound` is a silent no-op (closes the `exists()` - // TOCTOU gate). Ordering requires the dest lock conn to be dropped - // first so cross-platform unlink semantics hold. + // 7. Clear any WAL/SHM siblings BEFORE persist so the DB and its + // siblings are replaced atomically (all-or-nothing). Sibling paths + // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is + // a silent no-op. Requires the lock conn dropped first for + // cross-platform unlink semantics. if let Some(file_name) = dest_db_path.file_name() { for ext in ["-wal", "-shm"] { let mut sibling_name = file_name.to_os_string(); @@ -351,28 +262,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet tmp.persist(dest_db_path) .map_err(|e| WalletStorageError::Io(e.error))?; - // 9. Fsync the destination's parent directory so the atomic rename's - // dentry update is durable across power loss (no-op on non-Unix). + // 9. Make the rename's dentry update durable. fsync_parent_dir(dest_db_path)?; - // 10. Re-tighten siblings (SQLite may materialise -wal/-shm on next - // open; this is idempotent at restore-completion time). + // 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). apply_secure_permissions(dest_db_path)?; Ok(()) } -/// Run `PRAGMA integrity_check` and return `Ok(())` when SQLite reports -/// the single row `"ok"`. Any other result becomes a typed -/// `IntegrityCheckFailed` via the caller-supplied builder; an -/// underlying rusqlite error surfaces as `IntegrityCheckRunFailed`. -/// -/// SQLite returns one row per detected problem (capped at -/// `PRAGMA integrity_check(N)`; default 100). All rows are collected -/// and joined with `\n` so the typed report carries every diagnostic -/// instead of just the first line. -/// -/// `pub(crate)` so the persister's open-time A-8 probe shares the -/// same helper rather than reimplementing the report-rendering rule. +/// Run `PRAGMA integrity_check` and return `Ok(())` only on the single +/// row `"ok"`. Any other result becomes a typed `IntegrityCheckFailed` via +/// the caller-supplied builder; an underlying rusqlite error surfaces as +/// `IntegrityCheckRunFailed`. SQLite returns one row per detected problem +/// (default cap 100); all rows are `\n`-joined so the report carries every +/// diagnostic, not just the first. pub(crate) fn run_integrity_check( conn: &Connection, on_failure: F, @@ -392,12 +295,9 @@ where match item { Ok(s) => rows.push(s), Err(e) => { - // Severe corruption can cause SQLite to surface a - // `DatabaseCorrupt` SqliteFailure partway through the - // integrity_check stream. Treat it as end-of-stream - // when we already have diagnostics (the rows we have - // are still valid); if we have NOTHING, surface the - // typed `IntegrityCheckRunFailed`. + // SQLite can surface a `DatabaseCorrupt` partway through + // the stream; treat it as end-of-stream when we already + // have diagnostic rows, else surface it below. trailing_err = Some(e); break; } @@ -458,6 +358,10 @@ pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result = Vec::new(); let mut kept = 0; for (idx, (ts, path)) in files.into_iter().enumerate() { + // `keep_last_n` is a FLOOR: the N newest are always kept even if + // `max_age` would evict them, so an age+count policy can't delete + // every backup. `None` gives no floor (age-only may prune all). + let within_floor = matches!(policy.keep_last_n, Some(n) if idx < n); let pass_count = match policy.keep_last_n { Some(n) => idx < n, None => true, @@ -466,16 +370,14 @@ pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result now.duration_since(ts).map(|d| d <= max).unwrap_or(true), None => true, }; - if pass_count && pass_age { + if within_floor || (pass_count && pass_age) { kept += 1; } else { match std::fs::remove_file(&path) { Ok(()) => removed.push(path), Err(e) => { - // A failed `remove_file` leaves the file on disk, so - // it MUST be counted in `kept`. The invariant - // `kept + removed.len() == total` then holds and - // `failed_removals` is a subset of `kept`. + // A failed removal leaves the file on disk, so count it + // as kept to preserve `kept + removed == total`. failed_removals.push((path, e)); kept += 1; } @@ -559,6 +461,46 @@ mod tests { assert_eq!(secs, 1767225600); } + /// `backup_timestamp` must extract the embedded timestamp (not fall + /// back to mtime) for every `BackupKind` shape, including ones with + /// inner `-`. Guards the `rsplit('-')` coupling against a future label + /// that shifts the trailing token. + #[test] + fn backup_timestamp_extracts_embedded_token_for_all_kinds() { + let want = parse_compact_timestamp("20260101T000000Z").unwrap(); + let real_wallet_id = hex::encode([0xABu8; 32]); + let names = [ + "wallet-20260101T000000Z.db".to_string(), + // Multiple `-` from the from/to version segments. + "pre-migration-1-to-2-20260101T000000Z.db".to_string(), + // 64 lowercase hex chars: hex::encode never emits `-`, so the + // timestamp stays the last `-`-delimited token. + format!("pre-delete-{real_wallet_id}-20260101T000000Z.db"), + "pre-restore-20260101T000000Z.db".to_string(), + ]; + for name in names { + let got = backup_timestamp(Path::new(&name)); + assert_eq!( + got, + Some(want), + "backup_timestamp must parse the embedded token, not fall back to mtime, for {name}" + ); + } + } + + /// A label with a trailing non-timestamp segment must return `None` + /// (prune falls back to mtime) rather than misread a wrong token as a + /// valid time — a detectable regression if a future `BackupKind` + /// appends a `-`-bearing suffix after the timestamp. + #[test] + fn backup_timestamp_rejects_trailing_non_timestamp_segment() { + assert_eq!( + backup_timestamp(Path::new("pre-delete-20260101T000000Z-label.db")), + None, + "a trailing non-timestamp segment must not parse as a timestamp" + ); + } + #[test] fn is_backup_file_recognises_prefixes() { assert!(is_backup_file(Path::new("/tmp/wallet-20260101T000000Z.db"))); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/config.rs b/packages/rs-platform-wallet-storage/src/sqlite/config.rs index 1beb7c2c021..90832bfdc68 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/config.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/config.rs @@ -40,11 +40,19 @@ impl JournalMode { } /// SQLite synchronous mode. +/// +/// `Normal` (the default, paired with WAL) is **app-crash durable**: a +/// committed write survives a process crash but NOT a power loss / OS +/// crash mid-checkpoint, where the last transactions in the WAL can be +/// lost. Choose `Full` for power-loss durability at the cost of an fsync +/// per commit. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Synchronous { Off, + /// WAL default: durable across application crash, not power loss. #[default] Normal, + /// fsync on every commit: durable across power loss / OS crash. Full, Extra, } @@ -109,10 +117,8 @@ impl SqlitePersisterConfig { /// `/backups/auto/` (or `./backups/auto/` if the DB path has no parent). /// -/// Public so the CLI binary (a separate compilation unit) can share the -/// same resolution as the library's `SqlitePersisterConfig::new`. The -/// preferred narrower visibility would be `pub(super)`, but `pub use` -/// re-exports up to the crate root cannot expose a `pub(super)` item. +/// Public so the CLI binary (a separate compilation unit) shares the same +/// resolution as `SqlitePersisterConfig::new`. pub fn default_auto_backup_dir(db_path: &Path) -> PathBuf { let parent = db_path .parent() diff --git a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs index de8e182f208..e136e34a6c5 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs @@ -1,23 +1,40 @@ //! Single connection-open choke-point. //! -//! `PRAGMA foreign_keys` is per-connection and resets to OFF on every -//! open — it is not persisted in the database file, and no compile-time -//! knob in `libsqlite3-sys`'s bundled build forces it on. Enforcement is -//! therefore a runtime discipline: every connection that mutates rows -//! must enable it, and we must *prove* it took, because the pragma -//! silently no-ops on a SQLite built without FK support. -//! -//! Every library connection-open site routes through [`open_conn`] so -//! there is exactly one place that owns flags + FK enforcement. The CLI -//! binary's read-only `peek_schema_version` probe opens directly — it -//! never mutates rows, so FK enforcement is moot, and `open_conn` is -//! `pub(crate)` (not reachable from the separate bin target). +//! `PRAGMA foreign_keys` is per-connection, defaults to OFF on every open, +//! and silently no-ops on a SQLite built without FK support — so every +//! writer connection must enable it and read it back to prove it took. +//! All library opens route through [`open_conn`]; the CLI's read-only +//! `peek_schema_version` probe opens directly (no mutations, and +//! `open_conn` is `pub(crate)`, unreachable from the bin target). use rusqlite::{Connection, OpenFlags}; use std::path::Path; use crate::sqlite::error::WalletStorageError; +/// Magic stamped into the SQLite header `application_id` (offset 68) by +/// `V001__initial`. ASCII `"PLWT"` (Platform Wallet) big-endian. A +/// refinery-versioned DB whose `application_id` does not equal this is a +/// foreign SQLite database, not a wallet-storage DB. +pub(crate) const APPLICATION_ID: i32 = 0x504C_5754; + +/// Read the header `application_id` and assert it equals +/// [`APPLICATION_ID`]. Returns [`WalletStorageError::NotAWalletDb`] on +/// mismatch. The caller decides WHEN to run this — `open()` runs it +/// pre-migration on a refinery-versioned DB; `restore_from` runs it on +/// the staged copy. A brand-new (unmigrated) DB reports `0` and is the +/// caller's responsibility to skip (V001 stamps the real value). +pub(crate) fn assert_wallet_application_id(conn: &Connection) -> Result<(), WalletStorageError> { + let found: i32 = conn.pragma_query_value(None, "application_id", |row| row.get(0))?; + if found != APPLICATION_ID { + return Err(WalletStorageError::NotAWalletDb { + expected: APPLICATION_ID, + found, + }); + } + Ok(()) +} + /// How the opened connection will be used. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Access { @@ -35,10 +52,9 @@ pub(crate) enum Access { /// For [`Access::ReadWrite`], enables `PRAGMA foreign_keys = ON` and /// reads it back, returning [`WalletStorageError::ForeignKeysNotEnforced`] /// if the result is not `1`. For [`Access::ReadOnly`], opens with -/// `SQLITE_OPEN_READ_ONLY` and performs no pragma. URI filename parsing -/// is deliberately not enabled: the crate never constructs `file:` URIs, -/// and leaving it off keeps a path from ever smuggling query parameters -/// (e.g. `?mode=rwc`) that could defeat the read-only intent. +/// `SQLITE_OPEN_READ_ONLY` and performs no pragma. URI filename parsing is +/// deliberately left off so a path can't smuggle query parameters (e.g. +/// `?mode=rwc`) that defeat the read-only intent. pub(crate) fn open_conn(path: &Path, access: Access) -> Result { let conn = match access { Access::ReadWrite => Connection::open(path)?, @@ -77,10 +93,8 @@ mod tests { assert_eq!(on, 1, "read-back must observe FK enforcement is on"); } - /// The hard-error variant the read-back returns when the pragma is a - /// no-op is wired and reachable. We can't build a FK-less SQLite in - /// the bundled build, so assert the typed error renders the intended - /// message rather than truncating the contract to "untestable". + /// The bundled build can't produce a FK-less SQLite, so assert the + /// read-back error variant at least renders its intended message. #[test] fn foreign_keys_not_enforced_variant_renders() { let err = WalletStorageError::ForeignKeysNotEnforced; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index c1767d5b7e3..f78cfd6e731 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -1,18 +1,13 @@ //! Typed errors for `platform-wallet-storage`. //! -//! Every variant carries the upstream error via `#[source]` (or -//! `#[from]` where the conversion is the only thing the trait does), -//! never via a stringified copy. Variants never store user-facing -//! prose — the `#[error("...")]` attribute provides the renderable -//! `Display` form; the typed fields carry diagnostics. +//! Variants carry the upstream error via `#[source]`/`#[from]`, never a +//! stringified copy; the `#[error("...")]` attribute provides `Display`. //! -//! At the `PlatformWalletPersistence` trait boundary, this type -//! converts into `PersistenceError`: `LockPoisoned` keeps its -//! dedicated variant; everything else flows through -//! `PersistenceError::Backend { kind, source }` — `kind` is classified -//! by [`WalletStorageError::persistence_kind`] (Transient / Constraint / -//! Fatal) and `source` carries the boxed typed error so consumers can -//! walk `Error::source()` to the underlying `rusqlite` payload. +//! At the `PlatformWalletPersistence` boundary this converts into +//! `PersistenceError`: `LockPoisoned` keeps its dedicated variant, and +//! everything else flows through `Backend { kind, source }` where `kind` +//! comes from [`WalletStorageError::persistence_kind`] and `source` +//! preserves the typed error for `Error::source()` walking. use std::path::PathBuf; @@ -99,7 +94,7 @@ pub enum WalletStorageError { }, /// `delete_wallet` (or another wallet-id-keyed operation) was - /// called with an id that has no matching `wallet_metadata` row. + /// called with an id that has no matching `wallets` row. #[error("wallet not found: {}", hex::encode(wallet_id))] WalletNotFound { wallet_id: [u8; 32] }, @@ -199,12 +194,20 @@ pub enum WalletStorageError { #[error("identity entry id disagrees with its map key")] IdentityEntryIdMismatch, + /// An `account_registrations` row's typed `(account_type, account_index)` + /// columns disagreed with the decoded `AccountRegistrationEntry` blob. + /// Rejected at decode time so the manifest oracle never hands back an + /// entry that names a different account type or index than the indexed + /// columns it was selected by. + #[error( + "account_registrations entry fields disagree with typed columns \ + (typed columns vs blob account_type or account_index mismatch)" + )] + AccountRegistrationEntryMismatch, + /// An `asset_locks` row's typed-column `(outpoint, account_index)` - /// disagreed with the lifecycle blob's `(out_point, account_index)`. - /// Mirrors `IdentityKeyEntryMismatch` — a torn write, partial - /// migration, or restored corruption that survives the per-row - /// `integrity_check` is still rejected at decode time rather than - /// mis-bucketing the lock under the wrong account. + /// disagreed with the lifecycle blob's. Rejected at decode time rather + /// than mis-bucketing the lock under the wrong account. #[error( "asset_lock entry fields disagree with typed columns \ (typed outpoint={typed_outpoint}, blob outpoint={blob_outpoint}, \ @@ -217,26 +220,15 @@ pub enum WalletStorageError { blob_account_index: u32, }, - /// A blob payload exceeded the configured allocation cap during - /// decode. Surfaced separately from generic [`Self::BlobDecode`] so - /// operators can distinguish a hostile or corrupted oversize blob - /// from a structural decode failure. Defaults to 16 MiB — well - /// above any legitimate per-row payload. + /// A blob exceeded the decode allocation cap (default 16 MiB). + /// Separate from [`Self::BlobDecode`] so operators can distinguish an + /// oversize blob from a structural decode failure. #[error("blob exceeded decode size limit ({len_bytes} bytes > {limit_bytes} byte cap)")] BlobTooLarge { len_bytes: usize, limit_bytes: usize, }, - /// An unspent UTXO named an address absent from - /// `core_derived_addresses`, so its owning account index can't be - /// resolved. Persisting it would mis-file live funds under account - /// 0 with no path back to the real account, so the write is refused. - /// Spent-only placeholder rows tolerate a missing mapping (they're - /// excluded from the unspent set) and do not raise this. - #[error("unspent utxo address {address} is not in core_derived_addresses")] - UtxoAddressNotDerived { address: String }, - /// `PRAGMA foreign_keys = ON` was issued on open but the read-back /// reported the constraint enforcement is still off — the linked /// SQLite build silently ignores the pragma (no FK support compiled @@ -244,6 +236,42 @@ pub enum WalletStorageError { #[error("SQLite foreign-key enforcement could not be enabled on this connection")] ForeignKeysNotEnforced, + /// The requested `journal_mode` read back as a different mode — + /// SQLite silently fell back (e.g. WAL→DELETE on some FUSE mounts). + /// With `synchronous=NORMAL` that risks corruption on power loss, so + /// open hard-errors instead of running downgraded. + #[error("journal_mode {requested} could not be applied (SQLite reports {actual})")] + JournalModeNotApplied { + requested: &'static str, + actual: String, + }, + + /// A pre-existing / restored DB passed `integrity_check` but its + /// `refinery_schema_history` carries a malformed row (non-RFC3339 + /// `applied_on` or non-numeric `checksum`). Probed BEFORE refinery + /// runs so a foreign or corrupted-but-integrity-valid input returns + /// a typed error instead of refinery panicking on the parse. + #[error("refinery_schema_history is malformed: {reason}")] + SchemaHistoryMalformed { reason: &'static str }, + + /// A restore source / opened DB carries a `refinery_schema_history` + /// (so it is refinery-versioned) but its `application_id` header does + /// not match the wallet-storage magic — it is a foreign SQLite DB, + /// not a wallet database. Rejected before it can be persisted over + /// the live wallet DB or migrated in place. + #[error( + "not a platform-wallet-storage database: application_id {found:#010x} != expected {expected:#010x}" + )] + NotAWalletDb { expected: i32, found: i32 }, + + /// A second [`SqlitePersister`](crate::SqlitePersister) `open()` on a + /// path already open in THIS process. Each handle has its own + /// `Mutex` and write buffer, so buffered writes on one are + /// invisible to the other — silent state divergence. Refused until the + /// first persister drops. + #[error("a SqlitePersister is already open on {} in this process", path.display())] + AlreadyOpen { path: PathBuf }, + /// A value couldn't be cast to the database's native i64 /// representation without losing magnitude. #[error("integer overflow casting `{field}` (value={value}) to {target}")] @@ -253,20 +281,11 @@ pub enum WalletStorageError { target: SafeCastTarget, }, - /// Flush failed transiently (e.g. `SQLITE_BUSY` / `SQLITE_LOCKED`) - /// for `wallet_id`. The buffered changeset has been restored — the - /// next `flush(wallet_id)` will retry the same data merged with - /// anything stored in between. Callers should back off and retry - /// rather than dropping state. - /// - /// **Use exponential backoff; do NOT tight-loop on this error** — - /// hammering the persister at full speed turns a transient lock - /// contention into a hot CPU spin and delays whoever holds the - /// lock from releasing it. - /// - /// The variant name `FlushRetryable` is intentionally embedded in - /// the `Display` output so operators grepping production logs can - /// match on the variant directly. + /// Flush failed transiently (e.g. `SQLITE_BUSY` / `SQLITE_LOCKED`) for + /// `wallet_id`. The buffered changeset is restored, so the next + /// `flush(wallet_id)` retries it merged with anything stored in + /// between. Use **exponential backoff** — tight-looping turns lock + /// contention into a CPU spin that starves the lock holder. #[error( "FlushRetryable: flush failed transiently for wallet {}; buffer preserved for retry", hex::encode(wallet_id) @@ -291,31 +310,21 @@ impl From for PersistenceError { } impl WalletStorageError { - /// Construct a typed `BlobDecode` error from a static reason. - /// Used by schema modules that hit a structural decode error - /// (e.g. a 32-byte id column with the wrong length, or trailing - /// bytes after a payload). + /// Construct a `BlobDecode` error from a static reason. Used by schema + /// modules on a structural decode error (wrong-length id, trailing + /// bytes). pub(crate) fn blob_decode(reason: &'static str) -> Self { Self::BlobDecode { reason } } - /// `true` when the underlying failure is safe to retry — the - /// caller should preserve in-flight state and call again. - /// Transient codes: - /// - `DatabaseBusy` / `DatabaseLocked`: contention. - /// - `DiskFull`: operator clears disk space. - /// - `SystemIoFailure`: kernel-level I/O blip (NFS, raid rebuild). - /// - `OutOfMemory`: transient memory pressure. - /// - /// All four classes are recoverable environmental conditions — - /// dropping buffered state on them would be data loss for a - /// problem the operator (or kernel) clears on its own. + /// `true` when the failure is safe to retry — the caller should + /// preserve in-flight state and call again. Transient codes are the + /// recoverable environmental ones: `DatabaseBusy`/`DatabaseLocked` + /// (contention), `DiskFull`, `SystemIoFailure`, `OutOfMemory`. /// - /// The OUTER match on `WalletStorageError` is intentionally - /// wildcard-free: the enum MUST NOT gain `#[non_exhaustive]` so a - /// future variant forces the author to classify it here. The - /// INNER match on `rusqlite::ErrorCode` uses a wildcard because - /// `ErrorCode` is `#[non_exhaustive]` upstream. + /// The OUTER match is intentionally wildcard-free so a future variant + /// forces explicit classification here; the INNER `ErrorCode` match + /// needs a wildcard because that enum is upstream `#[non_exhaustive]`. pub fn is_transient(&self) -> bool { use rusqlite::ErrorCode; match self { @@ -343,13 +352,10 @@ impl WalletStorageError { | Self::AutoBackupDirUnwritable { .. } | Self::WalletNotFound { .. } | Self::WalletIdMismatch { .. } - // TODO(qa): `LockPoisoned` is classified as fatal here, but - // the end-to-end mutex-poison flow has no automated test (a - // panicking thread + join is hard to reproduce - // deterministically). Manual verification only via the - // table-driven test in `tests/sqlite_error_classification`. - // If you change this classification, re-derive - // `handle_flush_error`'s fatal-branch behavior to match. + // TODO(qa): `LockPoisoned` fatal classification has no e2e + // mutex-poison test; verified manually via + // `tests/sqlite_error_classification`. Re-check + // `handle_flush_error`'s fatal branch if you change it. | Self::LockPoisoned | Self::RestoreDestinationLocked | Self::InvalidWalletIdHex { .. } @@ -362,28 +368,27 @@ impl WalletStorageError { | Self::ConsensusCodec { .. } | Self::BackupDestinationExists { .. } | Self::ForeignKeysNotEnforced + | Self::JournalModeNotApplied { .. } + | Self::SchemaHistoryMalformed { .. } + | Self::NotAWalletDb { .. } + | Self::AlreadyOpen { .. } | Self::IdentityKeyEntryMismatch | Self::IdentityEntryIdMismatch + | Self::AccountRegistrationEntryMismatch | Self::AssetLockEntryMismatch { .. } | Self::BlobTooLarge { .. } - | Self::UtxoAddressNotDerived { .. } | Self::IntegerOverflow { .. } => false, } } - /// Trait-boundary classification for the - /// [`PersistenceError::Backend`] kind field. Three classes: + /// Trait-boundary classification for [`PersistenceError::Backend`]: /// - /// - [`PersistenceErrorKind::Transient`] — every variant where - /// [`Self::is_transient`] is `true`. Caller MAY retry. - /// - [`PersistenceErrorKind::Constraint`] — SQL constraint / - /// FK / NOT NULL / UNIQUE / PK / CHECK violations. Schema / - /// integrity failure; caller bug, not infra. + /// - [`PersistenceErrorKind::Transient`] — [`Self::is_transient`] true; caller MAY retry. + /// - [`PersistenceErrorKind::Constraint`] — SQL constraint/FK/CHECK violation; caller bug. /// - [`PersistenceErrorKind::Fatal`] — everything else. /// - /// [`Self::LockPoisoned`] is handled by the `From` impl directly - /// (it maps to [`PersistenceError::LockPoisoned`] rather than - /// flowing through `Backend`). + /// [`Self::LockPoisoned`] never reaches here; the `From` impl maps it + /// straight to [`PersistenceError::LockPoisoned`]. pub fn persistence_kind(&self) -> PersistenceErrorKind { use rusqlite::ErrorCode; if self.is_transient() { @@ -395,10 +400,8 @@ impl WalletStorageError { { PersistenceErrorKind::Constraint } - // Refinery surfaces FK / constraint problems through - // rusqlite; if that path leaks through here the typed - // variant lives in `Self::Migration`, which we leave as - // `Fatal` since a migration failure isn't a caller bug. + // A migration failure (`Self::Migration`) isn't a caller bug, + // so it stays `Fatal` rather than `Constraint`. _ => PersistenceErrorKind::Fatal, } } @@ -442,11 +445,15 @@ impl WalletStorageError { Self::ConsensusCodec { .. } => "consensus_codec", Self::BackupDestinationExists { .. } => "backup_destination_exists", Self::ForeignKeysNotEnforced => "foreign_keys_not_enforced", + Self::JournalModeNotApplied { .. } => "journal_mode_not_applied", + Self::SchemaHistoryMalformed { .. } => "schema_history_malformed", + Self::NotAWalletDb { .. } => "not_a_wallet_db", + Self::AlreadyOpen { .. } => "already_open", Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch", Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch", + Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch", Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::BlobTooLarge { .. } => "blob_too_large", - Self::UtxoAddressNotDerived { .. } => "utxo_address_not_derived", Self::IntegerOverflow { .. } => "integer_overflow", } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/kv.rs b/packages/rs-platform-wallet-storage/src/sqlite/kv.rs index bdf502e5b1a..865b351f269 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/kv.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/kv.rs @@ -1,24 +1,14 @@ //! SQLite-backed [`KvStore`] implementation for [`SqlitePersister`]. //! -//! One dedicated table per [`ObjectId`] variant (`meta_global`, -//! `meta_wallet`, `meta_identity`, `meta_token`, `meta_contact`, -//! `meta_platform_address`). Each table has a composite PRIMARY KEY of -//! its id column(s) plus `key`, so uniqueness comes straight from the PK -//! — no partial indexes, no nullable scope column. None of the tables -//! carry an FK: a `put` succeeds before its parent object exists. The -//! `AFTER DELETE` triggers in `V001__initial.rs` clean a scope's -//! metadata up when its parent is deleted. +//! One dedicated `meta_*` table per [`ObjectId`] variant, each with a +//! composite PRIMARY KEY (id columns + `key`) for uniqueness and no FK +//! (a `put` may precede its parent; `AFTER DELETE` triggers in +//! `V001__initial.rs` reap metadata when the parent is deleted). //! -//! `match scope` resolves each operation to its table and id-column -//! bindings; the SQL body (length-precheck read, upsert, delete, -//! prefix-list) is factored once per op and parameterised by table name -//! and id predicate. Table and column names come from the matched -//! variant — compile-time constants, never caller input — so they are -//! `format!`-spliced; the id *values* are bound parameters. -//! -//! All operations reuse `SqlitePersister`'s single `Mutex` -//! via the crate-private `conn()` accessor; no separate connection is -//! opened. +//! `format!`-spliced table/column names are always compile-time constants +//! from the matched variant, never caller input; id *values* and keys are +//! bound parameters. Operations reuse the persister's single +//! `Mutex` via `conn()`. use rusqlite::{OptionalExtension, ToSql}; @@ -133,10 +123,9 @@ impl From for KvError { WalletStorageError::LockPoisoned => KvError::LockPoisoned, WalletStorageError::Sqlite(e) => KvError::Sqlite(e), other => { - // Other variants don't arise from the `conn()` accessor - // — the accessor either yields `LockPoisoned` or hands - // back the guard. Stuff anything else into `Sqlite` - // via its Display, preserving the source chain. + // `conn()` only ever yields `LockPoisoned` or the guard, + // so other variants are unreachable here; preserve the + // source chain anyway by wrapping into `Sqlite`. KvError::Sqlite(rusqlite::Error::ToSqlConversionFailure(Box::new(other))) } } @@ -152,13 +141,10 @@ impl KvStore for SqlitePersister { // Bind the id values then the key in placeholder order. let mut params: Vec<&dyn ToSql> = sql.id_vals.iter().map(|v| v as &dyn ToSql).collect(); params.push(&key); - // Single-snapshot read: select `length(value)` and `value` in one - // row. The length (column 0) is checked against `MAX_VALUE_LEN` - // before `row.get(1)` materialises the BLOB — rusqlite reads the - // BLOB lazily on that call, so the cap gates the allocation with - // no cross-snapshot TOCTOU window. The inner `Result` carries the - // over-cap length out of the closure without ever touching - // column 1. + // Select `length(value)` and `value` in one row: rusqlite reads + // the BLOB lazily on `row.get(1)`, so checking the length first + // gates the allocation with no TOCTOU window. The inner `Result` + // carries an over-cap length out without touching column 1. let row: Option, usize>> = conn .query_row( &format!("SELECT length(value), value FROM {} {where_key}", sql.table), @@ -185,9 +171,8 @@ impl KvStore for SqlitePersister { fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { validate_key(key)?; - // Cap the value before it reaches SQL so a `put` can never plant - // a row that a later `get` would refuse to materialise. The read - // path gates on the same `MAX_VALUE_LEN`. + // Cap before SQL so a `put` can't plant a row a later `get` would + // refuse to materialise (same `MAX_VALUE_LEN` on both paths). if value.len() > MAX_VALUE_LEN { return Err(KvError::ValueTooLarge { found: value.len(), @@ -196,9 +181,8 @@ impl KvStore for SqlitePersister { } let sql = ScopeSql::resolve(scope); let conn = self.conn().map_err(KvError::from)?; - // Column list / placeholders / conflict target all include the - // id columns ahead of `key`; the plain composite PK is the - // conflict target. Upsert refreshes `updated_at` on overwrite. + // Columns/placeholders/conflict-target put id columns ahead of + // `key` (the composite PK); upsert refreshes `updated_at`. let mut cols: Vec<&str> = sql.id_cols.to_vec(); cols.push("key"); let col_list = cols.join(", "); @@ -289,11 +273,11 @@ mod tests { let wid: WalletId = [id; 32]; let conn = p.lock_conn_for_test(); conn.execute( - "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![wid.as_slice()], ) - .expect("seed wallet_metadata"); + .expect("seed wallets"); wid } @@ -333,8 +317,7 @@ mod tests { #[test] fn global_composite_pk_rejects_duplicate() { - // Direct INSERTs (no ON CONFLICT) must be rejected because the - // composite PRIMARY KEY enforces per-key uniqueness. + // A direct INSERT (no ON CONFLICT) must hit the composite PK. let (p, _tmp) = open_persister(); let conn = p.lock_conn_for_test(); conn.execute( @@ -388,9 +371,8 @@ mod tests { #[test] fn get_rejects_oversized_value_before_materialising() { - // A row larger than MAX_VALUE_LEN (planted via direct SQL — - // bypassing `put`'s cap) must surface as ValueTooLarge instead - // of OOMing the process. + // A row over MAX_VALUE_LEN (planted directly, bypassing `put`) + // must surface ValueTooLarge instead of OOMing. let (p, _tmp) = open_persister(); let oversize = vec![0u8; MAX_VALUE_LEN + 1]; { @@ -442,7 +424,7 @@ mod tests { unreachable!() }; conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![wid.as_slice()], ) .expect("delete wallet"); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index 1163fe62044..d8600c55c6d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -8,9 +8,8 @@ use rusqlite::OptionalExtension; use crate::sqlite::error::WalletStorageError; -// `embed_migrations!` generates a `migrations` module with a `runner()` -// function. The path is relative to the crate root (where `Cargo.toml` -// lives). +// Generates a `migrations` module with `runner()`; path is relative to +// the crate root. refinery::embed_migrations!("./migrations"); /// Apply every pending migration to `conn`. @@ -20,10 +19,8 @@ pub fn run(conn: &mut rusqlite::Connection) -> Result Result { @@ -65,13 +62,15 @@ pub(crate) fn has_schema_history(conn: &rusqlite::Connection) -> Result Result<(), WalletStorageError> { @@ -98,6 +97,41 @@ pub fn assert_schema_version_supported( Ok(()) } +/// Probe `refinery_schema_history` rows BEFORE handing the connection to +/// refinery, which parses `applied_on` (RFC3339) and `checksum` (`u64`) +/// with `unwrap()` — a malformed value would abort the process. Surfaces +/// a typed [`WalletStorageError::SchemaHistoryMalformed`] instead. +/// Quietly succeeds when the table is absent. +pub(crate) fn assert_schema_history_well_formed( + conn: &rusqlite::Connection, +) -> Result<(), WalletStorageError> { + if !has_schema_history(conn)? { + return Ok(()); + } + let mut stmt = conn.prepare("SELECT applied_on, checksum FROM refinery_schema_history")?; + let rows = stmt.query_map([], |row| { + let applied_on: String = row.get(0)?; + let checksum: String = row.get(1)?; + Ok((applied_on, checksum)) + })?; + for row in rows { + let (applied_on, checksum) = row?; + // Validate the way refinery will parse, so a malformed value fails + // typed here rather than panicking inside the runner. + if chrono::DateTime::parse_from_rfc3339(&applied_on).is_err() { + return Err(WalletStorageError::SchemaHistoryMalformed { + reason: "applied_on is not a valid RFC3339 timestamp", + }); + } + if checksum.parse::().is_err() { + return Err(WalletStorageError::SchemaHistoryMalformed { + reason: "checksum is not a valid u64", + }); + } + } + Ok(()) +} + /// List `(version, name)` of every embedded migration. Used by tests and /// the migration-drift hash check. pub fn embedded_migrations() -> Vec<(i32, String)> { @@ -109,8 +143,10 @@ pub fn embedded_migrations() -> Vec<(i32, String)> { } /// SHA-256 over `(version, name)` of every embedded migration in version -/// order. Pinning this in tests catches edits to committed migrations -/// (forbidden by the append-only migration policy). +/// order. Deliberately content-blind: it hashes the migration set's +/// identity, not the SQL bodies, so it catches an added/removed/renamed +/// migration but ignores in-place DDL edits (a content-pinning guard +/// belongs with the schema freeze at release). #[cfg(any(test, feature = "__test-helpers"))] pub fn embedded_migrations_fingerprint() -> [u8; 32] { use sha2::{Digest, Sha256}; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 1cecf885308..cbdc4a3796e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1,7 +1,8 @@ //! [`SqlitePersister`] — the canonical `PlatformWalletPersistence` impl. +use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; @@ -19,44 +20,39 @@ use crate::sqlite::schema; use crate::sqlite::util::permissions::{apply_secure_permissions, precreate_secure}; use crate::sqlite::util::safe_cast; -/// Sub-areas of `ClientStartState` that `load()` does not yet -/// reconstruct (blocked on upstream `Wallet::from_persisted`). +/// Persisted-but-not-rehydrated areas, surfaced in the structured +/// `tracing::info!` summary on every `load()`. /// -/// Surfaced via the structured `tracing::info!` summary on every -/// `load()` (`unimplemented` + `wallets_pending_rehydration` fields). -pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["ClientStartState::wallets"]; +/// - `token_balances`: written by the `token_balances` slot but not read +/// back by `load()` (no reader wired in yet). +/// - `dashpay::overlay`: the `dashpay_profiles` / +/// `dashpay_payments_overlay` tables are a write-only indexed overlay; +/// DashPay state rehydrates from the identities blob, not these tables. +pub(crate) const LOAD_UNIMPLEMENTED: &[&str] = &["token_balances", "dashpay::overlay"]; /// Outcome of a `prune_backups` call. /// -/// Invariant: `kept == total_eligible - removed.len()`. A file is -/// counted as `kept` if it survived the policy (retained-by-rule) OR -/// if `remove_file` failed (`failed_removals` is a subset of `kept`). -/// Either way, the file is still on disk after this call. +/// Invariant: `kept == total_eligible - removed.len()`; a file is `kept` +/// if the policy retained it OR `remove_file` failed (so `failed_removals` +/// is a subset of `kept`). Either way it's still on disk. #[derive(Debug)] pub struct PruneReport { - /// Paths that were unlinked, sorted oldest-first by filename - /// timestamp. + /// Unlinked paths, oldest-first by filename timestamp. pub removed: Vec, - /// Files still on disk after this call. Equals - /// `total_eligible - removed.len()` and includes every - /// `failed_removals` entry — a file that couldn't be unlinked is - /// still on disk and therefore "kept". + /// Count still on disk (`total_eligible - removed.len()`), including + /// every `failed_removals` entry. pub kept: usize, - /// Files we tried to remove but couldn't, paired with the - /// underlying `io::Error`. Returned as part of `Ok(report)` so a - /// partial failure surfaces every removed AND every failed entry - /// — the caller can re-invoke `prune_backups` to retry just the - /// stragglers. + /// Files we couldn't remove, paired with the `io::Error`. Returned in + /// `Ok(report)` so the caller can re-invoke to retry the stragglers. pub failed_removals: Vec<(PathBuf, std::io::Error)>, } /// Retention policy for `prune_backups`. /// -/// **AND-semantics**: a file is kept iff it satisfies BOTH rules. A -/// policy with `keep_last_n = Some(3)` and `max_age = Some(30d)` keeps -/// at most the three newest backups AND only those younger than 30 -/// days — a four-day-old backup that's the fifth-newest is removed. -/// `RetentionPolicy::default()` (both `None`) keeps every file. +/// `keep_last_n` is a **floor**: the N newest backups are always kept even +/// if `max_age` would evict them, so a policy setting both can never delete +/// everything. `keep_last_n = None` gives no floor (age-only may prune +/// all); `default()` (both `None`) keeps every file. #[derive(Debug, Clone, Copy, Default)] pub struct RetentionPolicy { pub keep_last_n: Option, @@ -78,24 +74,51 @@ impl RetentionPolicy { } } +/// Canonicalized paths held by a live [`SqlitePersister`] in this process. +/// Refusing a second in-process open ([`WalletStorageError::AlreadyOpen`]) +/// prevents two handles with independent buffers diverging; cross-process +/// peers are handled by SQLite's own EXCLUSIVE locking. +fn open_path_registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashSet::new())) +} + +/// Insert `path`, returning [`WalletStorageError::AlreadyOpen`] if held. +/// Recover from a poisoned registry mutex rather than wedging every open. +fn register_open_path(path: PathBuf) -> Result<(), WalletStorageError> { + let mut set = open_path_registry() + .lock() + .unwrap_or_else(|p| p.into_inner()); + if set.contains(&path) { + return Err(WalletStorageError::AlreadyOpen { path }); + } + set.insert(path); + Ok(()) +} + +/// Remove `path` from the open-path registry on persister drop. +fn release_open_path(path: &Path) { + let mut set = open_path_registry() + .lock() + .unwrap_or_else(|p| p.into_inner()); + set.remove(path); +} + /// SQLite-backed `PlatformWalletPersistence`. pub struct SqlitePersister { config: SqlitePersisterConfig, - // Single connection serializes reads through the write lock. - // Acceptable for the current workload (per-wallet operations, small - // read footprint); a read-only pool over the same WAL-mode file is + /// Canonicalized DB path held in the process-wide open-path registry. + /// Removed from the registry when this persister drops. + registered_path: PathBuf, + // Single connection serializes reads through the write lock — + // acceptable for the current per-wallet workload; a read-only pool is // the planned follow-up if read contention becomes measurable. conn: Arc>, buffer: Buffer, - /// Test-only one-shot injector for `flush_inner`. Lives on the - /// struct so `force_next_flush_to_fail` can survive across `&self` - /// calls. Production builds keep the slot but never write to it - /// (no public setter outside `#[cfg(any(test, feature = "__test-helpers"))]`). + /// Test-only one-shot injector for `flush_inner`. #[cfg(any(test, feature = "__test-helpers"))] primed_flush_error: Mutex>, - /// Test-only one-shot injection consumed by `delete_wallet`'s - /// pre-flush phase. Lets a test assert the buffer-restore and - /// skip-backup semantics without provoking a real SQL error. + /// Test-only one-shot injector for `delete_wallet`'s pre-flush phase. #[cfg(any(test, feature = "__test-helpers"))] primed_pre_flush_error: Mutex>, } @@ -139,47 +162,41 @@ impl SqlitePersister { } } - // Pre-create the DB file owner-only (0600) with O_EXCL BEFORE - // rusqlite opens it: the file is born at 0600 (no umask window) - // and an attacker-planted symlink at the path makes the create - // fail rather than redirect (no chmod-by-path TOCTOU). A no-op - // when the DB already exists. Brings the SQLite path to parity - // with the secrets-vault file path. + // Pre-create owner-only (0600) with O_EXCL before rusqlite opens: + // no umask window, and a planted symlink makes the create fail + // rather than redirect (no chmod-by-path TOCTOU). No-op if it + // already exists. precreate_secure(&config.path)?; - // Open the connection AND apply pragmas before checking for - // pending migrations so the integrity probe sees the configured - // journal mode and busy timeout. `open_conn` enables foreign-key - // enforcement and asserts the read-back before any write lands. + // Open + apply pragmas before checking pending migrations so the + // integrity probe sees the configured journal mode / busy timeout. let mut conn = crate::sqlite::conn::open_conn(&config.path, crate::sqlite::conn::Access::ReadWrite)?; - // Re-tighten to 0600 on Unix (idempotent on re-open) and sweep - // the WAL/SHM sidecars that SQLite creates after open. + // Re-tighten to 0600 and sweep the WAL/SHM sidecars SQLite created. apply_secure_permissions(&config.path)?; apply_pragmas(&mut conn, &config)?; - // Determine whether `schema_history` exists *before* we run - // migrations — that's the signal for "is this DB pre-existing or - // brand-new?". Errors from the underlying query are propagated, - // not silently treated as "no history". + // `schema_history` presence is the pre-existing-vs-brand-new + // signal; query errors propagate rather than masking as "none". let had_schema_history = crate::sqlite::migrations::has_schema_history(&conn)?; - // Run integrity_check on a pre-existing DB BEFORE migrations alter - // it. Bit-rot or escaped-WAL corruption detected here surfaces as - // the typed `IntegrityCheckFailed` before any schema mutation - // lands. The pre-migration auto-backup snapshots the live state, - // so without this gate a corrupt DB gets backed up and migrated in - // the same pass — making the auto-backup useless for rollback. + // Integrity-check a pre-existing DB BEFORE migrations alter it, + // else a corrupt DB gets backed up and migrated in one pass, + // making the pre-migration auto-backup useless for rollback. if had_schema_history { crate::sqlite::backup::run_integrity_check(&conn, |report| { WalletStorageError::IntegrityCheckFailed { report } })?; } - // Refuse to open a DB produced by a newer binary — refinery's - // run() would no-op on pending_count==0, after which blob decoders - // would see forward-schema bytes. Symmetric with restore_from's - // max-version gate (both call the same helper). + // Refuse a newer-binary DB: refinery's run() no-ops at + // pending==0, after which blob decoders would read forward-schema + // bytes. Then assert the wallet application_id and a well-formed + // schema_history BEFORE refinery, so a foreign or + // corrupted-but-integrity-valid DB fails typed instead of being + // migrated in place or panicking the runner. if had_schema_history { crate::sqlite::migrations::assert_schema_version_supported(&conn)?; + crate::sqlite::conn::assert_wallet_application_id(&conn)?; + crate::sqlite::migrations::assert_schema_history_well_formed(&conn)?; } let pending = crate::sqlite::migrations::embedded_migrations(); let pending_count = if had_schema_history { @@ -199,11 +216,20 @@ impl SqlitePersister { )?; } - // Apply migrations through the typed-error chokepoint. let _report = crate::sqlite::migrations::run_for_open(&mut conn)?; + // Claim the path LAST so a failed open leaves no stale claim; + // canonicalize so symlinks / `.`-segments key the same as a + // sibling open would. + let registered_path = config + .path + .canonicalize() + .unwrap_or_else(|_| config.path.clone()); + register_open_path(registered_path.clone())?; + Ok(Self { config, + registered_path, conn: Arc::new(Mutex::new(conn)), buffer: Buffer::new(), #[cfg(any(test, feature = "__test-helpers"))] @@ -244,11 +270,10 @@ impl SqlitePersister { /// /// # Cross-process rollback caveat /// - /// The pre-restore auto-backup is taken BEFORE the SQLite-native - /// `BEGIN EXCLUSIVE` that guards the restore body. Under concurrent - /// cross-process access the rollback point may therefore miss writes - /// a peer committed between the snapshot and the lock. Serializing - /// restore intent across processes is the caller's responsibility. + /// The pre-restore auto-backup is taken BEFORE the restore body's + /// `BEGIN EXCLUSIVE`, so under concurrent cross-process access the + /// rollback point may miss writes a peer committed in between. Callers + /// must serialize restore intent across processes. pub fn restore_from( dest_db_path: &Path, src_backup: &Path, @@ -281,8 +306,7 @@ impl SqlitePersister { let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { operation: AutoBackupOperation::Restore, })?; - // Open the destination read-only just long enough to - // page-stream a snapshot to disk under auto_backup_dir. + // Open read-only just long enough to snapshot under auto_backup_dir. let dest_conn = crate::sqlite::conn::open_conn( dest_db_path, crate::sqlite::conn::Access::ReadOnly, @@ -295,13 +319,10 @@ impl SqlitePersister { )?; drop(dest_conn); } - // No row-count fingerprint guards the snapshot → EXCLUSIVE - // window: `backup::restore_from` holds a SQLite-native `BEGIN - // EXCLUSIVE` over the whole restore body, so peers that race the - // snapshot are excluded from there on. A count fingerprint would - // miss in-place UPDATEs on single-row tables and give operators - // false confidence; callers needing a quiesced rollback point - // must serialize restore intent at the application layer. + // No row-count fingerprint guards the snapshot→EXCLUSIVE window: + // `backup::restore_from`'s `BEGIN EXCLUSIVE` covers the body, and a + // count would miss in-place UPDATEs and give false confidence. + // Callers needing a quiesced point serialize restore intent. backup::restore_from(dest_db_path, src_backup) } @@ -326,22 +347,17 @@ impl SqlitePersister { /// /// # Cross-process rollback caveat /// - /// The pre-delete auto-backup is taken BEFORE the SQLite-native - /// `BEGIN EXCLUSIVE` that guards the cascade. Under concurrent - /// cross-process access the rollback point may therefore miss writes - /// a peer committed between the snapshot and the lock. Serializing - /// delete intent across processes is the caller's responsibility. + /// The pre-delete auto-backup is taken BEFORE the cascade's + /// `BEGIN EXCLUSIVE`, so under concurrent cross-process access the + /// rollback point may miss writes a peer committed in between. Callers + /// must serialize delete intent across processes. /// /// # Racing stores /// - /// Calls to `store(wallet_id, ...)` for the same wallet while - /// `delete_wallet` is in progress will be **discarded** after the - /// delete commits. The store call may return `Ok(())` (in - /// `FlushMode::Manual` it lands in the buffer), but its data does - /// not survive the delete — the post-commit re-drain inside - /// `delete_wallet` removes any buffered changeset that arrived - /// during the delete window. Synchronize at the caller layer if - /// you need different semantics. + /// A `store(wallet_id, ...)` racing this call is **discarded** after + /// the delete commits — it may return `Ok(())` (Manual mode buffers + /// it) but a post-commit re-drain removes it. Synchronize at the + /// caller layer if you need other semantics. pub fn delete_wallet( &self, wallet_id: WalletId, @@ -369,24 +385,20 @@ impl SqlitePersister { wallet_id: WalletId, skip_backup: bool, ) -> Result { - // Acquire the connection mutex FIRST so concurrent in-process - // `store()` calls block on it. Cross-process peers (other - // rusqlite Connections / sibling `SqlitePersister`s) are excluded - // by `BEGIN EXCLUSIVE` below — the in-process mutex alone never - // gave that guarantee. + // Take the conn mutex first so in-process `store()` blocks; + // cross-process peers are excluded by `BEGIN EXCLUSIVE` below. let mut conn = self.conn()?; - // Drain the buffered changeset so a later flush can't - // resurrect the wallet, and so the wallet counts as existing - // even when its only state is buffered. Hold the drained value - // in `drained_slot` and only consume it AFTER tx.commit(). + // Drain the buffer so a later flush can't resurrect the wallet and + // so a buffer-only wallet still counts as existing. Held in + // `drained_slot` and consumed only after commit. let drained = self.buffer.take_for_flush(&wallet_id)?; let had_buffered = drained.is_some(); let drained_slot: std::cell::Cell> = std::cell::Cell::new(drained); - // Helper: any pre-commit failure must restore the changeset so - // we don't lose pending writes on a delete that didn't happen. + // Any pre-commit failure must restore the changeset so a delete + // that didn't happen doesn't lose pending writes. let restore_buffer = |slot: &std::cell::Cell>| { if let Some(cs) = slot.take() { if let Err(e) = self.buffer.restore(wallet_id, cs) { @@ -400,11 +412,11 @@ impl SqlitePersister { }; let result: Result = (|| { - // Pre-flight existence check on the bare conn (no tx) so - // we don't waste a backup file on an unknown wallet. + // Existence check before backup so we don't snapshot for an + // unknown wallet. let exists_pre_flush = conn .query_row( - "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT 1 FROM wallets WHERE wallet_id = ?1", rusqlite::params![wallet_id.as_slice()], |_| Ok(()), ) @@ -414,36 +426,30 @@ impl SqlitePersister { return Err(WalletStorageError::WalletNotFound { wallet_id }); } - // Test-only injector — force the pre-flush below to fail with - // the primed error without depending on a real SQL failure. - // Keeps the test free of FK-poisoning scaffolding. + // Test-only injector to fail the pre-flush below. #[cfg(any(test, feature = "__test-helpers"))] let primed_pre_flush_error = self.consume_primed_pre_flush_error(); - // Flush the drained buffer to disk BEFORE `run_auto_backup` - // so the pre-delete snapshot includes every pending write. - // Without this the backup captures only already-persisted - // state and rollback-from-backup cannot recover the buffered - // (lost) data. - // - // The flush opens its own EXCLUSIVE tx and commits; - // `run_auto_backup` then runs against the freshly-flushed - // DB. On flush failure we restore the buffer via the outer - // `restore_buffer` helper and abort the delete. - // - // The cascade-side backup runs BEFORE the cascade's - // `BEGIN EXCLUSIVE` because rusqlite's `Backup::new` can't - // establish a backup whose source connection holds an - // active write tx on its own DB — `sqlite3_backup_step` - // would deadlock against the in-flight EXCLUSIVE. + // Flush the drained buffer (its own EXCLUSIVE tx) BEFORE + // `run_auto_backup` so the snapshot includes pending writes; + // otherwise rollback-from-backup can't recover them. The backup + // must precede the cascade's `BEGIN EXCLUSIVE` because + // `Backup::new` deadlocks if the source holds an active write tx. if let Some(cs) = drained_slot.take() { #[cfg(any(test, feature = "__test-helpers"))] if let Some(primed) = primed_pre_flush_error { drained_slot.set(Some(cs)); return Err(primed); } - let pre_flush_tx = - conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; + let pre_flush_tx = match conn + .transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive) + { + Ok(tx) => tx, + Err(e) => { + drained_slot.set(Some(cs)); + return Err(WalletStorageError::Sqlite(e)); + } + }; if let Err(e) = apply_changeset_to_tx(&pre_flush_tx, &wallet_id, &cs) { let _ = pre_flush_tx.rollback(); drained_slot.set(Some(cs)); @@ -455,12 +461,6 @@ impl SqlitePersister { } } - // Concurrent-peer detection relies on the auto-backup taken - // before the cascade plus the SQLite-native `BEGIN EXCLUSIVE` - // below — not a row-count fingerprint, which an in-place - // UPDATE on a single-row table would evade. Pre-flushing - // before the backup ensures the snapshot captures every - // buffered write. let backup_path = if skip_backup { None } else { @@ -472,27 +472,20 @@ impl SqlitePersister { )? }; - // SQLite-native EXCLUSIVE for the cascade window. Excludes - // cross-process peers (other rusqlite Connections, sibling - // `SqlitePersister`s) that would otherwise commit rows for - // `wallet_id` during the cascade. The in-process mutex on - // `conn` alone never gave that guarantee. Peers waiting on - // the lock back off via SQLite's `busy_timeout`. + // EXCLUSIVE for the cascade window excludes cross-process peers + // that the in-process conn mutex can't; they back off via + // `busy_timeout`. let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Exclusive)?; - // Deleting the parent `wallet_metadata` row drives the whole - // cleanup: native `ON DELETE CASCADE` removes every FK-bearing - // per-wallet/per-identity table, and the AFTER DELETE triggers - // broom every wallet/identity-scoped `meta_*` row (parentless - // included). No per-table accounting is needed — the - // cascade-completeness test asserts no row survives. - crate::sqlite::schema::wallet_meta::delete(&tx, &wallet_id)?; + // Deleting the parent `wallets` row drives all cleanup: native + // `ON DELETE CASCADE` clears FK-bearing tables and AFTER DELETE + // triggers reap the `meta_*` rows (the completeness test + // asserts nothing survives). + crate::sqlite::schema::wallets::delete(&tx, &wallet_id)?; tx.commit()?; - // Commit succeeded — drop the original drained changeset. drop(drained_slot.take()); - // Re-drain any changeset a Manual-mode store dropped into the - // buffer while we held conn. The wallet is gone — these - // writes are intentionally void. + // Discard any changeset a Manual-mode store buffered during the + // delete window — the wallet is gone. if let Ok(Some(_late)) = self.buffer.take_for_flush(&wallet_id) { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -511,24 +504,16 @@ impl SqlitePersister { result } - /// Attempt to flush every dirty wallet, regardless of flush mode. + /// Flush every dirty wallet regardless of flush mode — the only way + /// `Manual` writes become durable, and the retry path for transient + /// `Immediate`-mode failures left in the buffer. "Durable" means across + /// application crash (WAL + `synchronous=NORMAL`); use + /// [`Synchronous::Full`](crate::Synchronous) for power-loss durability. /// - /// In `Manual` mode this is the only way pending writes become - /// durable. In `Immediate` mode the buffer is normally empty (each - /// `store` flushes inline) but a transient failure during `store` - /// leaves the changeset in the buffer — `commit_writes` is the - /// retry path that drains those leftovers. - /// - /// Continues past per-wallet failures instead of fails-fast. - /// Each wallet's flush outcome lands on the returned - /// [`CommitReport`]: `succeeded` for durable writes, `failed` for - /// the classified `PersistenceError`. `still_pending` only fills - /// when a `LockPoisoned` short-circuit prevents the loop from - /// attempting the remaining wallets. - /// - /// Returns `Err` ONLY when even enumerating the dirty set fails - /// (e.g. the buffer mutex is poisoned). Once the loop starts, - /// every dirty wallet has a slot in the report. + /// Continues past per-wallet failures: each outcome lands on the + /// [`CommitReport`] (`succeeded` / `failed`), and `still_pending` fills + /// only when a `LockPoisoned` short-circuit skips the rest. Returns + /// `Err` only when enumerating the dirty set itself fails. pub fn commit_writes(&self) -> Result { self.commit_writes_inner() } @@ -539,12 +524,9 @@ impl SqlitePersister { failed: Vec::new(), still_pending: Vec::new(), }; - // Even in `FlushMode::Immediate` the buffer can be non-empty: - // a transient failure during `store()` re-merges the changeset - // back into the buffer via `handle_flush_error`. The retry path - // — `commit_writes()` — has to drain that leftover regardless - // of flush mode, otherwise transient-failure data sits there - // until the next per-wallet `store` happens to retry it. + // Even in `Immediate` mode the buffer can be non-empty: a transient + // `store()` failure re-merges the changeset, and only this drains + // it regardless of flush mode. let dirty = self .buffer .dirty_wallets() @@ -554,10 +536,8 @@ impl SqlitePersister { match self.flush_inner(&id) { Ok(()) => report.succeeded.push(id), Err(PersistenceError::LockPoisoned) => { - // Mutex is gone — no point hammering the remaining - // wallets. Record this one as failed and shovel the - // rest into still_pending so the caller knows what - // was never attempted. + // Mutex is gone; record this as failed and the rest as + // never-attempted instead of hammering them. report.failed.push((id, PersistenceError::LockPoisoned)); report.still_pending.extend(iter); return Ok(report); @@ -575,18 +555,10 @@ impl SqlitePersister { .map_err(|_| WalletStorageError::LockPoisoned) } - // The feature is named with Cargo's `__` prefix convention to - // signal "not part of the public API; downstream MUST NOT enable - // it" (https://doc.rust-lang.org/cargo/reference/features.html). - // The methods themselves are `#[doc(hidden)]` so they don't show - // up on docs.rs even when the feature is on. - /// Test-only: borrow the write connection. - /// - /// Tests use this to seed `wallet_metadata` rows directly, run - /// SELECTs against tables that aren't part of the public surface, - /// or probe `PRAGMA foreign_keys` / `PRAGMA journal_mode`. Gated - /// behind `cfg(test)` and the `__test-helpers` feature — - /// downstream crates MUST NOT enable it. + // The `__test-helpers` feature uses Cargo's `__` prefix convention: + // not public API, downstream MUST NOT enable it. + /// Test-only: borrow the write connection to seed rows or probe + /// non-public tables/pragmas. Downstream MUST NOT enable the feature. #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn lock_conn_for_test(&self) -> MutexGuard<'_, Connection> { @@ -608,8 +580,7 @@ impl SqlitePersister { .map_err(PersistenceError::from)?; let Some(cs) = cs else { return Ok(()) }; - // Test-only injector: surface a primed failure without ever - // touching SQL so take/restore semantics are exercised end-to-end. + // Test-only injector: surface a primed failure without touching SQL. #[cfg(any(test, feature = "__test-helpers"))] if let Some(injected) = self.consume_primed_flush_error() { return self.handle_flush_error(wallet_id, cs, injected); @@ -637,15 +608,11 @@ impl SqlitePersister { } /// Classify the failure: transient errors restore the buffer and - /// surface as `FlushRetryable`; everything else drops the - /// changeset and returns the original variant. + /// surface as `FlushRetryable`; everything else drops the changeset + /// and returns the original variant. // - // TODO(qa): the fatal branch below covers `LockPoisoned`, but no - // end-to-end mutex-poison test exists (a panicking thread plus a join - // is hard to reproduce deterministically). It is verified by hand via - // `Mutex::lock` failure injection at the typed-error layer; anyone - // touching the classification policy or this branch must reconfirm - // by hand. + // TODO(qa): the fatal `LockPoisoned` branch has no e2e mutex-poison + // test; verified by hand — reconfirm if you touch the classification. fn handle_flush_error( &self, wallet_id: &WalletId, @@ -655,9 +622,8 @@ impl SqlitePersister { let field_count = populated_field_count(&cs); let kind = err.error_kind_str(); if err.is_transient() { - // A failed restore (e.g. poisoned buffer mutex) means the - // buffered changeset is gone — that is itself fatal and - // must surface, not be masked by the transient signal. + // A failed restore loses the changeset — itself fatal, so + // surface it instead of the transient signal. if let Err(restore_err) = self.buffer.restore(*wallet_id, cs) { tracing::error!( wallet_id = %hex::encode(wallet_id), @@ -667,16 +633,13 @@ impl SqlitePersister { ); return Err(PersistenceError::from(restore_err)); } - // Narrow the error to its rusqlite source — only - // `Sqlite(SqliteFailure(BUSY|LOCKED, _))` qualifies for - // surfacing as `FlushRetryable`. + // Narrow to the rusqlite source for `FlushRetryable`. let source = match err { WalletStorageError::Sqlite(rusq) => rusq, WalletStorageError::FlushRetryable { source, .. } => source, other => { - // Defensive: classifier said "transient" but source - // isn't rusqlite. Surface unwrapped — better than - // lying about the source type. + // Defensive: "transient" but non-rusqlite source — + // surface raw rather than mislabel the source type. tracing::warn!( wallet_id = %hex::encode(wallet_id), error_kind = kind, @@ -703,16 +666,13 @@ impl SqlitePersister { dropped_field_count = field_count, "flush failed fatally — buffer wiped" ); - // `cs` dropped here. drop(cs); Err(PersistenceError::from(err)) } } - /// Test-only: arm a one-shot injection consumed by the next - /// `flush_inner`. Higher-level than `FailingConnection`; useful - /// when the test doesn't care which SQL error fires, only how the - /// wrapper reacts. + /// Test-only: arm a one-shot injection for the next `flush_inner`, + /// for tests that care only how the wrapper reacts to the error. #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn force_next_flush_to_fail(&self, err: WalletStorageError) { @@ -728,9 +688,7 @@ impl SqlitePersister { } /// Test-only: arm a one-shot pre-flush failure for the next - /// `delete_wallet` call. The injection fires only when there is - /// a drained buffered changeset to flush — i.e. when `delete_wallet` - /// actually exercises the pre-flush branch. + /// `delete_wallet`; fires only when there's a drained changeset to flush. #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn force_next_pre_flush_to_fail(&self, err: WalletStorageError) { @@ -748,9 +706,8 @@ impl SqlitePersister { .take() } - /// Test-only: probe whether the wallet has a buffered changeset. - /// Used to assert the buffer survives a failed pre-flush without - /// consuming it. + /// Test-only: whether the wallet has a buffered changeset (asserts the + /// buffer survives a failed pre-flush without consuming it). #[doc(hidden)] #[cfg(any(test, feature = "__test-helpers"))] pub fn buffer_has_changeset_for_test(&self, wallet_id: &WalletId) -> bool { @@ -761,22 +718,20 @@ impl SqlitePersister { } } -/// When a `Manual`-mode persister is dropped while dirty wallets remain, -/// log a structured `tracing::error!` so the silent-data-loss footgun -/// (the buffer dies with the persister) surfaces in operator logs. -/// -/// We intentionally do NOT auto-flush from `Drop` — `flush_inner` -/// can fail and `Drop` cannot propagate errors, so a swallow there -/// would be a worse failure mode than the loud log. `Immediate`-mode -/// persisters are durable on every `store` so they never trip this. +/// On drop of a `Manual`-mode persister with dirty wallets, log an error +/// so the silent-data-loss footgun surfaces. We do NOT auto-flush from +/// `Drop`: `flush_inner` can fail and `Drop` can't propagate, so swallowing +/// would be worse than a loud log. `Immediate` mode never trips this. impl Drop for SqlitePersister { fn drop(&mut self) { + // Release the path claim FIRST so it happens regardless of flush + // mode (the warning below early-returns for Immediate). + release_open_path(&self.registered_path); if self.config.flush_mode != FlushMode::Manual { return; } - // `dirty_wallets` only fails on a poisoned buffer mutex. A - // poisoned mutex on Drop already means the process is wedged; - // we still try to surface the lost state where we can. + // `dirty_wallets` only fails on a poisoned buffer mutex; surface + // the lost state where we can. let dirty = match self.buffer.dirty_wallets() { Ok(d) => d, Err(e) => { @@ -791,11 +746,9 @@ impl Drop for SqlitePersister { if dirty.is_empty() { return; } - // `take_for_flush` mutates the buffer (drains the changeset). - // That is intentional here: the persister is being dropped, no - // future caller can observe the buffer, and `populated_field_count` - // needs to inspect the changeset to produce the diagnostic. Do - // NOT treat `impl Drop` as side-effect-free. + // `take_for_flush` drains the buffer — intentional in `Drop`: no + // future caller can observe it, and we need the changeset to count + // fields for the diagnostic. let total_fields: usize = dirty .iter() .filter_map(|id| { @@ -819,16 +772,14 @@ impl PlatformWalletPersistence for SqlitePersister { /// Merge `changeset` into the per-wallet buffer. /// /// Durability matrix: - /// - In [`FlushMode::Immediate`] the call is **durable on `Ok`** — - /// one SQLite transaction wraps every populated per-table apply, - /// so either all sub-changesets land or none do. A transient - /// failure restores the buffer and surfaces - /// [`WalletStorageError::FlushRetryable`] wrapped in - /// `PersistenceError::Backend`. - /// - In [`FlushMode::Manual`] the call only merges into the - /// in-memory buffer. Durability requires - /// [`flush`](Self::flush) (per-wallet) or - /// [`commit_writes`](Self::commit_writes) (every dirty wallet). + /// - [`FlushMode::Immediate`]: on `Ok`, durable across application + /// crash — one transaction wraps every per-table apply (all-or- + /// nothing). A transient failure restores the buffer and surfaces + /// [`WalletStorageError::FlushRetryable`]. Use + /// [`Synchronous::Full`](crate::Synchronous) for power-loss durability. + /// - [`FlushMode::Manual`]: only merges into the buffer; durability + /// needs [`flush`](Self::flush) or + /// [`commit_writes`](Self::commit_writes). fn store( &self, wallet_id: WalletId, @@ -849,27 +800,24 @@ impl PlatformWalletPersistence for SqlitePersister { /// Load every wallet's start-state from disk. /// - /// Populates `platform_addresses` per wallet. `wallets` stays empty - /// pending an upstream `key_wallet::Wallet::from_persisted` - /// constructor — the count of wallets that *would* be rehydrated is - /// surfaced as the structured field `wallets_pending_rehydration` - /// on the `tracing::info!` summary. + /// Populates `platform_addresses` and the keyless per-wallet `wallets` + /// payload (network, birth height, account manifest, core state, + /// identities, `Consumed`-filtered asset locks). Carries **no** `Wallet` + /// or key material — the manager rebuilds each wallet watch-only and + /// signs later on demand. The `tracing::info!` summary reports + /// `wallets_rehydrated`. /// - /// Fail-hard: any row that fails to decode (or carries a malformed - /// `wallet_id`) aborts the whole load with a typed - /// [`WalletStorageError`]. Corruption is never silently skipped. + /// Fail-hard: any row that fails to decode (or has a malformed + /// `wallet_id`) aborts the whole load — corruption is never skipped. /// - /// **Query budget.** Constant w.r.t. wallet count: one `SELECT` over - /// `wallet_metadata` for the wallet-id list plus a fixed set of - /// grouped scans (sync state, addresses, platform-payment - /// registrations), not a per-wallet fan-out. + /// **Query budget.** Constant w.r.t. wallet count: one `SELECT` for the + /// id list plus a fixed set of grouped scans, not a per-wallet fan-out. /// /// # Concurrency /// - /// Holds the connection mutex for the duration of the read. - /// Concurrent `store` / `flush` / `delete_wallet` calls block - /// until `load` returns. Intended for one-shot use at process - /// startup, not interleaved with the hot write path. + /// Holds the connection mutex for the whole read, so concurrent + /// `store` / `flush` / `delete_wallet` block until it returns. Intended + /// for one-shot startup use, not the hot write path. /// /// # Examples /// @@ -907,23 +855,14 @@ impl PlatformWalletPersistence for SqlitePersister { /// # } /// ``` fn load(&self) -> Result { - // TODO: repopulate ClientStartState.wallets. The - // identity/contacts/asset-lock readers exist, but the - // `Wallet::from_persisted` wiring lands with the rehydration work; - // until then `load()` only rebuilds `platform_addresses` and emits - // a structured-log summary so operators see the gap. let conn = self.conn().map_err(PersistenceError::from)?; let mut state = ClientStartState::default(); let addrs_all = schema::platform_addrs::load_all(&conn).map_err(PersistenceError::from)?; - let wallets_seen = addrs_all.len(); let mut addresses_loaded: usize = 0; - for (wallet_id, (addrs, count)) in addrs_all { - // Omit a wallet that carries no platform state at all: no - // per-account registrations, no addresses, and all sync - // watermarks zero. Such a wallet contributes nothing to a - // restored provider. + // Skip a wallet with no platform state at all (no addresses, + // no registrations, all sync watermarks zero). if count > 0 || !addrs.per_account.is_empty() || addrs.sync_height > 0 @@ -935,11 +874,61 @@ impl PlatformWalletPersistence for SqlitePersister { } } + // Per-wallet keyless rehydration payload; the manager rebuilds each + // wallet watch-only and derives signing keys later on demand. + let wallet_ids = schema::wallets::list_ids(&conn).map_err(PersistenceError::from)?; + let wallets_seen = wallet_ids.len(); + for wallet_id in wallet_ids { + let (network_str, birth_height) = schema::wallets::fetch(&conn, &wallet_id) + .map_err(PersistenceError::from)? + .ok_or_else(|| { + PersistenceError::backend(format!( + "wallets row vanished mid-load for {}", + hex::encode(wallet_id) + )) + })?; + let network = schema::wallets::parse_network(&network_str).ok_or_else(|| { + PersistenceError::backend(format!( + "unknown persisted network {:?} for wallet {}", + network_str, + hex::encode(wallet_id) + )) + })?; + + let account_manifest = + schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?; + let core_state = schema::core_state::load_state(&conn, &wallet_id, network) + .map_err(PersistenceError::from)?; + let identity_manager = schema::identities::load_state(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let unused_asset_locks = schema::asset_locks::load_unconsumed(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let contacts = schema::contacts::load_changeset(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + + state.wallets.insert( + wallet_id, + platform_wallet::changeset::ClientWalletStartState { + network, + birth_height, + account_manifest, + core_state, + identity_manager, + unused_asset_locks, + contacts, + identity_keys, + }, + ); + } + let wallets_rehydrated = state.wallets.len(); + tracing::info!( wallets_seen, addresses_loaded, - wallets_rehydrated = 0usize, - wallets_pending_rehydration = wallets_seen, + wallets_rehydrated, + wallets_pending_rehydration = 0usize, unimplemented = ?LOAD_UNIMPLEMENTED, "load() summary" ); @@ -959,14 +948,10 @@ impl PlatformWalletPersistence for SqlitePersister { } } -// ----- Helpers ----- - -/// Count of top-level slots that carry any data. Feeds the persister's -/// `restored_field_count` / `dropped_field_count` tracing fields so -/// operators can see how much was kept or dropped on a flush retry / -/// fatal failure. Computed here from the public `PlatformWalletChangeSet` -/// fields + `Merge::is_empty()` so no storage-only helper leaks into -/// the `rs-platform-wallet` public API. +/// Count of top-level changeset slots carrying data, for the +/// `restored_field_count` / `dropped_field_count` tracing fields. Computed +/// from the public fields so no storage-only helper leaks into the +/// `rs-platform-wallet` API. fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize { [ cs.core.is_empty(), @@ -995,10 +980,8 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr reason: "synchronous=Off is rejected (data-loss footgun)", }); } - // `journal_mode=Memory` keeps the rollback journal in RAM and - // `journal_mode=Off` disables it outright. Either turns crash- - // safety into a coin flip for a wallet DB — reject loudly instead - // of silently corrupting on the next power loss. + // `journal_mode` Memory/Off keeps no on-disk rollback journal, making + // a wallet DB crash-unsafe — reject loudly. match config.journal_mode { crate::sqlite::config::JournalMode::Memory => { return Err(WalletStorageError::ConfigInvalid { @@ -1012,10 +995,8 @@ fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageEr } _ => {} } - // `busy_timeout=0` makes contended writers fail-fast with BUSY - // instead of waiting — non-fatal, but the operator almost certainly - // didn't mean it. Warn rather than reject because a few tests - // legitimately want the fail-fast behaviour. + // `busy_timeout=0` makes contended writers fail-fast with BUSY; + // warn (not reject) since a few tests legitimately want that. if config.busy_timeout.is_zero() { tracing::warn!( "SqlitePersisterConfig.busy_timeout=0; contended writers will return BUSY \ @@ -1029,9 +1010,19 @@ fn apply_pragmas( conn: &mut Connection, config: &SqlitePersisterConfig, ) -> Result<(), WalletStorageError> { - // `foreign_keys` is enabled + read-back-asserted in - // `crate::sqlite::conn::open_conn`, the single open choke-point. + // `foreign_keys` is enabled + read-back-asserted in `open_conn`. conn.pragma_update(None, "journal_mode", config.journal_mode.pragma_value())?; + // Read `journal_mode` back: `pragma_update` doesn't error when SQLite + // silently falls back (e.g. WAL→DELETE on FUSE), which with + // synchronous=NORMAL risks corruption on power loss. + let applied_journal: String = + conn.pragma_query_value(None, "journal_mode", |row| row.get(0))?; + if !applied_journal.eq_ignore_ascii_case(config.journal_mode.pragma_value()) { + return Err(WalletStorageError::JournalModeNotApplied { + requested: config.journal_mode.pragma_value(), + actual: applied_journal, + }); + } conn.pragma_update(None, "synchronous", config.synchronous.pragma_value())?; let ms = safe_cast::u64_to_i64( "busy_timeout_ms", @@ -1041,25 +1032,25 @@ fn apply_pragmas( Ok(()) } -/// Apply every populated sub-changeset of `cs` against the supplied -/// SQLite transaction. Does not commit; the caller owns the tx -/// lifecycle. Splitting this out from `write_changeset_in_one_tx` -/// lets `delete_wallet_inner` flush a drained buffer into a bespoke -/// pre-delete tx without re-opening the connection. +/// Apply every populated sub-changeset of `cs` against `tx` without +/// committing (caller owns the tx). Separate from +/// `write_changeset_in_one_tx` so `delete_wallet_inner` can flush a drained +/// buffer into its own pre-delete tx. fn apply_changeset_to_tx( tx: &rusqlite::Transaction<'_>, wallet_id: &WalletId, cs: &PlatformWalletChangeSet, ) -> Result<(), WalletStorageError> { if let Some(meta) = cs.wallet_metadata.as_ref() { - schema::wallet_meta::upsert(tx, wallet_id, meta)?; + schema::wallets::upsert(tx, wallet_id, meta)?; } if !cs.account_registrations.is_empty() { schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?; } - if !cs.account_address_pools.is_empty() { - schema::accounts::apply_pools(tx, wallet_id, &cs.account_address_pools)?; - } + // `account_address_pools` is intentionally NOT applied: UTXO attribution + // is hardcoded to the default account (index 0) in `core_state`, so the + // pool snapshot is no longer a storage input. The changeset field is kept + // for API stability and still feeds non-storage consumers. if let Some(core) = cs.core.as_ref() { schema::core_state::apply(tx, wallet_id, core)?; } @@ -1120,12 +1111,9 @@ fn ensure_dir(dir: &Path) -> Result<(), WalletStorageError> { } })?; } - // Best-effort writability probe via `NamedTempFile` (unguessable - // name, no race against concurrent persister opens). This is TOCTOU - // by construction — the dir CAN flip to unwritable between the probe - // and `backup::run_to` below — but the real write has its own error - // path, so the worst case is the operator gets the typed error from - // the actual backup attempt instead of this fast-fail probe. + // Fast-fail writability probe. TOCTOU by construction (the dir can flip + // before `run_to`), but the real write has its own error path, so the + // worst case is a later typed error instead of this early one. match tempfile::NamedTempFile::new_in(dir) { Ok(_probe) => Ok(()), Err(source) => Err(WalletStorageError::AutoBackupDirUnwritable { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 04e251496f0..9042f243767 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -1,15 +1,21 @@ -//! `account_registrations` + `account_address_pools` writers + readers. +//! `account_registrations` writer + keyless reader (platform-payment +//! registrations and the rehydration account-manifest oracle). use std::collections::BTreeMap; use key_wallet::bip32::ExtendedPubKey; use rusqlite::{params, Connection, Transaction}; -use platform_wallet::changeset::{AccountAddressPoolEntry, AccountRegistrationEntry}; +use platform_wallet::changeset::AccountRegistrationEntry; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: the account-registration xpub manifest reaching +// the `account_xpub_bytes` blob column. +impl_persistable_blob!(AccountRegistrationEntry); /// Decoded `platform_payment` account registration: the DIP-17 account /// index and its extended public key, recovered from the bincode-serde @@ -22,20 +28,16 @@ fn decode_platform_payment_row( account_index: i64, xpub_bytes: &[u8], ) -> Result { - let account_index = - u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { - field: "account_registrations.account_index", - value: account_index as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - })?; + let account_index = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.account_index", + account_index, + )?; let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?; Ok((account_index, entry.account_xpub)) } -/// Every `platform_payment` account registration for one wallet, decoded -/// into `(account_index, xpub)`. The xpub is recovered from the -/// bincode-serde `AccountRegistrationEntry` `apply_registrations` writes -/// into `account_xpub_bytes`. +/// Every `platform_payment` registration for one wallet, decoded into +/// `(account_index, xpub)`. #[cfg(any(test, feature = "__test-helpers"))] pub(crate) fn list_platform_payment_registrations( conn: &Connection, @@ -99,10 +101,8 @@ pub fn apply_registrations( if entries.is_empty() { return Ok(()); } - // `account_xpub_bytes` carries the bincode-serde encoded - // `AccountRegistrationEntry` (xpub + account_type). The - // separate `account_type` / `account_index` columns mirror - // the entry for direct SQL lookups. + // `account_xpub_bytes` holds the encoded `AccountRegistrationEntry`; the + // separate `account_type` / `account_index` columns mirror it for SQL. let mut stmt = tx.prepare_cached( "INSERT INTO account_registrations \ (wallet_id, account_type, account_index, account_xpub_bytes) \ @@ -124,52 +124,64 @@ pub fn apply_registrations( Ok(()) } -pub fn apply_pools( - tx: &Transaction<'_>, +/// Read every `account_registrations` row for `wallet_id` into a keyless +/// [`AccountRegistrationEntry`] manifest — the rehydration account-set oracle +/// (which accounts to re-derive + the per-account xpubs the wrong-account gate +/// checks). PUBLIC material only (xpub + account type), no `Wallet` minted. +/// Ordered by `(account_type, account_index)` for determinism; a row that +/// fails to decode is a hard [`WalletStorageError`]. +pub fn load_state( + conn: &Connection, wallet_id: &WalletId, - entries: &[AccountAddressPoolEntry], -) -> Result<(), WalletStorageError> { - if entries.is_empty() { - return Ok(()); - } - let mut stmt = tx.prepare_cached( - "INSERT INTO account_address_pools \ - (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5) \ - ON CONFLICT(wallet_id, account_type, account_index, pool_type) DO UPDATE SET \ - snapshot_blob = excluded.snapshot_blob", +) -> Result, WalletStorageError> { + // Select typed columns alongside the blob so we can cross-check them + // against the decoded entry — a row whose blob disagrees with its indexed + // columns is a sign of corruption or a schema bug and must be rejected + // rather than silently mis-bucketed. + let mut stmt = conn.prepare( + "SELECT account_type, account_index, account_xpub_bytes FROM account_registrations \ + WHERE wallet_id = ?1 ORDER BY account_type, account_index", )?; - for entry in entries { - let account_type = account_type_db_label(&entry.account_type); - let account_index = account_index(&entry.account_type); - let pool_type = pool_type_db_label(&entry.pool_type); - let payload = blob::encode(entry)?; - stmt.execute(params![ - wallet_id.as_slice(), - account_type, - i64::from(account_index), - pool_type, - payload, - ])?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + Ok(( + row.get::<_, String>(0)?, // account_type TEXT + row.get::<_, i64>(1)?, // account_index INTEGER + row.get::<_, Vec>(2)?, // account_xpub_bytes BLOB + )) + })?; + let mut out = Vec::new(); + for r in rows { + let (typed_account_type, typed_account_index, payload) = r?; + let entry = blob::decode::(&payload)?; + // Cross-check the typed indexed columns vs the decoded blob so + // a corruption that passes `PRAGMA integrity_check` is still + // caught here rather than feeding a wrong account to the oracle. + let blob_type = account_type_db_label(&entry.account_type); + let blob_index = account_index(&entry.account_type); + let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.account_index", + typed_account_index, + )?; + if blob_type != typed_account_type.as_str() || blob_index != typed_index { + return Err(WalletStorageError::AccountRegistrationEntryMismatch); + } + out.push(entry); } - Ok(()) + Ok(out) } -/// Single source of truth for the `account_type` TEXT-column domain -/// across `account_registrations`, `account_address_pools`, and -/// `core_derived_addresses`. +/// Source of truth for the `account_registrations.account_type` TEXT domain, +/// mirroring [`key_wallet::account::AccountType`]. +/// `migrations/V001__initial.rs` interpolates it into the table's +/// `CHECK (account_type IN (...))`; `account_type_labels_match_enum` keeps it +/// in sync with [`account_type_db_label`]. /// -/// Mirrors every variant of [`key_wallet::account::AccountType`] -/// (writer side: [`account_type_db_label`]). The migration in -/// `migrations/V001__initial.rs` interpolates this array into the -/// `CHECK (account_type IN (...))` clause on each of those tables, so -/// an unknown label is rejected at insert time rather than landing as -/// silent garbage. The `account_type_labels_match_enum` unit test -/// below enforces set-equality between this array and the writer's -/// output — drift (a renamed/added variant) becomes a failing test, -/// not a runtime divergence between Rust and SQLite. +/// `Standard` maps to two distinct labels by `StandardAccountType` variant +/// (`"standard_bip44"` / `"standard_bip32"`) so BIP44 and BIP32 standard +/// accounts with the same index never collide on their shared PK columns. pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ - "standard", + "standard_bip44", + "standard_bip32", "coinjoin", "identity_registration", "identity_topup", @@ -186,30 +198,23 @@ pub(crate) const ACCOUNT_TYPE_LABELS: &[&str] = &[ "platform_payment", ]; -/// Single source of truth for the `account_address_pools.pool_type` -/// TEXT-column domain. -/// -/// Mirrors every variant of -/// [`key_wallet::managed_account::address_pool::AddressPoolType`] -/// (writer side: [`pool_type_db_label`]). See [`ACCOUNT_TYPE_LABELS`] -/// for the broader rationale and the parity-test contract. -pub(crate) const POOL_TYPE_LABELS: &[&str] = &["external", "internal", "absent", "absent_hardened"]; - -/// Stable database label for an `AccountType` variant. +/// Stable database label for an `AccountType` variant (the `Debug` impl is not +/// a stable format; this match is the contract). An added upstream variant +/// fails this match's exhaustiveness check at compile time. /// -/// Used for the `account_type` text column on `account_registrations`, -/// `account_address_pools`, and `core_derived_addresses`. The -/// `Debug` impl on `AccountType` is NOT a stable serialisation -/// format; this match is the contract. Variants identical in -/// label are distinguished by the companion `account_index` column. -/// -/// Adding a variant to upstream `AccountType` makes this match -/// exhaustive-check fail at compile time, forcing an explicit label -/// decision rather than silent garbage. +/// `Standard` maps to two distinct labels by `StandardAccountType` so BIP44 +/// and BIP32 accounts with the same `index` never collapse onto the same PK. pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &'static str { - use key_wallet::account::AccountType; + use key_wallet::account::{AccountType, StandardAccountType}; match at { - AccountType::Standard { .. } => "standard", + AccountType::Standard { + standard_account_type: StandardAccountType::BIP44Account, + .. + } => "standard_bip44", + AccountType::Standard { + standard_account_type: StandardAccountType::BIP32Account, + .. + } => "standard_bip32", AccountType::CoinJoin { .. } => "coinjoin", AccountType::IdentityRegistration => "identity_registration", AccountType::IdentityTopUp { .. } => "identity_topup", @@ -227,24 +232,8 @@ pub(crate) fn account_type_db_label(at: &key_wallet::account::AccountType) -> &' } } -/// Stable database label for an `AddressPoolType` variant. -pub(crate) fn pool_type_db_label( - pool: &key_wallet::managed_account::address_pool::AddressPoolType, -) -> &'static str { - use key_wallet::managed_account::address_pool::AddressPoolType; - match pool { - AddressPoolType::External => "external", - AddressPoolType::Internal => "internal", - AddressPoolType::Absent => "absent", - AddressPoolType::AbsentHardened => "absent_hardened", - } -} - -/// Numeric account index embedded in an `AccountType`. -/// -/// Persisted in the `account_index` column of `account_registrations`, -/// `account_address_pools`, and `core_derived_addresses` (the last of -/// which is the script→account lookup the UTXO writer joins against). +/// Numeric account index embedded in an `AccountType`, persisted in the +/// `account_registrations.account_index` column. pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 { use key_wallet::account::AccountType; match at { @@ -271,11 +260,147 @@ mod tests { use super::*; use std::collections::HashSet; - /// Exhaustive sample of every [`key_wallet::account::AccountType`] - /// variant. The match arm in the loop below uses no wildcard, so - /// an upstream-added variant becomes a compile error here and - /// forces the developer to extend the sample list (and the - /// matching arm in `account_type_db_label` / [`ACCOUNT_TYPE_LABELS`]). + /// Open an in-memory SQLite connection and run the full schema migration + /// so tests can insert rows through the production table DDL. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// A fixed serialised extended public key for use in tests. Taken from the + /// BIP-32 mainnet test vector so it is stable and round-trips correctly. + fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode( + &hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D\ + 314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC\ + 2DF1050E2E8FF49C85C2", + ) + .unwrap(), + ) + .unwrap() + } + + /// `load_state` must return `AccountRegistrationEntryMismatch` when the + /// typed `account_type` column disagrees with the decoded blob. The test + /// inserts a row whose blob encodes a `PlatformPayment` entry but whose + /// column is set to `identity_registration`, then verifies the mismatch + /// is caught on the read path. + #[test] + fn load_state_rejects_account_type_column_mismatch() { + let conn = migrated_conn(); + let w = [0x11u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + + // Build a valid blob for PlatformPayment (account_index = 0). + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + + // Insert with a deliberately wrong `account_type` column label so + // the typed column and the blob disagree. + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'identity_registration', 0, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + let err = load_state(&conn, &w).expect_err("load_state must fail on type mismatch"); + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "expected AccountRegistrationEntryMismatch, got {err:?}" + ); + } + + /// `load_state` must return `AccountRegistrationEntryMismatch` when the + /// typed `account_index` column disagrees with the decoded blob, even when + /// `account_type` matches. + #[test] + fn load_state_rejects_account_index_column_mismatch() { + let conn = migrated_conn(); + let w = [0x22u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + + // Blob encodes PlatformPayment at account index 0. + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + + // Column says account_index = 1 but blob says 0 — deliberate mismatch. + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 1, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + let err = load_state(&conn, &w).expect_err("load_state must fail on index mismatch"); + assert!( + matches!(err, WalletStorageError::AccountRegistrationEntryMismatch), + "expected AccountRegistrationEntryMismatch, got {err:?}" + ); + } + + /// Baseline: a consistent row (column and blob agree) round-trips cleanly. + #[test] + fn load_state_accepts_consistent_row() { + let conn = migrated_conn(); + let w = [0x33u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 3, + key_class: 0, + }, + account_xpub: test_xpub(), + }; + let blob = blob::encode(&entry).unwrap(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 3, ?2)", + rusqlite::params![&w[..], blob], + ) + .unwrap(); + + let loaded = load_state(&conn, &w).expect("consistent row must load cleanly"); + assert_eq!(loaded.len(), 1); + assert!(matches!( + loaded[0].account_type, + key_wallet::account::AccountType::PlatformPayment { account: 3, .. } + )); + } + + /// Every [`key_wallet::account::AccountType`] variant; the wildcard-free + /// match below fails to compile if upstream adds one. `Standard` appears + /// twice — once per `StandardAccountType` — because both map to distinct + /// labels. fn all_account_type_variants() -> Vec { use key_wallet::account::{AccountType, StandardAccountType}; let variants = vec![ @@ -283,6 +408,10 @@ mod tests { index: 0, standard_account_type: StandardAccountType::BIP44Account, }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP32Account, + }, AccountType::CoinJoin { index: 0 }, AccountType::IdentityRegistration, AccountType::IdentityTopUp { @@ -311,9 +440,6 @@ mod tests { key_class: 0, }, ]; - // Compile-time exhaustiveness gate: an added upstream variant - // makes this match fail to compile and forces the sample list - // (and `account_type_db_label`) to be updated. for v in &variants { match v { AccountType::Standard { .. } @@ -336,25 +462,6 @@ mod tests { variants } - fn all_pool_type_variants() -> Vec { - use key_wallet::managed_account::address_pool::AddressPoolType; - let variants = vec![ - AddressPoolType::External, - AddressPoolType::Internal, - AddressPoolType::Absent, - AddressPoolType::AbsentHardened, - ]; - for v in &variants { - match v { - AddressPoolType::External - | AddressPoolType::Internal - | AddressPoolType::Absent - | AddressPoolType::AbsentHardened => {} - } - } - variants - } - #[test] fn account_type_labels_match_enum() { let from_writer: HashSet<&'static str> = all_account_type_variants() @@ -368,18 +475,4 @@ mod tests { from_const, from_writer ); } - - #[test] - fn pool_type_labels_match_enum() { - let from_writer: HashSet<&'static str> = all_pool_type_variants() - .iter() - .map(pool_type_db_label) - .collect(); - let from_const: HashSet<&'static str> = POOL_TYPE_LABELS.iter().copied().collect(); - assert_eq!( - from_writer, from_const, - "POOL_TYPE_LABELS ({:?}) drifted from pool_type_db_label codomain ({:?})", - from_const, from_writer - ); - } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index af89cdc1e4d..990bbacbf72 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -13,14 +13,17 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; -// Imports used only by the test-gated readers below. -#[cfg(any(test, feature = "__test-helpers"))] use { dashcore::OutPoint, platform_wallet::changeset::AssetLockEntry, platform_wallet::wallet::asset_lock::tracked::TrackedAssetLock, rusqlite::Connection, std::collections::BTreeMap, }; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: asset-lock lifecycle reaching `lifecycle_blob`. +impl_persistable_blob!(AssetLockEntry); + pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -66,19 +69,11 @@ pub fn apply( Ok(()) } -/// Single source of truth for the `asset_locks.status` TEXT-column -/// domain. -/// -/// Mirrors every variant of -/// [`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`] -/// (writer side: [`status_str`]). The migration in -/// `migrations/V001__initial.rs` interpolates this array into the -/// `CHECK (status IN (...))` clause so an unknown label is rejected at -/// insert time rather than landing as silent garbage. The -/// `asset_lock_status_labels_match_enum` unit test below enforces -/// set-equality between this array and the writer's output — drift (a -/// renamed/added variant) becomes a failing test, not a runtime -/// divergence between Rust and SQLite. +/// Source of truth for the `asset_locks.status` TEXT domain, mirroring +/// [`platform_wallet::wallet::asset_lock::tracked::AssetLockStatus`]. +/// `migrations/V001__initial.rs` interpolates it into a `CHECK (status IN +/// (...))`; `asset_lock_status_labels_match_enum` keeps it in sync with +/// [`status_str`]. pub(crate) const ASSET_LOCK_STATUS_LABELS: &[&str] = &[ "built", "broadcast", @@ -99,35 +94,29 @@ fn status_str(s: &AssetLockStatus) -> &'static str { /// Per-wallet asset-lock slice as returned by the readers — outer-keyed /// by `account_index`, inner-keyed by outpoint. -#[cfg(any(test, feature = "__test-helpers"))] pub type AssetLocksByAccount = BTreeMap>; -/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob)` +/// Decode one raw `(outpoint_bytes, account_index, lifecycle_blob, status)` /// tuple into the typed `(account_index, OutPoint, TrackedAssetLock)` -/// triple that [`load_state`] consumes. +/// triple that the reader functions consume. /// -/// Hard-fail behaviour: a malformed outpoint, blob, or out-of-range -/// account index returns a typed [`WalletStorageError`]. Every caller -/// propagates that error — corruption is never silently skipped. -#[cfg(any(test, feature = "__test-helpers"))] +/// Hard-fail behaviour: a malformed outpoint, blob, out-of-range +/// account index, or a mismatch between the typed columns and the blob +/// returns a typed [`WalletStorageError`]. Every caller propagates that +/// error — corruption is never silently skipped. fn decode_row( op_bytes: &[u8], account_index: i64, blob_bytes: &[u8], + typed_status: &str, ) -> Result<(u32, OutPoint, TrackedAssetLock), WalletStorageError> { let outpoint = blob::decode_outpoint(op_bytes)?; let entry: AssetLockEntry = blob::decode(blob_bytes)?; let account_index = - u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { - field: "asset_locks.account_index", - value: account_index as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - })?; - // Typed-column vs blob cross-check, symmetric with - // IdentityKeyEntryMismatch. A torn write / partial migration / - // restored corruption that passes PRAGMA integrity_check would - // otherwise silently mis-bucket the lock into the wrong account or - // report a different outpoint than the indexed column it was + crate::sqlite::util::safe_cast::i64_to_u32("asset_locks.account_index", account_index)?; + // Typed-column vs blob cross-check: corruption that passes PRAGMA + // integrity_check would otherwise mis-bucket the lock or report a + // different outpoint / account index than the indexed columns it was // selected by. if entry.out_point != outpoint || entry.account_index != account_index { return Err(WalletStorageError::AssetLockEntryMismatch { @@ -137,6 +126,15 @@ fn decode_row( blob_account_index: entry.account_index, }); } + // Status cross-check: the typed `status` column drives SQL-level filters + // (e.g. `load_unconsumed`'s `status NOT IN ('consumed')`), so a blob that + // disagrees with the column would cause a consumed lock to re-enter the + // live set or an active lock to be filtered out. + if status_str(&entry.status) != typed_status { + return Err(WalletStorageError::BlobDecode { + reason: "asset_locks.status column disagrees with lifecycle_blob status", + }); + } let tracked = TrackedAssetLock { out_point: entry.out_point, transaction: entry.transaction, @@ -150,33 +148,89 @@ fn decode_row( Ok((account_index, outpoint, tracked)) } -/// Build the per-wallet asset-lock slice for `ClientStartState` from -/// the `asset_locks` table, bucketed by account index. Every status -/// variant the changeset writes is considered "active": consumed -/// locks leave the table via [`AssetLockChangeSet::removed`], so a -/// row present here is by definition still in play. Any row that -/// fails to read or decode is a hard error — corruption is never -/// silently dropped. Retained for this crate's integration tests until -/// the rehydration path consumes it in `load()`. +/// Full-history asset-lock slice bucketed by account index, **including** +/// terminal `Consumed` rows (inspection reader for this crate's tests). Use +/// [`load_unconsumed`] for the rehydration feed. A row that fails to decode is +/// a hard error. #[cfg(any(test, feature = "__test-helpers"))] pub fn load_state( conn: &Connection, wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, lifecycle_blob \ + "SELECT outpoint, account_index, lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1", )?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; let blob_bytes: Vec = row.get(2)?; - Ok((op_bytes, account_index, blob_bytes)) + let status: String = row.get(3)?; + Ok((op_bytes, account_index, blob_bytes, status)) + })?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + for r in rows { + let (op_bytes, account_index, blob_bytes, status) = r?; + let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; + out.entry(acct).or_default().insert(outpoint, tracked); + } + Ok(out) +} + +/// Status-filtered rehydration feed: every asset lock **except** terminal +/// `Consumed` rows, bucketed by account index. Feeding `Consumed` locks back +/// into the live set would resurrect a spent one-shot lock as actionable +/// (A04/A08), so the exclusion is at the SQL level (`status NOT IN +/// ('consumed')`, `status` indexed); history stays visible via [`load_state`]. +/// A row that fails to decode is a hard [`WalletStorageError`]. +pub fn load_unconsumed( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let mut stmt = conn.prepare( + "SELECT outpoint, account_index, lifecycle_blob, status \ + FROM asset_locks WHERE wallet_id = ?1 AND status NOT IN ('consumed')", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op_bytes: Vec = row.get(0)?; + let account_index: i64 = row.get(1)?; + let blob_bytes: Vec = row.get(2)?; + let status: String = row.get(3)?; + Ok((op_bytes, account_index, blob_bytes, status)) })?; let mut out: AssetLocksByAccount = BTreeMap::new(); for r in rows { - let (op_bytes, account_index, blob_bytes) = r?; - let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes)?; + let (op_bytes, account_index, blob_bytes, status) = r?; + let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; + out.entry(acct).or_default().insert(outpoint, tracked); + } + Ok(out) +} + +/// Every asset lock bucketed by account index, **including** terminal +/// `Consumed` — history/inspection only; use [`load_unconsumed`] for the +/// rehydration feed. A row that fails to decode is a hard +/// [`WalletStorageError`]. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn list_active( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let mut stmt = conn.prepare( + "SELECT outpoint, account_index, lifecycle_blob, status \ + FROM asset_locks WHERE wallet_id = ?1", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op_bytes: Vec = row.get(0)?; + let account_index: i64 = row.get(1)?; + let blob_bytes: Vec = row.get(2)?; + let status: String = row.get(3)?; + Ok((op_bytes, account_index, blob_bytes, status)) + })?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + for r in rows { + let (op_bytes, account_index, blob_bytes, status) = r?; + let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } Ok(out) @@ -187,10 +241,81 @@ mod tests { use super::*; use std::collections::HashSet; - /// Exhaustive sample of every [`AssetLockStatus`] variant. The - /// trailing match arm in the loop fails to compile if upstream - /// adds a variant — forcing the developer to extend the list, - /// `status_str`, and [`ASSET_LOCK_STATUS_LABELS`] together. + /// Open an in-memory connection with the full schema applied. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// `decode_row` (and the reader functions that call it) must reject a row + /// whose `status` TEXT column disagrees with the `lifecycle_blob` status, + /// returning a `BlobDecode` corrupt error rather than silently mis-bucketing + /// a lock (e.g. treating a `Consumed` lock as `Built`). + #[test] + fn load_state_rejects_status_column_mismatch() { + use dashcore::hashes::Hash; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + + let mut conn = migrated_conn(); + let w = [0xAAu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + + // Build a minimal `AssetLockEntry` with `status = Built`. + let outpoint = dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x01u8; 32]), + vout: 0, + }; + let entry = AssetLockEntry { + out_point: outpoint, + // Dashcore Transaction with integer version and lock_time. + transaction: dashcore::Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status: AssetLockStatus::Built, + proof: None, + }; + let lifecycle_blob = blob::encode(&entry).unwrap(); + let op_bytes = blob::encode_outpoint(&outpoint).unwrap(); + + // Insert with status column = 'consumed' but blob says 'built'. + { + let tx = conn.transaction().unwrap(); + tx.execute( + "INSERT INTO asset_locks \ + (wallet_id, outpoint, status, account_index, identity_index, \ + amount_duffs, lifecycle_blob) \ + VALUES (?1, ?2, 'consumed', 0, 0, 1000, ?3)", + params![&w[..], &op_bytes[..], lifecycle_blob], + ) + .unwrap(); + tx.commit().unwrap(); + } + + // load_state must fail: status column ('consumed') ≠ blob status ('built'). + let err = load_state(&conn, &w) + .expect_err("load_state must reject a status column vs blob mismatch"); + assert!( + matches!(err, WalletStorageError::BlobDecode { .. }), + "expected BlobDecode for status mismatch, got {err:?}" + ); + } + + /// Every [`AssetLockStatus`] variant; the wildcard-free match below fails + /// to compile if upstream adds one. fn all_asset_lock_status_variants() -> Vec { let variants = vec![ AssetLockStatus::Built, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index 502d984116d..3cb375f30dd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -1,27 +1,46 @@ -//! BLOB-column codec helpers. +//! BLOB-column codec helpers: thin `bincode::serde` wrappers so every +//! `_blob` column uses one encoding path. Schema evolution is gated by the +//! refinery migration version — no per-blob revision tag. //! -//! Thin error-mapping wrappers around `bincode::serde` so every -//! `_blob` column in the SQLite schema uses one encoding path. Schema -//! evolution is gated by the refinery migration version on the -//! database as a whole — there is no per-blob revision tag. -//! -//! [`encode_outpoint`] / [`decode_outpoint`] encode a `dashcore::OutPoint` -//! the same way — via bincode-serde — for the `outpoint` PRIMARY KEY -//! columns (`core_utxos`, `asset_locks`). The bytes are a stable but not -//! fixed-length key; both columns are used for exact-match PK lookups, so -//! variable width is fine (no range scans or byte-order dependence). +//! [`encode_outpoint`] / [`decode_outpoint`] encode `dashcore::OutPoint` +//! the same way for the `outpoint` PK columns. The key is variable-width, +//! which is fine for the exact-match PK lookups (no range scans). use serde::de::DeserializeOwned; use serde::Serialize; use crate::sqlite::error::WalletStorageError; -/// Hard cap on bincode-serde decode allocations. 16 MiB is two orders -/// of magnitude above any legitimate per-row payload we ship — a -/// hostile or corrupted backup with an inflated length prefix is -/// rejected before the allocator wakes up. Applied symmetrically to -/// encode + decode so we can't write a payload we'd then refuse. -pub const BLOB_SIZE_LIMIT_BYTES: usize = 16 * 1024 * 1024; +/// Sealed-trait machinery enforcing the no-key-material-in-DB invariant at +/// the type level: only types opting in via [`impl_persistable_blob!`] can +/// reach [`encode`]. +pub(crate) mod sealed { + /// `pub(crate)` supertrait of [`PersistableBlob`] — downstream cannot + /// name it, so the trait is sealed. + pub trait Sealed {} +} + +/// Marker for types allowed into a `_blob` column. Sealed via +/// [`sealed::Sealed`] so adding a (possibly key-bearing) type to the +/// persistence path is an explicit, reviewable `impl` rather than a silent +/// `T: Serialize` slip. +pub trait PersistableBlob: Serialize + sealed::Sealed {} + +/// Seal-and-mark a type for [`blob::encode`](encode). +macro_rules! impl_persistable_blob { + ($($t:ty),+ $(,)?) => { + $( + impl $crate::sqlite::schema::blob::sealed::Sealed for $t {} + impl $crate::sqlite::schema::blob::PersistableBlob for $t {} + )+ + }; +} +pub(crate) use impl_persistable_blob; + +/// Hard cap on bincode-serde allocations, applied symmetrically to encode + +/// decode so a crafted length prefix can't OOM the host. Shares the crate-root +/// [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES) with the KV value cap. +pub const BLOB_SIZE_LIMIT_BYTES: usize = crate::SIZE_LIMIT_BYTES; fn bounded_config() -> bincode::config::Configuration< bincode::config::LittleEndian, @@ -31,8 +50,10 @@ fn bounded_config() -> bincode::config::Configuration< bincode::config::standard().with_limit::() } -/// Encode a serde-derived value into a `BLOB` payload. -pub fn encode(value: &T) -> Result, WalletStorageError> { +/// Encode a [`PersistableBlob`] value into a `BLOB` payload. The sealed bound +/// (not a bare `T: Serialize`) guards against unreviewed types reaching a +/// `_blob` column. +pub fn encode(value: &T) -> Result, WalletStorageError> { Ok(bincode::serde::encode_to_vec(value, bounded_config())?) } @@ -66,9 +87,11 @@ pub fn decode(blob: &[u8]) -> Result Ok(value) } -/// Encode a `dashcore::OutPoint` for an `outpoint` PRIMARY KEY column. -/// Uses the same bincode-serde path as every other column — a stable -/// (not fixed-length) key, which the exact-match PK lookups don't mind. +// An outpoint is a PUBLIC (txid, vout) reference — never key material. +impl_persistable_blob!(dashcore::OutPoint); + +/// Encode a `dashcore::OutPoint` for an `outpoint` PRIMARY KEY column via the +/// shared [`encode`] path. pub fn encode_outpoint(op: &dashcore::OutPoint) -> Result, WalletStorageError> { encode(op) } @@ -76,7 +99,6 @@ pub fn encode_outpoint(op: &dashcore::OutPoint) -> Result, WalletStorage /// Decode an outpoint key produced by [`encode_outpoint`]. Rejects /// malformed or trailing bytes with a typed [`WalletStorageError`] via /// the shared [`decode`] path. -#[cfg(any(test, feature = "__test-helpers"))] pub fn decode_outpoint(bytes: &[u8]) -> Result { decode(bytes) } @@ -90,6 +112,7 @@ mod tests { a: u32, b: String, } + impl_persistable_blob!(Dummy); #[test] fn encode_decode_roundtrip() { @@ -161,11 +184,9 @@ mod tests { assert_eq!(decode_outpoint(&bytes).unwrap(), op); } - /// A truncated / malformed outpoint key is a typed decode error, not - /// a panic — replaces the old fixed-36-byte length check. A 4-byte - /// input is too short for the 32-byte txid prefix, so bincode fails - /// deterministically with `BincodeDecode` (UnexpectedEnd) before the - /// trailing-bytes check. + /// A truncated outpoint key is a typed decode error, not a panic: a + /// 4-byte input is too short for the 32-byte txid prefix, so bincode + /// fails deterministically with `BincodeDecode` (UnexpectedEnd). #[test] fn decode_outpoint_rejects_malformed_bytes() { let res = decode_outpoint(&[0x01u8; 4]); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index c1314115ebd..aa69e5fd8ae 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -1,45 +1,36 @@ //! Unified `contacts` table writer and per-wallet reader. //! -//! One row per `(wallet_id, owner_id, contact_id)` relationship. The -//! `state` column records which lifecycle stage the relationship is in -//! (`sent` / `received` / `established`), collapsing what used to be -//! three sibling tables into one. A pending relationship is `sent` XOR -//! `received` and carries only the matching request blob; an -//! `established` relationship carries both request blobs plus the four +//! One row per `(wallet_id, owner_id, contact_id)` relationship; the `state` +//! column records its lifecycle stage (`sent` / `received` / `established`). A +//! pending relationship is `sent` XOR `received` and carries only the matching +//! request blob; an `established` one carries both request blobs plus the four //! metadata columns (`alias`, `note`, `is_hidden`, `accepted_accounts`). -use rusqlite::{params, Transaction}; - -use platform_wallet::changeset::ContactChangeSet; -use platform_wallet::wallet::platform_wallet::WalletId; +use std::collections::BTreeMap; -use crate::sqlite::error::WalletStorageError; -use crate::sqlite::schema::blob; +use rusqlite::{params, Connection, Transaction}; -#[cfg(feature = "__test-helpers")] use dpp::prelude::Identifier; -#[cfg(feature = "__test-helpers")] use platform_wallet::changeset::{ - ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey, + ContactChangeSet, ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey, }; -#[cfg(feature = "__test-helpers")] use platform_wallet::wallet::identity::{ContactRequest, EstablishedContact}; -#[cfg(feature = "__test-helpers")] -use rusqlite::Connection; -#[cfg(feature = "__test-helpers")] -use std::collections::BTreeMap; +use platform_wallet::wallet::platform_wallet::WalletId; -/// Single source of truth for the `contacts.state` TEXT-column domain. -/// -/// One label per lifecycle stage of a DashPay contact relationship -/// (writer side: [`contact_state_db_label`]). The migration in -/// `migrations/V001__initial.rs` interpolates this array into the -/// `CHECK (state IN (...))` clause so an unknown label is rejected at -/// insert time rather than landing as silent garbage. The -/// `contact_state_labels_match_enum` unit test below enforces -/// set-equality between this array and the writer's output — drift (a -/// renamed/added stage) becomes a failing test, not a runtime -/// divergence between Rust and SQLite. +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: contact-request types + the accepted-account +// index reaching the contacts `_blob` columns (contact requests carry +// public keys/refs; accepted_accounts is a list of account indices). +impl_persistable_blob!(ContactRequest, Vec); + +/// Source of truth for the `contacts.state` TEXT domain, one label per +/// contact-relationship lifecycle stage. `migrations/V001__initial.rs` +/// interpolates it into a `CHECK (state IN (...))`; +/// `contact_state_labels_match_enum` keeps it in sync with +/// [`contact_state_db_label`]. pub(crate) const CONTACT_STATE_LABELS: &[&str] = &["sent", "received", "established"]; /// Lifecycle stage of a `contacts` row. @@ -67,7 +58,6 @@ fn contact_state_db_label(state: ContactState) -> &'static str { /// unknown label is a hard error — the migration's `CHECK` constraint /// already rejects writes outside the domain, so reaching this arm /// means on-disk corruption or a forward-incompatible row. -#[cfg(feature = "__test-helpers")] fn contact_state_from_label(label: &str) -> Result { match label { "sent" => Ok(ContactState::Sent), @@ -79,16 +69,21 @@ fn contact_state_from_label(label: &str) -> Result, + pub incoming_requests: BTreeMap, + pub established: BTreeMap, +} + +/// See the `not(__test-helpers)` definition for the canonical docs. #[derive(Debug, Default, PartialEq)] #[cfg(feature = "__test-helpers")] pub struct ContactsRecords { @@ -97,26 +92,20 @@ pub struct ContactsRecords { pub established: BTreeMap, } -/// Apply a [`ContactChangeSet`] onto the unified `contacts` table. -/// -/// Ordering matches the previous three-table writer: inserts before -/// removes. The auto-establishment contract is honoured by `state` -/// precedence — a pending upsert (`sent` / `received`) never downgrades -/// an already-`established` row, and an `established` upsert collapses -/// any prior pending row for the same pair (both request blobs + the -/// four metadata columns are set, `state = 'established'`). +/// Apply a [`ContactChangeSet`] onto the `contacts` table (inserts before +/// removes). Auto-establishment is honoured by `state` precedence: a pending +/// upsert never downgrades an already-`established` row, and an `established` +/// upsert collapses any prior pending row for the pair (both request blobs + +/// the four metadata columns, `state = 'established'`). pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, cs: &ContactChangeSet, ) -> Result<(), WalletStorageError> { if !cs.sent_requests.is_empty() { - // Pending-sent upsert. `outgoing_request` is set; `state` becomes - // 'sent' UNLESS the row is already established (don't downgrade) - // or an incoming request blob is already stored — in which case - // both sides are present and the row promotes to 'established' in - // the same statement. The inserted label flows through - // `contact_state_db_label` so it stays the writer's codomain. + // Pending-sent upsert: `state` becomes 'sent' unless the row is + // already established or an incoming blob is present, in which case + // both sides exist and it promotes to 'established' in one statement. let sent = contact_state_db_label(ContactState::Sent); let mut stmt = tx.prepare_cached( "INSERT INTO contacts (wallet_id, owner_id, contact_id, state, outgoing_request) \ @@ -152,11 +141,7 @@ pub fn apply( } } if !cs.incoming_requests.is_empty() { - // Pending-received upsert. Symmetric to the sent path: - // `incoming_request` is set, `state` becomes 'received' unless the - // row is already established or an outgoing request blob is already - // stored — in which case both sides are present and the row - // promotes to 'established' in the same statement. + // Pending-received upsert, symmetric to the sent path. let received = contact_state_db_label(ContactState::Received); let mut stmt = tx.prepare_cached( "INSERT INTO contacts (wallet_id, owner_id, contact_id, state, incoming_request) \ @@ -192,9 +177,8 @@ pub fn apply( } } if !cs.established.is_empty() { - // Establishment collapses any prior pending row for the pair: set - // both request blobs + the four metadata columns and force the - // `established` state. + // Collapse any prior pending row: both request blobs + the four + // metadata columns, forcing the `established` state. let established_label = contact_state_db_label(ContactState::Established); let mut stmt = tx.prepare_cached( "INSERT INTO contacts \ @@ -236,7 +220,6 @@ pub fn apply( /// decode (bad blob, non-32-byte id, unknown state, or a pending row /// missing its request blob) is a hard error — corruption is never /// silently dropped. -#[cfg(feature = "__test-helpers")] pub(crate) fn load_state( conn: &Connection, wallet_id: &WalletId, @@ -311,11 +294,29 @@ pub(crate) fn load_state( Ok(state) } +/// Build a keyless [`ContactChangeSet`] for one wallet — the +/// rehydration feed the manager layers onto the restored managed +/// identities. PUBLIC material only; `removed_*` are always empty +/// (deletes never reach storage as rows). Fail-hard on a corrupt row, +/// inherited from [`load_state`]. +pub fn load_changeset( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let records = load_state(conn, wallet_id)?; + Ok(ContactChangeSet { + sent_requests: records.sent_requests, + incoming_requests: records.incoming_requests, + established: records.established, + removed_sent: Default::default(), + removed_incoming: Default::default(), + }) +} + /// Decode a `ContactRequest` from a nullable request column. A NULL /// column on a state that requires it (a pending row missing its blob, /// or an established row missing either side) is a hard error — the /// shape invariant is part of the on-disk contract. -#[cfg(feature = "__test-helpers")] fn decode_request( column: &'static str, bytes: Option<&[u8]>, @@ -329,7 +330,6 @@ fn decode_request( } } -#[cfg(feature = "__test-helpers")] fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), WalletStorageError> { let a32 = <[u8; 32]>::try_from(a) .map_err(|_| WalletStorageError::blob_decode("contacts.id column is not 32 bytes"))?; @@ -338,9 +338,8 @@ fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), Walle Ok((Identifier::from(a32), Identifier::from(b32))) } -/// Test-helper wrapper over [`load_state`] so this crate's integration -/// tests can assert on the hardened (fail-hard) contacts reader without -/// promoting the production surface beyond `pub(crate)`. +/// Test-helper wrapper over [`load_state`] without promoting the production +/// surface beyond `pub(crate)`. #[cfg(feature = "__test-helpers")] pub fn load_state_for_test( conn: &Connection, @@ -354,10 +353,8 @@ mod tests { use super::*; use std::collections::HashSet; - /// Exhaustive sample of every [`ContactState`] variant. The match - /// arm uses no wildcard, so an added variant becomes a compile error - /// here and forces the sample list, `contact_state_db_label`, and - /// [`CONTACT_STATE_LABELS`] to be updated together. + /// Every [`ContactState`] variant; the wildcard-free match below fails to + /// compile if one is added. fn all_contact_state_variants() -> Vec { let variants = vec![ ContactState::Sent, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 129819b0bce..03ad59fb04e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -5,6 +5,7 @@ use std::collections::BTreeMap; use rusqlite::{params, Connection, OptionalExtension, Transaction}; +use dashcore::ephemerealdata::chain_lock::ChainLock; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::Utxo; use platform_wallet::changeset::CoreChangeSet; @@ -12,6 +13,42 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: core-chain state reaching `record_blob` / +// `islock_blob` (transaction records + InstantLocks are public chain data). +impl_persistable_blob!(TransactionRecord, dashcore::InstantLock); + +/// Bounded bincode config for `ChainLock` BLOB columns — native bincode +/// (not the serde bridge) because `ChainLock` enables the `bincode` feature +/// but not `serde`. The size limit caps allocations symmetrically with other +/// BLOB columns (`blob::BLOB_SIZE_LIMIT_BYTES`). +fn chain_lock_config() -> impl bincode::config::Config { + bincode::config::standard().with_limit::<{ blob::BLOB_SIZE_LIMIT_BYTES }>() +} + +/// Encode a `ChainLock` to bytes for storage in `core_sync_state`. +fn encode_chain_lock(cl: &ChainLock) -> Result, WalletStorageError> { + Ok(bincode::encode_to_vec(cl, chain_lock_config())?) +} + +/// Decode a `ChainLock` from `core_sync_state.last_applied_chain_lock`. +/// Returns `None` + emits a `tracing::warn` on any decode failure so a +/// single corrupt byte cannot prevent the wallet from loading (the next +/// ChainLock event will repopulate the column). +fn decode_chain_lock_soft(bytes: &[u8]) -> Option { + match bincode::decode_from_slice::(bytes, chain_lock_config()) { + Ok((cl, _)) => Some(cl), + Err(e) => { + tracing::warn!( + error = %e, + "core_sync_state.last_applied_chain_lock: decode failed; \ + field left None — the next ChainLock sync will repopulate" + ); + None + } + } +} /// Apply a `CoreChangeSet` inside a transaction. pub fn apply( @@ -49,40 +86,15 @@ pub fn apply( ])?; } } - // Derived addresses are written BEFORE UTXOs (within the same - // transaction) so the UTXO writer's address→account_index lookup - // sees the freshly recorded rows. - if !cs.addresses_derived.is_empty() { - let mut stmt = tx.prepare_cached( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ - ON CONFLICT(wallet_id, account_type, address) DO UPDATE SET \ - account_index = excluded.account_index, \ - derivation_path = excluded.derivation_path", - )?; - for da in &cs.addresses_derived { - let account_type = - crate::sqlite::schema::accounts::account_type_db_label(&da.account_type); - let account_index = crate::sqlite::schema::accounts::account_index(&da.account_type); - let pool_type = crate::sqlite::schema::accounts::pool_type_db_label(&da.pool_type); - let address = da.address.to_string(); - let path = format!("{}/{}", pool_type, da.derivation_index); - stmt.execute(params![ - wallet_id.as_slice(), - account_type, - i64::from(account_index), - address, - path, - false - ])?; - } - } + // `addresses_derived` is intentionally NOT persisted here. The iOS + // address registry is fed by the FFI `addresses_derived` callback (fired + // before the UTXO changeset in the same round), and UTXO attribution is + // hardcoded to the default account (index 0); the storage layer keeps no + // derived-address lookup table. if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; - let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; for utxo in &cs.new_utxos { - execute_upsert_utxo(&mut stmt, &mut lookup_stmt, wallet_id, utxo, false)?; + execute_upsert_utxo(&mut stmt, wallet_id, utxo, false)?; } } if !cs.spent_utxos.is_empty() { @@ -92,7 +104,6 @@ pub fn apply( "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", )?; let mut upsert_stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; - let mut lookup_stmt = tx.prepare_cached(ACCOUNT_INDEX_BY_ADDRESS_SQL)?; for utxo in &cs.spent_utxos { let op = blob::encode_outpoint(&utxo.outpoint)?; let exists: bool = exists_stmt @@ -102,12 +113,11 @@ pub fn apply( if exists { mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; } else { - // Spent-only synthetic row: best-effort account_index - // from the derived-address map. A spend of an - // externally-funded address we never derived defaults - // to 0 (logged) — harmless, since spent rows are - // excluded from `list_unspent_utxos`. - execute_upsert_utxo(&mut upsert_stmt, &mut lookup_stmt, wallet_id, utxo, true)?; + // Spent-only synthetic row for a UTXO we never saw unspent. + // account_index is the hardcoded default like every row, and + // inert anyway since spent rows are excluded from + // `list_unspent_utxos`. + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true)?; } } } @@ -126,18 +136,26 @@ pub fn apply( ])?; } } - if cs.last_processed_height.is_some() || cs.synced_height.is_some() { - upsert_sync_state(tx, wallet_id, cs.last_processed_height, cs.synced_height)?; + if cs.last_processed_height.is_some() + || cs.synced_height.is_some() + || cs.last_applied_chain_lock.is_some() + { + let cl_bytes = cs + .last_applied_chain_lock + .as_ref() + .map(encode_chain_lock) + .transpose()?; + upsert_sync_state( + tx, + wallet_id, + cs.last_processed_height, + cs.synced_height, + cl_bytes, + )?; } Ok(()) } -/// Resolve the owning account index for a UTXO by its rendered address, -/// joining against the `core_derived_addresses` map written earlier in -/// the same transaction. -const ACCOUNT_INDEX_BY_ADDRESS_SQL: &str = - "SELECT account_index FROM core_derived_addresses WHERE wallet_id = ?1 AND address = ?2"; - const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL) \ @@ -148,49 +166,30 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ account_index = excluded.account_index, \ spent = excluded.spent"; +/// Account index written for every `core_utxos` row. The product uses only +/// the default account (index 0); a non-default funds account causes +/// `core_bridge::warn_if_non_default_account` to emit a `warn!` log but +/// the record is still persisted under index 0 (dropping it would +/// undercount the balance and lose funds). The one reader +/// (`list_unspent_utxos` per-account grouping) groups everything under 0. +const CORE_UTXO_ACCOUNT_INDEX: i64 = 0; + +/// Upsert one `core_utxos` row. `account_index` is the hardcoded default +/// ([`CORE_UTXO_ACCOUNT_INDEX`]); `spent` marks spent-only synthetic rows. fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, - lookup_stmt: &mut rusqlite::CachedStatement<'_>, wallet_id: &WalletId, utxo: &Utxo, spent: bool, ) -> Result<(), WalletStorageError> { let op = blob::encode_outpoint(&utxo.outpoint)?; - let address = utxo.address.to_string(); - // `Utxo` carries no account index; recover it from the - // derived-address map written earlier in this transaction. - let looked_up: Option = lookup_stmt - .query_row(params![wallet_id.as_slice(), &address], |row| row.get(0)) - .optional()?; - let account_index: i64 = match looked_up { - Some(idx) => idx, - // An unspent UTXO whose address we never derived would land in - // the wallet's funds under account 0 and never re-derive — silent - // mis-bucketing of live money. Refuse it. The spent-only - // placeholder path tolerates the fallback because spent rows are - // excluded from `list_unspent_utxos`, so a wrong index there is - // inert. - None if !spent => { - return Err(WalletStorageError::UtxoAddressNotDerived { - address: address.clone(), - }); - } - None => { - tracing::debug!( - wallet_id = %hex::encode(wallet_id), - address = %address, - "spent-only UTXO address not found in core_derived_addresses; using account_index 0 placeholder" - ); - 0 - } - }; stmt.execute(params![ wallet_id.as_slice(), &op[..], crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, utxo.txout.script_pubkey.as_bytes(), i64::from(utxo.height), - account_index, + CORE_UTXO_ACCOUNT_INDEX, spent, ])?; Ok(()) @@ -201,16 +200,20 @@ fn upsert_sync_state( wallet_id: &WalletId, last_processed: Option, synced: Option, + chain_lock_bytes: Option>, ) -> Result<(), WalletStorageError> { - // Monotonic-max semantics — keep the larger of (current, new). - let current_raw: (Option, Option) = tx + // Read current row for monotonic-max height merge + to carry forward any + // existing chain lock when the changeset doesn't include a new one. + let current_raw: (Option, Option, Option>) = tx .query_row( - "SELECT last_processed_height, synced_height FROM core_sync_state WHERE wallet_id = ?1", + "SELECT last_processed_height, synced_height, last_applied_chain_lock \ + FROM core_sync_state WHERE wallet_id = ?1", params![wallet_id.as_slice()], - |row| Ok((row.get(0)?, row.get(1)?)), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()? - .unwrap_or((None, None)); + .unwrap_or((None, None, None)); + // Monotonic-max semantics for sync watermarks. let current = ( sync_height_u32("core_sync_state.last_processed_height", current_raw.0)?, sync_height_u32("core_sync_state.synced_height", current_raw.1)?, @@ -223,33 +226,162 @@ fn upsert_sync_state( (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), }; + // Chain lock: take the new bytes when provided; keep the existing bytes + // when the changeset has no chain lock update (None = "no change"). + let cl_final = chain_lock_bytes.or(current_raw.2); tx.execute( - "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) \ - VALUES (?1, ?2, ?3) \ + "INSERT INTO core_sync_state \ + (wallet_id, last_processed_height, synced_height, last_applied_chain_lock) \ + VALUES (?1, ?2, ?3, ?4) \ ON CONFLICT(wallet_id) DO UPDATE SET \ last_processed_height = excluded.last_processed_height, \ - synced_height = excluded.synced_height", - params![wallet_id.as_slice(), lp.map(i64::from), sy.map(i64::from),], + synced_height = excluded.synced_height, \ + last_applied_chain_lock = excluded.last_applied_chain_lock", + params![ + wallet_id.as_slice(), + lp.map(i64::from), + sy.map(i64::from), + cl_final + ], )?; Ok(()) } +/// Bulk-reconstruct the keyless [`CoreChangeSet`] projection for one wallet +/// from the `core_*` tables. PUBLIC material only; mints no `Wallet`. `network` +/// (from `wallets`) turns a persisted `script` back into an `Address`. +/// +/// # Reconstructed (safety-critical-correct) +/// +/// - **Unspent UTXOs** (`new_utxos`): every `spent = 0` row — the balance +/// source (no-silent-zero); a row with a block `height` is confirmed. +/// - **Transaction records** / **IS-locks** / **sync watermarks**: decoded +/// bit-exact, fail-hard on a corrupt blob. +/// +/// # Deferred to the first post-load `sync` (safe re-warm) +/// +/// - **Per-account UTXO attribution / `is_coinbase` / `is_instantlocked` / +/// `is_trusted` / `used` flags**: not carried by `core_utxos`; defaulted and +/// refreshed on the next scan. The wallet *total* balance is unaffected. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, +) -> Result { + let mut cs = CoreChangeSet::default(); + + // Unspent UTXOs → new_utxos (the balance source). + { + let mut stmt = conn.prepare( + "SELECT outpoint, value, script, height FROM core_utxos \ + WHERE wallet_id = ?1 AND spent = 0", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let op: Vec = row.get(0)?; + let value: i64 = row.get(1)?; + let script: Vec = row.get(2)?; + let height: Option = row.get(3)?; + Ok((op, value, script, height)) + })?; + for r in rows { + let (op_bytes, value, script_bytes, height) = r?; + let outpoint = blob::decode_outpoint(&op_bytes)?; + let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; + let height_u32 = match height { + None => 0u32, + Some(h) => crate::sqlite::util::safe_cast::i64_to_u32("core_utxos.height", h)?, + }; + let script = dashcore::ScriptBuf::from_bytes(script_bytes); + let address = dashcore::Address::from_script(&script, network) + .map_err(|_| WalletStorageError::blob_decode("core_utxos.script not an address"))?; + let confirmed = height.map(|h| h > 0).unwrap_or(false); + let utxo = Utxo { + outpoint, + txout: dashcore::TxOut { + value, + script_pubkey: script, + }, + address, + height: height_u32, + is_coinbase: false, + is_confirmed: confirmed, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + cs.new_utxos.push(utxo); + } + } + + { + let mut stmt = + conn.prepare("SELECT record_blob FROM core_transactions WHERE wallet_id = ?1")?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + for r in rows { + let payload = r?; + cs.records + .push(blob::decode::(&payload)?); + } + } + + { + let mut stmt = + conn.prepare("SELECT txid, islock_blob FROM core_instant_locks WHERE wallet_id = ?1")?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let txid: Vec = row.get(0)?; + let blob_bytes: Vec = row.get(1)?; + Ok((txid, blob_bytes)) + })?; + for r in rows { + use dashcore::hashes::Hash; + let (txid_bytes, blob_bytes) = r?; + let txid = dashcore::Txid::from_slice(&txid_bytes) + .map_err(|_| WalletStorageError::blob_decode("core_instant_locks.txid"))?; + let islock: dashcore::ephemerealdata::instant_lock::InstantLock = + blob::decode(&blob_bytes)?; + cs.instant_locks_for_non_final_records.insert(txid, islock); + } + } + + // Sync watermarks + persisted chain lock. + if let Some((lp, sy, cl_bytes)) = conn + .query_row( + "SELECT last_processed_height, synced_height, last_applied_chain_lock \ + FROM core_sync_state WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| { + let lp: Option = row.get(0)?; + let sy: Option = row.get(1)?; + let cl: Option> = row.get(2)?; + Ok((lp, sy, cl)) + }, + ) + .optional()? + { + // Fail-hard on an out-of-range watermark (corruption is never skipped). + cs.last_processed_height = sync_height_u32("core_sync_state.last_processed_height", lp)?; + cs.synced_height = sync_height_u32("core_sync_state.synced_height", sy)?; + // Soft-fail on a corrupt chain lock blob — a single bad byte must not + // prevent the wallet from loading; the next ChainLock event repopulates. + if let Some(bytes) = cl_bytes { + cs.last_applied_chain_lock = decode_chain_lock_soft(&bytes); + } + } + + Ok(cs) +} + /// Convert a stored sync-height column to `u32`, erroring on overflow /// rather than silently truncating a corrupt/out-of-range value. fn sync_height_u32( field: &'static str, value: Option, ) -> Result, WalletStorageError> { - match value { - None => Ok(None), - Some(v) => Ok(Some(u32::try_from(v).map_err(|_| { - WalletStorageError::IntegerOverflow { - field, - value: v as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - } - })?)), - } + value + .map(|v| crate::sqlite::util::safe_cast::i64_to_u32(field, v)) + .transpose() } /// Fetch a single transaction record by txid. Returns `Ok(None)` if @@ -310,20 +442,13 @@ pub fn list_unspent_utxos( let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; let height = match height { None => None, - Some(h) => Some( - u32::try_from(h).map_err(|_| WalletStorageError::IntegerOverflow { - field: "core_utxos.height", - value: h as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - })?, - ), + Some(h) => Some(crate::sqlite::util::safe_cast::i64_to_u32( + "core_utxos.height", + h, + )?), }; let account_index = - u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { - field: "core_utxos.account_index", - value: account_index as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - })?; + crate::sqlite::util::safe_cast::i64_to_u32("core_utxos.account_index", account_index)?; let row = UnspentRow { outpoint, value, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index b6caa52fb11..e9dd5cdb6ae 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -1,16 +1,19 @@ //! `dashpay_profiles` + `dashpay_payments_overlay` writers. //! +//! # Write-only indexed overlay (NOT a rehydration source) +//! +//! These tables are honored on write but `load()` does NOT read them back: +//! DashPay state is rehydrated from the identities `entry_blob`, which is the +//! authoritative load source. They exist for future per-profile/per-payment +//! indexed queries. Round-trip pinned by +//! `tests/sqlite_dashpay_overlay_contract.rs`. +//! //! # Precondition //! -//! Every `identity_id` in the supplied profile / payment maps MUST -//! already exist in the `identities` table and belong to the flush's -//! `wallet_id`. The writer relies on the -//! `identities(identity_id, wallet_id)` row produced by -//! [`super::identities::apply`] (in the same transaction or earlier) -//! for parenting; the FK to `identities(identity_id)` enforces the -//! existence half, but not the wallet match. The precondition check -//! below runs in every build and propagates -//! [`WalletStorageError::WalletIdMismatch`] on a mis-attributed caller. +//! Every `identity_id` MUST already exist in `identities` and belong to the +//! flush's `wallet_id`. The FK enforces existence; the wallet match is checked +//! here and propagates [`WalletStorageError::WalletIdMismatch`] on a +//! mis-attributed caller. use std::collections::BTreeMap; @@ -22,23 +25,20 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: DashPay overlay types reaching `_blob` columns. +impl_persistable_blob!(DashPayProfile, PaymentEntry); -/// Both dashpay tables are keyed by identity only; their FK targets -/// `identities(identity_id)` so cascade flows through the -/// `wallet_metadata → identities` chain. -/// -/// The `wallet_id` parameter is kept on the signature for symmetry -/// with the persister's `write_changeset_in_one_tx` dispatch table, -/// and feeds the precondition check; it does not feed any column. +/// Both tables are keyed by identity only; their FK to +/// `identities(identity_id)` cascades via the `wallets → identities` chain. +/// `wallet_id` feeds the precondition check only — no column. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, profiles: Option<&BTreeMap>>, payments: Option<&BTreeMap>>, ) -> Result<(), WalletStorageError> { - // Precondition: every identity_id we touch must already belong to - // the flush-scope wallet (or to no wallet if scope is the - // sentinel). Cheap SELECT inside the same tx, run in every build. let touched: std::collections::BTreeSet = profiles .iter() .flat_map(|m| m.keys().copied()) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index aa74f87b919..d8ac1ab8373 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -5,12 +5,14 @@ use rusqlite::{params, Transaction}; use platform_wallet::changeset::IdentityChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; -// Imports used only by the test-gated readers below. -#[cfg(any(test, feature = "__test-helpers"))] use {platform_wallet::changeset::IdentityEntry, rusqlite::Connection}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; +use crate::sqlite::schema::blob::impl_persistable_blob; + +// PUBLIC material only: identity snapshot reaching the `entry_blob` column. +impl_persistable_blob!(IdentityEntry); pub fn apply( tx: &Transaction<'_>, @@ -18,43 +20,29 @@ pub fn apply( cs: &IdentityChangeSet, ) -> Result<(), WalletStorageError> { if !cs.identities.is_empty() { - // PK is `identity_id` alone; `wallet_id` is nullable and links - // the identity to its parent wallet for cascade. The all-zero - // wallet id is treated as "no parent wallet known" and stored - // as NULL so the FK to `wallet_metadata` doesn't activate. - // - // COALESCE order — `COALESCE(identities.wallet_id, - // excluded.wallet_id)` — preserves an already-parented row's - // wallet_id on re-upsert; the excluded value only fills when - // the on-disk row is still NULL. This is the orphan → parented - // promotion path; the reverse (mismatched re-parent) is caught - // by the per-entry cross-check below. + // COALESCE keeps an already-parented row's wallet_id on re-upsert + // (excluded fills only when on-disk is NULL): the orphan → parented + // promotion path. The all-zero sentinel stores NULL (no parent). let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); let mut stmt = tx.prepare_cached( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, ?3, ?4, 0) \ ON CONFLICT(identity_id) DO UPDATE SET \ wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ - wallet_index = excluded.wallet_index, \ + identity_index = excluded.identity_index, \ entry_blob = excluded.entry_blob, \ tombstoned = 0", )?; let wallet_id_param = wallet_id_to_param(wallet_id); for (id, entry) in &cs.identities { - // The map key is bound into the `identity_id` column while - // `entry` is what the serialized blob carries; a disagreement - // would persist a row whose typed id names a different - // identity than its blob. Reject before encoding so the two - // representations can never diverge on disk. + // Typed id column and blob must name the same identity; reject + // before encoding so the two can never diverge on disk. if entry.id != *id { return Err(WalletStorageError::IdentityEntryIdMismatch); } - // Cross-check: the entry's own wallet_id (when set) must - // agree with the flush scope so the typed columns and the - // serialized blob describe the same parenting. Sentinel - // scope ("no parent wallet known") requires the entry's - // wallet_id to also be `None` — otherwise a real wallet's - // identity would be written under the orphan slot. + // The entry's wallet_id (when set) must match the flush scope; + // sentinel scope requires it to be `None`, else a real wallet's + // identity would land in the orphan slot. if let Some(entry_wallet_id) = entry.wallet_id { if scope_is_sentinel { return Err(WalletStorageError::WalletIdMismatch { @@ -79,18 +67,22 @@ pub fn apply( } } if !cs.removed.is_empty() { - let mut stmt = - tx.prepare_cached("UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1")?; + // Scope the tombstone to the flush wallet (NULL-safe `IS`) so wallet + // A's `removed` set can't tombstone wallet B's identity; the sentinel + // scope maps to NULL and tombstones only orphan rows. + let wallet_id_param = wallet_id_to_param(wallet_id); + let mut stmt = tx.prepare_cached( + "UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1 AND wallet_id IS ?2", + )?; for id in &cs.removed { - stmt.execute(params![id.as_slice()])?; + stmt.execute(params![id.as_slice(), wallet_id_param])?; } } Ok(()) } -/// Map the caller-supplied `WalletId` (32 bytes) to the nullable -/// `identities.wallet_id` column: the all-zero id is treated as "no -/// parent wallet" and stored as NULL so the FK doesn't activate. +/// Map a `WalletId` to the nullable `identities.wallet_id` column: the +/// all-zero sentinel becomes NULL so the FK doesn't activate. fn wallet_id_to_param(wallet_id: &WalletId) -> Option<&[u8]> { if wallet_id.iter().all(|b| *b == 0) { None @@ -112,11 +104,8 @@ pub fn fetch( identity_id: &[u8; 32], ) -> Result, WalletStorageError> { use rusqlite::OptionalExtension; - // Scope the lookup to the caller's wallet so a peer wallet that - // happens to share the identity-id row can never leak through. - // The sentinel WalletId (all zeros) matches orphan rows (NULL - // wallet_id); a real WalletId matches only that wallet's rows. - // `IS` is NULL-safe equality so the NULL branch works uniformly. + // Scope to the caller's wallet (NULL-safe `IS`) so a peer wallet sharing + // the identity-id row can't leak through; sentinel matches orphan rows. let wallet_id_param = wallet_id_to_param(wallet_id); let row: Option<(Vec, i64)> = conn .query_row( @@ -132,29 +121,19 @@ pub fn fetch( } } -/// Build a [`platform_wallet::changeset::IdentityManagerStartState`] -/// for one wallet from the `identities` table. Tombstoned rows are skipped (a logical delete, -/// not corruption); any row that fails to decode is a hard error — -/// corruption is never silently dropped. -/// -/// The bucket selection mirrors `IdentityManager`'s layout: -/// rows with `IdentityEntry.identity_index = Some(_)` go into -/// `wallet_identities[wallet_id]`; rows with `None` go into +/// Build an [`IdentityManagerStartState`](platform_wallet::changeset::IdentityManagerStartState) +/// for one wallet. Tombstoned rows are skipped; a row that fails to decode is +/// a hard error (corruption is never silently dropped). Rows with +/// `identity_index = Some(_)` bucket into `wallet_identities`, `None` into /// `out_of_wallet_identities`. -/// -/// Retained for this crate's integration tests until the -/// `Wallet::from_persisted` rehydration path consumes it in `load()`. -#[cfg(any(test, feature = "__test-helpers"))] pub fn load_state( conn: &Connection, wallet_id: &WalletId, ) -> Result { use platform_wallet::changeset::IdentityManagerStartState; - // `identities.wallet_id` is nullable; this load path wants only the - // rows belonging to the wallet the caller asked for, so the WHERE - // clause matches by wallet_id (orphan identities — wallet_id NULL — - // are out of scope for this per-wallet loader). + // Per-wallet loader: match by wallet_id, so orphan rows (NULL wallet_id) + // are out of scope. let mut stmt = conn.prepare( "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", )?; @@ -189,7 +168,6 @@ pub fn load_state( /// using a freshly minted V0 [`Identity`] for `(id, balance, revision)`. /// Live runtime fields (contacts maps, public-key derivations) are /// recovered separately via the contacts / identity_keys readers. -#[cfg(any(test, feature = "__test-helpers"))] fn managed_identity_from_entry( entry: &IdentityEntry, wallet_id: &WalletId, @@ -220,12 +198,10 @@ fn managed_identity_from_entry( } } -/// Insert a stub identity row so identity_keys / dashpay_profiles can -/// reference it via their native composite FK. Used by tests that exercise -/// identity_keys persistence without going through the full identity -/// flow. The stub row carries a `null`-encoded `IdentityEntry` so the -/// `entry_blob` column always decodes — callers wanting real data -/// overwrite via [`apply`]. +/// Insert a stub identity row (test helper) so identity_keys / +/// dashpay_profiles can reference it via their FK. The stub carries a +/// `null`-encoded `IdentityEntry` so `entry_blob` always decodes; real data +/// overwrites via [`apply`]. #[cfg(any(test, feature = "__test-helpers"))] pub fn ensure_exists( conn: &Connection, @@ -253,7 +229,7 @@ pub fn ensure_exists( let wallet_id_param = wallet_id_to_param(wallet_id); conn.execute( "INSERT OR IGNORE INTO identities \ - (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, NULL, ?3, 0)", params![&identity_id[..], wallet_id_param, payload], )?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index fc85a19c6a7..784480b7719 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -1,23 +1,16 @@ //! `identity_keys` table writer. Stores PUBLIC key material only — no -//! signing-key bytes ever reach this table. +//! signing-key bytes reach this table. //! -//! `IdentityKeyEntry`'s `public_key: dpp::IdentityPublicKey` uses -//! `#[serde(tag = "$formatVersion")]` on the parent enum, which -//! bincode-serde rejects (it requires `deserialize_any`). The other -//! fields are plain serde-compatible types. To keep the -//! "one blob per row" property we transcribe the entry into a wire -//! shape where the public key is bincode-2-native-encoded (the dpp -//! types derive `Encode`/`Decode`) and the surrounding fields ride -//! the bincode-serde encoder. The shape is documented on the -//! `IdentityKeyWire` struct below. +//! `IdentityKeyEntry.public_key`'s `#[serde(tag = ...)]` enum is rejected by +//! bincode-serde (needs `deserialize_any`), so `IdentityKeyWire` pre-encodes +//! the key with bincode's native `Encode`/`Decode` and rides the surrounding +//! fields on the serde encoder, keeping one blob per row. -use rusqlite::{params, Transaction}; +use rusqlite::{params, Connection, Transaction}; use serde::{Deserialize, Serialize}; -use dpp::identity::KeyID; -// Used only by the test-gated `into_entry` and the unit tests below. -#[cfg(any(test, feature = "__test-helpers"))] use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyID; use dpp::prelude::Identifier; use platform_wallet::changeset::{ IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, @@ -27,10 +20,8 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; -/// On-disk wire shape for `IdentityKeyEntry`. The `public_key` field -/// is pre-encoded via bincode 2's native `Encode/Decode` impls on -/// `dpp::IdentityPublicKey` so bincode-serde doesn't trip on dpp's -/// `serde(tag = ...)` representation. +/// On-disk wire shape for `IdentityKeyEntry`, with `public_key_bincode` +/// holding the natively-encoded key (see module docs). #[derive(Debug, Clone, Serialize, Deserialize)] struct IdentityKeyWire { identity_id: Identifier, @@ -41,6 +32,10 @@ struct IdentityKeyWire { derivation_indices: Option, } +// PUBLIC material only reaching `entry_blob`: the wire shape carries +// bincode-encoded public keys + public-key hashes. No private bytes. +crate::sqlite::schema::blob::impl_persistable_blob!(IdentityKeyWire); + impl IdentityKeyWire { fn from_entry(entry: &IdentityKeyEntry) -> Result { let pk = bincode::encode_to_vec(&entry.public_key, bincode::config::standard())?; @@ -54,14 +49,11 @@ impl IdentityKeyWire { }) } - #[cfg(any(test, feature = "__test-helpers"))] fn into_entry(self) -> Result { let (public_key, consumed): (IdentityPublicKey, usize) = bincode::decode_from_slice(&self.public_key_bincode, bincode::config::standard())?; - // Consistent with the outer blob::decode trailing-byte guard: a - // valid-prefix + trailing-garbage payload that bincode's decoder - // happily accepts (it stops after the typed length) is corruption - // or forward-schema drift — refuse it. + // Reject a valid-prefix + trailing-garbage payload (bincode stops + // after the typed length); mirrors the outer blob::decode guard. if consumed != self.public_key_bincode.len() { return Err(WalletStorageError::blob_decode( "unexpected trailing bytes in identity_keys.public_key_bincode", @@ -78,49 +70,42 @@ impl IdentityKeyWire { } } -/// `identity_keys` is keyed by `(identity_id, key_id)`; the parent FK -/// targets `identities(identity_id)`. The caller-supplied [`WalletId`] -/// scopes cross-checks against the entry's own `wallet_id` field so -/// the entry-blob and the typed columns stay aligned. +/// Keyed by `(wallet_id, identity_id, key_id)` with FKs to `wallets` and +/// `identities`. The typed `wallet_id` column comes from the flush scope; the +/// entry's own `wallet_id` (when set) is cross-checked against it so the typed +/// columns and the blob stay aligned. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, cs: &IdentityKeysChangeSet, ) -> Result<(), WalletStorageError> { if !cs.upserts.is_empty() { + // `derivation_blob` is always NULL (reserved); derivation_indices ride + // inside the IdentityKeyWire blob, the source of truth. let mut stmt = tx.prepare_cached( "INSERT INTO identity_keys \ - (identity_id, key_id, public_key_blob, public_key_hash) \ - VALUES (?1, ?2, ?3, ?4) \ - ON CONFLICT(identity_id, key_id) DO UPDATE SET \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, NULL) \ + ON CONFLICT(wallet_id, identity_id, key_id) DO UPDATE SET \ public_key_blob = excluded.public_key_blob, \ - public_key_hash = excluded.public_key_hash", + public_key_hash = excluded.public_key_hash, \ + derivation_blob = NULL", )?; for ((identity_id, key_id), entry) in &cs.upserts { - // Reject any disagreement between the map key / outer - // wallet_id (informational scope) and the entry fields - // (what the serialized blob carries) so the two - // representations of a row can never diverge on disk. + // Typed columns and blob fields must agree so a row can never + // diverge on disk. if entry.identity_id != *identity_id || entry.key_id != *key_id { return Err(WalletStorageError::IdentityKeyEntryMismatch); } - // Sentinel scope ("no parent wallet known") requires the - // entry's wallet_id to also be `None`; a real entry - // wallet_id under sentinel scope would silently file the - // key under the wrong parenting. Non-sentinel scope - // requires the entry's wallet_id (when set) to match - // exactly. - let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); - match (scope_is_sentinel, entry.wallet_id) { - (true, Some(_)) => return Err(WalletStorageError::IdentityKeyEntryMismatch), - (false, Some(entry_wallet_id)) if entry_wallet_id != *wallet_id => { + if let Some(entry_wallet_id) = entry.wallet_id { + if entry_wallet_id != *wallet_id { return Err(WalletStorageError::IdentityKeyEntryMismatch); } - _ => {} } let wire = IdentityKeyWire::from_entry(entry)?; let entry_blob = blob::encode(&wire)?; stmt.execute(params![ + wallet_id.as_slice(), identity_id.as_slice(), i64::from(*key_id), entry_blob, @@ -129,22 +114,63 @@ pub fn apply( } } if !cs.removed.is_empty() { - let mut stmt = - tx.prepare_cached("DELETE FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2")?; + let mut stmt = tx.prepare_cached( + "DELETE FROM identity_keys \ + WHERE wallet_id = ?1 AND identity_id = ?2 AND key_id = ?3", + )?; for (identity_id, key_id) in &cs.removed { - stmt.execute(params![identity_id.as_slice(), i64::from(*key_id)])?; + stmt.execute(params![ + wallet_id.as_slice(), + identity_id.as_slice(), + i64::from(*key_id), + ])?; } } Ok(()) } /// Decode an `identity_keys.public_key_blob` cell back to the entry. -#[cfg(any(test, feature = "__test-helpers"))] pub fn decode_entry(payload: &[u8]) -> Result { let wire: IdentityKeyWire = blob::decode(payload)?; wire.into_entry() } +/// Read every `identity_keys` row for `wallet_id` back into a keyless +/// [`IdentityKeysChangeSet`] (PUBLIC material only — the blob is an +/// `IdentityPublicKey`; private keys are NOT stored or read here). +/// +/// Keyed by `(identity_id, key_id)`; `removed` is always empty (deletes +/// reach storage as `DELETE`s, never as rows). Any row whose blob fails +/// to decode is a hard, typed [`WalletStorageError`] — corruption is +/// never silently dropped. +pub fn load_state( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let mut cs = IdentityKeysChangeSet::default(); + let mut stmt = conn.prepare( + "SELECT identity_id, key_id, public_key_blob FROM identity_keys WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let identity_id_bytes: Vec = row.get(0)?; + let key_id: i64 = row.get(1)?; + let payload: Vec = row.get(2)?; + let id32 = <[u8; 32]>::try_from(identity_id_bytes.as_slice()).map_err(|_| { + WalletStorageError::blob_decode("identity_keys.identity_id is not 32 bytes") + })?; + let identity_id = Identifier::from(id32); + let key_id = KeyID::try_from(key_id).map_err(|_| WalletStorageError::IntegerOverflow { + field: "identity_keys.key_id", + value: key_id as u64, + target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, + })?; + let entry = decode_entry(&payload)?; + cs.upserts.insert((identity_id, key_id), entry); + } + Ok(cs) +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index bcd8ef00ab9..40a8ed251d2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -1,17 +1,10 @@ -//! Per-area SQLite writers + readers. +//! Per-area SQLite writers + readers, one submodule per table or cluster. //! -//! Each submodule owns one table or a small cluster (e.g. `accounts` -//! owns the registration + address-pool tables). Writers take a -//! `&rusqlite::Transaction` and an already resolved sub-changeset; -//! readers take `&rusqlite::Connection`. -//! -//! Encoding policy: scalars that fan out to per-row indexes go into -//! typed SQLite columns (heights, hashes, outpoints, flags). The -//! `_blob` columns carry the full sub-changeset entry encoded with -//! `bincode::serde::encode_to_vec` against the serde-derived types in -//! `platform-wallet` — see [`blob::encode`] / [`blob::decode`]. -//! Schema evolution is gated by the refinery migration version on -//! the database; individual blobs have no inline revision tag. +//! Encoding policy: scalars that fan out to per-row indexes go into typed +//! columns (heights, hashes, outpoints, flags); `_blob` columns carry the +//! full sub-changeset entry via [`blob::encode`] / [`blob::decode`]. Schema +//! evolution is gated by the refinery migration version — blobs carry no +//! inline revision tag. pub mod accounts; pub mod asset_locks; @@ -23,17 +16,12 @@ pub mod identities; pub mod identity_keys; pub mod platform_addrs; pub mod token_balances; -pub mod wallet_meta; +pub mod wallets; -/// Defensive check that every `identity_id` in `touched` exists in -/// `identities` and belongs to `wallet_id` (or has NULL wallet_id when -/// scope is the all-zero sentinel). Used by identity-owned writers -/// (`dashpay`, `token_balances`) to reject mis-attributed callers; the -/// check runs in every build. -/// -/// Returns [`WalletStorageError::WalletIdMismatch`] for the first -/// offending row found. Rows that don't exist in `identities` aren't -/// flagged here — the FK on the child table will reject the write. +/// Reject any `identity_id` in `touched` whose `identities` row does not +/// belong to `wallet_id` (NULL wallet_id matches the all-zero sentinel), +/// returning [`WalletStorageError::WalletIdMismatch`] on the first offender. +/// Absent rows are left to the child-table FK. pub(crate) fn assert_identities_belong_to_wallet( tx: &rusqlite::Transaction<'_>, wallet_id: &platform_wallet::wallet::platform_wallet::WalletId, @@ -48,15 +36,11 @@ pub(crate) fn assert_identities_belong_to_wallet( .query_row(rusqlite::params![identity_id.as_slice()], |row| row.get(0)) .optional()?; let Some(found_wallet_id) = row else { - // Row absent — FK on the child table will reject the - // upcoming write with a clearer error than guessing. + // Row absent — let the child-table FK reject the write. continue; }; - // INTENTIONAL: the `Some(found)` arms below zero-pad a stored - // wallet_id whose width is not 32 into the diagnostic `found` field. - // This is diagnostic-only and cosmetic — a malformed stored width - // already triggers a mismatch error; reporting it zero-padded carries - // no security impact, so a typed length error is not warranted. + // INTENTIONAL: arms below zero-pad a non-32-byte stored wallet_id into + // the diagnostic `found` field — cosmetic only, a mismatch still errors. match (scope_is_sentinel, found_wallet_id) { (true, None) => {} // sentinel scope matches NULL parenting (true, Some(found)) => { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs index 95291ecf3b1..eb12d1d0e55 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -33,11 +33,8 @@ pub fn apply( nonce = excluded.nonce", )?; for entry in &cs.addresses { - // The row is keyed by the outer `wallet_id`; an entry that - // names a different wallet would otherwise be mis-filed. The - // native FK also rejects an unknown parent, but this typed - // error pinpoints the mismatch instead of surfacing a raw - // FOREIGN KEY failure. + // Reject an entry naming a different wallet; the typed error + // pinpoints the mismatch instead of a raw FOREIGN KEY failure. if entry.wallet_id != *wallet_id { return Err(WalletStorageError::WalletIdMismatch { expected: *wallet_id, @@ -141,17 +138,11 @@ pub fn list_per_wallet( Ok(out) } -/// Reassemble the per-account committed state for one wallet from its -/// `platform_payment` registrations (xpub per account index) and its -/// `platform_addresses` rows (the derived address set + known balances). -/// -/// Each registration becomes one [`PerAccountPlatformAddressState`]; the -/// address rows whose `account_index` matches populate its `addresses` -/// bijection and `found` balance map via -/// [`PerAccountPlatformAddressState::insert_persisted_entry`]. Address -/// rows for an account with no registration are skipped — without the -/// xpub the provider can't extend that account's gap window, so there's -/// nothing to restore. +/// Reassemble the per-account committed state from `platform_payment` +/// registrations (one [`PerAccountPlatformAddressState`] each) plus the +/// `platform_addresses` rows whose `account_index` matches. Address rows for an +/// unregistered account are skipped — without the xpub there's nothing to +/// restore. fn build_per_account( registrations: &[accounts::PlatformPaymentRegistration], address_rows: &[PlatformAddressRow], @@ -187,12 +178,9 @@ fn account_state_from_rows( state } -/// Build `PlatformAddressSyncStartState` for one wallet: the -/// network-scoped sync watermark plus the per-account committed state -/// reconstructed from registrations + address rows. -/// -/// `load()` uses the grouped [`load_all`] path; this per-wallet form is -/// retained for this crate's integration tests. +/// Build `PlatformAddressSyncStartState` for one wallet: the sync watermark +/// plus the per-account state from registrations + address rows. `load()` uses +/// the grouped [`load_all`] path; this per-wallet form is for tests. #[cfg(any(test, feature = "__test-helpers"))] pub fn load_state( conn: &Connection, @@ -240,24 +228,19 @@ pub fn count_per_wallet( Ok(usize::try_from(n).unwrap_or(usize::MAX)) } -/// One row of [`load_all`] aggregated state per wallet: -/// `(sync_state, address_row_count)`. -/// -/// `address_row_count` is the number of `platform_addresses` rows for the -/// wallet — `load()` uses it (with the watermark and per_account) to -/// decide whether the wallet carries any platform state worth surfacing. +/// One [`load_all`] row per wallet: `(sync_state, +/// reconstructed_address_count)`. The count includes only address rows +/// actually rebuilt into `sync_state.per_account` (those with a matching +/// registration), so it never claims state `load()` did not surface. pub type LoadAllEntry = (PlatformAddressSyncStartState, usize); -/// Bulk reader for `load()`. Cost is a fixed number of grouped scans — -/// one over `platform_address_sync`, one over `platform_addresses`, and -/// one over the `platform_payment` `account_registrations` — regardless -/// of wallet count, rather than a per-wallet fan-out. +/// Bulk reader for `load()`: three grouped scans (over `platform_address_sync`, +/// `platform_addresses`, and the `platform_payment` `account_registrations`) +/// regardless of wallet count, instead of a per-wallet fan-out. /// -/// Driven by [`wallet_meta::list_ids`](crate::sqlite::schema::wallet_meta::list_ids): -/// orphaned `platform_addresses` / `platform_address_sync` rows whose -/// `wallet_id` is absent from `wallet_metadata` are intentionally NOT -/// surfaced. Native foreign keys prevent such orphans; a future re-wire -/// that needs them must restore the id-union over the area tables. +/// Driven by [`wallets::list_ids`](crate::sqlite::schema::wallets::list_ids), so +/// rows whose `wallet_id` is absent from `wallets` are not surfaced (native FKs +/// prevent such orphans anyway). pub fn load_all(conn: &Connection) -> Result, WalletStorageError> { let sync_by_wallet = all_sync_state(conn)?; let addresses_by_wallet = all_address_rows(conn)?; @@ -267,7 +250,7 @@ pub fn load_all(conn: &Connection) -> Result, W let empty_regs: Vec = Vec::new(); let mut out: BTreeMap = BTreeMap::new(); - for wallet_id in crate::sqlite::schema::wallet_meta::list_ids(conn)? { + for wallet_id in crate::sqlite::schema::wallets::list_ids(conn)? { let (h, t, r) = sync_by_wallet.get(&wallet_id).copied().unwrap_or((0, 0, 0)); let address_rows = addresses_by_wallet.get(&wallet_id).unwrap_or(&empty_rows); let registrations = registrations_by_wallet @@ -279,11 +262,27 @@ pub fn load_all(conn: &Connection) -> Result, W sync_timestamp: t, last_known_recent_block: r, }; - out.insert(wallet_id, (sync, address_rows.len())); + let reconstructed = reconstructed_address_count(registrations, address_rows); + out.insert(wallet_id, (sync, reconstructed)); } Ok(out) } +/// Count the `platform_addresses` rows [`build_per_account`] rebuilds: those +/// whose `account_index` has a matching registration, keeping the count aligned +/// with `per_account`. +fn reconstructed_address_count( + registrations: &[accounts::PlatformPaymentRegistration], + address_rows: &[PlatformAddressRow], +) -> usize { + let registered: std::collections::BTreeSet = + registrations.iter().map(|(idx, _)| *idx).collect(); + address_rows + .iter() + .filter(|r| registered.contains(&r.account_index)) + .count() +} + /// One grouped scan of `platform_address_sync` → `(sync_height, /// sync_timestamp, last_known_recent_block)` per wallet. fn all_sync_state( @@ -367,23 +366,9 @@ fn decode_address_row( let mut hash160 = [0u8; 20]; hash160.copy_from_slice(address_bytes); let balance = safe_cast::i64_to_u64("platform_addresses.balance", balance)?; - let nonce = u32::try_from(nonce).map_err(|_| WalletStorageError::IntegerOverflow { - field: "platform_addresses.nonce", - value: nonce as u64, - target: safe_cast::SafeCastTarget::U64, - })?; - let account_index = - u32::try_from(account_index).map_err(|_| WalletStorageError::IntegerOverflow { - field: "platform_addresses.account_index", - value: account_index as u64, - target: safe_cast::SafeCastTarget::U64, - })?; - let address_index = - u32::try_from(address_index).map_err(|_| WalletStorageError::IntegerOverflow { - field: "platform_addresses.address_index", - value: address_index as u64, - target: safe_cast::SafeCastTarget::U64, - })?; + let nonce = safe_cast::i64_to_u32("platform_addresses.nonce", nonce)?; + let account_index = safe_cast::i64_to_u32("platform_addresses.account_index", account_index)?; + let address_index = safe_cast::i64_to_u32("platform_addresses.address_index", address_index)?; Ok(PlatformAddressRow { account_index, address_index, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs index 8c8d05de68e..25df36e4a4d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/token_balances.rs @@ -2,15 +2,11 @@ //! //! # Precondition //! -//! Every `identity_id` in the supplied changeset MUST already exist in -//! the `identities` table and belong to the flush's `wallet_id` (or -//! have a NULL `identities.wallet_id` when the scope is the all-zero -//! sentinel). The writer relies on -//! [`super::identities::apply`] for parenting; the FK to -//! `identities(identity_id)` enforces existence but not the wallet -//! match. The precondition check below runs in every build and -//! propagates [`WalletStorageError::WalletIdMismatch`] on a -//! mis-attributed caller. +//! Every `identity_id` MUST already exist in `identities` and belong to the +//! flush's `wallet_id` (or have NULL `wallet_id` for the all-zero sentinel +//! scope). The FK enforces existence; the wallet match is checked here and +//! propagates [`WalletStorageError::WalletIdMismatch`] on a mis-attributed +//! caller. use rusqlite::{params, Transaction}; @@ -20,16 +16,11 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::util::safe_cast; -/// `token_balances` is keyed by `(identity_id, token_id)`. The caller -/// supplies a [`WalletId`] for symmetry with sibling writers and to -/// feed the precondition check; it does not feed any column, because -/// cascade flows -/// `wallet_metadata → identities → token_balances` through the -/// nullable `identities.wallet_id` FK. -// -// Orphan-row policy: there is no automatic prune API. Cascade flows -// through `identities`; hosts that delete identities out-of-band must -// prune `token_balances` themselves. +/// Keyed by `(identity_id, token_id)`. `wallet_id` feeds the precondition +/// check only — no column — since cascade flows +/// `wallets → identities → token_balances` via the nullable +/// `identities.wallet_id` FK. No auto-prune: hosts deleting identities +/// out-of-band must prune `token_balances` themselves. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs similarity index 65% rename from packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs rename to packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs index a76b517ca73..66d3dbaed3c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/wallet_meta.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/wallets.rs @@ -1,4 +1,4 @@ -//! `wallet_metadata` writer + helpers. +//! `wallets` writer + helpers. use rusqlite::{params, Connection, Transaction}; @@ -7,7 +7,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; -/// Insert / replace a `wallet_metadata` row. +/// Insert / replace a `wallets` row. pub fn upsert( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -15,7 +15,7 @@ pub fn upsert( ) -> Result<(), WalletStorageError> { let network = network_to_str(entry.network); let mut stmt = tx.prepare_cached( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, ?2, ?3) \ ON CONFLICT(wallet_id) DO UPDATE SET network = excluded.network, \ birth_height = excluded.birth_height", @@ -24,16 +24,12 @@ pub fn upsert( Ok(()) } -/// Ensure a `wallet_metadata` parent row exists for the given id. Used -/// by tests that exercise persistence without going through registration. -/// -/// Idempotent — silently a no-op when the row already exists. Defaults -/// `network = "testnet"`, `birth_height = 0` (the same fall-back the -/// SPV scan uses when the chain tip is unknown). +/// Ensure a `wallets` parent row exists for the given id (test helper). +/// Idempotent; defaults `network = "testnet"`, `birth_height = 0`. #[cfg(any(test, feature = "__test-helpers"))] pub fn ensure_exists(conn: &Connection, wallet_id: &WalletId) -> Result<(), WalletStorageError> { conn.execute( - "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, ?2, ?3)", params![wallet_id.as_slice(), "testnet", 0i64], )?; @@ -42,7 +38,7 @@ pub fn ensure_exists(conn: &Connection, wallet_id: &WalletId) -> Result<(), Wall /// All known wallet ids (used by `delete_wallet`, `load`, `inspect`). pub fn list_ids(conn: &Connection) -> Result, WalletStorageError> { - let mut stmt = conn.prepare("SELECT wallet_id FROM wallet_metadata ORDER BY wallet_id")?; + let mut stmt = conn.prepare("SELECT wallet_id FROM wallets ORDER BY wallet_id")?; let rows = stmt.query_map([], |row| row.get::<_, Vec>(0))?; let mut out = Vec::new(); for r in rows { @@ -58,48 +54,36 @@ pub fn list_ids(conn: &Connection) -> Result, WalletStorageError> } /// Lookup `(network, birth_height)` for a wallet, if known. -#[cfg(any(test, feature = "__test-helpers"))] pub fn fetch( conn: &Connection, wallet_id: &WalletId, ) -> Result, WalletStorageError> { let mut stmt = - conn.prepare("SELECT network, birth_height FROM wallet_metadata WHERE wallet_id = ?1")?; + conn.prepare("SELECT network, birth_height FROM wallets WHERE wallet_id = ?1")?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; if let Some(row) = rows.next()? { let network: String = row.get(0)?; let height: i64 = row.get(1)?; - let height = u32::try_from(height).map_err(|_| WalletStorageError::IntegerOverflow { - field: "wallet_metadata.birth_height", - value: height as u64, - target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, - })?; + let height = crate::sqlite::util::safe_cast::i64_to_u32("wallets.birth_height", height)?; Ok(Some((network, height))) } else { Ok(None) } } -/// Delete a wallet_metadata row (native `ON DELETE CASCADE` fires). +/// Delete a wallets row (native `ON DELETE CASCADE` fires). pub fn delete(tx: &Transaction<'_>, wallet_id: &WalletId) -> Result { let n = tx.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![wallet_id.as_slice()], )?; Ok(n) } -/// Single source of truth for the `wallet_metadata.network` TEXT-column -/// domain. -/// -/// Mirrors every variant of [`key_wallet::Network`] (writer side: -/// [`network_to_str`]). The migration in `migrations/V001__initial.rs` -/// interpolates this array into a `CHECK (network IN (...))` clause so -/// an unknown label is rejected at insert time rather than landing as -/// silent garbage. The `network_labels_match_enum` unit test below -/// enforces set-equality between this array and the writer's output — -/// drift (a renamed/added variant) becomes a failing test, not a -/// runtime divergence between Rust and SQLite. +/// Source of truth for the `wallets.network` TEXT domain, mirroring +/// [`key_wallet::Network`]. `migrations/V001__initial.rs` interpolates it into +/// a `CHECK (network IN (...))` clause; `network_labels_match_enum` keeps it in +/// sync with [`network_to_str`]. pub(crate) const NETWORK_LABELS: &[&str] = &["mainnet", "testnet", "devnet", "regtest"]; fn network_to_str(net: key_wallet::Network) -> &'static str { @@ -112,7 +96,6 @@ fn network_to_str(net: key_wallet::Network) -> &'static str { } /// Inverse of `network_to_str`. -#[cfg(any(test, feature = "__test-helpers"))] pub fn parse_network(s: &str) -> Option { match s { "mainnet" => Some(key_wallet::Network::Mainnet), @@ -128,13 +111,9 @@ mod tests { use super::*; use std::collections::HashSet; - /// Every [`key_wallet::Network`] variant — kept exhaustive by the - /// `match` arm below, which the compiler's exhaustiveness check - /// turns into a build failure if upstream adds a variant. + /// Every [`key_wallet::Network`] variant; the `match` below fails to + /// compile if upstream adds one, keeping the list in lockstep. fn all_network_variants() -> Vec { - // The match's exhaustiveness fails to compile on a new variant. - // Mapping every existing variant to itself keeps the list and the - // enum in lockstep. let variants = [ key_wallet::Network::Mainnet, key_wallet::Network::Testnet, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs index c02632913b2..1ef1f5823ac 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/safe_cast.rs @@ -1,18 +1,10 @@ //! Safe integer conversions for the SQLite `INTEGER` column boundary. //! -//! SQLite's `INTEGER` affinity is `i64`. Rust's wallet types (credits -//! balances, durations cast to milliseconds, monotonic-max heights, -//! token balances) are `u64`. Naively `as i64` casting wraps values -//! ≥ `i64::MAX` to negative numbers and silently sign-extends them -//! back to large `u64` on read. -//! -//! Every cross-boundary cast in the writer / reader paths runs through -//! one of these helpers and produces a typed +//! SQLite's `INTEGER` affinity is `i64`, but wallet types are `u64`; a +//! naive `as i64` wraps values ≥ `i64::MAX` to negatives and sign-extends +//! them back on read. Every durable boundary cast routes through one of +//! these helpers, which return a typed //! [`WalletStorageError::IntegerOverflow`] on out-of-range input. -//! `clippy::cast_possible_wrap` and `cast_sign_loss` warnings stay -//! allowed crate-wide because many in-crate casts are bounded (e.g. -//! `u8` tags, `u32` indices ≤ `i32::MAX`); the contract is that -//! *durable boundary casts* go through this module. use crate::sqlite::error::WalletStorageError; @@ -23,6 +15,8 @@ pub enum SafeCastTarget { I64, #[error("u64")] U64, + #[error("u32")] + U32, } /// Cast `value: u64` to `i64`, surfacing @@ -40,24 +34,62 @@ pub fn u64_to_i64(field: &'static str, value: u64) -> Result Result { u64::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { field, - // For negative inputs the wrapped representation is what we - // surface — the operator looks at the original bits, not the - // post-cast u64 garbage. + // Surface the original bit pattern, not post-cast garbage. value: value as u64, target: SafeCastTarget::U64, }) } +/// Cast a stored `i64` column to `u32`, surfacing +/// [`WalletStorageError::IntegerOverflow`] when the value is negative or +/// exceeds `u32::MAX`. The single boundary helper for the readers that +/// map `INTEGER` columns (heights, account/address indices, nonces) back +/// to their `u32` Rust types. +/// +/// `field` is a compile-time identifier (e.g. +/// `"core_sync_state.synced_height"`) naming the column so the resulting +/// error is actionable. +pub fn i64_to_u32(field: &'static str, value: i64) -> Result { + u32::try_from(value).map_err(|_| WalletStorageError::IntegerOverflow { + field, + value: value as u64, + target: SafeCastTarget::U32, + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn i64_to_u32_happy_path() { + assert_eq!(i64_to_u32("x", 0).unwrap(), 0); + assert_eq!(i64_to_u32("x", u32::MAX as i64).unwrap(), u32::MAX); + } + + #[test] + fn i64_to_u32_overflow_high_and_negative() { + assert!(matches!( + i64_to_u32("h", i64::from(u32::MAX) + 1).unwrap_err(), + WalletStorageError::IntegerOverflow { + target: SafeCastTarget::U32, + .. + } + )); + assert!(matches!( + i64_to_u32("h", -1).unwrap_err(), + WalletStorageError::IntegerOverflow { + target: SafeCastTarget::U32, + .. + } + )); + } + #[test] fn u64_to_i64_happy_path() { assert_eq!(u64_to_i64("x", 0).unwrap(), 0); diff --git a/packages/rs-platform-wallet-storage/tests/common/mod.rs b/packages/rs-platform-wallet-storage/tests/common/mod.rs index cc0f4d1d70d..6f58c0c81df 100644 --- a/packages/rs-platform-wallet-storage/tests/common/mod.rs +++ b/packages/rs-platform-wallet-storage/tests/common/mod.rs @@ -41,18 +41,18 @@ pub fn ro_conn(path: &std::path::Path) -> Connection { .expect("open ro conn") } -/// Insert a stub `wallet_metadata` row so child writes pass the native +/// Insert a stub `wallets` row so child writes pass the native /// FK. Bypasses the buffer/flush layer — tests use this when they /// want to exercise a single sub-changeset writer in isolation. pub fn ensure_wallet_meta(persister: &SqlitePersister, wallet_id: &WalletId) { use rusqlite::params; let conn = persister.lock_conn_for_test(); conn.execute( - "INSERT OR IGNORE INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![wallet_id.as_slice()], ) - .expect("ensure wallet_metadata"); + .expect("ensure wallets"); } /// Insert a stub `identities` row so identity-owned table writes @@ -66,16 +66,14 @@ pub fn ensure_identity( identity_id: &[u8; 32], parent_wallet_id: Option<&WalletId>, ) { - use rusqlite::params; let conn = persister.lock_conn_for_test(); - let wid_param: Option<&[u8]> = parent_wallet_id.map(|w| w.as_slice()); - conn.execute( - "INSERT OR IGNORE INTO identities \ - (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ - VALUES (?1, ?2, NULL, X'00', 0)", - params![&identity_id[..], wid_param], - ) - .expect("ensure identity"); + // Delegate to the production stub writer so `entry_blob` holds a + // real, decodable `IdentityEntry` (the wired `load()` decodes every + // identity row). The all-zero sentinel WalletId maps to a NULL + // `wallet_id` column, so `None` lands as an orphan identity. + let scope: WalletId = parent_wallet_id.copied().unwrap_or([0u8; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists(&conn, &scope, identity_id) + .expect("ensure identity"); } /// Insert a stub `token_balances` row so `meta_token` writes pass the @@ -100,7 +98,7 @@ pub fn ensure_token_balance( /// Insert a stub `established` row in the unified `contacts` table so /// the `cascade_meta_contact_on_contact_delete` trigger has an /// established-contact parent to fire on for `meta_contact` writes keyed -/// by `(wallet_id, owner_id, contact_id)`. The parent `wallet_metadata` +/// by `(wallet_id, owner_id, contact_id)`. The parent `wallets` /// row must already exist (seed via [`ensure_wallet_meta`]). pub fn ensure_contact_established( persister: &SqlitePersister, @@ -121,7 +119,7 @@ pub fn ensure_contact_established( /// Insert a stub `sent` contact row (pending outgoing request) so a /// `meta_contact` write keyed by `(wallet_id, owner_id, contact_id)` has -/// a non-established parent to exercise. The parent `wallet_metadata` +/// a non-established parent to exercise. The parent `wallets` /// row must already exist. pub fn ensure_contact_sent( persister: &SqlitePersister, @@ -162,7 +160,7 @@ pub fn ensure_contact_received( /// Insert a stub `platform_addresses` row so `meta_platform_address` /// writes pass the composite FK to /// `platform_addresses(wallet_id, address)`. The parent -/// `wallet_metadata` row must already exist (seed via +/// `wallets` row must already exist (seed via /// [`ensure_wallet_meta`]). `address` is an opaque BLOB. pub fn ensure_platform_address(persister: &SqlitePersister, wallet_id: &WalletId, address: &[u8]) { use rusqlite::params; diff --git a/packages/rs-platform-wallet-storage/tests/secrets_api.rs b/packages/rs-platform-wallet-storage/tests/secrets_api.rs index aab8ab8d57c..9c57c659b71 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_api.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_api.rs @@ -17,6 +17,14 @@ use platform_wallet_storage::secrets::{ }; fn vault_path(dir: &Path) -> PathBuf { + // `open` refuses a group/other-writable parent dir; a umask-0002 + // tempdir lands at 0o775, so tighten it to 0o700 first. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)) + .expect("tighten vault parent dir to 0o700 so open() passes the perm check"); + } dir.join("vault.pwsvault") } @@ -169,3 +177,75 @@ fn wrapper_debug_is_redacted() { let s = SecretString::new("PLAINTEXTNEEDLE"); assert!(!format!("{s:?}").contains("PLAINTEXT")); } + +/// SECRETS.md (the on-disk vault is "explicitly attacker-controllable", +/// defenses must "fail closed", error doc: "malformed vault file ... +/// truncated header"). A garbage / truncated / empty / non-UTF-8 vault +/// fed through the FULL `EncryptedFileStore::open` integration path +/// (`read_vault_at` -> `Vec::with_capacity(len)` -> `format::deserialize`) +/// must surface a clean typed `MalformedVault` and NEVER panic. The +/// `format.rs` unit tests exercise `deserialize` in isolation; they do +/// not prove the file-open seam (perms check, size cap, allocation, +/// take()) is wired to the same clean-error outcome. +#[cfg(unix)] +#[test] +fn garbage_vault_file_fails_closed_at_open_no_panic() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let cases: &[(&str, &[u8])] = &[ + ("empty", b""), + ("ascii-garbage", b"this is not a vault at all"), + ("truncated-json", b"{\"version\":1,\"kdf\":{\"id\":1,"), + ("non-utf8", &[0xff, 0xfe, 0x00, 0x01, 0x80, 0x80]), + ("json-but-not-a-vault", b"{\"hello\":\"world\"}"), + ]; + for (name, bytes) in cases { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + fs::write(&path, bytes).unwrap(); + // Match the resident-vault perm precondition so the failure is + // attributable to parsing, not to the (separately tested) perm + // refusal. + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("garbage vault must fail to open"); + assert!( + matches!(err, SecretStoreError::MalformedVault), + "case `{name}`: expected MalformedVault, got {err:?}" + ); + // The clean error must not echo the offending input bytes. + let rendered = format!("{err}"); + assert!( + !rendered.contains("not a vault") && !rendered.contains("hello"), + "case `{name}`: error leaked input bytes: {rendered}" + ); + } +} + +/// SECRETS.md: an unknown/rolled-forward `format_version` is refused +/// fail-closed through the file-open seam, distinct from a malformed +/// body. The format.rs unit test proves the parser; this proves the +/// `open()` path preserves the distinction end to end. +#[cfg(unix)] +#[test] +fn unknown_version_vault_is_refused_at_open() { + use std::fs; + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + // A structurally JSON document whose `version` is in the future: + // the lax probe reads it, then the version gate rejects it before + // any KDF/AEAD work. + fs::write(&path, br#"{"version":999,"extra":"tolerated-by-probe"}"#).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + + let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) + .expect_err("unknown version must fail to open"); + assert!( + matches!(err, SecretStoreError::VersionUnsupported { found: 999 }), + "expected VersionUnsupported{{999}}, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs b/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs index 23cc10d582e..1f45e2ceaeb 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs @@ -9,7 +9,7 @@ use platform_wallet_storage::secrets::{ default_credential_store, EncryptedFileStore, SecretBytes, SecretStoreError, SecretString, - WalletId, SERVICE_PREFIX, + WalletId, MAX_PLAINTEXT_LEN, MIN_PASSPHRASE_LEN, SERVICE_PREFIX, }; #[test] @@ -23,6 +23,9 @@ fn default_build_exposes_secrets_surface() { } let _ = _accepts_path as fn(_, _) -> _; let _ = SERVICE_PREFIX.len(); + // The Tier-2 public consts are re-exported on the default build. + let _ = MAX_PLAINTEXT_LEN; + let _ = MIN_PASSPHRASE_LEN; let _ = std::mem::size_of::(); let _ = std::mem::size_of::(); let _ = std::mem::size_of::(); diff --git a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs index 68e0cf48d01..9f1111f2899 100644 --- a/packages/rs-platform-wallet-storage/tests/secrets_scan.rs +++ b/packages/rs-platform-wallet-storage/tests/secrets_scan.rs @@ -10,17 +10,13 @@ //! `mnemonic`, `seed`, `xpriv`, or `secret` breaks the test, forcing //! the author to rename or add an allow-list entry with rationale. //! -//! Out of scope by design: files in `src/sqlite/` outside of -//! `schema/` (`persister.rs`, `backup.rs`, `buffer.rs`, `config.rs`, -//! `error.rs`, `migrations.rs`, `util/`) are NOT scanned. They never -//! define database columns and may legitimately reference the -//! forbidden tokens in doc comments. The future `src/secrets/` -//! submodule slot is exempt for the same reason. -//! -//! The check is intentionally string-level: it does not parse SQL or -//! Rust. A column literally named `private_X` is the kind of mistake -//! we want to catch; legitimate uses inside doc comments are -//! allow-listed via the `ALLOWLIST` constant below. +//! Scope and blind spots: this is a column/comment NAMING scan, not a +//! value-content scan — it cannot see the bytes a serialized value +//! carries. Value-level safety is a separate guarantee via the sealed +//! `PersistableBlob` trait in `src/sqlite/schema/blob.rs`. Files outside +//! `schema/` define no columns and are not scanned; `src/secrets/` is +//! exempt by design and covered by its own `tests/secrets_guard.rs`. +//! Legitimate uses inside doc comments are allow-listed via `ALLOWLIST`. use std::path::Path; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs new file mode 100644 index 00000000000..d604c48aa87 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs @@ -0,0 +1,121 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Genesis-rescan regression for the hardcoded `account_index = 0` design +//! (PR #3828). +//! +//! Before: a UTXO landing on a freshly-derived gap-limit-edge address could +//! race address-derivation persistence and be mis-attributed or dropped, so +//! the bridge smuggled a full pool snapshot in-band to resolve it. Now UTXO +//! attribution is hardcoded to the default account (index 0) at the storage +//! writer — no in-band snapshot, no address→account lookup table. This test +//! pins that a UTXO on a real gap-limit-edge address persists directly with +//! `account_index == 0`, contributes the exact balance, and never aborts +//! the flush. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::AddressInfo; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::core_state; + +/// The LAST address in the wallet's Standard BIP44 external pool — the +/// gap-limit-edge address, the one most likely to be a fresh extension and +/// thus the worst case for the retired attribution race. +fn gap_limit_edge_address(seed_byte: u8) -> AddressInfo { + use key_wallet::account::AccountType; + use key_wallet::managed_account::address_pool::AddressPoolType; + + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + + for managed in info.all_managed_accounts() { + let account_type = managed.managed_account_type().to_account_type(); + if !matches!(account_type, AccountType::Standard { index: 0, .. }) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let infos: Vec = pool.addresses.values().cloned().collect(); + return infos.last().cloned().unwrap(); + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { + use dashcore::hashes::Hash; + key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x7E; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr.clone(), + height: 7, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +/// A UTXO on a freshly-derived gap-limit-edge address persists directly with +/// the hardcoded `account_index == 0`: no snapshot, no lookup, no flush +/// abort, and the unspent balance is exact. +#[test] +fn utxo_on_fresh_gap_limit_address_persists_under_account_zero() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + let edge = gap_limit_edge_address(0x55); + let addr = edge.address.clone(); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_at(&addr, 0, 777_000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("a UTXO on a fresh gap-limit address must persist, not abort"); + + let conn = persister.lock_conn_for_test(); + let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); + + let total: usize = by_account.values().map(|v| v.len()).sum(); + assert_eq!(total, 1, "the edge-address UTXO must persist"); + + let rows = by_account + .get(&0) + .expect("UTXO must be attributed to the default account (index 0)"); + assert_eq!( + rows.len(), + 1, + "exactly the one edge-address UTXO under account 0" + ); + assert_eq!(rows[0].value, 777_000, "value preserved"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs new file mode 100644 index 00000000000..5839b6e0975 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs @@ -0,0 +1,125 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `schema::accounts::load_state` reads `account_registrations` rows back +//! into a keyless [`AccountRegistrationEntry`] manifest, bit-exact, +//! fail-hard on a corrupt blob, and never mints a `Wallet`. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::AccountType; +use platform_wallet::changeset::{AccountRegistrationEntry, PlatformWalletChangeSet}; +use platform_wallet_storage::sqlite::schema::accounts; +use platform_wallet_storage::WalletStorageError; + +fn xpub() -> key_wallet::bip32::ExtendedPubKey { + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + let w = Wallet::from_seed_bytes( + [7u8; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .expect("wallet"); + w.accounts + .all_accounts() + .first() + .expect("at least one account") + .account_xpub +} + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// Registrations round-trip bit-exact, in stable order. +#[test] +fn a1_account_registrations_roundtrip() { + let (persister, _tmp, path) = fresh_persister(); + use platform_wallet::changeset::PlatformWalletPersistence; + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); + + let entries = vec![ + AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + account_xpub: xpub(), + }, + AccountRegistrationEntry { + account_type: AccountType::IdentityRegistration, + account_xpub: xpub(), + }, + ]; + let cs = PlatformWalletChangeSet { + account_registrations: entries.clone(), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w).expect("load_state"); + drop(conn); + + assert_eq!(manifest.len(), 2, "all rows must be returned"); + // Bit-exact xpub round-trip. + for e in &manifest { + assert_eq!(e.account_xpub, xpub()); + } + let has_standard = manifest + .iter() + .any(|e| matches!(e.account_type, AccountType::Standard { index: 0, .. })); + let has_idreg = manifest + .iter() + .any(|e| matches!(e.account_type, AccountType::IdentityRegistration)); + assert!(has_standard && has_idreg); +} + +/// An empty wallet yields an empty manifest, not an error. +#[test] +fn a1_empty_manifest_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let manifest = accounts::load_state(&conn, &w).expect("load_state"); + drop(conn); + assert!(manifest.is_empty()); +} + +/// A corrupt `account_xpub_bytes` blob is a typed hard error, never a +/// silent skip. +#[test] +fn a1_corrupt_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA3); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, account_xpub_bytes) \ + VALUES (?1, 'standard_bip44', 0, X'00')", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = accounts::load_state(&conn, &w); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt account_xpub_bytes must be a typed BincodeDecode; got {result:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs new file mode 100644 index 00000000000..f187fccdfbe --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_asset_locks_filter.rs @@ -0,0 +1,143 @@ +#![allow(clippy::field_reassign_with_default)] + +//! The status-filtered asset-lock reader excludes terminal `Consumed` +//! rows so a spent one-shot lock never resurrects as actionable on +//! rehydration, while the historical row stays on disk. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, Transaction, Txid}; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry, PlatformWalletPersistence}; +use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; +use platform_wallet_storage::sqlite::schema::asset_locks; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen") +} + +fn entry(op: OutPoint, status: AssetLockStatus) -> AssetLockEntry { + AssetLockEntry { + out_point: op, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1000, + status, + proof: None, + } +} + +fn op(b: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([b; 32]), + vout: 0, + } +} + +/// Store a mix including one terminal `Consumed`. After reopen: the +/// `Consumed` row is still on disk, is absent from the filtered +/// rehydration feed, and non-terminal rows survive. +#[test] +fn rt4_consumed_excluded_from_rehydration_feed() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA4); + ensure_wallet_meta(&persister, &w); + + let op_built = op(0x10); + let op_cl = op(0x11); + let op_consumed = op(0x12); + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks + .insert(op_built, entry(op_built, AssetLockStatus::Built)); + cs.asset_locks + .insert(op_cl, entry(op_cl, AssetLockStatus::ChainLocked)); + cs.asset_locks + .insert(op_consumed, entry(op_consumed, AssetLockStatus::Consumed)); + persister + .store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + + // (a) the Consumed row is still physically on disk. + let consumed_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM asset_locks WHERE wallet_id = ?1 AND status = 'consumed'", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(consumed_rows, 1, "Consumed row must persist on disk"); + + // Unfiltered reader still returns the Consumed entry... + let unfiltered = asset_locks::load_state(&conn, &w).unwrap(); + let all_ops: Vec<_> = unfiltered + .values() + .flat_map(|m| m.keys().copied()) + .collect(); + assert!( + all_ops.contains(&op_consumed), + "unfiltered load_state must still see Consumed (historical)" + ); + + // (b)+(c) the filtered rehydration feed excludes Consumed, keeps + // the rest. + let feed = asset_locks::load_unconsumed(&conn, &w).unwrap(); + drop(conn); + let feed_ops: Vec<_> = feed.values().flat_map(|m| m.keys().copied()).collect(); + assert!( + !feed_ops.contains(&op_consumed), + "Consumed must NOT resurrect in the rehydration feed" + ); + assert!(feed_ops.contains(&op_built), "Built must survive"); + assert!(feed_ops.contains(&op_cl), "ChainLocked must survive"); + assert_eq!(feed_ops.len(), 2); +} + +/// An all-consumed wallet yields an empty rehydration feed, no error. +#[test] +fn a2_all_consumed_yields_empty_feed() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xA5); + ensure_wallet_meta(&persister, &w); + let o = op(0x20); + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks + .insert(o, entry(o, AssetLockStatus::Consumed)); + persister + .store( + w, + platform_wallet::changeset::PlatformWalletChangeSet { + asset_locks: Some(cs), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let feed = asset_locks::load_unconsumed(&conn, &w).unwrap(); + drop(conn); + assert!(feed.is_empty(), "all-consumed wallet → empty feed"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs index 085169cd2d4..0597725c44a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs @@ -70,7 +70,7 @@ fn tc052_delete_wallet_auto_backup_disabled() { let conn = persister.lock_conn_for_test(); let n: i64 = conn .query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) @@ -106,7 +106,7 @@ fn tc054_unwritable_auto_backup_dir() { let conn = persister.lock_conn_for_test(); let n: i64 = conn .query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) @@ -144,3 +144,82 @@ fn tc055_auto_backups_subject_to_retention() { assert_eq!(report.kept, 2); assert_eq!(report.removed.len(), 3); } + +/// Prune orders by the EMBEDDED filename timestamp, not mtime (proven by +/// giving older files newer mtimes). With `keep_last_n = 1` it evicts even +/// a pre-delete safety backup when that backup is not the newest by +/// embedded timestamp: the auto dir is not a protected vault, so operators +/// must size retention above the rollback horizon they care about. +#[test] +fn tc056_aggressive_prune_evicts_safety_backup_and_orders_by_embedded_ts() { + let (persister, _tmp, _path) = fresh_persister(); + let dir = persister.config_for_test().auto_backup_dir.clone().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let stamp = |hours_ago: i64| { + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::hours(hours_ago)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + .to_string() + }; + + // Newest by embedded timestamp: a manual backup taken AFTER the + // delete. The pre-delete safety backup is older by embedded ts. + let manual = dir.join(format!("wallet-{}.db", stamp(0))); + let safety = dir.join(format!( + "pre-delete-{}-{}.db", + hex::encode([0x11u8; 32]), + stamp(1) + )); + let old_manual = dir.join(format!("wallet-{}.db", stamp(48))); + std::fs::write(&manual, b"m").unwrap(); + std::fs::write(&safety, b"s").unwrap(); + std::fs::write(&old_manual, b"o").unwrap(); + + // Invert mtime vs embedded order: give the OLDEST-by-embedded-ts + // file the NEWEST mtime. If prune (wrongly) sorted by mtime, it + // would keep `old_manual`; sorting by the embedded token keeps + // `manual`. This deterministically exercises the embedded-timestamp + // path rather than the mtime fallback. + let now = std::time::SystemTime::now(); + let hour = std::time::Duration::from_secs(3600); + filetime::set_file_mtime(&old_manual, filetime::FileTime::from_system_time(now)).unwrap(); + filetime::set_file_mtime(&safety, filetime::FileTime::from_system_time(now - hour)).unwrap(); + filetime::set_file_mtime( + &manual, + filetime::FileTime::from_system_time(now - hour * 2), + ) + .unwrap(); + + let report = persister + .prune_backups( + &dir, + platform_wallet_storage::RetentionPolicy { + keep_last_n: Some(1), + max_age: None, + }, + ) + .unwrap(); + + assert_eq!(report.kept, 1, "keep_last_n = 1 keeps exactly one file"); + assert_eq!(report.removed.len(), 2); + // Embedded-ts ordering kept the newest-by-token file (`manual`), + // NOT the newest-by-mtime file (`old_manual`). + assert!( + manual.exists(), + "newest-by-embedded-timestamp file must survive keep_last_n = 1" + ); + assert!( + !old_manual.exists(), + "an old file with a fresh mtime must NOT be treated as newest" + ); + // The safety backup is NOT special-cased: aggressive retention + // evicts it. Operators must size retention above the rollback + // horizon they care about. + assert!( + !safety.exists(), + "pre-delete safety backup is evicted by keep_last_n = 1 when not newest \ + (auto dir is not a protected vault)" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs index 129e76bfdf7..68eadff1d13 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_check_constraints.rs @@ -1,8 +1,7 @@ -//! Smoke tests for the enum-domain `CHECK` constraints on the five -//! enum-shaped TEXT columns (`wallet_metadata.network`, -//! `account_registrations.account_type`, -//! `account_address_pools.account_type`/`pool_type`, -//! `core_derived_addresses.account_type`, and `asset_locks.status`). +//! Smoke tests for the enum-domain `CHECK` constraints. The schema has +//! four such TEXT columns across four domains: `wallets.network`, +//! `account_registrations.account_type`, `asset_locks.status`, and the +//! synthetic `contacts.state`. These tests exercise each directly. //! //! The per-module parity unit tests in `src/sqlite/schema/*` cover the //! Rust↔const-array equality. These tests cover the runtime half: a @@ -43,10 +42,10 @@ fn check_rejects_bad_network_label() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); let res = conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid(1).as_slice(), "not-a-network", 0i64], ); - assert_constraint_check(res, "wallet_metadata.network"); + assert_constraint_check(res, "wallets.network"); } #[test] @@ -55,10 +54,10 @@ fn check_rejects_bad_account_type_on_registrations() { let conn = persister.lock_conn_for_test(); // First seed a valid parent row so we don't trip the FK. conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid(2).as_slice(), "testnet", 0i64], ) - .expect("seed wallet_metadata"); + .expect("seed wallets"); let res = conn.execute( "INSERT INTO account_registrations \ (wallet_id, account_type, account_index, account_xpub_bytes) \ @@ -68,39 +67,15 @@ fn check_rejects_bad_account_type_on_registrations() { assert_constraint_check(res, "account_registrations.account_type"); } -#[test] -fn check_rejects_bad_pool_type() { - let (persister, _tmp, _path) = fresh_persister(); - let conn = persister.lock_conn_for_test(); - conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", - params![wid(3).as_slice(), "testnet", 0i64], - ) - .expect("seed wallet_metadata"); - let res = conn.execute( - "INSERT INTO account_address_pools \ - (wallet_id, account_type, account_index, pool_type, snapshot_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5)", - params![ - wid(3).as_slice(), - "standard", - 0i64, - "not_a_pool", - &[0u8; 4][..] - ], - ); - assert_constraint_check(res, "account_address_pools.pool_type"); -} - #[test] fn check_rejects_bad_asset_lock_status() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid(4).as_slice(), "testnet", 0i64], ) - .expect("seed wallet_metadata"); + .expect("seed wallets"); let res = conn.execute( "INSERT INTO asset_locks \ (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) \ @@ -128,9 +103,64 @@ fn check_accepts_every_known_label_network() { { let wid_bytes = [i as u8 + 10; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", params![wid_bytes.as_slice(), *label, 0i64], ) .unwrap_or_else(|e| panic!("network={label} should be accepted: {e}")); } } + +#[test] +fn check_rejects_bad_contact_state() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + // Seed a valid parent wallet so the insert trips the state CHECK, not the FK. + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(7).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + let res = conn.execute( + "INSERT INTO contacts (wallet_id, owner_id, contact_id, state) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + wid(7).as_slice(), + &[0xAAu8; 32][..], + &[0xBBu8; 32][..], + "not_a_contact_state" + ], + ); + assert_constraint_check(res, "contacts.state"); +} + +#[test] +fn check_accepts_every_known_contact_state_label() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, ?2, ?3)", + params![wid(8).as_slice(), "testnet", 0i64], + ) + .expect("seed wallets"); + // Mirrors `sqlite::schema::contacts::CONTACT_STATE_LABELS`; hardcoded + // because that const is `pub(crate)` and unreachable from this separate + // integration-test crate (same constraint as the network test above). + // The per-module `contact_state_labels_match_enum` unit test guards the + // const itself against drift, so a label added there without updating + // this list surfaces in that test, not as a silent gap here. + for (i, label) in ["sent", "received", "established"].iter().enumerate() { + // Same wallet+owner, distinct contact_id per label to keep the + // composite PK (wallet_id, owner_id, contact_id) unique. + conn.execute( + "INSERT INTO contacts (wallet_id, owner_id, contact_id, state) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + wid(8).as_slice(), + &[0xC0u8; 32][..], + &[i as u8; 32][..], + *label + ], + ) + .unwrap_or_else(|e| panic!("contact state={label} should be accepted: {e}")); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs b/packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs new file mode 100644 index 00000000000..e428c1da708 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_commit_writes_lock_poison_shortcircuit.rs @@ -0,0 +1,108 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `commit_writes` LockPoisoned short-circuit accounting: a +//! `PersistenceError::LockPoisoned` from any wallet's flush aborts the +//! loop early — the offending wallet lands in `failed` and every +//! not-yet-attempted wallet is moved to `still_pending`. +//! +//! Driven deterministically via the `force_next_flush_to_fail` injector +//! (a real panicking-thread mutex poison is non-deterministic), which +//! sends the exact same `LockPoisoned` through `flush_inner` -> +//! `handle_flush_error`'s fatal branch. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister_with_mode, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::{FlushMode, WalletStorageError}; + +fn changeset(synced: u32) -> PlatformWalletChangeSet { + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }), + ..Default::default() + } +} + +/// Wallets flush in sorted-id order. Priming a `LockPoisoned` to fire on +/// the FIRST flush (wallet A) must: +/// - record A in `failed` (as LockPoisoned), +/// - move the not-yet-attempted wallets B and C into `still_pending`, +/// - leave `succeeded` empty, +/// - and `commit_writes` itself still returns `Ok(report)` (the loop +/// short-circuits cleanly, it does not propagate `Err`). +#[test] +fn lock_poisoned_short_circuit_fills_still_pending() { + let (persister, _tmp, path) = fresh_persister_with_mode(FlushMode::Manual); + let a = wid(0xA0); + let b = wid(0xB0); + let c = wid(0xC0); + for id in [&a, &b, &c] { + ensure_wallet_meta(&persister, id); + } + persister.store(a, changeset(1)).unwrap(); + persister.store(b, changeset(2)).unwrap(); + persister.store(c, changeset(3)).unwrap(); + + // Fires on the first flush_inner -> sorted order -> wallet A. + persister.force_next_flush_to_fail(WalletStorageError::LockPoisoned); + + let report = persister + .commit_writes() + .expect("commit_writes must return Ok(report), not Err, on a LockPoisoned short-circuit"); + + assert_eq!( + report.failed.len(), + 1, + "exactly one wallet (A) must be recorded as failed; report={report:?}" + ); + assert_eq!(report.failed[0].0, a, "the failed wallet must be A"); + assert!( + matches!( + report.failed[0].1, + platform_wallet::changeset::PersistenceError::LockPoisoned + ), + "A's failure must be LockPoisoned, got {:?}", + report.failed[0].1 + ); + + assert!( + report.succeeded.is_empty(), + "no wallet should have flushed after the short-circuit; report={report:?}" + ); + + let mut pending = report.still_pending.clone(); + pending.sort(); + assert_eq!( + pending, + vec![b, c], + "B and C were never attempted and must land in still_pending; report={report:?}" + ); + assert!( + !report.is_ok(), + "a report with failures must not be is_ok()" + ); + + // B and C must NOT be durable — the loop never reached them. + let conn = common::ro_conn(&path); + for id in [&b, &c] { + let n: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + n, + 0, + "still_pending wallet {} must not have been flushed", + hex::encode(id) + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 9a719306e0b..694d82e532c 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -30,12 +30,12 @@ fn tc078_object_safety() { /// rarely do. const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ( - "wallet_meta.rs", - "SELECT wallet_id FROM wallet_metadata ORDER BY wallet_id", + "wallets.rs", + "SELECT wallet_id FROM wallets ORDER BY wallet_id", ), ( - "wallet_meta.rs", - "SELECT network, birth_height FROM wallet_metadata WHERE wallet_id", + "wallets.rs", + "SELECT network, birth_height FROM wallets WHERE wallet_id", ), ("asset_locks.rs", "SELECT outpoint, account_index"), ("platform_addrs.rs", "SELECT account_index, address_index"), @@ -58,6 +58,27 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations", ), ("core_state.rs", "SELECT outpoint, value, script, height"), + // Full-rehydration readers — one-shot SELECTs in `load_state`. + ( + "accounts.rs", + "SELECT account_type, account_index, account_xpub_bytes FROM account_registrations", + ), + ( + "core_state.rs", + "SELECT record_blob FROM core_transactions WHERE wallet_id", + ), + ( + "core_state.rs", + "SELECT txid, islock_blob FROM core_instant_locks WHERE wallet_id", + ), + ( + "core_state.rs", + "SELECT last_processed_height, synced_height, last_applied_chain_lock FROM core_sync_state WHERE wallet_id", + ), + ( + "identity_keys.rs", + "SELECT identity_id, key_id, public_key_blob FROM identity_keys WHERE wallet_id", + ), // P4 readers — `load_state` per area uses one-shot SELECTs. ( "identities.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs new file mode 100644 index 00000000000..94ea6f17faf --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs @@ -0,0 +1,187 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Contacts + identity-keys rehydrate through the keyless `load()` path: +//! store → drop → reopen → load → assert the +//! `ClientWalletStartState.contacts` / `.identity_keys` slots carry the +//! persisted PUBLIC material bit-exact. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + ContactChangeSet, ContactRequestEntry, IdentityKeyEntry, IdentityKeysChangeSet, + PlatformWalletChangeSet, PlatformWalletPersistence, ReceivedContactRequestKey, + SentContactRequestKey, +}; +use platform_wallet::wallet::identity::ContactRequest; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +fn req(sender: u8, recipient: u8) -> ContactRequestEntry { + ContactRequestEntry { + request: ContactRequest { + sender_id: Identifier::from([sender; 32]), + recipient_id: Identifier::from([recipient; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_account_label: None, + encrypted_public_key: vec![9, 9, 9], + auto_accept_proof: None, + core_height_created_at: 42, + created_at: 7, + }, + } +} + +fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +/// Contacts (sent + received) rehydrate bit-exact into the keyless +/// `ClientWalletStartState.contacts` slot. +#[test] +fn g_rt1_contacts_rehydrate_into_keyless_payload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC0); + ensure_wallet_meta(&persister, &w); + + let sent_key = SentContactRequestKey { + owner_id: Identifier::from([0x11; 32]), + recipient_id: Identifier::from([0x22; 32]), + }; + let recv_key = ReceivedContactRequestKey { + owner_id: Identifier::from([0x11; 32]), + sender_id: Identifier::from([0x33; 32]), + }; + let sent_entry = req(0x11, 0x22); + let recv_entry = req(0x33, 0x11); + let mut sent = std::collections::BTreeMap::new(); + sent.insert(sent_key, sent_entry.clone()); + let mut recv = std::collections::BTreeMap::new(); + recv.insert(recv_key, recv_entry.clone()); + + persister + .store( + w, + PlatformWalletChangeSet { + contacts: Some(ContactChangeSet { + sent_requests: sent, + incoming_requests: recv, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + + let got_sent = slice + .contacts + .sent_requests + .get(&sent_key) + .expect("sent request rehydrated"); + assert_eq!( + got_sent.request.core_height_created_at, + sent_entry.request.core_height_created_at + ); + assert_eq!( + got_sent.request.encrypted_public_key, + sent_entry.request.encrypted_public_key + ); + let got_recv = slice + .contacts + .incoming_requests + .get(&recv_key) + .expect("incoming request rehydrated"); + assert_eq!(got_recv.request.sender_id, recv_entry.request.sender_id); + // The rehydration feed never carries deletes. + assert!(slice.contacts.removed_sent.is_empty()); + assert!(slice.contacts.removed_incoming.is_empty()); +} + +/// Identity-key entries rehydrate bit-exact into the keyless +/// `ClientWalletStartState.identity_keys` slot. +#[test] +fn g_rt2_identity_keys_rehydrate_into_keyless_payload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x44; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let e0 = key_entry(id, 0, 0xAA); + let e1 = key_entry(id, 1, 0xBB); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id, 0), e0.clone()); + keys.upserts.insert((id, 1), e1.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + assert_eq!(slice.identity_keys.upserts.len(), 2); + assert_eq!(slice.identity_keys.upserts.get(&(id, 0)), Some(&e0)); + assert_eq!(slice.identity_keys.upserts.get(&(id, 1)), Some(&e1)); + assert!(slice.identity_keys.removed.is_empty()); +} + +/// A metadata-only wallet has empty (not error) contacts / +/// identity-keys slots. +#[test] +fn g_rt3_empty_slots_for_bare_wallet() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet present"); + assert!(slice.contacts.sent_requests.is_empty()); + assert!(slice.contacts.incoming_requests.is_empty()); + assert!(slice.contacts.established.is_empty()); + assert!(slice.identity_keys.upserts.is_empty()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs new file mode 100644 index 00000000000..9689d87ec68 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -0,0 +1,392 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `schema::core_state::load_state` bulk-reconstructs the keyless +//! `CoreChangeSet` (UTXOs, records, IS-locks, sync watermarks), and the +//! no-silent-zero balance contract holds end-to-end. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::{OutPoint, Txid}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Utxo; +#[cfg(feature = "rehydration-apply")] +use platform_wallet::changeset::AccountRegistrationEntry; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::core_state; +use platform_wallet_storage::WalletStorageError; + +/// Keyless account manifest the rehydration path resolves xpubs from. +#[cfg(feature = "rehydration-apply")] +fn manifest_for(wallet: &Wallet) -> Vec { + wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect() +} + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// Build a wallet + a UTXO paying one of its BIP44 addresses, value +/// `value`, confirmed at `height`. +fn wallet_and_utxo(seed: [u8; 64], value: u64, height: u32, vout: u32) -> (Wallet, Utxo) { + let w = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&w, 1); + // Any monitored address of the wallet — what a real UTXO would pay. + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .expect("at least one monitored address"); + let script = address.script_pubkey(); + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([0x55; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: script, + }, + address, + height, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + (w, utxo) +} + +/// A non-zero balance survives store → drop → reopen → load, guarding +/// against a silent-zero-balance reconstruction. +#[test] +fn rt2_nonzero_balance_survives_reopen() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB1); + ensure_wallet_meta(&persister, &w); + + let seed = [0x42; 64]; + let (wallet, utxo) = wallet_and_utxo(seed, 1_234_500, 100, 0); + + let cs = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + last_processed_height: Some(200), + synced_height: Some(200), + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).expect("load_state"); + drop(conn); + + // The persisted UTXO round-trips by outpoint + value. + assert_eq!(core.new_utxos.len(), 1); + assert_eq!(core.new_utxos[0].outpoint, utxo.outpoint); + assert_eq!(core.new_utxos[0].value(), 1_234_500); + assert_eq!(core.last_processed_height, Some(200)); + assert_eq!(core.synced_height, Some(200)); + + // End-to-end: apply onto a freshly minted skeleton (the manager's + // rehydration path) and assert the wallet balance is the persisted + // amount — NOT a silent zero. The manager-apply leg drives #3692's + // `apply_persisted_core_state`, gated behind `rehydration-apply`; the + // storage `load_state` assertions above run standalone regardless. + #[cfg(feature = "rehydration-apply")] + { + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet::manager::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_for(&wallet), + &core, + ) + .expect("BIP44 reconstruction must not error"); + let bal = WalletInfoInterface::balance(&info); + let total = bal.confirmed() + bal.unconfirmed() + bal.immature() + bal.locked(); + assert_eq!( + total, 1_234_500, + "reconstructed wallet balance must be exact" + ); + assert!(total > 0, "silent zero balance is a FAIL"); + // Height-bearing UTXO lands in the confirmed bucket. + assert_eq!(bal.confirmed(), 1_234_500); + } + // `wallet` only feeds the gated manager-apply leg above. + #[cfg(not(feature = "rehydration-apply"))] + let _ = &wallet; +} + +/// Spent UTXOs are excluded from the reconstructed feed. +#[test] +fn b2_spent_utxo_excluded() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB2); + ensure_wallet_meta(&persister, &w); + let seed = [0x07; 64]; + let (_w, u_unspent) = wallet_and_utxo(seed, 1000, 10, 0); + let (_w2, u_spent) = wallet_and_utxo(seed, 9999, 10, 1); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![u_unspent.clone()], + spent_utxos: vec![u_spent.clone()], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); + drop(conn); + let ops: Vec<_> = core.new_utxos.iter().map(|u| u.outpoint).collect(); + assert!(ops.contains(&u_unspent.outpoint)); + assert!( + !ops.contains(&u_spent.outpoint), + "spent UTXO must not resurrect on reload" + ); +} + +/// A corrupt `record_blob` is a typed hard error. +#[test] +fn b3_corrupt_record_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB3); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_transactions \ + (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) \ + VALUES (?1, ?2, NULL, NULL, NULL, 0, X'00')", + rusqlite::params![w.as_slice(), &[0x11u8; 32][..]], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = core_state::load_state(&conn, &w, key_wallet::Network::Testnet); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt record_blob must be a typed BincodeDecode; got {result:?}" + ); +} + +/// A CoinJoin-only wallet (no BIP44 account) with non-zero persisted +/// UTXOs reconstructs to the correct non-zero total, never a silent +/// `Ok` + 0. +#[test] +fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { + use std::collections::BTreeSet; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xBF); + ensure_wallet_meta(&persister, &w); + + // CoinJoin-only topology: empty BIP44/BIP32 sets, one CoinJoin + // account, no special accounts. + let mut coinjoin = BTreeSet::new(); + coinjoin.insert(0u32); + let opts = WalletAccountCreationOptions::SpecificAccounts( + BTreeSet::new(), + BTreeSet::new(), + coinjoin, + BTreeSet::new(), + BTreeSet::new(), + None, + ); + let seed = [0x4F; 64]; + let wallet = Wallet::from_seed_bytes(seed, key_wallet::Network::Testnet, opts).unwrap(); + assert!( + wallet.accounts.standard_bip44_accounts.is_empty(), + "fixture must be BIP44-free to exercise F2" + ); + let info = ManagedWalletInfo::from_wallet(&wallet, 1); + assert!( + info.accounts.standard_bip44_accounts.is_empty() + && !info.accounts.coinjoin_accounts.is_empty(), + "managed info must be CoinJoin-only" + ); + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .expect("CoinJoin-only wallet still has monitored addresses"); + + let utxo = Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([0x77; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value: 9_000_000, + script_pubkey: address.script_pubkey(), + }, + address, + height: 50, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + last_processed_height: Some(60), + synced_height: Some(60), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); + drop(conn); + assert_eq!(core.new_utxos.len(), 1); + + // Manager-apply leg (#3692 `apply_persisted_core_state`) gated behind + // `rehydration-apply`; the storage `load_state` assertions above run + // standalone regardless. + #[cfg(feature = "rehydration-apply")] + { + let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet::manager::rehydrate::apply_persisted_core_state( + &mut info, + &manifest_for(&wallet), + &core, + ) + .expect("CoinJoin-only reconstruction must not error"); + let bal = WalletInfoInterface::balance(&info); + let total = bal.confirmed() + bal.unconfirmed() + bal.immature() + bal.locked(); + assert_eq!( + total, 9_000_000, + "CoinJoin-only wallet must reconstruct the exact non-zero total — \ + a silent zero is a FAIL" + ); + } +} + +/// Empty wallet → empty core state, no error. +#[test] +fn b4_empty_core_state_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB4); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); + drop(conn); + assert!(core.new_utxos.is_empty()); + assert!(core.records.is_empty()); + assert_eq!(core.last_processed_height, None); +} + +/// `last_applied_chain_lock` persists through flush → reopen → `load_state` +/// and through the higher-level `PlatformWalletPersistence::load()` path. +/// +/// Adversarial confirmation: the assertion at the end fails if the reader +/// `load_state` does NOT populate `cs.last_applied_chain_lock` (i.e. if +/// the old code path "left None" is still in place). +#[test] +fn b5_last_applied_chain_lock_round_trips() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB5); + ensure_wallet_meta(&persister, &w); + + // Construct a deterministic ChainLock. + let cl = ChainLock { + block_height: 88_888, + block_hash: BlockHash::from_byte_array([0xCAu8; 32]), + signature: [0xBBu8; 96].into(), + }; + + // Persist via the normal store → flush path. + let cs = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(cl.clone()), + synced_height: Some(88_888), + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).expect("store"); + PlatformWalletPersistence::flush(&persister, w).expect("flush"); + drop(persister); + + // Reopen and read via `core_state::load_state` directly. + let p2 = reopen(&path); + { + let conn = p2.lock_conn_for_test(); + let loaded = core_state::load_state(&conn, &w, key_wallet::Network::Testnet) + .expect("load_state must succeed"); + assert_eq!( + loaded.last_applied_chain_lock.as_ref(), + Some(&cl), + "core_state::load_state must populate last_applied_chain_lock from disk" + ); + // Other fields carried by the same row must also survive. + assert_eq!(loaded.synced_height, Some(88_888)); + } + drop(p2); + + // Adversarial path: `PlatformWalletPersistence::load()` must also surface + // the chain lock through `ClientStartState.wallets[w].core_state`. + let p3 = reopen(&path); + let start_state = PlatformWalletPersistence::load(&p3).expect("load must succeed"); + let wallet_start = start_state + .wallets + .get(&w) + .expect("wallet must be in load output"); + assert_eq!( + wallet_start.core_state.last_applied_chain_lock.as_ref(), + Some(&cl), + "PlatformWalletPersistence::load must carry last_applied_chain_lock \ + through ClientWalletStartState.core_state — fails if reader still leaves it None" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs new file mode 100644 index 00000000000..a5a9b1de3b3 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs @@ -0,0 +1,109 @@ +#![allow(clippy::field_reassign_with_default)] + +//! DashPay write-only overlay contract. +//! +//! `dashpay_profiles` / `dashpay_payments_overlay` are a write-only +//! indexed overlay: data written via the dedicated `dashpay_*` changeset +//! slots IS persisted to the tables, but `load()` rehydrates DashPay +//! state from the identities `entry_blob`, NOT from these tables. These +//! tests pin both halves of that contract: +//! +//! 1. A `dashpay_*` write lands in the overlay tables (queryable directly). +//! 2. Writing ONLY the overlay (no identity blob carrying the same data) +//! does not corrupt `load()` — load succeeds and surfaces the wallet's +//! other state intact. + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_identity, ensure_wallet_meta, fresh_persister, wid}; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::DashPayProfile; + +fn profile(name: &str) -> DashPayProfile { + DashPayProfile { + display_name: Some(name.to_string()), + ..Default::default() + } +} + +#[test] +fn dashpay_overlay_write_is_persisted_to_its_table() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + let identity = [0xA2u8; 32]; + ensure_wallet_meta(&persister, &w); + ensure_identity(&persister, &identity, Some(&w)); + + let mut profiles: BTreeMap> = BTreeMap::new(); + profiles.insert(Identifier::from(identity), Some(profile("alice"))); + + let mut cs = PlatformWalletChangeSet::default(); + cs.dashpay_profiles = Some(profiles); + persister.store(w, cs).expect("store dashpay profile"); + persister.flush(w).expect("flush"); + + // The overlay row is physically present in its dedicated table. + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM dashpay_profiles WHERE identity_id = ?1", + rusqlite::params![&identity[..]], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "dashpay_profiles overlay row must be persisted"); +} + +#[test] +fn overlay_only_write_does_not_corrupt_load() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB1); + let identity = [0xB2u8; 32]; + ensure_wallet_meta(&persister, &w); + ensure_identity(&persister, &identity, Some(&w)); + + // Give the wallet real, loadable core state plus an overlay-only + // DashPay write (no identity blob carries this profile). + let mut core_cs = PlatformWalletChangeSet::default(); + core_cs.wallet_metadata = Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: w, + birth_height: 0, + }); + core_cs.core = Some(CoreChangeSet { + synced_height: Some(99), + last_processed_height: Some(99), + ..Default::default() + }); + persister.store(w, core_cs).expect("store core"); + persister.flush(w).expect("flush core"); + + let mut profiles: BTreeMap> = BTreeMap::new(); + profiles.insert(Identifier::from(identity), Some(profile("bob"))); + let mut overlay_cs = PlatformWalletChangeSet::default(); + overlay_cs.dashpay_profiles = Some(profiles); + persister.store(w, overlay_cs).expect("store overlay"); + persister.flush(w).expect("flush overlay"); + + // The documented contract: load() reads DashPay from the identities + // blob (not the overlay table), so the overlay-only write neither + // appears in nor corrupts the loaded state. load() must still + // succeed and surface the wallet's core state. + let state = persister + .load() + .expect("load must succeed despite overlay-only write"); + let wallet = state + .wallets + .get(&w) + .expect("wallet present in loaded state"); + assert_eq!( + wallet.core_state.synced_height, + Some(99), + "core state must rehydrate intact alongside an unread overlay" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs index bfe7b455b05..eed743b7187 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_buffer_reconcile.rs @@ -116,7 +116,7 @@ fn pre_delete_backup_includes_buffered_writes() { .unwrap(); let in_backup_meta: Option = backup .query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) @@ -130,7 +130,7 @@ fn pre_delete_backup_includes_buffered_writes() { assert_eq!( in_backup_meta, Some(1), - "pre-delete backup must contain the flushed buffered wallet_metadata row" + "pre-delete backup must contain the flushed buffered wallets row" ); } @@ -148,11 +148,11 @@ fn pre_flush_failure_preserves_buffer_and_skips_backup() { let persister = SqlitePersister::open(cfg).unwrap(); let w = wid(0xC1); - // Seed wallet_metadata so the wallet exists in the live DB. + // Seed wallets so the wallet exists in the live DB. { let conn = persister.lock_conn_for_test(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", rusqlite::params![w.as_slice()], ) @@ -186,7 +186,7 @@ fn pre_flush_failure_preserves_buffer_and_skips_backup() { let meta_rows: i64 = { let conn = persister.lock_conn_for_test(); conn.query_row( - "SELECT COUNT(*) FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| row.get(0), ) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs index 4ac4dbbcab0..fa1fcfc06fb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_cross_process_exclusion.rs @@ -8,7 +8,7 @@ mod common; use common::{ensure_wallet_meta, fresh_persister, wid}; -use rusqlite::TransactionBehavior; +use rusqlite::{OptionalExtension as _, TransactionBehavior}; /// When a peer holds EXCLUSIVE on the destination, `delete_wallet` /// must block / fail on busy rather than proceeding through an @@ -20,9 +20,8 @@ fn delete_wallet_blocks_when_peer_holds_exclusive() { ensure_wallet_meta(&persister, &w); let backup_dir = tempfile::tempdir().expect("backup dir"); - // Wire the persister with auto-backup so delete_wallet exercises - // the backup + cascade path (the canonical path under test). - // Re-open persister using a config that knows about the dir. + // Re-open with auto-backup wired so delete_wallet exercises the + // backup + cascade path (the canonical path under test). drop(persister); let cfg = platform_wallet_storage::SqlitePersisterConfig::new(&db_path) .with_auto_backup_dir(Some(backup_dir.path().to_path_buf())); @@ -89,14 +88,15 @@ fn delete_wallet_single_process_still_works() { let report = persister.delete_wallet(w).expect("delete succeeds"); assert!(report.backup_path.is_some(), "auto-backup should fire"); - // wallet_metadata row should be gone. + // wallets row should be gone. let conn = persister.lock_conn_for_test(); let row: Option = conn .query_row( - "SELECT 1 FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT 1 FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |r| r.get(0), ) - .ok(); - assert!(row.is_none(), "wallet_metadata row must be gone"); + .optional() + .expect("wallets query must not fail — only absence is expected"); + assert!(row.is_none(), "wallets row must be gone"); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs new file mode 100644 index 00000000000..1d34a2b12d8 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_partial_commit_window.rs @@ -0,0 +1,162 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Coverage for `delete_wallet_inner`'s two-transaction shape: the +//! pre-flush tx (drains + commits the buffered changeset) and the cascade +//! tx (deletes the parent `wallets` row) are SEPARATE SQLite +//! transactions. These tests probe what is durable on disk and what is +//! left in the buffer when the delete aborts AFTER the pre-flush has +//! already committed its changeset. + +mod common; + +use common::wid; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; +use rusqlite::TransactionBehavior; + +/// Self-consistent changeset that materializes a brand-new wallet on +/// flush (FK-valid `wallets` row + a `core_sync_state` child row). +fn full_changeset(synced: u32) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }); + cs +} + +fn core_rows_for(persister: &SqlitePersister, w: &[u8; 32]) -> i64 { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM core_sync_state WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap() +} + +fn wallets_rows_for(persister: &SqlitePersister, w: &[u8; 32]) -> i64 { + let conn = persister.lock_conn_for_test(); + conn.query_row( + "SELECT COUNT(*) FROM wallets WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap() +} + +/// A peer holds a SQLite-native EXCLUSIVE on the same DB file, so the +/// pre-flush's own `BEGIN EXCLUSIVE` fails with BUSY (real +/// `?`-propagation, not the injector) and the cascade is never reached. +/// On that failure the buffered changeset must survive — either still in +/// the buffer (restored) or already durable on disk. +#[test] +fn preflush_begin_exclusive_busy_preserves_buffer() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + // No auto_backup_dir + skip_backup keeps the test on the + // pre-flush -> cascade path without a backup dependency. + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(FlushMode::Manual); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xD1); + + // Buffer a brand-new wallet's full changeset (only state is buffered). + persister.store(w, full_changeset(42)).unwrap(); + + // A peer process grabs EXCLUSIVE on the same file and holds it, + // forcing the persister's own `BEGIN EXCLUSIVE` (pre-flush AND + // cascade both use it) to fail with BUSY past the busy_timeout. + let mut peer = rusqlite::Connection::open(&path).unwrap(); + let peer_guard = peer + .transaction_with_behavior(TransactionBehavior::Exclusive) + .expect("peer EXCLUSIVE"); + + let err = persister.delete_wallet_skip_backup(w); + assert!( + err.is_err(), + "delete must fail while a peer holds EXCLUSIVE; got {err:?}" + ); + + drop(peer_guard); + drop(peer); + + // Contract: a failed delete must not have removed the wallet. Either + // the wallet's state is still buffered (pre-flush never committed) OR + // it is durable on disk (pre-flush committed before the cascade + // aborted). Both are acceptable per the two-tx design; what is NOT + // acceptable is the changeset vanishing from BOTH the buffer and disk. + let on_disk_core = core_rows_for(&persister, &w); + let on_disk_wallets = wallets_rows_for(&persister, &w); + let in_buffer = persister.buffer_has_changeset_for_test(&w); + + assert!( + in_buffer || (on_disk_wallets == 1 && on_disk_core == 1), + "after a failed delete the buffered changeset must survive somewhere: \ + in_buffer={in_buffer}, on_disk_wallets={on_disk_wallets}, on_disk_core={on_disk_core}" + ); + + // If it is on disk, the wallet must NOT have been deleted (delete + // returned Err) — i.e. the cascade did not run. + if on_disk_wallets == 1 { + assert_eq!( + on_disk_core, 1, + "pre-flush committed the wallet but its child row is missing — \ + partial pre-flush is a torn write" + ); + } +} + +/// A pre-flush-committed changeset is durable even though `delete_wallet` +/// aborts; a clean retry once the peer lock is gone converges to a fully +/// deleted wallet. +#[test] +fn delete_retry_after_transient_abort_converges_to_deleted() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(FlushMode::Manual); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xD2); + + persister.store(w, full_changeset(7)).unwrap(); + + { + let mut peer = rusqlite::Connection::open(&path).unwrap(); + let _peer_guard = peer + .transaction_with_behavior(TransactionBehavior::Exclusive) + .expect("peer EXCLUSIVE"); + let first = persister.delete_wallet_skip_backup(w); + assert!(first.is_err(), "first delete must fail under peer lock"); + // peer guard drops here, releasing the lock + } + + // Retry with the lock gone: the wallet must end fully deleted + // regardless of whether the first attempt left state on disk or in + // the buffer. + persister + .delete_wallet_skip_backup(w) + .expect("retry delete must succeed once the peer lock is gone"); + + persister + .commit_writes() + .expect("commit_writes drains buffer"); + + assert_eq!( + wallets_rows_for(&persister, &w), + 0, + "wallet parent row must be gone after a converged delete" + ); + assert_eq!( + core_rows_for(&persister, &w), + 0, + "wallet child rows must be gone after a converged delete" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs new file mode 100644 index 00000000000..a0d0cf8a25a --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_real_apply_failure.rs @@ -0,0 +1,73 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `delete_wallet` pre-flush apply failure driven by a REAL SQL error, +//! exercising the apply / commit restore branches a test injector cannot. +//! +//! Strategy: buffer a wallet's full changeset in `Manual` mode, then drop +//! a child table the pre-flush apply needs (`core_sync_state`) via a side +//! connection. The delete's pre-flush `apply_changeset_to_tx` then hits a +//! real "no such table" failure on the core-state INSERT, and the +//! buffered changeset MUST be restored (not lost). + +mod common; + +use common::wid; +use key_wallet::Network; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{FlushMode, SqlitePersister, SqlitePersisterConfig}; + +fn full_changeset(synced: u32) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(synced), + last_processed_height: Some(synced), + ..Default::default() + }); + cs +} + +#[test] +fn delete_wallet_pre_flush_apply_real_sql_failure_restores_buffer() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let cfg = SqlitePersisterConfig::new(&path).with_flush_mode(FlushMode::Manual); + let persister = SqlitePersister::open(cfg).unwrap(); + let w = wid(0xC7); + + // Buffer a brand-new wallet (state lives only in the buffer). + persister.store(w, full_changeset(21)).unwrap(); + assert!( + persister.buffer_has_changeset_for_test(&w), + "precondition: changeset is buffered" + ); + + // Drop the table the pre-flush apply will INSERT into. Use a side + // connection so the persister's own conn is untouched until delete. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute("DROP TABLE core_sync_state", []).unwrap(); + } + + // delete_wallet drains the buffer, opens the pre-flush EXCLUSIVE tx, + // and applies the changeset — the core-state INSERT now fails with a + // real "no such table" SQL error. + let err = persister.delete_wallet_skip_backup(w); + assert!( + err.is_err(), + "delete must fail when the pre-flush apply hits a real SQL error; got {err:?}" + ); + + // The buffered changeset MUST survive the failed delete — the + // apply-branch restore put it back. + assert!( + persister.buffer_has_changeset_for_test(&w), + "buffered changeset must be restored after a real pre-flush apply failure" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs index 7f9f18eb71b..64eb8c0a627 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_delete_wallet.rs @@ -76,7 +76,7 @@ fn concurrent_store_does_not_resurrect_deleted_wallet() { // test; here we only guard against a racing store resurrecting the // wallet after the delete commit. let conn = persister.lock_conn_for_test(); - for table in ["wallet_metadata", "core_sync_state"] { + for table in ["wallets", "core_sync_state"] { let n: i64 = conn .query_row( &format!("SELECT COUNT(*) FROM {table} WHERE wallet_id = ?1"), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 12415933f2d..56c31887d7b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -1,17 +1,13 @@ #![allow(clippy::field_reassign_with_default)] -//! `WalletStorageError::is_transient` + `error_kind_str` exhaustiveness -//! check via a wildcard-free `match`, plus the boundary mapping of -//! `FlushRetryable` into `PersistenceError::Backend`. +//! `WalletStorageError::is_transient` + `error_kind_str` exhaustiveness, +//! plus the boundary mapping of `FlushRetryable` into +//! `PersistenceError::Backend`. //! -//! The check is structured as a `match` over `&WalletStorageError` -//! that covers every variant explicitly. There is NO `_` arm — when a -//! future variant lands on `WalletStorageError`, this file refuses to -//! compile until the author adds a classification + tag here too. -//! Combined with the wildcard-free matches in -//! `error::is_transient` / `error::error_kind_str` and the workspace -//! ban on `#[non_exhaustive]` for this enum, the policy is enforced -//! at the type system level end-to-end. +//! The check is a wildcard-free `match` with one arm per variant (no +//! `_`), so a new `WalletStorageError` variant fails to compile here +//! until it is classified — mirroring the matches in `error::is_transient` +//! / `error::error_kind_str`. use std::path::PathBuf; @@ -96,15 +92,9 @@ fn samples() -> Vec { sqlite_disk_full(), sqlite_io_failure(), sqlite_oom(), - // Migration uses an internal refinery error — we cannot easily - // synthesise one without a full runner. The `Migration(_)` arm - // in the match below uses a lazily-generated value via - // `unimplemented_variant_marker` since the test body never - // reads the inner error. We construct a different concrete - // variant whose match arm is `Migration` — see comment in arm. - // Skipped from samples because refinery::Error has no public - // `From` we can lean on; the arm is still exhaustively - // covered by the match itself. + // Migration wraps a refinery error with no public constructor, so + // it can't be synthesised here. It's omitted from the samples but + // the `Migration(_)` arm below still keeps the match exhaustive. WalletStorageError::IntegrityCheckFailed { report: "rows missing".into(), }, @@ -145,6 +135,20 @@ fn samples() -> Vec { limit_bytes: 16 * 1024 * 1024, }, WalletStorageError::ForeignKeysNotEnforced, + WalletStorageError::JournalModeNotApplied { + requested: "WAL", + actual: "delete".into(), + }, + WalletStorageError::SchemaHistoryMalformed { + reason: "bad applied_on", + }, + WalletStorageError::NotAWalletDb { + expected: 0x504C_5754, + found: 0, + }, + WalletStorageError::AlreadyOpen { + path: PathBuf::from("/x/w.db"), + }, WalletStorageError::LockPoisoned, WalletStorageError::RestoreDestinationLocked, WalletStorageError::InvalidWalletIdHex { @@ -153,12 +157,9 @@ fn samples() -> Vec { WalletStorageError::InvalidWalletIdLength { actual: 10 }, WalletStorageError::ConfigInvalid { reason: "bad knob" }, WalletStorageError::IdentityEntryIdMismatch, - WalletStorageError::UtxoAddressNotDerived { - address: "yMockAddress".into(), - }, + WalletStorageError::AccountRegistrationEntryMismatch, // BincodeEncode / BincodeDecode / HashDecode / ConsensusCodec - // need real upstream errors — synthesise minimal ones via the - // public constructors / `From` impls. + // need real upstream errors; omitted but covered by their arms. WalletStorageError::BlobDecode { reason: "bad shape", }, @@ -183,13 +184,9 @@ fn samples() -> Vec { ] } -/// wildcard-free exhaustiveness gate. -/// -/// The body is a `match` over `&WalletStorageError` with one arm per -/// variant — NO `_` arm, NO `..` rest patterns over enum variants. -/// Adding a new variant to `WalletStorageError` triggers a compile -/// error here AND in `error::is_transient`; the two failures together -/// keep the classification policy honest. +/// Wildcard-free exhaustiveness gate: each variant's expected +/// `(is_transient, error_kind_str)` pair is asserted via a `match` with +/// no `_` arm. #[test] fn tc_p2_005_is_transient_table() { fn classify(err: &WalletStorageError) -> (bool, &'static str) { @@ -246,9 +243,17 @@ fn tc_p2_005_is_transient_table() { (false, "asset_lock_entry_mismatch") } WalletStorageError::BlobTooLarge { .. } => (false, "blob_too_large"), - WalletStorageError::UtxoAddressNotDerived { .. } => (false, "utxo_address_not_derived"), WalletStorageError::ForeignKeysNotEnforced => (false, "foreign_keys_not_enforced"), + WalletStorageError::JournalModeNotApplied { .. } => (false, "journal_mode_not_applied"), + WalletStorageError::SchemaHistoryMalformed { .. } => { + (false, "schema_history_malformed") + } + WalletStorageError::NotAWalletDb { .. } => (false, "not_a_wallet_db"), + WalletStorageError::AlreadyOpen { .. } => (false, "already_open"), WalletStorageError::IntegerOverflow { .. } => (false, "integer_overflow"), + WalletStorageError::AccountRegistrationEntryMismatch => { + (false, "account_registration_entry_mismatch") + } } } @@ -305,9 +310,9 @@ fn tc_p2_010_boundary_error_mapping() { "missing wallet_id hex prefix: {outer}" ); - // Walk the typed source chain to the inner rusqlite payload — - // post- the source is `Box` so - // the chain is preserved structurally, not just stringified. + // Walk the typed source chain to the inner rusqlite payload: the + // source is `Box`, so the chain is preserved + // structurally, not just stringified. let mut chain = String::new(); let mut cur: Option<&(dyn std::error::Error + 'static)> = source.source(); while let Some(e) = cur { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs new file mode 100644 index 00000000000..867a6f9ce36 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_fk_changeset_ordering.rs @@ -0,0 +1,269 @@ +#![allow(clippy::field_reassign_with_default)] + +//! FK parent-before-child ordering inside a single immediate-FK +//! transaction, exercised through the production `store()` -> flush path. +//! Two contracts hold: +//! +//! 1. A child whose FK parent is neither in the same payload nor on disk +//! aborts the flush with a `Constraint`-kind `PersistenceError` and +//! wipes the buffer (non-transient => no retry): the caller must +//! include the parent in the same `store()` or write it first. +//! 2. A changeset carrying parent and child together commits — the fixed +//! dispatch order writes the parent first for every FK edge. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid}; + +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, + PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::identity_keys; +use platform_wallet_storage::FlushMode; + +fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + } +} + +fn identity_entry(id: Identifier, wallet_id: Option) -> IdentityEntry { + IdentityEntry { + id, + balance: 0, + revision: 0, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Unknown, + wallet_id, + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +fn keys_changeset(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeysChangeSet { + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((identity, key_id), key_entry(identity, key_id, byte)); + keys +} + +fn identities_changeset(id: Identifier, wallet_id: Option) -> IdentityChangeSet { + let mut identities = std::collections::BTreeMap::new(); + identities.insert(id, identity_entry(id, wallet_id)); + IdentityChangeSet { + identities, + removed: Default::default(), + } +} + +/// A changeset carrying `identity_keys` for an identity whose +/// `identities` parent is absent from both the payload and the DB (the +/// `wallets` parent is present, isolating the failure) aborts the flush +/// with a `Constraint`-kind `PersistenceError` carrying the constraint +/// class — not a panic or a raw-string-only error. +#[test] +fn identity_keys_without_parent_identity_aborts_with_constraint_kind() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); // wallet parent present; identity parent absent + let orphan_identity = Identifier::from([0x33; 32]); + + let err = persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys_changeset(orphan_identity, 0, 0x11)), + ..Default::default() + }, + ) + .expect_err("child-without-parent flush must fail, not silently succeed"); + + assert_eq!( + err.kind(), + Some(PersistenceErrorKind::Constraint), + "an immediate-FK abort must surface as a Constraint-kind PersistenceError, got {err:?}" + ); + // The underlying rusqlite source must be walkable to the real FK + // violation — the typed wrapper preserves it rather than flattening + // to a lossy string. + let source_chain = { + use std::error::Error; + let mut s = String::new(); + let mut cur: Option<&dyn Error> = Some(&err); + while let Some(e) = cur { + s.push_str(&e.to_string()); + s.push('\n'); + cur = e.source(); + } + s + }; + assert!( + source_chain.contains("FOREIGN KEY"), + "the FK violation must be reachable via Error::source(), got chain:\n{source_chain}" + ); +} + +/// The constraint abort wipes the buffer: a follow-up `flush()` is a +/// clean no-op and nothing reached disk for the orphaned identity. +#[test] +fn constraint_abort_wipes_buffer_no_silent_retry() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA2); + ensure_wallet_meta(&persister, &w); + let orphan_identity = Identifier::from([0x44; 32]); + + let _ = persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys_changeset(orphan_identity, 0, 0x55)), + ..Default::default() + }, + ) + .expect_err("must fail"); + + // Buffer wiped: the next flush finds nothing to write and is a no-op. + PlatformWalletPersistence::flush(&persister, w).expect("post-abort flush is a clean no-op"); + + // And nothing was committed for the orphan identity. + let on_disk = + identity_keys::load_state(&persister.lock_conn_for_test(), &w).expect("load identity_keys"); + assert!( + on_disk.upserts.is_empty(), + "no identity_keys row may have been committed for the orphaned identity" + ); +} + +/// The same contract in Manual flush mode: `store` only buffers, the +/// abort surfaces from the explicit `flush`, and the buffer is wiped so +/// the failed write is dropped (not silently re-attempted forever). +#[test] +fn manual_mode_child_without_parent_aborts_on_flush_and_drops_buffer() { + let (persister, _tmp, _path) = fresh_persister_with_mode(FlushMode::Manual); + let w = wid(0xA3); + ensure_wallet_meta(&persister, &w); + let orphan_identity = Identifier::from([0x66; 32]); + + // Manual mode: store buffers without touching SQL. + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys_changeset(orphan_identity, 0, 0x77)), + ..Default::default() + }, + ) + .expect("manual-mode store only buffers"); + + let err = PlatformWalletPersistence::flush(&persister, w) + .expect_err("explicit flush must surface the FK abort"); + assert_eq!( + err.kind(), + Some(PersistenceErrorKind::Constraint), + "manual-mode flush abort must also be Constraint-kind, got {err:?}" + ); + + // Buffer wiped on the fatal classification: a second flush is a no-op. + PlatformWalletPersistence::flush(&persister, w).expect("second flush is a clean no-op"); +} + +/// The recovery contract: a COMPLETE changeset carrying the `identities` +/// parent AND its `identity_keys` child in the SAME `store()` commits. +/// This proves the fixed dispatch order writes `identities` before +/// `identity_keys` so the immediate FK is satisfied at the child insert +/// — the parent-before-child invariant for the `identity_id` FK edge. +#[test] +fn parent_and_child_in_same_changeset_commits() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB1); + ensure_wallet_meta(&persister, &w); + let identity = Identifier::from([0x88; 32]); + + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(identities_changeset(identity, Some(w))), + identity_keys: Some(keys_changeset(identity, 0, 0x99)), + ..Default::default() + }, + ) + .expect("parent+child in one changeset must commit under the fixed dispatch order"); + + let on_disk = + identity_keys::load_state(&persister.lock_conn_for_test(), &w).expect("load identity_keys"); + assert_eq!( + on_disk.upserts.len(), + 1, + "the child identity_keys row must be committed alongside its parent identity" + ); + assert!( + on_disk.upserts.contains_key(&(identity, 0)), + "the committed row must be the one we wrote" + ); +} + +/// The same edge from the wallets side: a complete changeset that carries +/// the `wallets` root anchor (via `wallet_metadata`) AND a `wallet_id`-FK +/// child (`identity_keys`, also needing its identity parent) commits in +/// one flush. `wallets` is dispatched first, so the child's +/// `wallet_id -> wallets` FK is satisfied even when the wallets row did +/// not pre-exist. +#[test] +fn wallets_anchor_and_children_in_same_changeset_commits() { + use key_wallet::Network; + use platform_wallet::changeset::WalletMetadataEntry; + + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB2); // deliberately NOT pre-seeded — the changeset carries it + let identity = Identifier::from([0xAB; 32]); + + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }), + identities: Some(identities_changeset(identity, Some(w))), + identity_keys: Some(keys_changeset(identity, 0, 0xCD)), + ..Default::default() + }, + ) + .expect("wallets anchor + children in one changeset must commit"); + + let on_disk = + identity_keys::load_state(&persister.lock_conn_for_test(), &w).expect("load identity_keys"); + assert_eq!( + on_disk.upserts.len(), + 1, + "the wallet_id-FK child must commit because wallets is dispatched first" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs index e97a87c3be7..bab34805575 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_keys.rs @@ -1,10 +1,10 @@ #![allow(clippy::field_reassign_with_default)] -//! Native foreign-key enforcement and the delete cascade. +//! TC-045..TC-049 — native foreign-key enforcement and the delete cascade. mod common; -use common::{ensure_wallet_meta, fresh_persister, wid}; +use common::{ensure_identity, ensure_wallet_meta, fresh_persister, wid}; /// PRAGMA foreign_keys is ON on the connection. #[test] @@ -17,7 +17,7 @@ fn tc045_foreign_keys_on() { assert_eq!(fk, 1, "foreign_keys pragma not ON"); } -/// insert into a child table without a wallet_metadata parent fails. +/// insert into a child table without a wallets parent fails. #[test] fn tc046_orphan_child_insert_rejected() { let (persister, _tmp, _path) = fresh_persister(); @@ -35,7 +35,7 @@ fn tc046_orphan_child_insert_rejected() { ); } -/// deleting wallet_metadata cascades. +/// deleting wallets cascades. #[test] fn tc047_delete_wallet_cascade() { let (persister, _tmp, _path) = fresh_persister(); @@ -120,3 +120,51 @@ fn tc048_setnull_on_tx_delete() { "spent_in_txid should have been set to NULL" ); } + +/// TC-049: `identity_keys` rows carry TWO `ON DELETE CASCADE` parents +/// (`wallet_id -> wallets`, `identity_id -> identities`). +/// Deleting the wallet must purge the child via that dual-cascade — both +/// paths firing on one row is idempotent, not a double-free error. +#[test] +fn tc049_delete_wallet_cascades_identity_keys() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC4); + let identity = [0xE4u8; 32]; + // Seed BOTH FK parents: the wallets row and a wallet-scoped + // identities row, so the child satisfies both cascade chains. + ensure_wallet_meta(&persister, &w); + ensure_identity(&persister, &identity, Some(&w)); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'01', ?3, NULL)", + rusqlite::params![w.as_slice(), &identity[..], &[0u8; 20][..]], + ) + .unwrap(); + } + + let before: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(before, 1, "seed row must exist before delete"); + + let report = persister.delete_wallet(w).expect("delete_wallet"); + assert_eq!(report.wallet_id, w); + + let after: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(after, 0, "dual cascade must purge the identity_keys row"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs new file mode 100644 index 00000000000..547d00ee241 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_identity_keys_reader.rs @@ -0,0 +1,150 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `schema::identity_keys::load_state` reads `identity_keys` rows back +//! into a keyless `IdentityKeysChangeSet`, bit-exact, fail-hard on a +//! corrupt blob. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::identity_keys; +use platform_wallet_storage::WalletStorageError; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: identity, + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: u32::from(byte), + }), + } +} + +/// Identity-key rows round-trip bit-exact into the keyless +/// `IdentityKeysChangeSet`. +#[test] +fn gk1_identity_keys_roundtrip() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + let id_a = Identifier::from([0x0A; 32]); + let id_b = Identifier::from([0x0B; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id_a.as_slice().try_into().unwrap(), + ) + .unwrap(); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id_b.as_slice().try_into().unwrap(), + ) + .unwrap(); + + let e1 = key_entry(id_a, 0, 0x11); + let e2 = key_entry(id_a, 1, 0x22); + let e3 = key_entry(id_b, 0, 0x33); + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id_a, 0), e1.clone()); + keys.upserts.insert((id_a, 1), e2.clone()); + keys.upserts.insert((id_b, 0), e3.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w).expect("load_state"); + drop(conn); + + assert_eq!(cs.upserts.len(), 3); + assert_eq!(cs.upserts.get(&(id_a, 0)), Some(&e1)); + assert_eq!(cs.upserts.get(&(id_a, 1)), Some(&e2)); + assert_eq!(cs.upserts.get(&(id_b, 0)), Some(&e3)); + assert!(cs.removed.is_empty()); +} + +/// An empty wallet yields an empty changeset, not an error. +#[test] +fn gk2_empty_identity_keys_is_ok() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w).expect("load_state"); + drop(conn); + assert!(cs.upserts.is_empty()); +} + +/// A corrupt `public_key_blob` is a typed hard error, never a silent +/// skip. +#[test] +fn gk3_corrupt_blob_is_hard_error() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD3); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0C; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'00', ?3, NULL)", + rusqlite::params![w.as_slice(), id.as_slice(), &[0u8; 20][..]], + ) + .unwrap(); + } + drop(persister); + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let result = identity_keys::load_state(&conn, &w); + drop(conn); + assert!( + matches!(result, Err(WalletStorageError::BincodeDecode { .. })), + "corrupt public_key_blob must be a typed BincodeDecode; got {result:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs index e4c6ffbf746..e156a51345b 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -1,14 +1,9 @@ #![allow(clippy::field_reassign_with_default)] -//! `load()` reconstructs the wired-up subset of client start-state. -//! -//! The wallet-level fields (`wallets[*].utxos` / `.unused_asset_locks`) -//! are blocked on upstream `Wallet::from_persisted` — the persister -//! stores the data (verified via direct SQL probes) but cannot -//! reconstruct the `Wallet` + `ManagedWalletInfo` pair that -//! `ClientWalletStartState` requires. The unwired fields are listed in -//! `persister::LOAD_UNIMPLEMENTED` and surfaced via a `tracing::warn!` -//! on every `load`. +//! `load()` reconstruction tests: `load()` returns a keyless per-wallet +//! payload (network, birth height, account manifest, core-state +//! projection, identities, `Consumed`-filtered asset locks, contacts, +//! identity keys) from which the manager re-derives the signing `Wallet`. mod common; @@ -218,15 +213,105 @@ fn wallet_without_platform_state_is_omitted_from_load() { ); } -/// non-wired-up sub-areas are written to disk (verified by -/// direct SQL probes) but do not surface in the load result. -/// -/// Constructs non-empty `ContactChangeSet` and `TokenBalanceChangeSet` -/// payloads — `is_empty()` returns false on either, so the buffer -/// flushes them — then asserts both the `contacts` and `token_balances` -/// rows are present in SQLite after a reopen, while -/// `ClientStartState.platform_addresses` stays empty for the wallet -/// (no platform-address activity was stored). +/// `load_all`'s reported count excludes address rows for an account with +/// no registration. Such rows are skipped during `per_account` +/// reconstruction (no xpub, nothing to restore), so counting them would +/// claim platform state that `load()` never surfaces. +#[test] +fn load_all_count_excludes_unregistered_account_addresses() { + use platform_wallet::changeset::AccountRegistrationEntry; + + let (persister, _tmp, path) = fresh_persister(); + + // Wallet A: one registered account (2 addresses) plus an orphan + // account_index with no registration (1 address). Only the 2 + // registered-account rows reconstruct, so the count must be 2. + let a = wid(0x70); + ensure_wallet_meta(&persister, &a); + let registered = 4u32; + let unregistered = 9u32; + let mut cs_a = PlatformWalletChangeSet::default(); + cs_a.account_registrations = vec![AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: registered, + key_class: 0, + }, + account_xpub: test_xpub(), + }]; + cs_a.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![ + entry(a, registered, 0, 0xC0), + entry(a, registered, 1, 0xC1), + entry(a, unregistered, 0, 0xC2), + ], + ..Default::default() + }); + persister.store(a, cs_a).unwrap(); + + // Wallet B: only orphan-account rows, no registration and no + // watermark — nothing reconstructs, so the count must be 0 and the + // wallet must be omitted from `load()`. + let b = wid(0x71); + ensure_wallet_meta(&persister, &b); + let mut cs_b = PlatformWalletChangeSet::default(); + cs_b.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![entry(b, unregistered, 0, 0xD0)], + ..Default::default() + }); + persister.store(b, cs_b).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let all = + platform_wallet_storage::sqlite::schema::platform_addrs::load_all(&conn).expect("load_all"); + let total_rows_a = + platform_wallet_storage::sqlite::schema::platform_addrs::count_per_wallet(&conn, &a) + .expect("count_per_wallet"); + drop(conn); + + // Sanity: wallet A really does carry the orphan row on disk. + assert_eq!(total_rows_a, 3, "wallet A has 3 platform_addresses rows"); + + let (sync_a, count_a) = all.get(&a).expect("wallet A present in load_all"); + assert_eq!( + *count_a, 2, + "only the 2 registered-account rows are reconstructed; the orphan row is excluded" + ); + assert_eq!( + sync_a.per_account.len(), + 1, + "exactly the registered account reconstructs" + ); + assert!( + sync_a.per_account.contains_key(®istered), + "the registered account is present in per_account" + ); + + let (_, count_b) = all.get(&b).expect("wallet B present in load_all"); + assert_eq!( + *count_b, 0, + "an orphan-only wallet reconstructs no addresses, so its count is 0" + ); + + // The count drives `load()`'s surfacing gate: wallet B carries no + // reconstructable state, so it must not appear in the load result. + let state = p2.load().unwrap(); + assert!( + state.platform_addresses.contains_key(&a), + "wallet A reconstructs the registered account and must surface" + ); + assert!( + !state.platform_addresses.contains_key(&b), + "wallet B has only an unregistered-account row and must be omitted" + ); +} + +/// `token_balances` is persisted-but-not-rehydrated (deferred) while +/// contacts rehydrate into `state.wallets[w].contacts`. Both tables are +/// durable on disk after reopen (direct SQL probes), the contact +/// round-trips into the keyless payload, and `state.platform_addresses` +/// stays empty (no platform-address activity was stored). #[test] fn tc043_non_wired_up_persisted_but_not_returned() { use dpp::prelude::Identifier; @@ -293,6 +378,16 @@ fn tc043_non_wired_up_persisted_but_not_returned() { !state.platform_addresses.contains_key(&w), "no platform-address activity was stored — wallet must be absent" ); + // Contacts rehydrate into the keyless payload. + let slice = state.wallets.get(&w).expect("wallet rehydrated"); + let key = SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }; + assert!( + slice.contacts.sent_requests.contains_key(&key), + "the persisted sent contact request must rehydrate" + ); drop(p2); let conn = common::ro_conn(&path); @@ -368,13 +463,9 @@ fn contact_request_entry(sender: u8, recipient: u8) -> ContactRequestEntry { } } -/// identities reader round-trips per wallet, exact equality -/// on `id`s. -/// -/// `persister.load()` no longer surfaces the identities slot (the -/// `ClientStartState` revert dropped it), so this exercises the -/// hardened dormant reader `schema::identities::load_state` directly — -/// keeping its fail-hard behaviour genuinely covered. +/// identities reader round-trips per wallet, exact equality on `id`s. +/// Exercises the hardened reader `schema::identities::load_state` +/// directly (not surfaced by `load()`), covering its fail-hard behaviour. #[test] fn tc_p4_003_load_identities_two_wallets() { use platform_wallet_storage::sqlite::schema::identities; @@ -988,8 +1079,8 @@ fn tc_p4_005_load_asset_locks_bucketed() { assert_eq!(b_buckets[&0].len(), 1); } -/// empty wallets emit `wallets_pending_rehydration = N` -/// and `wallets` slot stays empty. +/// Every persisted wallet is rehydrated into the keyless `wallets` +/// payload — `wallets_rehydrated = N`, none pending. #[tracing_test::traced_test] #[test] fn tc_p4_006_pending_rehydration_count() { @@ -1000,12 +1091,12 @@ fn tc_p4_006_pending_rehydration_count() { drop(persister); let p2 = reopen(&path); let state = p2.load().unwrap(); - assert!(state.wallets.is_empty()); - assert!(logs_contain("wallets_pending_rehydration=3")); - assert!(logs_contain("wallets_rehydrated=0")); + assert_eq!(state.wallets.len(), 3, "all 3 wallets rehydrated"); + assert!(logs_contain("wallets_rehydrated=3")); + assert!(logs_contain("wallets_pending_rehydration=0")); } -/// load() summary carries every counter, including zeros. +/// load() summary carries the real rehydration counters. #[tracing_test::traced_test] #[test] fn tc_p4_007_summary_log_counters() { @@ -1018,8 +1109,8 @@ fn tc_p4_007_summary_log_counters() { for field in [ "wallets_seen=2", "addresses_loaded=0", - "wallets_rehydrated=0", - "wallets_pending_rehydration=2", + "wallets_rehydrated=2", + "wallets_pending_rehydration=0", ] { assert!(logs_contain(field), "missing structured field: {field}"); } @@ -1088,10 +1179,10 @@ fn tc_p4_008_corruption_is_hard_error() { assert_eq!(b_state.wallet_identities.get(&b).map(|m| m.len()), Some(1)); } -/// 008b: `contacts::load_state` is fail-hard. A garbage -/// `outgoing_request` blob yields a typed `BincodeDecode`; a non-32-byte -/// id column yields a typed `BlobDecode`. Neither is silently skipped, -/// and an intact wallet still decodes cleanly. +/// `contacts::load_state` is fail-hard. A garbage `outgoing_request` +/// blob yields a typed `BincodeDecode`; a non-32-byte id column yields a +/// typed `BlobDecode`. Neither is silently skipped, and an intact wallet +/// still decodes cleanly. #[test] fn tc_p4_008b_contacts_corruption_is_hard_error() { use platform_wallet_storage::sqlite::schema::contacts; @@ -1160,10 +1251,9 @@ fn tc_p4_008b_contacts_corruption_is_hard_error() { assert_eq!(good_state.sent_requests.len(), 1); } -/// 008c: `asset_locks::load_state` is fail-hard. A garbage -/// `lifecycle_blob` yields a typed `BincodeDecode`; a malformed -/// `outpoint` column yields a typed decode error. An intact wallet -/// still decodes cleanly. +/// `asset_locks::load_state` is fail-hard. A garbage `lifecycle_blob` +/// yields a typed `BincodeDecode`; a malformed `outpoint` column yields a +/// typed decode error. An intact wallet still decodes cleanly. #[test] fn tc_p4_008c_asset_locks_corruption_is_hard_error() { use dashcore::hashes::Hash; @@ -1256,19 +1346,18 @@ fn tc_p4_008c_asset_locks_corruption_is_hard_error() { assert_eq!(good_state[&0].len(), 1); } -/// 008d: `wallet_meta::list_ids` is fail-hard on a malformed -/// stored `wallet_id`. This is the code path where a non-32-byte id -/// actually surfaces (the per-area `load_state` readers take a typed -/// `&WalletId`, so the length check belongs here). A 10-byte -/// `wallet_metadata.wallet_id` yields a typed `InvalidWalletIdLength`. +/// `wallets::list_ids` is fail-hard on a malformed stored `wallet_id`. +/// This is the code path where a non-32-byte id actually surfaces (the +/// per-area `load_state` readers take a typed `&WalletId`). A 10-byte +/// `wallets.wallet_id` yields a typed `InvalidWalletIdLength`. #[test] fn tc_p4_008d_list_ids_rejects_non_32_byte_wallet_id() { - use platform_wallet_storage::sqlite::schema::wallet_meta; + use platform_wallet_storage::sqlite::schema::wallets; let (persister, _tmp, path) = fresh_persister(); { let conn = persister.lock_conn_for_test(); conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", rusqlite::params![&[0xAAu8; 10][..]], ) @@ -1278,7 +1367,7 @@ fn tc_p4_008d_list_ids_rejects_non_32_byte_wallet_id() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let result = wallet_meta::list_ids(&conn); + let result = wallets::list_ids(&conn); drop(conn); assert!( matches!( @@ -1290,19 +1379,10 @@ fn tc_p4_008d_list_ids_rejects_non_32_byte_wallet_id() { ); } -/// `load()` query cost is bounded per wallet. -/// -/// `load()` now drives the platform-address reader off -/// `wallet_meta::list_ids` and issues a fixed, small number of -/// statements per listed wallet (the dedup collapse traded the old -/// constant-query bulk scans for the fail-hard per-wallet readers). -/// This pins the per-wallet statement count so a future regression -/// that fans out into an unbounded per-row round trip is caught. -/// -/// Verified by enabling `sqlite3_trace_v2` on the persister's -/// connection, counting `Stmt` events for the duration of one -/// `load()`. `serial_test::serial` because the trace counter is a -/// process-wide `AtomicUsize` (`Connection::trace_v2`'s callback must +/// `load()` query cost is constant per wallet (no unbounded per-row +/// fan-out), without pinning a brittle magic number. Counts `Stmt` +/// events via `sqlite3_trace_v2` over one `load()`; `serial` because the +/// counter is a process-wide `AtomicUsize` (the `trace_v2` callback must /// be a `fn`, not a `Fn`). #[test] #[serial_test::serial] @@ -1359,21 +1439,37 @@ fn tc_p4_012_load_query_count_bounded() { seed_wallets(&p10, 10); let count_ten = count_load_queries(&p10); - // `load()` issues a fixed number of grouped scans regardless of - // wallet count: `wallet_meta::list_ids` plus one scan each over - // `platform_address_sync`, `platform_addresses`, and the - // `platform_payment` `account_registrations`. The count must NOT - // grow with the number of wallets — that's the constant-query - // contract. + // The per-wallet delta must be a constant (10×N readers minus the + // one shared `wallets::list_ids` divides evenly by 9), i.e. + // load() is O(1) statements per wallet — no unbounded per-row + // fan-out. The exact constant is not pinned (brittle as readers + // evolve) but it must be small and bounded. + let delta = count_ten - count_one; assert_eq!( - count_one, count_ten, - "load() query count must not grow with wallet count \ - (N=1 → {count_one}, N=10 → {count_ten})" + delta % 9, + 0, + "per-wallet statement count must be constant \ + (N=1 → {count_one}, N=10 → {count_ten}, delta → {delta})" + ); + let per_wallet = delta / 9; + assert!( + (1..=20).contains(&per_wallet), + "per-wallet statement count must be small + bounded, got {per_wallet}" + ); + // Shared (wallet-count-independent) overhead: the `list_ids` + + // `platform_addrs::load_all` scans. `count_one = shared + per_wallet` + // ⇒ shared must itself be a small constant, not growing with N. + let shared = count_one - per_wallet; + assert!( + (1..=8).contains(&shared), + "shared load() overhead must be a small constant, got {shared} \ + (N=1 → {count_one}, per-wallet → {per_wallet})" ); + // And it really is N-independent: N=10 total == shared + 10×per_wallet. assert_eq!( - count_one, 4, - "load() must issue exactly 4 grouped statements \ - (list_ids + sync + addresses + registrations), got {count_one}" + count_ten, + shared + 10 * per_wallet, + "load() statement count must be exactly shared + N×per_wallet" ); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs new file mode 100644 index 00000000000..ff4d91744f1 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs @@ -0,0 +1,153 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `SqlitePersister::load()` returns the keyless per-wallet rehydration +//! payload in `ClientStartState.wallets` (network, birth height, account +//! manifest, core state, identities, filtered asset locks), carrying no +//! `Wallet`/seed. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +/// A registered wallet with UTXOs round-trips into the keyless `wallets` +/// payload — manifest, network, birth height, core state. +#[test] +fn c1_load_populates_keyless_wallet_payload() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + + let seed = [0x21; 64]; + let wallet = Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 7); + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .unwrap(); + + // Registration round: metadata + per-account manifest. + let manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + let reg = PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 7, + }), + account_registrations: manifest.clone(), + ..Default::default() + }; + persister.store(w, reg).unwrap(); + + // A UTXO so the balance is non-zero. + let utxo = key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: { + use dashcore::hashes::Hash; + dashcore::Txid::from_byte_array([0x99; 32]) + }, + vout: 0, + }, + txout: dashcore::TxOut { + value: 777_000, + script_pubkey: address.script_pubkey(), + }, + address, + height: 5, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + last_processed_height: Some(50), + synced_height: Some(50), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + + assert_eq!(state.wallets.len(), 1, "the wallet must be in the payload"); + let slice = state.wallets.get(&w).expect("wallet slice"); + assert_eq!(slice.network, key_wallet::Network::Testnet); + assert_eq!(slice.birth_height, 7); + // Every persisted row round-trips. The writer's + // `(account_type_label, account_index)` upsert key collapses a few + // distinct special-purpose variants that share a label+index (a + // persist-side characteristic, not a load bug), so the manifest is + // a faithful read of what is on disk: non-empty, containing the + // primary BIP44 account. + assert!(!slice.account_manifest.is_empty()); + assert!( + slice.account_manifest.iter().any(|e| matches!( + e.account_type, + key_wallet::account::AccountType::Standard { .. } + )), + "BIP44 account must be in the manifest" + ); + assert_eq!(slice.core_state.new_utxos.len(), 1); + assert_eq!(slice.core_state.new_utxos[0].value(), 777_000); + assert_eq!(slice.core_state.last_processed_height, Some(50)); +} + +/// Empty DB → empty `wallets`, no error (the `load()` doctest contract). +#[test] +fn c2_empty_db_empty_wallets() { + let (persister, _tmp, path) = fresh_persister(); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().unwrap(); + assert!(state.wallets.is_empty()); + assert!(state.is_empty()); +} + +/// A wallet with only metadata (no UTXOs) still appears, with an empty +/// core projection — not silently dropped. +#[test] +fn c3_metadata_only_wallet_present() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC3); + ensure_wallet_meta(&persister, &w); + drop(persister); + let p2 = reopen(&path); + let state = p2.load().unwrap(); + let slice = state.wallets.get(&w).expect("metadata-only wallet present"); + assert!(slice.account_manifest.is_empty()); + assert!(slice.core_state.new_utxos.is_empty()); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index 8b90ce8b957..fb402eeb2f6 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -67,13 +67,13 @@ fn tc027_smoke_insert_every_table() { let wallet_id = [42u8; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![wallet_id.as_slice()], ) .unwrap(); let identity_id = [7u8; 32]; conn.execute( - "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + "INSERT INTO identities (wallet_id, identity_index, identity_id, entry_blob, tombstoned) \ VALUES (?1, NULL, ?2, X'01', 0)", params![wallet_id.as_slice(), identity_id.as_slice()], ) @@ -86,12 +86,7 @@ fn tc027_smoke_insert_every_table() { // Labels must match the writer-side canonical strings — see the // CHECK constraint sourced from `ACCOUNT_TYPE_LABELS` in // `sqlite::schema::accounts`. - "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard', 0, X'00')", - &[&wallet_id.as_slice()], - ), - ( - "account_address_pools", - "INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard', 0, 'external', X'00')", + "INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&wallet_id.as_slice()], ), ( @@ -109,11 +104,6 @@ fn tc027_smoke_insert_every_table() { "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&wallet_id.as_slice(), &txid], ), - ( - "core_derived_addresses", - "INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'standard', 0, 'addr', '', 0)", - &[&wallet_id.as_slice()], - ), ( "core_sync_state", "INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, NULL, NULL)", @@ -121,10 +111,11 @@ fn tc027_smoke_insert_every_table() { ), ( "identity_keys", - // identity_keys is keyed by (identity_id, key_id); the FK - // targets identities(identity_id). - "INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) VALUES (?1, 0, X'00', X'00')", - &[&identity_id.as_slice()], + // identity_keys is keyed by (wallet_id, identity_id, key_id); + // the wallet_id FK targets wallets and the + // identity_id FK targets identities(identity_id). + "INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", + &[&wallet_id.as_slice(), &identity_id.as_slice()], ), ( "contacts", @@ -208,10 +199,9 @@ fn tc028_idempotent_reopen() { /// append-only migration hash. /// -/// The hash is computed at runtime from the embedded list. Because this -/// test belongs to the migration drift policy, we assert the list is -/// non-empty and the hash is stable across successive calls — not a -/// pinned value (which would force a churn on every committed migration). +/// Asserts intra-run stability and a non-empty list — not content +/// pinning. The fingerprint is content-blind (hashes `(version, name)` +/// only), so this guards the migration set's identity, not its DDL. #[test] fn tc029_migration_fingerprint_stable() { let a = mig::embedded_migrations_fingerprint(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs b/packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs new file mode 100644 index 00000000000..0c9060d3ede --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_money_column_overflow_on_read.rs @@ -0,0 +1,119 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Money/balance columns: a negative `i64` stored on disk (a wrapped +//! value, a restored-corruption row, or a torn write that passes +//! `PRAGMA integrity_check`) MUST abort the read with +//! [`WalletStorageError::IntegerOverflow`] rather than sign-extending +//! into a multi-quintillion `u64` balance. `birth_height`/`sync_height` +//! get the same guard in `sqlite_structural_hardening.rs`; here we cover +//! the genuine value-bearing columns, with `platform_addresses.balance` +//! riding the production `load()` path end-to-end. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::changeset::{ + AccountRegistrationEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::WalletStorageError; +use rusqlite::params; + +/// A deterministic test xpub (BIP-32 mainnet test vector) shared by the +/// account-registration seeding helpers in this file. +fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode( + &hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D\ + 314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC\ + 2DF1050E2E8FF49C85C2", + ) + .unwrap(), + ) + .unwrap() +} + +/// `platform_addresses.balance`: a negative on-disk value must abort +/// the production `load()` with `IntegerOverflow{field: +/// "platform_addresses.balance"}`, NOT load a sign-extended u64 +/// balance. This rides `load() -> platform_addrs::load_all -> +/// decode_address_row -> i64_to_u64`. +/// +/// An `account_registrations` row for account 0 is seeded so the test +/// exercises the realistic production path where platform addresses are +/// always preceded by a registration. +#[test] +fn platform_address_balance_negative_on_disk_errors_on_load() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + // Seed an account_registrations row for account 0 (PlatformPayment) so + // the test scenario is realistic: in production, platform_addresses rows + // are only present when the corresponding account is registered. + let mut cs = PlatformWalletChangeSet::default(); + cs.account_registrations = vec![AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class: 0, + }, + account_xpub: test_xpub(), + }]; + persister.store(w, cs).expect("store account registration"); + PlatformWalletPersistence::flush(&persister, w).expect("flush account registration"); + + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, X'0000000000000000000000000000000000000000', ?2, 0)", + params![w.as_slice(), -1i64], + ) + .unwrap(); + } + + let err = PlatformWalletPersistence::load(&persister) + .expect_err("a negative on-disk balance must abort load(), not sign-extend"); + let backend = format!("{err:?}"); + assert!( + backend.contains("IntegerOverflow") && backend.contains("platform_addresses.balance"), + "expected IntegerOverflow for platform_addresses.balance, got {backend}" + ); +} + +/// `core_utxos.value`: a negative on-disk value must abort the unspent +/// read with `IntegerOverflow{field: "core_utxos.value"}` rather than +/// reporting a sign-extended u64 amount for live funds. +#[test] +fn core_utxo_value_negative_on_disk_errors_on_read() { + use dashcore::hashes::Hash; + use platform_wallet_storage::sqlite::schema::{blob, core_state}; + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + let outpoint = blob::encode_outpoint(&dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x22; 32]), + vout: 0, + }) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + // Declare the address so the row is treated as a real, unspent + // UTXO and the value cast is reached (account_index 0). + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, ?3, X'00', NULL, 0, 0, NULL)", + params![w.as_slice(), &outpoint, -1i64], + ) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + let err = core_state::list_unspent_utxos(&conn, &w) + .expect_err("a negative on-disk utxo value must error, not sign-extend"); + let s = format!("{err:?}"); + assert!( + matches!(err, WalletStorageError::IntegerOverflow { field, .. } if field == "core_utxos.value"), + "expected IntegerOverflow for core_utxos.value, got {s}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs index ad060bdadc5..1efd76e224e 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_object_metadata.rs @@ -24,11 +24,8 @@ fn id32(byte: u8) -> [u8; 32] { [byte; 32] } -// --------------------------------------------------------------------- -// 001..006 — per-scope roundtrip (get→None, put, get, overwrite, -// delete, get→None). Parent rows seeded first. -// --------------------------------------------------------------------- - +/// Full per-scope roundtrip: get→None, put, get, overwrite, delete, +/// get→None. Parent rows must be seeded first by the caller. fn roundtrip(p: &impl KvStore, scope: &ObjectId) { assert_eq!(p.get(scope, "k").unwrap(), None); p.put(scope, "k", b"v1").unwrap(); @@ -111,12 +108,6 @@ fn tc_md_006_roundtrip_platform_address() { ); } -// --------------------------------------------------------------------- -// 007..011 — parentless `put` SUCCEEDS for the five typed scopes -// (no parent row seeded) and the value reads back. — Global -// put on empty DB → Ok. -// --------------------------------------------------------------------- - /// Put `(scope, "k") = b"v"` with NO parent row present, then read it /// back. Asserts the soft-cascade model: writes don't require a parent. fn assert_parentless_put_roundtrips(p: &impl KvStore, scope: &ObjectId) { @@ -183,11 +174,8 @@ fn tc_md_012_put_global_on_empty_db_is_ok() { ); } -// --------------------------------------------------------------------- -// delete of a never-existing key is idempotent (returns Ok), -// for the Global scope and a typed scope. -// --------------------------------------------------------------------- - +/// delete of a never-existing key is idempotent for both the Global +/// scope and a typed scope. #[test] fn delete_missing_key_is_idempotent() { let (p, _tmp, _path) = fresh_persister(); @@ -197,11 +185,6 @@ fn delete_missing_key_is_idempotent() { p.delete(&ObjectId::Wallet(w), "never-existed").unwrap(); } -// --------------------------------------------------------------------- -// list_keys returns keys in ascending order regardless of -// insertion order. -// --------------------------------------------------------------------- - #[test] fn list_keys_is_ascending_regardless_of_insert_order() { let (p, _tmp, _path) = fresh_persister(); @@ -214,10 +197,8 @@ fn list_keys_is_ascending_regardless_of_insert_order() { ); } -// --------------------------------------------------------------------- -// 013..016 — soft cascade via AFTER DELETE trigger: seed+put, -// DELETE FROM the direct parent table, assert the meta row is gone. -// --------------------------------------------------------------------- +// Soft cascade via AFTER DELETE trigger: seed+put, DELETE FROM the +// direct parent table, assert the meta row is gone. #[test] fn tc_md_013_cascade_identity() { @@ -313,9 +294,7 @@ fn tc_md_016_cascade_platform_address() { assert_eq!(p.get(&scope, "k").unwrap(), None); } -// --------------------------------------------------------------------- -// 017 / 017b — wallet cascade (direct + transitive via identities). -// --------------------------------------------------------------------- +// Wallet cascade: direct, plus transitive via identities. #[test] fn tc_md_017_cascade_wallet() { @@ -328,10 +307,10 @@ fn tc_md_017_cascade_wallet() { { let conn = p.lock_conn_for_test(); conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![w.as_slice()], ) - .expect("delete wallet_metadata"); + .expect("delete wallets"); } assert_eq!(p.get(&scope, "k").unwrap(), None); } @@ -349,20 +328,18 @@ fn tc_md_017b_cascade_identity_via_wallet() { { let conn = p.lock_conn_for_test(); conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", + "DELETE FROM wallets WHERE wallet_id = ?1", params![w.as_slice()], ) - .expect("delete wallet_metadata"); + .expect("delete wallets"); } - // wallet_metadata delete → identities FK cascade → meta_identity + // wallets delete → identities FK cascade → meta_identity // trigger (SQLite fires it for FK-cascade-deleted rows natively). assert_eq!(p.get(&scope, "k").unwrap(), None); } -// --------------------------------------------------------------------- -// 018 / 019 — delete_wallet purges every meta_* for the wallet; -// Global + other wallet's meta_wallet survive; report wiring. -// --------------------------------------------------------------------- +// delete_wallet purges every meta_* for the wallet; Global and another +// wallet's meta_wallet survive. #[test] fn tc_md_018_delete_wallet_purges_all_meta_for_wallet() { @@ -513,12 +490,9 @@ fn tc_md_019_delete_wallet_report_counts_meta_tables() { assert_eq!(global, 1, "meta_global must survive the per-wallet delete"); } -// --------------------------------------------------------------------- -// DET scenario — write metadata before the parent exists, read it back, -// then create the parent (metadata still present), then delete the -// parent (the AFTER DELETE trigger removes the metadata). -// --------------------------------------------------------------------- - +/// Write metadata before the parent exists, read it back, create the +/// parent (metadata persists), then delete it (the AFTER DELETE trigger +/// removes the metadata). #[test] fn det_write_before_parent_then_create_then_delete() { use rusqlite::params; @@ -552,13 +526,9 @@ fn det_write_before_parent_then_create_then_delete() { assert_eq!(p.get(&scope, "alias").unwrap(), None); } -// --------------------------------------------------------------------- -// The meta_* triggers coexist with the pre-existing -// `setnull_core_utxos_on_tx_delete` trigger during delete_wallet: a -// wallet with core_transactions + core_utxos (a UTXO spent_in that tx) -// deletes cleanly and leaves nothing behind. -// --------------------------------------------------------------------- - +/// The meta_* triggers coexist with the `setnull_core_utxos_on_tx_delete` +/// trigger during delete_wallet: a wallet with core_transactions + +/// core_utxos (a UTXO spent_in that tx) deletes cleanly, leaving nothing. #[test] fn delete_wallet_with_core_tx_and_utxo_stays_consistent() { use rusqlite::params; @@ -612,14 +582,10 @@ fn delete_wallet_with_core_tx_and_utxo_stays_consistent() { assert_eq!(p.get(&ObjectId::Wallet(w), "k").unwrap(), None); } -// --------------------------------------------------------------------- -// Trigger-on-FK-cascade proof at SQLite defaults. SQLite fires an AFTER -// DELETE trigger for a row removed by an FK ON DELETE CASCADE natively — -// `recursive_triggers` (off by default) does not gate this. On a RAW -// connection at defaults, the one-hop chain wallet_metadata delete → -// identities FK cascade → meta_identity trigger cleans up. -// --------------------------------------------------------------------- - +/// SQLite fires an AFTER DELETE trigger for a row removed by FK ON DELETE +/// CASCADE natively — `recursive_triggers` (off by default) does not gate +/// this. On a raw connection at defaults, the one-hop chain wallets +/// delete → identities FK cascade → meta_identity trigger cleans up. #[test] fn meta_identity_cleanup_fires_on_wallet_cascade() { use rusqlite::{params, Connection}; @@ -642,13 +608,13 @@ fn meta_identity_cleanup_fires_on_wallet_cascade() { let w = [0x90u8; 32]; let idy = [0x91u8; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![&w[..]], ) .unwrap(); conn.execute( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, NULL, X'00', 0)", params![&idy[..], &w[..]], ) @@ -659,12 +625,9 @@ fn meta_identity_cleanup_fires_on_wallet_cascade() { ) .unwrap(); - // wallet_metadata delete → identities FK cascade → meta_identity trigger. - conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", - params![&w[..]], - ) - .unwrap(); + // wallets delete → identities FK cascade → meta_identity trigger. + conn.execute("DELETE FROM wallets WHERE wallet_id = ?1", params![&w[..]]) + .unwrap(); let identity_rows: i64 = conn .query_row( @@ -688,13 +651,9 @@ fn meta_identity_cleanup_fires_on_wallet_cascade() { ); } -// --------------------------------------------------------------------- -// Two-hop trigger-on-FK-cascade proof at SQLite defaults. The meta_token -// chain spans two FK cascades: wallet_metadata delete → identities (FK -// cascade) → token_balances (FK cascade) → meta_token trigger. This -// fires natively without recursive_triggers. -// --------------------------------------------------------------------- - +/// The meta_token chain spans two FK cascades: wallets delete → +/// identities → token_balances → meta_token trigger, firing natively +/// without recursive_triggers. #[test] fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { use rusqlite::{params, Connection}; @@ -718,13 +677,13 @@ fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { let idy = [0xA1u8; 32]; let token = [0xA2u8; 32]; conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) \ + "INSERT INTO wallets (wallet_id, network, birth_height) \ VALUES (?1, 'testnet', 0)", params![&w[..]], ) .unwrap(); conn.execute( - "INSERT INTO identities (identity_id, wallet_id, wallet_index, entry_blob, tombstoned) \ + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, NULL, X'00', 0)", params![&idy[..], &w[..]], ) @@ -743,11 +702,8 @@ fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { .unwrap(); // Two-hop cascade: wallet → identities → token_balances → trigger. - conn.execute( - "DELETE FROM wallet_metadata WHERE wallet_id = ?1", - params![&w[..]], - ) - .unwrap(); + conn.execute("DELETE FROM wallets WHERE wallet_id = ?1", params![&w[..]]) + .unwrap(); let token_rows: i64 = conn .query_row( @@ -774,10 +730,6 @@ fn meta_token_cleanup_fires_on_wallet_cascade_two_hops() { ); } -// --------------------------------------------------------------------- -// 020..022 — key bounds. -// --------------------------------------------------------------------- - #[test] fn tc_md_020_empty_key_rejected() { let (p, _tmp, _path) = fresh_persister(); @@ -820,11 +772,8 @@ fn tc_md_022_max_length_key_accepted() { ); } -// --------------------------------------------------------------------- -// oversized value planted directly is rejected on `get` -// before materialisation, across every meta_* table. -// --------------------------------------------------------------------- - +/// An oversized value planted directly is rejected on `get` before +/// materialisation, across every meta_* table. #[test] fn tc_md_023_oversized_value_rejected_before_materialising() { use rusqlite::params; @@ -942,10 +891,8 @@ fn tc_md_023_oversized_value_rejected_before_materialising() { } } -// --------------------------------------------------------------------- -// list_keys prefix with literal `%`/`_`/`\` (not wildcards). -// --------------------------------------------------------------------- - +/// list_keys treats `%`/`_`/`\` in the prefix as literals, not LIKE +/// wildcards. #[test] fn tc_md_024_list_keys_escapes_like_metacharacters() { let (p, _tmp, _path) = fresh_persister(); @@ -977,11 +924,8 @@ fn tc_md_024_list_keys_escapes_like_metacharacters() { ); } -// --------------------------------------------------------------------- -// scope isolation: same key string across Wallet(A)/Wallet(B) -// and Global/Wallet(A) stays independent. -// --------------------------------------------------------------------- - +/// The same key string across Wallet(A)/Wallet(B) and Global/Wallet(A) +/// stays scope-independent. #[test] fn tc_md_025_scope_isolation() { let (p, _tmp, _path) = fresh_persister(); @@ -1072,14 +1016,12 @@ fn delete_wallet_leaves_no_surviving_rows() { let txid = vec![0x01u8; 32]; let outpoint = vec![0x02u8; 36]; let stmts: &[(&str, &[&dyn rusqlite::ToSql])] = &[ - ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard', 0, X'00')", &[&a.as_slice()]), - ("INSERT INTO account_address_pools (wallet_id, account_type, account_index, pool_type, snapshot_blob) VALUES (?1, 'standard', 0, 'external', X'00')", &[&a.as_slice()]), + ("INSERT INTO account_registrations (wallet_id, account_type, account_index, account_xpub_bytes) VALUES (?1, 'standard_bip44', 0, X'00')", &[&a.as_slice()]), ("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, X'00')", &[&a.as_slice(), &txid]), ("INSERT INTO core_utxos (wallet_id, outpoint, value, script, account_index, spent) VALUES (?1, ?2, 0, X'00', 0, 0)", &[&a.as_slice(), &outpoint]), ("INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, X'00')", &[&a.as_slice(), &txid]), - ("INSERT INTO core_derived_addresses (wallet_id, account_type, account_index, address, derivation_path, used) VALUES (?1, 'standard', 0, 'addr', '', 0)", &[&a.as_slice()]), ("INSERT INTO core_sync_state (wallet_id, last_processed_height, synced_height) VALUES (?1, 1, 1)", &[&a.as_slice()]), - ("INSERT INTO identity_keys (identity_id, key_id, public_key_blob, public_key_hash) VALUES (?1, 0, X'00', X'00')", &[&idy.as_slice()]), + ("INSERT INTO identity_keys (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) VALUES (?1, ?2, 0, X'00', X'00', NULL)", &[&a.as_slice(), &idy.as_slice()]), ("INSERT INTO platform_address_sync (wallet_id, sync_height, sync_timestamp, last_known_recent_block) VALUES (?1, 0, 0, 0)", &[&a.as_slice()]), ("INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'built', 0, 0, 0, X'00')", &[&a.as_slice(), &outpoint]), ("INSERT INTO dashpay_profiles (identity_id, profile_blob) VALUES (?1, X'00')", &[&idy.as_slice()]), @@ -1242,13 +1184,11 @@ fn delete_wallet_leaves_no_surviving_rows() { // survive. Scoping each count by `wallet_id` catches an over-broad // cascade that an unscoped whole-table COUNT(*) would miss. let wallet_scoped = [ - "wallet_metadata", + "wallets", "account_registrations", - "account_address_pools", "core_transactions", "core_utxos", "core_instant_locks", - "core_derived_addresses", "core_sync_state", "identities", "contacts", @@ -1295,7 +1235,7 @@ fn delete_wallet_leaves_no_surviving_rows() { // rows. (b is seeded in a representative subset of the scoped tables, // not all of them, so we check exactly the tables it was given.) let b_wallet_scoped = [ - "wallet_metadata", + "wallets", "core_sync_state", "identities", "contacts", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs b/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs index 5b16833a06c..a9a3c93e684 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_open_integrity_check.rs @@ -53,7 +53,7 @@ fn atom_013_open_rejects_corrupt_db() { // Push the DB past a few pages with a chunky meta row. for i in 0..20u32 { conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", params![vec![i as u8; 32].as_slice(), i as i64], ) .unwrap(); @@ -120,7 +120,7 @@ fn tc_code_016_a_integrity_report_collects_all_rows() { let conn = persister.lock_conn_for_test(); for i in 0..40u32 { conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", params![vec![i as u8; 32].as_slice(), i as i64], ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs index 3004b16fc88..b1092736f7f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_persist_roundtrip.rs @@ -1,17 +1,10 @@ #![allow(clippy::field_reassign_with_default)] -//! Per-sub-changeset round-trip tests. -//! -//! Now that `platform-wallet`'s `serde` feature is active, every -//! changeset blob is a single bincode-serde payload — these tests -//! store a non-trivial entry, reopen the persister, decode the blob, -//! and assert structural equality (where the type allows) or -//! field-level equality (where it doesn't, e.g. `TransactionRecord` -//! which is `Debug + Clone` only upstream). -//! -//! TC-001 (CoreChangeSet records) is exercised through the trait -//! method in `sqlite_buffer_semantics.rs::tc001_get_core_tx_record_roundtrip`. -//! TC-015 (multi-wallet coexistence) lives there too. +//! Per-sub-changeset round-trip tests: store a non-trivial entry, reopen +//! the persister, decode the bincode-serde blob, and assert structural +//! equality (or field-level equality where the type isn't `PartialEq`). +//! CoreChangeSet records and multi-wallet coexistence are covered in +//! `sqlite_buffer_semantics.rs`. mod common; @@ -74,7 +67,7 @@ fn tc013_wallet_metadata_roundtrip() { let conn = persister.lock_conn_for_test(); let (network, birth_height): (String, i64) = conn .query_row( - "SELECT network, birth_height FROM wallet_metadata WHERE wallet_id = ?1", + "SELECT network, birth_height FROM wallets WHERE wallet_id = ?1", rusqlite::params![w.as_slice()], |row| Ok((row.get(0)?, row.get(1)?)), ) @@ -248,8 +241,8 @@ fn tc007_identity_key_entry_roundtrip() { let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); let conn = p2.lock_conn_for_test(); - // identity_keys is keyed by (identity_id, key_id); the wallet_id - // column is not part of the schema. + // Single wallet under test, so (identity_id, key_id) selects the + // one row; the full PK is (wallet_id, identity_id, key_id). let blob_bytes: Vec = conn .query_row( "SELECT public_key_blob FROM identity_keys WHERE identity_id = ?1 AND key_id = ?2", @@ -260,12 +253,9 @@ fn tc007_identity_key_entry_roundtrip() { let decoded = platform_wallet_storage::sqlite::schema::identity_keys::decode_entry(&blob_bytes).unwrap(); assert_eq!(decoded, entry); - // The load-bearing NFR-10 check is `tests/secrets_scan.rs`, - // which greps every file under `src/sqlite/schema/` and - // `migrations/` for forbidden secret-material substrings — - // bincode wire bytes carry no field names, so any runtime - // substring scan against the blob would be a false-confidence - // smoke test. + // No runtime substring scan on the blob: bincode wire bytes carry no + // field names, so it would be false confidence. The real secret-leak + // guard is the source grep in `tests/secrets_scan.rs`. drop(tmp); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs b/packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs new file mode 100644 index 00000000000..ea0fadd9622 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_qa_identity_tombstone.rs @@ -0,0 +1,290 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Write-path coverage for the `IdentityChangeSet.removed` tombstone +//! branch. The tombstone runs a wallet-scoped, NULL-safe +//! `UPDATE identities SET tombstoned = 1 WHERE identity_id = ?1 AND +//! wallet_id IS ?2`, mirroring the upsert's per-entry wallet cross-check. +//! These tests pin that a tombstoned identity is excluded from the +//! per-wallet `load_state` and that a foreign wallet's `removed` set +//! cannot tombstone this wallet's identity. + +mod common; + +use std::collections::{BTreeMap, BTreeSet}; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; +use platform_wallet::changeset::{ + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::IdentityStatus; +use platform_wallet_storage::sqlite::schema::identities; + +fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { + platform_wallet_storage::SqlitePersister::open( + platform_wallet_storage::SqlitePersisterConfig::new(path), + ) + .expect("reopen persister") +} + +/// Build an `IdentityEntry` parented to a specific wallet (so the upsert +/// cross-check passes and the typed `wallet_id` column is populated). +fn entry_for(id: u8, wallet_id: [u8; 32]) -> IdentityEntry { + IdentityEntry { + id: Identifier::from([id; 32]), + balance: u64::from(id), + revision: 1, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: Some(wallet_id), + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +/// An identity routed through `IdentityChangeSet.removed` is tombstoned +/// and disappears from the per-wallet `load_state` while a sibling, +/// non-removed identity survives. +#[test] +fn qa_tomb1_removed_identity_excluded_from_load() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD0); + ensure_wallet_meta(&persister, &w); + + let keep = entry_for(0x01, w); + let drop_me = entry_for(0x02, w); + let mut idents: BTreeMap = BTreeMap::new(); + idents.insert(keep.id, keep.clone()); + idents.insert(drop_me.id, drop_me.clone()); + + // First flush: insert both. + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: idents, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + + // Second flush: tombstone drop_me. + let mut removed: BTreeSet = BTreeSet::new(); + removed.insert(drop_me.id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: Default::default(), + removed, + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + + // The tombstoned row is still physically present (logical delete). + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "tombstone is a logical delete; row stays on disk"); + + let tombstoned: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1 AND tombstoned = 1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tombstoned, 1, "exactly one identity tombstoned"); + + // load_state must skip the tombstoned identity and keep the other. + let state = identities::load_state(&conn, &w).unwrap(); + drop(conn); + let wallet_idents = state.wallet_identities.get(&w).expect("wallet bucket"); + assert_eq!( + wallet_idents.len(), + 1, + "load_state must surface only the non-tombstoned identity" + ); + let surviving_ids: Vec = wallet_idents.values().map(|m| m.identity.id()).collect(); + assert!( + surviving_ids.contains(&keep.id), + "kept identity must survive load" + ); + assert!( + !surviving_ids.contains(&drop_me.id), + "tombstoned identity must NOT appear in load" + ); +} + +/// Re-upserting a tombstoned identity clears the tombstone (the upsert +/// sets `tombstoned = 0`) — the resurrection path the writer relies on. +#[test] +fn qa_tomb2_reupsert_clears_tombstone() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + let e = entry_for(0x05, w); + let mut idents: BTreeMap = BTreeMap::new(); + idents.insert(e.id, e.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: idents.clone(), + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + + let mut removed: BTreeSet = BTreeSet::new(); + removed.insert(e.id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: Default::default(), + removed, + }), + ..Default::default() + }, + ) + .unwrap(); + + // Re-upsert resurrects. + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: idents, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let tombstoned: i64 = conn + .query_row( + "SELECT tombstoned FROM identities WHERE identity_id = ?1", + rusqlite::params![e.id.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let state = identities::load_state(&conn, &w).unwrap(); + drop(conn); + assert_eq!(tombstoned, 0, "re-upsert must clear the tombstone flag"); + assert_eq!( + state + .wallet_identities + .get(&w) + .map(|m| m.len()) + .unwrap_or(0), + 1, + "resurrected identity must reappear in load" + ); +} + +/// The tombstone UPDATE is scoped by `wallet_id`: a `removed` entry +/// naming an identity parented to a different wallet is a no-op against +/// that wallet's row (NULL-safe `wallet_id IS ?2` predicate). An +/// identity_id is globally unique to one wallet, so this is +/// defense-in-depth enforcing the isolation the data model assumes. +#[test] +fn qa_tomb3_tombstone_update_is_wallet_scoped() { + let (persister, _tmp, path) = fresh_persister(); + let wa = wid(0xE0); + let wb = wid(0xE1); + ensure_wallet_meta(&persister, &wa); + ensure_wallet_meta(&persister, &wb); + + // Identity 0x07 is parented to wallet B. + let b_ident = entry_for(0x07, wb); + let mut b_map: BTreeMap = BTreeMap::new(); + b_map.insert(b_ident.id, b_ident.clone()); + persister + .store( + wb, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: b_map, + removed: Default::default(), + }), + ..Default::default() + }, + ) + .unwrap(); + + // Wallet A flushes a `removed` set naming wallet B's identity id. + let mut removed: BTreeSet = BTreeSet::new(); + removed.insert(b_ident.id); + persister + .store( + wa, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities: Default::default(), + removed, + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let tombstoned: i64 = conn + .query_row( + "SELECT tombstoned FROM identities WHERE identity_id = ?1", + rusqlite::params![b_ident.id.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let b_state = identities::load_state(&conn, &wb).unwrap(); + drop(conn); + + // Cross-wallet isolation: wallet A's `removed` set names wallet B's + // identity, but the wallet-scoped tombstone UPDATE leaves B's row + // untouched, so B's load still surfaces the identity. + assert_eq!( + tombstoned, 0, + "wallet-scoped tombstone: A's removed set must NOT affect B's identity" + ); + assert_eq!( + b_state + .wallet_identities + .get(&wb) + .map(|m| m.len()) + .unwrap_or(0), + 1, + "B's identity must survive A's unrelated tombstone" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs b/packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs new file mode 100644 index 00000000000..f69129df335 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_second_open_guard.rs @@ -0,0 +1,64 @@ +//! Second-open guard: a process-wide registry refuses a second +//! `SqlitePersister::open()` on the same canonical path while the first +//! is alive, so two in-process handles can't diverge (each owns an +//! independent `Mutex` + write buffer). Dropping the first +//! releases the claim so a later open succeeds. + +mod common; + +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +/// `SqlitePersister` is not `Debug`, so `Result::expect_err` can't be +/// used on an `open()` result — extract the error by matching instead. +fn open_err(cfg: SqlitePersisterConfig) -> WalletStorageError { + match SqlitePersister::open(cfg) { + Ok(_) => panic!("expected open() to fail"), + Err(e) => e, + } +} + +#[test] +fn second_open_on_same_path_is_refused() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + + let first = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("first open"); + + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + + // Releasing the first handle frees the claim. + drop(first); + let _reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .expect("open after the first handle drops must succeed"); +} + +#[test] +fn distinct_paths_open_concurrently() { + let tmp = tempfile::tempdir().unwrap(); + let a = tmp.path().join("a.db"); + let b = tmp.path().join("b.db"); + + let _pa = SqlitePersister::open(SqlitePersisterConfig::new(&a)).expect("open a"); + // A different path is unaffected by the registry. + let _pb = SqlitePersister::open(SqlitePersisterConfig::new(&b)).expect("open b"); +} + +#[test] +fn second_open_via_noncanonical_path_is_refused() { + // A `.`-segmented path canonicalizes to the same key as the plain + // path, so the registry still catches the second open. + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("w.db"); + let _first = SqlitePersister::open(SqlitePersisterConfig::new(&path)).expect("first open"); + + let dotted = tmp.path().join(".").join("w.db"); + let err = open_err(SqlitePersisterConfig::new(&dotted)); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen for the equivalent path, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs index 1018974bd56..9c79b36bafb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_structural_hardening.rs @@ -20,14 +20,14 @@ use platform_wallet::wallet::platform_wallet::WalletId; use platform_wallet_storage::WalletStorageError; use rusqlite::params; -/// a child insert without a `wallet_metadata` parent is +/// a child insert without a `wallets` parent is /// rejected by the native FK (not a trigger). #[test] fn native_fk_rejects_orphan_child() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); let res = conn.execute( - "INSERT INTO identities (wallet_id, wallet_index, identity_id, entry_blob, tombstoned) \ + "INSERT INTO identities (wallet_id, identity_index, identity_id, entry_blob, tombstoned) \ VALUES (?1, NULL, ?2, X'00', 0)", params![[7u8; 32].as_slice(), [9u8; 32].as_slice()], ); @@ -38,9 +38,11 @@ fn native_fk_rejects_orphan_child() { ); } -/// an `identity_keys` row whose `identities` parent does not -/// exist is rejected by the FK to `identities(identity_id)` (cascade -/// chain `wallet_metadata → identities → identity_keys`). +/// An `identity_keys` row whose `identities` parent does not exist is +/// rejected by the FK to `identities(identity_id)`. The `wallet_id` +/// parent exists (via `ensure_wallet_meta`), so the failure is +/// specifically the missing identity, not the wallet (cascade chain +/// `wallets → identities → identity_keys`). #[test] fn native_fk_rejects_identity_keys_without_identity() { let (persister, _tmp, _path) = fresh_persister(); @@ -49,9 +51,9 @@ fn native_fk_rejects_identity_keys_without_identity() { let conn = persister.lock_conn_for_test(); let res = conn.execute( "INSERT INTO identity_keys \ - (identity_id, key_id, public_key_blob, public_key_hash) \ - VALUES (?1, 0, X'00', X'00')", - params![[3u8; 32].as_slice()], + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, X'00', X'00', NULL)", + params![w.as_slice(), [3u8; 32].as_slice()], ); let err = res.unwrap_err().to_string(); assert!( @@ -114,38 +116,27 @@ fn make_utxo(addr: &Address, vout: u32, value: u64) -> Utxo { Utxo::new(outpoint, txout, addr.clone(), 10, false) } -/// UTXOs resolve their real `account_index` from the derived-address -/// map written earlier in the same transaction, instead of a hardcoded -/// 0. +/// Every `core_utxos` row is written with the hardcoded default +/// `account_index = 0` (the product uses only the default account; a +/// non-default account causes `core_bridge::warn_if_non_default_account` +/// to log a `warn!` but still persists the record under index 0 to avoid +/// fund loss), so the per-account grouping reader buckets every unspent +/// UTXO — at any address — under account 0. #[test] -fn multi_account_utxos_bucket_to_real_account() { +fn utxos_bucket_under_default_account_index_zero() { use platform_wallet_storage::sqlite::schema::core_state; let (persister, _tmp, _path) = fresh_persister(); let w: WalletId = wid(0xC7); ensure_wallet_meta(&persister, &w); - let addr_acct5 = p2pkh(0x05); - let addr_acct9 = p2pkh(0x09); + let addr_a = p2pkh(0x05); + let addr_b = p2pkh(0x09); { let mut conn = persister.lock_conn_for_test(); - // Pre-seed the derived-address map with two distinct accounts. - for (acct, addr) in [(5u32, &addr_acct5), (9u32, &addr_acct9)] { - conn.execute( - "INSERT INTO core_derived_addresses \ - (wallet_id, account_type, account_index, address, derivation_path, used) \ - VALUES (?1, 'standard', ?2, ?3, '0/0', 0)", - params![w.as_slice(), acct as i64, addr.to_string()], - ) - .unwrap(); - } - let cs = CoreChangeSet { - new_utxos: vec![ - make_utxo(&addr_acct5, 0, 1000), - make_utxo(&addr_acct9, 1, 2000), - ], + new_utxos: vec![make_utxo(&addr_a, 0, 1000), make_utxo(&addr_b, 1, 2000)], ..Default::default() }; let tx = conn.transaction().unwrap(); @@ -156,47 +147,20 @@ fn multi_account_utxos_bucket_to_real_account() { let conn = persister.lock_conn_for_test(); let by_account = core_state::list_unspent_utxos(&conn, &w).unwrap(); assert_eq!( - by_account.get(&5).map(|v| v.len()), - Some(1), - "account 5 should hold exactly one UTXO" + by_account.len(), + 1, + "all UTXOs bucket under a single (default) account" ); assert_eq!( - by_account.get(&9).map(|v| v.len()), - Some(1), - "account 9 should hold exactly one UTXO" - ); -} - -/// A NEW unspent UTXO whose address is absent from -/// `core_derived_addresses` cannot resolve an owning account, so the -/// write is refused with the typed `UtxoAddressNotDerived` instead of -/// silently mis-filing live funds under account 0. -#[test] -fn unspent_utxo_on_undeclared_address_is_rejected() { - use platform_wallet_storage::sqlite::schema::core_state; - - let (persister, _tmp, _path) = fresh_persister(); - let w: WalletId = wid(0xC8); - ensure_wallet_meta(&persister, &w); - - let addr_unknown = p2pkh(0xEE); - let mut conn = persister.lock_conn_for_test(); - let cs = CoreChangeSet { - new_utxos: vec![make_utxo(&addr_unknown, 0, 3000)], - ..Default::default() - }; - let tx = conn.transaction().unwrap(); - let err = core_state::apply(&tx, &w, &cs) - .expect_err("unspent UTXO on an undeclared address must error"); - assert!( - matches!(err, WalletStorageError::UtxoAddressNotDerived { .. }), - "expected UtxoAddressNotDerived, got {err:?}" + by_account.get(&0).map(|v| v.len()), + Some(2), + "both UTXOs are attributed to the default account (index 0)" ); } -/// A spent-only placeholder UTXO whose address was never derived still -/// persists with the account-0 fallback — spent rows are excluded from -/// the unspent set, so the placeholder index is inert. +/// A spent-only placeholder UTXO (no prior unspent row to mark) persists +/// with the hardcoded account 0 — spent rows are excluded from the unspent +/// set, so the index is inert. #[test] fn spent_only_utxo_on_undeclared_address_uses_zero_fallback() { use platform_wallet_storage::sqlite::schema::core_state; @@ -230,20 +194,20 @@ fn spent_only_utxo_on_undeclared_address_uses_zero_fallback() { /// an out-of-range `birth_height` errors rather than truncating. #[test] fn birth_height_overflow_errors_not_truncates() { - use platform_wallet_storage::sqlite::schema::wallet_meta; + use platform_wallet_storage::sqlite::schema::wallets; let (persister, _tmp, _path) = fresh_persister(); let w = wid(0xD1); { let conn = persister.lock_conn_for_test(); // 1<<40 overflows u32 but fits the i64 column. conn.execute( - "INSERT INTO wallet_metadata (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', ?2)", params![w.as_slice(), 1_099_511_627_776i64], ) .unwrap(); } let conn = persister.lock_conn_for_test(); - let err = wallet_meta::fetch(&conn, &w).expect_err("overflow must error"); + let err = wallets::fetch(&conn, &w).expect_err("overflow must error"); assert!( matches!(err, WalletStorageError::IntegerOverflow { .. }), "expected IntegerOverflow, got {err:?}" diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs b/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs new file mode 100644 index 00000000000..ae3ef2ce92c --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_wallet_db_identity.rs @@ -0,0 +1,168 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Wallet-DB identity gates: the `application_id` header magic and the +//! `refinery_schema_history` well-formedness probe. +//! +//! - A foreign refinery-versioned SQLite DB (has `schema_history`, passes +//! `integrity_check`, version within range) but the WRONG +//! `application_id` must be rejected as `NotAWalletDb` — both on +//! `restore_from` (destination untouched) and on `open()`. +//! - A wallet DB whose `refinery_schema_history` carries a malformed +//! `applied_on` / `checksum` must surface a typed +//! `SchemaHistoryMalformed` rather than panicking inside refinery. + +mod common; + +use common::{fresh_persister, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; +use rusqlite::Connection; + +/// `SqlitePersister` is not `Debug`, so `Result::expect_err` can't be +/// used on an `open()` result — extract the error by matching instead. +fn open_err(cfg: SqlitePersisterConfig) -> WalletStorageError { + match SqlitePersister::open(cfg) { + Ok(_) => panic!("expected open() to fail"), + Err(e) => e, + } +} + +/// Build a "foreign" refinery-versioned DB at `path`: it has a +/// `refinery_schema_history` table with a well-formed row and passes +/// `integrity_check`, but carries a DIFFERENT `application_id`, so it is +/// NOT a wallet-storage database. +fn write_foreign_refinery_db(path: &std::path::Path, application_id: i32) { + let conn = Connection::open(path).expect("open foreign db"); + conn.pragma_update(None, "application_id", application_id) + .expect("stamp foreign application_id"); + conn.execute_batch( + "CREATE TABLE refinery_schema_history ( + version INTEGER PRIMARY KEY, + name TEXT, + applied_on TEXT, + checksum TEXT + ); + INSERT INTO refinery_schema_history (version, name, applied_on, checksum) + VALUES (1, 'initial', '2026-01-01T00:00:00+00:00', '12345'); + CREATE TABLE some_foreign_table (x INTEGER);", + ) + .expect("seed foreign schema"); + drop(conn); +} + +/// Materialize a real wallet DB on disk, then return its path inside a +/// kept-alive tempdir. +fn fresh_wallet_db() -> (tempfile::TempDir, std::path::PathBuf) { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x11); + let mut cs = PlatformWalletChangeSet::default(); + cs.wallet_metadata = Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 0, + }); + cs.core = Some(CoreChangeSet { + synced_height: Some(5), + last_processed_height: Some(5), + ..Default::default() + }); + persister.store(w, cs).expect("store"); + persister.flush(w).expect("flush"); + drop(persister); + (tmp, path) +} + +#[test] +fn restore_from_rejects_foreign_application_id_destination_untouched() { + let (_tmp, dest) = fresh_wallet_db(); + + // Snapshot the live destination bytes so we can prove restore left it + // untouched on rejection. + let before = std::fs::read(&dest).expect("read dest before"); + + let src_tmp = tempfile::tempdir().unwrap(); + let foreign = src_tmp.path().join("foreign.db"); + // Anything but the wallet-storage magic. + write_foreign_refinery_db(&foreign, 0x0BAD_F00D_u32 as i32); + + let err = SqlitePersister::restore_from_skip_backup(&dest, &foreign) + .expect_err("restore of a foreign refinery DB must fail"); + assert!( + matches!(err, WalletStorageError::NotAWalletDb { .. }), + "expected NotAWalletDb, got {err:?}" + ); + + let after = std::fs::read(&dest).expect("read dest after"); + assert_eq!( + before, after, + "destination wallet DB must be byte-identical after a rejected restore" + ); + + // The destination must still open as a wallet DB. + SqlitePersister::open(SqlitePersisterConfig::new(&dest)) + .expect("destination still opens after rejected restore"); +} + +#[test] +fn open_rejects_foreign_application_id() { + let tmp = tempfile::tempdir().unwrap(); + let foreign = tmp.path().join("foreign.db"); + write_foreign_refinery_db(&foreign, 0x0BAD_F00D_u32 as i32); + + let err = open_err(SqlitePersisterConfig::new(&foreign)); + assert!( + matches!(err, WalletStorageError::NotAWalletDb { .. }), + "expected NotAWalletDb, got {err:?}" + ); +} + +#[test] +fn open_accepts_a_real_wallet_db_with_stamped_application_id() { + let (_tmp, path) = fresh_wallet_db(); + // Reopening a genuine wallet DB must pass the application_id gate. + SqlitePersister::open(SqlitePersisterConfig::new(&path)) + .expect("reopen of a genuine wallet DB must succeed"); +} + +#[test] +fn open_rejects_malformed_schema_history_without_panicking() { + let (_tmp, path) = fresh_wallet_db(); + + // Corrupt the schema_history `applied_on` to a non-RFC3339 value via + // a side connection, then reopen. Refinery would unwrap()-panic on + // this; the pre-run probe must turn it into a typed error. + { + let conn = Connection::open(&path).expect("side conn"); + conn.execute( + "UPDATE refinery_schema_history SET applied_on = 'not-a-timestamp'", + [], + ) + .expect("corrupt applied_on"); + } + + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::SchemaHistoryMalformed { .. }), + "expected SchemaHistoryMalformed, got {err:?}" + ); +} + +#[test] +fn open_rejects_non_numeric_checksum_in_schema_history() { + let (_tmp, path) = fresh_wallet_db(); + { + let conn = Connection::open(&path).expect("side conn"); + conn.execute( + "UPDATE refinery_schema_history SET checksum = 'deadbeef'", + [], + ) + .expect("corrupt checksum"); + } + let err = open_err(SqlitePersisterConfig::new(&path)); + assert!( + matches!(err, WalletStorageError::SchemaHistoryMalformed { .. }), + "expected SchemaHistoryMalformed, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 83b6d860742..128a38d29b3 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -1,36 +1,66 @@ //! Per-wallet portion of [`ClientStartState`](crate::changeset::ClientStartState). //! -//! Everything a single wallet contributes to the startup snapshot: the -//! key-wallet [`Wallet`] + [`ManagedWalletInfo`] pair, a lean -//! identity-manager snapshot, and still-unused asset locks bucketed by -//! account index. +//! **Keyless by type.** This carries everything needed to *reconstruct* +//! a watch-only wallet — network, birth height, the account manifest, +//! the rebuilt core-state projection, identities, filtered asset locks — +//! but **no** [`Wallet`](key_wallet::Wallet) and no seed. The persister +//! can never mint a `Wallet`; the manager rebuilds a watch-only one via +//! [`Wallet::new_watch_only`](key_wallet::wallet::Wallet::new_watch_only) +//! from the manifest, applies this state, and defers signing-key +//! derivation to the on-demand sign path +//! ([`sign_with_mnemonic_resolver`] and its siblings). +//! +//! [`sign_with_mnemonic_resolver`]: https://docs.rs/rs-platform-wallet-ffi/ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; +use crate::changeset::{ + AccountRegistrationEntry, ContactChangeSet, CoreChangeSet, IdentityKeysChangeSet, +}; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; -use key_wallet::wallet::ManagedWalletInfo; -use key_wallet::Wallet; +use key_wallet::Network; -/// Per-wallet slice of the startup snapshot. +/// Keyless per-wallet slice of the startup snapshot. /// -/// Used as the value type in [`ClientStartState::wallets`](crate::changeset::ClientStartState::wallets). +/// Used as the value type in +/// [`ClientStartState::wallets`](crate::changeset::ClientStartState::wallets). +/// The structural absence of a `Wallet`/seed field is the SECRETS.md +/// boundary, enforced by type rather than convention. #[derive(Debug)] pub struct ClientWalletStartState { - /// The key-wallet [`Wallet`] to rehydrate on startup. Carries the - /// HD key material and account configuration the rest of the - /// per-wallet state hangs off of. - pub wallet: Wallet, - /// Managed wallet info holding non-key-material state (balances, - /// account metadata, UTXO set, etc.) for this wallet. - pub wallet_info: ManagedWalletInfo, + /// Network the wallet is bound to (from `wallet_metadata`). + pub network: Network, + /// Best estimate of the chain tip at creation time (`0` = scan + /// from genesis / unknown). + pub birth_height: u32, + /// Keyless account manifest — the account-set oracle for building the + /// watch-only wallet (one watch-only account per entry's xpub). + pub account_manifest: Vec, + /// Keyless projection of the persisted core rows (UTXOs, tx + /// records, IS-locks, sync watermarks, `last_applied_chain_lock`). + /// The manager applies this onto a fresh + /// `ManagedWalletInfo::from_wallet` skeleton built from the + /// watch-only wallet. Rebuilt by the `core_state::load_state` reader + /// (item B). + pub core_state: CoreChangeSet, /// Lean snapshot of this wallet's - /// [`IdentityManager`](crate::wallet::identity::IdentityManager): - /// owned + watched identities, primary selection, and the - /// gap-limit scan watermark. + /// [`IdentityManager`](crate::wallet::identity::IdentityManager). pub identity_manager: IdentityManagerStartState, - /// Asset locks that have not yet been consumed by an identity - /// registration / top-up, keyed by account index → outpoint. + /// Asset locks not yet consumed by an identity registration / + /// top-up, keyed by account index → outpoint. Terminal `Consumed` + /// rows are already filtered out by the asset-lock reader. pub unused_asset_locks: BTreeMap>, + /// Persisted DashPay contact state (sent/received requests + + /// established contacts) to layer onto the rehydrated managed + /// identities. PUBLIC material — `removed_*` are always empty + /// (deletes never reach storage as rows). Routed by the manager + /// after `IdentityManager::from`, mirroring the runtime apply path. + pub contacts: ContactChangeSet, + /// Persisted per-identity PUBLIC key entries (no private key + /// material) to layer onto the rehydrated managed identities so + /// `Identity.public_keys` is populated at load time instead of + /// only after the next sync. `removed` is always empty. + pub identity_keys: IdentityKeysChangeSet, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e7af9be1c7..c1f606b03b5 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,194 +1,16 @@ //! Hydrate a [`PlatformWalletManager`] from its persister. -use std::collections::BTreeMap; -use std::sync::Arc; - -use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; +use crate::changeset::PlatformWalletPersistence; use crate::error::PlatformWalletError; -use crate::wallet::core::WalletBalance; -use crate::wallet::identity::IdentityManager; -use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; -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. - /// - /// 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`. + /// Rehydrate the manager's wallet maps from the configured persister. /// - /// 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). - /// - /// [`WalletManager`]: key_wallet_manager::WalletManager + /// Keyless rehydration lands in #3692; #3968 ships the storage layer + /// only, so on the independent branch this entry point is a stub. pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { - let ClientStartState { - mut platform_addresses, - wallets, - // Shielded restore happens lazily on `bind_shielded`, - // 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 - )) - })?; - - 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. - let mut inserted_in_manager: Vec = Vec::new(); - let mut inserted_in_wallets: Vec = Vec::new(); - let mut load_error: Option = None; - - 'load: for (expected_wallet_id, wallet_state) in wallets { - let ClientWalletStartState { - wallet, - wallet_info, - identity_manager, - unused_asset_locks, - } = wallet_state; - - // Flatten the (account → outpoint → lock) map into the flat - // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` - // holds today. - 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(), - core_balance.unconfirmed(), - core_balance.immature(), - core_balance.locked(), - ); - let platform_info = PlatformWalletInfo { - core_wallet: wallet_info, - balance: Arc::clone(&balance), - identity_manager: IdentityManager::from(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) { - Ok(id) => id, - Err(e) => { - load_error = Some(PlatformWalletError::WalletCreation(format!( - "Failed to register persisted wallet in WalletManager: {}", - e - ))); - break 'load; - } - } - }; - 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, - ))); - let platform_wallet = PlatformWallet::new( - Arc::clone(&self.sdk), - wallet_id, - Arc::clone(&self.wallet_manager), - balance, - Arc::clone(&self.lock_notify), - Arc::clone(&persister_dyn), - 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() - .initialize_from_persisted(persisted) - .await - { - load_error = Some(PlatformWalletError::WalletCreation(format!( - "Failed to restore platform address state: {}", - e - ))); - break 'load; - } - } else { - platform_wallet.platform().initialize().await; - } - - let platform_wallet = Arc::new(platform_wallet); - let mut wallets_guard = self.wallets.write().await; - wallets_guard.insert(wallet_id, platform_wallet); - drop(wallets_guard); - inserted_in_wallets.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 { - wallets_guard.remove(id); - } - } - if !inserted_in_manager.is_empty() { - let mut wm = self.wallet_manager.write().await; - for id in &inserted_in_manager { - let _ = wm.remove_wallet(id); - } - } - return Err(err); - } - - Ok(()) + todo!("keyless rehydration lands in #3692") } } 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 003/108] 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 004/108] 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 005/108] 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 006/108] 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 007/108] =?UTF-8?q?fix(platform-wallet):=20idempotent=20re?= =?UTF-8?q?peat=20restore=20=E2=80=94=20skip=20already-present=20wallet=20?= =?UTF-8?q?on=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 008/108] 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 009/108] 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 010/108] 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 011/108] 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 012/108] 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 d08abc2f4591288d1e2c4f332ab236657aa183f1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:42:24 +0000 Subject: [PATCH 013/108] fix(platform-wallet-storage): widen account-registration PK with discriminators to stop distinct-variant collapse (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit account_registrations keyed on (wallet_id, account_type, account_index) only. PlatformPayment key classes and DashPay (user, friend) identity pairs share that key across genuinely distinct accounts, so the ON CONFLICT DO UPDATE silently overwrote one with another — a restored wallet lost accounts (data loss). Chose option (a) widen-PK over fail-loud: a wallet legitimately holds multiple DashpayReceivingFunds accounts (one per contact) at the same index, so failing the collision would reject valid multi-contact wallets. Add key_class, user_identity_id, friend_identity_id as NOT NULL columns with sentinel defaults (0 / zeroblob) so non-discriminated variants still dedup on re-persist, and widen the PK to include them. The reader cross-checks every typed PK column against the decoded blob and orders deterministically. V001 edited in place (on-disk format unshipped). Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../migrations/V001__initial.rs | 8 +- .../src/sqlite/schema/accounts.rs | 190 ++++++++++++++++-- .../tests/sqlite_compile_time.rs | 2 +- .../tests/sqlite_load_wiring.rs | 11 +- 4 files changed, 189 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs index 45d56406182..b0847be5cd0 100644 --- a/packages/rs-platform-wallet-storage/migrations/V001__initial.rs +++ b/packages/rs-platform-wallet-storage/migrations/V001__initial.rs @@ -76,8 +76,14 @@ CREATE TABLE account_registrations ( wallet_id BLOB NOT NULL, account_type TEXT NOT NULL CHECK (account_type IN {account_type_check}), account_index INTEGER NOT NULL, + -- Discriminators sharing (account_type, account_index) across distinct + -- accounts: PlatformPayment key_class and the DashPay (user, friend) + -- identity pair. Sentinel default for variants without that axis. + key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), account_xpub_bytes BLOB NOT NULL, - PRIMARY KEY (wallet_id, account_type, account_index), + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id), FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 9042f243767..d5830959521 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -102,22 +102,31 @@ pub fn apply_registrations( return Ok(()); } // `account_xpub_bytes` holds the encoded `AccountRegistrationEntry`; the - // separate `account_type` / `account_index` columns mirror it for SQL. + // separate typed columns mirror it for SQL. `key_class` and the DashPay + // `(user, friend)` identity pair widen the PK so distinct accounts that + // share `(account_type, account_index)` don't overwrite each other. let mut stmt = tx.prepare_cached( "INSERT INTO account_registrations \ - (wallet_id, account_type, account_index, account_xpub_bytes) \ - VALUES (?1, ?2, ?3, ?4) \ - ON CONFLICT(wallet_id, account_type, account_index) DO UPDATE SET \ + (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id) DO UPDATE SET \ account_xpub_bytes = excluded.account_xpub_bytes", )?; for entry in entries { let account_type = account_type_db_label(&entry.account_type); let account_index = account_index(&entry.account_type); + let key_class = account_key_class(&entry.account_type); + let (user_identity_id, friend_identity_id) = account_dashpay_ids(&entry.account_type); let payload = blob::encode(entry)?; stmt.execute(params![ wallet_id.as_slice(), account_type, i64::from(account_index), + i64::from(key_class), + &user_identity_id[..], + &friend_identity_id[..], payload, ])?; } @@ -139,30 +148,45 @@ pub fn load_state( // columns is a sign of corruption or a schema bug and must be rejected // rather than silently mis-bucketed. let mut stmt = conn.prepare( - "SELECT account_type, account_index, account_xpub_bytes FROM account_registrations \ - WHERE wallet_id = ?1 ORDER BY account_type, account_index", + "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id, \ + account_xpub_bytes FROM account_registrations \ + WHERE wallet_id = ?1 \ + ORDER BY account_type, account_index, key_class, user_identity_id, friend_identity_id", )?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { Ok(( row.get::<_, String>(0)?, // account_type TEXT row.get::<_, i64>(1)?, // account_index INTEGER - row.get::<_, Vec>(2)?, // account_xpub_bytes BLOB + row.get::<_, i64>(2)?, // key_class INTEGER + row.get::<_, Vec>(3)?, // user_identity_id BLOB + row.get::<_, Vec>(4)?, // friend_identity_id BLOB + row.get::<_, Vec>(5)?, // account_xpub_bytes BLOB )) })?; let mut out = Vec::new(); for r in rows { - let (typed_account_type, typed_account_index, payload) = r?; + let (typed_type, typed_index, typed_key_class, typed_user, typed_friend, payload) = r?; let entry = blob::decode::(&payload)?; - // Cross-check the typed indexed columns vs the decoded blob so - // a corruption that passes `PRAGMA integrity_check` is still - // caught here rather than feeding a wrong account to the oracle. - let blob_type = account_type_db_label(&entry.account_type); + // Cross-check every typed PK column vs the decoded blob so a + // corruption that passes `PRAGMA integrity_check` is still caught + // here rather than feeding a wrong account to the oracle. let blob_index = account_index(&entry.account_type); + let blob_key_class = account_key_class(&entry.account_type); + let (blob_user, blob_friend) = account_dashpay_ids(&entry.account_type); let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( "account_registrations.account_index", - typed_account_index, + typed_index, + )?; + let typed_key_class = crate::sqlite::util::safe_cast::i64_to_u32( + "account_registrations.key_class", + typed_key_class, )?; - if blob_type != typed_account_type.as_str() || blob_index != typed_index { + if account_type_db_label(&entry.account_type) != typed_type.as_str() + || blob_index != typed_index + || blob_key_class != typed_key_class + || blob_user.as_slice() != typed_user.as_slice() + || blob_friend.as_slice() != typed_friend.as_slice() + { return Err(WalletStorageError::AccountRegistrationEntryMismatch); } out.push(entry); @@ -255,6 +279,38 @@ pub(crate) fn account_index(at: &key_wallet::account::AccountType) -> u32 { } } +/// Hardened `key_class` discriminator for `PlatformPayment`, persisted in the +/// `account_registrations.key_class` PK column. `0` for every other variant — +/// the sentinel "no key-class axis" value, matching the column default. +pub(crate) fn account_key_class(at: &key_wallet::account::AccountType) -> u32 { + use key_wallet::account::AccountType; + match at { + AccountType::PlatformPayment { key_class, .. } => *key_class, + _ => 0, + } +} + +/// DashPay `(user_identity_id, friend_identity_id)` discriminator pair — the +/// real account key for `DashpayReceivingFunds` / `DashpayExternalAccount`, +/// persisted in the matching PK columns. All-zero for every non-DashPay +/// variant (no identity axis), matching the column default. +pub(crate) fn account_dashpay_ids(at: &key_wallet::account::AccountType) -> ([u8; 32], [u8; 32]) { + use key_wallet::account::AccountType; + match at { + AccountType::DashpayReceivingFunds { + user_identity_id, + friend_identity_id, + .. + } + | AccountType::DashpayExternalAccount { + user_identity_id, + friend_identity_id, + .. + } => (*user_identity_id, *friend_identity_id), + _ => ([0u8; 32], [0u8; 32]), + } +} + #[cfg(test)] mod tests { use super::*; @@ -397,6 +453,112 @@ mod tests { )); } + /// Two `PlatformPayment` accounts sharing `(account_type, account_index)` + /// but differing in `key_class` must both survive a persist — the widened + /// PK keeps distinct key classes from collapsing onto one row (the + /// data-loss bug this fix addresses). + #[test] + fn distinct_key_class_accounts_do_not_collide() { + let mut conn = migrated_conn(); + let w = [0x44u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = |key_class: u32| AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 0, + key_class, + }, + account_xpub: test_xpub(), + }; + { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, &[entry(0), entry(1)]).unwrap(); + tx.commit().unwrap(); + } + let loaded = load_state(&conn, &w).expect("both key classes load"); + assert_eq!(loaded.len(), 2, "distinct key classes must both persist"); + let key_classes: HashSet = loaded + .iter() + .map(|e| match e.account_type { + key_wallet::account::AccountType::PlatformPayment { key_class, .. } => key_class, + _ => unreachable!("only PlatformPayment was inserted"), + }) + .collect(); + assert_eq!(key_classes, HashSet::from([0, 1])); + } + + /// Two `DashpayReceivingFunds` accounts at the same `index` but for + /// different contacts (distinct `friend_identity_id`) must both survive — + /// the per-contact identity pair is the real account key and must not + /// collapse on the shared `(account_type, account_index)`. + #[test] + fn distinct_dashpay_friends_do_not_collide() { + let mut conn = migrated_conn(); + let w = [0x55u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = |friend: [u8; 32]| AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id: [0xAB; 32], + friend_identity_id: friend, + }, + account_xpub: test_xpub(), + }; + { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, &[entry([0x01; 32]), entry([0x02; 32])]).unwrap(); + tx.commit().unwrap(); + } + let loaded = load_state(&conn, &w).expect("both contacts load"); + assert_eq!(loaded.len(), 2, "distinct contacts must both persist"); + let friends: HashSet<[u8; 32]> = loaded + .iter() + .map(|e| match e.account_type { + key_wallet::account::AccountType::DashpayReceivingFunds { + friend_identity_id, + .. + } => friend_identity_id, + _ => unreachable!("only DashpayReceivingFunds was inserted"), + }) + .collect(); + assert_eq!(friends, HashSet::from([[0x01; 32], [0x02; 32]])); + } + + /// Re-persisting the same account (identical full `AccountType`) updates in + /// place rather than inserting a duplicate — the idempotent upsert the + /// widened PK must preserve. + #[test] + fn idempotent_repersist_does_not_duplicate() { + let mut conn = migrated_conn(); + let w = [0x66u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![&w[..]], + ) + .unwrap(); + let entry = AccountRegistrationEntry { + account_type: key_wallet::account::AccountType::PlatformPayment { + account: 2, + key_class: 1, + }, + account_xpub: test_xpub(), + }; + for _ in 0..2 { + let tx = conn.transaction().unwrap(); + apply_registrations(&tx, &w, std::slice::from_ref(&entry)).unwrap(); + tx.commit().unwrap(); + } + let loaded = load_state(&conn, &w).expect("load"); + assert_eq!(loaded.len(), 1, "re-persist must not duplicate the row"); + } + /// Every [`key_wallet::account::AccountType`] variant; the wildcard-free /// match below fails to compile if upstream adds one. `Standard` appears /// twice — once per `StandardAccountType` — because both map to distinct diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 694d82e532c..688760c8546 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -61,7 +61,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", - "SELECT account_type, account_index, account_xpub_bytes FROM account_registrations", + "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id,", ), ( "core_state.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs index ff4d91744f1..c9c295fbc28 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs @@ -107,12 +107,11 @@ fn c1_load_populates_keyless_wallet_payload() { let slice = state.wallets.get(&w).expect("wallet slice"); assert_eq!(slice.network, key_wallet::Network::Testnet); assert_eq!(slice.birth_height, 7); - // Every persisted row round-trips. The writer's - // `(account_type_label, account_index)` upsert key collapses a few - // distinct special-purpose variants that share a label+index (a - // persist-side characteristic, not a load bug), so the manifest is - // a faithful read of what is on disk: non-empty, containing the - // primary BIP44 account. + // Every persisted account round-trips: the registration PK carries the + // full discriminator set (account_type, index, key_class, dashpay ids), + // so distinct variants never collapse onto one row. The manifest is a + // faithful read of what is on disk — non-empty, containing the primary + // BIP44 account. assert!(!slice.account_manifest.is_empty()); assert!( slice.account_manifest.iter().any(|e| matches!( From 9c41458989f7d7ac2d5ae579cbf89aa46d9afee9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:42:33 +0000 Subject: [PATCH 014/108] fix(platform-wallet-storage): reject restore_from onto an in-process open database (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit restore_from's doc promised the destination must not be open in this process, but restore_from_inner never consulted the open-path registry. Restoring over a live persister's file would leave that handle's connection and write buffer silently diverged from the freshly restored bytes. Canonicalize dest_db_path the same way open() registers it and return WalletStorageError::AlreadyOpen when the path is held by a live persister. The guard runs before the pre-restore auto-backup, so both restore_from and restore_from_skip_backup are covered. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/persister.rs | 23 +++++++++ .../tests/sqlite_restore_open_path_guard.rs | 49 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index cbdc4a3796e..a03d5e2064a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -104,6 +104,16 @@ fn release_open_path(path: &Path) { set.remove(path); } +/// `true` if `path` is held open by a live [`SqlitePersister`] in this +/// process. Callers pass a canonicalized path (matching how `open()` +/// registers it). +fn is_path_open(path: &Path) -> bool { + open_path_registry() + .lock() + .unwrap_or_else(|p| p.into_inner()) + .contains(path) +} + /// SQLite-backed `PlatformWalletPersistence`. pub struct SqlitePersister { config: SqlitePersisterConfig, @@ -302,6 +312,19 @@ impl SqlitePersister { auto_backup_dir: Option<&Path>, skip_backup: bool, ) -> Result<(), WalletStorageError> { + // Refuse to overwrite a database a live persister in this process is + // still holding open: that handle's buffer/connection would silently + // diverge from the restored bytes. Canonicalize to match how `open()` + // registers the path (symlinks / `.`-segments resolve to one key); a + // not-yet-existing dest can't be open, so the fallback path is fine. + let dest_canonical = dest_db_path + .canonicalize() + .unwrap_or_else(|_| dest_db_path.to_path_buf()); + if is_path_open(&dest_canonical) { + return Err(WalletStorageError::AlreadyOpen { + path: dest_canonical, + }); + } if !skip_backup && dest_db_path.exists() { let dir = auto_backup_dir.ok_or(WalletStorageError::AutoBackupDisabled { operation: AutoBackupOperation::Restore, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs b/packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs new file mode 100644 index 00000000000..8e8361b32e5 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_restore_open_path_guard.rs @@ -0,0 +1,49 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `restore_from` must refuse to overwrite a database that a live +//! [`SqlitePersister`] in this process is still holding open — that +//! handle's write buffer / connection would silently diverge from the +//! restored bytes. The guard mirrors `open()`'s in-process open-path +//! registry and clears once the holder drops. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet_storage::{SqlitePersister, WalletStorageError}; + +/// While a persister holds the destination open, both restore entry +/// points return [`WalletStorageError::AlreadyOpen`]; after it drops, the +/// restore succeeds. +#[test] +fn restore_refuses_an_in_process_open_destination() { + let (persister, _tmp, db_path) = fresh_persister(); + ensure_wallet_meta(&persister, &wid(0xA1)); + + // A valid wallet-storage backup to restore from. + let backup_dir = tempfile::tempdir().expect("backup dir"); + let backup_path = persister + .backup_to(backup_dir.path()) + .expect("online backup"); + + // The destination is still open in this process → refuse. + let err = SqlitePersister::restore_from_skip_backup(&db_path, &backup_path) + .expect_err("restore onto an open db must be refused"); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + + // The safe-by-default entry point guards before the auto-backup too. + let err = SqlitePersister::restore_from(&db_path, &backup_path, Some(backup_dir.path())) + .expect_err("safe restore onto an open db must be refused"); + assert!( + matches!(err, WalletStorageError::AlreadyOpen { .. }), + "expected AlreadyOpen, got {err:?}" + ); + + // Once the holder drops, the open-path registry clears and the restore + // goes through. + drop(persister); + SqlitePersister::restore_from_skip_backup(&db_path, &backup_path) + .expect("restore succeeds after the holder closes"); +} From d257320ea1233295338147d545a91d665ab6702c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:42:47 +0000 Subject: [PATCH 015/108] fix(platform-wallet-storage): cross-check identity_keys decoded ids against typed columns on read (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit identity_keys::load_state inserted each decoded entry under the typed (identity_id, key_id) columns with no cross-check of the blob's own identity_id / key_id / wallet_id — unlike its accounts and asset_locks siblings. A row whose blob disagrees with its indexed columns (corruption that passes PRAGMA integrity_check) would be silently mis-keyed into the upsert map. Assert the decoded ids and wallet scope equal the typed columns after decode_entry, returning the existing IdentityKeyEntryMismatch otherwise. Mirrors the writer-side guard and the sibling readers. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/schema/identity_keys.rs | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 784480b7719..68cb470c168 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -166,6 +166,18 @@ pub fn load_state( target: crate::sqlite::util::safe_cast::SafeCastTarget::U64, })?; let entry = decode_entry(&payload)?; + // Cross-check the decoded blob against the typed columns it was + // selected by (mirrors `accounts`/`asset_locks` readers): a row whose + // blob names a different identity / key / wallet than its indexed + // columns is corruption, never silently mis-keyed into the map. + if entry.identity_id != identity_id || entry.key_id != key_id { + return Err(WalletStorageError::IdentityKeyEntryMismatch); + } + if let Some(entry_wallet_id) = entry.wallet_id { + if entry_wallet_id != *wallet_id { + return Err(WalletStorageError::IdentityKeyEntryMismatch); + } + } cs.upserts.insert((identity_id, key_id), entry); } Ok(cs) @@ -178,6 +190,109 @@ mod tests { use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; + /// In-memory connection with the full schema applied. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// A valid `IdentityPublicKey` for building wire blobs in tests. + fn sample_public_key() -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }) + } + + /// Insert an `identity_keys` row with a fully-formed wire blob, first + /// staging the `wallets` + `identities` FK parents it depends on. + fn insert_key_row( + conn: &Connection, + wallet: &[u8; 32], + typed_identity: &[u8; 32], + wire: &IdentityKeyWire, + ) { + conn.execute( + "INSERT OR IGNORE INTO wallets (wallet_id, network, birth_height) \ + VALUES (?1, 'testnet', 0)", + params![&wallet[..]], + ) + .unwrap(); + crate::sqlite::schema::identities::ensure_exists(conn, wallet, typed_identity).unwrap(); + let entry_blob = blob::encode(wire).unwrap(); + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, ?3, ?4, NULL)", + params![&wallet[..], &typed_identity[..], entry_blob, &[0u8; 20][..]], + ) + .unwrap(); + } + + /// `load_state` rejects a row whose decoded blob names a different + /// `identity_id` than its typed column — corruption is a hard, typed + /// error rather than a silent mis-key into the upsert map. + #[test] + fn load_state_rejects_identity_id_column_mismatch() { + let conn = migrated_conn(); + let wallet = [0x11u8; 32]; + let typed_identity = [0xBBu8; 32]; + let wire = IdentityKeyWire { + identity_id: Identifier::from([0xAAu8; 32]), // disagrees with the column + key_id: 0, + public_key_bincode: bincode::encode_to_vec( + sample_public_key(), + bincode::config::standard(), + ) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet).expect_err("identity_id mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + + /// `load_state` rejects a row whose decoded blob carries a `wallet_id` + /// different from the wallet scope the typed column is read under. + #[test] + fn load_state_rejects_wallet_id_blob_mismatch() { + let conn = migrated_conn(); + let wallet = [0x22u8; 32]; + let typed_identity = [0xCCu8; 32]; + let wire = IdentityKeyWire { + identity_id: Identifier::from(typed_identity), // matches the column + key_id: 0, + public_key_bincode: bincode::encode_to_vec( + sample_public_key(), + bincode::config::standard(), + ) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: Some([0xDDu8; 32]), // disagrees with the read scope + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet).expect_err("wallet_id mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + /// A `public_key_bincode` payload whose IdentityPublicKey prefix is /// valid but carries trailing garbage is refused at decode time /// rather than silently dropping the trailing bytes. From 8e7fd66543b86ecf3295adc103cf6591e26fdcab Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:42:47 +0000 Subject: [PATCH 016/108] fix(platform-wallet-storage): scope identities upsert overwrite to the owning wallet (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit identities apply used ON CONFLICT(identity_id) DO UPDATE. Ownership was already guarded (wallet_id COALESCE + wallet-scoped tombstone), but entry_blob / identity_index overwrite and the tombstoned=0 reset fired unconditionally across wallet scopes — a wallet-B flush could clobber wallet-A's blob/index and resurrect a tombstoned identity. Add 'WHERE identities.wallet_id IS NULL OR identities.wallet_id IS excluded.wallet_id' to the DO UPDATE so the overwrite fires only for an unowned row (orphan -> parented promotion) or the owning wallet. A cross-wallet write becomes a no-op (SQLite skips a false-WHERE upsert without erroring). Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/schema/identities.rs | 128 +++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index d8ac1ab8373..7f0f0ccd8ef 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -24,6 +24,12 @@ pub fn apply( // (excluded fills only when on-disk is NULL): the orphan → parented // promotion path. The all-zero sentinel stores NULL (no parent). let scope_is_sentinel = wallet_id.iter().all(|b| *b == 0); + // The DO UPDATE WHERE keeps a wallet-B flush from overwriting wallet + // A's row: it fires only when the on-disk row is unowned (orphan → + // parented promotion) or already owned by the incoming scope. A + // cross-wallet write becomes a no-op (SQLite skips a false-WHERE + // upsert without erroring), preserving the resident blob, index, and + // tombstone. `IS` is the NULL-safe match for the nullable column. let mut stmt = tx.prepare_cached( "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ VALUES (?1, ?2, ?3, ?4, 0) \ @@ -31,7 +37,8 @@ pub fn apply( wallet_id = COALESCE(identities.wallet_id, excluded.wallet_id), \ identity_index = excluded.identity_index, \ entry_blob = excluded.entry_blob, \ - tombstoned = 0", + tombstoned = 0 \ + WHERE identities.wallet_id IS NULL OR identities.wallet_id IS excluded.wallet_id", )?; let wallet_id_param = wallet_id_to_param(wallet_id); for (id, entry) in &cs.identities { @@ -235,3 +242,122 @@ pub fn ensure_exists( )?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::prelude::Identifier; + use platform_wallet::changeset::IdentityChangeSet; + use platform_wallet::wallet::identity::IdentityStatus; + + fn migrated_conn() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + fn insert_wallet(conn: &Connection, wallet: &[u8; 32]) { + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&wallet[..]], + ) + .unwrap(); + } + + fn entry( + id: [u8; 32], + wallet_id: Option<[u8; 32]>, + balance: u64, + index: Option, + ) -> IdentityEntry { + IdentityEntry { + id: Identifier::from(id), + balance, + revision: 0, + identity_index: index, + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Unknown, + wallet_id, + dashpay_profile: None, + dashpay_payments: Default::default(), + } + } + + fn apply_in_tx(conn: &mut Connection, scope: &[u8; 32], cs: &IdentityChangeSet) { + let tx = conn.transaction().unwrap(); + apply(&tx, scope, cs).unwrap(); + tx.commit().unwrap(); + } + + /// A wallet-B flush naming an identity already owned by wallet A must NOT + /// overwrite A's blob / index or clear A's tombstone — the DO UPDATE WHERE + /// scopes the overwrite to the owning wallet, so the cross-wallet write is + /// a no-op. + #[test] + fn cross_wallet_upsert_does_not_overwrite_resident_row() { + let mut conn = migrated_conn(); + let a = [0xA1u8; 32]; + let b = [0xB2u8; 32]; + let x = [0x01u8; 32]; + insert_wallet(&conn, &a); + insert_wallet(&conn, &b); + + // A registers X (balance 1000, index 5), then tombstones it. + let mut cs_a = IdentityChangeSet::default(); + cs_a.identities + .insert(Identifier::from(x), entry(x, Some(a), 1000, Some(5))); + apply_in_tx(&mut conn, &a, &cs_a); + let mut cs_a_remove = IdentityChangeSet::default(); + cs_a_remove.removed.insert(Identifier::from(x)); + apply_in_tx(&mut conn, &a, &cs_a_remove); + + // B flushes X (balance 2000, index 9, unowned blob). Must be a no-op. + let mut cs_b = IdentityChangeSet::default(); + cs_b.identities + .insert(Identifier::from(x), entry(x, None, 2000, Some(9))); + apply_in_tx(&mut conn, &b, &cs_b); + + let (resident, tombstoned) = fetch(&conn, &a, &x).unwrap().expect("A still owns the row"); + assert_eq!(resident.balance, 1000, "A's blob must survive B's write"); + assert_eq!(resident.identity_index, Some(5), "A's index must survive"); + assert!(tombstoned, "A's tombstone must not be reset by B"); + assert!( + fetch(&conn, &b, &x).unwrap().is_none(), + "B must not have taken ownership" + ); + } + + /// The WHERE still permits the orphan → parented promotion path: an + /// unowned (NULL wallet_id) row is claimed by the first wallet to flush it. + #[test] + fn orphan_promotion_still_applies() { + let mut conn = migrated_conn(); + let a = [0xA1u8; 32]; + let y = [0x02u8; 32]; + insert_wallet(&conn, &a); + + // Orphan Y under the sentinel scope (NULL wallet_id). + let mut cs_orphan = IdentityChangeSet::default(); + cs_orphan + .identities + .insert(Identifier::from(y), entry(y, None, 10, None)); + apply_in_tx(&mut conn, &[0u8; 32], &cs_orphan); + assert!( + fetch(&conn, &a, &y).unwrap().is_none(), + "Y starts unowned by A" + ); + + // A claims Y (balance 500, index 3). + let mut cs_a = IdentityChangeSet::default(); + cs_a.identities + .insert(Identifier::from(y), entry(y, Some(a), 500, Some(3))); + apply_in_tx(&mut conn, &a, &cs_a); + + let (claimed, _) = fetch(&conn, &a, &y).unwrap().expect("A claimed Y"); + assert_eq!(claimed.balance, 500, "promotion applies the new blob"); + assert_eq!(claimed.identity_index, Some(3)); + } +} From 6365be794350f23e203bee9b895f311d211eb4c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:50:46 +0000 Subject: [PATCH 017/108] fix(platform-wallet-storage): restore used_core_addresses from core_utxos to prevent address reuse (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3968's ClientWalletStartState was missing the used_core_addresses field that #3692 added, so its struct diverged AND the native/SQLite path could not restore address used-ness — a used-then-emptied address would be handed back as a fresh receive address (address reuse). Add the field (type Vec, matching #3692 byte-for-byte) and populate it in load(). The in-band pool snapshot was retired (account_index hardcoded 0), so used addresses are derived from the full core_utxos set (spent + unspent): every address that ever held a UTXO is used, mapped script_pubkey -> Address network-aware. A focused test persists a spent (zero-balance) UTXO and asserts its address comes back in used_core_addresses. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/persister.rs | 8 ++ .../src/sqlite/schema/core_state.rs | 27 ++++ .../tests/sqlite_compile_time.rs | 1 + .../tests/sqlite_used_core_addresses.rs | 129 ++++++++++++++++++ .../changeset/client_wallet_start_state.rs | 13 +- 5 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index a03d5e2064a..f44eb075b8e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -930,6 +930,13 @@ impl PlatformWalletPersistence for SqlitePersister { .map_err(PersistenceError::from)?; let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id) .map_err(PersistenceError::from)?; + // Every address that ever held a UTXO (spent + unspent) is "used": + // the address-reuse guard so a used-then-emptied address is never + // handed back as a fresh receive address. The in-band pool snapshot + // was retired, so we derive this from the full core_utxos set. + let used_core_addresses = + schema::core_state::load_used_addresses(&conn, &wallet_id, network) + .map_err(PersistenceError::from)?; state.wallets.insert( wallet_id, @@ -942,6 +949,7 @@ impl PlatformWalletPersistence for SqlitePersister { unused_asset_locks, contacts, identity_keys, + used_core_addresses, }, ); } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 03ad59fb04e..a9cf9864c43 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -373,6 +373,33 @@ pub fn load_state( Ok(cs) } +/// Every address that has ever held a `core_utxos` row for this wallet — +/// spent **and** unspent — deduplicated. The rehydration address-reuse +/// guard: an address whose UTXO was since spent must still be marked used +/// so it's never handed back out as a fresh receive address. `network` +/// turns each persisted `script` back into an [`Address`](dashcore::Address); +/// a script that isn't a valid address is a hard error (corruption is never +/// silently dropped), matching [`load_state`]'s unspent-UTXO handling. +pub fn load_used_addresses( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, +) -> Result, WalletStorageError> { + let mut stmt = conn + .prepare("SELECT DISTINCT script FROM core_utxos WHERE wallet_id = ?1 ORDER BY script")?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + let mut out = Vec::new(); + for r in rows { + let script = dashcore::ScriptBuf::from_bytes(r?); + let address = dashcore::Address::from_script(&script, network) + .map_err(|_| WalletStorageError::blob_decode("core_utxos.script not an address"))?; + out.push(address); + } + Ok(out) +} + /// Convert a stored sync-height column to `u32`, erroring on overflow /// rather than silently truncating a corrupt/out-of-range value. fn sync_height_u32( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 688760c8546..f17fd6d0898 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -58,6 +58,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations", ), ("core_state.rs", "SELECT outpoint, value, script, height"), + ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs b/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs new file mode 100644 index 00000000000..00f1f59da62 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs @@ -0,0 +1,129 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `load()` reconstructs `ClientWalletStartState.used_core_addresses` from +//! the full `core_utxos` set (spent + unspent). A used-then-emptied +//! address must still come back marked used so the rehydrated wallet never +//! hands it out again as a fresh receive address (address reuse). + +mod common; + +use common::{fresh_persister, wid}; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + WalletMetadataEntry, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +fn reopen(path: &std::path::Path) -> SqlitePersister { + SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("reopen") +} + +/// A spent (zero-balance) UTXO's address is still reported in +/// `used_core_addresses`, even though it no longer contributes to balance. +#[test] +fn spent_utxo_address_is_marked_used() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xDD); + + let wallet = Wallet::from_seed_bytes( + [0x42; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 7); + let address = WalletInfoInterface::monitored_addresses(&info) + .into_iter() + .next() + .unwrap(); + + // Register the wallet so it appears in load()'s payload. + let manifest: Vec = wallet + .accounts + .all_accounts() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .collect(); + persister + .store( + w, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: key_wallet::Network::Testnet, + wallet_group_id: [0u8; 32], + birth_height: 7, + }), + account_registrations: manifest, + ..Default::default() + }, + ) + .unwrap(); + + // A UTXO on `address`, then spend it: the row stays on disk with + // spent = 1 and contributes no balance. + let utxo = key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: { + use dashcore::hashes::Hash; + dashcore::Txid::from_byte_array([0x99; 32]) + }, + vout: 0, + }, + txout: dashcore::TxOut { + value: 500_000, + script_pubkey: address.script_pubkey(), + }, + address: address.clone(), + height: 5, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo.clone()], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + spent_utxos: vec![utxo], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let state = p2.load().expect("load"); + let slice = state.wallets.get(&w).expect("wallet slice"); + + assert!( + slice.core_state.new_utxos.is_empty(), + "the spent UTXO must not come back as unspent" + ); + assert!( + slice.used_core_addresses.contains(&address), + "a spent UTXO's address must still be reported as used" + ); +} 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
, } From 163656fdbb70aebf1c376cc5447548c9908a1def Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:25 +0000 Subject: [PATCH 018/108] fix(platform-wallet)!: return typed errors from the #3692 stubs instead of panicking (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_from_persistor() and the FFI build_wallet_start_state() were `todo!()` stubs whose keyless-load path lands in #3692. Both run beneath an extern "C" boundary, where an unwind is undefined behaviour — a panic there is worse than an error. Return PlatformWalletError::WalletCreation / PersistenceError::backend respectively. The integration branch still overwrites both bodies with #3692's real implementation. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../rs-platform-wallet-ffi/src/persistence.rs | 7 ++++++- packages/rs-platform-wallet/src/manager/load.rs | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 465ec62c16c..91ca34b3f5e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3387,7 +3387,12 @@ fn build_wallet_start_state( // only this final assembly. `_` consumes the two start-state slices // whose sole consumer was the removed struct literal. let _ = (identity_manager, unused_asset_locks); - todo!("seeded FFI restore path lands in #3692") + // Must NOT panic: this runs beneath an `extern "C"` boundary where an + // unwind is undefined behaviour. Return a typed backend error until the + // seeded FFI restore path lands in #3692 (which replaces this assembly). + Err(PersistenceError::backend( + "seeded FFI restore path is not available on this build (lands in #3692)".to_string(), + )) } /// Translate the `IdentityRestoreEntryFFI` slice carried on a wallet diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index c1f606b03b5..ac1922c1d9b 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -8,9 +8,19 @@ use super::PlatformWalletManager; impl PlatformWalletManager

{ /// Rehydrate the manager's wallet maps from the configured persister. /// - /// Keyless rehydration lands in #3692; #3968 ships the storage layer - /// only, so on the independent branch this entry point is a stub. + /// # Errors + /// + /// Keyless rehydration lands in #3692; #3968 ships the storage layer only, + /// so on the independent branch this entry point returns + /// [`PlatformWalletError::WalletCreation`] rather than performing the + /// rebuild. It must NOT panic — this is called across the C ABI, where an + /// unwind is undefined behaviour. The integration branch replaces the whole + /// body with the real implementation. pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { - todo!("keyless rehydration lands in #3692") + Err(PlatformWalletError::WalletCreation( + "keyless rehydration from the persister is not available on this build \ + (lands in #3692)" + .to_string(), + )) } } From d00d59b8241366d4eed0422188f73c244a1a8b71 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:25 +0000 Subject: [PATCH 019/108] fix(platform-wallet-storage): rollback-safe restore ordering + keep_last_n floor (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: restore_from cleared the destination's WAL/SHM siblings BEFORE the atomic persist swap, so a failed persist (disk full, EXDEV, perms) left the live DB without its WAL-committed state. Reorder: persist FIRST (the rename is the commit point — a failed restore now leaves the old DB untouched), then unlink the now-stale siblings so a leftover -wal can't shadow the restored DB. M4: prune's keep_last_n acted as a ceiling when combined with max_age — files beyond the N newest were evicted even within the age window. Make it a true floor: keep a file if it satisfies EITHER policy (the union), removing only files failing both. Adds a regression test. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/backup.rs | 65 ++++++++++--------- .../tests/sqlite_auto_backup.rs | 49 ++++++++++++++ 2 files changed, 85 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs index 7eaca6fe6aa..ac175b6af5b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -133,10 +133,13 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { /// Validation runs against the source and again against the STAGED bytes, /// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks /// every other SQLite peer (which advisory flock could not). The staged -/// temp is `persist`-ed as an atomic rename only after all gates pass, so -/// either the DB and its WAL/SHM siblings are replaced together or nothing -/// is touched; the parent dir is fsynced afterward. See the numbered steps -/// in the body for the per-phase rationale. +/// temp is `persist`-ed as an atomic rename only after all gates pass, and +/// that rename is the commit point: if it fails, the live DB and its WAL/SHM +/// siblings are left untouched, so a failed restore never strands the old DB +/// without its WAL-committed state. The now-stale WAL/SHM siblings are +/// unlinked only AFTER the swap succeeds (so a leftover `-wal` can't shadow +/// the restored DB); the parent dir is fsynced afterward. See the numbered +/// steps in the body for the per-phase rationale. /// /// # Lock-release-before-rename trade-off /// @@ -231,20 +234,28 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet .set_permissions(std::fs::Permissions::from_mode(0o600))?; } - // 6. Release the EXCLUSIVE lock before touching siblings/rename: on - // Windows / some FUSE mounts `remove_file` on a still-open file - // returns `PermissionDenied`, and the rename window wants a clean - // close (see lock-release trade-off above). + // 6. Release the EXCLUSIVE lock before the rename/unlinks: on Windows / + // some FUSE mounts `remove_file` on a still-open file returns + // `PermissionDenied`, and the rename window wants a clean close (see + // lock-release trade-off above). if let Some(conn) = dest_lock_conn.take() { let _ = conn.execute_batch("ROLLBACK"); drop(conn); } - // 7. Clear any WAL/SHM siblings BEFORE persist so the DB and its - // siblings are replaced atomically (all-or-nothing). Sibling paths - // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is - // a silent no-op. Requires the lock conn dropped first for - // cross-platform unlink semantics. + // 7. Persist the staged DB atomically over the destination FIRST. The + // atomic rename is the commit point: if it fails (disk full, EXDEV, + // perms) the live DB and its WAL/SHM siblings are left untouched, so a + // failed restore can never strand the old DB without its WAL-committed + // state. Sibling cleanup (step 8) runs only once the swap has succeeded. + tmp.persist(dest_db_path) + .map_err(|e| WalletStorageError::Io(e.error))?; + + // 8. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover + // `-wal` can't shadow the restored DB on the next open. Sibling paths + // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is a + // silent no-op. The lock conn was dropped in step 6 for cross-platform + // unlink semantics. if let Some(file_name) = dest_db_path.file_name() { for ext in ["-wal", "-shm"] { let mut sibling_name = file_name.to_os_string(); @@ -258,11 +269,7 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet } } - // 8. Persist atomically over the destination. - tmp.persist(dest_db_path) - .map_err(|e| WalletStorageError::Io(e.error))?; - - // 9. Make the rename's dentry update durable. + // 9. Make the rename + unlink dentry updates durable. fsync_parent_dir(dest_db_path)?; // 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). @@ -358,19 +365,19 @@ pub fn prune(dir: &Path, policy: RetentionPolicy) -> Result = Vec::new(); let mut kept = 0; for (idx, (ts, path)) in files.into_iter().enumerate() { - // `keep_last_n` is a FLOOR: the N newest are always kept even if - // `max_age` would evict them, so an age+count policy can't delete - // every backup. `None` gives no floor (age-only may prune all). - let within_floor = matches!(policy.keep_last_n, Some(n) if idx < n); - let pass_count = match policy.keep_last_n { - Some(n) => idx < n, - None => true, - }; - let pass_age = match policy.max_age { + // `keep_last_n` is a FLOOR: the N newest are always kept. `max_age` is + // an independent age window. A file is kept if it satisfies EITHER + // policy (the union), and removed only when it fails BOTH — so a + // within-age file beyond the N newest is still kept (the bug fix: the + // count must not cap the age window). With no policy set at all (both + // `None`) every file is kept. + let count_keep = matches!(policy.keep_last_n, Some(n) if idx < n); + let age_keep = match policy.max_age { Some(max) => now.duration_since(ts).map(|d| d <= max).unwrap_or(true), - None => true, + None => false, }; - if within_floor || (pass_count && pass_age) { + let no_policy = policy.keep_last_n.is_none() && policy.max_age.is_none(); + if no_policy || count_keep || age_keep { kept += 1; } else { match std::fs::remove_file(&path) { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs index 0597725c44a..504a998995d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_auto_backup.rs @@ -223,3 +223,52 @@ fn tc056_aggressive_prune_evicts_safety_backup_and_orders_by_embedded_ts() { (auto dir is not a protected vault)" ); } + +/// `keep_last_n` is a FLOOR, not a ceiling: with both `keep_last_n` and +/// `max_age` set, a file beyond the N newest but still within `max_age` must +/// be KEPT (the union of the two policies), and only files failing BOTH are +/// evicted. Regression guard for the count-caps-the-age-window bug. +#[test] +fn keep_last_n_is_a_floor_not_a_ceiling_with_max_age() { + let (persister, _tmp, _path) = fresh_persister(); + let dir = persister.config_for_test().auto_backup_dir.clone().unwrap(); + std::fs::create_dir_all(&dir).unwrap(); + + let stamp = |hours_ago: i64| { + chrono::Utc::now() + .checked_sub_signed(chrono::Duration::hours(hours_ago)) + .unwrap() + .format("%Y%m%dT%H%M%SZ") + .to_string() + }; + // Newest (0h) kept by the floor; the 1h file is beyond the floor but + // within the 2h window (kept by age); the 48h file fails both (evicted). + let newest = dir.join(format!("wallet-{}.db", stamp(0))); + let within_age = dir.join(format!("wallet-{}.db", stamp(1))); + let too_old = dir.join(format!("wallet-{}.db", stamp(48))); + for p in [&newest, &within_age, &too_old] { + std::fs::write(p, b"x").unwrap(); + } + + let report = persister + .prune_backups( + &dir, + platform_wallet_storage::RetentionPolicy { + keep_last_n: Some(1), + max_age: Some(std::time::Duration::from_secs(2 * 3600)), + }, + ) + .unwrap(); + + assert_eq!( + report.kept, 2, + "floor (1) + within-age (1) must both survive" + ); + assert_eq!(report.removed.len(), 1); + assert!(newest.exists(), "the newest file is kept by the floor"); + assert!( + within_age.exists(), + "a within-max_age file beyond the floor must NOT be evicted by the count" + ); + assert!(!too_old.exists(), "a file failing both policies is evicted"); +} From f3ca134e74913e6543d7a2cd2ffd39b920b70ca4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:43 +0000 Subject: [PATCH 020/108] fix(platform-wallet-storage): reject a foreign SQLite DB at open + correct load query-budget doc (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M3: a pre-existing non-wallet SQLite file (schema objects but no refinery_schema_history) was treated as brand-new and migrated in place, grafting wallet tables onto a foreign schema. open() now detects a non-empty DB without refinery history and rejects it via the application_id gate (NotAWalletDb) instead of migrating. N4: the load() 'Query budget' doc claimed a constant query count, but the keyless per-wallet payload is a fan-out (O(wallets) reads). Corrected the doc to describe the actual behaviour. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/migrations.rs | 13 +++++++++ .../src/sqlite/persister.rs | 14 +++++++-- .../tests/sqlite_foreign_db_rejection.rs | 29 +++++++++++++++++++ 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index d8600c55c6d..b2e25c6517c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -62,6 +62,19 @@ pub(crate) fn has_schema_history(conn: &rusqlite::Connection) -> Result Result { + let exists = conn + .query_row("SELECT 1 FROM sqlite_master LIMIT 1", [], |_| Ok(())) + .optional()? + .is_some(); + Ok(exists) +} + /// Refuse to operate on a DB whose `refinery_schema_history` MAX(version) /// exceeds [`max_supported_version`], returning /// [`WalletStorageError::SchemaVersionUnsupported`]. This is a forward-only diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index f44eb075b8e..b4126abb960 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -207,6 +207,12 @@ impl SqlitePersister { crate::sqlite::migrations::assert_schema_version_supported(&conn)?; crate::sqlite::conn::assert_wallet_application_id(&conn)?; crate::sqlite::migrations::assert_schema_history_well_formed(&conn)?; + } else if crate::sqlite::migrations::db_has_objects(&conn)? { + // A pre-existing file with schema objects but NO refinery history is + // a foreign (non-wallet) SQLite DB. Migrating it in place would graft + // wallet tables onto someone else's schema; reject via the + // application_id gate (a foreign DB never carries our magic) instead. + crate::sqlite::conn::assert_wallet_application_id(&conn)?; } let pending = crate::sqlite::migrations::embedded_migrations(); let pending_count = if had_schema_history { @@ -833,8 +839,12 @@ impl PlatformWalletPersistence for SqlitePersister { /// Fail-hard: any row that fails to decode (or has a malformed /// `wallet_id`) aborts the whole load — corruption is never skipped. /// - /// **Query budget.** Constant w.r.t. wallet count: one `SELECT` for the - /// id list plus a fixed set of grouped scans, not a per-wallet fan-out. + /// **Query budget.** Platform addresses load via grouped bulk scans + /// (constant), but the keyless per-wallet payload is a fan-out: one + /// id-list `SELECT` plus a fixed set of per-wallet reads for each wallet + /// (core state, identities, asset locks, contacts, identity keys, used + /// addresses). O(wallets) queries overall — acceptable for one-shot + /// startup, not the hot path. /// /// # Concurrency /// diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs new file mode 100644 index 00000000000..7a7476bf9ec --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_foreign_db_rejection.rs @@ -0,0 +1,29 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `open()` must reject a pre-existing NON-wallet SQLite file (schema objects +//! but no `refinery_schema_history`) instead of silently grafting wallet +//! tables onto a foreign schema. + +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; + +#[test] +fn open_rejects_foreign_sqlite_without_refinery_history() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("foreign.db"); + + // A plain SQLite DB with a user table but no refinery history and no + // wallet application_id. + { + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute("CREATE TABLE not_ours (id INTEGER PRIMARY KEY)", []) + .unwrap(); + } + + // `SqlitePersister` isn't `Debug`, so take `.err()` rather than + // `.expect_err()` (which would need the Ok type to be `Debug`). + let err = SqlitePersister::open(SqlitePersisterConfig::new(&path)).err(); + assert!( + matches!(err, Some(WalletStorageError::NotAWalletDb { .. })), + "a foreign sqlite db must be rejected as NotAWalletDb, got {err:?}" + ); +} From 7ae801bd359bdadaf5ba584dae1a4a10c9177dee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:43 +0000 Subject: [PATCH 021/108] fix(platform-wallet-storage): reader cross-checks + decode hardening across schema modules (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H4: last_applied_chain_lock was blind-overwritten while heights max-merged, so an out-of-order lower-height chain lock regressed the finalized checkpoint. Monotonic-max-merge the chain lock by block_height too. H5: identities::load_state trusted the decoded blob's identity_id over the typed column. M1: contacts::load_state decoded ContactRequests without checking sender/recipient vs the typed owner/contact columns. M2: decode_platform_payment_row decoded the blob without validating its account_type/index. All now apply the cross-check pattern (reject on mismatch) like the sibling readers. M5: decode_chain_lock_soft ignored trailing bytes after a valid ChainLock; now asserts consumed == len. L1: core_transactions reader capped record_blob via length() BEFORE materializing, so a tampered oversize blob can't OOM the process. L3: accounts-reader roundtrip test now uses distinct xpubs and asserts the deterministic manifest order. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/schema/accounts.rs | 16 +++-- .../src/sqlite/schema/contacts.rs | 24 +++++++ .../src/sqlite/schema/core_state.rs | 62 +++++++++++++++---- .../src/sqlite/schema/identities.rs | 45 +++++++++++++- .../tests/sqlite_accounts_reader.rs | 53 +++++++++++----- .../tests/sqlite_compile_time.rs | 2 +- .../tests/sqlite_core_state_reader.rs | 50 +++++++++++++++ 7 files changed, 219 insertions(+), 33 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index d5830959521..3fed9287a9d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -25,15 +25,23 @@ pub(crate) type PlatformPaymentRegistration = (u32, ExtendedPubKey); /// One `platform_payment` registration row decoded into /// `(account_index, xpub)`. fn decode_platform_payment_row( - account_index: i64, + typed_index: i64, xpub_bytes: &[u8], ) -> Result { - let account_index = crate::sqlite::util::safe_cast::i64_to_u32( + let typed_index = crate::sqlite::util::safe_cast::i64_to_u32( "account_registrations.account_index", - account_index, + typed_index, )?; let entry: AccountRegistrationEntry = blob::decode(xpub_bytes)?; - Ok((account_index, entry.account_xpub)) + // Callers select `WHERE account_type = 'platform_payment'`, so the decoded + // blob must agree: a PlatformPayment account at the same index. A row whose + // blob disagrees is corrupt / mis-bucketed, never fed to the oracle. + if account_type_db_label(&entry.account_type) != "platform_payment" + || account_index(&entry.account_type) != typed_index + { + return Err(WalletStorageError::AccountRegistrationEntryMismatch); + } + Ok((typed_index, entry.account_xpub)) } /// Every `platform_payment` registration for one wallet, decoded into diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index aa69e5fd8ae..313e408c814 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -243,6 +243,8 @@ pub(crate) fn load_state( match contact_state_from_label(&label)? { ContactState::Sent => { let request = decode_request("outgoing_request", outgoing.as_deref())?; + // We are the sender; the contact is the recipient. + check_request_parties(&request, &owner_id, &contact_id)?; state.sent_requests.insert( SentContactRequestKey { owner_id, @@ -253,6 +255,8 @@ pub(crate) fn load_state( } ContactState::Received => { let request = decode_request("incoming_request", incoming.as_deref())?; + // The contact is the sender; we are the recipient. + check_request_parties(&request, &contact_id, &owner_id)?; state.incoming_requests.insert( ReceivedContactRequestKey { owner_id, @@ -264,6 +268,9 @@ pub(crate) fn load_state( ContactState::Established => { let outgoing_request = decode_request("outgoing_request", outgoing.as_deref())?; let incoming_request = decode_request("incoming_request", incoming.as_deref())?; + // Outgoing = us→contact; incoming = contact→us. + check_request_parties(&outgoing_request, &owner_id, &contact_id)?; + check_request_parties(&incoming_request, &contact_id, &owner_id)?; let alias: Option = row.get(5)?; let note: Option = row.get(6)?; let is_hidden: bool = row.get::<_, Option>(7)?.unwrap_or(0) != 0; @@ -330,6 +337,23 @@ fn decode_request( } } +/// Cross-check a decoded [`ContactRequest`]'s parties against the typed +/// `(owner_id, contact_id)` columns it was selected by. A blob that names a +/// different sender/recipient than its indexed columns is corruption (or a +/// mis-filed row) and is rejected rather than rehydrated into the wrong slot. +fn check_request_parties( + request: &ContactRequest, + expected_sender: &Identifier, + expected_recipient: &Identifier, +) -> Result<(), WalletStorageError> { + if request.sender_id != *expected_sender || request.recipient_id != *expected_recipient { + return Err(WalletStorageError::blob_decode( + "contacts request sender/recipient disagree with the typed owner/contact columns", + )); + } + Ok(()) +} + fn decode_pair_key(a: &[u8], b: &[u8]) -> Result<(Identifier, Identifier), WalletStorageError> { let a32 = <[u8; 32]>::try_from(a) .map_err(|_| WalletStorageError::blob_decode("contacts.id column is not 32 bytes"))?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index a9cf9864c43..ff6cefa7c6d 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -38,7 +38,16 @@ fn encode_chain_lock(cl: &ChainLock) -> Result, WalletStorageError> { /// ChainLock event will repopulate the column). fn decode_chain_lock_soft(bytes: &[u8]) -> Option { match bincode::decode_from_slice::(bytes, chain_lock_config()) { - Ok((cl, _)) => Some(cl), + // Reject a valid-prefix + trailing-garbage payload (bincode stops + // after the typed length) the same way the BLOB decoders do. + Ok((cl, consumed)) if consumed == bytes.len() => Some(cl), + Ok(_) => { + tracing::warn!( + "core_sync_state.last_applied_chain_lock: trailing bytes after \ + ChainLock; field left None — the next ChainLock sync will repopulate" + ); + None + } Err(e) => { tracing::warn!( error = %e, @@ -50,6 +59,15 @@ fn decode_chain_lock_soft(bytes: &[u8]) -> Option { } } +/// Block height of an encoded `last_applied_chain_lock` blob, or `None` if it +/// can't be decoded. Used to monotonic-max-merge the chain lock so an +/// out-of-order lower-height update never regresses the finalized checkpoint. +fn chain_lock_height(bytes: &[u8]) -> Option { + bincode::decode_from_slice::(bytes, chain_lock_config()) + .ok() + .map(|(cl, _)| cl.block_height) +} + /// Apply a `CoreChangeSet` inside a transaction. pub fn apply( tx: &Transaction<'_>, @@ -226,9 +244,21 @@ fn upsert_sync_state( (Some(a), Some(b)) => Some(a.max(b)), (a, b) => a.or(b), }; - // Chain lock: take the new bytes when provided; keep the existing bytes - // when the changeset has no chain lock update (None = "no change"). - let cl_final = chain_lock_bytes.or(current_raw.2); + // Chain lock: monotonic-max by height like the sync watermarks above. + // A new chain lock replaces the stored one only when its height is >= + // the stored height, so an out-of-order lower-height update can't + // regress the finalized checkpoint. `None` (no update) keeps existing. + let cl_final = match (chain_lock_bytes, current_raw.2) { + (Some(new_bytes), Some(existing_bytes)) => { + if chain_lock_height(&new_bytes) >= chain_lock_height(&existing_bytes) { + Some(new_bytes) + } else { + Some(existing_bytes) + } + } + (Some(new_bytes), None) => Some(new_bytes), + (None, existing) => existing, + }; tx.execute( "INSERT INTO core_sync_state \ (wallet_id, last_processed_height, synced_height, last_applied_chain_lock) \ @@ -314,13 +344,23 @@ pub fn load_state( } { - let mut stmt = - conn.prepare("SELECT record_blob FROM core_transactions WHERE wallet_id = ?1")?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - row.get::<_, Vec>(0) - })?; - for r in rows { - let payload = r?; + // Cap on the cheap `length()` (O(1) from the row header) BEFORE + // materializing the blob, so a tampered oversize `record_blob` can't + // force a multi-gigabyte allocation that `blob::decode`'s post-hoc cap + // would only catch after the Vec is already built. + let mut stmt = conn.prepare( + "SELECT length(record_blob), record_blob FROM core_transactions WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + let len = usize::try_from(row.get::<_, i64>(0)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let payload: Vec = row.get(1)?; cs.records .push(blob::decode::(&payload)?); } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 7f0f0ccd8ef..fcd40f76d31 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -147,13 +147,31 @@ pub fn load_state( let mut state = IdentityManagerStartState::default(); let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { - let _identity_id: Vec = row.get(0)?; + let identity_id_bytes: Vec = row.get(0)?; let payload: Vec = row.get(1)?; let tombstoned: i64 = row.get(2)?; if tombstoned != 0 { continue; } let entry: IdentityEntry = blob::decode(&payload)?; + // Cross-check the decoded blob against the typed columns it was + // selected by (mirrors the accounts / identity_keys readers): the + // blob must name the same identity, and its own wallet_id (when set) + // must match the wallet scope, else the row is corrupt / mis-filed. + let typed_id = <[u8; 32]>::try_from(identity_id_bytes.as_slice()).map_err(|_| { + WalletStorageError::blob_decode("identities.identity_id is not 32 bytes") + })?; + if entry.id != dpp::prelude::Identifier::from(typed_id) { + return Err(WalletStorageError::IdentityEntryIdMismatch); + } + if let Some(entry_wallet_id) = entry.wallet_id { + if entry_wallet_id != *wallet_id { + return Err(WalletStorageError::WalletIdMismatch { + expected: *wallet_id, + found: entry_wallet_id, + }); + } + } let managed = managed_identity_from_entry(&entry, wallet_id); match entry.identity_index { Some(idx) => { @@ -360,4 +378,29 @@ mod tests { assert_eq!(claimed.balance, 500, "promotion applies the new blob"); assert_eq!(claimed.identity_index, Some(3)); } + + /// `load_state` rejects a row whose decoded blob names a different + /// `identity_id` than its typed column — corruption is a hard, typed + /// error, never rehydrated under the wrong id. + #[test] + fn load_state_rejects_identity_id_column_mismatch() { + let conn = migrated_conn(); + let a = [0xA1u8; 32]; + insert_wallet(&conn, &a); + let typed_id = [0x01u8; 32]; // column + let blob_id = [0x02u8; 32]; // disagreeing blob + let payload = blob::encode(&entry(blob_id, Some(a), 100, Some(1))).unwrap(); + conn.execute( + "INSERT INTO identities (identity_id, wallet_id, identity_index, entry_blob, tombstoned) \ + VALUES (?1, ?2, 1, ?3, 0)", + params![&typed_id[..], &a[..], payload], + ) + .unwrap(); + + let err = load_state(&conn, &a).expect_err("identity_id mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityEntryIdMismatch), + "expected IdentityEntryIdMismatch, got {err:?}" + ); + } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs index 5839b6e0975..dc07894329a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_accounts_reader.rs @@ -12,11 +12,13 @@ use platform_wallet::changeset::{AccountRegistrationEntry, PlatformWalletChangeS use platform_wallet_storage::sqlite::schema::accounts; use platform_wallet_storage::WalletStorageError; -fn xpub() -> key_wallet::bip32::ExtendedPubKey { +/// A distinct extended public key per `seed` byte, so a round-trip test can +/// tell entries apart instead of asserting against one shared xpub. +fn xpub_from_seed(seed: u8) -> key_wallet::bip32::ExtendedPubKey { use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; let w = Wallet::from_seed_bytes( - [7u8; 64], + [seed; 64], key_wallet::Network::Testnet, WalletAccountCreationOptions::Default, ) @@ -35,7 +37,8 @@ fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { .expect("reopen persister") } -/// Registrations round-trip bit-exact, in stable order. +/// Registrations round-trip bit-exact, in the reader's deterministic order +/// (`account_type` label ascending), with each entry keeping its OWN xpub. #[test] fn a1_account_registrations_roundtrip() { let (persister, _tmp, path) = fresh_persister(); @@ -43,17 +46,23 @@ fn a1_account_registrations_roundtrip() { let w = wid(0xA1); ensure_wallet_meta(&persister, &w); + // Distinct xpubs so the round-trip proves each entry keeps its own key, + // not just that *some* xpub survives. + let standard_xpub = xpub_from_seed(7); + let idreg_xpub = xpub_from_seed(8); + assert_ne!(standard_xpub, idreg_xpub, "fixtures must differ"); + let entries = vec![ AccountRegistrationEntry { account_type: AccountType::Standard { index: 0, standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, }, - account_xpub: xpub(), + account_xpub: standard_xpub, }, AccountRegistrationEntry { account_type: AccountType::IdentityRegistration, - account_xpub: xpub(), + account_xpub: idreg_xpub, }, ]; let cs = PlatformWalletChangeSet { @@ -69,17 +78,29 @@ fn a1_account_registrations_roundtrip() { drop(conn); assert_eq!(manifest.len(), 2, "all rows must be returned"); - // Bit-exact xpub round-trip. - for e in &manifest { - assert_eq!(e.account_xpub, xpub()); - } - let has_standard = manifest - .iter() - .any(|e| matches!(e.account_type, AccountType::Standard { index: 0, .. })); - let has_idreg = manifest - .iter() - .any(|e| matches!(e.account_type, AccountType::IdentityRegistration)); - assert!(has_standard && has_idreg); + // Reader orders by `account_type` label: 'identity_registration' sorts + // before 'standard_bip44', so the manifest is deterministically ordered. + assert!( + matches!(manifest[0].account_type, AccountType::IdentityRegistration), + "identity_registration must sort first, got {:?}", + manifest[0].account_type + ); + assert_eq!( + manifest[0].account_xpub, idreg_xpub, + "IdentityRegistration must keep its own xpub" + ); + assert!( + matches!( + manifest[1].account_type, + AccountType::Standard { index: 0, .. } + ), + "standard_bip44 must sort second, got {:?}", + manifest[1].account_type + ); + assert_eq!( + manifest[1].account_xpub, standard_xpub, + "Standard must keep its own xpub" + ); } /// An empty wallet yields an empty manifest, not an error. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index f17fd6d0898..b3db91fb455 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -66,7 +66,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "core_state.rs", - "SELECT record_blob FROM core_transactions WHERE wallet_id", + "SELECT length(record_blob), record_blob FROM core_transactions", ), ( "core_state.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index 9689d87ec68..4ad78a43fcb 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -390,3 +390,53 @@ fn b5_last_applied_chain_lock_round_trips() { through ClientWalletStartState.core_state — fails if reader still leaves it None" ); } + +/// A lower-height chain lock arriving AFTER a higher one must not regress the +/// stored `last_applied_chain_lock`: heights monotonic-max merge just like the +/// sync watermarks, so an out-of-order update can't roll the finalized +/// checkpoint backwards. +#[test] +fn chain_lock_does_not_regress_on_lower_height_update() { + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::BlockHash; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xB6); + ensure_wallet_meta(&persister, &w); + + let high = ChainLock { + block_height: 100_000, + block_hash: BlockHash::from_byte_array([0xAAu8; 32]), + signature: [0x11u8; 96].into(), + }; + let low = ChainLock { + block_height: 90_000, + block_hash: BlockHash::from_byte_array([0xBBu8; 32]), + signature: [0x22u8; 96].into(), + }; + + let store_cl = |cl: ChainLock| { + let cs = PlatformWalletChangeSet { + core: Some(CoreChangeSet { + last_applied_chain_lock: Some(cl), + ..Default::default() + }), + ..Default::default() + }; + persister.store(w, cs).expect("store"); + PlatformWalletPersistence::flush(&persister, w).expect("flush"); + }; + store_cl(high.clone()); + store_cl(low); // out-of-order, lower height — must not win + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let loaded = core_state::load_state(&conn, &w, key_wallet::Network::Testnet) + .expect("load_state must succeed"); + assert_eq!( + loaded.last_applied_chain_lock.as_ref(), + Some(&high), + "a lower-height chain lock must not regress the stored higher one" + ); +} From c9a268332455df18272a1596aa62033e9d912be7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:59 +0000 Subject: [PATCH 022/108] fix(platform-wallet-storage): reject embedded-NUL kv keys (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L2: validate_key relied on chars().count() == SQLite length(), but SQLite length() stops at the first NUL, so a NUL-bearing key broke the invariant (and SQLite string comparisons). Reject embedded NUL explicitly with a new KvError::KeyContainsNul variant. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- packages/rs-platform-wallet-storage/src/kv.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/kv.rs b/packages/rs-platform-wallet-storage/src/kv.rs index ddfadbfcb2d..f492f037a7d 100644 --- a/packages/rs-platform-wallet-storage/src/kv.rs +++ b/packages/rs-platform-wallet-storage/src/kv.rs @@ -81,6 +81,12 @@ pub enum KvError { #[error("kv key is empty")] KeyEmpty, + /// Key contained an embedded NUL (`\0`). SQLite `length()` counts only + /// the bytes before the first NUL, so a NUL-bearing key would break the + /// `chars().count()` == SQLite `length()` invariant the CHECK relies on. + #[error("kv key contains an embedded NUL")] + KeyContainsNul, + /// Key exceeded [`MAX_KEY_LEN`]. `len` is the key's code-point count /// (the same unit the SQL `length()` CHECK uses). #[error("kv key too long: {len} code points (max {})", MAX_KEY_LEN)] @@ -159,6 +165,12 @@ pub(crate) fn validate_key(key: &str) -> Result<(), KvError> { if key.is_empty() { return Err(KvError::KeyEmpty); } + // An embedded NUL truncates SQLite's `length()` (and string comparisons), + // so reject it before the count below — otherwise `chars().count()` and the + // SQL CHECK would disagree on the key's length and identity. + if key.contains('\0') { + return Err(KvError::KeyContainsNul); + } let code_points = key.chars().count(); if code_points > MAX_KEY_LEN { return Err(KvError::KeyTooLong { len: code_points }); @@ -190,4 +202,11 @@ mod tests { let k = "a".repeat(MAX_KEY_LEN); assert!(validate_key(&k).is_ok()); } + + #[test] + fn validate_rejects_embedded_nul() { + assert!(matches!(validate_key("a\0b"), Err(KvError::KeyContainsNul))); + // A leading/trailing NUL is rejected too. + assert!(matches!(validate_key("\0"), Err(KvError::KeyContainsNul))); + } } From 1a9a17ec3128c3b40e617886b053f7a570f337af Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:31:59 +0000 Subject: [PATCH 023/108] docs(platform-wallet-storage): re-sync changeset doc, correct cascade/rebuild notes, add scope assertion (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit N1: drop the stale '(from wallet_metadata)' ref on ClientWalletStartState.network to re-sync with #3692. L4: README now marks the manager-side rebuild (load_from_persistor) as pending/stubbed on this build, not live. N2: SCHEMA.md corrected — identity-scoped meta cleanup is conditional on the identities row existing and being wallet-linked, not unconditional. N3: sqlite_migrations smoke test now also asserts identity_keys is countable by its direct wallet_id column. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- packages/rs-platform-wallet-storage/README.md | 11 +++++++---- packages/rs-platform-wallet-storage/SCHEMA.md | 15 ++++++++++----- .../tests/sqlite_migrations.rs | 15 +++++++++++++++ .../src/changeset/client_wallet_start_state.rs | 2 +- 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md index 04c4d6057eb..88a0522b173 100644 --- a/packages/rs-platform-wallet-storage/README.md +++ b/packages/rs-platform-wallet-storage/README.md @@ -162,10 +162,13 @@ reconstructed from these per-area readers: | `contacts` | `schema::contacts::load_changeset` | | `identity_keys` | `schema::identity_keys::load_state` | -The payload carries **no** `Wallet` and no key material — the manager -rebuilds each wallet watch-only via `Wallet::new_watch_only` from the -manifest and applies this state; signing keys are derived later on demand -via the `sign_with_mnemonic_resolver` path. +The payload carries **no** `Wallet` and no key material. On this +storage-only build the **manager-side rebuild is not yet wired**: +`PlatformWalletManager::load_from_persistor` returns a typed error rather +than reconstructing wallets. The keyless rebuild (watch-only via +`Wallet::new_watch_only` from the manifest, then on-demand signing-key +derivation through the `sign_with_mnemonic_resolver` path) lands in #3692. +`load()` itself already reconstructs the full keyless payload. Loading is **fail-hard**: any row that fails to decode, or a stored `wallet_id` that is not exactly 32 bytes, aborts the whole call with a typed diff --git a/packages/rs-platform-wallet-storage/SCHEMA.md b/packages/rs-platform-wallet-storage/SCHEMA.md index ee64b11df45..abe9c265b67 100644 --- a/packages/rs-platform-wallet-storage/SCHEMA.md +++ b/packages/rs-platform-wallet-storage/SCHEMA.md @@ -506,11 +506,16 @@ global-config persister can write to typed scopes whose parent tables stay empty). Cleanup is instead a soft cascade. Deleting a `wallets` row fires a wallet-rooted `AFTER DELETE` trigger that brooms the wallet-scoped tables (`meta_wallet`, `meta_contact`, -`meta_platform_address`) by `wallet_id`, and the FK cascade through -`identities` fires a per-identity trigger that brooms `meta_identity` + -`meta_token` by `identity_id`. Both legs key on the id alone, so a wallet -delete cleans its metadata transitively whether or not the typed parent -was ever written and regardless of any contact's lifecycle state. +`meta_platform_address`) by `wallet_id` — unconditionally, regardless of +whether the typed parent row was ever written or any contact's lifecycle +state. The identity-scoped tables (`meta_identity`, `meta_token`) are +broomed by a *different* leg: the `wallets → identities` FK cascade deletes +each linked `identities` row, and a per-identity `AFTER DELETE` trigger then +brooms by `identity_id`. That leg fires only for identities the wallet +actually owns, so it cleans `meta_token` even when no `token_balances` row +ever existed — but identity-scoped metadata for an identity whose +`identities` row was never written (or is not linked to this wallet) +survives the delete as an orphan (see the orphan-metadata limitation above). Additional triggers handle direct deletes of a single `token_balances`, `contacts`, or `platform_addresses` row. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs index fb402eeb2f6..35d0f7afc01 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migrations.rs @@ -185,6 +185,21 @@ fn tc027_smoke_insert_every_table() { .unwrap(); assert!(n >= 1, "{table} insert did not land"); } + + // `identity_keys` is counted above via the identity join, but it also + // carries its OWN `wallet_id` column (the direct per-wallet read scope); + // verify the smoke row is countable that way too. + let direct: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identity_keys WHERE wallet_id = ?1", + rusqlite::params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert!( + direct >= 1, + "identity_keys must be countable by its direct wallet_id column" + ); } /// re-open is idempotent. 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). From f4ac7f576b22182c98540aab646867c391e2d567 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:45:55 +0000 Subject: [PATCH 024/108] =?UTF-8?q?fix(platform-wallet-storage):=20secrets?= =?UTF-8?q?=20hardening=20=E2=80=94=20atomic=20reprotect,=20scheme-0=20zer?= =?UTF-8?q?oize,=20version=20width=20(#3968=20review)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1: SecretStore::reprotect did a non-atomic get→set; a concurrent set/delete between them could let a transform built on stale bytes clobber a newer value. The File arm now runs the read→rewrap→write under the store's single lock (new EncryptedFileStore::reprotect_bytes). The Os arm is a per-item keyring with no transaction, so its non-atomicity is now documented as a residual. H3: the scheme-0 (unprotected) wrap left a cleartext plaintext copy in the envelope's Vec unzeroized; it's now wiped after encoding (the returned SecretBytes is the only retained copy). L6: UnsupportedEnvelopeVersion stored the version as u8, truncating Envelope.version (u32) — now u32 so a >255 version isn't aliased in diagnostics (with a regression test). L5: narrowed the error-module non-leakage doc — the Io variant intentionally carries its non-secret OS source; every other variant is source-free. L7: documented the vault's read-side duplicate-JSON-key collapse (serde_json last-wins), benign because every entry is AEAD-bound to (wallet_id, label). Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/secrets/error.rs | 20 +++++++---- .../src/secrets/file/format.rs | 8 ++++- .../src/secrets/file/mod.rs | 20 +++++++++++ .../src/secrets/store.rs | 26 +++++++++++--- .../src/secrets/wire/envelope.rs | 36 +++++++++++++++---- 5 files changed, 92 insertions(+), 18 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/error.rs b/packages/rs-platform-wallet-storage/src/secrets/error.rs index 7057946714a..09fc2d01995 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/error.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/error.rs @@ -1,10 +1,15 @@ //! Secret-store error taxonomy and its `keyring_core::Error` projection. //! //! Variants carry only non-secret diagnostics (POSIX mode bits, header -//! version, vault path) — never a secret byte, passphrase, plaintext, or -//! stringified source (CWE-209/CWE-532). The public, fully-typed path is -//! the [`SecretStore`](crate::secrets::SecretStore) API; the SPI -//! projection into `keyring_core::Error` is lossy (see the [`From`] impl). +//! version, vault path) — never a secret byte, passphrase, or plaintext +//! (CWE-209/CWE-532). The single carried source is the [`Io`] variant's +//! OS error (an errno plus the non-secret caller-supplied path); every +//! other variant is source-free so a crypto/format failure can't stringify +//! a secret. The public, fully-typed path is the +//! [`SecretStore`](crate::secrets::SecretStore) API; the SPI projection into +//! `keyring_core::Error` is lossy (see the [`From`] impl). +//! +//! [`Io`]: SecretStoreError::Io use std::path::Path; @@ -86,9 +91,10 @@ pub enum SecretStoreError { /// [`VersionUnsupported`]: SecretStoreError::VersionUnsupported #[error("unsupported secret envelope version {found}")] UnsupportedEnvelopeVersion { - /// The envelope `version` byte read from the (unauthenticated) - /// header. - found: u8, + /// The full `version` field read from the (unauthenticated) + /// envelope header. `u32` to match `Envelope.version` — a truncating + /// `u8` would alias distinct out-of-range versions in diagnostics. + found: u32, }, /// The vault file was malformed (bad magic, truncated header, bad diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs index c137afb2439..5fa925960f1 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/format.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/format.rs @@ -19,7 +19,13 @@ //! ``` //! //! Nested `BTreeMap`s give O(log n) lookup and a JSON-object shape that -//! excludes duplicate `(wallet_id, label)` pairs by construction. +//! excludes duplicate `(wallet_id, label)` pairs by construction on the +//! WRITE side. On the READ side, a hand-edited document with duplicate JSON +//! keys is not rejected — `serde_json` collapses duplicates last-wins into +//! the `BTreeMap`. That is benign: every entry's ciphertext is AEAD-sealed +//! with its `(wallet_id, label)` bound as AAD, so a collapsed or reordered +//! structure can never surface bytes that don't authenticate against the +//! surviving key (a forged duplicate fails its tag as `Corruption`). //! //! Parsing is two-step: a lax [`VersionProbe`] reads `version` first //! (tolerating future-version siblings), then the strict [`Vault`] diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index 68ad88d76c5..26731dc7462 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -275,6 +275,26 @@ impl EncryptedFileStore { lock_inner(&self.inner).delete(wallet_id, label) } + /// Atomic read-modify-write of `(wallet_id, label)`. Holds the store lock + /// across the read → `transform` → write so a concurrent `put`/`delete` + /// can't interleave and let a transform built on stale bytes clobber a + /// newer value. `transform` receives the currently-stored bytes (`None` + /// if absent) and returns the bytes to persist. + pub(crate) fn reprotect_bytes( + &self, + wallet_id: &WalletId, + label: &str, + transform: F, + ) -> Result<(), SecretStoreError> + where + F: FnOnce(Option) -> Result, + { + let mut inner = lock_inner(&self.inner); + let current = inner.get(wallet_id, label)?; + let next = transform(current)?; + inner.put(wallet_id, label, &next) + } + #[cfg(test)] pub(crate) fn test_read_vault_from_disk(&self) -> Result, SecretStoreError> { read_vault_at(&lock_inner(&self.inner).path) diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 7f0147f1659..0e86857551c 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -252,6 +252,13 @@ impl SecretStore { /// **Entropy is the caller's:** the `new` password's entropy is the /// whole confidentiality guarantee for the re-protected object; this /// crate enforces only non-blank, not strength. + /// + /// **Atomicity:** on the `File` arm the read → rewrap → write runs under + /// the store's single lock, so a concurrent `set`/`delete` can't interleave + /// and let this rewrite (built on the bytes read here) clobber a newer + /// value. The `Os` arm is a per-item keyring with no transaction, so its + /// read→write is NOT atomic — a documented residual; serialize reprotect + /// intent at the caller if a concurrent writer is possible there. pub fn reprotect( &self, service: &WalletId, @@ -259,10 +266,21 @@ impl SecretStore { current: Option<&SecretString>, new: Option<&SecretString>, ) -> Result<(), SecretStoreError> { - let Some(secret) = self.get_secret(service, label, current)? else { - return Err(SecretStoreError::NoEntry); - }; - self.set_secret(service, label, &secret, new) + match self { + Self::File(s) => s.reprotect_bytes(service, label, |stored| { + let Some(stored) = stored else { + return Err(SecretStoreError::NoEntry); + }; + let secret = envelope::unwrap(service, label, current, stored.expose_secret())?; + envelope::wrap(service, label, new, secret.expose_secret()) + }), + Self::Os(_) => { + let Some(secret) = self.get_secret(service, label, current)? else { + return Err(SecretStoreError::NoEntry); + }; + self.set_secret(service, label, &secret, new) + } + } } /// Delete the secret stored under `(service, label)`. diff --git a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs index 2aacea03291..9588a7088cd 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/wire/envelope.rs @@ -134,11 +134,19 @@ pub(crate) fn wrap_with_params( } let Some(pw) = password else { - let envelope = Envelope { + use zeroize::Zeroize; + // The scheme-0 plaintext copy rides the envelope in the clear. Encode, + // then wipe that copy before it drops — the returned SecretBytes is the + // only retained copy and zeroizes itself. + let mut envelope = Envelope { version: ENVELOPE_VERSION, payload: Payload::Unprotected(plaintext.to_vec()), }; - return Ok(SecretBytes::new(encode_envelope(&envelope))); + let encoded = encode_envelope(&envelope); + if let Payload::Unprotected(ref mut bytes) = envelope.payload { + bytes.zeroize(); + } + return Ok(SecretBytes::new(encoded)); }; // Reject a blank object password BEFORE any salt / derive. @@ -224,11 +232,8 @@ pub(crate) fn unwrap( } if envelope.version != ENVELOPE_VERSION { - // `found` keeps the historical u8 — the error API stayed u8 for - // back-compat; an out-of-range u32 wraps but the decoder above - // already accepts every u32 so this only narrows the diagnostic. return Err(SecretStoreError::UnsupportedEnvelopeVersion { - found: envelope.version as u8, + found: envelope.version, }); } @@ -808,6 +813,25 @@ mod tests { } } + /// A version above 255 must surface its FULL `u32`, not a truncated `u8` + /// (`300 as u8 == 44` would alias a different version in diagnostics). + #[test] + fn unsupported_version_preserves_full_u32() { + let env = Envelope { + version: 300, + payload: Payload::Unprotected(b"x".to_vec()), + }; + let blob = encode(&env); + let err = unwrap(&wid(1), "seed", None, &blob).unwrap_err(); + assert!( + matches!( + err, + SecretStoreError::UnsupportedEnvelopeVersion { found: 300 } + ), + "version must not be truncated to u8, got {err:?}" + ); + } + /// TC-020 — a hand-crafted byte stream with an unknown payload /// enum tag yields Corruption (bincode's natural fail-closed). #[test] From 860ea7ff958954a565d3ccec779f06dd2e853524 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 29 Jun 2026 18:00:49 +0000 Subject: [PATCH 025/108] fix(platform-wallet-storage): surface unconfirmed vault-write durability via a pollable counter (#3968 review, H2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault's atomic write fsyncs the parent dir to make the persist() rename durable across power loss. A dir-fsync failure happens AFTER the data is committed + visible, so it stays intentionally non-fatal — propagating it would force put/delete/rekey to roll back in-memory state that already matches disk, diverging the live handle. Previously it was swallowed at warn! + Ok, which the review flagged as silent. Option B: keep the Result contract unchanged (still Ok — the write succeeded) but stop swallowing the signal. Elevate the log warn! -> error!, and bump a per-store durability_uncertain AtomicU64 exposed via EncryptedFileStore::durability_uncertain_count() (and SecretStore pass-through, Some on File / None on Os). A caller that cares about hard durability polls it rather than seeing a spurious error. The increment-on-failure path is environment-specific (can't force a dir-fsync failure portably) so it isn't unit-tested; a test guards the accessor + the no-spurious-bump invariant on a confirmed write. Co-Authored-By: Claude Opus 4.6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/secrets/file/mod.rs | 111 ++++++++++++++---- .../src/secrets/store.rs | 12 ++ 2 files changed, 98 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs index 26731dc7462..95c3354a40e 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/file/mod.rs @@ -47,6 +47,7 @@ use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use keyring_core::api::{Credential, CredentialApi, CredentialPersistence, CredentialStoreApi}; @@ -92,6 +93,9 @@ pub const MAX_SECRET_LEN: usize = 64 * 1024; #[derive(Clone)] pub struct EncryptedFileStore { inner: Arc>, + /// Shared clone of [`EncryptedFileStoreInner::durability_uncertain`] so + /// the pollable accessor reads it WITHOUT taking the state lock. + durability_uncertain: Arc, } /// Resident store state behind a single [`Mutex`] so every mutation sees @@ -120,6 +124,14 @@ struct EncryptedFileStoreInner { /// /// [`rekey`]: EncryptedFileStore::rekey passphrase: SecretString, + /// Count of vault writes whose data committed (atomic rename succeeded) + /// but whose parent-directory fsync could NOT be confirmed, so the + /// rename's durability across power loss is uncertain. Non-fatal by + /// design — the write still returns `Ok` and the data is visible — this + /// is a pollable signal, not a rollback trigger (see + /// [`EncryptedFileStore::durability_uncertain_count`]). Bumped under the + /// state lock from `sync_to_disk`; read lock-free via the shared `Arc`. + durability_uncertain: Arc, /// Holds the advisory write-lock on `.lock` for the store's /// lifetime; dropped (releasing the lock) when the store drops. _lock: VaultLock, @@ -186,17 +198,32 @@ impl EncryptedFileStore { None => Self::create_new_vault(&path, &passphrase)?, }; + let durability_uncertain = Arc::new(AtomicU64::new(0)); Ok(Self { inner: Arc::new(Mutex::new(EncryptedFileStoreInner { path, vault, derived_key, passphrase, + durability_uncertain: Arc::clone(&durability_uncertain), _lock: lock, })), + durability_uncertain, }) } + /// Number of vault writes whose data committed but whose parent-directory + /// fsync could not be confirmed (rename durability across power loss is + /// uncertain). Monotonic, process-lifetime; `0` means every write this + /// store performed was confirmed durable. This is the **observable signal** + /// behind the intentionally non-fatal handling: such a write still returns + /// `Ok` (data committed + visible), so a caller that cares about hard + /// durability polls this rather than seeing a spurious error it would + /// otherwise roll back. Read lock-free. + pub fn durability_uncertain_count(&self) -> u64 { + self.durability_uncertain.load(Ordering::Relaxed) + } + /// Load and decrypt an existing vault file, returning `Ok(None)` if /// the file does not exist. Verifies the passphrase against the /// header verify-token before returning. @@ -218,7 +245,9 @@ impl EncryptedFileStore { passphrase: &SecretString, ) -> Result<(Vault, SecretBytes), SecretStoreError> { let (vault, key) = build_fresh_vault(passphrase)?; - write_vault_at(path, &vault)?; + // Initial create: the store isn't built yet, so no durability counter + // to bump — a brand-new store's count starts at 0. + write_vault_at(path, &vault, None)?; Ok((vault, key)) } @@ -302,7 +331,7 @@ impl EncryptedFileStore { #[cfg(test)] pub(crate) fn test_write_vault_to_disk(&self, vault: &Vault) -> Result<(), SecretStoreError> { - write_vault_at(&lock_inner(&self.inner).path, vault) + write_vault_at(&lock_inner(&self.inner).path, vault, None) } /// Reload the vault from disk under the current passphrase, so a test @@ -334,7 +363,7 @@ impl EncryptedFileStoreInner { /// Re-encrypt the resident vault and atomically replace the /// on-disk file. Runs inside the state-lock critical section. fn sync_to_disk(&self) -> Result<(), SecretStoreError> { - write_vault_at(&self.path, &self.vault) + write_vault_at(&self.path, &self.vault, Some(&self.durability_uncertain)) } /// In-place seal + disk-write for [`EncryptedFileStore::put_bytes`]; @@ -616,13 +645,21 @@ fn read_vault_at(path: &Path) -> Result, SecretStoreError> { /// parent-dir fsync. The destination is never pre-removed, so a crash /// leaves the old or new vault, never none. The temp holds only /// ciphertext + header, never plaintext. -fn write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { - do_write_vault_at(path, vault).inspect_err(|e| { +fn write_vault_at( + path: &Path, + vault: &Vault, + durability_uncertain: Option<&AtomicU64>, +) -> Result<(), SecretStoreError> { + do_write_vault_at(path, vault, durability_uncertain).inspect_err(|e| { tracing::warn!(error = %e, "failed to write vault file"); }) } -fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> { +fn do_write_vault_at( + path: &Path, + vault: &Vault, + durability_uncertain: Option<&AtomicU64>, +) -> Result<(), SecretStoreError> { let serialized = format::serialize(vault); // Defence in depth: never write a vault the read path would refuse, // so the on-disk file is never left unopenable. @@ -647,29 +684,33 @@ fn do_write_vault_at(path: &Path, vault: &Vault) -> Result<(), SecretStoreError> .map_err(|e| SecretStoreError::io_at(path, e.error))?; // The vault is now committed on disk via the atomic persist() rename above. // A subsequent parent-directory fsync failure cannot undo the already-written - // file. Propagating the error would cause callers (put/delete/rekey) to roll - // back in-memory state that already matches the on-disk vault — leaving the - // live handle diverged from disk. Log a warning instead so the caller's - // rollback guard is not triggered. + // file. Propagating the error would force callers (put/delete/rekey) to roll + // back in-memory state that already matches the on-disk vault — diverging the + // live handle from disk. So this stays NON-FATAL: the write returns `Ok` and + // the data is committed + visible. We surface the unconfirmed power-loss + // durability two ways instead of swallowing it — an `error!` log AND a bump + // of the pollable `durability_uncertain` counter + // ([`EncryptedFileStore::durability_uncertain_count`]). #[cfg(unix)] { + let signal_unconfirmed = |e: &std::io::Error| { + tracing::error!( + error = %e, + parent = %parent.display(), + "parent-dir fsync unconfirmed after vault persist; data is committed on disk \ + but its rename durability across power loss is NOT confirmed" + ); + if let Some(counter) = durability_uncertain { + counter.fetch_add(1, Ordering::Relaxed); + } + }; match fs::File::open(parent) { Ok(d) => { if let Err(e) = d.sync_all() { - tracing::warn!( - error = %e, - parent = %parent.display(), - "parent-dir fsync failed after vault persist; vault is committed on disk" - ); + signal_unconfirmed(&e); } } - Err(e) => { - tracing::warn!( - error = %e, - parent = %parent.display(), - "parent-dir open for fsync failed after vault persist; vault is committed on disk" - ); - } + Err(e) => signal_unconfirmed(&e), } } Ok(()) @@ -1177,6 +1218,26 @@ mod tests { assert!(matches!(missing, KeyringError::NoEntry)); } + /// The durability-uncertain counter exists, starts at 0, and a normal + /// write (whose parent-dir fsync is confirmed) does NOT bump it. The + /// increment-on-fsync-FAILURE path is environment-specific and not forced + /// here; this guards the accessor + the no-spurious-bump invariant. + #[test] + fn durability_uncertain_count_zero_for_confirmed_writes() { + let dir = tempfile::tempdir().unwrap(); + let path = vault_path(dir.path()); + let s = store_at(&path); + assert_eq!(s.durability_uncertain_count(), 0, "fresh store has none"); + entry(&s, wid(1), "bip39_mnemonic") + .set_secret(b"abandon abandon") + .unwrap(); + assert_eq!( + s.durability_uncertain_count(), + 0, + "a confirmed-durable write must not bump the counter" + ); + } + #[test] fn wrong_passphrase_on_reopen_fails_with_typed_error() { let dir = tempfile::tempdir().unwrap(); @@ -1848,7 +1909,7 @@ mod tests { } let mut vault = read_vault_at(&path).unwrap().unwrap(); vault.kdf.m_kib = u32::MAX; - write_vault_at(&path, &vault).unwrap(); + write_vault_at(&path, &vault, None).unwrap(); let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) .expect_err("inflated KDF must fail open"); assert!(matches!(err, SecretStoreError::KdfFailure), "got {err:?}"); @@ -2096,7 +2157,7 @@ mod tests { vault.kdf.enforce_bounds().is_ok(), "shift must stay in bounds" ); - write_vault_at(&path, &vault).unwrap(); + write_vault_at(&path, &vault, None).unwrap(); let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) .expect_err("KDF-param shift must fail the verify-token"); assert!( @@ -2118,7 +2179,7 @@ mod tests { } let mut vault = read_vault_at(&path).unwrap().unwrap(); vault.salt[0] ^= 0x01; - write_vault_at(&path, &vault).unwrap(); + write_vault_at(&path, &vault, None).unwrap(); let err = EncryptedFileStore::open(&path, SecretString::new("pw-correct")) .expect_err("flipped salt must fail open"); assert!( diff --git a/packages/rs-platform-wallet-storage/src/secrets/store.rs b/packages/rs-platform-wallet-storage/src/secrets/store.rs index 0e86857551c..6d6fb9fdc2c 100644 --- a/packages/rs-platform-wallet-storage/src/secrets/store.rs +++ b/packages/rs-platform-wallet-storage/src/secrets/store.rs @@ -283,6 +283,18 @@ impl SecretStore { } } + /// Pollable durability signal — see + /// [`EncryptedFileStore::durability_uncertain_count`]. `Some(count)` on the + /// `File` arm (writes whose data committed but whose parent-dir fsync was + /// unconfirmed); `None` on the `Os` arm, whose backend owns its own + /// durability and exposes no such signal here. + pub fn durability_uncertain_count(&self) -> Option { + match self { + Self::File(s) => Some(s.durability_uncertain_count()), + Self::Os(_) => None, + } + } + /// Delete the secret stored under `(service, label)`. /// /// Returns `Ok(true)` if a credential was removed, `Ok(false)` if no 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 026/108] 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 027/108] 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 028/108] 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 08b288471647f18a1ba1ebc4aefe5af646bd1ae0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 08:55:06 +0000 Subject: [PATCH 029/108] fix(platform-wallet-storage): require full ChainLock consumption in merge-height helper (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `chain_lock_height` decoded the `last_applied_chain_lock` blob without requiring `consumed == bytes.len()`, unlike the load-side `decode_chain_lock_soft`. A corrupt stored blob (valid ChainLock prefix + trailing bytes) therefore yielded a height and could out-rank a later valid lower-height update in the monotonic-max merge, staying stuck — while the load path already rejects it (→ None). Match the load decoder so a corrupt blob loses to the next valid update. Adds a unit test. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../src/sqlite/schema/core_state.rs | 36 +++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index ff6cefa7c6d..5386b13bdc8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -63,9 +63,12 @@ fn decode_chain_lock_soft(bytes: &[u8]) -> Option { /// can't be decoded. Used to monotonic-max-merge the chain lock so an /// out-of-order lower-height update never regresses the finalized checkpoint. fn chain_lock_height(bytes: &[u8]) -> Option { - bincode::decode_from_slice::(bytes, chain_lock_config()) - .ok() - .map(|(cl, _)| cl.block_height) + match bincode::decode_from_slice::(bytes, chain_lock_config()) { + // Require full consumption (like `decode_chain_lock_soft`) so a corrupt + // stored blob can't out-rank a later valid update and stay stuck. + Ok((cl, consumed)) if consumed == bytes.len() => Some(cl.block_height), + _ => None, + } } /// Apply a `CoreChangeSet` inside a transaction. @@ -527,3 +530,30 @@ pub fn list_unspent_utxos( } Ok(by_account) } + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::hashes::Hash; + use dashcore::BlockHash; + + fn sample_chain_lock(height: u32) -> ChainLock { + ChainLock { + block_height: height, + block_hash: BlockHash::from_byte_array([0x11u8; 32]), + signature: [0x22u8; 96].into(), + } + } + + #[test] + fn chain_lock_height_rejects_trailing_bytes() { + let bytes = encode_chain_lock(&sample_chain_lock(100_000)).expect("encode"); + assert_eq!(chain_lock_height(&bytes), Some(100_000)); + + // A corrupt blob (valid prefix + trailing garbage) must not yield a + // height, else it stays stuck atop later valid lower-height updates. + let mut corrupt = bytes.clone(); + corrupt.extend_from_slice(&[0xFFu8; 4]); + assert_eq!(chain_lock_height(&corrupt), None); + } +} From 73df060de92fea9bdb541e1f568a34f3ff4916b6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 10:44:52 +0000 Subject: [PATCH 030/108] fix(platform-wallet-storage): pre-read BLOB size-gate on rehydration readers (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `length()` gate — checked BEFORE materialising the `Vec` — to every load-path BLOB reader that was missing it, mirroring the existing pattern already applied to `core_transactions.record_blob` in core_state.rs. Readers gated: - core_state.rs : core_instant_locks.islock_blob - accounts.rs : account_xpub_bytes (load_state, list_platform_payment_registrations, all_platform_payment_registrations) - asset_locks.rs : lifecycle_blob (load_unconsumed, load_state, list_active) - identities.rs : entry_blob (load_state, fetch) - contacts.rs : outgoing_request / incoming_request / accepted_accounts (load_state) - identity_keys.rs : public_key_blob (load_state) Each reader's SELECT now leads with `length()` (O(1) from the row header) and returns `WalletStorageError::BlobTooLarge` before calling `row.get::<_, Vec>()`, so a tampered/corrupt oversize BLOB in a local wallet DB cannot force a multi-GiB allocation that `blob::decode`'s post-hoc cap only catches after the Vec is built. Compile-time allow-list in sqlite_compile_time.rs updated to match the new SQL strings; two regression tests added (accounts xpub_bytes + identity_keys public_key_blob). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../src/sqlite/schema/accounts.rs | 74 +++++++++------ .../src/sqlite/schema/asset_locks.rs | 66 +++++++------ .../src/sqlite/schema/contacts.rs | 52 ++++++++-- .../src/sqlite/schema/core_state.rs | 27 ++++-- .../src/sqlite/schema/identities.rs | 43 ++++++--- .../src/sqlite/schema/identity_keys.rs | 12 ++- .../tests/sqlite_blob_size_gate_on_load.rs | 94 +++++++++++++++++++ .../tests/sqlite_compile_time.rs | 14 ++- 8 files changed, 288 insertions(+), 94 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 3fed9287a9d..70d8b8a9447 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -52,16 +52,23 @@ pub(crate) fn list_platform_payment_registrations( wallet_id: &WalletId, ) -> Result, WalletStorageError> { let mut stmt = conn.prepare( - "SELECT account_index, account_xpub_bytes FROM account_registrations \ + "SELECT account_index, length(account_xpub_bytes), account_xpub_bytes \ + FROM account_registrations \ WHERE wallet_id = ?1 AND account_type = 'platform_payment' \ ORDER BY account_index", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, Vec>(1)?)) - })?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out = Vec::new(); - for r in rows { - let (idx, bytes) = r?; + while let Some(row) = rows.next()? { + let idx: i64 = row.get(0)?; + let len = usize::try_from(row.get::<_, i64>(1)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let bytes: Vec = row.get(2)?; out.push(decode_platform_payment_row(idx, &bytes)?); } Ok(out) @@ -75,20 +82,24 @@ pub(crate) fn all_platform_payment_registrations( conn: &Connection, ) -> Result>, WalletStorageError> { let mut stmt = conn.prepare( - "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations \ + "SELECT wallet_id, account_index, length(account_xpub_bytes), account_xpub_bytes \ + FROM account_registrations \ WHERE account_type = 'platform_payment' \ ORDER BY wallet_id, account_index", )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Vec>(2)?, - )) - })?; + let mut rows = stmt.query([])?; let mut out: BTreeMap> = BTreeMap::new(); - for r in rows { - let (wid_bytes, idx, bytes) = r?; + while let Some(row) = rows.next()? { + let wid_bytes: Vec = row.get(0)?; + let idx: i64 = row.get(1)?; + let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let bytes: Vec = row.get(3)?; let wallet_id = <[u8; 32]>::try_from(wid_bytes.as_slice()).map_err(|_| { WalletStorageError::InvalidWalletIdLength { actual: wid_bytes.len(), @@ -155,25 +166,30 @@ pub fn load_state( // against the decoded entry — a row whose blob disagrees with its indexed // columns is a sign of corruption or a schema bug and must be rejected // rather than silently mis-bucketed. + // `length(account_xpub_bytes)` is read first (O(1) from the row header) so + // an oversize blob is caught before the Vec is allocated. let mut stmt = conn.prepare( "SELECT account_type, account_index, key_class, user_identity_id, friend_identity_id, \ - account_xpub_bytes FROM account_registrations \ + length(account_xpub_bytes), account_xpub_bytes FROM account_registrations \ WHERE wallet_id = ?1 \ ORDER BY account_type, account_index, key_class, user_identity_id, friend_identity_id", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - Ok(( - row.get::<_, String>(0)?, // account_type TEXT - row.get::<_, i64>(1)?, // account_index INTEGER - row.get::<_, i64>(2)?, // key_class INTEGER - row.get::<_, Vec>(3)?, // user_identity_id BLOB - row.get::<_, Vec>(4)?, // friend_identity_id BLOB - row.get::<_, Vec>(5)?, // account_xpub_bytes BLOB - )) - })?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out = Vec::new(); - for r in rows { - let (typed_type, typed_index, typed_key_class, typed_user, typed_friend, payload) = r?; + while let Some(row) = rows.next()? { + let typed_type: String = row.get(0)?; // account_type TEXT + let typed_index: i64 = row.get(1)?; // account_index INTEGER + let typed_key_class: i64 = row.get(2)?; // key_class INTEGER + let typed_user: Vec = row.get(3)?; // user_identity_id BLOB + let typed_friend: Vec = row.get(4)?; // friend_identity_id BLOB + let len = usize::try_from(row.get::<_, i64>(5)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let payload: Vec = row.get(6)?; // account_xpub_bytes BLOB let entry = blob::decode::(&payload)?; // Cross-check every typed PK column vs the decoded blob so a // corruption that passes `PRAGMA integrity_check` is still caught diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index 990bbacbf72..02e225db8b0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -158,19 +158,23 @@ pub fn load_state( wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, lifecycle_blob, status \ + "SELECT outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + while let Some(row) = rows.next()? { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; - let blob_bytes: Vec = row.get(2)?; - let status: String = row.get(3)?; - Ok((op_bytes, account_index, blob_bytes, status)) - })?; - let mut out: AssetLocksByAccount = BTreeMap::new(); - for r in rows { - let (op_bytes, account_index, blob_bytes, status) = r?; + let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let blob_bytes: Vec = row.get(3)?; + let status: String = row.get(4)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } @@ -188,19 +192,23 @@ pub fn load_unconsumed( wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, lifecycle_blob, status \ + "SELECT outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1 AND status NOT IN ('consumed')", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + while let Some(row) = rows.next()? { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; - let blob_bytes: Vec = row.get(2)?; - let status: String = row.get(3)?; - Ok((op_bytes, account_index, blob_bytes, status)) - })?; - let mut out: AssetLocksByAccount = BTreeMap::new(); - for r in rows { - let (op_bytes, account_index, blob_bytes, status) = r?; + let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let blob_bytes: Vec = row.get(3)?; + let status: String = row.get(4)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } @@ -217,19 +225,23 @@ pub fn list_active( wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, lifecycle_blob, status \ + "SELECT outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + let mut out: AssetLocksByAccount = BTreeMap::new(); + while let Some(row) = rows.next()? { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; - let blob_bytes: Vec = row.get(2)?; - let status: String = row.get(3)?; - Ok((op_bytes, account_index, blob_bytes, status)) - })?; - let mut out: AssetLocksByAccount = BTreeMap::new(); - for r in rows { - let (op_bytes, account_index, blob_bytes, status) = r?; + let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let blob_bytes: Vec = row.get(3)?; + let status: String = row.get(4)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index 313e408c814..a61dc029386 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -226,9 +226,15 @@ pub(crate) fn load_state( ) -> Result { let mut state = ContactsRecords::default(); + // `length()` for each blob column is read before the column itself so an + // oversize blob is caught before the Vec is allocated. NULL blobs return + // NULL from `length()`, which maps to `None` here — no gate needed. let mut stmt = conn.prepare( - "SELECT owner_id, contact_id, state, outgoing_request, incoming_request, \ - alias, note, is_hidden, accepted_accounts \ + "SELECT owner_id, contact_id, state, \ + length(outgoing_request), outgoing_request, \ + length(incoming_request), incoming_request, \ + alias, note, is_hidden, \ + length(accepted_accounts), accepted_accounts \ FROM contacts WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; @@ -236,8 +242,28 @@ pub(crate) fn load_state( let owner: Vec = row.get(0)?; let contact: Vec = row.get(1)?; let label: String = row.get(2)?; - let outgoing: Option> = row.get(3)?; - let incoming: Option> = row.get(4)?; + let outgoing_len: Option = row.get(3)?; + if let Some(n) = outgoing_len { + let n = usize::try_from(n).unwrap_or(usize::MAX); + if n > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: n, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + } + let outgoing: Option> = row.get(4)?; + let incoming_len: Option = row.get(5)?; + if let Some(n) = incoming_len { + let n = usize::try_from(n).unwrap_or(usize::MAX); + if n > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: n, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + } + let incoming: Option> = row.get(6)?; let (owner_id, contact_id) = decode_pair_key(&owner, &contact)?; match contact_state_from_label(&label)? { @@ -271,10 +297,20 @@ pub(crate) fn load_state( // Outgoing = us→contact; incoming = contact→us. check_request_parties(&outgoing_request, &owner_id, &contact_id)?; check_request_parties(&incoming_request, &contact_id, &owner_id)?; - let alias: Option = row.get(5)?; - let note: Option = row.get(6)?; - let is_hidden: bool = row.get::<_, Option>(7)?.unwrap_or(0) != 0; - let accepted_blob: Option> = row.get(8)?; + let alias: Option = row.get(7)?; + let note: Option = row.get(8)?; + let is_hidden: bool = row.get::<_, Option>(9)?.unwrap_or(0) != 0; + let accepted_len: Option = row.get(10)?; + if let Some(n) = accepted_len { + let n = usize::try_from(n).unwrap_or(usize::MAX); + if n > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: n, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + } + let accepted_blob: Option> = row.get(11)?; let accepted_accounts: Vec = match accepted_blob { Some(bytes) => blob::decode(&bytes)?, None => Vec::new(), diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 5386b13bdc8..5b04a4898ef 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -370,16 +370,25 @@ pub fn load_state( } { - let mut stmt = - conn.prepare("SELECT txid, islock_blob FROM core_instant_locks WHERE wallet_id = ?1")?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - let txid: Vec = row.get(0)?; - let blob_bytes: Vec = row.get(1)?; - Ok((txid, blob_bytes)) - })?; - for r in rows { + // Same pre-read length gate as `record_blob` above: `length()` is + // O(1) from the row header, so a tampered oversize `islock_blob` + // can't force a large allocation before the cap rejects it. + let mut stmt = conn.prepare( + "SELECT txid, length(islock_blob), islock_blob \ + FROM core_instant_locks WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { use dashcore::hashes::Hash; - let (txid_bytes, blob_bytes) = r?; + let txid_bytes: Vec = row.get(0)?; + let len = usize::try_from(row.get::<_, i64>(1)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let blob_bytes: Vec = row.get(2)?; let txid = dashcore::Txid::from_slice(&txid_bytes) .map_err(|_| WalletStorageError::blob_decode("core_instant_locks.txid"))?; let islock: dashcore::ephemerealdata::instant_lock::InstantLock = diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index fcd40f76d31..828f134535c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -110,21 +110,28 @@ pub fn fetch( wallet_id: &WalletId, identity_id: &[u8; 32], ) -> Result, WalletStorageError> { - use rusqlite::OptionalExtension; // Scope to the caller's wallet (NULL-safe `IS`) so a peer wallet sharing // the identity-id row can't leak through; sentinel matches orphan rows. let wallet_id_param = wallet_id_to_param(wallet_id); - let row: Option<(Vec, i64)> = conn - .query_row( - "SELECT entry_blob, tombstoned FROM identities \ - WHERE identity_id = ?1 AND wallet_id IS ?2", - params![&identity_id[..], wallet_id_param], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - match row { + let mut stmt = conn.prepare( + "SELECT length(entry_blob), entry_blob, tombstoned FROM identities \ + WHERE identity_id = ?1 AND wallet_id IS ?2", + )?; + let mut rows = stmt.query(params![&identity_id[..], wallet_id_param])?; + match rows.next()? { None => Ok(None), - Some((payload, tombstoned)) => Ok(Some((blob::decode(&payload)?, tombstoned != 0))), + Some(row) => { + let len = usize::try_from(row.get::<_, i64>(0)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let payload: Vec = row.get(1)?; + let tombstoned: i64 = row.get(2)?; + Ok(Some((blob::decode(&payload)?, tombstoned != 0))) + } } } @@ -142,14 +149,22 @@ pub fn load_state( // Per-wallet loader: match by wallet_id, so orphan rows (NULL wallet_id) // are out of scope. let mut stmt = conn.prepare( - "SELECT identity_id, entry_blob, tombstoned FROM identities WHERE wallet_id = ?1", + "SELECT identity_id, length(entry_blob), entry_blob, tombstoned \ + FROM identities WHERE wallet_id = ?1", )?; let mut state = IdentityManagerStartState::default(); let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { let identity_id_bytes: Vec = row.get(0)?; - let payload: Vec = row.get(1)?; - let tombstoned: i64 = row.get(2)?; + let len = usize::try_from(row.get::<_, i64>(1)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let payload: Vec = row.get(2)?; + let tombstoned: i64 = row.get(3)?; if tombstoned != 0 { continue; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 68cb470c168..36c6376ee12 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -149,13 +149,21 @@ pub fn load_state( ) -> Result { let mut cs = IdentityKeysChangeSet::default(); let mut stmt = conn.prepare( - "SELECT identity_id, key_id, public_key_blob FROM identity_keys WHERE wallet_id = ?1", + "SELECT identity_id, key_id, length(public_key_blob), public_key_blob \ + FROM identity_keys WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { let identity_id_bytes: Vec = row.get(0)?; let key_id: i64 = row.get(1)?; - let payload: Vec = row.get(2)?; + let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); + if len > blob::BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len, + limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, + }); + } + let payload: Vec = row.get(3)?; let id32 = <[u8; 32]>::try_from(identity_id_bytes.as_slice()).map_err(|_| { WalletStorageError::blob_decode("identity_keys.identity_id is not 32 bytes") })?; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs new file mode 100644 index 00000000000..9d2a1558141 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs @@ -0,0 +1,94 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Pre-read BLOB size-gate regression tests. +//! +//! Proves that each load-path BLOB reader rejects an oversize row with +//! [`WalletStorageError::BlobTooLarge`] **before** materialising the `Vec`, +//! i.e. the `length()` gate fires first. The oversize blob is planted +//! directly via raw SQL so the production encode path (which enforces the cap +//! on writes) is bypassed — simulating a tampered / corrupted local wallet DB. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use rusqlite::params; + +use platform_wallet_storage::sqlite::schema::{accounts, identities, identity_keys}; +use platform_wallet_storage::WalletStorageError; + +/// Blob larger than the 16 MiB cap: one byte over the limit is enough to +/// trigger the pre-read gate without wasting more memory than necessary. +fn oversize_blob() -> Vec { + vec![0u8; platform_wallet_storage::SIZE_LIMIT_BYTES + 1] +} + +// ── accounts::load_state — account_xpub_bytes ──────────────────────────────── + +/// An `account_registrations` row whose `account_xpub_bytes` blob exceeds the +/// 16 MiB cap is rejected by `accounts::load_state` with `BlobTooLarge` +/// **before** the Vec is allocated (the `length(account_xpub_bytes)` gate). +#[test] +fn blob_gate_accounts_load_state_rejects_oversize_xpub_bytes() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xA1); + ensure_wallet_meta(&persister, &w); + + let blob = oversize_blob(); + let conn = persister.lock_conn_for_test(); + // Plant the oversize blob directly; `zero_id` (32-byte all-zero) is the + // default sentinel for `user_identity_id` / `friend_identity_id`. + let zero_id = [0u8; 32]; + conn.execute( + "INSERT INTO account_registrations \ + (wallet_id, account_type, account_index, key_class, \ + user_identity_id, friend_identity_id, account_xpub_bytes) \ + VALUES (?1, 'platform_payment', 0, 0, ?2, ?3, ?4)", + params![w.as_slice(), &zero_id[..], &zero_id[..], blob.as_slice()], + ) + .expect("insert oversize xpub_bytes row"); + + let err = accounts::load_state(&conn, &w) + .expect_err("load_state must reject an oversize account_xpub_bytes blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +// ── identity_keys::load_state — public_key_blob ────────────────────────────── + +/// An `identity_keys` row whose `public_key_blob` exceeds the 16 MiB cap is +/// rejected by `identity_keys::load_state` with `BlobTooLarge` before the Vec +/// is materialised (the `length(public_key_blob)` gate). +#[test] +fn blob_gate_identity_keys_load_state_rejects_oversize_public_key_blob() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xB1); + ensure_wallet_meta(&persister, &w); + let identity_id = [0xCCu8; 32]; + + let blob = oversize_blob(); + let conn = persister.lock_conn_for_test(); + // `identity_keys` has a FK to `identities(identity_id)`; plant the stub. + identities::ensure_exists(&conn, &w, &identity_id).expect("ensure identity stub"); + let zero_hash = [0u8; 20]; + conn.execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, ?3, ?4, NULL)", + params![ + w.as_slice(), + &identity_id[..], + blob.as_slice(), + &zero_hash[..] + ], + ) + .expect("insert oversize public_key_blob row"); + + let err = identity_keys::load_state(&conn, &w) + .expect_err("load_state must reject an oversize public_key_blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index b3db91fb455..809e1bda92d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -49,13 +49,15 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "platform_addrs.rs", "SELECT wallet_id, account_index, address_index, address, balance, nonce", ), + // Pre-read `length()` gates added by PR #3968 review — substrings updated + // to reflect the new `length()` column in each SELECT. ( "accounts.rs", - "SELECT account_index, account_xpub_bytes FROM account_registrations", + "SELECT account_index, length(account_xpub_bytes), account_xpub_bytes", ), ( "accounts.rs", - "SELECT wallet_id, account_index, account_xpub_bytes FROM account_registrations", + "SELECT wallet_id, account_index, length(account_xpub_bytes), account_xpub_bytes", ), ("core_state.rs", "SELECT outpoint, value, script, height"), ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), @@ -70,7 +72,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "core_state.rs", - "SELECT txid, islock_blob FROM core_instant_locks WHERE wallet_id", + "SELECT txid, length(islock_blob), islock_blob", ), ( "core_state.rs", @@ -78,12 +80,14 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "identity_keys.rs", - "SELECT identity_id, key_id, public_key_blob FROM identity_keys WHERE wallet_id", + "SELECT identity_id, key_id, length(public_key_blob), public_key_blob", ), // P4 readers — `load_state` per area uses one-shot SELECTs. + // Substring covers both `fetch` (`SELECT length(entry_blob)…`) and + // `load_state` (`SELECT identity_id, length(entry_blob)…`). ( "identities.rs", - "SELECT identity_id, entry_blob, tombstoned", + "length(entry_blob), entry_blob, tombstoned", ), ("contacts.rs", "SELECT owner_id, contact_id, state"), ]; From 145439408ae8c756ccd073420888336f4fbcbc17 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:07:37 +0000 Subject: [PATCH 031/108] fix(platform-wallet-storage): DRY blob size-gate helper + close remaining read-before-cap / unbounded-bincode gaps (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * blob.rs: make bounded_config() pub(crate); add check_size(i64) and check_fixed_width(i64, usize, &'static str) helpers * Remove chain_lock_config() — replaced everywhere with blob::bounded_config() * DRY all inline 4-6 line gate blocks across accounts, asset_locks, identities, contacts, identity_keys → single check_size/check_fixed_width call * core_state::load_state — restructure query_row→prepare+query for last_applied_chain_lock; gate length() before materializing Vec * core_state::get_tx_record — same restructure so check_size can early-return * platform_addrs::list_per_wallet / all_address_rows — add length(address) pre-read; check_fixed_width(len, 20) guards fixed-width column * identity_keys::from_entry / into_entry — switch from unbounded standard() to bounded_config() for inner public_key_bincode encode/decode * Tests: 4 new blob-gate integration tests (oversize chain-lock, wrong-width address, oversize address, crafted inner pk_bincode) * sqlite_compile_time: update allow-list substrings for modified SQL strings Co-Authored-By: Claude Opus 4.6 --- .../src/sqlite/schema/accounts.rs | 16 +-- .../src/sqlite/schema/asset_locks.rs | 24 +--- .../src/sqlite/schema/blob.rs | 38 +++++- .../src/sqlite/schema/contacts.rs | 33 +---- .../src/sqlite/schema/core_state.rs | 111 +++++++--------- .../src/sqlite/schema/identities.rs | 16 +-- .../src/sqlite/schema/identity_keys.rs | 56 +++++--- .../src/sqlite/schema/platform_addrs.rs | 57 ++++---- .../tests/sqlite_blob_size_gate_on_load.rs | 124 +++++++++++++++++- .../tests/sqlite_compile_time.rs | 4 +- 10 files changed, 290 insertions(+), 189 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs index 70d8b8a9447..dfa83b03378 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/accounts.rs @@ -61,13 +61,7 @@ pub(crate) fn list_platform_payment_registrations( let mut out = Vec::new(); while let Some(row) = rows.next()? { let idx: i64 = row.get(0)?; - let len = usize::try_from(row.get::<_, i64>(1)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(1)?)?; let bytes: Vec = row.get(2)?; out.push(decode_platform_payment_row(idx, &bytes)?); } @@ -182,13 +176,7 @@ pub fn load_state( let typed_key_class: i64 = row.get(2)?; // key_class INTEGER let typed_user: Vec = row.get(3)?; // user_identity_id BLOB let typed_friend: Vec = row.get(4)?; // friend_identity_id BLOB - let len = usize::try_from(row.get::<_, i64>(5)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(5)?)?; let payload: Vec = row.get(6)?; // account_xpub_bytes BLOB let entry = blob::decode::(&payload)?; // Cross-check every typed PK column vs the decoded blob so a diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index 02e225db8b0..297a2e3f388 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -166,13 +166,7 @@ pub fn load_state( while let Some(row) = rows.next()? { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; - let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(2)?)?; let blob_bytes: Vec = row.get(3)?; let status: String = row.get(4)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; @@ -200,13 +194,7 @@ pub fn load_unconsumed( while let Some(row) = rows.next()? { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; - let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(2)?)?; let blob_bytes: Vec = row.get(3)?; let status: String = row.get(4)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; @@ -233,13 +221,7 @@ pub fn list_active( while let Some(row) = rows.next()? { let op_bytes: Vec = row.get(0)?; let account_index: i64 = row.get(1)?; - let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(2)?)?; let blob_bytes: Vec = row.get(3)?; let status: String = row.get(4)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs index 3cb375f30dd..aa2f40b4aab 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/blob.rs @@ -42,7 +42,7 @@ pub(crate) use impl_persistable_blob; /// [`SIZE_LIMIT_BYTES`](crate::SIZE_LIMIT_BYTES) with the KV value cap. pub const BLOB_SIZE_LIMIT_BYTES: usize = crate::SIZE_LIMIT_BYTES; -fn bounded_config() -> bincode::config::Configuration< +pub(crate) fn bounded_config() -> bincode::config::Configuration< bincode::config::LittleEndian, bincode::config::Varint, bincode::config::Limit, @@ -50,6 +50,42 @@ fn bounded_config() -> bincode::config::Configuration< bincode::config::standard().with_limit::() } +/// Gate a variable-width blob column BEFORE materializing the `Vec`. +/// `len` is the value of `length()` selected in the same row. +/// Returns [`WalletStorageError::BlobTooLarge`] when `len` exceeds the cap. +pub(crate) fn check_size(len: i64) -> Result<(), WalletStorageError> { + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + if len_usize > BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len_usize, + limit_bytes: BLOB_SIZE_LIMIT_BYTES, + }); + } + Ok(()) +} + +/// Gate a fixed-width blob column BEFORE materializing the `Vec`. +/// Returns [`WalletStorageError::BlobTooLarge`] for oversize values, or +/// [`WalletStorageError::BlobDecode`] when `len != expected`. `col` names +/// the column in the decode reason string. +pub(crate) fn check_fixed_width( + len: i64, + expected: usize, + col: &'static str, +) -> Result<(), WalletStorageError> { + let len_usize = usize::try_from(len).unwrap_or(usize::MAX); + if len_usize > BLOB_SIZE_LIMIT_BYTES { + return Err(WalletStorageError::BlobTooLarge { + len_bytes: len_usize, + limit_bytes: BLOB_SIZE_LIMIT_BYTES, + }); + } + if len_usize != expected { + return Err(WalletStorageError::blob_decode(col)); + } + Ok(()) +} + /// Encode a [`PersistableBlob`] value into a `BLOB` payload. The sealed bound /// (not a bare `T: Serialize`) guards against unreviewed types reaching a /// `_blob` column. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs index a61dc029386..408adddaf77 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/contacts.rs @@ -242,26 +242,12 @@ pub(crate) fn load_state( let owner: Vec = row.get(0)?; let contact: Vec = row.get(1)?; let label: String = row.get(2)?; - let outgoing_len: Option = row.get(3)?; - if let Some(n) = outgoing_len { - let n = usize::try_from(n).unwrap_or(usize::MAX); - if n > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: n, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + if let Some(n) = row.get::<_, Option>(3)? { + blob::check_size(n)?; } let outgoing: Option> = row.get(4)?; - let incoming_len: Option = row.get(5)?; - if let Some(n) = incoming_len { - let n = usize::try_from(n).unwrap_or(usize::MAX); - if n > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: n, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + if let Some(n) = row.get::<_, Option>(5)? { + blob::check_size(n)?; } let incoming: Option> = row.get(6)?; let (owner_id, contact_id) = decode_pair_key(&owner, &contact)?; @@ -300,15 +286,8 @@ pub(crate) fn load_state( let alias: Option = row.get(7)?; let note: Option = row.get(8)?; let is_hidden: bool = row.get::<_, Option>(9)?.unwrap_or(0) != 0; - let accepted_len: Option = row.get(10)?; - if let Some(n) = accepted_len { - let n = usize::try_from(n).unwrap_or(usize::MAX); - if n > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: n, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + if let Some(n) = row.get::<_, Option>(10)? { + blob::check_size(n)?; } let accepted_blob: Option> = row.get(11)?; let accepted_accounts: Vec = match accepted_blob { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 5b04a4898ef..64284dea8d5 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -19,17 +19,9 @@ use crate::sqlite::schema::blob::impl_persistable_blob; // `islock_blob` (transaction records + InstantLocks are public chain data). impl_persistable_blob!(TransactionRecord, dashcore::InstantLock); -/// Bounded bincode config for `ChainLock` BLOB columns — native bincode -/// (not the serde bridge) because `ChainLock` enables the `bincode` feature -/// but not `serde`. The size limit caps allocations symmetrically with other -/// BLOB columns (`blob::BLOB_SIZE_LIMIT_BYTES`). -fn chain_lock_config() -> impl bincode::config::Config { - bincode::config::standard().with_limit::<{ blob::BLOB_SIZE_LIMIT_BYTES }>() -} - /// Encode a `ChainLock` to bytes for storage in `core_sync_state`. fn encode_chain_lock(cl: &ChainLock) -> Result, WalletStorageError> { - Ok(bincode::encode_to_vec(cl, chain_lock_config())?) + Ok(bincode::encode_to_vec(cl, blob::bounded_config())?) } /// Decode a `ChainLock` from `core_sync_state.last_applied_chain_lock`. @@ -37,7 +29,7 @@ fn encode_chain_lock(cl: &ChainLock) -> Result, WalletStorageError> { /// single corrupt byte cannot prevent the wallet from loading (the next /// ChainLock event will repopulate the column). fn decode_chain_lock_soft(bytes: &[u8]) -> Option { - match bincode::decode_from_slice::(bytes, chain_lock_config()) { + match bincode::decode_from_slice::(bytes, blob::bounded_config()) { // Reject a valid-prefix + trailing-garbage payload (bincode stops // after the typed length) the same way the BLOB decoders do. Ok((cl, consumed)) if consumed == bytes.len() => Some(cl), @@ -63,7 +55,7 @@ fn decode_chain_lock_soft(bytes: &[u8]) -> Option { /// can't be decoded. Used to monotonic-max-merge the chain lock so an /// out-of-order lower-height update never regresses the finalized checkpoint. fn chain_lock_height(bytes: &[u8]) -> Option { - match bincode::decode_from_slice::(bytes, chain_lock_config()) { + match bincode::decode_from_slice::(bytes, blob::bounded_config()) { // Require full consumption (like `decode_chain_lock_soft`) so a corrupt // stored blob can't out-rank a later valid update and stay stuck. Ok((cl, consumed)) if consumed == bytes.len() => Some(cl.block_height), @@ -347,22 +339,15 @@ pub fn load_state( } { - // Cap on the cheap `length()` (O(1) from the row header) BEFORE - // materializing the blob, so a tampered oversize `record_blob` can't - // force a multi-gigabyte allocation that `blob::decode`'s post-hoc cap - // would only catch after the Vec is already built. + // Pre-read `length()` gate (O(1) from the row header) before + // materializing the blob so a tampered oversize `record_blob` can't + // force a multi-gigabyte allocation. let mut stmt = conn.prepare( "SELECT length(record_blob), record_blob FROM core_transactions WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { - let len = usize::try_from(row.get::<_, i64>(0)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(0)?)?; let payload: Vec = row.get(1)?; cs.records .push(blob::decode::(&payload)?); @@ -370,9 +355,7 @@ pub fn load_state( } { - // Same pre-read length gate as `record_blob` above: `length()` is - // O(1) from the row header, so a tampered oversize `islock_blob` - // can't force a large allocation before the cap rejects it. + // Same pre-read length gate as `record_blob` above. let mut stmt = conn.prepare( "SELECT txid, length(islock_blob), islock_blob \ FROM core_instant_locks WHERE wallet_id = ?1", @@ -381,13 +364,7 @@ pub fn load_state( while let Some(row) = rows.next()? { use dashcore::hashes::Hash; let txid_bytes: Vec = row.get(0)?; - let len = usize::try_from(row.get::<_, i64>(1)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(1)?)?; let blob_bytes: Vec = row.get(2)?; let txid = dashcore::Txid::from_slice(&txid_bytes) .map_err(|_| WalletStorageError::blob_decode("core_instant_locks.txid"))?; @@ -397,28 +374,32 @@ pub fn load_state( } } - // Sync watermarks + persisted chain lock. - if let Some((lp, sy, cl_bytes)) = conn - .query_row( - "SELECT last_processed_height, synced_height, last_applied_chain_lock \ - FROM core_sync_state WHERE wallet_id = ?1", - params![wallet_id.as_slice()], - |row| { - let lp: Option = row.get(0)?; - let sy: Option = row.get(1)?; - let cl: Option> = row.get(2)?; - Ok((lp, sy, cl)) - }, - ) - .optional()? + // Sync watermarks + persisted chain lock. Read `length()` first so an + // oversize chain-lock blob is rejected before the Vec is allocated. { - // Fail-hard on an out-of-range watermark (corruption is never skipped). - cs.last_processed_height = sync_height_u32("core_sync_state.last_processed_height", lp)?; - cs.synced_height = sync_height_u32("core_sync_state.synced_height", sy)?; - // Soft-fail on a corrupt chain lock blob — a single bad byte must not - // prevent the wallet from loading; the next ChainLock event repopulates. - if let Some(bytes) = cl_bytes { - cs.last_applied_chain_lock = decode_chain_lock_soft(&bytes); + let mut stmt = conn.prepare( + "SELECT last_processed_height, synced_height, \ + length(last_applied_chain_lock), last_applied_chain_lock \ + FROM core_sync_state WHERE wallet_id = ?1", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + if let Some(row) = rows.next()? { + let lp: Option = row.get(0)?; + let sy: Option = row.get(1)?; + // Gate before materializing: NULL length means no chain lock. + if let Some(n) = row.get::<_, Option>(2)? { + blob::check_size(n)?; + } + let cl_bytes: Option> = row.get(3)?; + // Fail-hard on an out-of-range watermark (corruption never skipped). + cs.last_processed_height = + sync_height_u32("core_sync_state.last_processed_height", lp)?; + cs.synced_height = sync_height_u32("core_sync_state.synced_height", sy)?; + // Soft-fail on a corrupt chain-lock blob — a single bad byte must + // not prevent loading; the next ChainLock event repopulates. + if let Some(bytes) = cl_bytes { + cs.last_applied_chain_lock = decode_chain_lock_soft(&bytes); + } } } @@ -470,17 +451,19 @@ pub fn get_tx_record( wallet_id: &WalletId, txid: &dashcore::Txid, ) -> Result, WalletStorageError> { - let row: Option> = conn - .query_row( - "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", - params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)], - |row| row.get(0), - ) - .optional()?; - match row { - None => Ok(None), - Some(payload) => Ok(Some(blob::decode(&payload)?)), - } + // Pre-read `length()` gate before materializing, consistent with the + // bulk load_state path above. + let mut stmt = conn.prepare( + "SELECT length(record_blob), record_blob FROM core_transactions \ + WHERE wallet_id = ?1 AND txid = ?2", + )?; + let mut rows = stmt.query(params![wallet_id.as_slice(), AsRef::<[u8]>::as_ref(txid)])?; + let Some(row) = rows.next()? else { + return Ok(None); + }; + blob::check_size(row.get::<_, i64>(0)?)?; + let payload: Vec = row.get(1)?; + Ok(Some(blob::decode(&payload)?)) } /// Row representing one unspent UTXO. Used by tests that probe the diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 828f134535c..e8a32608b97 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -121,13 +121,7 @@ pub fn fetch( match rows.next()? { None => Ok(None), Some(row) => { - let len = usize::try_from(row.get::<_, i64>(0)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(0)?)?; let payload: Vec = row.get(1)?; let tombstoned: i64 = row.get(2)?; Ok(Some((blob::decode(&payload)?, tombstoned != 0))) @@ -156,13 +150,7 @@ pub fn load_state( let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { let identity_id_bytes: Vec = row.get(0)?; - let len = usize::try_from(row.get::<_, i64>(1)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(1)?)?; let payload: Vec = row.get(2)?; let tombstoned: i64 = row.get(3)?; if tombstoned != 0 { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 36c6376ee12..a7626d8f216 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -38,7 +38,7 @@ crate::sqlite::schema::blob::impl_persistable_blob!(IdentityKeyWire); impl IdentityKeyWire { fn from_entry(entry: &IdentityKeyEntry) -> Result { - let pk = bincode::encode_to_vec(&entry.public_key, bincode::config::standard())?; + let pk = bincode::encode_to_vec(&entry.public_key, blob::bounded_config())?; Ok(Self { identity_id: entry.identity_id, key_id: entry.key_id, @@ -51,7 +51,7 @@ impl IdentityKeyWire { fn into_entry(self) -> Result { let (public_key, consumed): (IdentityPublicKey, usize) = - bincode::decode_from_slice(&self.public_key_bincode, bincode::config::standard())?; + bincode::decode_from_slice(&self.public_key_bincode, blob::bounded_config())?; // Reject a valid-prefix + trailing-garbage payload (bincode stops // after the typed length); mirrors the outer blob::decode guard. if consumed != self.public_key_bincode.len() { @@ -156,13 +156,7 @@ pub fn load_state( while let Some(row) = rows.next()? { let identity_id_bytes: Vec = row.get(0)?; let key_id: i64 = row.get(1)?; - let len = usize::try_from(row.get::<_, i64>(2)?).unwrap_or(usize::MAX); - if len > blob::BLOB_SIZE_LIMIT_BYTES { - return Err(WalletStorageError::BlobTooLarge { - len_bytes: len, - limit_bytes: blob::BLOB_SIZE_LIMIT_BYTES, - }); - } + blob::check_size(row.get::<_, i64>(2)?)?; let payload: Vec = row.get(3)?; let id32 = <[u8; 32]>::try_from(identity_id_bytes.as_slice()).map_err(|_| { WalletStorageError::blob_decode("identity_keys.identity_id is not 32 bytes") @@ -191,6 +185,34 @@ pub fn load_state( Ok(cs) } +/// Build an outer `public_key_blob` payload whose inner `public_key_bincode` +/// field contains a crafted byte sequence that causes the inner +/// `blob::bounded_config()` decode to fail. Used by the blob-gate integration +/// test to prove the inner decode is bounded end-to-end. +/// +/// The outer blob is well within [`blob::BLOB_SIZE_LIMIT_BYTES`]; +/// only the *inner* decode path is stressed. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn crafted_entry_blob_with_bad_pk_bincode_for_test() -> Vec { + // 0xFC followed by four 0xFF bytes is bincode's 5-byte varint encoding + // for u32::MAX (4 294 967 295). Decoded as the first value inside + // IdentityPublicKey, this either triggers LimitExceeded (4 GB read + // attempt > 16 MiB bound) or an InvalidVariant — either way the decode + // fails without OOM-allocating. + let wire = IdentityKeyWire { + identity_id: dpp::prelude::Identifier::from([0xAAu8; 32]), + key_id: 0, + public_key_bincode: vec![0xFCu8, 0xFF, 0xFF, 0xFF, 0xFF], + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + // Intentionally unbounded outer encode — test setup only, not a + // production path. + bincode::serde::encode_to_vec(&wire, bincode::config::standard()) + .expect("test helper outer encode must not fail") +} + #[cfg(test)] mod tests { use super::*; @@ -255,11 +277,8 @@ mod tests { let wire = IdentityKeyWire { identity_id: Identifier::from([0xAAu8; 32]), // disagrees with the column key_id: 0, - public_key_bincode: bincode::encode_to_vec( - sample_public_key(), - bincode::config::standard(), - ) - .unwrap(), + public_key_bincode: bincode::encode_to_vec(sample_public_key(), blob::bounded_config()) + .unwrap(), public_key_hash: [0u8; 20], wallet_id: None, derivation_indices: None, @@ -283,11 +302,8 @@ mod tests { let wire = IdentityKeyWire { identity_id: Identifier::from(typed_identity), // matches the column key_id: 0, - public_key_bincode: bincode::encode_to_vec( - sample_public_key(), - bincode::config::standard(), - ) - .unwrap(), + public_key_bincode: bincode::encode_to_vec(sample_public_key(), blob::bounded_config()) + .unwrap(), public_key_hash: [0u8; 20], wallet_id: Some([0xDDu8; 32]), // disagrees with the read scope derivation_indices: None, @@ -316,7 +332,7 @@ mod tests { data: BinaryData::new(vec![2u8; 33]), disabled_at: None, }); - let mut pk_bincode = bincode::encode_to_vec(&pk, bincode::config::standard()).unwrap(); + let mut pk_bincode = bincode::encode_to_vec(&pk, blob::bounded_config()).unwrap(); pk_bincode.push(0xFF); // trailing garbage past the typed length let wire = IdentityKeyWire { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs index eb12d1d0e55..88d16f9038e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -14,6 +14,7 @@ use platform_wallet::wallet::{PerAccountPlatformAddressState, PerWalletPlatformA use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::accounts; +use crate::sqlite::schema::blob; use crate::sqlite::util::safe_cast; pub fn apply( @@ -110,23 +111,26 @@ pub fn list_per_wallet( conn: &Connection, wallet_id: &WalletId, ) -> Result, WalletStorageError> { + // length(address) is read first (O(1)) so an oversize or wrong-width + // address blob is caught before materializing the Vec. let mut stmt = conn.prepare( - "SELECT account_index, address_index, address, balance, nonce \ + "SELECT account_index, address_index, length(address), address, balance, nonce \ FROM platform_addresses WHERE wallet_id = ?1 \ ORDER BY account_index, address_index, address", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, Vec>(2)?, - row.get::<_, i64>(3)?, - row.get::<_, i64>(4)?, - )) - })?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out = Vec::new(); - for r in rows { - let (account_index, address_index, address_bytes, balance, nonce) = r?; + while let Some(row) = rows.next()? { + let account_index: i64 = row.get(0)?; + let address_index: i64 = row.get(1)?; + blob::check_fixed_width( + row.get::<_, i64>(2)?, + 20, + "platform_addresses.address is not 20 bytes", + )?; + let address_bytes: Vec = row.get(3)?; + let balance: i64 = row.get(4)?; + let nonce: i64 = row.get(5)?; out.push(decode_address_row( account_index, address_index, @@ -321,23 +325,26 @@ fn all_sync_state( fn all_address_rows( conn: &Connection, ) -> Result>, WalletStorageError> { + // length(address) is read first (O(1)) so an oversize or wrong-width + // address blob is caught before materializing the Vec. let mut stmt = conn.prepare( - "SELECT wallet_id, account_index, address_index, address, balance, nonce \ + "SELECT wallet_id, account_index, address_index, length(address), address, balance, nonce \ FROM platform_addresses ORDER BY wallet_id, account_index, address_index, address", )?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, Vec>(0)?, - row.get::<_, i64>(1)?, - row.get::<_, i64>(2)?, - row.get::<_, Vec>(3)?, - row.get::<_, i64>(4)?, - row.get::<_, i64>(5)?, - )) - })?; + let mut rows = stmt.query([])?; let mut out: BTreeMap> = BTreeMap::new(); - for r in rows { - let (wid_bytes, account_index, address_index, address_bytes, balance, nonce) = r?; + while let Some(row) = rows.next()? { + let wid_bytes: Vec = row.get(0)?; + let account_index: i64 = row.get(1)?; + let address_index: i64 = row.get(2)?; + blob::check_fixed_width( + row.get::<_, i64>(3)?, + 20, + "platform_addresses.address is not 20 bytes", + )?; + let address_bytes: Vec = row.get(4)?; + let balance: i64 = row.get(5)?; + let nonce: i64 = row.get(6)?; let wallet_id = wallet_id_from_bytes(&wid_bytes)?; out.entry(wallet_id).or_default().push(decode_address_row( account_index, diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs index 9d2a1558141..f736574a41d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs @@ -13,7 +13,7 @@ mod common; use common::{ensure_wallet_meta, fresh_persister, wid}; use rusqlite::params; -use platform_wallet_storage::sqlite::schema::{accounts, identities, identity_keys}; +use platform_wallet_storage::sqlite::schema::{accounts, core_state, identities, identity_keys}; use platform_wallet_storage::WalletStorageError; /// Blob larger than the 16 MiB cap: one byte over the limit is enough to @@ -22,6 +22,128 @@ fn oversize_blob() -> Vec { vec![0u8; platform_wallet_storage::SIZE_LIMIT_BYTES + 1] } +// ── core_state::load_state — last_applied_chain_lock ──────────────────────── + +/// An oversize `last_applied_chain_lock` blob is caught by the pre-read +/// `length()` gate in `core_state::load_state` and returned as `BlobTooLarge` +/// **before** the Vec is allocated. +#[test] +fn blob_gate_core_state_load_state_rejects_oversize_chain_lock() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + + let blob = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_sync_state \ + (wallet_id, last_processed_height, synced_height, last_applied_chain_lock) \ + VALUES (?1, 0, 0, ?2)", + params![w.as_slice(), blob.as_slice()], + ) + .expect("insert oversize chain_lock row"); + + let err = core_state::load_state(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_state must reject an oversize last_applied_chain_lock blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge, got {err:?}" + ); +} + +// ── platform_addrs — address column (fixed 20 bytes) ──────────────────────── + +/// A `platform_addresses` row whose `address` column is wider than 20 bytes +/// but within the BLOB cap is rejected with `BlobDecode` by the +/// `check_fixed_width` gate before the Vec is materialized. +#[test] +fn blob_gate_platform_addrs_load_all_rejects_wrong_width_address() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xD1); + ensure_wallet_meta(&persister, &w); + + // 21-byte address: wrong width, within size cap. + let bad_addr = vec![0x42u8; 21]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, ?2, 0, 0)", + params![w.as_slice(), bad_addr.as_slice()], + ) + .expect("insert wrong-width address row"); + + // load_all drives all_address_rows which has the check_fixed_width gate. + use platform_wallet_storage::sqlite::schema::platform_addrs; + let err = + platform_addrs::load_all(&conn).expect_err("load_all must reject a wrong-width address"); + assert!( + matches!( + err, + WalletStorageError::BlobDecode { .. } | WalletStorageError::BlobTooLarge { .. } + ), + "expected BlobDecode or BlobTooLarge for wrong-width address, got {err:?}" + ); +} + +/// A `platform_addresses` row whose `address` column exceeds the 16 MiB cap +/// is rejected with `BlobTooLarge` by the `check_fixed_width` gate. +#[test] +fn blob_gate_platform_addrs_load_all_rejects_oversize_address() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + + let oversize_addr = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO platform_addresses \ + (wallet_id, account_index, address_index, address, balance, nonce) \ + VALUES (?1, 0, 0, ?2, 0, 0)", + params![w.as_slice(), oversize_addr.as_slice()], + ) + .expect("insert oversize address row"); + + use platform_wallet_storage::sqlite::schema::platform_addrs; + let err = + platform_addrs::load_all(&conn).expect_err("load_all must reject an oversize address blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize address, got {err:?}" + ); +} + +// ── identity_keys — bounded inner public_key_bincode decode ────────────────── + +/// A `public_key_blob` that is small enough to pass the outer size gate but +/// contains a crafted `public_key_bincode` whose content causes the inner +/// `blob::bounded_config()` decode to fail deterministically, without +/// OOM-allocating. Proves the inner nested decode is end-to-end capped. +#[test] +fn blob_gate_identity_keys_bounded_inner_public_key_bincode() { + // Build an outer entry blob (tiny, within the 16 MiB gate) that wraps + // a public_key_bincode containing a huge-length varint. The test helper + // in identity_keys builds this without going through the bounded encode. + let crafted_blob = identity_keys::crafted_entry_blob_with_bad_pk_bincode_for_test(); + + assert!( + crafted_blob.len() < platform_wallet_storage::SIZE_LIMIT_BYTES, + "test blob must fit within the outer gate to exercise the inner path" + ); + + // decode_entry: outer blob::decode succeeds (small blob, valid serde wire); + // into_entry's inner decode fails on the crafted pk_bincode. + let err = identity_keys::decode_entry(&crafted_blob) + .expect_err("inner decode must fail on crafted public_key_bincode"); + assert!( + matches!( + err, + WalletStorageError::BincodeDecode { .. } | WalletStorageError::BlobTooLarge { .. } + ), + "expected bounded decode error, got {err:?}" + ); +} + // ── accounts::load_state — account_xpub_bytes ──────────────────────────────── /// An `account_registrations` row whose `account_xpub_bytes` blob exceeds the diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 809e1bda92d..e89fa0fa287 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -47,7 +47,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "platform_addrs.rs", - "SELECT wallet_id, account_index, address_index, address, balance, nonce", + "SELECT wallet_id, account_index, address_index, length(address), address, balance, nonce", ), // Pre-read `length()` gates added by PR #3968 review — substrings updated // to reflect the new `length()` column in each SELECT. @@ -76,7 +76,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "core_state.rs", - "SELECT last_processed_height, synced_height, last_applied_chain_lock FROM core_sync_state WHERE wallet_id", + "SELECT last_processed_height, synced_height,", ), ( "identity_keys.rs", From 6ba6d4a314fdf3ec3c0a2b9b89c4f02834d3b722 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 12:42:32 +0000 Subject: [PATCH 032/108] fix(platform-wallet-storage): SQLITE_LIMIT_LENGTH global blob floor + typed gate on unspent-UTXO reader (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Cargo.toml: enable rusqlite `limits` feature (required for set_limit / Limit API) * conn.rs: define SQLITE_MAX_BLOB_BYTES = 2 × SIZE_LIMIT_BYTES (32 MiB); call conn.set_limit(SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) in open_conn for every connection (both ReadWrite and ReadOnly). Global backstop caps all ungated columns (script, outpoint, wallet_id, txid, identity_id, etc.) without requiring per-column length() pre-reads. Per-column typed gates still fire first at 16 MiB; this floor covers the remaining ~11 sibling columns Marvin's audit flagged. * core_state.rs load_state UTXO reader: restructure query_map closure → prepare + query + while let Some(row) so BlobTooLarge can be returned early; add length(outpoint) (col 0) and length(script) (col 3) to the SELECT; blob::check_size gate each before materializing Vec. * Tests: 2 new blob-gate integration tests: - connection_has_sqlite_limit_length_set: verifies SQLITE_LIMIT_LENGTH = 32 MiB - blob_gate_core_utxos_load_state_rejects_oversize_script: oversize script → BlobTooLarge * sqlite_compile_time.rs: add allow-list entry for new length()-prefixed UTXO SELECT 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- .../rs-platform-wallet-storage/Cargo.toml | 1 + .../src/sqlite/conn.rs | 22 ++++++++ .../src/sqlite/schema/core_state.rs | 27 ++++++---- .../tests/sqlite_blob_size_gate_on_load.rs | 53 +++++++++++++++++++ .../tests/sqlite_compile_time.rs | 6 +++ 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index d61796bc8e0..99524b6ca8a 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -46,6 +46,7 @@ rusqlite = { version = "0.38", features = [ "backup", "blob", "hooks", + "limits", "trace", ], optional = true } refinery = { version = "0.9", default-features = false, features = [ diff --git a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs index e136e34a6c5..623635dae93 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs @@ -7,11 +7,27 @@ //! `peek_schema_version` probe opens directly (no mutations, and //! `open_conn` is `pub(crate)`, unreachable from the bin target). +use rusqlite::limits::Limit; use rusqlite::{Connection, OpenFlags}; use std::path::Path; use crate::sqlite::error::WalletStorageError; +/// Global per-connection BLOB / string length ceiling applied to every +/// connection opened by this crate via [`open_conn`]. +/// +/// Value: **2 × [`crate::SIZE_LIMIT_BYTES`]** (= 32 MiB), giving one stop +/// of headroom above the typed per-column cap so that per-column gates (which +/// fire at 16 MiB via [`check_size`](crate::sqlite::schema::blob::check_size)) +/// still take precedence on explicitly gated columns while this backstop caps +/// ALL other columns — `script`, `outpoint`, `wallet_id`, `txid`, +/// `identity_id`, etc. — that carry no individual `length()` pre-read gate. +/// SQLite's compile-time default is ~1 GiB per string/BLOB/row; this reduces +/// it to 32 MiB for every connection opened by this crate, blocking a +/// tampered wallet DB from forcing multi-hundred-MiB heap allocations on +/// ungated columns. +pub(crate) const SQLITE_MAX_BLOB_BYTES: i32 = (crate::SIZE_LIMIT_BYTES * 2) as i32; + /// Magic stamped into the SQLite header `application_id` (offset 68) by /// `V001__initial`. ASCII `"PLWT"` (Platform Wallet) big-endian. A /// refinery-versioned DB whose `application_id` does not equal this is a @@ -60,6 +76,12 @@ pub(crate) fn open_conn(path: &Path, access: Access) -> Result Connection::open(path)?, Access::ReadOnly => Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)?, }; + // Hard-cap every string/BLOB column at SQLITE_MAX_BLOB_BYTES (32 MiB). + // Per-column typed gates (check_size / check_fixed_width) still fire first + // on explicitly gated columns because their cap (16 MiB) is smaller. + // This backstop covers the rest without requiring individual `length()` + // pre-reads on every column in every reader. + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES)?; if access == Access::ReadWrite { enforce_foreign_keys(&conn)?; } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 64284dea8d5..9d81eddbf70 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -296,20 +296,25 @@ pub fn load_state( let mut cs = CoreChangeSet::default(); // Unspent UTXOs → new_utxos (the balance source). + // Pre-read `length()` gates on `outpoint` and `script` before materializing + // the Vec so tampered oversize values are caught before heap allocation. + // Uses `prepare + query + while let` (not `query_map`) so the typed + // `BlobTooLarge` error can be returned from the loop body directly. { let mut stmt = conn.prepare( - "SELECT outpoint, value, script, height FROM core_utxos \ - WHERE wallet_id = ?1 AND spent = 0", + "SELECT length(outpoint), outpoint, value, length(script), script, height \ + FROM core_utxos WHERE wallet_id = ?1 AND spent = 0", )?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - let op: Vec = row.get(0)?; - let value: i64 = row.get(1)?; - let script: Vec = row.get(2)?; - let height: Option = row.get(3)?; - Ok((op, value, script, height)) - })?; - for r in rows { - let (op_bytes, value, script_bytes, height) = r?; + let mut rows = stmt.query(params![wallet_id.as_slice()])?; + while let Some(row) = rows.next()? { + // col 0: length(outpoint) — gate before materializing + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let value: i64 = row.get(2)?; + // col 3: length(script) — gate before materializing + blob::check_size(row.get::<_, i64>(3)?)?; + let script_bytes: Vec = row.get(4)?; + let height: Option = row.get(5)?; let outpoint = blob::decode_outpoint(&op_bytes)?; let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; let height_u32 = match height { diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs index f736574a41d..3927d3ee2e5 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs @@ -22,6 +22,59 @@ fn oversize_blob() -> Vec { vec![0u8; platform_wallet_storage::SIZE_LIMIT_BYTES + 1] } +// ── global SQLITE_LIMIT_LENGTH backstop ───────────────────────────────────── + +/// Every connection opened by this crate via `open_conn` must have +/// `SQLITE_LIMIT_LENGTH` set to `SQLITE_MAX_BLOB_BYTES` (32 MiB). This +/// confirms the global backstop is applied even before any per-column gate. +#[test] +fn connection_has_sqlite_limit_length_set() { + use rusqlite::limits::Limit; + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + // SQLITE_MAX_BLOB_BYTES = 2 × SIZE_LIMIT_BYTES = 32 MiB. + let expected = (platform_wallet_storage::SIZE_LIMIT_BYTES as i64 * 2) as i32; + let actual = conn + .limit(Limit::SQLITE_LIMIT_LENGTH) + .expect("SQLITE_LIMIT_LENGTH must be readable"); + assert_eq!( + actual, expected, + "connection must have SQLITE_LIMIT_LENGTH = {expected} (32 MiB), got {actual}" + ); +} + +// ── core_state::load_state — core_utxos script ────────────────────────────── + +/// An oversize `script` blob in `core_utxos` is caught by the pre-read +/// `length(script)` gate in `core_state::load_state` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_utxos_load_state_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xF1); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + // A 33-byte outpoint: bincode encodes txid(32 bytes) + vout(1 byte for 0). + // The outpoint gate passes (33 bytes << 16 MiB); only the script gate fires. + let tiny_op = vec![0u8; 33]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, ?3, NULL, 0, 0, NULL)", + params![w.as_slice(), tiny_op.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize script row"); + + let err = core_state::load_state(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_state must reject an oversize script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize script, got {err:?}" + ); +} + // ── core_state::load_state — last_applied_chain_lock ──────────────────────── /// An oversize `last_applied_chain_lock` blob is caught by the pre-read diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index e89fa0fa287..2b41a43aa63 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -59,7 +59,13 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "accounts.rs", "SELECT wallet_id, account_index, length(account_xpub_bytes), account_xpub_bytes", ), + // list_unspent_utxos (test-helper reader, ungated — global SQLITE_LIMIT_LENGTH covers it). ("core_state.rs", "SELECT outpoint, value, script, height"), + // load_state unspent-UTXO reader: pre-read length() gates on outpoint and script. + ( + "core_state.rs", + "SELECT length(outpoint), outpoint, value, length(script), script, height", + ), ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( From 53b7d26de3e4d3f2e59ad727d99dbe7c4c45c283 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:02:18 +0000 Subject: [PATCH 033/108] chore(platform-wallet-storage): compile-time assert SQLITE_MAX_BLOB_BYTES fits i32 (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guards against a future increase to SIZE_LIMIT_BYTES silently wrapping the `as i32` cast in SQLITE_MAX_BLOB_BYTES and turning the global blob backstop into a no-op. The assert fires at compile time with a descriptive message, so widening SIZE_LIMIT_BYTES beyond ~1 GiB is a build error rather than a silent security regression. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 4.6 --- packages/rs-platform-wallet-storage/src/sqlite/conn.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs index 623635dae93..74b95d4336a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/conn.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/conn.rs @@ -28,6 +28,15 @@ use crate::sqlite::error::WalletStorageError; /// ungated columns. pub(crate) const SQLITE_MAX_BLOB_BYTES: i32 = (crate::SIZE_LIMIT_BYTES * 2) as i32; +// Compile-time guard: the `as i32` cast above is lossless only while +// SIZE_LIMIT_BYTES ≤ i32::MAX / 2 (~1 GiB). Widening SIZE_LIMIT_BYTES +// beyond that would silently truncate the limit, turning the backstop into +// a no-op. This assertion makes such a change a compile error instead. +const _: () = assert!( + crate::SIZE_LIMIT_BYTES <= (i32::MAX as usize) / 2, + "SQLITE_MAX_BLOB_BYTES would overflow i32 — lower SIZE_LIMIT_BYTES or widen the limit type", +); + /// Magic stamped into the SQLite header `application_id` (offset 68) by /// `V001__initial`. ASCII `"PLWT"` (Platform Wallet) big-endian. A /// refinery-versioned DB whose `application_id` does not equal this is a 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 034/108] 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 035/108] 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 036/108] 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 037/108] 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 038/108] 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 039/108] 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 040/108] 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 041/108] 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 042/108] 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 043/108] 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 044/108] 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 045/108] 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 046/108] 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 047/108] 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 048/108] 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 049/108] 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 050/108] 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 051/108] 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 052/108] 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 053/108] 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 054/108] 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 055/108] 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 056/108] 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 057/108] 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 058/108] feat(platform-wallet)!: 3-state LoadOutcome, report already-registered as skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn LoadOutcome from a struct into a 3-state enum — Loaded (full), Partial (some loaded, some skipped), NoneUsable (rows present, all skipped) — so callers can tell a clean load from one that left wallets behind. Add `impl From for Result` (partial and nothing-usable map to the new PlatformWalletError::LoadIncomplete) per reviewer request, plus loaded()/skipped() accessors. An already-registered wallet (idempotency check, or WalletExists at insert) is now reported as a SkipReason::AlreadyRegistered skip instead of being silently folded into loaded, so the caller can distinguish "not freshly loaded" from a genuine load. FFI: adapt manager.rs to the accessor API and add reason code 300 (already-registered); LoadOutcomeFFI count pair encodes the 3-state. Swift: fix stale-skip-state bug — assign lastLoadSkippedWallets before the throwing check() so a failed load clears prior skip state; map reason code 300. BREAKING CHANGE: LoadOutcome is now an enum; its loaded/skipped fields become accessor methods. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/manager.rs | 37 ++++-- packages/rs-platform-wallet/src/error.rs | 16 +++ .../rs-platform-wallet/src/manager/load.rs | 79 +++++++----- .../src/manager/load_outcome.rs | 119 ++++++++++++++---- .../tests/rehydration_load.rs | 83 +++++++----- .../PlatformWalletManager.swift | 31 +++-- 6 files changed, 256 insertions(+), 109 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 65a110dca70..8e165d2ea18 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -193,6 +193,10 @@ pub const LOAD_SKIP_REASON_CORRUPT_OTHER: u32 = 199; /// `reason_code`: an unrecognized `SkipReason` — forward-compat /// fallback until this crate maps a newly added skip reason. pub const LOAD_SKIP_REASON_OTHER: u32 = 200; +/// `reason_code`: the wallet was already registered before this load +/// pass reached it (a prior load, or a runtime-created wallet), so its +/// persisted row was not freshly loaded. Not corruption. +pub const LOAD_SKIP_REASON_ALREADY_REGISTERED: u32 = 300; /// One wallet skipped during `load_from_persistor` because its /// persisted row was structurally corrupt (per-row decode failure). @@ -203,14 +207,15 @@ pub const LOAD_SKIP_REASON_OTHER: u32 = 200; pub struct SkippedWalletFFI { /// The (public) 32-byte wallet id that was skipped. pub wallet_id: [u8; 32], - /// Structural skip reason — one of the `LOAD_SKIP_REASON_*` - /// constants: [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100), + /// Skip reason — one of the `LOAD_SKIP_REASON_*` constants: + /// [`LOAD_SKIP_REASON_MISSING_MANIFEST`] (100), /// [`LOAD_SKIP_REASON_MALFORMED_XPUB`] (101), /// [`LOAD_SKIP_REASON_DECODE_ERROR`] (102), /// [`LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH`] (103), - /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), or - /// [`LOAD_SKIP_REASON_OTHER`] (200). No secret material is ever - /// carried. + /// [`LOAD_SKIP_REASON_CORRUPT_OTHER`] (199), + /// [`LOAD_SKIP_REASON_OTHER`] (200), or + /// [`LOAD_SKIP_REASON_ALREADY_REGISTERED`] (300). No secret material + /// is ever carried. pub reason_code: u32, } @@ -218,6 +223,10 @@ pub struct SkippedWalletFFI { /// see which wallets loaded and which were skipped (and why) instead /// of the outcome being silently discarded. /// +/// The count pair encodes the Rust `LoadOutcome` 3-state: `skipped_count +/// == 0` is a full load, `loaded_count == 0` with skips is +/// nothing-usable, and both non-zero is a partial load. +/// /// `skipped` is a heap array of length `skipped_count`; pass this /// struct (by pointer) to /// [`platform_wallet_load_outcome_free`] exactly once to release it. @@ -245,6 +254,7 @@ fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { // generic corrupt-row code until this mapping is extended. _ => LOAD_SKIP_REASON_CORRUPT_OTHER, }, + platform_wallet::SkipReason::AlreadyRegistered => LOAD_SKIP_REASON_ALREADY_REGISTERED, // `SkipReason` is #[non_exhaustive]; a future reason maps to a // generic skip code until this mapping is extended. _ => LOAD_SKIP_REASON_OTHER, @@ -420,23 +430,26 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( // Never silently drop the outcome: log a structured summary plus // one line per skipped wallet (the host can inspect / clear the - // corrupt rows). + // corrupt rows). The `loaded_count`/`skipped_count` pair below + // encodes the Rust `LoadOutcome` 3-state for the host: skipped == 0 + // is a full load, loaded == 0 with skips is nothing-usable, and both + // non-zero is a partial load. tracing::info!( - loaded = outcome.loaded.len(), - skipped = outcome.skipped.len(), + loaded = outcome.loaded().len(), + skipped = outcome.skipped().len(), "platform_wallet_manager_load_from_persistor complete" ); - for (wid, reason) in &outcome.skipped { + for (wid, reason) in outcome.skipped() { tracing::warn!( wallet_id = %hex::encode(wid), reason = %reason, - "load_from_persistor skipped wallet (corrupt persisted row)" + "load_from_persistor skipped a persisted wallet" ); } if !out_outcome.is_null() { let skipped_vec: Vec = outcome - .skipped + .skipped() .iter() .map(|(wid, reason)| SkippedWalletFFI { wallet_id: *wid, @@ -448,7 +461,7 @@ pub unsafe extern "C" fn platform_wallet_manager_load_from_persistor( std::ptr::write( out_outcome, LoadOutcomeFFI { - loaded_count: outcome.loaded.len(), + loaded_count: outcome.loaded().len(), skipped_count, skipped: skipped_ptr, }, diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index f7097690a3c..0c6a04142d2 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -23,6 +23,22 @@ pub enum PlatformWalletError { #[error("failed to load persisted client state: {0}")] PersisterLoad(#[from] crate::changeset::PersistenceError), + /// `load_from_persistor` finished without loading every persisted + /// wallet: `loaded_count` loaded and `skipped` were passed over (a + /// corrupt row, or one already registered). Produced only by + /// converting a non-`Loaded` [`LoadOutcome`] via `Result::from`; the + /// load call itself returns the richer [`LoadOutcome`] directly so + /// callers that tolerate a partial load keep the per-wallet detail. + /// + /// [`LoadOutcome`]: crate::manager::load_outcome::LoadOutcome + #[error("persisted wallet load incomplete: {loaded_count} loaded, {} skipped", skipped.len())] + LoadIncomplete { + /// How many wallets loaded before the skips. + loaded_count: usize, + /// The skipped `(wallet_id, reason)` set, in load order. + skipped: Vec<([u8; 32], crate::manager::load_outcome::SkipReason)>, + }, + /// The persisted wallet has UTXOs to restore but no funds-bearing /// account in its reconstructed account collection to hold them. /// Fail-closed rather than reconstructing a silent zero balance — diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 48f5ee66367..b88701c056b 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -36,24 +36,31 @@ impl PlatformWalletManager

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

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

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

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

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

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

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

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

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

{ /// check or as `WalletExists` at insert): the wallet is **skipped** /// with [`SkipReason::AlreadyRegistered`] and left untouched — kept /// out of the rollback set so a later hard-fail never evicts it. A - /// second `load_from_persistor` is therefore idempotent, and the - /// caller can tell an already-present wallet from one freshly loaded. + /// second `load_from_persistor` therefore mutates no state and returns + /// `Ok(LoadOutcome)` — a repeat where every wallet is already + /// registered is a [`NoneUsable`](LoadOutcome::NoneUsable) no-op, not a + /// failure — and the caller can tell an already-present wallet from one + /// freshly loaded via [`loaded`](LoadOutcome::loaded) / + /// [`skipped`](LoadOutcome::skipped). /// - **Whole-load failure** (persister I/O, programmer error, /// registering a persisted wallet in `WalletManager`): /// `Err(_)` — every wallet inserted earlier in this pass is diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs index a2fea9e29e1..6c284544e72 100644 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -2,7 +2,6 @@ //! //! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor -use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; /// Why a persisted wallet row was passed over during a load pass. @@ -88,11 +87,11 @@ impl std::fmt::Display for CorruptKind { /// call). The load path is watch-only and never touches the seed, so no /// wrong-seed outcome appears here. /// -/// Convert into a [`Result`] with `Result::from` / `.into()` when an -/// incomplete load should read as a failure: [`Loaded`](Self::Loaded) -/// maps to `Ok`, while [`Partial`](Self::Partial) and -/// [`NoneUsable`](Self::NoneUsable) map to -/// [`PlatformWalletError::LoadIncomplete`]. +/// Inspect the outcome via [`loaded`](Self::loaded) / [`skipped`](Self::skipped): +/// the skip reasons are what separate a harmless repeat +/// ([`AlreadyRegistered`](SkipReason::AlreadyRegistered)) from genuine +/// corruption ([`CorruptPersistedRow`](SkipReason::CorruptPersistedRow)) — a +/// distinction a flat `Result` shape would erase. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum LoadOutcome { @@ -145,22 +144,3 @@ impl LoadOutcome { } } } - -impl From for Result { - /// A partial or nothing-usable load converts to `Err` so a caller - /// using `?` treats an incomplete load as a failure; a full load is - /// `Ok`. - fn from(outcome: LoadOutcome) -> Self { - match outcome { - LoadOutcome::Loaded { .. } => Ok(outcome), - LoadOutcome::Partial { loaded, skipped } => Err(PlatformWalletError::LoadIncomplete { - loaded_count: loaded.len(), - skipped, - }), - LoadOutcome::NoneUsable { skipped } => Err(PlatformWalletError::LoadIncomplete { - loaded_count: 0, - skipped, - }), - } - } -} From 3abc3858f0b49871b22e64c86b0f81e21560aa9f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:49:40 +0000 Subject: [PATCH 064/108] refactor(platform-wallet): make manager::rehydrate module private The module's only non-test item is `pub(super) fn build_watch_only_wallet`, used solely from `manager::load`, so it exposes nothing externally usable. Narrow the declaration to `mod rehydrate;`, matching the private sibling `mod load;` / `mod wallet_lifecycle;`. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/manager/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 57fa3784348..4438cd8934a 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -5,7 +5,7 @@ pub mod identity_sync; mod load; pub mod load_outcome; pub mod platform_address_sync; -pub mod rehydrate; +mod rehydrate; #[cfg(feature = "shielded")] pub mod shielded_sync; mod wallet_lifecycle; From 83d9effc555d1646195da071fa083dfb6922eb44 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:06:06 +0000 Subject: [PATCH 065/108] fix(dpp): drop stale extended_public_key impls broken by rust-dashcore afcff156 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three FixedKeySigner test mocks implemented key_wallet::signer::Signer's extended_public_key, which afcff156 moved into a separate ExtendedPubKeySigner subtrait — the same break already fixed in rs-sdk-ffi's mnemonic_resolver_core_signer.rs, missed here because the post-merge verification was scoped to rs-platform-wallet/-ffi/rs-sdk-ffi and never ran a workspace-wide check. Nothing in rs-dpp calls .extended_public_key() on these mocks or requires ExtendedPubKeySigner, so the method is dead weight, not a missing impl — deleted outright along with the now-unused ExtendedPubKey imports. Co-Authored-By: Claude Opus 4.5 --- packages/rs-dpp/src/state_transition/mod.rs | 11 +---------- .../signing_tests.rs | 11 +---------- .../signing_tests.rs | 11 +---------- 3 files changed, 3 insertions(+), 30 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 74ebc13c977..92bf4a4e583 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -3335,7 +3335,7 @@ mod tests { use dashcore::secp256k1::{ ecdsa, rand::rngs::OsRng, Message, PublicKey, Secp256k1, SecretKey, }; - use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; + use key_wallet::bip32::DerivationPath; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory signer used only by this test. Mirrors how a @@ -3370,15 +3370,6 @@ mod tests { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } - - async fn extended_public_key( - &self, - _path: &DerivationPath, - ) -> Result { - // Test stub holds a single raw key with no chain code; extended - // public key derivation is not meaningful here. - Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) - } } // Generate a single random key. Using the same key on both sides is diff --git a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs index b2c4fb67f83..67f95088409 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/address_funds/address_funding_from_asset_lock_transition/signing_tests.rs @@ -204,7 +204,7 @@ async fn try_from_asset_lock_with_signer_and_private_key_signs_multiple_inputs() async fn try_from_asset_lock_with_signers_produces_matching_signature() { use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message}; - use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; + use key_wallet::bip32::DerivationPath; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how the @@ -237,15 +237,6 @@ async fn try_from_asset_lock_with_signers_produces_matching_signature() { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } - - async fn extended_public_key( - &self, - _path: &DerivationPath, - ) -> Result { - // Test stub holds a single raw key with no chain code; extended - // public key derivation is not meaningful here. - Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) - } } let secp = Secp256k1::new(); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 5ea8e633b3a..c31e3b1fc1e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -33,7 +33,7 @@ use platform_version::version::PlatformVersion; use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1, SecretKey}; -use key_wallet::bip32::{DerivationPath, ExtendedPubKey}; +use key_wallet::bip32::DerivationPath; use key_wallet::signer::{Signer as KwSigner, SignerMethod}; /// Fixed-key in-memory `key_wallet::signer::Signer`. Mirrors how a @@ -77,15 +77,6 @@ impl KwSigner for FixedKeySigner { async fn public_key(&self, _path: &DerivationPath) -> Result { Ok(self.public) } - - async fn extended_public_key( - &self, - _path: &DerivationPath, - ) -> Result { - // Test stub holds a single raw key with no chain code; extended - // public key derivation is not meaningful here. - Err("FixedKeySigner: no chain code — extended_public_key not supported".to_string()) - } } fn make_chain_asset_lock_proof() -> AssetLockProof { From 72469469fe0a4d7c78e45a86dbbef46131a0f517 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:17:20 +0000 Subject: [PATCH 066/108] feat(platform-wallet): add IdentityManagerStartState::merge_contacts_and_keys Domain-side routing that folds persisted PUBLIC keys + contact state onto the already-built managed identities so `Identity.public_keys` and the contact maps are populated at load time (pre-keyed shape), across BOTH the wallet and out-of-wallet buckets. Orphan entries (owner not loaded) are logged and skipped; `removed_*` are ignored (rehydration feed is insert-only); no `Network` needed. Reuse the existing DPP `IdentityGettersV0::add_public_key` accessor for the key insert and refactor the replay path `apply_identity_key_entry` onto it, dropping the clone-the-whole-map idiom (one routing primitive, shared). Unit tests: key routed into wallet + out-of-wallet identities, contacts to the correct maps, no cross-identity leakage, orphan skip, empty no-op. Co-Authored-By: Claude Opus 4.8 --- .../changeset/identity_manager_start_state.rs | 340 +++++++++++++++++- .../wallet/identity/state/manager/apply.rs | 9 +- 2 files changed, 340 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs index fbb42fa9e09..f1018225000 100644 --- a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs @@ -4,10 +4,12 @@ //! struct — no methods, no invariants, no live handles — so persisters //! can round-trip it without dragging in the manager's business logic. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; +use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; +use crate::changeset::{ContactChangeSet, IdentityKeysChangeSet}; use crate::wallet::identity::ManagedIdentity; use crate::wallet::identity::RegistrationIndex; use crate::wallet::platform_wallet::WalletId; @@ -27,3 +29,339 @@ pub struct IdentityManagerStartState { /// inner-keyed by BIP-9 registration index. pub wallet_identities: BTreeMap>, } + +impl IdentityManagerStartState { + /// Fold persisted PUBLIC keys and contact state into the already-built + /// managed identities so `Identity.public_keys` and the contact maps + /// are populated at load time, matching the FFI persister's pre-keyed + /// bucket shape (each `ManagedIdentity` carries its own keys/contacts + /// with no separate changeset layered on afterwards). + /// + /// Each entry is routed by owner `identity_id` across BOTH buckets; + /// keys are applied before contacts so a contact never lands before + /// its owner's keys. Entries whose owner is absent from either bucket + /// (e.g. a tombstoned identity's orphaned rows) are logged and + /// skipped, never fatal. `removed_*` are ignored — the rehydration + /// feed is insert-only. No `Network` is needed: the key insert is + /// network-independent. + pub fn merge_contacts_and_keys( + &mut self, + contacts: ContactChangeSet, + identity_keys: IdentityKeysChangeSet, + ) { + // One transient id → &mut ManagedIdentity view over both buckets so + // routing is O(1) per entry rather than a per-entry bucket scan. The + // two buckets are disjoint fields, so their mutable borrows coexist. + let mut by_id: HashMap = HashMap::new(); + for managed in self.out_of_wallet_identities.values_mut() { + by_id.insert(managed.identity.id(), managed); + } + for inner in self.wallet_identities.values_mut() { + for managed in inner.values_mut() { + by_id.insert(managed.identity.id(), managed); + } + } + + for (_key, entry) in identity_keys.upserts { + match by_id.get_mut(&entry.identity_id) { + Some(managed) => managed.identity.add_public_key(entry.public_key), + None => tracing::warn!( + identity = %entry.identity_id, + key_id = entry.key_id, + "skipping identity key during rehydration merge: owner identity not loaded" + ), + } + } + for (key, entry) in contacts.sent_requests { + match by_id.get_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 rehydration merge: owner identity not loaded" + ), + } + } + for (key, entry) in contacts.incoming_requests { + match by_id.get_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 rehydration merge: owner identity not loaded" + ), + } + } + for (key, established) in contacts.established { + match by_id.get_mut(&key.owner_id) { + Some(managed) => managed.apply_established_contact(established), + None => tracing::warn!( + owner = %key.owner_id, + "skipping established contact during rehydration merge: owner identity not loaded" + ), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::{ + ContactRequestEntry, IdentityKeyEntry, ReceivedContactRequestKey, SentContactRequestKey, + }; + use crate::wallet::identity::{ContactRequest, EstablishedContact}; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + + fn identity(id_byte: u8) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from([id_byte; 32]), + public_keys: BTreeMap::new(), + balance: 1_000, + revision: 1, + }) + } + + fn wallet_identity(state: &mut IdentityManagerStartState, w: WalletId, idx: u32, id_byte: u8) { + let mut managed = ManagedIdentity::new(identity(id_byte), idx); + managed.wallet_id = Some(w); + state + .wallet_identities + .entry(w) + .or_default() + .insert(idx, managed); + } + + fn key_entry( + id_byte: u8, + key_id: u32, + data_byte: u8, + security: SecurityLevel, + ) -> IdentityKeyEntry { + IdentityKeyEntry { + identity_id: Identifier::from([id_byte; 32]), + key_id, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level: security, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![data_byte; 33]), + disabled_at: None, + }), + public_key_hash: [data_byte; 20], + wallet_id: None, + derivation_indices: None, + } + } + + fn keys(entries: impl IntoIterator) -> IdentityKeysChangeSet { + let mut cs = IdentityKeysChangeSet::default(); + for e in entries { + cs.upserts.insert((e.identity_id, e.key_id), e); + } + cs + } + + fn request(sender: u8, recipient: u8) -> ContactRequest { + ContactRequest { + sender_id: Identifier::from([sender; 32]), + recipient_id: Identifier::from([recipient; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: vec![7; 96], + auto_accept_proof: None, + core_height_created_at: 11, + created_at: 22, + } + } + + /// A key upsert whose owner is a wallet-bucket identity lands in that + /// identity's `public_keys` map, keyed by `KeyID`. + #[test] + fn merge_routes_key_into_wallet_identity() { + let w: WalletId = [0xAA; 32]; + let mut state = IdentityManagerStartState::default(); + wallet_identity(&mut state, w, 5, 0x01); + + state.merge_contacts_and_keys( + ContactChangeSet::default(), + keys([key_entry(0x01, 0, 0xAB, SecurityLevel::HIGH)]), + ); + + let managed = &state.wallet_identities[&w][&5]; + let pk = managed + .identity + .public_keys() + .get(&0) + .expect("key routed onto identity"); + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + assert_eq!(pk.data().as_slice(), &[0xAB; 33]); + } + + /// Out-of-wallet identities are covered by the merge too — a naive + /// implementation that only walked `wallet_identities` would drop + /// their keys. + #[test] + fn merge_routes_key_into_out_of_wallet_identity() { + let id = Identifier::from([0x02; 32]); + let mut state = IdentityManagerStartState::default(); + state + .out_of_wallet_identities + .insert(id, ManagedIdentity::new_out_of_wallet(identity(0x02))); + + state.merge_contacts_and_keys( + ContactChangeSet::default(), + keys([key_entry(0x02, 3, 0xCD, SecurityLevel::CRITICAL)]), + ); + + assert!(state.out_of_wallet_identities[&id] + .identity + .public_keys() + .contains_key(&3)); + } + + /// Sent / incoming / established contacts each route to their own map. + #[test] + fn merge_routes_contacts_to_correct_maps() { + let w: WalletId = [0xAA; 32]; + let owner = 0x01; + let mut state = IdentityManagerStartState::default(); + wallet_identity(&mut state, w, 0, owner); + + let owner_id = Identifier::from([owner; 32]); + let mut contacts = ContactChangeSet::default(); + contacts.sent_requests.insert( + SentContactRequestKey { + owner_id, + recipient_id: Identifier::from([0x22; 32]), + }, + ContactRequestEntry { + request: request(owner, 0x22), + }, + ); + contacts.incoming_requests.insert( + ReceivedContactRequestKey { + owner_id, + sender_id: Identifier::from([0x33; 32]), + }, + ContactRequestEntry { + request: request(0x33, owner), + }, + ); + let contact_c = Identifier::from([0x44; 32]); + contacts.established.insert( + SentContactRequestKey { + owner_id, + recipient_id: contact_c, + }, + EstablishedContact { + contact_identity_id: contact_c, + outgoing_request: request(owner, 0x44), + incoming_request: request(0x44, owner), + alias: Some("c".into()), + note: None, + is_hidden: false, + accepted_accounts: vec![1], + }, + ); + + state.merge_contacts_and_keys(contacts, IdentityKeysChangeSet::default()); + + let managed = &state.wallet_identities[&w][&0]; + assert!(managed + .sent_contact_requests + .contains_key(&Identifier::from([0x22; 32]))); + assert!(managed + .incoming_contact_requests + .contains_key(&Identifier::from([0x33; 32]))); + assert!(managed.established_contacts.contains_key(&contact_c)); + } + + /// Two identities with the same numeric `KeyID` but different owners + /// keep disjoint key maps — the group-by must not misattribute. + #[test] + fn merge_does_not_leak_keys_across_identities() { + let w: WalletId = [0xAA; 32]; + let mut state = IdentityManagerStartState::default(); + wallet_identity(&mut state, w, 0, 0x01); + wallet_identity(&mut state, w, 1, 0x02); + + state.merge_contacts_and_keys( + ContactChangeSet::default(), + keys([ + key_entry(0x01, 0, 0xA0, SecurityLevel::HIGH), + key_entry(0x02, 0, 0xB0, SecurityLevel::HIGH), + ]), + ); + + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + let a = &state.wallet_identities[&w][&0]; + let b = &state.wallet_identities[&w][&1]; + assert_eq!(a.identity.public_keys().len(), 1); + assert_eq!(b.identity.public_keys().len(), 1); + assert_eq!(a.identity.public_keys()[&0].data().as_slice(), &[0xA0; 33]); + assert_eq!(b.identity.public_keys()[&0].data().as_slice(), &[0xB0; 33]); + } + + /// An entry whose owner is absent from both buckets is logged and + /// skipped — never a panic. + #[test] + fn merge_skips_orphan_entries() { + let w: WalletId = [0xAA; 32]; + let mut state = IdentityManagerStartState::default(); + wallet_identity(&mut state, w, 0, 0x01); + + let mut contacts = ContactChangeSet::default(); + contacts.sent_requests.insert( + SentContactRequestKey { + owner_id: Identifier::from([0xEE; 32]), + recipient_id: Identifier::from([0xFF; 32]), + }, + ContactRequestEntry { + request: request(0xEE, 0xFF), + }, + ); + // Key for an identity that isn't present in either bucket. + state.merge_contacts_and_keys( + contacts, + keys([key_entry(0xEE, 0, 0x01, SecurityLevel::HIGH)]), + ); + + let managed = &state.wallet_identities[&w][&0]; + assert!(managed.identity.public_keys().is_empty()); + assert!(managed.sent_contact_requests.is_empty()); + } + + /// Empty changesets are a no-op. + #[test] + fn merge_empty_changesets_is_noop() { + let w: WalletId = [0xAA; 32]; + let mut state = IdentityManagerStartState::default(); + wallet_identity(&mut state, w, 0, 0x01); + + state.merge_contacts_and_keys( + ContactChangeSet::default(), + IdentityKeysChangeSet::default(), + ); + + let managed = &state.wallet_identities[&w][&0]; + assert!(managed.identity.public_keys().is_empty()); + assert!(managed.sent_contact_requests.is_empty()); + assert!(managed.incoming_contact_requests.is_empty()); + assert!(managed.established_contacts.is_empty()); + } +} 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 09dd930c0cc..84b8a485657 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 @@ -18,7 +18,6 @@ use super::{IdentityLocation, IdentityManager}; 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; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, KeyID}; use dpp::prelude::Identifier; @@ -154,16 +153,10 @@ impl IdentityManager { /// keys changeset was persisted without its scalar sibling, or the /// owner was removed since), the entry is logged and skipped. pub(crate) fn apply_identity_key_entry(&mut self, entry: IdentityKeyEntry, _network: Network) { - // `add_public_key` lives on `IdentityFactory` / V0 setter trait; - // bring it into scope here. - use dpp::identity::accessors::IdentitySettersV0; - if let Some(managed) = self.locate_mut(&entry.identity_id) { // Insert into the DPP `Identity`'s `public_keys` map by id; // replay-safe (idempotent overwrite). - let mut keys = managed.identity.public_keys().clone(); - keys.insert(entry.public_key.id(), entry.public_key.clone()); - managed.identity.set_public_keys(keys); + managed.identity.add_public_key(entry.public_key); } else { tracing::warn!( identity = %entry.identity_id, From 46f765ce423854f64f0da08c1a8a329175334798 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:17:32 +0000 Subject: [PATCH 067/108] feat(platform-wallet-storage): pre-key identities in SQLite load() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `schema::identities::load_prekeyed`, an orchestrator that reads the identities, then folds this wallet's persisted identity keys and contacts onto them via `merge_contacts_and_keys` — each ManagedIdentity leaves the loader already carrying its own public keys + contact state, so Platform signing works immediately post-load with no key sync. Rewire `persister.rs::load()` onto it and leave `ClientWalletStartState.contacts` / `.identity_keys` at `Default` (empty), matching the FFI persister: the manager's `apply_contacts_and_keys(empty, empty)` becomes a harmless no-op, so this PR is correct and mergeable in either order relative to #3692's field removal. Also set the required `core_wallet_info: None` (SQLite reconstructs core state from typed rows). Schema test: load_prekeyed populates keys across both identity buckets. Co-Authored-By: Claude Opus 4.8 --- .../src/sqlite/persister.rs | 19 ++-- .../src/sqlite/schema/identities.rs | 87 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index b4126abb960..1d3fdcab693 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -932,14 +932,15 @@ impl PlatformWalletPersistence for SqlitePersister { schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?; let core_state = schema::core_state::load_state(&conn, &wallet_id, network) .map_err(PersistenceError::from)?; - let identity_manager = schema::identities::load_state(&conn, &wallet_id) + // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader + // already carrying its own public keys + contact state (matching + // the FFI persister), so signing works immediately post-load + // without a key sync. `ClientWalletStartState.contacts` / + // `.identity_keys` stay empty — nothing is layered on afterwards. + let identity_manager = schema::identities::load_prekeyed(&conn, &wallet_id) .map_err(PersistenceError::from)?; let unused_asset_locks = schema::asset_locks::load_unconsumed(&conn, &wallet_id) .map_err(PersistenceError::from)?; - let contacts = schema::contacts::load_changeset(&conn, &wallet_id) - .map_err(PersistenceError::from)?; - let identity_keys = schema::identity_keys::load_state(&conn, &wallet_id) - .map_err(PersistenceError::from)?; // Every address that ever held a UTXO (spent + unspent) is "used": // the address-reuse guard so a used-then-emptied address is never // handed back as a fresh receive address. The in-band pool snapshot @@ -954,11 +955,15 @@ impl PlatformWalletPersistence for SqlitePersister { network, birth_height, account_manifest, + // SQLite persister reconstructs core state from typed rows + // (see `core_state` below), not a full snapshot. + core_wallet_info: None, core_state, identity_manager, unused_asset_locks, - contacts, - identity_keys, + // Pre-keyed into `identity_manager` above; nothing to layer. + contacts: Default::default(), + identity_keys: Default::default(), used_core_addresses, }, ); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index e8a32608b97..74ece60b32b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -192,6 +192,24 @@ pub fn load_state( Ok(state) } +/// Build a fully pre-keyed +/// [`IdentityManagerStartState`](platform_wallet::changeset::IdentityManagerStartState) +/// for one wallet: read the identities, then fold this wallet's persisted +/// identity keys and contacts onto them so every `ManagedIdentity` carries +/// its own `public_keys` and contact maps at load time — no separate +/// changeset layered on afterwards. Fail-hard on a corrupt row, inherited +/// from the three underlying readers. +pub fn load_prekeyed( + conn: &Connection, + wallet_id: &WalletId, +) -> Result { + let mut state = load_state(conn, wallet_id)?; + let identity_keys = crate::sqlite::schema::identity_keys::load_state(conn, wallet_id)?; + let contacts = crate::sqlite::schema::contacts::load_changeset(conn, wallet_id)?; + state.merge_contacts_and_keys(contacts, identity_keys); + Ok(state) +} + /// Reconstruct a [`ManagedIdentity`] from a persisted [`IdentityEntry`] /// using a freshly minted V0 [`Identity`] for `(id, balance, revision)`. /// Live runtime fields (contacts maps, public-key derivations) are @@ -382,6 +400,75 @@ mod tests { assert_eq!(claimed.identity_index, Some(3)); } + /// `load_prekeyed` folds each identity's persisted keys onto it across + /// BOTH buckets — a wallet-owned identity (`identity_index = Some`) and + /// an out-of-wallet one (`identity_index = None`) each receive their own + /// key, with no cross-attribution. + #[test] + fn load_prekeyed_populates_keys_in_both_buckets() { + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use platform_wallet::changeset::{IdentityKeyEntry, IdentityKeysChangeSet}; + + let mut conn = migrated_conn(); + let w = [0x0Au8; 32]; + insert_wallet(&conn, &w); + + let wallet_owned = Identifier::from([0x11u8; 32]); + let out_of_wallet = Identifier::from([0x22u8; 32]); + + let mut ids = IdentityChangeSet::default(); + ids.identities + .insert(wallet_owned, entry([0x11; 32], Some(w), 100, Some(0))); + ids.identities + .insert(out_of_wallet, entry([0x22; 32], Some(w), 200, None)); + + let key = |id: Identifier, byte: u8| IdentityKeyEntry { + identity_id: id, + key_id: 0, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![byte; 33]), + disabled_at: None, + }), + public_key_hash: [byte; 20], + wallet_id: None, + derivation_indices: None, + }; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((wallet_owned, 0), key(wallet_owned, 0xA1)); + keys.upserts + .insert((out_of_wallet, 0), key(out_of_wallet, 0xB2)); + + let tx = conn.transaction().unwrap(); + apply(&tx, &w, &ids).unwrap(); + crate::sqlite::schema::identity_keys::apply(&tx, &w, &keys).unwrap(); + tx.commit().unwrap(); + + let state = load_prekeyed(&conn, &w).unwrap(); + let wo = &state.wallet_identities[&w][&0]; + assert_eq!( + wo.identity.public_keys()[&0].data().as_slice(), + &[0xA1; 33], + "wallet-owned identity carries its own key" + ); + let oow = &state.out_of_wallet_identities[&out_of_wallet]; + assert_eq!( + oow.identity.public_keys()[&0].data().as_slice(), + &[0xB2; 33], + "out-of-wallet identity carries its own key" + ); + } + /// `load_state` rejects a row whose decoded blob names a different /// `identity_id` than its typed column — corruption is a hard, typed /// error, never rehydrated under the wrong id. From 3b3b3eb4f25585b15a888db7611c01f504377ca0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:17:43 +0000 Subject: [PATCH 068/108] test(platform-wallet-storage): assert pre-keyed rehydration on ManagedIdentity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite sqlite_contacts_keys_rehydration.rs to the pre-keyed shape, asserting on `ManagedIdentity.identity.public_keys()` / `.established_contacts` / `.sent_contact_requests` / `.incoming_contact_requests` after store→drop→reopen→load, per the #3968 test spec TC-1..TC-10: - TC-1 keys populate public_keys bit-exact, no sync - TC-2 (load-bearing) freshly-loaded AUTH/CRITICAL key is selectable via the exact `get_first_public_key_matching` predicate the signing path uses - TC-3 established contact restores; TC-4 sent/incoming directionality - TC-5/6 no cross-identity key/contact leakage (same KeyID, two identities) - TC-7 zero keys, TC-8 out-of-wallet bucket, TC-9 tombstoned-orphan rows don't crash the join, TC-10 cross-wallet scoping preserved Re-point sqlite_load_reconstruction.rs::tc043 onto the managed identity (the field it asserted is now empty by design). Co-Authored-By: Claude Opus 4.8 --- .../tests/sqlite_contacts_keys_rehydration.rs | 622 +++++++++++++++--- .../tests/sqlite_load_reconstruction.rs | 20 +- 2 files changed, 531 insertions(+), 111 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs index 94ea6f17faf..f95789d050d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_contacts_keys_rehydration.rs @@ -1,23 +1,28 @@ #![allow(clippy::field_reassign_with_default)] -//! Contacts + identity-keys rehydrate through the keyless `load()` path: -//! store → drop → reopen → load → assert the -//! `ClientWalletStartState.contacts` / `.identity_keys` slots carry the -//! persisted PUBLIC material bit-exact. +//! Pre-keyed rehydration: identities load from SQLite already carrying +//! their own public keys + contact state, with nothing layered on +//! afterwards. store → drop → reopen → `load()` → assert on the +//! `ManagedIdentity` fields directly (TC-1..TC-10 of the #3968 spec). mod common; +use std::collections::{BTreeMap, HashSet}; + use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use platform_wallet::changeset::{ - ContactChangeSet, ContactRequestEntry, IdentityKeyEntry, IdentityKeysChangeSet, - PlatformWalletChangeSet, PlatformWalletPersistence, ReceivedContactRequestKey, - SentContactRequestKey, + ContactChangeSet, ContactRequestEntry, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, + IdentityKeysChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, + ReceivedContactRequestKey, SentContactRequestKey, }; -use platform_wallet::wallet::identity::ContactRequest; +use platform_wallet::wallet::identity::{ContactRequest, EstablishedContact, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { platform_wallet_storage::SqlitePersister::open( @@ -26,31 +31,49 @@ fn reopen(path: &std::path::Path) -> platform_wallet_storage::SqlitePersister { .expect("reopen persister") } -fn req(sender: u8, recipient: u8) -> ContactRequestEntry { - ContactRequestEntry { - request: ContactRequest { - sender_id: Identifier::from([sender; 32]), - recipient_id: Identifier::from([recipient; 32]), - sender_key_index: 1, - recipient_key_index: 2, - account_reference: 3, - encrypted_account_label: None, - encrypted_public_key: vec![9, 9, 9], - auto_accept_proof: None, - core_height_created_at: 42, - created_at: 7, - }, +/// A wallet-owned identity entry — lands in `wallet_identities[w][index]`. +fn wallet_identity_entry(id: Identifier, w: WalletId, index: u32) -> IdentityEntry { + IdentityEntry { + id, + balance: 1_000, + revision: 1, + identity_index: Some(index), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Unknown, + wallet_id: Some(w), + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +/// An out-of-wallet identity entry (`identity_index = None`) — lands in +/// `out_of_wallet_identities`. +fn out_of_wallet_entry(id: Identifier, w: WalletId) -> IdentityEntry { + IdentityEntry { + identity_index: None, + ..wallet_identity_entry(id, w, 0) + } +} + +fn id_changeset(entries: impl IntoIterator) -> IdentityChangeSet { + let mut cs = IdentityChangeSet::default(); + for e in entries { + cs.identities.insert(e.id, e); } + cs } -fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { +fn key_entry(id: Identifier, key_id: u32, byte: u8, security: SecurityLevel) -> IdentityKeyEntry { IdentityKeyEntry { - identity_id: identity, + identity_id: id, key_id, public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { id: key_id, purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, + security_level: security, contract_bounds: None, key_type: KeyType::ECDSA_SECP256K1, read_only: false, @@ -63,36 +86,191 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { } } -/// Contacts (sent + received) rehydrate bit-exact into the keyless -/// `ClientWalletStartState.contacts` slot. +fn keys_changeset(entries: impl IntoIterator) -> IdentityKeysChangeSet { + let mut cs = IdentityKeysChangeSet::default(); + for e in entries { + cs.upserts.insert((e.identity_id, e.key_id), e); + } + cs +} + +fn contact_request(sender: Identifier, recipient: Identifier) -> ContactRequest { + ContactRequest { + sender_id: sender, + recipient_id: recipient, + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_account_label: None, + encrypted_public_key: vec![9, 9, 9], + auto_accept_proof: None, + core_height_created_at: 42, + created_at: 7, + } +} + +fn established(owner: Identifier, contact: Identifier) -> EstablishedContact { + EstablishedContact { + contact_identity_id: contact, + outgoing_request: contact_request(owner, contact), + incoming_request: contact_request(contact, owner), + alias: Some("friend".into()), + note: Some("met at conf".into()), + is_hidden: false, + accepted_accounts: vec![0, 3, 7], + } +} + +/// TC-1 — a freshly-loaded identity carries its persisted keys in +/// `public_keys()` immediately, bit-exact, with no sync. #[test] -fn g_rt1_contacts_rehydrate_into_keyless_payload() { +fn tc1_identity_keys_populate_public_keys_on_load() { let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xC0); + let w = wid(0xC1); ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x44; 32]); + let e0 = key_entry(id, 0, 0xAA, SecurityLevel::HIGH); + let e1 = key_entry(id, 1, 0xBB, SecurityLevel::HIGH); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([e0.clone(), e1.clone()])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); - let sent_key = SentContactRequestKey { - owner_id: Identifier::from([0x11; 32]), - recipient_id: Identifier::from([0x22; 32]), - }; - let recv_key = ReceivedContactRequestKey { - owner_id: Identifier::from([0x11; 32]), - sender_id: Identifier::from([0x33; 32]), - }; - let sent_entry = req(0x11, 0x22); - let recv_entry = req(0x33, 0x11); - let mut sent = std::collections::BTreeMap::new(); - sent.insert(sent_key, sent_entry.clone()); - let mut recv = std::collections::BTreeMap::new(); - recv.insert(recv_key, recv_entry.clone()); + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + let pks = managed.identity.public_keys(); + assert_eq!(pks.len(), 2); + assert_eq!(pks.get(&0), Some(&e0.public_key)); + assert_eq!(pks.get(&1), Some(&e1.public_key)); +} +/// TC-2 (load-bearing) — the freshly-loaded AUTHENTICATION/CRITICAL key is +/// immediately selectable via the exact `get_first_public_key_matching` +/// predicate the Platform signing path uses, before any sync. +#[test] +fn tc2_loaded_auth_key_is_selectable_for_signing() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x55; 32]); + let key = key_entry(id, 0, 0xCC, SecurityLevel::CRITICAL); persister .store( w, PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([key.clone()])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + // Exact call shape from wallet/identity/network/dpns.rs:207 and friends. + let selected = managed + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + HashSet::from([SecurityLevel::HIGH, SecurityLevel::CRITICAL]), + HashSet::from([KeyType::ECDSA_SECP256K1]), + false, + ) + .expect("auth signing key selectable immediately after load, no sync"); + assert_eq!(selected.id(), 0); + assert_eq!(selected, &key.public_key); +} + +/// TC-3 — an established contact restores onto the identity, bit-exact. +#[test] +fn tc3_established_contact_restores_onto_identity() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC3); + ensure_wallet_meta(&persister, &w); + let owner = Identifier::from([0x11; 32]); + let contact = Identifier::from([0x22; 32]); + let contact_state = established(owner, contact); + let mut established_map = BTreeMap::new(); + established_map.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: contact, + }, + contact_state.clone(), + ); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(owner, w, 0)])), + contacts: Some(ContactChangeSet { + established: established_map, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + assert_eq!(managed.established_contacts.len(), 1); + assert_eq!( + managed.established_contacts.get(&contact), + Some(&contact_state) + ); + assert!(managed.sent_contact_requests.is_empty()); + assert!(managed.incoming_contact_requests.is_empty()); +} + +/// TC-4 — pending sent and incoming requests restore with correct +/// directionality and map keys. +#[test] +fn tc4_sent_and_incoming_requests_restore_directionally() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC4); + ensure_wallet_meta(&persister, &w); + let owner = Identifier::from([0x11; 32]); + let recipient = Identifier::from([0x22; 32]); + let sender = Identifier::from([0x33; 32]); + + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }, + ContactRequestEntry { + request: contact_request(owner, recipient), + }, + ); + let mut incoming = BTreeMap::new(); + incoming.insert( + ReceivedContactRequestKey { + owner_id: owner, + sender_id: sender, + }, + ContactRequestEntry { + request: contact_request(sender, owner), + }, + ); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(owner, w, 0)])), contacts: Some(ContactChangeSet { sent_requests: sent, - incoming_requests: recv, + incoming_requests: incoming, ..Default::default() }), ..Default::default() @@ -101,87 +279,327 @@ fn g_rt1_contacts_rehydrate_into_keyless_payload() { .unwrap(); drop(persister); - let p2 = reopen(&path); - let state = p2.load().expect("load"); - let slice = state.wallets.get(&w).expect("wallet rehydrated"); + let state = reopen(&path).load().expect("load"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + let s = managed + .sent_contact_requests + .get(&recipient) + .expect("sent restored, keyed by recipient"); + assert_eq!(s.sender_id, owner); + assert_eq!(s.recipient_id, recipient); + let i = managed + .incoming_contact_requests + .get(&sender) + .expect("incoming restored, keyed by sender"); + assert_eq!(i.sender_id, sender); + assert_eq!(i.recipient_id, owner); + assert!(managed.established_contacts.is_empty()); +} - let got_sent = slice - .contacts - .sent_requests - .get(&sent_key) - .expect("sent request rehydrated"); +/// TC-5 — two identities in one wallet with the same numeric `KeyID` keep +/// disjoint key maps; the group-by must not misattribute. +#[test] +fn tc5_no_cross_identity_key_leakage() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC5); + ensure_wallet_meta(&persister, &w); + let a = Identifier::from([0xA0; 32]); + let b = Identifier::from([0xB0; 32]); + let a0 = key_entry(a, 0, 0x0A, SecurityLevel::HIGH); + let a1 = key_entry(a, 1, 0x1A, SecurityLevel::HIGH); + let b0 = key_entry(b, 0, 0x0B, SecurityLevel::HIGH); // same KeyID 0, different data + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([ + wallet_identity_entry(a, w, 0), + wallet_identity_entry(b, w, 1), + ])), + identity_keys: Some(keys_changeset([a0.clone(), a1.clone(), b0.clone()])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load"); + let wallet = &state.wallets[&w].identity_manager.wallet_identities[&w]; + let managed_a = &wallet[&0]; + let managed_b = &wallet[&1]; + assert_eq!(managed_a.identity.public_keys().len(), 2); + assert_eq!(managed_b.identity.public_keys().len(), 1); assert_eq!( - got_sent.request.core_height_created_at, - sent_entry.request.core_height_created_at + managed_a.identity.public_keys().get(&0), + Some(&a0.public_key) ); assert_eq!( - got_sent.request.encrypted_public_key, - sent_entry.request.encrypted_public_key + managed_b.identity.public_keys().get(&0), + Some(&b0.public_key) + ); + assert_ne!( + managed_a.identity.public_keys().get(&0), + managed_b.identity.public_keys().get(&0), + "same KeyID on different identities must not collapse" ); - let got_recv = slice - .contacts - .incoming_requests - .get(&recv_key) - .expect("incoming request rehydrated"); - assert_eq!(got_recv.request.sender_id, recv_entry.request.sender_id); - // The rehydration feed never carries deletes. - assert!(slice.contacts.removed_sent.is_empty()); - assert!(slice.contacts.removed_incoming.is_empty()); -} - -/// Identity-key entries rehydrate bit-exact into the keyless -/// `ClientWalletStartState.identity_keys` slot. +} + +/// TC-6 — contact state does not leak across identities in one wallet. #[test] -fn g_rt2_identity_keys_rehydrate_into_keyless_payload() { +fn tc6_no_cross_identity_contact_leakage() { let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xC1); + let w = wid(0xC6); ensure_wallet_meta(&persister, &w); - let id = Identifier::from([0x44; 32]); - platform_wallet_storage::sqlite::schema::identities::ensure_exists( - &persister.lock_conn_for_test(), - &w, - id.as_slice().try_into().unwrap(), - ) - .unwrap(); + let a = Identifier::from([0xA0; 32]); + let b = Identifier::from([0xB0; 32]); + let c = Identifier::from([0xCC; 32]); + let d = Identifier::from([0xDD; 32]); - let e0 = key_entry(id, 0, 0xAA); - let e1 = key_entry(id, 1, 0xBB); - let mut keys = IdentityKeysChangeSet::default(); - keys.upserts.insert((id, 0), e0.clone()); - keys.upserts.insert((id, 1), e1.clone()); + let mut established_map = BTreeMap::new(); + established_map.insert( + SentContactRequestKey { + owner_id: a, + recipient_id: c, + }, + established(a, c), + ); + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: b, + recipient_id: d, + }, + ContactRequestEntry { + request: contact_request(b, d), + }, + ); persister .store( w, PlatformWalletChangeSet { - identity_keys: Some(keys), + identities: Some(id_changeset([ + wallet_identity_entry(a, w, 0), + wallet_identity_entry(b, w, 1), + ])), + contacts: Some(ContactChangeSet { + established: established_map, + sent_requests: sent, + ..Default::default() + }), ..Default::default() }, ) .unwrap(); drop(persister); - let p2 = reopen(&path); - let state = p2.load().expect("load"); - let slice = state.wallets.get(&w).expect("wallet rehydrated"); - assert_eq!(slice.identity_keys.upserts.len(), 2); - assert_eq!(slice.identity_keys.upserts.get(&(id, 0)), Some(&e0)); - assert_eq!(slice.identity_keys.upserts.get(&(id, 1)), Some(&e1)); - assert!(slice.identity_keys.removed.is_empty()); + let state = reopen(&path).load().expect("load"); + let wallet = &state.wallets[&w].identity_manager.wallet_identities[&w]; + let managed_a = &wallet[&0]; + let managed_b = &wallet[&1]; + assert!(managed_a.established_contacts.contains_key(&c)); + assert!(managed_a.sent_contact_requests.is_empty()); + assert!(managed_b.sent_contact_requests.contains_key(&d)); + assert!(managed_b.established_contacts.is_empty()); } -/// A metadata-only wallet has empty (not error) contacts / -/// identity-keys slots. +/// TC-7 — an identity with zero persisted keys loads fine with an empty +/// key map and its scalar fields intact. #[test] -fn g_rt3_empty_slots_for_bare_wallet() { +fn tc7_identity_with_zero_keys_loads_empty() { let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xC2); + let w = wid(0xC7); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x77; 32]); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load succeeds"); + let managed = &state.wallets[&w].identity_manager.wallet_identities[&w][&0]; + assert!(managed.identity.public_keys().is_empty()); + assert_eq!(managed.identity.balance(), 1_000); + assert_eq!(managed.identity.revision(), 1); + assert!(managed.established_contacts.is_empty()); +} + +/// TC-8 — an out-of-wallet identity (no `identity_index`) with zero keys +/// and contacts loads into `out_of_wallet_identities`, empty. +/// +/// Note: the SQLite `load_state`/`managed_identity_from_entry` path always +/// fills `wallet_id` with the load scope, so a row read under wallet `w` +/// carries `wallet_id = Some(w)` even in the out-of-wallet bucket (the +/// spec's `wallet_id: None` is unreachable through the wallet-scoped +/// reader). We assert the bucket placement and emptiness — the join's +/// concern — not that field. +#[test] +fn tc8_out_of_wallet_identity_loads_empty() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC8); ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x88; 32]); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([out_of_wallet_entry(id, w)])), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path).load().expect("load succeeds"); + let im = &state.wallets[&w].identity_manager; + let managed = im + .out_of_wallet_identities + .get(&id) + .expect("out-of-wallet identity present"); + assert!(managed.identity.public_keys().is_empty()); + assert!(managed.established_contacts.is_empty()); + assert!(managed.sent_contact_requests.is_empty()); + assert!(managed.incoming_contact_requests.is_empty()); +} + +/// TC-9 — a tombstoned identity's orphaned key/contact rows don't crash +/// the join; the identity is absent and its rows are silently unattributed. +#[test] +fn tc9_tombstoned_identity_orphan_rows_dont_crash_load() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC9); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x99; 32]); + let contact = Identifier::from([0xAB; 32]); + let mut established_map = BTreeMap::new(); + established_map.insert( + SentContactRequestKey { + owner_id: id, + recipient_id: contact, + }, + established(id, contact), + ); + // Store identity + key + contact. + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(id, w, 0)])), + identity_keys: Some(keys_changeset([key_entry( + id, + 0, + 0x99, + SecurityLevel::HIGH, + )])), + contacts: Some(ContactChangeSet { + established: established_map, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + // Tombstone the identity only; its key/contact rows are left behind. + let mut removed = IdentityChangeSet::default(); + removed.removed.insert(id); + persister + .store( + w, + PlatformWalletChangeSet { + identities: Some(removed), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let state = reopen(&path) + .load() + .expect("load must succeed despite orphan rows"); + let im = &state.wallets[&w].identity_manager; + let present = im + .wallet_identities + .values() + .flat_map(|m| m.values()) + .any(|m| m.identity.id() == id) + || im.out_of_wallet_identities.contains_key(&id); + assert!( + !present, + "tombstoned identity must be absent from both buckets" + ); +} + +/// TC-10 — cross-wallet scoping is preserved: two wallets each holding an +/// identity with the same `KeyID` get only their own key. +#[test] +fn tc10_cross_wallet_scoping_preserved() { + let (persister, _tmp, path) = fresh_persister(); + let wa = wid(0xAA); + let wb = wid(0xBB); + ensure_wallet_meta(&persister, &wa); + ensure_wallet_meta(&persister, &wb); + let ia = Identifier::from([0x1A; 32]); + let ib = Identifier::from([0x1B; 32]); + persister + .store( + wa, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(ia, wa, 0)])), + identity_keys: Some(keys_changeset([key_entry( + ia, + 0, + 0xAA, + SecurityLevel::HIGH, + )])), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + wb, + PlatformWalletChangeSet { + identities: Some(id_changeset([wallet_identity_entry(ib, wb, 0)])), + identity_keys: Some(keys_changeset([key_entry( + ib, + 0, + 0xBB, + SecurityLevel::HIGH, + )])), + ..Default::default() + }, + ) + .unwrap(); drop(persister); - let p2 = reopen(&path); - let state = p2.load().expect("load"); - let slice = state.wallets.get(&w).expect("wallet present"); - assert!(slice.contacts.sent_requests.is_empty()); - assert!(slice.contacts.incoming_requests.is_empty()); - assert!(slice.contacts.established.is_empty()); - assert!(slice.identity_keys.upserts.is_empty()); + + let state = reopen(&path).load().expect("load"); + let managed_a = &state.wallets[&wa].identity_manager.wallet_identities[&wa][&0]; + let managed_b = &state.wallets[&wb].identity_manager.wallet_identities[&wb][&0]; + assert_eq!(managed_a.identity.public_keys().len(), 1); + assert_eq!(managed_b.identity.public_keys().len(), 1); + assert_eq!( + managed_a + .identity + .public_keys() + .get(&0) + .unwrap() + .data() + .as_slice(), + &[0xAA; 33] + ); + assert_eq!( + managed_b + .identity + .public_keys() + .get(&0) + .unwrap() + .data() + .as_slice(), + &[0xBB; 33] + ); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs index e156a51345b..0383475ffaf 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_reconstruction.rs @@ -308,9 +308,9 @@ fn load_all_count_excludes_unregistered_account_addresses() { } /// `token_balances` is persisted-but-not-rehydrated (deferred) while -/// contacts rehydrate into `state.wallets[w].contacts`. Both tables are +/// contacts pre-key onto the owner's managed identity. Both tables are /// durable on disk after reopen (direct SQL probes), the contact -/// round-trips into the keyless payload, and `state.platform_addresses` +/// round-trips onto the rehydrated identity, and `state.platform_addresses` /// stays empty (no platform-address activity was stored). #[test] fn tc043_non_wired_up_persisted_but_not_returned() { @@ -378,15 +378,17 @@ fn tc043_non_wired_up_persisted_but_not_returned() { !state.platform_addresses.contains_key(&w), "no platform-address activity was stored — wallet must be absent" ); - // Contacts rehydrate into the keyless payload. + // Contacts pre-key onto the owner's managed identity (out-of-wallet + // bucket, since the stub identity carries no `identity_index`). let slice = state.wallets.get(&w).expect("wallet rehydrated"); - let key = SentContactRequestKey { - owner_id: owner, - recipient_id: recipient, - }; + let managed = slice + .identity_manager + .out_of_wallet_identities + .get(&owner) + .expect("owner identity rehydrated"); assert!( - slice.contacts.sent_requests.contains_key(&key), - "the persisted sent contact request must rehydrate" + managed.sent_contact_requests.contains_key(&recipient), + "the persisted sent contact request must rehydrate onto its identity" ); drop(p2); From 960228e33af898e4f61db6b42d77d2fd96fdf571 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:30:47 +0000 Subject: [PATCH 069/108] docs(platform-wallet): trim merge_contacts_and_keys rustdoc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the fabricated "keys before contacts" ordering rationale — the key and contact maps are independent (established-contact routing never reads public_keys), so the ordering carries no invariant. Trim the block under the public-API rustdoc line cap. Docs only; no behaviour change. Co-Authored-By: Claude Opus 4.8 --- .../changeset/identity_manager_start_state.rs | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs index f1018225000..b76d3f65fb2 100644 --- a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs @@ -31,19 +31,16 @@ pub struct IdentityManagerStartState { } impl IdentityManagerStartState { - /// Fold persisted PUBLIC keys and contact state into the already-built + /// Fold persisted PUBLIC keys and contact state onto the already-built /// managed identities so `Identity.public_keys` and the contact maps - /// are populated at load time, matching the FFI persister's pre-keyed - /// bucket shape (each `ManagedIdentity` carries its own keys/contacts - /// with no separate changeset layered on afterwards). + /// are populated at load time — the FFI persister's pre-keyed shape, + /// with no separate changeset layered on afterwards. /// - /// Each entry is routed by owner `identity_id` across BOTH buckets; - /// keys are applied before contacts so a contact never lands before - /// its owner's keys. Entries whose owner is absent from either bucket - /// (e.g. a tombstoned identity's orphaned rows) are logged and - /// skipped, never fatal. `removed_*` are ignored — the rehydration - /// feed is insert-only. No `Network` is needed: the key insert is - /// network-independent. + /// Entries route by owner `identity_id` across BOTH buckets; one whose + /// owner is absent (e.g. a tombstoned identity's orphaned rows) is + /// logged and skipped, never fatal. `removed_*` are ignored + /// (insert-only feed); no `Network` needed (key insert is + /// network-independent). pub fn merge_contacts_and_keys( &mut self, contacts: ContactChangeSet, From 0af850ab86ab1a299ae7a145f6c7b9db9f12fac8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:31:33 +0000 Subject: [PATCH 070/108] refactor(platform-wallet): drop dead identity_keys/contacts from ClientWalletStartState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FFI/iOS load path reconstructs identity PUBLIC keys and DashPay contacts directly into the managed identities via build_wallet_identity_bucket, so ClientWalletStartState.identity_keys and .contacts were always Default::default() on that path — dead surface that fed apply_contacts_and_keys a no-op. Remove both fields together (the load-path call took them in one call): - ClientWalletStartState: drop both fields + trim the import. - manager/load.rs: drop the destructure bindings, the apply_contacts_and_keys call, and the now-redundant `mut`. - persistence.rs (FFI): drop the two Default initializers; correct the stale doc claiming iOS carries no contacts back — contacts are restored inline via restore_dashpay_contacts as of #3841. - Fill the compile-forced test literals. Keep the ContactChangeSet/IdentityKeysChangeSet types and the apply_* methods — still used by the live runtime replay path (PlatformWalletInfo::apply_changeset). Add regression test TC-3692-011: the restored identity's CRITICAL/AUTH/ ECDSA signing key is selectable via get_first_public_key_matching immediately after load with zero sync, guarding post-load Platform signing against this removal. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-ffi/src/persistence.rs | 16 +-- .../changeset/client_wallet_start_state.rs | 13 +- .../rs-platform-wallet/src/manager/load.rs | 14 +- .../tests/rehydration_load.rs | 122 +++++++++++++++++- 4 files changed, 125 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 4ee70f488f8..52b50123cbd 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3556,16 +3556,10 @@ fn build_wallet_start_state( }) .collect(); - // `contacts` / `identity_keys` are the PR-3 keyless feed the - // manager layers onto the managed identities via - // `apply_contacts_and_keys`. The iOS path does NOT use them: - // identity PUBLIC keys are already reconstructed straight into - // `Identity.public_keys` by `build_wallet_identity_bucket` (feeding - // the slot too would double-apply), and `WalletRestoreEntryFFI` - // carries no contacts back from Swift on load — surfacing them - // would need a new cross-boundary struct field + Swift wiring, - // tracked as a follow-up. Empty slots make `apply_contacts_and_keys` - // a no-op for this path, preserving the established iOS behaviour. + // Identity PUBLIC keys and DashPay contacts are already restored + // into `identity_manager` by `build_wallet_identity_bucket` + // (contacts inline via `restore_dashpay_contacts`), so the load + // path carries no separate keyless contact/key feed. let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, @@ -3573,8 +3567,6 @@ fn build_wallet_start_state( core_wallet_info: Box::new(wallet_info), identity_manager, unused_asset_locks, - contacts: Default::default(), - identity_keys: Default::default(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index df2e63c0381..ecb115ace8c 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -13,7 +13,7 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; -use crate::changeset::{AccountRegistrationEntry, ContactChangeSet, IdentityKeysChangeSet}; +use crate::changeset::AccountRegistrationEntry; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; @@ -55,15 +55,4 @@ pub struct ClientWalletStartState { /// top-up, keyed by account index → outpoint. Terminal `Consumed` /// rows are already filtered out by the asset-lock reader. pub unused_asset_locks: BTreeMap>, - /// Persisted DashPay contact state (sent/received requests + - /// established contacts) to layer onto the rehydrated managed - /// identities. PUBLIC material — `removed_*` are always empty - /// (deletes never reach storage as rows). Routed by the manager - /// after `IdentityManager::from`, mirroring the runtime apply path. - pub contacts: ContactChangeSet, - /// Persisted per-identity PUBLIC key entries (no private key - /// material) to layer onto the rehydrated managed identities so - /// `Identity.public_keys` is populated at load time instead of - /// only after the next sync. `removed` is always empty. - pub identity_keys: IdentityKeysChangeSet, } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index da447a8266b..91eed9fbd67 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -122,8 +122,6 @@ impl PlatformWalletManager

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

{ core_balance.immature(), core_balance.locked(), ); - // Build the identity manager from the (id, balance, - // revision) skeleton, then layer the persisted PUBLIC - // contacts + identity keys onto it — the same routing the - // runtime changeset-replay path uses. - let mut identity_manager = IdentityManager::from(identity_manager); - identity_manager.apply_contacts_and_keys(contacts, identity_keys, network); + // Build the identity manager from the snapshot; public keys + // and contacts are already reconstructed into it upstream by + // the FFI persister (`build_wallet_identity_bucket`). + let identity_manager = IdentityManager::from(identity_manager); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), @@ -413,8 +409,6 @@ mod rollback_tests { core_wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), }, ) } diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index 9bd81b40f2e..f4fcabca2d0 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -20,18 +20,21 @@ //! deep pool addresses survive the reload; a snapshot whose //! `wallet_id` mismatches its row is skipped as corrupt. +use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; +use dpp::prelude::Identifier; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use platform_wallet::changeset::{ - AccountRegistrationEntry, ClientStartState, ClientWalletStartState, PersistenceError, - PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, IdentityManagerStartState, + PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::error::PlatformWalletError; use platform_wallet::events::{EventHandler, PlatformEventHandler}; use platform_wallet::manager::load_outcome::CorruptKind; use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::ManagedIdentity; use platform_wallet::{LoadOutcome, PlatformWalletManager, SkipReason}; // ---- test doubles ---- @@ -77,8 +80,6 @@ impl PlatformWalletPersistence for FixedLoadPersister { core_wallet_info: w.core_wallet_info.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), }, ); } @@ -177,8 +178,6 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { core_wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), - contacts: Default::default(), - identity_keys: Default::default(), }, ) } @@ -893,3 +892,114 @@ async fn rt_concurrent_loads_register_each_wallet_exactly_once() { } } } + +/// Persister for TC-3692-011. Rebuilds — fresh on each `load()`, since the +/// identity-manager snapshot is intentionally not `Clone` — a +/// `ClientStartState` carrying one wallet-owned identity whose +/// `Identity.public_keys` already holds a CRITICAL / AUTHENTICATION / +/// ECDSA_SECP256K1 key, the shape the FFI/iOS path produces via +/// `build_wallet_identity_bucket` (never through an `IdentityKeysChangeSet`). +struct IdentityKeyedLoadPersister { + seed: [u8; 64], + identity_id: Identifier, +} + +impl PlatformWalletPersistence for IdentityKeyedLoadPersister { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::{IdentityPublicKey, Purpose}; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, KeyType, SecurityLevel}; + + let (id, mut s) = slice(self.seed); + + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let mut public_keys = BTreeMap::new(); + public_keys.insert(0, key); + let identity = Identity::V0(IdentityV0 { + id: self.identity_id, + public_keys, + balance: 0, + revision: 0, + }); + + let mut inner = BTreeMap::new(); + inner.insert(0u32, ManagedIdentity::new(identity, 0)); + let mut ims = IdentityManagerStartState::default(); + ims.wallet_identities.insert(id, inner); + s.identity_manager = ims; + + let mut st = ClientStartState::default(); + st.wallets.insert(id, s); + Ok(st) + } +} + +/// TC-3692-011 [CRITICAL]: the restored identity's signing key is +/// selectable via the exact `get_first_public_key_matching` predicate the +/// real signing path uses (`contract.rs`, `dpns.rs`, ...) immediately +/// after `load_from_persistor`, with zero sync. The FFI/iOS path populates +/// `Identity.public_keys` at construction, so dropping +/// `ClientWalletStartState.identity_keys` must not change this — the key +/// must sit in the exact map slot the lookup reads, with no reliance on +/// `apply_identity_key_entry` ever running on the load path. +#[tokio::test] +async fn rt_signing_key_selectable_immediately_after_load() { + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::Purpose; + use dpp::identity::{KeyType, SecurityLevel}; + + let seed = [0x5A; 64]; + let identity_id = Identifier::from([0x11; 32]); + let p = Arc::new(IdentityKeyedLoadPersister { seed, identity_id }); + let h = Arc::new(RecordingHandler::default()); + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let mgr = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&p), h)); + + let outcome = mgr.load_from_persistor().await.expect("Ok"); + assert!( + outcome.skipped().is_empty(), + "identity row must not be skipped" + ); + let id = outcome + .loaded() + .first() + .copied() + .expect("exactly one wallet loaded"); + + // Immediately — no SPV sync, no identity refresh — drive the exact + // predicate a real signing flow uses to pick its authentication key. + let wallet = mgr.get_wallet(&id).await.expect("wallet registered"); + let wm = wallet.wallet_manager().read().await; + let info = wm.get_wallet_info(&id).expect("wallet info present"); + let managed = info + .identity_manager + .identity(&identity_id) + .expect("identity restored into the manager"); + let selected = managed + .identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::CRITICAL].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .expect("CRITICAL/AUTH/ECDSA signing key must be selectable post-load, zero sync"); + assert_eq!(selected.id(), 0, "the exact installed key is selected"); +} From cf33f83cf6cd2a822e4742e2a5c45bf0edab4d06 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:39:32 +0000 Subject: [PATCH 071/108] fix(platform-wallet-storage): cross-check inner public_key.id() in identity_keys reader identity_keys::load_state now verifies the decoded blob's inner `IdentityPublicKey.id()` against the typed `key_id` column. That inner id becomes the DPP signing-selection map key via `add_public_key`, so a locally-tampered row could otherwise file a key under a mismatched KeyID. Hard-errors as `IdentityKeyEntryMismatch`, matching the reader's existing column-vs-blob cross-checks for identity_id / key_id / wallet_id. +1 rejection test. (Smythe LOW S1 review follow-up.) Co-Authored-By: Claude Opus 4.8 --- .../src/sqlite/schema/identity_keys.rs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index a7626d8f216..7d9834ac479 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -9,6 +9,7 @@ use rusqlite::{params, Connection, Transaction}; use serde::{Deserialize, Serialize}; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyID; use dpp::prelude::Identifier; @@ -172,7 +173,13 @@ pub fn load_state( // selected by (mirrors `accounts`/`asset_locks` readers): a row whose // blob names a different identity / key / wallet than its indexed // columns is corruption, never silently mis-keyed into the map. - if entry.identity_id != identity_id || entry.key_id != key_id { + // `public_key.id()` is verified too — it becomes the DPP + // signing-selection map key via `add_public_key`, so a mismatch would + // file the key under a wrong KeyID rather than being caught here. + if entry.identity_id != identity_id + || entry.key_id != key_id + || entry.public_key.id() != key_id + { return Err(WalletStorageError::IdentityKeyEntryMismatch); } if let Some(entry_wallet_id) = entry.wallet_id { @@ -317,6 +324,44 @@ mod tests { ); } + /// `load_state` rejects a row whose inner `IdentityPublicKey.id()` + /// disagrees with the typed `key_id` column. That inner id becomes the + /// DPP signing-selection map key via `add_public_key`, so a mismatch + /// must hard-error, not file the key under the wrong KeyID. + #[test] + fn load_state_rejects_public_key_id_mismatch() { + let conn = migrated_conn(); + let wallet = [0x33u8; 32]; + let typed_identity = [0xEEu8; 32]; + // Inner key carries id=5, but insert_key_row stamps the key_id column 0. + let mismatched_pk = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 5, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }); + let wire = IdentityKeyWire { + identity_id: Identifier::from(typed_identity), + key_id: 0, // agrees with the typed column + public_key_bincode: bincode::encode_to_vec(&mismatched_pk, blob::bounded_config()) + .unwrap(), + public_key_hash: [0u8; 20], + wallet_id: None, + derivation_indices: None, + }; + insert_key_row(&conn, &wallet, &typed_identity, &wire); + + let err = load_state(&conn, &wallet).expect_err("public_key.id() mismatch must fail"); + assert!( + matches!(err, WalletStorageError::IdentityKeyEntryMismatch), + "expected IdentityKeyEntryMismatch, got {err:?}" + ); + } + /// A `public_key_bincode` payload whose IdentityPublicKey prefix is /// valid but carries trailing garbage is refused at decode time /// rather than silently dropping the trailing bytes. From 29a3853944694df933646618deb27f447d75b79f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:45:12 +0000 Subject: [PATCH 072/108] docs(platform-wallet): correct apply_contacts_and_keys + FFI build comments post field-removal - apply_contacts_and_keys rustdoc: the method has a single caller (the runtime changeset-replay path) now that the load_from_persistor caller was removed with ClientWalletStartState.contacts/identity_keys; drop the stale "shared with the persister rehydration path" claim. - persistence.rs: trim the ClientWalletStartState build comment to the internal-comment length budget. Doc-only; no logic change. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 3 +-- .../src/wallet/identity/state/manager/apply.rs | 13 ++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 52b50123cbd..a93cb6bee2d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3558,8 +3558,7 @@ fn build_wallet_start_state( // Identity PUBLIC keys and DashPay contacts are already restored // into `identity_manager` by `build_wallet_identity_bucket` - // (contacts inline via `restore_dashpay_contacts`), so the load - // path carries no separate keyless contact/key feed. + // (contacts inline via `restore_dashpay_contacts`). let wallet_state = ClientWalletStartState { network, birth_height: entry.birth_height, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index db2fe21d7e0..705f1863bd4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -205,18 +205,13 @@ impl IdentityManager { } /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the - /// already-restored managed identities. - /// - /// Single source of truth for the contact / identity-key routing — - /// shared by the runtime changeset-replay path - /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)) - /// and the persister rehydration path - /// ([`load_from_persistor`](crate::PlatformWalletManager::load_from_persistor)). + /// already-restored managed identities, for the runtime + /// changeset-replay path + /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)). /// Identity keys are applied first so a contact entry never lands /// before its owner's keys; orphan entries (owner not in the /// wallet) are logged and skipped, never fatal. `removed_*` and - /// `ignored`/`unignored` are honoured for the replay path; the - /// rehydration feed leaves them empty. + /// `ignored`/`unignored` are honoured. pub(crate) fn apply_contacts_and_keys( &mut self, contacts: ContactChangeSet, From d6b789e8650f6c52e5ceda23c4b1a2c60c47cf21 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:47:05 +0000 Subject: [PATCH 073/108] docs(platform-wallet-ffi): mark missing end-to-end build_wallet_identity_bucket test in source QA flagged that the end-to-end restore-coverage gap was disclosed only in prose. Per our TODO convention, mark it at the call site: no single test drives build_wallet_identity_bucket with all four restore_* categories at once; each helper is unit-tested in isolation and the calls are presence-checked. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index a93cb6bee2d..e806f875936 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3769,6 +3769,10 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { From adec4b7c488df47ea60d61524cc6dd238dd7820d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:51:06 +0000 Subject: [PATCH 074/108] docs(platform-wallet-ffi): enrich build_wallet_identity_bucket TODO with mitigation detail Spell out in the source marker why the end-to-end test is deferred (needs a heavy raw-pointer WalletRestoreEntryFFI fixture) and how the gap is mitigated today: the restore_* + build_identity_public_keys calls are grep-verified present and in order, and each restore_* has a passing per-helper unit test. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index e806f875936..98ee8fe337e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3770,9 +3770,9 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { From a814932d970f61730d32e5d991644f775476828d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 12:17:14 +0000 Subject: [PATCH 075/108] fix(platform-wallet): fund asset-lock test fixtures via confirmed UTXO, not mempool rust-dashcore#836 ("don't build asset locks on unconfirmed funds") added TransactionBuilder::require_final_inputs(), which retains only is_confirmed || is_instantlocked UTXOs before coin selection in build_asset_lock/build_asset_lock_with_signer. Correct behavior: funds that are only in the mempool can't have an InstantSend lock yet, so an asset lock built on them would just 300s-timeout on Platform. funded_wallet_manager funded its test wallet via TransactionContext::Mempool, so its sole UTXO was unconfirmed. Since the 647fa98 rust-dashcore bump pulled in #836, the confirmation filter empties the candidate set and 3 asset_lock::build tests fail in setup with "Coin selection error: No UTXOs available for selection" before ever reaching the broadcast-rejection paths they exist to exercise. Fund via TransactionContext::InBlock instead, matching the confirmed-funds idiom already used in rust-dashcore's own tests. No production code changed; core::broadcast's tests (the fixture's other consumer) also verified green. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/test_support.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 4b7f525cd24..33423afec0a 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -10,11 +10,11 @@ use std::sync::Arc; use async_trait::async_trait; use dashcore::secp256k1::{ecdsa, Message, PublicKey, Secp256k1}; -use dashcore::{Network, Transaction, Txid}; +use dashcore::{BlockHash, Network, Transaction, Txid}; use key_wallet::account::account_type::StandardAccountType; use key_wallet::signer::{Signer, SignerMethod}; use key_wallet::test_utils::TestWalletContext; -use key_wallet::transaction_checking::TransactionContext; +use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::{DerivationPath, Wallet}; use key_wallet_manager::WalletManager; use tokio::sync::RwLock; @@ -154,10 +154,15 @@ pub(crate) async fn funded_wallet_manager( } }; + // Funded as an already-mined (confirmed) UTXO, not mempool: since + // rust-dashcore#836, asset-lock building excludes unconfirmed/non- + // InstantSend inputs (`require_final_inputs`), so a mempool-only fixture + // would starve `build_asset_lock`'s coin selection before ever reaching + // the broadcast-rejection paths these tests exist to exercise. let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); - let result = ctx - .check_transaction(&funding_tx, TransactionContext::Mempool) - .await; + let block = + TransactionContext::InBlock(BlockInfo::new(600_000, BlockHash::dummy(0), 1_700_000_000)); + let result = ctx.check_transaction(&funding_tx, block).await; assert!( result.is_relevant, "funding tx should be relevant to {account_type:?}" From 04b93e491632a3515d492ed6a36e4a59e41ee625 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:07:17 +0000 Subject: [PATCH 076/108] docs(platform-wallet-storage): correct empty-manifest branch comment The old comment claimed a metadata-only wallet "still appears with a zero balance rather than being dropped." It does not: manager/load.rs re-checks the empty manifest and skips the wallet as MissingManifest one layer up (proved by rt_corrupt_row_skipped_and_other_loads). The persister-layer placeholder exists only so one unregistered wallet's empty manifest doesn't abort load() for every other wallet via `?` propagation. Doc-only; no logic change. Co-Authored-By: Claude Opus 4.8 --- .../rs-platform-wallet-storage/src/sqlite/persister.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 0fe76d9d8ea..94b4b1042e9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -956,10 +956,10 @@ impl PlatformWalletPersistence for SqlitePersister { // this directly — the old skeleton + core_state replay fallback is // gone. let watch_only = if account_manifest.is_empty() { - // A metadata-only wallet (created, no accounts registered yet) - // has no manifest to rebuild from; represent it as an empty - // watch-only wallet so it still appears with a zero balance - // rather than being dropped. + // Placeholder empty wallet: the manager re-checks the empty + // manifest and skips this wallet as MissingManifest one layer + // up (see rt_corrupt_row_skipped_and_other_loads). It exists so + // one unregistered wallet doesn't abort load() for all others via `?`. key_wallet::wallet::Wallet::new_watch_only( network, wallet_id, From 1a332ad4f25fe0d160564a9b2be59d4a3ad04fcc Mon Sep 17 00:00:00 2001 From: "Claudius the Magnificent AI, on behalf of lklimek" <8431764+Claudius-Maginificent@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:47:45 +0200 Subject: [PATCH 077/108] feat(platform-wallet): snapshot readers + unified V002 schema migration (#3986) Co-authored-by: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../rs-platform-wallet-storage/Cargo.toml | 6 + .../migrations/V002__unified.rs | 80 +++ .../src/sqlite/backup.rs | 63 ++- .../src/sqlite/migrations.rs | 46 ++ .../src/sqlite/persister.rs | 63 +-- .../src/sqlite/schema/core_pool.rs | 216 ++++++++ .../src/sqlite/schema/core_state.rs | 66 ++- .../src/sqlite/schema/mod.rs | 2 + .../src/sqlite/schema/versions.rs | 259 +++++++++ .../tests/fixture_gen.rs | 386 +++++++++++++ .../tests/fixtures/.gitignore | 4 + .../tests/fixtures/populated_v001.db | Bin 0 -> 217088 bytes .../tests/sqlite_blob_size_gate_on_load.rs | 66 ++- .../tests/sqlite_compile_time.rs | 5 + .../tests/sqlite_core_pool_writer.rs | 507 ++++++++++++++++++ .../tests/sqlite_migration_execution.rs | 410 ++++++++++++++ .../tests/sqlite_pool_reader.rs | 345 ++++++++++++ .../tests/sqlite_schema_pinning.rs | 116 ++++ .../tests/sqlite_store_generation.rs | 165 ++++++ .../tests/sqlite_v002_isolation.rs | 87 +++ .../tests/sqlite_v002_migration.rs | 198 +++++++ .../tests/sqlite_version_bump.rs | 427 +++++++++++++++ .../src/changeset/changeset.rs | 12 +- 23 files changed, 3452 insertions(+), 77 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V002__unified.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs create mode 100644 packages/rs-platform-wallet-storage/tests/fixture_gen.rs create mode 100644 packages/rs-platform-wallet-storage/tests/fixtures/.gitignore create mode 100644 packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs create mode 100644 packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index c9c93394bbf..1e1e27bddc5 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -252,3 +252,9 @@ kv = ["sqlite"] __test-helpers = ["sqlite"] # e2e tests that drive the #3692 manager-apply path; enabled in the integrated stack (dash-evo-tool). rehydration-apply = [] +# Pass-through to `platform-wallet/shielded` so the feature-gated +# `PlatformWalletChangeSet::shielded` field is visible to the exhaustive +# `versions::touched_domains` destructure (the R8 forgotten-domain guard). +# Storage persists no shielded state itself; this only aligns visibility so +# an added always-on field stays a compile error. +shielded = ["platform-wallet/shielded"] diff --git a/packages/rs-platform-wallet-storage/migrations/V002__unified.rs b/packages/rs-platform-wallet-storage/migrations/V002__unified.rs new file mode 100644 index 00000000000..75a23ac37f4 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V002__unified.rs @@ -0,0 +1,80 @@ +//! Unified additive migration for `platform-wallet-storage` (#3968). +//! +//! Additive-only: V001 stays byte-identical so refinery's applied-migration +//! checksum for version 1 never diverges on an existing store. V002 lifts +//! `max_supported_version()` from 1 to 2 automatically (the value is derived +//! from the embedded list) and lands three concerns in one migration event: +//! +//! - `core_address_pool` — per-index address-pool rows with a `used` flag, +//! the first-class row store that replaces `core_utxos` script-derivation +//! for the address-reuse guard. `account_type` and `pool_type` are both in +//! the primary key: `account_type` so two accounts that collapse to the same +//! `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` and +//! `ProviderVotingKeys`, both `0, 0`) never overwrite each other, and +//! `pool_type` so an External (receive) and Internal (change) pool never +//! collide at the same `address_index`. `script` (the address' +//! `script_pubkey`) is stored so the reader returns used addresses verbatim +//! and the UTXO writer can attribute an outpoint to its owning account, both +//! without re-deriving. +//! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq` +//! bumped inside the flush transaction, the cache-invalidation keystone. +//! No FK (a domain row may be written before its typed parent syncs, +//! mirroring the `meta_*` tables); a soft-cascade trigger reaps rows on +//! wallet delete. +//! - `meta_store_generation` — a single-row store-generation token, +//! initialized with `randomblob(16)` so the rendered SQL stays deterministic (the +//! content fingerprint pins the text, the runtime value is unique per +//! store). Regenerated on restore. +//! +//! No MAC column ships here — manifest authentication is deferred out of +//! this workstream (dev-plan §7). + +pub fn migration() -> String { + "\ +CREATE TABLE core_address_pool ( + wallet_id BLOB NOT NULL, + account_type TEXT NOT NULL, + account_index INTEGER NOT NULL, + key_class INTEGER NOT NULL DEFAULT 0, + pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), + address_index INTEGER NOT NULL, + script BLOB NOT NULL, + used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), + PRIMARY KEY (wallet_id, account_type, account_index, key_class, pool_type, address_index), + FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE +); + +CREATE INDEX idx_core_address_pool_used + ON core_address_pool(wallet_id, used); + +-- The UTXO writer attributes an outpoint to its owning account by matching +-- the outpoint's script against a pool row. +CREATE INDEX idx_core_address_pool_script + ON core_address_pool(wallet_id, script); + +CREATE TABLE meta_data_versions ( + wallet_id BLOB NOT NULL, + domain TEXT NOT NULL, + seq INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (wallet_id, domain) +); + +-- Soft-cascade reap, matching the meta_* tables: no FK (a domain may be +-- bumped before its typed parent exists), so a trigger clears rows when +-- the owning wallet is deleted. +CREATE TRIGGER cascade_meta_data_versions_on_wallet_delete +AFTER DELETE ON wallets +FOR EACH ROW +BEGIN + DELETE FROM meta_data_versions WHERE wallet_id = OLD.wallet_id; +END; + +CREATE TABLE meta_store_generation ( + id INTEGER NOT NULL PRIMARY KEY CHECK (id = 0), + generation BLOB NOT NULL +); + +INSERT INTO meta_store_generation (id, generation) VALUES (0, randomblob(16)); +" + .to_string() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs index ac175b6af5b..b5f3ae80fa5 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -132,14 +132,17 @@ pub fn run_to(src: &Connection, dest: &Path) -> Result<(), WalletStorageError> { /// /// Validation runs against the source and again against the STAGED bytes, /// under a SQLite-native `BEGIN EXCLUSIVE` on `dest_db_path` that blocks -/// every other SQLite peer (which advisory flock could not). The staged -/// temp is `persist`-ed as an atomic rename only after all gates pass, and -/// that rename is the commit point: if it fails, the live DB and its WAL/SHM -/// siblings are left untouched, so a failed restore never strands the old DB -/// without its WAL-committed state. The now-stale WAL/SHM siblings are -/// unlinked only AFTER the swap succeeds (so a leftover `-wal` can't shadow -/// the restored DB); the parent dir is fsynced afterward. See the numbered -/// steps in the body for the per-phase rationale. +/// every other SQLite peer (which advisory flock could not). The +/// store-generation token is rotated INTO the staged temp before the swap, +/// so the single commit point brings in the restored bytes and the fresh +/// token together — a peer never observes restored content carrying the +/// source's stale token. The staged temp is `persist`-ed as an atomic rename +/// only after all gates pass, and that rename is the commit point: if it +/// fails, the live DB and its WAL/SHM siblings are left untouched, so a failed +/// restore never strands the old DB without its WAL-committed state. The +/// now-stale WAL/SHM siblings are unlinked only AFTER the swap succeeds (so a +/// leftover `-wal` can't shadow the restored DB); the parent dir is fsynced +/// afterward. See the numbered steps in the body for the per-phase rationale. /// /// # Lock-release-before-rename trade-off /// @@ -224,7 +227,27 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet crate::sqlite::migrations::assert_schema_history_well_formed(&staged)?; } - // 5. chmod 0o600 on the temp BEFORE persist so the destination + // 5. Regenerate the store-generation token INTO the staged temp, before + // the atomic rename, so the single commit point (step 8) swaps in the + // restored bytes and the rotated token together — there is no window + // where restored content is observable with the source's stale token. + // The staged DB is switched to DELETE journaling first so the UPDATE + // lands in the main file with no `-wal` frames stranded outside the + // rename; the reopened destination is forced back to its configured + // journal mode on its next open. A pre-V002 backup has no generation + // table; `regenerate_generation` is a no-op there and the token is + // (re)seeded on its later migration to V002. + { + let conn = + crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?; + conn.pragma_update(None, "journal_mode", "DELETE")?; + crate::sqlite::schema::versions::regenerate_generation(&conn)?; + drop(conn); + // Durably flush the regenerated token before the rename commits it. + tmp.as_file().sync_all()?; + } + + // 6. chmod 0o600 on the temp BEFORE persist so the destination // inherits owner-only mode via the rename (post-persist chmod could // fail with the new DB already live). #[cfg(unix)] @@ -234,7 +257,7 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet .set_permissions(std::fs::Permissions::from_mode(0o600))?; } - // 6. Release the EXCLUSIVE lock before the rename/unlinks: on Windows / + // 7. Release the EXCLUSIVE lock before the rename/unlinks: on Windows / // some FUSE mounts `remove_file` on a still-open file returns // `PermissionDenied`, and the rename window wants a clean close (see // lock-release trade-off above). @@ -243,18 +266,20 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet drop(conn); } - // 7. Persist the staged DB atomically over the destination FIRST. The - // atomic rename is the commit point: if it fails (disk full, EXDEV, - // perms) the live DB and its WAL/SHM siblings are left untouched, so a - // failed restore can never strand the old DB without its WAL-committed - // state. Sibling cleanup (step 8) runs only once the swap has succeeded. + // 8. Persist the staged DB atomically over the destination. The atomic + // rename is the single commit point: it swaps in both the restored + // bytes and the rotated generation token together. If it fails (disk + // full, EXDEV, perms) the live DB and its WAL/SHM siblings are left + // untouched, so a failed restore can never strand the old DB without + // its WAL-committed state. Sibling cleanup (step 9) runs only once the + // swap has succeeded. tmp.persist(dest_db_path) .map_err(|e| WalletStorageError::Io(e.error))?; - // 8. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover + // 9. Clear the now-stale WAL/SHM siblings AFTER the swap so a leftover // `-wal` can't shadow the restored DB on the next open. Sibling paths // use `OsString::push` so non-UTF-8 bytes round-trip; `NotFound` is a - // silent no-op. The lock conn was dropped in step 6 for cross-platform + // silent no-op. The lock conn was dropped in step 7 for cross-platform // unlink semantics. if let Some(file_name) = dest_db_path.file_name() { for ext in ["-wal", "-shm"] { @@ -269,10 +294,10 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet } } - // 9. Make the rename + unlink dentry updates durable. + // 10. Make the rename + unlink dentry updates durable. fsync_parent_dir(dest_db_path)?; - // 10. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). + // 11. Re-tighten perms (idempotent; SQLite may re-materialise -wal/-shm). apply_secure_permissions(dest_db_path)?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index 6cbb427f190..c1099290aa9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -175,6 +175,52 @@ pub fn embedded_migrations_fingerprint() -> [u8; 32] { hasher.finalize().into() } +/// SHA-256 over `(version, name, rendered SQL)` of every embedded migration +/// in version order. Unlike [`embedded_migrations_fingerprint`] this is +/// content-level: it pins each migration's SQL body, so an in-place DDL edit +/// (e.g. renaming a table inside a same-named file) breaks the golden test. +/// This is the guard the D0 schema freeze relies on; the identity-only +/// fingerprint cannot catch a same-name body edit. +/// +/// The SQL *text* is deterministic even where a value is generated at run +/// time (`randomblob(16)`): the literal string is hashed, not the runtime +/// bytes. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql_fingerprint() -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + let mut hasher = Sha256::new(); + for m in &migrations { + hasher.update((m.version() as u32).to_be_bytes()); + hasher.update([0u8]); + hasher.update(m.name().as_bytes()); + hasher.update([0u8]); + let sql = m + .sql() + .expect("embedded migrations always carry rendered SQL"); + hasher.update(sql.as_bytes()); + hasher.update([0u8]); + } + hasher.finalize().into() +} + +/// Rendered SQL of every embedded migration, in version order. Used by the +/// schema-freeze grep guard to scan for retired table names. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn embedded_migrations_sql() -> Vec { + let mut migrations = migrations::runner().get_migrations().clone(); + migrations.sort_by_key(|m| m.version()); + migrations + .iter() + .map(|m| { + m.sql() + .expect("embedded migrations always carry rendered SQL") + .to_string() + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 94b4b1042e9..500d5383be5 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; use platform_wallet::changeset::{ - ClientStartState, Merge, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::wallet::platform_wallet::WalletId; @@ -941,13 +941,27 @@ impl PlatformWalletPersistence for SqlitePersister { .map_err(PersistenceError::from)?; let unused_asset_locks = schema::asset_locks::load_unconsumed(&conn, &wallet_id) .map_err(PersistenceError::from)?; - // Every address that ever held a UTXO (spent + unspent) is "used": - // the address-reuse guard so a used-then-emptied address is never - // handed back as a fresh receive address. The in-band pool snapshot - // was retired, so we derive this from the full core_utxos set. - let used_core_addresses = - schema::core_state::load_used_addresses(&conn, &wallet_id, network) + // Used addresses drive the reuse guard: a used-then-emptied + // address must never be handed back as a fresh receive address. + // Union the verbatim `core_address_pool` used-set with the + // `core_utxos`-derived set (spent + unspent). The guard is + // monotonic, so a mixed store — historical UTXOs plus a later + // partial pool snapshot that never enumerates them — must surface + // both; neither source may shadow the other. Deduped by script. + let used_core_addresses = { + let mut seen = std::collections::HashSet::new(); + let mut union = Vec::new(); + let pool = schema::core_pool::load_used_addresses(&conn, &wallet_id, network) .map_err(PersistenceError::from)?; + let utxo = schema::core_state::load_used_addresses(&conn, &wallet_id, network) + .map_err(PersistenceError::from)?; + for addr in pool.into_iter().chain(utxo) { + if seen.insert(addr.script_pubkey().to_bytes()) { + union.push(addr); + } + } + union + }; // Reconstruct a populated `ManagedWalletInfo` from typed rows: // rebuild the wallet watch-only from the manifest, then layer the @@ -1039,25 +1053,9 @@ impl PlatformWalletPersistence for SqlitePersister { /// from the public fields so no storage-only helper leaks into the /// `rs-platform-wallet` API. fn populated_field_count(cs: &PlatformWalletChangeSet) -> usize { - [ - cs.core.is_empty(), - cs.identities.is_empty(), - cs.identity_keys.is_empty(), - cs.contacts.is_empty(), - cs.platform_addresses.is_empty(), - cs.asset_locks.is_empty(), - cs.token_balances.is_empty(), - cs.dashpay_profiles.as_ref().is_none_or(|m| m.is_empty()), - cs.dashpay_payments_overlay - .as_ref() - .is_none_or(|m| m.is_empty()), - cs.wallet_metadata.is_none(), - cs.account_registrations.is_empty(), - cs.account_address_pools.is_empty(), - ] - .iter() - .filter(|empty| !**empty) - .count() + // Single source of truth with the version-domain mapping: each populated + // field is exactly one touched domain. + schema::versions::touched_domains(cs).len() } fn validate_config(config: &SqlitePersisterConfig) -> Result<(), WalletStorageError> { @@ -1133,10 +1131,12 @@ fn apply_changeset_to_tx( if !cs.account_registrations.is_empty() { schema::accounts::apply_registrations(tx, wallet_id, &cs.account_registrations)?; } - // `account_address_pools` is intentionally NOT applied: UTXO attribution - // is hardcoded to the default account (index 0) in `core_state`, so the - // pool snapshot is no longer a storage input. The changeset field is kept - // for API stability and still feeds non-storage consumers. + // Pools land before core so the UTXO writer can attribute each outpoint + // to its owning account by matching the outpoint's script against a + // freshly-written `core_address_pool` row. + if !cs.account_address_pools.is_empty() { + schema::core_pool::apply_pools(tx, wallet_id, &cs.account_address_pools)?; + } if !cs.pending_contact_crypto_added.is_empty() || !cs.pending_contact_crypto_cleared.is_empty() { schema::pending_contact_crypto::apply_pending_contact_crypto( @@ -1175,6 +1175,9 @@ fn apply_changeset_to_tx( cs.dashpay_payments_overlay.as_ref(), )?; } + // Bump each touched domain's version inside this same tx so a domain's + // cache-invalidation marker commits atomically with its data. + schema::versions::bump_touched_domains(tx, wallet_id, cs)?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs new file mode 100644 index 00000000000..6f729d66173 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -0,0 +1,216 @@ +//! Writer + account-attribution helper for the `core_address_pool` table. +//! +//! Per-index address-pool rows carrying a `used` flag, scoped by +//! `(wallet_id, account_type, account_index, key_class, pool_type, +//! address_index)`. The first-class row store the reader consumes verbatim — +//! no `core_utxos` script-derivation, no horizon-walk re-derivation. Populated +//! from the `account_address_pools` changeset snapshots; the UTXO writer reads +//! it back to attribute an outpoint to its owning account. + +use rusqlite::{params, OptionalExtension, Transaction}; + +use platform_wallet::changeset::AccountAddressPoolEntry; +use platform_wallet::wallet::platform_wallet::WalletId; + +use key_wallet::managed_account::address_pool::AddressPoolType; + +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::accounts; +use crate::sqlite::schema::blob; + +/// Stored `pool_type` discriminant. Kept in the primary key so an External +/// and an Internal pool never collide at the same `address_index`. +pub(crate) fn pool_type_to_i64(pool_type: AddressPoolType) -> i64 { + match pool_type { + AddressPoolType::External => 0, + AddressPoolType::Internal => 1, + AddressPoolType::Absent => 2, + AddressPoolType::AbsentHardened => 3, + } +} + +const UPSERT_POOL_SQL: &str = "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, address_index, script, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, pool_type, address_index) \ + DO UPDATE SET \ + script = excluded.script, \ + used = MAX(used, excluded.used)"; + +/// Expand `account_address_pools` snapshots into per-index +/// `core_address_pool` rows. Idempotent: `script` is derivation-stable and +/// `used` is monotonic (`MAX`), so re-applying the same snapshot is a no-op +/// and a used address can never revert to unused (the reuse-guard invariant). +pub fn apply_pools( + tx: &Transaction<'_>, + wallet_id: &WalletId, + pools: &[AccountAddressPoolEntry], +) -> Result<(), WalletStorageError> { + if pools.is_empty() { + return Ok(()); + } + let mut stmt = tx.prepare_cached(UPSERT_POOL_SQL)?; + for entry in pools { + // `account_type` discriminates accounts that collapse to the same + // `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` + // and `ProviderVotingKeys`, both `0, 0`); without it they would upsert + // onto the same PK and overwrite each other's rows. + let account_type = accounts::account_type_db_label(&entry.account_type); + let account_index = i64::from(accounts::account_index(&entry.account_type)); + // TODO(key_class): PlatformPayment carries a real key_class; every + // other account maps to the 0 sentinel until the pool snapshot + // threads a per-pool key class. + let key_class = i64::from(accounts::account_key_class(&entry.account_type)); + let pool_type = pool_type_to_i64(entry.pool_type); + for info in &entry.addresses { + stmt.execute(params![ + wallet_id.as_slice(), + account_type, + account_index, + key_class, + pool_type, + i64::from(info.index), + info.script_pubkey.as_bytes(), + info.used, + ])?; + } + } + Ok(()) +} + +/// Owning account index for a UTXO, matched by its `script_pubkey` against a +/// pool row. `None` when no pool row covers the script — the UTXO writer +/// then falls back to account 0 (the one-way historical-attribution default, +/// R7): funds are never dropped, only conservatively bucketed. +pub fn account_index_for_script( + tx: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result, WalletStorageError> { + // A script can appear under several pool rows (distinct account_type / + // key_class / pool_type share the same `script_pubkey` for reused keys); + // an explicit PK-ordered tie-break makes the pick deterministic instead of + // relying on SQLite's arbitrary `LIMIT 1` row. + let idx: Option = tx + .prepare_cached( + "SELECT account_index FROM core_address_pool \ + WHERE wallet_id = ?1 AND script = ?2 \ + ORDER BY account_type, account_index, key_class, pool_type, address_index ASC \ + LIMIT 1", + )? + .query_row(params![wallet_id.as_slice(), script], |row| row.get(0)) + .optional()?; + idx.map(|v| crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", v)) + .transpose() +} + +/// Used addresses for a wallet, read verbatim from `core_address_pool` +/// (`used = 1`) with no re-derivation. Possibly empty. The caller **unions** +/// this with the `core_utxos`-derived set — the reuse guard is monotonic, so +/// a mixed store (historical UTXOs a later partial pool snapshot never +/// enumerates) must surface both sources, never drop the historical ones. +/// +/// `network` turns each stored `script` back into an [`Address`]; a script +/// that isn't a valid address is a hard error — corruption is never silently +/// dropped, matching [`crate::sqlite::schema::core_state::load_used_addresses`]. +pub fn load_used_addresses( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + network: dashcore::Network, +) -> Result, WalletStorageError> { + // Gate the largest stored `script` with a cheap aggregate BEFORE the + // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a + // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB + // cap) rather than SQLite's own `TooBig` mid-sort, and never OOMs the host. + let max_script_len: Option = conn.query_row( + "SELECT MAX(length(script)) FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + if let Some(len) = max_script_len { + blob::check_size(len)?; + } + let mut stmt = conn.prepare( + "SELECT DISTINCT script FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1 ORDER BY script", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + let mut out = Vec::new(); + for r in rows { + let script = dashcore::ScriptBuf::from_bytes(r?); + let address = dashcore::Address::from_script(&script, network).map_err(|_| { + WalletStorageError::blob_decode("core_address_pool.script not an address") + })?; + out.push(address); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// In-memory connection with the full schema migrated in, so tests insert + /// through the production DDL. + fn migrated_conn() -> rusqlite::Connection { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + conn + } + + /// `account_index_for_script` is deterministic when several pool rows + /// share one script: the PK-ordered tie-break (`account_type` first) picks + /// the same row regardless of insert order, closing the `LIMIT 1`-without- + /// `ORDER BY` non-determinism. + #[test] + fn account_index_for_script_is_deterministic_on_shared_script() { + let mut conn = migrated_conn(); + let w = [0x77u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + let script = [0xABu8; 25]; + let tx = conn.transaction().unwrap(); + // Same script under two account types with different account_index. + // Insert the later-sorting `standard_bip44` FIRST so a bare `LIMIT 1` + // could return either row depending on SQLite's scan order. + tx.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 9, 0, 0, 0, ?2, 1)", + params![&w[..], &script[..]], + ) + .unwrap(); + tx.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'coinjoin', 4, 0, 0, 0, ?2, 1)", + params![&w[..], &script[..]], + ) + .unwrap(); + // ORDER BY account_type ASC: 'coinjoin' < 'standard_bip44', so the + // coinjoin row (account_index 4) is the deterministic winner. + let got = account_index_for_script(&tx, &w, &script).unwrap(); + assert_eq!(got, Some(4), "tie-break must pick the account_type-min row"); + tx.commit().unwrap(); + } + + #[test] + fn pool_type_discriminants_are_stable_and_distinct() { + let all = [ + AddressPoolType::External, + AddressPoolType::Internal, + AddressPoolType::Absent, + AddressPoolType::AbsentHardened, + ]; + let mapped: Vec = all.iter().copied().map(pool_type_to_i64).collect(); + assert_eq!(mapped, vec![0, 1, 2, 3]); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 9d81eddbf70..e6fae4bdd04 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -99,15 +99,16 @@ pub fn apply( ])?; } } - // `addresses_derived` is intentionally NOT persisted here. The iOS - // address registry is fed by the FFI `addresses_derived` callback (fired - // before the UTXO changeset in the same round), and UTXO attribution is - // hardcoded to the default account (index 0); the storage layer keeps no - // derived-address lookup table. + // `addresses_derived` is intentionally NOT persisted here — the pool + // snapshot (`account_address_pools`) is the derived-address source, and + // it is applied to `core_address_pool` before this in the same flush tx, + // so a UTXO's owning account resolves by matching its script against a + // pool row (falling back to account 0 when no pool row covers it). if !cs.new_utxos.is_empty() { let mut stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; for utxo in &cs.new_utxos { - execute_upsert_utxo(&mut stmt, wallet_id, utxo, false)?; + let account_index = resolve_account_index(tx, wallet_id, utxo)?; + execute_upsert_utxo(&mut stmt, wallet_id, utxo, account_index, false)?; } } if !cs.spent_utxos.is_empty() { @@ -126,11 +127,11 @@ pub fn apply( if exists { mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; } else { - // Spent-only synthetic row for a UTXO we never saw unspent. - // account_index is the hardcoded default like every row, and - // inert anyway since spent rows are excluded from - // `list_unspent_utxos`. - execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true)?; + // Spent-only synthetic row for a UTXO we never saw unspent; + // attribute like any other row (inert — spent rows are + // excluded from `list_unspent_utxos`). + let account_index = resolve_account_index(tx, wallet_id, utxo)?; + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, account_index, true)?; } } } @@ -179,20 +180,29 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ account_index = excluded.account_index, \ spent = excluded.spent"; -/// Account index written for every `core_utxos` row. The product uses only -/// the default account (index 0); a non-default funds account causes -/// `core_bridge::warn_if_non_default_account` to emit a `warn!` log but -/// the record is still persisted under index 0 (dropping it would -/// undercount the balance and lose funds). The one reader -/// (`list_unspent_utxos` per-account grouping) groups everything under 0. -const CORE_UTXO_ACCOUNT_INDEX: i64 = 0; +/// Owning account for a UTXO, resolved by matching its `script_pubkey` +/// against a `core_address_pool` row. Falls back to account 0 when no pool +/// row covers the script — the one-way historical-attribution default (R7): +/// funds are never dropped, only conservatively bucketed. +fn resolve_account_index( + tx: &Transaction<'_>, + wallet_id: &WalletId, + utxo: &Utxo, +) -> Result { + let script = utxo.txout.script_pubkey.as_bytes(); + let account = + crate::sqlite::schema::core_pool::account_index_for_script(tx, wallet_id, script)? + .unwrap_or(0); + Ok(i64::from(account)) +} -/// Upsert one `core_utxos` row. `account_index` is the hardcoded default -/// ([`CORE_UTXO_ACCOUNT_INDEX`]); `spent` marks spent-only synthetic rows. +/// Upsert one `core_utxos` row with its resolved `account_index`; `spent` +/// marks spent-only synthetic rows. fn execute_upsert_utxo( stmt: &mut rusqlite::CachedStatement<'_>, wallet_id: &WalletId, utxo: &Utxo, + account_index: i64, spent: bool, ) -> Result<(), WalletStorageError> { let op = blob::encode_outpoint(&utxo.outpoint)?; @@ -202,7 +212,7 @@ fn execute_upsert_utxo( crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, utxo.txout.script_pubkey.as_bytes(), i64::from(utxo.height), - CORE_UTXO_ACCOUNT_INDEX, + account_index, spent, ])?; Ok(()) @@ -423,6 +433,20 @@ pub fn load_used_addresses( wallet_id: &WalletId, network: dashcore::Network, ) -> Result, WalletStorageError> { + // Gate the largest stored `script` with a cheap aggregate BEFORE the + // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a + // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB + // cap) rather than SQLite's own `TooBig` mid-sort, and never OOMs the host. + // `core_utxos` has no `(wallet_id, script)` index, so the read would sort + // the blob; the aggregate gate fires first regardless of query plan. + let max_script_len: Option = conn.query_row( + "SELECT MAX(length(script)) FROM core_utxos WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + )?; + if let Some(len) = max_script_len { + blob::check_size(len)?; + } let mut stmt = conn .prepare("SELECT DISTINCT script FROM core_utxos WHERE wallet_id = ?1 ORDER BY script")?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index 1fc0f7d9b82..64663a44af8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -10,6 +10,7 @@ pub mod accounts; pub mod asset_locks; pub mod blob; pub mod contacts; +pub mod core_pool; pub mod core_state; pub mod dashpay; pub mod identities; @@ -17,6 +18,7 @@ pub mod identity_keys; pub mod pending_contact_crypto; pub mod platform_addrs; pub mod token_balances; +pub mod versions; pub mod wallets; /// Reject any `identity_id` in `touched` whose `identities` row does not diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs new file mode 100644 index 00000000000..ad6c08409f7 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs @@ -0,0 +1,259 @@ +//! Store-scoped version + generation metadata: `meta_data_versions` and +//! `meta_store_generation`. +//! +//! `meta_data_versions` carries a monotonic `seq` per `(wallet_id, domain)`, +//! bumped inside the flush transaction so a domain's cache-invalidation +//! marker and its data commit atomically. `meta_store_generation` holds the +//! single store-generation token, stable across flushes and regenerated on +//! restore. + +use rusqlite::{params, Transaction}; + +use platform_wallet::changeset::{Merge, PlatformWalletChangeSet}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use crate::sqlite::error::WalletStorageError; + +/// A wallet-state family whose durable version `seq` is bumped when the +/// matching changeset field is flushed. One variant per persisted +/// changeset field — the cache-invalidation keystone (R8). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Domain { + Core, + Identities, + IdentityKeys, + Contacts, + PlatformAddresses, + AssetLocks, + TokenBalances, + DashpayProfiles, + DashpayPaymentsOverlay, + WalletMetadata, + AccountRegistrations, + AccountAddressPools, + PendingContactCrypto, +} + +impl Domain { + /// Stable `meta_data_versions.domain` label. `Debug` is not a stable + /// wire format; this match is the contract. + pub fn as_str(self) -> &'static str { + match self { + Domain::Core => "core", + Domain::Identities => "identities", + Domain::IdentityKeys => "identity_keys", + Domain::Contacts => "contacts", + Domain::PlatformAddresses => "platform_addresses", + Domain::AssetLocks => "asset_locks", + Domain::TokenBalances => "token_balances", + Domain::DashpayProfiles => "dashpay_profiles", + Domain::DashpayPaymentsOverlay => "dashpay_payments_overlay", + Domain::WalletMetadata => "wallet_metadata", + Domain::AccountRegistrations => "account_registrations", + Domain::AccountAddressPools => "account_address_pools", + Domain::PendingContactCrypto => "pending_contact_crypto", + } + } + + /// Every domain, for coverage tests. + #[cfg(any(test, feature = "__test-helpers"))] + pub const ALL: [Domain; 13] = [ + Domain::Core, + Domain::Identities, + Domain::IdentityKeys, + Domain::Contacts, + Domain::PlatformAddresses, + Domain::AssetLocks, + Domain::TokenBalances, + Domain::DashpayProfiles, + Domain::DashpayPaymentsOverlay, + Domain::WalletMetadata, + Domain::AccountRegistrations, + Domain::AccountAddressPools, + Domain::PendingContactCrypto, + ]; +} + +/// Domains carrying data in `cs`. The destructure is exhaustive (no `..`), so +/// adding a field to `PlatformWalletChangeSet` is a compile error here until +/// it gains a `Domain` variant and an arm below — the R8 forgotten-domain +/// guard. The feature-gated `shielded` field is bound under the storage +/// crate's pass-through `shielded` feature; storage versions no shielded state +/// here, so it is deliberately ignored, not mapped to a domain. +pub fn touched_domains(cs: &PlatformWalletChangeSet) -> Vec { + let PlatformWalletChangeSet { + core, + identities, + identity_keys, + contacts, + platform_addresses, + asset_locks, + token_balances, + dashpay_profiles, + dashpay_payments_overlay, + wallet_metadata, + account_registrations, + account_address_pools, + pending_contact_crypto_added, + pending_contact_crypto_cleared, + #[cfg(feature = "shielded")] + shielded, + } = cs; + #[cfg(feature = "shielded")] + let _ = shielded; + + // A sub-changeset carried but empty (`Some(default)`) is not a real + // change; the `Merge::is_empty` bound is the shared emptiness contract. + fn present(opt: &Option) -> bool { + !opt.is_empty() + } + + let mut out = Vec::new(); + if present(core) { + out.push(Domain::Core); + } + if present(identities) { + out.push(Domain::Identities); + } + if present(identity_keys) { + out.push(Domain::IdentityKeys); + } + if present(contacts) { + out.push(Domain::Contacts); + } + if present(platform_addresses) { + out.push(Domain::PlatformAddresses); + } + if present(asset_locks) { + out.push(Domain::AssetLocks); + } + if present(token_balances) { + out.push(Domain::TokenBalances); + } + if dashpay_profiles.as_ref().is_some_and(|m| !m.is_empty()) { + out.push(Domain::DashpayProfiles); + } + if dashpay_payments_overlay + .as_ref() + .is_some_and(|m| !m.is_empty()) + { + out.push(Domain::DashpayPaymentsOverlay); + } + if wallet_metadata.is_some() { + out.push(Domain::WalletMetadata); + } + if !account_registrations.is_empty() { + out.push(Domain::AccountRegistrations); + } + if !account_address_pools.is_empty() { + out.push(Domain::AccountAddressPools); + } + if !pending_contact_crypto_added.is_empty() || !pending_contact_crypto_cleared.is_empty() { + out.push(Domain::PendingContactCrypto); + } + out +} + +/// Saturating increment of one domain's `seq`, inside the caller's flush tx. +/// The first bump sets `seq = 1`; thereafter it increments but never wraps past +/// `i64::MAX` — a wrap to a lower value would look like a rollback to a +/// client's memoized `(generation, domain, seq)` cache and silently +/// reintroduce staleness (the exact bug class R8 exists to prevent). +pub fn bump_domain( + tx: &Transaction<'_>, + wallet_id: &WalletId, + domain: Domain, +) -> Result<(), WalletStorageError> { + tx.prepare_cached( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) VALUES (?1, ?2, 1) \ + ON CONFLICT(wallet_id, domain) DO UPDATE SET \ + seq = CASE WHEN seq >= 9223372036854775807 THEN seq ELSE seq + 1 END", + )? + .execute(params![wallet_id.as_slice(), domain.as_str()])?; + Ok(()) +} + +/// Bump every domain touched by `cs`, inside the caller's flush tx. +pub fn bump_touched_domains( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &PlatformWalletChangeSet, +) -> Result<(), WalletStorageError> { + for domain in touched_domains(cs) { + bump_domain(tx, wallet_id, domain)?; + } + Ok(()) +} + +/// Read the current `seq` for one `(wallet_id, domain)`; `0` when the domain +/// has never been bumped (no row). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_seq( + conn: &rusqlite::Connection, + wallet_id: &WalletId, + domain: Domain, +) -> Result { + use rusqlite::OptionalExtension; + let seq: Option = conn + .query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = ?2", + params![wallet_id.as_slice(), domain.as_str()], + |row| row.get(0), + ) + .optional()?; + Ok(seq.unwrap_or(0)) +} + +/// Read the 16-byte store-generation token written by V002. `None` on a +/// pre-V002 store (the table is absent). +#[cfg(any(test, feature = "__test-helpers"))] +pub fn read_generation( + conn: &rusqlite::Connection, +) -> Result, WalletStorageError> { + use rusqlite::OptionalExtension; + if !generation_table_exists(conn)? { + return Ok(None); + } + let bytes: Option> = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |row| row.get(0), + ) + .optional()?; + match bytes { + None => Ok(None), + Some(b) => { + let arr: [u8; 16] = b.as_slice().try_into().map_err(|_| { + WalletStorageError::blob_decode("meta_store_generation.generation not 16 bytes") + })?; + Ok(Some(arr)) + } + } +} + +/// Regenerate the store-generation token so a restored copy is +/// distinguishable from its source. A no-op on a pre-V002 store (no table); +/// such a store gets a fresh token when it later migrates to V002. +pub fn regenerate_generation(conn: &rusqlite::Connection) -> Result<(), WalletStorageError> { + if !generation_table_exists(conn)? { + return Ok(()); + } + conn.execute( + "UPDATE meta_store_generation SET generation = randomblob(16) WHERE id = 0", + [], + )?; + Ok(()) +} + +fn generation_table_exists(conn: &rusqlite::Connection) -> Result { + use rusqlite::OptionalExtension; + Ok(conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'meta_store_generation'", + [], + |_| Ok(()), + ) + .optional()? + .is_some()) +} diff --git a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs new file mode 100644 index 00000000000..8c1993ad458 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs @@ -0,0 +1,386 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Populated-V001 fixture capture. +//! +//! `regenerate_populated_v001_fixture` (`#[ignore]`) writes a realistic +//! multi-wallet store, built by the CURRENT V001-only persister, to +//! `tests/fixtures/populated_v001.db`. That committed `.db` is the +//! regression anchor for the migration-execution suites (TC-B-031/032/033/ +//! 035/036): once V002 lands, a populated V001-only store is no longer +//! reproducible from source, so the bytes must be captured before any +//! schema change. +//! +//! `populated_v001_fixture_is_present_and_openable` is the always-run guard +//! that keeps the committed fixture honest: it opens read-only, asserts the +//! store is at schema version 1, and spot-checks the seeded rows. + +mod common; + +use std::path::{Path, PathBuf}; + +use common::wid; +use dpp::prelude::Identifier; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::bip32::ExtendedPubKey; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use platform_wallet::changeset::{ + AccountRegistrationEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, + IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet, PlatformWalletPersistence, + SentContactRequestKey, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +/// The two wallets the fixture carries. +const FULL_WALLET: u8 = 0xA1; +const EMPTY_WALLET: u8 = 0xB2; + +/// Absolute path to the committed fixture under the crate's test tree. +fn fixture_path() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("populated_v001.db") +} + +/// A deterministic test xpub decoded from a fixed serialized form, matching +/// the reconstruction suite so registrations round-trip reproducibly. +fn test_xpub() -> ExtendedPubKey { + ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap() +} + +/// First external address of the Standard BIP44 account 0, derived from a +/// fixed seed so the UTXO lands on a real, script-round-trippable address. +fn first_external_address(seed_byte: u8) -> dashcore::Address { + use key_wallet::managed_account::address_pool::AddressPoolType; + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec<_> = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos.first().cloned().unwrap().address; + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo { + use dashcore::hashes::Hash; + key_wallet::Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([0x7E; 32]), + vout, + }, + txout: dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + address: addr.clone(), + height: 200, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn one_tx_record() -> key_wallet::managed_account::transaction_record::TransactionRecord { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, Txid}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + let mut record = TransactionRecord::new( + Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 200, + BlockHash::from_byte_array([0x03; 32]), + 1_735_689_600, + )), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 150_000, + ); + record.txid = Txid::from_byte_array([0x7E; 32]); + record +} + +fn identity_entry() -> IdentityEntry { + IdentityEntry { + id: Identifier::from([0xC1; 32]), + balance: 42, + revision: 1, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +/// Build the populated multi-wallet store at `path` via the real persister. +fn build_populated_store(path: &Path) { + let cfg = SqlitePersisterConfig::new(path); + let persister = SqlitePersister::open(cfg).expect("open persister"); + + let full: WalletId = wid(FULL_WALLET); + let empty: WalletId = wid(EMPTY_WALLET); + + // Empty wallet: registered (a `wallets` row) but never synced. + persister + .store( + empty, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: empty, + birth_height: 50, + }), + ..Default::default() + }, + ) + .expect("store empty wallet meta"); + + // Full wallet: metadata + registration + core state + identity + contact. + persister + .store( + full, + PlatformWalletChangeSet { + wallet_metadata: Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: full, + birth_height: 100, + }), + account_registrations: vec![AccountRegistrationEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + account_xpub: test_xpub(), + }], + ..Default::default() + }, + ) + .expect("store full wallet meta + registration"); + + let addr = first_external_address(FULL_WALLET); + persister + .store( + full, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![one_tx_record()], + new_utxos: vec![utxo_at(&addr, 0, 150_000)], + last_processed_height: Some(200), + synced_height: Some(200), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("store full wallet core state"); + + let mut identities = std::collections::BTreeMap::new(); + let ident = identity_entry(); + identities.insert(ident.id, ident); + let owner = Identifier::from([0xC1; 32]); + let recipient = Identifier::from([0xC2; 32]); + let mut sent = std::collections::BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: recipient, + }, + ContactRequestEntry { + request: ContactRequest { + sender_id: owner, + recipient_id: recipient, + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 200, + created_at: 0, + }, + }, + ); + persister + .store( + full, + PlatformWalletChangeSet { + identities: Some(IdentityChangeSet { + identities, + removed: Default::default(), + }), + contacts: Some(ContactChangeSet { + sent_requests: sent, + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("store full wallet identity + contact"); + + persister.flush(full).expect("flush full"); + persister.flush(empty).expect("flush empty"); +} + +/// Fixture regenerator. Ignored by default — run explicitly to rebuild the +/// committed fixture: +/// `cargo test -p platform-wallet-storage --test fixture_gen -- --ignored regenerate`. +#[test] +#[ignore = "regenerator: rewrites the committed fixture on disk"] +fn regenerate_populated_v001_fixture() { + let tmp = tempfile::tempdir().expect("tempdir"); + let src = tmp.path().join("build.db"); + build_populated_store(&src); + + // Re-open the source and take a checkpointed single-file backup so the + // committed fixture carries no side WAL/journal. + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(&src)).expect("reopen built store"); + let dest = fixture_path(); + if dest.exists() { + std::fs::remove_file(&dest).expect("remove stale fixture"); + } + persister.backup_to(&dest).expect("capture fixture backup"); +} + +/// Always-run guard: the committed fixture opens, is at schema version 1, +/// and carries the seeded rows (full wallet populated, empty wallet bare). +#[test] +fn populated_v001_fixture_is_present_and_openable() { + let path = fixture_path(); + assert!( + path.exists(), + "committed fixture missing at {}; regenerate with the #[ignore] test", + path.display() + ); + + let conn = rusqlite::Connection::open_with_flags( + &path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .expect("open fixture read-only"); + + // The fixture is a V001-only store: refinery history tops out at 1. + let max_version: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .expect("read schema history"); + assert_eq!( + max_version, 1, + "fixture must be at V001, not a later schema" + ); + + let full = wid(FULL_WALLET); + let empty = wid(EMPTY_WALLET); + + let wallet_count: i64 = conn + .query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get(0)) + .unwrap(); + assert_eq!(wallet_count, 2, "two wallets: one full, one empty"); + + let reg_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(reg_count, 1, "full wallet has one account registration"); + + let (utxo_count, account_index): (i64, i64) = conn + .query_row( + "SELECT COUNT(*), MAX(account_index) FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(utxo_count, 1, "full wallet has one unspent UTXO"); + assert_eq!( + account_index, 0, + "V001 hardcodes account_index=0 — the pre-redirect writer gap" + ); + + let tx_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(tx_count, 1, "full wallet has one transaction record"); + + let ident_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(ident_count, 1, "full wallet has one identity"); + + let contact_count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(contact_count, 1, "full wallet has one contact"); + + // The empty wallet is bare: a `wallets` row and nothing else. + let empty_utxos: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![empty.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(empty_utxos, 0, "empty wallet has no core state"); +} diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore b/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore new file mode 100644 index 00000000000..5eaeada7098 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixtures/.gitignore @@ -0,0 +1,4 @@ +# SQLite WAL/SHM side files produced when a test opens a committed fixture. +*.db-wal +*.db-shm +*.db-journal diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db b/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db new file mode 100644 index 0000000000000000000000000000000000000000..8b5c4eb4ab3709f61194f0fd151055dda54791f6 GIT binary patch literal 217088 zcmeI*U2Gf4VFz$hl1Yj_n|HaS>_zcqbiRwooMfAlb@EyDwG_FMS<{q{remvZu(#rl z#67Kc>D{F)1^tp_w>bm>QUooEyc9v30!8~!6e!vt1={4HJ>;Pe0s4?O4}IuMA6hgo zMT-VSXLoj&yZlrVo=$E5M9${U%+AjI=5u!~<^J1c#iC?OH+Ch9EJU7ND z1=1Z)GD*TdPb5{PmZ->NrM$jED(f{;c~CCTI+50X?{LMmFDh=T`ev`w?o~^7^VJP< zN7x`4{{pkb&MwPyUR|#WrL{^&k}RnTt3p+%6omV3$;@^JS+ZUsMWHOPS{3s53;Cju z%ob9y^uydh*btO!8wyJuef&av>dw`~v0xd>_BJ(4YH2N1DQpIVI z2ltA0n>y*8Vv+kojhIc!`hj8>khRw>rvaVhb^|}W%|HXqe2c7?i&y+sA^A$ND+4=) zo!4?ws1()b;_1cd=wZs~tBSlIRBn*2A*l72nI&-ueZBU%wp!UGz`Su%FUadwwxE9XG(reQL2Nxe4e8O_R zKX-V3GM*N$Mh~xsJ4~>Di(JiV(_nbAcmD|{aZmO8Q7|spo%j{9N3D1~ef4Ve*-P#I z6g;J?2#N0A6-@u*Oq;#B3Y$aOOxlplhSMc^JM%zG+4Z|458qNWNmV{acFlOWOb+IV9?}y6X)XTrKRX^%s5Tw4Jw0fD<(_mOuRhK^mbS^}7&#Q9XnP9VK(H0b4wg)|4msNcQIchYnx^Py z7Kk$&3RPt)w`W+q`u^M;DJu3fy~_p(D~?IbhGej- zg_~)Xgfh#rLqj)BBC|fFXm!h3H(6e8D)T0}=a%XoFBI9K2Vz}ifP^(Qu^N;VSp``v z$+S#r%#tlbp_-iaa`Kv(G$qBjLhjoQu2Gf9bW3Wfmc4N&d#t2vFK3b`iq&AVIm?#q z<)wYayy9fjzGmn0#C8#@`3L11xyGjmy9jJI=k}`3?VF!E^Eqnh_WmuiFz+eza{q`Y zd#d}*)~2|5U{SNz65DS9^Uk)1T1N}bw!vpfXYD-fV4+972$o{8^k8Yvcaom~F_$JJB|C$KRT5jOrd5Q!)dGl-3dZHUUy{9WL(wV7tB}HQa z_PAq>Ww~Evy61jl6|%$qF}SH1R>NC-J9}X=J2w$a=coE9xwR2zR0TiU2`Ae!|C-Ic@!8;!bg&?k5-B-JMo-wfJ2VCS>PHuY>?By8_@y_?NTMKp=6 zxu3~b3%B#t%>3e_+nM}$so7K&HdnHo2Bcz@VE1IL-B6nB-LY6&TJA^Gry63eD}voo ze(DPQ8 zrdW!k&R?5A009U<00Izz00bZa0SG_<0w0>d?ASzkD*DwSANS`l+B)+WbRRkJ_y6;e z)coM$Ar=A_B`V009U<00Izz z00bZa0SG_<0uVT30>z1PdFa0u81~eF-~WF%lKSo$s~w610SG_<0uX=z1Rwwb2tWV= z5P-mNflp18$A&%~h`i7L{r_)9QolJoOQe7R1Rwwb2tWV=5P$##AOHafKwyLd3i}Cz zd*g47B@&TgKVdNZ#{$Mm|F-uBAN}HYUOD>K*I)gG_g??~k1qfH_ji9P@$`>B|3{Jc z`TPH`M^axOVfm321Rwwb2tWV=5P$##AOHafKmY=x5m=ol-y8aVfB4e@e*gauk<>qo zrdY@Y0uX=z1Rwwb2tWV=5P$##AOL|=6?k=`oEZ9q!2b9Dk>^jfT(}qnAOHafKmY;| zfB*y_009U<00O5hV88#z{r@SW<8lyy00bZa0SG_<0uX=z1Rwx`Qx@RA|L^Z2slPkr z3*&MSfB*y_009U<00Izz00bZa0SNqf0@>)f^4QqW-yIlv-~0W4-;1Qa_v2j>XF~u2 z5P$##AOHafKmY;|fB*y_@Zktt9xo67`Tx{^uz&u)5V`oLsXw^zhZlb>@!bpW#9vPQ zR_sgX#wY%E;+^rrST_1kk%j2j{5l+8c|M+g{q^Xty=Y0BDy{1V70m;!E}E8Pbsj!n zs0#U-Kx+AwvOqf0l1!4Y&l5>isU<2hSt+ltkji?ER34Pe0B;)ywPs;&{^H*T4f9pCefNo!x?mE2iBv;-;!^hI z-7K-Q%Z`}$=;IgSQ+KW=js?q5wzsKaQcG*8Dwl0p`?@LWnrQ8dGG+aoj}Xg{G*hZu zimsU+Q?<0l`Y{=DdNRLSW2xLmX3g!u2lGp^o2`50xDcgEQFugpr6pc2>J+;K7)Pft%rpNZ|B?ZmW8cHV2E_AKV4I7tuITUL_|HLF)Q-ILT7^(HWX;^Y0( ztQ$(R=UKhxJ*i&T*>uF(k&W?wIod5fD9-N%_B=dzAbGVK^|a(K6|O%HG-#f6(P~xQG)4D(qH<`c)ePk zO@C|>WQ$EBux{``OWE~%7H`TeMUzzJb8InkSImwMXV^27d?u#Ud?J>9aCP7~4zjun zRxoJty@_-2^wLuFH)fos^Tvlkw-u8mbSB=Ofw52%_Dph;X7o<94w{tIghy;op{im>!% ztJxhQwzN$)UC5yrMcY%@4vwuzcCf^@SbRSqO0sOQ9hc~57Kk$&3RPvcHgY{}B zOZOy^rQT&OGg)_T@|*`vjci9(RO}gdmkknD9Fv$0$zWFtH`6Q$WtL@!hHjcfW_^ln zEiG?%=%zAnl6!8c?(ssA9eN7+b zQ0r)+**5qr>8zcH9W3;y*RxVAmj3KgznRP{d2sx)?RU@M_~E1RczS9odK`CVG`7H;ROnfb*KKHxHZzP{i7A9kntCPvdq54WZJvSFUKJUnT^Dvv>w-l9{ zoyV_+tUF`f?%9972@H5XKe^ZGyGIG#G6(Vv-88%53Ny3=J=ma`Q=r-Tu zon<$>PLnarQP1pGUy7v*m--DzuQS+NQ{HK&4nLBPr`Knqhs*x0jo<&+5^aindh=JK zP~=N)S8LxJhoU>rwQq1j34%8_egs2#mi$;eJu?%1@4nN+{V2OYp@Vd&uZN;e(&7CR zIyV)({p~FZi|O2-@JO~PP?fr9r|I{+Cy>Oy8}io|o(tRJRVmxw?6Dgld)Fk9t(Mg? zD6zKtBhJ&Jr>Wq8VQJdgf3(+7VJUw2_0GR+a9;Me=fOuk zyg}Hk&$dB+C+QdgmcF~mc1Bv?Cjg=L^sgOSJb7pk#FpxO;=J(UpyzOHDZdq&&c_np z;aiehFT~PszuvDGIFDS~uN&N3|8~F&kx2BPf7sU(%hR#+dbZysEq#Y-;-;iZ?A-$E zdEw*BM{kjMI-89?U2=LvI5vFrN~mXq=)ZT2h_$J67X2gB6?lF5O(Ao{`(!Be@K~FH+?GT(6{G#y+NxRNNV!5@oW%lVNs=gi;{_8t^QjctgFH8-3r`QQ(&zjv~ zmyRPJcit&Jd%b6KP_z4-PYi4be~tAQuU1-kC(?b15 zmwT~M4=(=m|6hotzTm&##}^1d00Izz00bZa0SG_<0uX=z1U@(cGa4(G6I+RI4*7g_ z$cGF$|C^tVMx9!Wu|MtK{~u*P{Ewgi|KJ)HIYR&f5P$##AOHafKmY;|fB*zONCErj z|A##b!2SOR*#gKL0uX=z1Rwwb2tWV=5P$##An?Hpoap!e*~NJNgD(rN009U<00Izz z00bZa0SG_<0uX?}hbLga|Hu9Rhv$zgLjVF0fB*y_009U<00Izz00d4$0Qdi=p^R%l z00Izz00bZa0SG_<0uX=z1U_5={`>#milpB9aLa+KLjVF0fB*y_009U<00Izz00hpi zz~N6vFOeCYc{eNGH zq`tyK@h1cz009U<00Izz00bZa0SG_<0;efJ&#@;9lOOaG1^fL!e*XV7+ZES>00bZa z0SG_<0uX=z1Rwwb2%NqEe*XXT&2dEtKmY;|fB*y_009U<00Izzz-bHM{r_o;<9ZN) z00bZa0SG_<0uX=z1Rwx`(-*+||I;_e6(Ilt2tWV=5P$##AOHafKmY=#Er9p`r!9`_ zK>z{}fB*y_009U<00Izz00d570Pp`#-yBzj00bZa0SG_<0uX=z1Rwwb2%NS6-v6Jr zIIaf)2tWV=5P$##AOHafKmY;|IDG-U|37_mToD2gfB*y_009U<00Izz00ba#+5&k0 zf7;@>9t0o&0SG_<0uX=z1Rwwb2teTU1@Qj=^v!Wa2tWV=5P$##AOHafKmY;|fWT=B zT$p@2@_gjmV`Fb#{L70+7rs2X5&w(HNbGmw&(2k1jq!gu_u`l_{^yZzKlh3Ak;%V~ z{EAaP{^ux)TFS-JpIwSZ6iuf4=3`Z{s3^58-8mL_sU?X`RkF5pV^@@9*`TH==6VKS zKT@y6)33c2eKuuDn<{k@)pgC1>Q?Z3u22>7HG$OfD`kNMV@M`R*yo9)s?-t{nUIz8 z`UQ6LkIFY?#-Rs%$Js2lfT*Pfd8la&>bih1){m4#}ug+qN1R zmMlwFgxW(vsE{1VSBfMze`3L11 z$+R?OpEmV+Ba?MAy;m*W%~vL&$a2jt2n24 z2XoyG;6AocOW&OtNb6y^y58ZHqjV;ozI-`)+-d{!@#OIGudjFG z@`w{nKmpnR9g~PVU1SGM7XclYl#9$B)<4BTFSJ3oRee)Zom2SNY&R&6a6$W$1?RmF zZw_X2>C8|ZULhBUAAI^K|7tva>sIvX#~d74G8;|lKxF^zvI)`@^*w5+(m}uQnGjpQ zR3{tn?ltXMH`FWLMUxjq++;1+Yq|`!L&h6SEU#J8&evV4R@bXSX|2MSszA~#sS2w? zRj3q%`@{`bs2R*HOV%r-D3k@Z`WN!|3;Cju%&xx{OV@Ap8|?j1#9Y6a%;Z-h343Ss z)0boE`*TAxHuNn;rEHP(Gju~is--o1%W$%jllj#eOYDL&S}(UGYa=4$3%5yi z{b6!NSSwYWt&^K_wYq*cwC{2v?Jdp2+pGZt`>$JMyQbt-bbP}o4jf!TC9hVwnK zrgNHKdu|yRAa3=8A@4!O;xh4|E+>BR9yFB_)fXU0L=!){QA>OQYapqf`N?qO2se8L022SXeh23{H=i^grQ;Da$eSF-m%XRp{z<$DL zSgJ$ymH0^qKASpT^{|~An$WbsVuwxNBrx~#$-2oWn7)(ssAu5i=~#L_+i#-e9nu|m zY?=%oUp{(^#M9Yq^y!i_Nrq#?N3VpO!g0ftxC}ps^{5_qUTz7ycKiIy!2LkyeHl*> zXay2nv)UW~4`}_vzyBY_V+`a00SG_<0uX=z1Rwwb2tWV=5IEZc`1k*3yM0hP2tWV= z5P$##AOHafKmY;|fWRmO@cw@kipT>35P$##AOHafKmY;|fB*y_aJB{T{{L*Z4@w6C z2tWV=5P$##AOHafKmY;|7=-}d|Bpfuc|ZUH5P$##AOHafKmY;|fB*!}wgBG$pY8TR z=^y|B2tWV=5P$##AOHafKmY=x5WxHYQ79r02tWV=5P$##AOHafKmY;|fWX-n!2AER z-99KC1Rwwb2tWV=5P$##AOHafKwuOCc>g~NMdSei2tWV=5P$##AOHafKmY;|INJhv z|9`gI2c?4m1Rwwb2tWV=5P$##AOHafj6wkK|3{&SJRkr82tWV=5P$##AOHafKmY<~ zTLAC>&vyHubP#|51Rwwb2tWV=5P$##AOL|;2;lwyC=`(g1Rwwb2tWV=5P$##AOHaf zK;Uc(;QjyEZXc8m0uX=z1Rwwb2tWV=5P$##ATSC6`~80+k&2}L=3?XGrRVXt3#edSW_(Lf*XjINWrSiIa$jNzVeQUgC7 zA-0D8M59KV$~){x2eI`dty!JG{5$|KPfWREX6u#GX+CzMSd|~$2;UUmlonvccv4^8@+6pik}$=aSxTj z1FrkLE`fGNC$7C$6n7wbhdnF4>?n1OO{ebdu{b0Pd(>^t$EVh&5>I#g__)KD>+pkt z{e;nkPU(Vi^Ej~zPjA=B{S29A`pCFr#>-^rY}F^5&Utg#q~~{2wMXfoY5 zAFGN*MX6=!&T*e5A=h)Ry?*oy*W&4=rRdX#mb9tTo{4$}E_e$_ex)q*p2$hY=~Qm> zvGGJ!%IhnnvR)&V2j%jt6YEXj46;Y;4n6RMYr-RTo_j7S+!hLVNJgdFw$;e6WLdHz z)E){#h2)&6FgJfA%dfB}sV&NL>2ZZtQ&HoR9m$JJ6 literal 0 HcmV?d00001 diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs index 3927d3ee2e5..7aa20715cc3 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_blob_size_gate_on_load.rs @@ -13,7 +13,9 @@ mod common; use common::{ensure_wallet_meta, fresh_persister, wid}; use rusqlite::params; -use platform_wallet_storage::sqlite::schema::{accounts, core_state, identities, identity_keys}; +use platform_wallet_storage::sqlite::schema::{ + accounts, core_pool, core_state, identities, identity_keys, +}; use platform_wallet_storage::WalletStorageError; /// Blob larger than the 16 MiB cap: one byte over the limit is enough to @@ -104,6 +106,68 @@ fn blob_gate_core_state_load_state_rejects_oversize_chain_lock() { ); } +// ── core_pool::load_used_addresses — core_address_pool script ──────────────── + +/// An oversize `script` blob in `core_address_pool` is caught by the pre-read +/// `length(script)` gate in `core_pool::load_used_addresses` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_pool_load_used_addresses_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + params![w.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize pool script row"); + + let err = core_pool::load_used_addresses(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_used_addresses must reject an oversize pool script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize pool script, got {err:?}" + ); +} + +// ── core_state::load_used_addresses — core_utxos script ────────────────────── + +/// An oversize `script` blob in `core_utxos` is caught by the pre-read +/// `length(script)` gate in `core_state::load_used_addresses` and returned as +/// `BlobTooLarge` **before** the Vec is allocated. +#[test] +fn blob_gate_core_state_load_used_addresses_rejects_oversize_script() { + let (persister, _tmp, _path) = fresh_persister(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + + let oversize_script = oversize_blob(); + // 33-byte outpoint (txid 32 + vout 1); its own gate passes, only the + // script gate fires. + let tiny_op = vec![0u8; 33]; + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, height, account_index, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, ?3, NULL, 0, 0, NULL)", + params![w.as_slice(), tiny_op.as_slice(), oversize_script.as_slice()], + ) + .expect("insert oversize utxo script row"); + + let err = core_state::load_used_addresses(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_used_addresses must reject an oversize utxo script blob"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge for oversize utxo script, got {err:?}" + ); +} + // ── platform_addrs — address column (fixed 20 bytes) ──────────────────────── /// A `platform_addresses` row whose `address` column is wider than 20 bytes diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 761e4178906..93687643b46 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -67,6 +67,11 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT length(outpoint), outpoint, value, length(script), script, height", ), ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), + // Pool reader: verbatim used-set, a one-shot read-only scan per wallet. + ( + "core_pool.rs", + "SELECT DISTINCT script FROM core_address_pool", + ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( "accounts.rs", diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs new file mode 100644 index 00000000000..961a7579674 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs @@ -0,0 +1,507 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `core_address_pool` writer + `core_utxos.account_index` attribution. +//! Covers TC-B-001 (pool rows with `used` flags), TC-B-002 +//! (real account_index, not the retired `=0` constant), TC-B-010 (idempotent +//! per-changeset pool state), TC-B-015 (`key_class` survives). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network, Utxo}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +/// Real external-pool `AddressInfo`s for a wallet's Standard BIP44 account 0, +/// sorted by derivation index — genuine scripts that round-trip. +fn external_infos(seed_byte: u8) -> Vec { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos; + } + } + panic!("wallet must expose a non-empty Standard BIP44 external pool"); +} + +fn utxo_on(info: &AddressInfo, value: u64) -> Utxo { + use dashcore::hashes::Hash; + Utxo { + outpoint: dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([info.index as u8 ^ 0x5A; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value, + script_pubkey: info.script_pubkey.clone(), + }, + address: info.address.clone(), + height: 10, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + } +} + +fn pool_entry( + account_type: AccountType, + pool_type: AddressPoolType, + addresses: Vec, +) -> AccountAddressPoolEntry { + AccountAddressPoolEntry { + account_type, + pool_type, + addresses, + } +} + +/// TC-B-001 — six pool rows with `used` set on indices {0,2,4}; the pool +/// table is a first-class row store, not a `core_utxos` derivation. +#[test] +fn tc_b_001_pool_rows_with_used_flags() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA0); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x11); + infos.truncate(6); + assert_eq!(infos.len(), 6, "need at least six derived addresses"); + for info in infos.iter_mut() { + info.used = matches!(info.index, 0 | 2 | 4); + } + let entry = pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos.clone(), + ); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![entry], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND key_class = 0 AND pool_type = 0", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 6, "exactly six scoped rows"); + + for info in &infos { + let used: i64 = conn + .query_row( + "SELECT used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND key_class = 0 \ + AND pool_type = 0 AND address_index = ?2", + rusqlite::params![w.as_slice(), i64::from(info.index)], + |r| r.get(0), + ) + .unwrap(); + let expect = i64::from(matches!(info.index, 0 | 2 | 4)); + assert_eq!(used, expect, "used flag for index {}", info.index); + } +} + +/// TC-B-002 — a UTXO whose owning account is index 1 stores +/// `account_index = 1`, not the retired hardcoded 0. +#[test] +fn tc_b_002_account_index_is_real_not_zero() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA2); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x22); + let addr0 = infos[0].clone(); + let addr1 = infos[1].clone(); + + // Pools declaring the address' owning account: addr0 -> account 0, + // addr1 -> account 1 (non-default). + let pools = vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![addr0.clone()], + ), + pool_entry( + AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + vec![addr1.clone()], + ), + ]; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: pools, + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr0, 111), utxo_on(&addr1, 222)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let account_for = |script: &[u8]| -> i64 { + conn.query_row( + "SELECT account_index FROM core_utxos WHERE wallet_id = ?1 AND script = ?2", + rusqlite::params![w.as_slice(), script], + |r| r.get(0), + ) + .unwrap() + }; + assert_eq!( + account_for(addr1.script_pubkey.as_bytes()), + 1, + "UTXO on account 1's address must store account_index = 1" + ); + assert_eq!( + account_for(addr0.script_pubkey.as_bytes()), + 0, + "UTXO on account 0's address must store account_index = 0" + ); +} + +/// A UTXO whose script matches no pool row falls back to account 0 — the +/// one-way historical-attribution default (R7), funds never dropped. +#[test] +fn utxo_without_pool_row_defaults_to_account_zero() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA3); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x33); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&infos[0], 500)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let account: i64 = conn + .query_row( + "SELECT account_index FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(account, 0, "unattributed UTXO defaults to account 0"); +} + +/// TC-B-010 — a used-flag flip persists and a second no-op flush leaves the +/// pool rows unchanged; `used` is monotonic and never reverts. +#[test] +fn tc_b_010_pool_state_idempotent_and_monotonic() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA4); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x44); + infos.truncate(3); + let mk = |infos: &[AddressInfo]| { + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + infos.to_vec(), + ) + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&infos)], + ..Default::default() + }, + ) + .unwrap(); + + // Flip index 1 to used. + let mut flipped = infos.clone(); + flipped[1].used = true; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&flipped)], + ..Default::default() + }, + ) + .unwrap(); + + let used_of = |conn: &rusqlite::Connection, idx: u32| -> i64 { + conn.query_row( + "SELECT used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_index = 0 AND pool_type = 0 AND address_index = ?2", + rusqlite::params![w.as_slice(), i64::from(idx)], + |r| r.get(0), + ) + .unwrap() + }; + { + let conn = persister.lock_conn_for_test(); + assert_eq!(used_of(&conn, 1), 1, "flip must persist"); + assert_eq!(used_of(&conn, 0), 0, "unrelated row unchanged"); + } + + // A stale snapshot with used=false for index 1 must NOT un-use it. + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![mk(&infos)], + ..Default::default() + }, + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + assert_eq!( + used_of(&conn, 1), + 1, + "used is monotonic — a stale snapshot never reverts it" + ); +} + +/// TC-B-015 — a non-default `key_class` round-trips into the pool row's PK. +#[test] +fn tc_b_015_key_class_survives() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA5); + ensure_wallet_meta(&persister, &w); + + let infos = external_infos(0x55); + let entry = pool_entry( + AccountType::PlatformPayment { + account: 2, + key_class: 1, + }, + AddressPoolType::External, + vec![infos[0].clone()], + ); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![entry], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let (account_index, key_class): (i64, i64) = conn + .query_row( + "SELECT account_index, key_class FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(account_index, 2, "PlatformPayment account index"); + assert_eq!(key_class, 1, "non-default key_class must survive"); +} + +/// A single external `AddressInfo` at derivation index 0 for a seed, with a +/// chosen `used` flag. Two seeds yield distinct scripts so a cross-account +/// overwrite is observable. +fn index_zero_info(seed_byte: u8, used: bool) -> Vec { + let mut infos = external_infos(seed_byte); + infos.truncate(1); + infos[0].used = used; + infos +} + +/// Assert the pool rows for `(wallet, account_type)` are exactly `(script, +/// used)`, and that `total` rows exist for the wallet overall. +fn assert_pool_row( + persister: &platform_wallet_storage::SqlitePersister, + w: &WalletId, + label: &str, + want_script: &[u8], + want_used: i64, +) { + let conn = persister.lock_conn_for_test(); + let (script, used): (Vec, i64) = conn + .query_row( + "SELECT script, used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = ?2", + rusqlite::params![w.as_slice(), label], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap_or_else(|e| panic!("expected exactly one row for {label}: {e}")); + assert_eq!(script, want_script, "{label} script must survive verbatim"); + assert_eq!(used, want_used, "{label} used flag must survive"); +} + +/// Two account types that both collapse to the `(account_index=0, +/// key_class=0)` sentinel — `IdentityRegistration` and `ProviderVotingKeys` — +/// must not overwrite each other's pool rows. Before the PK was widened with +/// `account_type` they upserted onto one PK tuple, silently losing one +/// account's `script` and merging `used`. +#[test] +fn distinct_account_types_sharing_index_zero_do_not_collide() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA6); + ensure_wallet_meta(&persister, &w); + + let id_reg = index_zero_info(0x61, true); + let prov = index_zero_info(0x62, false); + assert_ne!( + id_reg[0].script_pubkey, prov[0].script_pubkey, + "the two account types must carry distinct scripts to prove no overwrite" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::IdentityRegistration, + AddressPoolType::External, + id_reg.clone(), + ), + pool_entry( + AccountType::ProviderVotingKeys, + AddressPoolType::External, + prov.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_pool_row( + &persister, + &w, + "identity_registration", + id_reg[0].script_pubkey.as_bytes(), + 1, + ); + assert_pool_row( + &persister, + &w, + "provider_voting", + prov[0].script_pubkey.as_bytes(), + 0, + ); + let conn = persister.lock_conn_for_test(); + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "both account types must persist as separate rows"); +} + +/// `Standard { index: 0 }` and `CoinJoin { index: 0 }` also both map to +/// `(account_index=0, key_class=0)` yet are distinct accounts; the +/// `account_type` discriminator (`standard_bip44` vs `coinjoin`) must keep +/// their pool rows separate. +#[test] +fn standard_and_coinjoin_index_zero_do_not_collide() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA7); + ensure_wallet_meta(&persister, &w); + + let std0 = index_zero_info(0x71, true); + let cj0 = index_zero_info(0x72, false); + assert_ne!( + std0[0].script_pubkey, cj0[0].script_pubkey, + "the two account types must carry distinct scripts to prove no overwrite" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + AddressPoolType::External, + std0.clone(), + ), + pool_entry( + AccountType::CoinJoin { index: 0 }, + AddressPoolType::External, + cj0.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + assert_pool_row( + &persister, + &w, + "standard_bip44", + std0[0].script_pubkey.as_bytes(), + 1, + ); + assert_pool_row( + &persister, + &w, + "coinjoin", + cj0[0].script_pubkey.as_bytes(), + 0, + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs new file mode 100644 index 00000000000..f49e8802113 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs @@ -0,0 +1,410 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Migration execution against the populated-V001 fixture. +//! Covers TC-B-031 (data preserved), TC-B-032 (pre-migration auto-backup), +//! TC-B-033 (backup restorable + re-migration determinism), TC-B-034 +//! (forward-version rejection at the new max), TC-B-035 (idempotent +//! re-entry), TC-B-036 (empty wallet through migration). + +mod common; + +use std::path::{Path, PathBuf}; + +use common::{ro_conn, wid}; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig, WalletStorageError}; +use rusqlite::Connection; + +const FULL_WALLET: u8 = 0xA1; +const EMPTY_WALLET: u8 = 0xB2; + +fn fixture_src() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("populated_v001.db") +} + +/// Copy the committed V001 fixture into `dir` so migration runs on a +/// throwaway copy, never the committed file. +fn copy_fixture(dir: &Path) -> PathBuf { + let dst = dir.join("wallet.db"); + std::fs::copy(fixture_src(), &dst).expect("copy fixture"); + dst +} + +fn schema_version(conn: &Connection) -> i64 { + conn.query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap() +} + +fn table_exists(conn: &Connection, table: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + rusqlite::params![table], + |_| Ok(()), + ) + .is_ok() +} + +fn count(conn: &Connection, sql: &str, wallet: &[u8; 32]) -> i64 { + conn.query_row(sql, rusqlite::params![wallet.as_slice()], |r| r.get(0)) + .unwrap() +} + +/// Assert the post-migration store carries the full fixture data intact. +fn assert_full_data_preserved(conn: &Connection) { + let full = wid(FULL_WALLET); + assert_eq!(schema_version(conn), 2, "must be migrated to V002"); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 2, + "both wallets preserved" + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + &full + ), + 1 + ); + let (utxos, acct): (i64, i64) = conn + .query_row( + "SELECT COUNT(*), MAX(account_index) FROM core_utxos WHERE wallet_id = ?1", + rusqlite::params![full.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(utxos, 1, "UTXO preserved"); + assert_eq!( + acct, 0, + "pre-existing UTXO keeps account_index=0 (R7 one-way backfill)" + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + &full + ), + 1 + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + &full + ), + 1 + ); + assert_eq!( + count( + conn, + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + &full + ), + 1 + ); + // New V002 tables exist with sane defaults. + assert!(table_exists(conn, "core_address_pool")); + assert!(table_exists(conn, "meta_data_versions")); + let gen_len: i64 = conn + .query_row( + "SELECT length(generation) FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(gen_len, 16, "generation seeded at migration"); +} + +/// TC-B-031 — opening a populated V001 fixture with the post-redirect binary +/// migrates it and preserves every pre-existing row. +#[test] +fn tc_b_031_populated_v001_migration_preserves_data() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + { + let pre = ro_conn(&path); + assert_eq!(schema_version(&pre), 1, "fixture starts at V001"); + } + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = p.lock_conn_for_test(); + assert_full_data_preserved(&conn); + } + // The full wallet reconstructs; the used-set falls back to the + // UTXO-derived address (no pool rows in a migrated store). + let state = p.load().unwrap(); + let full = wid(FULL_WALLET); + let slice = state.wallets.get(&full).expect("full wallet reconstructs"); + assert_eq!( + slice.used_core_addresses.len(), + 1, + "migrated store falls back to the UTXO-derived used-set" + ); +} + +/// TC-B-036 — the empty wallet inside the populated store migrates without a +/// NOT NULL violation and reads empty-but-valid. +#[test] +fn tc_b_036_empty_wallet_through_migration() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let state = p.load().unwrap(); + let empty = wid(EMPTY_WALLET); + let slice = state + .wallets + .get(&empty) + .expect("empty wallet still surfaces post-migration"); + assert!( + slice.used_core_addresses.is_empty(), + "empty wallet is empty-but-valid, not corrupt" + ); +} + +/// TC-B-032 — a byte-faithful pre-migration auto-backup is written before the +/// schema changes are visible in the live file. +#[test] +fn tc_b_032_pre_migration_backup_created() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let backup_dir = tmp.path().join("backups"); + let p = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap(); + drop(p); + + let backup = std::fs::read_dir(&backup_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("pre-migration-1-to-2-") && n.ends_with(".db")) + }) + .expect("pre-migration backup must exist"); + + // The backup captured the PRE-migration state: schema version 1, and no + // V002 table. + let bconn = ro_conn(&backup); + assert_eq!( + schema_version(&bconn), + 1, + "backup is the pre-migration V001 state" + ); + assert!( + !table_exists(&bconn, "core_address_pool"), + "backup must predate the V002 schema" + ); + assert_eq!( + bconn + .query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) + .unwrap(), + 2, + "backup carries the original data" + ); +} + +/// TC-B-033 — the pre-migration backup restores cleanly and re-migrating it +/// reaches the identical end state as a direct migration (determinism). +#[test] +fn tc_b_033_backup_restorable_and_remigration_deterministic() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let backup_dir = tmp.path().join("backups"); + { + let _p = SqlitePersister::open( + SqlitePersisterConfig::new(&path).with_auto_backup_dir(Some(backup_dir.clone())), + ) + .unwrap(); + } + let backup = std::fs::read_dir(&backup_dir) + .unwrap() + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .find(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("pre-migration-1-to-2-")) + }) + .expect("backup exists"); + + // Restore the V001 backup into a fresh dest, then reopen to re-migrate. + let dest = tmp.path().join("restored.db"); + SqlitePersister::restore_from_skip_backup(&dest, &backup).expect("restore V001 backup"); + { + let rconn = ro_conn(&dest); + assert_eq!(schema_version(&rconn), 1, "restored store is at V001"); + } + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&dest)).unwrap(); + let conn = p2.lock_conn_for_test(); + assert_full_data_preserved(&conn); +} + +/// TC-B-034 — the forward-version gate now rejects at the NEW max (2); a +/// forged version-3 row is refused. +#[test] +fn tc_b_034_forward_version_rejected_at_new_max() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + { + let _p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + } + { + let conn = Connection::open(&path).unwrap(); + conn.execute( + "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ + VALUES (3, 'future', '', '0')", + [], + ) + .unwrap(); + } + match SqlitePersister::open(SqlitePersisterConfig::new(&path)) { + Err(WalletStorageError::SchemaVersionUnsupported { + found, + max_supported, + }) => { + assert_eq!(found, 3); + assert_eq!(max_supported, 2, "max must reflect the post-redirect V002"); + } + Err(other) => panic!("expected SchemaVersionUnsupported, got {other:?}"), + Ok(_) => panic!("forward-version DB must be refused"), + } +} + +/// A structural + row snapshot of the affected tables, for convergence +/// comparison between a clean migration and a recovered one. Excludes the +/// per-store random generation token (unique by design). +fn migration_snapshot(conn: &Connection) -> Vec { + let full = wid(FULL_WALLET); + vec![ + schema_version(conn), + conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get(0)) + .unwrap(), + count( + conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM identities WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM contacts WHERE wallet_id = ?1", + &full, + ), + count( + conn, + "SELECT COUNT(*) FROM account_registrations WHERE wallet_id = ?1", + &full, + ), + i64::from(table_exists(conn, "core_address_pool")), + i64::from(table_exists(conn, "meta_data_versions")), + i64::from(table_exists(conn, "meta_store_generation")), + ] +} + +/// TC-B-035 — crash mid-migrate: an interrupted V002 (partial DDL, no commit) +/// leaves the store at the last committed version (V001) with no partial +/// tables; re-opening resumes and converges byte-equal to a clean direct +/// migration. Empirically demonstrates refinery's per-migration transaction +/// guarantee (one tx per migration — no `set_grouped`/`no_transaction`). +#[test] +fn tc_b_035_interrupted_migration_recovers_to_clean_state() { + // Reference: a fresh copy migrated straight through. + let clean_dir = tempfile::tempdir().unwrap(); + let clean_path = copy_fixture(clean_dir.path()); + let clean_snapshot = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&clean_path)).unwrap(); + let conn = p.lock_conn_for_test(); + migration_snapshot(&conn) + }; + assert_eq!(clean_snapshot[0], 2, "clean migration reaches V002"); + + // Crash simulation: apply part of V002's DDL inside a transaction that is + // rolled back before commit — exactly what a crash before the migration's + // single COMMIT leaves behind (SQLite DDL is transactional). + let crash_dir = tempfile::tempdir().unwrap(); + let crash_path = copy_fixture(crash_dir.path()); + { + let conn = Connection::open(&crash_path).unwrap(); + conn.execute_batch( + "BEGIN; \ + CREATE TABLE core_address_pool ( \ + wallet_id BLOB NOT NULL, account_type TEXT NOT NULL, \ + account_index INTEGER NOT NULL, \ + key_class INTEGER NOT NULL, pool_type INTEGER NOT NULL, \ + address_index INTEGER NOT NULL, script BLOB NOT NULL, \ + used INTEGER NOT NULL); \ + ROLLBACK;", + ) + .unwrap(); + // The rolled-back DDL left no trace: still V001, no partial table. + let pre = ro_conn(&crash_path); + assert_eq!(schema_version(&pre), 1, "interrupted migrate stays at V001"); + assert!( + !table_exists(&pre, "core_address_pool"), + "partial DDL must have rolled back" + ); + } + + // Recovery: re-open runs the pending migration cleanly. + let recovered_snapshot = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&crash_path)).unwrap(); + let conn = p.lock_conn_for_test(); + migration_snapshot(&conn) + }; + assert_eq!( + recovered_snapshot, clean_snapshot, + "a store recovered from an interrupted migration must converge to the \ + same end state as a clean direct migration" + ); +} + +/// Re-entry idempotency: reopening a fully-migrated store is a no-op — no +/// further migration, and the generation token does not rotate (it only +/// rotates on migrate/restore, not a plain reopen). +#[test] +fn reopen_of_migrated_store_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let path = copy_fixture(tmp.path()); + let read = |conn: &Connection| -> (Vec, [u8; 16]) { + let gen: Vec = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .unwrap(); + (migration_snapshot(conn), gen.try_into().unwrap()) + }; + let first = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p.lock_conn_for_test(); + read(&conn) + }; + let second = { + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let conn = p.lock_conn_for_test(); + read(&conn) + }; + assert_eq!(first.0[0], 2, "first open migrates to V002"); + assert_eq!(first, second, "reopen is a byte-stable no-op"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs new file mode 100644 index 00000000000..f550d8b2108 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs @@ -0,0 +1,345 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Verbatim pool-snapshot reader. Covers TC-B-020 (used-set +//! comes from `core_address_pool`, not `core_utxos` re-derivation), TC-B-023 +//! (deep-derivation window — no horizon-walk truncation), TC-B-025/007 +//! (empty wallet loads empty-but-valid), multi-wallet isolation (TC-B-026), +//! and the pool ∪ `core_utxos` used-set union (pre-pool + mixed stores). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::address::Payload; +use dashcore::hashes::Hash; +use dashcore::{Address, Network, PubkeyHash}; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Utxo}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +fn external_infos(seed_byte: u8) -> Vec { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type == AddressPoolType::External && !pool.addresses.is_empty() { + let mut infos: Vec = pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos; + } + } + } + panic!("no external pool"); +} + +fn p2pkh(byte: u8) -> Address { + Address::new( + Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([byte; 20])), + ) +} + +/// TC-B-020 — the used-set is the verbatim pool `used=1` state, computed +/// without touching `core_utxos`: no UTXO is stored, yet the used addresses +/// surface (a projection-derived reader would return an empty set). +#[test] +fn tc_b_020_used_set_from_pool_not_utxos() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x20); + ensure_wallet_meta(&persister, &w); + + let mut infos = external_infos(0x20); + infos.truncate(10); + assert_eq!(infos.len(), 10); + let used_indices = [0u32, 3, 7]; + for info in infos.iter_mut() { + info.used = used_indices.contains(&info.index); + } + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: infos.clone(), + }], + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces in load"); + let got: std::collections::BTreeSet = slice + .used_core_addresses + .iter() + .map(|a| a.to_string()) + .collect(); + let expected: std::collections::BTreeSet = infos + .iter() + .filter(|i| used_indices.contains(&i.index)) + .map(|i| i.address.to_string()) + .collect(); + assert_eq!(got, expected, "used-set must equal the pool's used=1 rows"); +} + +/// TC-B-023 — a wallet whose pool advanced past the old horizon-walk window +/// (used up to index 45, then 30 unused) restores its full used-set: the +/// index-45 address is present, never truncated at 30. +#[test] +fn tc_b_023_deep_derivation_window_not_truncated() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x23); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + for i in 0u32..=75 { + let used = i32::from(i <= 45); + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, ?2, ?3, ?4)", + rusqlite::params![ + w.as_slice(), + i64::from(i), + p2pkh(i as u8).script_pubkey().as_bytes(), + used + ], + ) + .unwrap(); + } + } + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces"); + assert_eq!( + slice.used_core_addresses.len(), + 46, + "indices 0..=45 are used and must all restore" + ); + let want = p2pkh(45).to_string(); + assert!( + slice + .used_core_addresses + .iter() + .any(|a| a.to_string() == want), + "the index-45 used address must survive (no gap-limit-30 truncation)" + ); +} + +/// TC-B-025/007 — an empty wallet (a `wallets` row, no pool rows, no UTXOs) +/// loads as empty-but-valid: present with an empty used-set, not corrupt. +#[test] +fn tc_b_025_empty_wallet_is_empty_but_valid() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x25); + ensure_wallet_meta(&persister, &w); + + let state = persister.load().unwrap(); + let slice = state + .wallets + .get(&w) + .expect("empty wallet must still surface"); + assert!( + slice.used_core_addresses.is_empty(), + "empty wallet has an empty used-set" + ); +} + +fn utxo_on(addr: &Address, byte: u8, value: u64) -> Utxo { + Utxo::new( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([byte; 32]), 0), + dashcore::TxOut { + value, + script_pubkey: addr.script_pubkey(), + }, + addr.clone(), + 10, + false, + ) +} + +/// A pre-pool store (UTXOs, no `core_address_pool` rows) yields the +/// reuse-guard set from the `core_utxos`-derived half of the union. +#[test] +fn pre_pool_store_yields_utxo_derived_used_set() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x26); + ensure_wallet_meta(&persister, &w); + + let addr = p2pkh(0x99); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr, 0x11, 1000)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces"); + assert_eq!(slice.used_core_addresses.len(), 1); + assert_eq!(slice.used_core_addresses[0].to_string(), addr.to_string()); +} + +/// TC-B-026 — reader multi-wallet isolation: two wallets seeded with +/// distinct, distinguishable used addresses (and balances) load such that +/// neither wallet's snapshot shows the other's — no cross-wallet leakage. +#[test] +fn tc_b_026_reader_isolates_two_wallets() { + let (persister, _tmp, _path) = fresh_persister(); + let a: WalletId = wid(0x2A); + let b: WalletId = wid(0x2B); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + let addr_a = p2pkh(0xA1); + let addr_b = p2pkh(0xB1); + persister + .store( + a, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr_a, 0x01, 111)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + persister + .store( + b, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&addr_b, 0x02, 222)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let a_used: Vec = state.wallets[&a] + .used_core_addresses + .iter() + .map(|x| x.to_string()) + .collect(); + let b_used: Vec = state.wallets[&b] + .used_core_addresses + .iter() + .map(|x| x.to_string()) + .collect(); + assert_eq!( + a_used, + vec![addr_a.to_string()], + "A sees only its own address" + ); + assert_eq!( + b_used, + vec![addr_b.to_string()], + "B sees only its own address" + ); + assert!( + !a_used.contains(&addr_b.to_string()), + "A must not see B's address" + ); + assert!( + !b_used.contains(&addr_a.to_string()), + "B must not see A's address" + ); +} + +/// Mixed-store regression — a historical `core_utxos` address that +/// a later partial pool snapshot never enumerates must surface BOTH the +/// historical UTXO address and the pool used address. The union must never +/// let the pool set shadow the historical one (address-reuse / funds safety). +#[test] +fn mixed_store_unions_utxo_and_pool_used_sets() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0x27); + ensure_wallet_meta(&persister, &w); + + // Historical UTXO on address X, written before any pool snapshot exists. + let historical = p2pkh(0xAA); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo_on(&historical, 0x12, 500)], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + // A later pool snapshot marks a DIFFERENT address Y used and does not + // enumerate the historical address at all. + let mut infos = external_infos(0x27); + infos.truncate(1); + infos[0].used = true; + let pool_used = infos[0].address.clone(); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + pool_type: AddressPoolType::External, + addresses: infos.clone(), + }], + ..Default::default() + }, + ) + .unwrap(); + + let state = persister.load().unwrap(); + let slice = state.wallets.get(&w).expect("wallet surfaces"); + let got: std::collections::BTreeSet = slice + .used_core_addresses + .iter() + .map(|a| a.to_string()) + .collect(); + assert!( + got.contains(&historical.to_string()), + "historical UTXO address must survive a later partial pool snapshot" + ); + assert!( + got.contains(&pool_used.to_string()), + "pool used address must be present" + ); + assert_eq!(got.len(), 2, "exactly the union of both sources, deduped"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs new file mode 100644 index 00000000000..a472ce46dab --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -0,0 +1,116 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Content-level schema-freeze guards. +//! +//! TC-B-040: pin the rendered migration SQL with a golden fingerprint so an +//! in-place DDL edit (which the identity-only fingerprint is documented not +//! to catch) breaks CI. TC-B-041: assert the retired cross-branch table +//! names never appear as SQL identifiers in the writer/reader/migration/ +//! backup SQL — the drift the content-blind fingerprint cannot catch. + +use std::path::Path; + +use platform_wallet_storage::sqlite::migrations as mig; + +/// Golden `(version, name)` fingerprint of the frozen migration set. Bump +/// deliberately only when adding/removing/renaming a migration file. +const EXPECTED_ID_FINGERPRINT: &str = + "114e07f057947594e3d098ba62169f0887c2a407feb78c7ea835a4b35d582fd9"; + +/// Golden content-level fingerprint over every migration's rendered SQL. +/// Bump deliberately only when the DDL body itself changes; an accidental +/// change (a silent table rename) must fail this test, not slip through. +const EXPECTED_SQL_FINGERPRINT: &str = + "98f2a7c86a1383fc32922551c537d1af9955428f6068afde9dd33f2a8a49d90d"; + +/// Table names that lost the cross-branch reconciliation and must never +/// resurface as SQL identifiers on this frozen (`wallets`) baseline. +const RETIRED_SQL_NAMES: &[&str] = &[ + "wallet_metadata", + "account_address_pools", + "core_derived_addresses", +]; + +/// TC-B-040 (identity) — the migration set's identity is pinned. +#[test] +fn tc_b_040_identity_fingerprint_pinned() { + assert_eq!( + hex::encode(mig::embedded_migrations_fingerprint()), + EXPECTED_ID_FINGERPRINT, + "migration set identity changed; a file was added/removed/renamed. \ + If intentional, update EXPECTED_ID_FINGERPRINT." + ); +} + +/// TC-B-040 (content) — the rendered migration SQL is pinned, closing the +/// content-blind gap the identity fingerprint documents. +#[test] +fn tc_b_040_sql_fingerprint_pinned() { + assert_eq!( + hex::encode(mig::embedded_migrations_sql_fingerprint()), + EXPECTED_SQL_FINGERPRINT, + "a migration's DDL body changed. On this frozen baseline that is a \ + schema-drift alarm (D0). If intentional, update EXPECTED_SQL_FINGERPRINT." + ); +} + +/// The retired names appear nowhere in the rendered migration SQL. +#[test] +fn tc_b_041_migration_sql_has_no_retired_names() { + for sql in mig::embedded_migrations_sql() { + for name in RETIRED_SQL_NAMES { + assert!( + !sql.contains(name), + "retired table name `{name}` present in migration SQL" + ); + } + } +} + +/// TC-B-041 — no writer/reader/migration/backup SQL string references a +/// retired table name. `wallet_metadata` / `account_address_pools` are also +/// legitimate Rust changeset fields, so the scan flags only SQL-keyword-led +/// table usage (`FROM`/`INTO`/`UPDATE`/`TABLE`/`JOIN`/`ON `), never a +/// bare `cs.` access. +#[test] +fn tc_b_041_no_retired_table_name_in_sql_strings() { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let migrations_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("migrations"); + let sql_keywords = ["FROM", "INTO", "UPDATE", "TABLE", "JOIN", "ON"]; + + let mut offenders = Vec::new(); + for dir in [src, migrations_dir] { + visit(&dir, &mut |path, line_no, line| { + for name in RETIRED_SQL_NAMES { + for kw in sql_keywords { + if line.contains(&format!("{kw} {name}")) { + offenders.push(format!("{}:{line_no}: {}", path.display(), line.trim())); + } + } + } + }); + } + assert!( + offenders.is_empty(), + "retired table name used in SQL: {offenders:#?}" + ); +} + +fn visit(dir: &Path, on_line: &mut impl FnMut(&Path, usize, &str)) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + visit(&p, on_line); + } else if p.extension().is_some_and(|e| e == "rs") { + let Ok(text) = std::fs::read_to_string(&p) else { + continue; + }; + for (i, line) in text.lines().enumerate() { + on_line(&p, i + 1, line); + } + } + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs new file mode 100644 index 00000000000..7d7c396e683 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs @@ -0,0 +1,165 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Store-generation token behaviour. Covers TC-B-004 +//! (present + stable across a normal flush) and TC-B-024 (regenerated on +//! restore so a restored copy is distinguishable from its source). + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, ro_conn, wid}; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::versions; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +/// TC-B-004 — the generation is present, 16 bytes, and unchanged by a normal +/// changeset flush (it only rotates on migrate/restore). +#[test] +fn tc_b_004_generation_present_and_stable_across_flush() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0x01); + ensure_wallet_meta(&persister, &w); + + let g1 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn) + .unwrap() + .expect("fresh V002 store carries a generation") + }; + assert!( + g1.iter().any(|b| *b != 0), + "generation must not be all-zero" + ); + + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g2 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_eq!(g1, g2, "a normal flush must not rotate the generation"); + drop(persister); + let _ = path; +} + +/// TC-B-024 — restoring from a backup rotates the generation, so a client +/// cache keyed on the pre-restore generation misses rather than serving +/// stale entries. +#[test] +fn tc_b_024_generation_rotates_on_restore() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x02); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(5), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g1 = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + let backup_path = persister.backup_to(tmp.path()).unwrap(); + // The backup is a byte-copy, so it carries the same generation. + { + let bconn = ro_conn(&backup_path); + assert_eq!( + versions::read_generation(&bconn).unwrap().unwrap(), + g1, + "backup carries the source generation verbatim" + ); + } + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + let g2 = { + let conn = p2.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_ne!( + g1, g2, + "restore must rotate the generation (restored copy != source)" + ); + drop(p2); + drop(tmp); +} + +/// The generation is rotated as part of the atomic swap — folded into the +/// staged temp BEFORE the rename — not by a post-swap RW re-open on the +/// destination. Proof: right after `restore_from` returns, the destination +/// already carries the rotated token (readable via a read-only open, no RW +/// connection needed) AND has no lingering `-wal`/`-shm` siblings. The old +/// ordering rotated the token through a post-swap RW connection, which on a +/// WAL-mode DB left sibling files behind; folding it into the swap removes any +/// window where restored content is observable with the source's stale token. +#[test] +fn generation_rotated_within_atomic_swap_leaves_no_wal_siblings() { + let (persister, tmp, path) = fresh_persister(); + let w = wid(0x03); + ensure_wallet_meta(&persister, &w); + persister + .store( + w, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(9), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + + let g_src = { + let conn = persister.lock_conn_for_test(); + versions::read_generation(&conn).unwrap().unwrap() + }; + let backup_path = persister.backup_to(tmp.path()).unwrap(); + drop(persister); + + SqlitePersister::restore_from_skip_backup(&path, &backup_path).expect("restore"); + + // No WAL/SHM siblings linger: regeneration ran on the staged temp, not via + // a post-swap RW open on the destination. + for ext in ["-wal", "-shm"] { + let sibling = std::path::PathBuf::from(format!("{}{ext}", path.display())); + assert!( + !sibling.exists(), + "restored DB must have no {ext} sibling (regen must not re-open dest RW): {sibling:?}" + ); + } + + // The rotated token is already observable via a read-only open (no RW + // connection created) and differs from the source's. + let g_dst = { + let conn = ro_conn(&path); + versions::read_generation(&conn).unwrap().unwrap() + }; + assert_ne!( + g_src, g_dst, + "restore must rotate the generation within the atomic swap" + ); + drop(tmp); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs new file mode 100644 index 00000000000..f21966880af --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs @@ -0,0 +1,87 @@ +#![allow(clippy::field_reassign_with_default)] + +//! Cross-wallet isolation + delete cascade for the new V002 tables +//! (`core_address_pool`, `meta_data_versions`) — TC-B-006. Two wallets with +//! fully-overlapping keys must not collide, must not leak across wallets, and +//! deleting one must leave the other's V002 rows intact. + +mod common; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use platform_wallet::wallet::platform_wallet::WalletId; + +fn pool_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap() +} + +fn versions_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { + conn.query_row( + "SELECT COUNT(*) FROM meta_data_versions WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap() +} + +/// TC-B-006 — overlapping keys across two wallets coexist without PK +/// collision, and deleting wallet A cascades away only A's V002 rows while +/// wallet B's survive intact. +#[test] +fn tc_b_006_v002_tables_isolate_and_cascade_per_wallet() { + let (persister, _tmp, _path) = fresh_persister(); + let a: WalletId = wid(0x0A); + let b: WalletId = wid(0x0B); + ensure_wallet_meta(&persister, &a); + ensure_wallet_meta(&persister, &b); + + // Identical (account_type, account_index, key_class, pool_type, + // address_index, domain) for both wallets — only wallet_id differs. + { + let conn = persister.lock_conn_for_test(); + for w in [&a, &b] { + conn.execute( + "INSERT INTO core_address_pool \ + (wallet_id, account_type, account_index, key_class, pool_type, \ + address_index, script, used) \ + VALUES (?1, 'standard_bip44', 0, 0, 0, 0, ?2, 1)", + rusqlite::params![w.as_slice(), &[0xEEu8; 25][..]], + ) + .expect("overlapping-key pool rows must not collide across wallets"); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) \ + VALUES (?1, 'core', 3)", + rusqlite::params![w.as_slice()], + ) + .expect("overlapping-domain version rows must not collide across wallets"); + } + + // No cross-wallet read leakage: each wallet sees exactly its own row. + assert_eq!(pool_count(&conn, &a), 1); + assert_eq!(pool_count(&conn, &b), 1); + assert_eq!(versions_count(&conn, &a), 1); + assert_eq!(versions_count(&conn, &b), 1); + } + + // Delete wallet A — FK ON DELETE CASCADE (core_address_pool) and the + // meta_data_versions soft-cascade trigger must reap only A's rows. + persister.delete_wallet_skip_backup(a).expect("delete A"); + + let conn = persister.lock_conn_for_test(); + assert_eq!(pool_count(&conn, &a), 0, "A's pool rows cascade-deleted"); + assert_eq!( + versions_count(&conn, &a), + 0, + "A's version rows removed by the delete trigger" + ); + assert_eq!(pool_count(&conn, &b), 1, "B's pool rows survive A's delete"); + assert_eq!( + versions_count(&conn, &b), + 1, + "B's version rows survive A's delete" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs new file mode 100644 index 00000000000..f06018e3ed0 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs @@ -0,0 +1,198 @@ +#![allow(clippy::field_reassign_with_default)] + +//! V002 unified-migration schema tests. +//! +//! Covers TC-B-030 (fresh store migrates clean to the new target version), +//! TC-B-003 (`meta_data_versions` shape + PK), the schema half of TC-B-001 +//! (`core_address_pool` shape + PK), and the store-generation seed. + +mod common; + +use std::collections::BTreeMap; + +use common::fresh_persister; +use platform_wallet_storage::sqlite::migrations as mig; +use rusqlite::Connection; + +/// Column metadata from `PRAGMA table_info`: name → (type, notnull, pk_pos). +fn table_columns(conn: &Connection, table: &str) -> BTreeMap { + let mut stmt = conn + .prepare(&format!("PRAGMA table_info({table})")) + .expect("prepare table_info"); + let rows = stmt + .query_map([], |row| { + let name: String = row.get(1)?; + let ty: String = row.get(2)?; + let notnull: i64 = row.get(3)?; + let pk: i64 = row.get(5)?; + Ok((name, (ty, notnull != 0, pk))) + }) + .expect("query table_info"); + rows.map(|r| r.expect("row")).collect() +} + +fn table_exists(conn: &Connection, table: &str) -> bool { + conn.query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", + rusqlite::params![table], + |_| Ok(()), + ) + .optional_exists() +} + +trait OptionalExists { + fn optional_exists(self) -> bool; +} +impl OptionalExists for rusqlite::Result<()> { + fn optional_exists(self) -> bool { + matches!(self, Ok(())) + } +} + +/// The unified migration lifts the supported schema version to 2. +#[test] +fn max_supported_version_is_two() { + assert_eq!( + mig::max_supported_version(), + 2, + "V002 must raise max_supported_version to 2" + ); +} + +/// TC-B-030 — a fresh store migrates clean to the new target version and +/// every new table exists. +#[test] +fn tc_b_030_fresh_store_migrates_to_version_two() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let max: i64 = conn + .query_row( + "SELECT MAX(version) FROM refinery_schema_history", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(max, 2, "fresh store must land at schema version 2"); + for table in [ + "core_address_pool", + "meta_data_versions", + "meta_store_generation", + ] { + assert!(table_exists(&conn, table), "missing table {table}"); + } +} + +/// Schema half of TC-B-001 — `core_address_pool` carries per-index rows +/// scoped by `(wallet_id, account_type, account_index, key_class, pool_type, +/// address_index)`, a stored `script`, and a `used` flag. +#[test] +fn tc_b_001_core_address_pool_shape() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let cols = table_columns(&conn, "core_address_pool"); + + for (name, ty) in [ + ("wallet_id", "BLOB"), + ("account_type", "TEXT"), + ("account_index", "INTEGER"), + ("key_class", "INTEGER"), + ("pool_type", "INTEGER"), + ("address_index", "INTEGER"), + ("script", "BLOB"), + ("used", "INTEGER"), + ] { + let col = cols + .get(name) + .unwrap_or_else(|| panic!("core_address_pool missing column {name}")); + assert_eq!(col.0, ty, "column {name} has unexpected type"); + } + + // Composite PK includes account_type so accounts collapsing to the same + // (account_index, key_class) sentinel never overwrite each other, and + // pool_type so External/Internal pools never collide at one address_index. + let pk: BTreeMap = cols + .iter() + .filter(|(_, (_, _, pk))| *pk > 0) + .map(|(name, (_, _, pk))| (*pk, name.clone())) + .collect(); + let pk_order: Vec<&str> = pk.values().map(String::as_str).collect(); + assert_eq!( + pk_order, + vec![ + "wallet_id", + "account_type", + "account_index", + "key_class", + "pool_type", + "address_index" + ], + "core_address_pool PK must be (wallet_id, account_type, account_index, key_class, \ + pool_type, address_index)" + ); +} + +/// TC-B-003 — `meta_data_versions` is `(wallet_id BLOB, domain TEXT, seq +/// INTEGER)` with composite PK `(wallet_id, domain)`; `seq` defaults to 0. +#[test] +fn tc_b_003_meta_data_versions_shape() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let cols = table_columns(&conn, "meta_data_versions"); + + assert_eq!(cols["wallet_id"].0, "BLOB"); + assert_eq!(cols["domain"].0, "TEXT"); + assert_eq!(cols["seq"].0, "INTEGER"); + + let pk: BTreeMap = cols + .iter() + .filter(|(_, (_, _, pk))| *pk > 0) + .map(|(name, (_, _, pk))| (*pk, name.clone())) + .collect(); + let pk_order: Vec<&str> = pk.values().map(String::as_str).collect(); + assert_eq!( + pk_order, + vec!["wallet_id", "domain"], + "meta_data_versions PK must be (wallet_id, domain)" + ); + + // A domain with no writes yet has seq default 0. + let w = [0x01u8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain) VALUES (?1, 'core_pool')", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + let seq: i64 = conn + .query_row( + "SELECT seq FROM meta_data_versions WHERE wallet_id = ?1 AND domain = 'core_pool'", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(seq, 0, "seq must default to 0 for a fresh domain"); +} + +/// The store-generation token is seeded on migration as a non-empty +/// 16-byte blob in the single-row `meta_store_generation` table. +#[test] +fn store_generation_seeded_16_bytes() { + let (persister, _tmp, _path) = fresh_persister(); + let conn = persister.lock_conn_for_test(); + let gen: Vec = conn + .query_row( + "SELECT generation FROM meta_store_generation WHERE id = 0", + [], + |r| r.get(0), + ) + .expect("store generation row must exist"); + assert_eq!(gen.len(), 16, "store generation must be 16 bytes"); + assert!( + gen.iter().any(|b| *b != 0), + "generation must not be all-zero" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs new file mode 100644 index 00000000000..00dafbd4015 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs @@ -0,0 +1,427 @@ +#![allow(clippy::field_reassign_with_default)] + +//! `meta_data_versions` bump discipline. Covers TC-B-011 +//! (bump rides the flush tx), TC-B-012 (atomic rollback — data and bump are +//! all-or-nothing), TC-B-013 (every domain maps to a bump; none silently +//! excluded), TC-B-014 (saturating seq, never wraps). + +mod common; + +use std::collections::BTreeMap; + +use common::{ensure_wallet_meta, fresh_persister, wid}; +use dpp::prelude::Identifier; +use key_wallet::account::{AccountType, StandardAccountType}; +use key_wallet::managed_account::address_pool::AddressPoolType; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{AddressInfo, Network}; +use platform_wallet::changeset::{ + AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, ContactChangeSet, + ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, + IdentityKeysChangeSet, PlatformAddressBalanceEntry, PlatformAddressChangeSet, + PlatformWalletChangeSet, PlatformWalletPersistence, SentContactRequestKey, + TokenBalanceChangeSet, WalletMetadataEntry, +}; +use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::versions::{self, Domain}; + +fn one_external_info(seed_byte: u8) -> AddressInfo { + let wallet = Wallet::from_seed_bytes( + [seed_byte; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let info = ManagedWalletInfo::from_wallet(&wallet, 0); + for managed in info.all_managed_accounts() { + if !matches!( + managed.managed_account_type().to_account_type(), + AccountType::Standard { index: 0, .. } + ) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type == AddressPoolType::External && !pool.addresses.is_empty() { + return pool.addresses.values().next().cloned().unwrap(); + } + } + } + panic!("no external pool"); +} + +fn std_account() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + +fn test_xpub() -> key_wallet::bip32::ExtendedPubKey { + key_wallet::bip32::ExtendedPubKey::decode(&hex::decode( + "0488B21E000000000000000000873DFF81C02F525623FD1FE5167EAC3A55A049DE3D314BB42EE227FFED37D5080339A36013301597DAEF41FBE593A02CC513D0B55527EC2DF1050E2E8FF49C85C2", + ).unwrap()).unwrap() +} + +/// A changeset that touches exactly one domain, with minimal non-empty data. +/// DB-validity is irrelevant here — `touched_domains` is a pure function. +fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { + let mut cs = PlatformWalletChangeSet::default(); + match domain { + Domain::Core => { + cs.core = Some(CoreChangeSet { + synced_height: Some(1), + ..Default::default() + }) + } + Domain::Identities => { + let mut m = BTreeMap::new(); + let id = Identifier::from([0x01; 32]); + m.insert(id, identity_entry(id)); + cs.identities = Some(IdentityChangeSet { + identities: m, + removed: Default::default(), + }); + } + Domain::IdentityKeys => { + let mut keys = IdentityKeysChangeSet::default(); + let id = Identifier::from([0x02; 32]); + keys.upserts.insert((id, 0), identity_key_entry(id)); + cs.identity_keys = Some(keys); + } + Domain::Contacts => { + let mut sent = BTreeMap::new(); + sent.insert( + SentContactRequestKey { + owner_id: Identifier::from([0x03; 32]), + recipient_id: Identifier::from([0x04; 32]), + }, + contact_request_entry(0x03, 0x04), + ); + cs.contacts = Some(ContactChangeSet { + sent_requests: sent, + ..Default::default() + }); + } + Domain::PlatformAddresses => { + cs.platform_addresses = Some(PlatformAddressChangeSet { + addresses: vec![PlatformAddressBalanceEntry { + wallet_id: [0; 32], + account_index: 0, + address_index: 0, + address: key_wallet::PlatformP2PKHAddress::new([0x05; 20]), + funds: dash_sdk::platform::address_sync::AddressFunds { + balance: 1, + nonce: 0, + }, + }], + ..Default::default() + }); + } + Domain::AssetLocks => { + cs.asset_locks = Some(AssetLockChangeSet::default()); + // Empty map is "empty" — seed one entry to mark it touched. + cs.asset_locks = Some(asset_lock_changeset()); + } + Domain::TokenBalances => { + let mut balances = BTreeMap::new(); + balances.insert( + (Identifier::from([0x06; 32]), Identifier::from([0x07; 32])), + 1u64, + ); + cs.token_balances = Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }); + } + Domain::DashpayProfiles => { + let mut m = BTreeMap::new(); + m.insert(Identifier::from([0x08; 32]), None); + cs.dashpay_profiles = Some(m); + } + Domain::DashpayPaymentsOverlay => { + let mut inner = BTreeMap::new(); + inner.insert( + "tx".to_string(), + platform_wallet::wallet::identity::PaymentEntry::new_sent( + Identifier::from([0x0A; 32]), + 1, + None, + ), + ); + let mut m = BTreeMap::new(); + m.insert(Identifier::from([0x09; 32]), inner); + cs.dashpay_payments_overlay = Some(m); + } + Domain::WalletMetadata => { + cs.wallet_metadata = Some(WalletMetadataEntry { + network: Network::Testnet, + wallet_group_id: [0; 32], + birth_height: 1, + }); + } + Domain::AccountRegistrations => { + cs.account_registrations = vec![AccountRegistrationEntry { + account_type: std_account(), + account_xpub: test_xpub(), + }]; + } + Domain::AccountAddressPools => { + cs.account_address_pools = vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![], + }]; + } + } + cs +} + +fn identity_entry(id: Identifier) -> IdentityEntry { + IdentityEntry { + id, + balance: 1, + revision: 1, + identity_index: Some(0), + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + dpns_names: Vec::new(), + contested_dpns_names: Vec::new(), + status: IdentityStatus::Active, + wallet_id: None, + dashpay_profile: None, + dashpay_payments: Default::default(), + } +} + +fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + IdentityKeyEntry { + identity_id: id, + key_id: 0, + public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![2u8; 33]), + disabled_at: None, + }), + public_key_hash: [3u8; 20], + wallet_id: None, + derivation_indices: None, + } +} + +fn contact_request_entry(sender: u8, recipient: u8) -> ContactRequestEntry { + ContactRequestEntry { + request: ContactRequest { + sender_id: Identifier::from([sender; 32]), + recipient_id: Identifier::from([recipient; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_account_label: None, + encrypted_public_key: Vec::new(), + auto_accept_proof: None, + core_height_created_at: 0, + created_at: 0, + }, + } +} + +fn asset_lock_changeset() -> AssetLockChangeSet { + use dashcore::hashes::Hash; + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + let op = OutPoint { + txid: Txid::from_byte_array([0x0B; 32]), + vout: 0, + }; + let mut cs = AssetLockChangeSet::default(); + cs.asset_locks.insert( + op, + AssetLockEntry { + out_point: op, + transaction: Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 1, + status: AssetLockStatus::Built, + proof: None, + }, + ); + cs +} + +/// TC-B-013 — every domain maps to exactly its own bump; none silently +/// excluded. Each single-field changeset yields exactly its domain, and the +/// union covers `Domain::ALL`. The exhaustive destructure in +/// `touched_domains` makes a newly added field a compile error there. +#[test] +fn tc_b_013_every_domain_maps_and_isolates() { + use std::collections::BTreeSet; + let mut covered = BTreeSet::new(); + for domain in Domain::ALL { + let cs = single_domain_changeset(domain); + let touched = versions::touched_domains(&cs); + assert_eq!( + touched, + vec![domain], + "single-field changeset for {domain:?} must touch exactly that domain" + ); + covered.insert(domain.as_str()); + } + let all: BTreeSet<&str> = Domain::ALL.iter().map(|d| d.as_str()).collect(); + assert_eq!(covered, all, "all domains must be reachable"); +} + +/// TC-B-011 — a flush touching the core-pool domain commits the pool row and +/// its `meta_data_versions.seq` together (same connection, same tx). +#[test] +fn tc_b_011_bump_rides_the_flush() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB1); + ensure_wallet_meta(&persister, &w); + + let info = one_external_info(0x11); + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert!(pool_rows >= 1, "pool row must be present"); + let seq = versions::read_seq(&conn, &w, Domain::AccountAddressPools).unwrap(); + assert_eq!(seq, 1, "the domain's seq bumped in the same flush"); + // No unrelated domain bumped. + assert_eq!(versions::read_seq(&conn, &w, Domain::Core).unwrap(), 0); +} + +/// A domain bumps once per flush; two flushes → seq 2. +#[test] +fn repeated_flush_increments_seq() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB0); + ensure_wallet_meta(&persister, &w); + for _ in 0..2 { + persister + .store(w, single_domain_changeset(Domain::WalletMetadata)) + .unwrap(); + } + let conn = persister.lock_conn_for_test(); + assert_eq!( + versions::read_seq(&conn, &w, Domain::WalletMetadata).unwrap(), + 2 + ); +} + +/// TC-B-012 — atomicity: a flush that fails partway persists neither the +/// data nor the version bump. A pool write plus a token-balance write whose +/// identity FK is absent must roll the whole tx back. +#[test] +fn tc_b_012_partial_failure_rolls_back_data_and_bump() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB2); + ensure_wallet_meta(&persister, &w); + + let info = one_external_info(0x22); + let mut balances = BTreeMap::new(); + // No identities row for this id → token_balances FK violation mid-flush. + balances.insert( + (Identifier::from([0xEE; 32]), Identifier::from([0xEF; 32])), + 1u64, + ); + let result = persister.store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![AccountAddressPoolEntry { + account_type: std_account(), + pool_type: AddressPoolType::External, + addresses: vec![info], + }], + token_balances: Some(TokenBalanceChangeSet { + balances, + ..Default::default() + }), + ..Default::default() + }, + ); + assert!(result.is_err(), "FK violation must fail the flush"); + + let conn = persister.lock_conn_for_test(); + let pool_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + let version_rows: i64 = conn + .query_row( + "SELECT COUNT(*) FROM meta_data_versions WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(pool_rows, 0, "pool write must roll back with the failed tx"); + assert_eq!(version_rows, 0, "no bump may survive a rolled-back flush"); +} + +/// TC-B-014 — a seq pre-seeded to i64::MAX saturates on the next bump and +/// never wraps to a lower value (which would look like a cache rollback). +#[test] +fn tc_b_014_seq_saturates_at_i64_max() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xB4); + ensure_wallet_meta(&persister, &w); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO meta_data_versions (wallet_id, domain, seq) \ + VALUES (?1, 'wallet_metadata', 9223372036854775807)", + rusqlite::params![w.as_slice()], + ) + .unwrap(); + } + persister + .store(w, single_domain_changeset(Domain::WalletMetadata)) + .unwrap(); + let conn = persister.lock_conn_for_test(); + assert_eq!( + versions::read_seq(&conn, &w, Domain::WalletMetadata).unwrap(), + i64::MAX, + "seq must saturate, never wrap" + ); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index a5a5b72661d..ea2d6df3c1d 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1178,12 +1178,12 @@ pub struct PlatformWalletChangeSet { /// the merge policy (plain `Vec::extend`, dedup is the apply-side /// caller's job). pub account_registrations: Vec, - /// Full address-pool snapshots: emitted once at wallet registration. - /// Incremental derivations are delivered via `core.addresses_derived` - /// (the `WalletEvent` bus / FFI path); no per-block in-band pool - /// snapshot is written. The storage persister intentionally ignores this - /// field (UTXO attribution is hardcoded to account 0); non-storage - /// consumers (e.g. the iOS FFI address registry) may still read it. + /// Full address-pool snapshots: emitted once at wallet registration and + /// on later pool extension / used-flag flips. Incremental derivations + /// also arrive via `core.addresses_derived` (the `WalletEvent` bus / FFI + /// path). The storage persister expands these into per-index + /// `core_address_pool` rows (per-index `used` state + owning account for + /// UTXO attribution); the reader restores the used-set from them verbatim. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, /// Deferred contact-crypto ops enqueued by the seedless background sweep From 4624cece28436b7457e6e51e85eb0bcc3ae679b3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:37:53 +0000 Subject: [PATCH 078/108] fix(platform-wallet-storage): DashPay pool PK collision + V002 migration-number collision T5: core_address_pool's primary key omitted the DashPay (user_identity_id, friend_identity_id) pair, so two contacts on one wallet collapsed onto the same PK and silently overwrote each other's pool rows once dashpay_account_registration_changeset started emitting AccountAddressPoolEntry for DashPay contacts. Widen the PK to mirror account_registrations (V001), thread the identity pair through apply_pools/UPSERT_POOL_SQL and the account_index_for_script tie-break, and add a regression test covering two DashPay contacts on one wallet. While adding a passing regression test, found every SqlitePersister::open()/ migrations::run() call was failing with "UNIQUE constraint failed: refinery_schema_history.version": V002__address_height_pin.rs (PR #4019, already merged) and V002__unified.rs (PR #3986) both independently claimed migration version 2. Renumber the unified migration to V003 (rename file + test files, bump every version-2 reference to 3, recompute the schema-freeze golden fingerprints). This was silently failing 25/243 --lib unit tests and all 8 sqlite_core_pool_writer tests; all now pass (243/243, 21/21 targeted). Co-Authored-By: Claude Opus 4.5 --- .../{V002__unified.rs => V003__unified.rs} | 27 +++++-- .../src/sqlite/backup.rs | 4 +- .../src/sqlite/schema/core_pool.rs | 32 +++++--- .../src/sqlite/schema/versions.rs | 8 +- .../tests/fixture_gen.rs | 4 +- .../tests/sqlite_core_pool_writer.rs | 78 +++++++++++++++++++ .../tests/sqlite_migration_execution.rs | 35 +++++---- .../tests/sqlite_pool_reader.rs | 8 ++ .../tests/sqlite_schema_pinning.rs | 4 +- .../tests/sqlite_store_generation.rs | 2 +- ..._isolation.rs => sqlite_v003_isolation.rs} | 8 +- ..._migration.rs => sqlite_v003_migration.rs} | 36 ++++++--- .../tests/sqlite_version_bump.rs | 17 +++- 13 files changed, 202 insertions(+), 61 deletions(-) rename packages/rs-platform-wallet-storage/migrations/{V002__unified.rs => V003__unified.rs} (70%) rename packages/rs-platform-wallet-storage/tests/{sqlite_v002_isolation.rs => sqlite_v003_isolation.rs} (92%) rename packages/rs-platform-wallet-storage/tests/{sqlite_v002_migration.rs => sqlite_v003_migration.rs} (83%) diff --git a/packages/rs-platform-wallet-storage/migrations/V002__unified.rs b/packages/rs-platform-wallet-storage/migrations/V003__unified.rs similarity index 70% rename from packages/rs-platform-wallet-storage/migrations/V002__unified.rs rename to packages/rs-platform-wallet-storage/migrations/V003__unified.rs index 75a23ac37f4..bfbdc97e7a8 100644 --- a/packages/rs-platform-wallet-storage/migrations/V002__unified.rs +++ b/packages/rs-platform-wallet-storage/migrations/V003__unified.rs @@ -1,9 +1,13 @@ //! Unified additive migration for `platform-wallet-storage` (#3968). //! //! Additive-only: V001 stays byte-identical so refinery's applied-migration -//! checksum for version 1 never diverges on an existing store. V002 lifts -//! `max_supported_version()` from 1 to 2 automatically (the value is derived -//! from the embedded list) and lands three concerns in one migration event: +//! checksum for version 1 never diverges on an existing store. Numbered V003, +//! not V002: PR #4019 (ADDR-09, `V002__address_height_pin.rs`) independently +//! claimed version 2 and landed on this branch first — two migrations cannot +//! share a version number (refinery's `refinery_schema_history` collides on +//! it), so this one sequences after. V003 lifts `max_supported_version()` +//! from 2 to 3 automatically (the value is derived from the embedded list) +//! and lands three concerns in one migration event: //! //! - `core_address_pool` — per-index address-pool rows with a `used` flag, //! the first-class row store that replaces `core_utxos` script-derivation @@ -12,10 +16,15 @@ //! `(account_index, key_class)` sentinel (e.g. `IdentityRegistration` and //! `ProviderVotingKeys`, both `0, 0`) never overwrite each other, and //! `pool_type` so an External (receive) and Internal (change) pool never -//! collide at the same `address_index`. `script` (the address' -//! `script_pubkey`) is stored so the reader returns used addresses verbatim -//! and the UTXO writer can attribute an outpoint to its owning account, both -//! without re-deriving. +//! collide at the same `address_index`. The PK also carries the DashPay +//! `(user_identity_id, friend_identity_id)` pair, mirroring +//! `account_registrations` (V001): `DashpayReceivingFunds` accounts all +//! collapse to `(account_type='dashpay_receiving', account_index=0)`, so +//! without the identity pair two contacts on one wallet would upsert onto +//! the same PK and silently overwrite each other's pool rows. `script` (the +//! address' `script_pubkey`) is stored so the reader returns used addresses +//! verbatim and the UTXO writer can attribute an outpoint to its owning +//! account, both without re-deriving. //! - `meta_data_versions` — per-`(wallet_id, domain)` monotonic `seq` //! bumped inside the flush transaction, the cache-invalidation keystone. //! No FK (a domain row may be written before its typed parent syncs, @@ -36,11 +45,13 @@ CREATE TABLE core_address_pool ( account_type TEXT NOT NULL, account_index INTEGER NOT NULL, key_class INTEGER NOT NULL DEFAULT 0, + user_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), + friend_identity_id BLOB NOT NULL DEFAULT (zeroblob(32)), pool_type INTEGER NOT NULL CHECK (pool_type IN (0, 1, 2, 3)), address_index INTEGER NOT NULL, script BLOB NOT NULL, used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1)), - PRIMARY KEY (wallet_id, account_type, account_index, key_class, pool_type, address_index), + PRIMARY KEY (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, pool_type, address_index), FOREIGN KEY (wallet_id) REFERENCES wallets(wallet_id) ON DELETE CASCADE ); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs index b5f3ae80fa5..f424c6770b3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/backup.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/backup.rs @@ -234,9 +234,9 @@ pub fn restore_from(dest_db_path: &Path, src_backup: &Path) -> Result<(), Wallet // The staged DB is switched to DELETE journaling first so the UPDATE // lands in the main file with no `-wal` frames stranded outside the // rename; the reopened destination is forced back to its configured - // journal mode on its next open. A pre-V002 backup has no generation + // journal mode on its next open. A pre-V003 backup has no generation // table; `regenerate_generation` is a no-op there and the token is - // (re)seeded on its later migration to V002. + // (re)seeded on its later migration to V003. { let conn = crate::sqlite::conn::open_conn(tmp.path(), crate::sqlite::conn::Access::ReadWrite)?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs index 6f729d66173..b6a4015d103 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -1,8 +1,12 @@ //! Writer + account-attribution helper for the `core_address_pool` table. //! //! Per-index address-pool rows carrying a `used` flag, scoped by -//! `(wallet_id, account_type, account_index, key_class, pool_type, -//! address_index)`. The first-class row store the reader consumes verbatim — +//! `(wallet_id, account_type, account_index, key_class, user_identity_id, +//! friend_identity_id, pool_type, address_index)` — the DashPay identity pair +//! is in the PK (mirroring `account_registrations`) so distinct contacts on +//! one wallet, which otherwise collapse to the same `(dashpay_receiving, 0)` +//! sentinel, never overwrite each other's pool rows. The first-class row +//! store the reader consumes verbatim — //! no `core_utxos` script-derivation, no horizon-walk re-derivation. Populated //! from the `account_address_pools` changeset snapshots; the UTXO writer reads //! it back to attribute an outpoint to its owning account. @@ -30,9 +34,11 @@ pub(crate) fn pool_type_to_i64(pool_type: AddressPoolType) -> i64 { } const UPSERT_POOL_SQL: &str = "INSERT INTO core_address_pool \ - (wallet_id, account_type, account_index, key_class, pool_type, address_index, script, used) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \ - ON CONFLICT(wallet_id, account_type, account_index, key_class, pool_type, address_index) \ + (wallet_id, account_type, account_index, key_class, user_identity_id, friend_identity_id, \ + pool_type, address_index, script, used) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) \ + ON CONFLICT(wallet_id, account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index) \ DO UPDATE SET \ script = excluded.script, \ used = MAX(used, excluded.used)"; @@ -61,6 +67,11 @@ pub fn apply_pools( // other account maps to the 0 sentinel until the pool snapshot // threads a per-pool key class. let key_class = i64::from(accounts::account_key_class(&entry.account_type)); + // DashPay accounts all collapse to (dashpay_receiving/dashpay_external, + // account_index=0); the identity pair is the real per-contact + // discriminator, all-zero for every other account type. + let (user_identity_id, friend_identity_id) = + accounts::account_dashpay_ids(&entry.account_type); let pool_type = pool_type_to_i64(entry.pool_type); for info in &entry.addresses { stmt.execute(params![ @@ -68,6 +79,8 @@ pub fn apply_pools( account_type, account_index, key_class, + user_identity_id.as_slice(), + friend_identity_id.as_slice(), pool_type, i64::from(info.index), info.script_pubkey.as_bytes(), @@ -88,14 +101,15 @@ pub fn account_index_for_script( script: &[u8], ) -> Result, WalletStorageError> { // A script can appear under several pool rows (distinct account_type / - // key_class / pool_type share the same `script_pubkey` for reused keys); - // an explicit PK-ordered tie-break makes the pick deterministic instead of - // relying on SQLite's arbitrary `LIMIT 1` row. + // key_class / identity pair / pool_type share the same `script_pubkey` + // for reused keys); an explicit PK-ordered tie-break makes the pick + // deterministic instead of relying on SQLite's arbitrary `LIMIT 1` row. let idx: Option = tx .prepare_cached( "SELECT account_index FROM core_address_pool \ WHERE wallet_id = ?1 AND script = ?2 \ - ORDER BY account_type, account_index, key_class, pool_type, address_index ASC \ + ORDER BY account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index ASC \ LIMIT 1", )? .query_row(params![wallet_id.as_slice(), script], |row| row.get(0)) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs index ad6c08409f7..48b009dac78 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/versions.rs @@ -204,8 +204,8 @@ pub fn read_seq( Ok(seq.unwrap_or(0)) } -/// Read the 16-byte store-generation token written by V002. `None` on a -/// pre-V002 store (the table is absent). +/// Read the 16-byte store-generation token written by V003. `None` on a +/// pre-V003 store (the table is absent). #[cfg(any(test, feature = "__test-helpers"))] pub fn read_generation( conn: &rusqlite::Connection, @@ -233,8 +233,8 @@ pub fn read_generation( } /// Regenerate the store-generation token so a restored copy is -/// distinguishable from its source. A no-op on a pre-V002 store (no table); -/// such a store gets a fresh token when it later migrates to V002. +/// distinguishable from its source. A no-op on a pre-V003 store (no table); +/// such a store gets a fresh token when it later migrates to V003. pub fn regenerate_generation(conn: &rusqlite::Connection) -> Result<(), WalletStorageError> { if !generation_table_exists(conn)? { return Ok(()); diff --git a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs index 8c1993ad458..e96b1ac00b5 100644 --- a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs +++ b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs @@ -6,7 +6,7 @@ //! multi-wallet store, built by the CURRENT V001-only persister, to //! `tests/fixtures/populated_v001.db`. That committed `.db` is the //! regression anchor for the migration-execution suites (TC-B-031/032/033/ -//! 035/036): once V002 lands, a populated V001-only store is no longer +//! 035/036): once V002/V003 land, a populated V001-only store is no longer //! reproducible from source, so the bytes must be captured before any //! schema change. //! @@ -154,6 +154,8 @@ fn identity_entry() -> IdentityEntry { wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs index 961a7579674..63446728e17 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_pool_writer.rs @@ -505,3 +505,81 @@ fn standard_and_coinjoin_index_zero_do_not_collide() { 0, ); } + +/// Two DashPay contacts on one wallet both collapse to +/// `(account_type='dashpay_receiving', account_index=0, key_class=0)` — the +/// same `user_identity_id`, distinct `friend_identity_id`. Before the PK +/// carried the DashPay identity pair, the second contact's pool row would +/// silently overwrite the first's via `ON CONFLICT DO UPDATE`. +#[test] +fn distinct_dashpay_friends_do_not_collide_in_pool() { + let (persister, _tmp, _path) = fresh_persister(); + let w: WalletId = wid(0xA8); + ensure_wallet_meta(&persister, &w); + + let user_identity_id = [0xABu8; 32]; + let friend_a = index_zero_info(0x81, true); + let friend_b = index_zero_info(0x82, false); + assert_ne!( + friend_a[0].script_pubkey, friend_b[0].script_pubkey, + "the two contacts must carry distinct scripts to prove no overwrite" + ); + + let dashpay_account = |friend_identity_id: [u8; 32]| AccountType::DashpayReceivingFunds { + index: 0, + user_identity_id, + friend_identity_id, + }; + + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + dashpay_account([0x01; 32]), + AddressPoolType::External, + friend_a.clone(), + ), + pool_entry( + dashpay_account([0x02; 32]), + AddressPoolType::External, + friend_b.clone(), + ), + ], + ..Default::default() + }, + ) + .unwrap(); + + let conn = persister.lock_conn_for_test(); + let assert_friend_row = |friend_identity_id: [u8; 32], want_script: &[u8], want_used: i64| { + let (script, used): (Vec, i64) = conn + .query_row( + "SELECT script, used FROM core_address_pool \ + WHERE wallet_id = ?1 AND account_type = 'dashpay_receiving' \ + AND user_identity_id = ?2 AND friend_identity_id = ?3", + rusqlite::params![w.as_slice(), &user_identity_id[..], &friend_identity_id[..]], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap_or_else(|e| { + panic!("expected exactly one row for friend {friend_identity_id:?}: {e}") + }); + assert_eq!( + script, want_script, + "contact's script must survive verbatim" + ); + assert_eq!(used, want_used, "contact's used flag must survive"); + }; + assert_friend_row([0x01; 32], friend_a[0].script_pubkey.as_bytes(), 1); + assert_friend_row([0x02; 32], friend_b[0].script_pubkey.as_bytes(), 0); + + let total: i64 = conn + .query_row( + "SELECT COUNT(*) FROM core_address_pool WHERE wallet_id = ?1", + rusqlite::params![w.as_slice()], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(total, 2, "both contacts must persist as separate rows"); +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs index f49e8802113..b2444095bd1 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs @@ -5,6 +5,11 @@ //! TC-B-033 (backup restorable + re-migration determinism), TC-B-034 //! (forward-version rejection at the new max), TC-B-035 (idempotent //! re-entry), TC-B-036 (empty wallet through migration). +//! +//! TODO(post-#3986/#3968 reconcile): does not compile against the shipped +//! `ClientWalletStartState` — same phantom `used_core_addresses` field gap as +//! `sqlite_pool_reader.rs`; see that file's TODO. Pre-existing, unrelated to +//! the T5 PK-collision fix. mod common; @@ -59,7 +64,7 @@ fn count(conn: &Connection, sql: &str, wallet: &[u8; 32]) -> i64 { /// Assert the post-migration store carries the full fixture data intact. fn assert_full_data_preserved(conn: &Connection) { let full = wid(FULL_WALLET); - assert_eq!(schema_version(conn), 2, "must be migrated to V002"); + assert_eq!(schema_version(conn), 3, "must be migrated to V003"); assert_eq!( conn.query_row("SELECT COUNT(*) FROM wallets", [], |r| r.get::<_, i64>(0)) .unwrap(), @@ -189,12 +194,12 @@ fn tc_b_032_pre_migration_backup_created() { .find(|p| { p.file_name() .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("pre-migration-1-to-2-") && n.ends_with(".db")) + .is_some_and(|n| n.starts_with("pre-migration-1-to-3-") && n.ends_with(".db")) }) .expect("pre-migration backup must exist"); // The backup captured the PRE-migration state: schema version 1, and no - // V002 table. + // V003 table. let bconn = ro_conn(&backup); assert_eq!( schema_version(&bconn), @@ -203,7 +208,7 @@ fn tc_b_032_pre_migration_backup_created() { ); assert!( !table_exists(&bconn, "core_address_pool"), - "backup must predate the V002 schema" + "backup must predate the V003 schema" ); assert_eq!( bconn @@ -234,7 +239,7 @@ fn tc_b_033_backup_restorable_and_remigration_deterministic() { .find(|p| { p.file_name() .and_then(|n| n.to_str()) - .is_some_and(|n| n.starts_with("pre-migration-1-to-2-")) + .is_some_and(|n| n.starts_with("pre-migration-1-to-3-")) }) .expect("backup exists"); @@ -250,8 +255,8 @@ fn tc_b_033_backup_restorable_and_remigration_deterministic() { assert_full_data_preserved(&conn); } -/// TC-B-034 — the forward-version gate now rejects at the NEW max (2); a -/// forged version-3 row is refused. +/// TC-B-034 — the forward-version gate now rejects at the NEW max (3); a +/// forged version-4 row is refused. #[test] fn tc_b_034_forward_version_rejected_at_new_max() { let tmp = tempfile::tempdir().unwrap(); @@ -263,7 +268,7 @@ fn tc_b_034_forward_version_rejected_at_new_max() { let conn = Connection::open(&path).unwrap(); conn.execute( "INSERT INTO refinery_schema_history (version, name, applied_on, checksum) \ - VALUES (3, 'future', '', '0')", + VALUES (4, 'future', '', '0')", [], ) .unwrap(); @@ -273,8 +278,8 @@ fn tc_b_034_forward_version_rejected_at_new_max() { found, max_supported, }) => { - assert_eq!(found, 3); - assert_eq!(max_supported, 2, "max must reflect the post-redirect V002"); + assert_eq!(found, 4); + assert_eq!(max_supported, 3, "max must reflect the post-redirect V003"); } Err(other) => panic!("expected SchemaVersionUnsupported, got {other:?}"), Ok(_) => panic!("forward-version DB must be refused"), @@ -321,8 +326,8 @@ fn migration_snapshot(conn: &Connection) -> Vec { ] } -/// TC-B-035 — crash mid-migrate: an interrupted V002 (partial DDL, no commit) -/// leaves the store at the last committed version (V001) with no partial +/// TC-B-035 — crash mid-migrate: an interrupted V003 (partial DDL, no commit) +/// leaves the store at the last committed version (V002) with no partial /// tables; re-opening resumes and converges byte-equal to a clean direct /// migration. Empirically demonstrates refinery's per-migration transaction /// guarantee (one tx per migration — no `set_grouped`/`no_transaction`). @@ -336,9 +341,9 @@ fn tc_b_035_interrupted_migration_recovers_to_clean_state() { let conn = p.lock_conn_for_test(); migration_snapshot(&conn) }; - assert_eq!(clean_snapshot[0], 2, "clean migration reaches V002"); + assert_eq!(clean_snapshot[0], 3, "clean migration reaches V003"); - // Crash simulation: apply part of V002's DDL inside a transaction that is + // Crash simulation: apply part of V003's DDL inside a transaction that is // rolled back before commit — exactly what a crash before the migration's // single COMMIT leaves behind (SQLite DDL is transactional). let crash_dir = tempfile::tempdir().unwrap(); @@ -405,6 +410,6 @@ fn reopen_of_migrated_store_is_idempotent() { let conn = p.lock_conn_for_test(); read(&conn) }; - assert_eq!(first.0[0], 2, "first open migrates to V002"); + assert_eq!(first.0[0], 3, "first open migrates to V003"); assert_eq!(first, second, "reopen is a byte-stable no-op"); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs index f550d8b2108..0e9befc448f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs @@ -5,6 +5,14 @@ //! (deep-derivation window — no horizon-walk truncation), TC-B-025/007 //! (empty wallet loads empty-but-valid), multi-wallet isolation (TC-B-026), //! and the pool ∪ `core_utxos` used-set union (pre-pool + mixed stores). +//! +//! TODO(post-#3986/#3968 reconcile): this file does not compile against the +//! shipped `ClientWalletStartState` — it assumes a `used_core_addresses` +//! field that was never added; the shipped design folds used-address data +//! into `core_wallet_info` instead (see `persister.rs::load`'s +//! `used_core_addresses` local + `apply_persisted_core_state`). Pre-existing +//! from the #3986 squash-merge, unrelated to the T5 PK-collision fix — needs +//! a rewrite against the real struct shape, out of scope here. mod common; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs index a472ce46dab..f3bfb9bc124 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::migrations as mig; /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "114e07f057947594e3d098ba62169f0887c2a407feb78c7ea835a4b35d582fd9"; + "6a073aeb0ea0a411a6fbe77732bf33c1e316024eba0920aa2105140d1172bedf"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump deliberately only when the DDL body itself changes; an accidental /// change (a silent table rename) must fail this test, not slip through. const EXPECTED_SQL_FINGERPRINT: &str = - "98f2a7c86a1383fc32922551c537d1af9955428f6068afde9dd33f2a8a49d90d"; + "c55ce79569d2bce2a1f0b7c682aa4826a46b23a379d19777068296454017234e"; /// Table names that lost the cross-branch reconciliation and must never /// resurface as SQL identifiers on this frozen (`wallets`) baseline. diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs index 7d7c396e683..01754e12d00 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_store_generation.rs @@ -25,7 +25,7 @@ fn tc_b_004_generation_present_and_stable_across_flush() { let conn = persister.lock_conn_for_test(); versions::read_generation(&conn) .unwrap() - .expect("fresh V002 store carries a generation") + .expect("fresh V003 store carries a generation") }; assert!( g1.iter().any(|b| *b != 0), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v003_isolation.rs similarity index 92% rename from packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs rename to packages/rs-platform-wallet-storage/tests/sqlite_v003_isolation.rs index f21966880af..2e11ab21a0a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_v002_isolation.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v003_isolation.rs @@ -1,9 +1,9 @@ #![allow(clippy::field_reassign_with_default)] -//! Cross-wallet isolation + delete cascade for the new V002 tables +//! Cross-wallet isolation + delete cascade for the new V003 tables //! (`core_address_pool`, `meta_data_versions`) — TC-B-006. Two wallets with //! fully-overlapping keys must not collide, must not leak across wallets, and -//! deleting one must leave the other's V002 rows intact. +//! deleting one must leave the other's V003 rows intact. mod common; @@ -29,10 +29,10 @@ fn versions_count(conn: &rusqlite::Connection, w: &WalletId) -> i64 { } /// TC-B-006 — overlapping keys across two wallets coexist without PK -/// collision, and deleting wallet A cascades away only A's V002 rows while +/// collision, and deleting wallet A cascades away only A's V003 rows while /// wallet B's survive intact. #[test] -fn tc_b_006_v002_tables_isolate_and_cascade_per_wallet() { +fn tc_b_006_v003_tables_isolate_and_cascade_per_wallet() { let (persister, _tmp, _path) = fresh_persister(); let a: WalletId = wid(0x0A); let b: WalletId = wid(0x0B); diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs similarity index 83% rename from packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs rename to packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs index f06018e3ed0..bc5f8734ede 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_v002_migration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_v003_migration.rs @@ -1,6 +1,8 @@ #![allow(clippy::field_reassign_with_default)] -//! V002 unified-migration schema tests. +//! V003 unified-migration schema tests. Numbered V003, not V002: PR #4019 +//! (ADDR-09) independently claimed version 2 (`V002__address_height_pin.rs`) +//! and landed first, so the unified migration sequences after it. //! //! Covers TC-B-030 (fresh store migrates clean to the new target version), //! TC-B-003 (`meta_data_versions` shape + PK), the schema half of TC-B-001 @@ -49,20 +51,20 @@ impl OptionalExists for rusqlite::Result<()> { } } -/// The unified migration lifts the supported schema version to 2. +/// The unified migration lifts the supported schema version to 3. #[test] -fn max_supported_version_is_two() { +fn max_supported_version_is_three() { assert_eq!( mig::max_supported_version(), - 2, - "V002 must raise max_supported_version to 2" + 3, + "V003 must raise max_supported_version to 3" ); } /// TC-B-030 — a fresh store migrates clean to the new target version and /// every new table exists. #[test] -fn tc_b_030_fresh_store_migrates_to_version_two() { +fn tc_b_030_fresh_store_migrates_to_version_three() { let (persister, _tmp, _path) = fresh_persister(); let conn = persister.lock_conn_for_test(); let max: i64 = conn @@ -72,7 +74,7 @@ fn tc_b_030_fresh_store_migrates_to_version_two() { |r| r.get(0), ) .unwrap(); - assert_eq!(max, 2, "fresh store must land at schema version 2"); + assert_eq!(max, 3, "fresh store must land at schema version 3"); for table in [ "core_address_pool", "meta_data_versions", @@ -83,8 +85,12 @@ fn tc_b_030_fresh_store_migrates_to_version_two() { } /// Schema half of TC-B-001 — `core_address_pool` carries per-index rows -/// scoped by `(wallet_id, account_type, account_index, key_class, pool_type, -/// address_index)`, a stored `script`, and a `used` flag. +/// scoped by `(wallet_id, account_type, account_index, key_class, +/// user_identity_id, friend_identity_id, pool_type, address_index)`, a +/// stored `script`, and a `used` flag. The DashPay identity pair is in the PK +/// (mirroring `account_registrations`) so distinct contacts, which otherwise +/// collapse to the same `(dashpay_receiving, 0)` sentinel, never overwrite +/// each other's pool rows (T5). #[test] fn tc_b_001_core_address_pool_shape() { let (persister, _tmp, _path) = fresh_persister(); @@ -96,6 +102,8 @@ fn tc_b_001_core_address_pool_shape() { ("account_type", "TEXT"), ("account_index", "INTEGER"), ("key_class", "INTEGER"), + ("user_identity_id", "BLOB"), + ("friend_identity_id", "BLOB"), ("pool_type", "INTEGER"), ("address_index", "INTEGER"), ("script", "BLOB"), @@ -108,8 +116,10 @@ fn tc_b_001_core_address_pool_shape() { } // Composite PK includes account_type so accounts collapsing to the same - // (account_index, key_class) sentinel never overwrite each other, and - // pool_type so External/Internal pools never collide at one address_index. + // (account_index, key_class) sentinel never overwrite each other, the + // DashPay identity pair so distinct contacts never overwrite each other, + // and pool_type so External/Internal pools never collide at one + // address_index. let pk: BTreeMap = cols .iter() .filter(|(_, (_, _, pk))| *pk > 0) @@ -123,11 +133,13 @@ fn tc_b_001_core_address_pool_shape() { "account_type", "account_index", "key_class", + "user_identity_id", + "friend_identity_id", "pool_type", "address_index" ], "core_address_pool PK must be (wallet_id, account_type, account_index, key_class, \ - pool_type, address_index)" + user_identity_id, friend_identity_id, pool_type, address_index)" ); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs index 00dafbd4015..32b19fb0bf1 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_version_bump.rs @@ -20,9 +20,9 @@ use key_wallet::{AddressInfo, Network}; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, AssetLockChangeSet, ContactChangeSet, ContactRequestEntry, CoreChangeSet, IdentityChangeSet, IdentityEntry, IdentityKeyEntry, - IdentityKeysChangeSet, PlatformAddressBalanceEntry, PlatformAddressChangeSet, - PlatformWalletChangeSet, PlatformWalletPersistence, SentContactRequestKey, - TokenBalanceChangeSet, WalletMetadataEntry, + IdentityKeysChangeSet, PendingContactCrypto, PendingContactCryptoOp, + PlatformAddressBalanceEntry, PlatformAddressChangeSet, PlatformWalletChangeSet, + PlatformWalletPersistence, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; use platform_wallet::wallet::identity::{ContactRequest, IdentityStatus}; use platform_wallet::wallet::platform_wallet::WalletId; @@ -115,6 +115,7 @@ fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { funds: dash_sdk::platform::address_sync::AddressFunds { balance: 1, nonce: 0, + as_of_height: 0, }, }], ..Default::default() @@ -175,6 +176,14 @@ fn single_domain_changeset(domain: Domain) -> PlatformWalletChangeSet { addresses: vec![], }]; } + Domain::PendingContactCrypto => { + cs.pending_contact_crypto_added = vec![PendingContactCrypto { + owner_identity_id: Identifier::from([0x06; 32]), + contact_id: Identifier::from([0x07; 32]), + op: PendingContactCryptoOp::RegisterReceiving, + enqueued_at_ms: 0, + }]; + } } cs } @@ -193,6 +202,8 @@ fn identity_entry(id: Identifier) -> IdentityEntry { wallet_id: None, dashpay_profile: None, dashpay_payments: Default::default(), + contact_profiles: Default::default(), + ignored_senders: Default::default(), } } From 05bc9216ffbf34d7b99ee2a53195d42abec8cd25 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:39:36 +0000 Subject: [PATCH 079/108] docs(platform-wallet-storage): mark wallet-row-orphaning as an open product decision A crash between wallet-row creation and first-account-registration leaves that row with a permanently empty manifest. It is not corrupted or lost -- every future load correctly skips it as MissingManifest -- but no recovery path exists today. TODO left at the exact decision point pending product input (task #14): silent-skip-forever, or does this need a re-registration flow / TTL cleanup / surfaced diagnostic. Co-Authored-By: Claude Opus 4.5 --- .../rs-platform-wallet-storage/src/sqlite/persister.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 500d5383be5..603f874779f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -974,6 +974,16 @@ impl PlatformWalletPersistence for SqlitePersister { // manifest and skips this wallet as MissingManifest one layer // up (see rt_corrupt_row_skipped_and_other_loads). It exists so // one unregistered wallet doesn't abort load() for all others via `?`. + // + // TODO(product decision needed, task #14): a crash between wallet-row + // creation and first-account-registration leaves this row with a + // permanently empty manifest. It is not corrupted or lost — every + // future load correctly skips it as MissingManifest — but there is no + // recovery path today: no re-registration flow, no eviction, no + // surfacing to the user. Open question: does this need one (e.g. a + // TTL-based cleanup, a re-registration entry point, or a surfaced + // "orphaned wallet" diagnostic), or is silent-skip-forever acceptable? + // Awaiting product decision; not addressed in this change. key_wallet::wallet::Wallet::new_watch_only( network, wallet_id, From 36d84b65315c19916dd7f90b53ed2330e77c456d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:21:28 +0000 Subject: [PATCH 080/108] test(platform-wallet-storage): rewrite pool_reader used-set tests against shipped readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sqlite_pool_reader.rs` referenced a `ClientWalletStartState.used_core_addresses` field that was never shipped (an earlier-draft leftover), so it failed to compile. The used-address facts it pins (verbatim pool used=1, deep-index no-truncation, pool ∪ core_utxos dedup, cross-wallet isolation) live at the reader layer: `load()` folds the used-set into `core_wallet_info` by marking only addresses that resolve to a registered account's derived pool, and these keyless, arbitrary-address stores don't set that up. So each assertion now calls the two shipped `pub` readers `load()` itself uses — `core_pool::load_used_addresses` and `core_state::load_used_addresses` — unioned/deduped by script where a test spans both. Dropping the `load()` calls also sidesteps `RehydrationTopology- Unsupported` on the manifest-less UTXO tests. The `load()` → `core_wallet_info` marking path stays covered by `sqlite_used_core_addresses.rs`. Verification: sqlite_pool_reader 6/6; fmt + clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../tests/sqlite_pool_reader.rs | 93 +++++++++++-------- 1 file changed, 56 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs index 0e9befc448f..1faa7b58e4d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs @@ -6,13 +6,15 @@ //! (empty wallet loads empty-but-valid), multi-wallet isolation (TC-B-026), //! and the pool ∪ `core_utxos` used-set union (pre-pool + mixed stores). //! -//! TODO(post-#3986/#3968 reconcile): this file does not compile against the -//! shipped `ClientWalletStartState` — it assumes a `used_core_addresses` -//! field that was never added; the shipped design folds used-address data -//! into `core_wallet_info` instead (see `persister.rs::load`'s -//! `used_core_addresses` local + `apply_persisted_core_state`). Pre-existing -//! from the #3986 squash-merge, unrelated to the T5 PK-collision fix — needs -//! a rewrite against the real struct shape, out of scope here. +//! These assert directly on the two shipped reader fns `load()` itself calls — +//! `core_pool::load_used_addresses` (verbatim pool `used=1`) and +//! `core_state::load_used_addresses` (`core_utxos`-derived, spent + unspent) — +//! not on `load()`'s assembled `core_wallet_info`. The reuse-guard facts pinned +//! here (deep-index no-truncation, pool ∪ UTXO dedup) live at the reader layer: +//! `load()` only marks addresses that resolve to a *registered* account's +//! derived pool, which these keyless, arbitrary-address stores deliberately +//! don't set up. The `load()` → `core_wallet_info` marking path is covered by +//! `sqlite_used_core_addresses.rs`. mod common; @@ -30,6 +32,38 @@ use platform_wallet::changeset::{ AccountAddressPoolEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; +use platform_wallet_storage::SqlitePersister; + +/// Verbatim `core_address_pool` `used=1` addresses — the pool half of the +/// reuse-guard set `load()` reads. +fn pool_used(persister: &SqlitePersister, w: &WalletId) -> Vec

{ + let conn = persister.lock_conn_for_test(); + core_pool::load_used_addresses(&conn, w, Network::Testnet).expect("pool used-set") +} + +/// `core_utxos`-derived used addresses (spent + unspent) — the UTXO half. +fn utxo_used(persister: &SqlitePersister, w: &WalletId) -> Vec
{ + let conn = persister.lock_conn_for_test(); + core_state::load_used_addresses(&conn, w, Network::Testnet).expect("utxo used-set") +} + +/// The assembled reuse-guard set `load()` hands the manager: pool ∪ UTXO, +/// deduped by script (mirrors `SqlitePersister::load`). +fn used_set(persister: &SqlitePersister, w: &WalletId) -> Vec
{ + let conn = persister.lock_conn_for_test(); + let pool = core_pool::load_used_addresses(&conn, w, Network::Testnet).expect("pool used-set"); + let utxo = core_state::load_used_addresses(&conn, w, Network::Testnet).expect("utxo used-set"); + drop(conn); + let mut seen = std::collections::HashSet::new(); + let mut union = Vec::new(); + for addr in pool.into_iter().chain(utxo) { + if seen.insert(addr.script_pubkey().to_bytes()) { + union.push(addr); + } + } + union +} fn external_infos(seed_byte: u8) -> Vec { let wallet = Wallet::from_seed_bytes( @@ -97,10 +131,7 @@ fn tc_b_020_used_set_from_pool_not_utxos() { ) .unwrap(); - let state = persister.load().unwrap(); - let slice = state.wallets.get(&w).expect("wallet surfaces in load"); - let got: std::collections::BTreeSet = slice - .used_core_addresses + let got: std::collections::BTreeSet = pool_used(&persister, &w) .iter() .map(|a| a.to_string()) .collect(); @@ -110,6 +141,10 @@ fn tc_b_020_used_set_from_pool_not_utxos() { .map(|i| i.address.to_string()) .collect(); assert_eq!(got, expected, "used-set must equal the pool's used=1 rows"); + assert!( + utxo_used(&persister, &w).is_empty(), + "no UTXO stored: the used-set is pool-derived, not core_utxos-derived" + ); } /// TC-B-023 — a wallet whose pool advanced past the old horizon-walk window @@ -140,19 +175,15 @@ fn tc_b_023_deep_derivation_window_not_truncated() { } } - let state = persister.load().unwrap(); - let slice = state.wallets.get(&w).expect("wallet surfaces"); + let used = pool_used(&persister, &w); assert_eq!( - slice.used_core_addresses.len(), + used.len(), 46, "indices 0..=45 are used and must all restore" ); let want = p2pkh(45).to_string(); assert!( - slice - .used_core_addresses - .iter() - .any(|a| a.to_string() == want), + used.iter().any(|a| a.to_string() == want), "the index-45 used address must survive (no gap-limit-30 truncation)" ); } @@ -165,13 +196,8 @@ fn tc_b_025_empty_wallet_is_empty_but_valid() { let w: WalletId = wid(0x25); ensure_wallet_meta(&persister, &w); - let state = persister.load().unwrap(); - let slice = state - .wallets - .get(&w) - .expect("empty wallet must still surface"); assert!( - slice.used_core_addresses.is_empty(), + used_set(&persister, &w).is_empty(), "empty wallet has an empty used-set" ); } @@ -211,10 +237,9 @@ fn pre_pool_store_yields_utxo_derived_used_set() { ) .unwrap(); - let state = persister.load().unwrap(); - let slice = state.wallets.get(&w).expect("wallet surfaces"); - assert_eq!(slice.used_core_addresses.len(), 1); - assert_eq!(slice.used_core_addresses[0].to_string(), addr.to_string()); + let used = utxo_used(&persister, &w); + assert_eq!(used.len(), 1); + assert_eq!(used[0].to_string(), addr.to_string()); } /// TC-B-026 — reader multi-wallet isolation: two wallets seeded with @@ -255,14 +280,11 @@ fn tc_b_026_reader_isolates_two_wallets() { ) .unwrap(); - let state = persister.load().unwrap(); - let a_used: Vec = state.wallets[&a] - .used_core_addresses + let a_used: Vec = used_set(&persister, &a) .iter() .map(|x| x.to_string()) .collect(); - let b_used: Vec = state.wallets[&b] - .used_core_addresses + let b_used: Vec = used_set(&persister, &b) .iter() .map(|x| x.to_string()) .collect(); @@ -334,10 +356,7 @@ fn mixed_store_unions_utxo_and_pool_used_sets() { ) .unwrap(); - let state = persister.load().unwrap(); - let slice = state.wallets.get(&w).expect("wallet surfaces"); - let got: std::collections::BTreeSet = slice - .used_core_addresses + let got: std::collections::BTreeSet = used_set(&persister, &w) .iter() .map(|a| a.to_string()) .collect(); From 1fa145a4fad6b43022692a8a2833f8a13f2927dc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:21:42 +0000 Subject: [PATCH 081/108] test(platform-wallet-storage): rewrite migration_execution used-set tests + recapture V001 fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sqlite_migration_execution.rs` referenced the never-shipped `ClientWalletStartState.used_core_addresses` field, so it failed to compile. tc_b_031/036's used-set assertions now go through the same shipped readers as pool_reader (`core_pool`/`core_state::load_used_addresses`, unioned by script) rather than `core_wallet_info`: the fixture's UTXO sits on a seed-derived address (`first_external_address`) unrelated to the registered account's `test_xpub()`, so `load()`'s pool-marking never claims it — the used-set fact is a reader-layer one, exactly as in pool_reader. Fixture recapture: `V001__initial.rs` was edited in place after `populated_v001.db` was frozen, so migrating the frozen store forward tripped refinery's divergent-version guard. The regenerator now seeds a fully-migrated store (its writers reference V002/V003 tables — the `meta_data_versions` bump and `core_state::apply`'s `core_address_pool` lookup — so a direct V001-only seed isn't expressible), then lifts the V001-table rows onto a destination capped at V001 via `runner().set_target(Version(1))`, so the committed fixture carries the current V001 schema and migrates forward cleanly. fixture_gen's module doc is updated to match. Verification: sqlite_migration_execution 7/7, fixture_gen guard green, --lib 243/243; fmt + clippy --all-targets clean. Co-Authored-By: Claude Opus 4.8 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../tests/fixture_gen.rs | 81 +++++++++++++++--- .../tests/fixtures/populated_v001.db | Bin 217088 -> 233472 bytes .../tests/sqlite_migration_execution.rs | 50 ++++++++--- 3 files changed, 107 insertions(+), 24 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs index e96b1ac00b5..e8a5a23cdaf 100644 --- a/packages/rs-platform-wallet-storage/tests/fixture_gen.rs +++ b/packages/rs-platform-wallet-storage/tests/fixture_gen.rs @@ -2,13 +2,15 @@ //! Populated-V001 fixture capture. //! -//! `regenerate_populated_v001_fixture` (`#[ignore]`) writes a realistic -//! multi-wallet store, built by the CURRENT V001-only persister, to -//! `tests/fixtures/populated_v001.db`. That committed `.db` is the -//! regression anchor for the migration-execution suites (TC-B-031/032/033/ -//! 035/036): once V002/V003 land, a populated V001-only store is no longer -//! reproducible from source, so the bytes must be captured before any -//! schema change. +//! `regenerate_populated_v001_fixture` (`#[ignore]`) seeds a realistic +//! multi-wallet store via the persister, then lifts its V001-table rows onto a +//! destination capped at V001 only (`runner().set_target(Version(1))`), writing +//! `tests/fixtures/populated_v001.db`. Seeding runs fully-migrated because the +//! writers reference V002/V003 tables; the V001-capped copy is what the +//! migration-execution suites (TC-B-031/032/033/035/036) migrate forward. +//! Re-run it whenever V001's shape changes so the committed fixture's +//! applied-V001 checksum stays in lockstep with `V001__initial.rs` — otherwise +//! migrating the frozen store forward trips refinery's divergent-version guard. //! //! `populated_v001_fixture_is_present_and_openable` is the always-run guard //! that keeps the committed fixture honest: it opens read-only, asserts the @@ -276,17 +278,72 @@ fn build_populated_store(path: &Path) { fn regenerate_populated_v001_fixture() { let tmp = tempfile::tempdir().expect("tempdir"); let src = tmp.path().join("build.db"); + // Seed on a fully-migrated store: the writers (core_state UTXO attribution, + // the meta_data_versions bump) reference V002/V003 tables, so a V001-only + // seed is not expressible directly. The rows the migration suite reads all + // live in V001 tables, which we lift onto a V001-capped destination below. build_populated_store(&src); - // Re-open the source and take a checkpointed single-file backup so the - // committed fixture carries no side WAL/journal. - let persister = - SqlitePersister::open(SqlitePersisterConfig::new(&src)).expect("reopen built store"); let dest = fixture_path(); if dest.exists() { std::fs::remove_file(&dest).expect("remove stale fixture"); } - persister.backup_to(&dest).expect("capture fixture backup"); + + // Cap the destination at V001 only, via the `runner()` set_target helper, so + // the committed fixture is a genuine pre-V002 store carrying the CURRENT + // V001 schema — migratable forward by the TC-B-031/032/033/035/036 suite + // without a refinery applied-migration checksum divergence. + let mut conn = rusqlite::Connection::open(&dest).expect("create dest db"); + platform_wallet_storage::sqlite::migrations::runner() + .set_target(refinery::Target::Version(1)) + .run(&mut conn) + .expect("apply V001 only to fixture"); + + // Lift every populated V001 table from the built store. FKs are off for the + // bulk load (the source is already consistent). `refinery_schema_history` + // and empty tables (e.g. the V002-altered, unpopulated `platform_addresses`) + // are skipped, so `SELECT *` never straddles a V002/V003 column change. + conn.execute( + "ATTACH DATABASE ?1 AS built", + rusqlite::params![src.to_str().expect("utf8 src path")], + ) + .expect("attach built store"); + conn.execute_batch("PRAGMA foreign_keys = OFF;").unwrap(); + let tables: Vec = { + let mut stmt = conn + .prepare( + "SELECT name FROM main.sqlite_master \ + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \ + AND name <> 'refinery_schema_history'", + ) + .unwrap(); + let rows = stmt + .query_map([], |r| r.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap(); + rows + }; + for t in &tables { + let count: i64 = conn + .query_row(&format!("SELECT COUNT(*) FROM built.\"{t}\""), [], |r| { + r.get(0) + }) + .unwrap_or(0); + if count > 0 { + conn.execute_batch(&format!( + "INSERT INTO main.\"{t}\" SELECT * FROM built.\"{t}\";" + )) + .unwrap_or_else(|e| panic!("copy V001 table {t}: {e}")); + } + } + conn.execute_batch("DETACH DATABASE built;").unwrap(); + + // Collapse to a single committed file: rollback-journal mode + VACUUM leaves + // no side WAL/journal alongside the fixture. + conn.pragma_update(None, "journal_mode", "DELETE").unwrap(); + conn.execute_batch("VACUUM;").unwrap(); + drop(conn); } /// Always-run guard: the committed fixture opens, is at schema version 1, diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db b/packages/rs-platform-wallet-storage/tests/fixtures/populated_v001.db index 8b5c4eb4ab3709f61194f0fd151055dda54791f6..c08e81b8c0e179eba126c658b6efa1a8e8ec63d1 100644 GIT binary patch delta 2668 zcmeHJU2Ggz6`pfvc6Pjfv$o@m|M%LvvAy&^OS*OBYE#%-Kz?f55lU6QOP>q)%L zX4csmr}%~VCk-#Dn$1SN50$9fL_(pcmAt@+Ry6QXK@b(LkZ21JP#~Z_ATWYTKt;H- z{)ub?Z@e+m-MRbSt8>pi=R4n-#Z~9xsw*%y5=-g|KAX;5kaZkp+zbG77-NQE>~uuw z*nYg(K)BV;1Yi7{g+i>jVJ>uu{}4YDH=NV_qHv9SkMChw?mFB??}7qKHv2K(hv4WNF2%c&Cl_INEibmlpt-5?BrOYYnOj11;Poz~{PU!JOW?@cGKN=PK zM+f@G2k>~`6R`pO=sjdT2tLv_f`nX|Ln4=-K|C26K^``#&XD3^cK_^oI>UoQxT!It zoJ(rDl8I-Ogp#~O-DnKphp6%;x~9r06Wn$$sh&;8XOx1MCN9tG>A0LoD06zF-yg7H z`pn4-skA&pEH?|0fTV4X?XYPN&G^1uY~t*@+13m3|I_P#PcP%6a%i?T{gQFOX~wZy z)FAxc`hxIv?z`5baGJa3`kHIjao;{+x7$nYrH-4~>|gnw@+~bX?@OLj(-~S28m)XK zqs2pefU2u4Wmc)C2Cn%F3b9og+V;ELc)Sh<;c{y$ekzpc#+stz=VuiaZ`W%pt;h7d zs^iW2&d7Q)tx~z5I;ql%OD*8Dax$fv%8BJd#ZpsS)^U@T#`>&+wFNbSwdWOO4r^48 zG-^!GEKrHSGM-B(we)3GnZb!!Syh#k|Im(^lp@p1VS1p9wRw7|rkMJMGiklxM-UIq zr&6Z!;a@M_q*i4!2w0~R9S@y|#jvbx8=F2Wr!-|cxUDJ|sQ2-$Bjc_!ne=&j>%pP% zfu{yW$x=PMZQ0LD{WVlDD#^26aB~$e*h&fOg9g}dH2Yu%!9Ma*14KA!xxUzFZv-zu ziK*MwL~jDgW&4m3%+|0=`K#HLplEcu;Z5Kf2JTIUOfe_c5-3K4)nw^7swNXbaFY)L z;DIV*BLGJLHk$-fVJrq=k(T>T{-4{E?tjn!@`PlST3X;rqLBYR`}h2<+|?6yFOr`G z;ego-ipXLel=7R5BO^X2=S&ikKl))m2~MDPQ;u!c&Gk6hD1p(J+knsW(<`Dd4}2^p zJSUJ(hQUMDdSS@;XD=Z4+B%Q99!Bh;f)*Bwmkxs~7K@cFVKagZ!FMfL!;CcsJ&A1R^#CKd5T@AvYg?FqS!X@6q|Am{ge3KpHq6oR(W>U@)*OGnK z@dMk>sj*Zx+lj(~%~btt3{`;N_|q8j0W_NlttO!p$ODbW$O$9?ZM;m^px5jhf zqA_@V0{zy~z)M@`tx1Y{jk}ZR39$HhDbZ8dTswur+)}E}O1?UUnz)pr9UyTGclq9W zUK(rN-b14~g(|p)<;OkbNk24EOKCrp0e=Yd(y+gx#NXNbS_MO)Br6IA)htC zA(T3pJHs_YXYNI=k6p5daydR)h|mZcY%?qF=vJgofhEXG#|{)?et(#)VwX4Lm7-r{ q59v~l`df_oQPdA8`j9ZS$Xj5M7x<2*odi+FRNMwP*z&OcK>QaV!4_`- delta 1538 zcmbu9ZA@EL7{||Z&h5RorR}{?+FNL8FSNXrLg@>I!i2JsY-3aznVCWP0Py3@ij>w}YPEHQa${{2oj8TUyBZNSWuo`;#&|F6eYlIg|xgJ>{R1{ za*~pa%8!MhraOi7Htr;;QiJmPC@BS;(9?@?QVRz9MV#y>3pyR$X95`tlztP80}d6? zUlPQg-efF{K3Slp;y7sm9X&EmdP(Xfjux1~2fWg1hJ%3j^XVwtk-5X$IenE+-K)u= zK8nOG@k^m5_-r8D8Va-q13rHw5RNpqG&esV2!+BefnX%q7z{_6{7u2;K<1%SClf{Z zTJ7Yrz_>{a=m$}dHP}UB+Vp2VrRlp-cIzl3XPA{gof{oQeD{ic^(bM)3^R@8414#1 zGbo}EGJYgnGF&#k#_#OG(h%lW78drvE(*bVmS=7(_(a9yfz7`W?J;{Stben4lpJ|N zSjrtM)N=l1WCRD|(_DZ*&fZ`UdxlJagR~l7F-G)1>(_K2>lU@0S{5~Ebtsvrpu7dw zfty|_#unOb!I$L45-TT_mBDGv?BI)uk)ab)vx7skGkfn+<<)ZN0(!j&_t5hVs)S!9 zh>kwxhF&P4Z@9t7*pfvOeQ%7ks)=vL$a7>#XQtb3r~*m(-VMW8V_~JIt7)MZtTbK? zc48eXrja=Df+hQdm7ac9V@nhL;2N;zXRM=Mh#x*2D{Sd zg~I?g)!}x!3|o^3R4fmaLx}bvy6mg)m}+~n*lZ!<45UA6s9&h zK>QFu%)s*N2oE*UkpOTy)iAS0)(aZ)@opnm(qr7^>)T6W%4&`EMufSN#KSWu_P(08J$;4YlPpxNXKEqxS8*W>FU_ z9jnhz6iK*3Q>k7?_o>!`Ye=d&th_&{-U)moXB+hJ3n4l`K#J+%A$1pyA@VIB;%wdJ z{6b$YuapfF2KXRnJ7nV*C-ZCDMo0_!FH=8(EmS#~HD+3VTom)XfQ0MDxUo!st>7)) zDeVDXP$m7HBPoBA^6~%)0{rh56t#szsuy$eb%S4-u;G4Sn;TfEyOQP Vec { + let conn = persister.lock_conn_for_test(); + let pool = core_pool::load_used_addresses(&conn, w, dashcore::Network::Testnet) + .expect("pool used-set"); + let utxo = core_state::load_used_addresses(&conn, w, dashcore::Network::Testnet) + .expect("utxo used-set"); + drop(conn); + let mut seen = std::collections::HashSet::new(); + let mut union = Vec::new(); + for addr in pool.into_iter().chain(utxo) { + if seen.insert(addr.script_pubkey().to_bytes()) { + union.push(addr); + } + } + union +} + fn fixture_src() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") @@ -147,9 +170,12 @@ fn tc_b_031_populated_v001_migration_preserves_data() { // UTXO-derived address (no pool rows in a migrated store). let state = p.load().unwrap(); let full = wid(FULL_WALLET); - let slice = state.wallets.get(&full).expect("full wallet reconstructs"); + assert!( + state.wallets.contains_key(&full), + "full wallet reconstructs" + ); assert_eq!( - slice.used_core_addresses.len(), + used_set(&p, &full).len(), 1, "migrated store falls back to the UTXO-derived used-set" ); @@ -164,12 +190,12 @@ fn tc_b_036_empty_wallet_through_migration() { let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); let state = p.load().unwrap(); let empty = wid(EMPTY_WALLET); - let slice = state - .wallets - .get(&empty) - .expect("empty wallet still surfaces post-migration"); assert!( - slice.used_core_addresses.is_empty(), + state.wallets.contains_key(&empty), + "empty wallet still surfaces post-migration" + ); + assert!( + used_set(&p, &empty).is_empty(), "empty wallet is empty-but-valid, not corrupt" ); } From e9e98cc6784362169fa4b24f83e438a9ac08e19d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:21:12 +0000 Subject: [PATCH 082/108] feat(platform-wallet): deliver typed AddressNonceMismatch error to callers The optimistic address-nonce path submits `fetched + 1` by design and relies on the caller retrying when Platform rejects a stale/replayed value under a lagging DAPI replica read (Found-033). rs-sdk carries the typed `AddressInvalidNonceError` (consensus code 40603) end-to-end intact, but platform-wallet flattened it to a string at its error-mapping seams, so callers could not recover `expected_nonce` to honor their half of the optimistic-concurrency contract. Add a public `as_address_invalid_nonce` extractor (mirroring `as_asset_lock_proof_cl_height_too_low`, matching both the `Protocol(ConsensusError)` and `StateTransitionBroadcastError` SDK shapes), a first-class `PlatformWalletError::AddressNonceMismatch` variant carrying `{address, provided_nonce, expected_nonce}`, and a `promote_address_nonce_error` helper. Rewire the lossy string-collapse seams (shield `enrich`, `broadcast_shielded_spend`, `classify_spend_wait_failure`, identity `top_up_from_addresses`) to promote via the helper when it matches, else keep today's exact string fallback. rs-sdk is untouched. The plain transfer/withdrawal paths already preserve the typed error via `PlatformWalletError::Sdk`, from which the now-public extractor can recover the nonce. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/error.rs | 163 ++++++++++++++++++ .../identity/network/top_up_from_addresses.rs | 10 +- .../src/wallet/shielded/operations.rs | 9 +- 3 files changed, 175 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index e8c3a10a594..60589a53c39 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,6 +1,8 @@ use dpp::address_funds::PlatformAddress; +use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::fee::Credits; use dpp::identifier::Identifier; +use dpp::prelude::AddressNonce; use key_wallet::account::StandardAccountType; use key_wallet::Network; @@ -114,6 +116,26 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), + /// Platform rejected an address-funds transition because a spent + /// address's provided nonce did not equal Platform's expected next value + /// (DPP consensus code 40603, `AddressInvalidNonceError`): the optimistic + /// `fetched + 1` nonce raced a lagging DAPI replica's stale read. + /// + /// The rejection is by design — the address-nonce path is optimistic and + /// the caller owns the retry. This variant delivers Platform's + /// `expected_nonce` verbatim so the caller can rebuild the transition with + /// it (no re-fetch needed) instead of parsing a flattened string. + #[error( + "Address nonce mismatch for {address}: submitted nonce {provided_nonce}, \ + Platform expected {expected_nonce}; retry the operation with the \ + expected nonce" + )] + AddressNonceMismatch { + address: PlatformAddress, + provided_nonce: AddressNonce, + expected_nonce: AddressNonce, + }, + #[error("Address sync failed: {0}")] AddressSync(String), @@ -409,3 +431,144 @@ pub fn as_asset_lock_proof_cl_height_too_low( _ => None, } } + +/// Extract the `AddressInvalidNonceError` (DPP consensus code 40603) from an +/// SDK error if Platform rejected an address-funds transition because a spent +/// address's provided nonce did not equal its expected next value. +/// +/// Returns `Some(&error)` for both `dash_sdk::Error` shapes that carry a +/// consensus verdict — `StateTransitionBroadcastError` (wait-stream rejection) +/// and `Protocol(ProtocolError::ConsensusError)` (CheckTx rejection) — +/// exposing `address()`, `provided_nonce()`, and `expected_nonce()`. Returns +/// `None` for everything else. +/// +/// The address-nonce path is optimistic by design: the client submits +/// `fetched + 1` and Platform rejects a stale/replayed value under a lagging +/// replica read. This extractor lets a caller recover `expected_nonce` and +/// retry per that contract. Mirrors [`as_asset_lock_proof_cl_height_too_low`]; +/// the same coverage caveat applies — re-audit if a future `dash_sdk::Error` +/// variant starts carrying consensus errors through a different shape. +pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + + let consensus_error = match error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + }; + match consensus_error { + Some(ConsensusError::StateError(StateError::AddressInvalidNonceError(e))) => Some(e), + _ => None, + } +} + +/// Promote a nonce-rejection SDK error to the typed +/// [`PlatformWalletError::AddressNonceMismatch`] so callers can recover +/// `expected_nonce` and retry, instead of receiving the rejection flattened +/// to a string. +/// +/// Returns `None` for any error [`as_address_invalid_nonce`] does not match, +/// leaving the caller free to keep its existing fallback mapping. +pub fn promote_address_nonce_error(error: &dash_sdk::Error) -> Option { + as_address_invalid_nonce(error).map(|e| PlatformWalletError::AddressNonceMismatch { + address: *e.address(), + provided_nonce: e.provided_nonce(), + expected_nonce: e.expected_nonce(), + }) +} + +#[cfg(test)] +mod address_nonce_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + + const ADDR_BYTES: [u8; 20] = [7u8; 20]; + + /// An `AddressInvalidNonceError` wrapped as a `ConsensusError`, plus the + /// address it names, for asserting round-trip field fidelity. + fn nonce_consensus_error( + provided: AddressNonce, + expected: AddressNonce, + ) -> (PlatformAddress, dpp::consensus::ConsensusError) { + let address = PlatformAddress::P2pkh(ADDR_BYTES); + let err = AddressInvalidNonceError::new(address, provided, expected); + (address, err.into()) + } + + /// `Protocol(ConsensusError)` — the CheckTx-rejection shape. + fn protocol_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { + let (_, cause) = nonce_consensus_error(provided, expected); + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) + } + + /// `StateTransitionBroadcastError` — the wait-stream-rejection shape. + fn broadcast_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { + let (_, cause) = nonce_consensus_error(provided, expected); + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 40603, + message: "invalid address nonce".to_string(), + cause: Some(cause), + }) + } + + #[test] + fn extracts_nonce_error_from_protocol_shape() { + let err = protocol_shape(1, 2); + let got = as_address_invalid_nonce(&err).expect("protocol shape must match"); + assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(got.provided_nonce(), 1); + assert_eq!(got.expected_nonce(), 2); + } + + #[test] + fn extracts_nonce_error_from_broadcast_shape() { + let err = broadcast_shape(5, 6); + let got = as_address_invalid_nonce(&err).expect("broadcast shape must match"); + assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(got.provided_nonce(), 5); + assert_eq!(got.expected_nonce(), 6); + } + + #[test] + fn ignores_unrelated_and_causeless_errors() { + // A plainly unrelated SDK error. + assert!(as_address_invalid_nonce(&dash_sdk::Error::Generic("boom".to_string())).is_none()); + // The DAPI wait-timeout shape: a broadcast error with no consensus + // cause must NOT be misread as a nonce rejection. + let causeless = + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 0, + message: "timeout".to_string(), + cause: None, + }); + assert!(as_address_invalid_nonce(&causeless).is_none()); + } + + #[test] + fn promotes_both_shapes_to_typed_variant() { + for err in [protocol_shape(1, 2), broadcast_shape(1, 2)] { + match promote_address_nonce_error(&err) { + Some(PlatformWalletError::AddressNonceMismatch { + address, + provided_nonce, + expected_nonce, + }) => { + assert_eq!(address, PlatformAddress::P2pkh(ADDR_BYTES)); + assert_eq!(provided_nonce, 1); + assert_eq!(expected_nonce, 2); + } + other => panic!("expected AddressNonceMismatch, got {other:?}"), + } + } + } + + #[test] + fn promotion_leaves_unrelated_errors_for_the_fallback() { + assert!( + promote_address_nonce_error(&dash_sdk::Error::Generic("boom".to_string())).is_none() + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index da15c81cd8d..35f79e8ac37 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -76,10 +76,12 @@ impl IdentityWallet { .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity from addresses: {}", - e - )) + crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity from addresses: {}", + e + )) + }) })?; // Update the identity's balance in the local manager and diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9fe357f431e..0c0b6020d41 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -556,7 +556,8 @@ pub async fn shield, P: OrchardPr format_addresses_with_info(rich.addresses_with_info(), network), )) } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + crate::error::promote_address_nonce_error(e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) } }; @@ -2335,7 +2336,8 @@ async fn broadcast_shielded_spend( match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { - return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); + return Err(crate::error::promote_address_nonce_error(&e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); } Err(e) => { warn!( @@ -2443,7 +2445,8 @@ fn classify_spend_wait_failure( wait_err: &dash_sdk::Error, ) -> PlatformWalletError { if carries_consensus_rejection(wait_err) { - PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string()) + crate::error::promote_address_nonce_error(wait_err) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) } else { warn!( operation, From 650c32a8125139783560e8508122edfad735a7a1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:48:08 +0000 Subject: [PATCH 083/108] fix(platform-wallet-ffi): deliver AddressNonceMismatch across FFI + recurse nonce extractor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-001 (HIGH): the AddressNonceMismatch variant added at the wallet layer had no FFI mapping, so it both regressed and under-delivered at the C boundary hosts actually consume. - Regression: a shield nonce-rejection that used to surface as `ErrorShieldedBroadcastFailed` (code 16 — definitively failed, notes released, safe to retry) fell through `map_spend_result`'s catch-all to `ErrorWalletOperation` (code 6), dropping the retry-safety contract. - Fix: add a dedicated `ErrorAddressNonceMismatch = 21` result code (an additive enum slot) carrying the SAME definitively-failed / notes- released / safe-to-retry contract, and map to it in BOTH `map_spend_result` (covers shield/unshield/transfer/withdraw) AND the blanket `From` (covers identity `top_up_from_addresses`, which propagates via `?`/`.into()`). Hosts can now recognize the self- healing nonce race and retry — the retry re-fetches the address nonce. Nonce values are NOT exposed as structured out-fields: the shared `PlatformWalletFFIResult` is returned by value and mirrored by every language binding, so adding fields (or send-function out-params) would be an ABI-breaking change for all consumers. The submitted/expected nonces still travel in the result `message` (typed Display), and an FFI retry re-fetches the nonce anyway, so the practical loss is nil. QA-002 (LOW): `as_address_invalid_nonce` now recurses through `dash_sdk::Error::NoAvailableAddressesToRetry(inner)`, matching its sibling predicate `broadcast_definitely_failed` so the two stay in lockstep (latent today — the retry envelope never wraps a ConsensusError yet). Tests: AddressNonceMismatch -> ErrorAddressNonceMismatch via both the blanket From and map_spend_result; extractor unwraps the retry envelope. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 55 +++++++++++++++++++ .../src/shielded_send.rs | 34 ++++++++++++ packages/rs-platform-wallet/src/error.rs | 26 ++++++++- 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cec0966b9c2..869428b14bf 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -145,6 +145,20 @@ pub enum PlatformWalletFFIResultCode { /// observing the transaction reconciles the outcome. The host must NOT /// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`]. ErrorTransactionBroadcastUnconfirmed = 20, + /// Maps `PlatformWalletError::AddressNonceMismatch`. Platform rejected an + /// address-funds transition (shield, or identity top-up-from-addresses) + /// because the submitted address nonce raced Platform's expected next + /// value (a lagging DAPI replica stale read; consensus code 40603). Same + /// definitively-failed / notes-released / safe-to-retry contract as + /// [`Self::ErrorShieldedBroadcastFailed`] — the transition did NOT execute + /// and any note reservations were released (a shield reserves none) — but + /// as its OWN code so hosts can recognize this specific, self-healing + /// failure and retry: the retry re-fetches the address nonce, resolving + /// the mismatch without host intervention. The submitted and Platform- + /// expected nonce values travel in the result `message` (the typed + /// `Display`); they are not exposed as structured out-fields (that would + /// require an ABI-breaking change to `PlatformWalletFFIResult`). + ErrorAddressNonceMismatch = 21, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -285,6 +299,14 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } + // A definitively-failed address-nonce race (this reaches the blanket + // impl via identity `top_up_from_addresses`, which propagates the + // error through `?`/`.into()`). Keep the dedicated code so the host + // can recognize the self-healing failure and retry; the nonce values + // survive in the Display message. + PlatformWalletError::AddressNonceMismatch { .. } => { + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -664,6 +686,39 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` + /// FFI code through the blanket `From` impl (the path identity + /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening + /// to `ErrorUnknown`. The typed Display rendering — carrying the submitted + /// and expected nonce values — survives across the boundary as the message. + #[test] + fn address_nonce_mismatch_maps_to_dedicated_code() { + let err = PlatformWalletError::AddressNonceMismatch { + address: dpp::address_funds::PlatformAddress::P2pkh([7u8; 20]), + provided_nonce: 1, + expected_nonce: 2, + }; + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + "AddressNonceMismatch should map to ErrorAddressNonceMismatch (rendered: {rendered})" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!( + msg, rendered, + "Display payload must survive the FFI boundary verbatim" + ); + // The nonce values the host needs must be present in the message. + assert!( + msg.contains('1') && msg.contains('2'), + "nonce values must survive in the message" + ); + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 94fbbf1269a..7e89e681557 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -516,6 +516,15 @@ fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, format!("{operation} failed: {e}"), ), + // Definitively failed on an address-nonce race (a shield spends platform + // address funds; a shield reserves no notes). Its own code carries the + // safe-to-retry contract AND lets the host recognize the self-healing + // nonce mismatch — a plain retry re-fetches the nonce. Without this arm + // it would regress to the generic `ErrorWalletOperation` below. + Err(e @ PlatformWalletError::AddressNonceMismatch { .. }) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + format!("{operation} failed: {e}"), + ), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1438,4 +1447,29 @@ mod tests { PlatformWalletFFIResultCode::Success ); } + + /// A shield (Type 15) definitively rejected on an address-nonce race must + /// map to the dedicated `ErrorAddressNonceMismatch` — NOT regress to the + /// generic `ErrorWalletOperation` — so hosts keep the safe-to-retry signal. + /// The submitted/expected nonce values must survive in the message. + #[test] + fn map_spend_result_maps_address_nonce_mismatch_to_dedicated_code() { + let mismatch: Result<(), PlatformWalletError> = + Err(PlatformWalletError::AddressNonceMismatch { + address: PlatformAddress::P2pkh([7u8; 20]), + provided_nonce: 1, + expected_nonce: 2, + }); + let result = map_spend_result(mismatch, "shielded shield"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, + "shield nonce rejection must not regress to ErrorWalletOperation" + ); + let msg = message_of(&result); + assert!( + msg.contains('1') && msg.contains('2'), + "nonce values must survive in the message" + ); + } } diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 60589a53c39..3c50a6eafae 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -445,9 +445,11 @@ pub fn as_asset_lock_proof_cl_height_too_low( /// The address-nonce path is optimistic by design: the client submits /// `fetched + 1` and Platform rejects a stale/replayed value under a lagging /// replica read. This extractor lets a caller recover `expected_nonce` and -/// retry per that contract. Mirrors [`as_asset_lock_proof_cl_height_too_low`]; -/// the same coverage caveat applies — re-audit if a future `dash_sdk::Error` -/// variant starts carrying consensus errors through a different shape. +/// retry per that contract. Recurses through +/// [`dash_sdk::Error::NoAvailableAddressesToRetry`] so it stays in lockstep +/// with its sibling `broadcast_definitely_failed`; re-audit if a future +/// `dash_sdk::Error` variant starts carrying consensus errors through yet +/// another shape. pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; @@ -457,6 +459,12 @@ pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInval broadcast_err.cause.as_ref() } dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + // A consensus rejection can arrive wrapped when the dapi-client + // exhausted every address mid-retry; recurse so this predicate stays + // in lockstep with `broadcast_definitely_failed`. + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { + return as_address_invalid_nonce(inner) + } _ => None, }; match consensus_error { @@ -571,4 +579,16 @@ mod address_nonce_tests { promote_address_nonce_error(&dash_sdk::Error::Generic("boom".to_string())).is_none() ); } + + #[test] + fn extracts_nonce_error_wrapped_in_no_available_addresses_to_retry() { + // The dapi-client wraps the last rejection in `NoAvailableAddressesToRetry` + // when every address is exhausted mid-retry; the extractor must recurse + // into it (lockstep with `broadcast_definitely_failed`). + let inner = Box::new(protocol_shape(9, 10)); + let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(inner); + let got = as_address_invalid_nonce(&wrapped).expect("must unwrap the retry envelope"); + assert_eq!(got.provided_nonce(), 9); + assert_eq!(got.expected_nonce(), 10); + } } From 069ee6a7935f44801d3b78d899ef07444d34205a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:35 +0000 Subject: [PATCH 084/108] fix(swift-sdk): mirror ErrorAddressNonceMismatch (=21); pin FFI nonce test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QA-R2 #1 (MEDIUM): the Swift host binding — the only in-repo host — stopped at result code 20, so the new ErrorAddressNonceMismatch (=21) collapsed to `.errorUnknown` → `.unknown`, and the typed nonce-race fix never reached the shipped host. Mirror it in PlatformWalletResult.swift: add `case errorAddressNonceMismatch = 21` to `PlatformWalletResultCode`, its `init(ffi:)` arm (matching the cbindgen constant `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH`), a dedicated `PlatformWalletError.addressNonceMismatch(String)` case, and the `init(result:)` mapping — mirroring `errorShieldedBroadcastFailed`'s definitively-failed / notes-released / safe-to-retry semantics. Swift compilation was NOT verified: the cbindgen-generated header + DashSDKFFI module are produced by build_ios.sh (iOS toolchain, unavailable here); the change is pattern-exact against the existing arms and the `prefix_with_name + ScreamingSnakeCase` cbindgen config. QA-R2 #2 (LOW): the two new nonce-value FFI tests asserted bare digits (`contains('1') && contains('2')`), which a provided/expected transposition would pass. Pin the exact rendered substrings instead (`submitted nonce 1` / `Platform expected 2`) so a transposition fails. Per the ABI decision (keep dedicated-code-only), document at the blanket `From` mapping arm that structured provided/expected nonce out-fields are intentionally out of scope — PlatformWalletFFIResult is by-value/ABI-frozen; the values travel in the message and an FFI retry re-fetches the nonce. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 22 ++++++++++++------- .../src/shielded_send.rs | 10 +++++++-- .../PlatformWallet/PlatformWalletResult.swift | 20 +++++++++++++++++ 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 869428b14bf..5bad1aa9183 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -299,11 +299,12 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } - // A definitively-failed address-nonce race (this reaches the blanket - // impl via identity `top_up_from_addresses`, which propagates the - // error through `?`/`.into()`). Keep the dedicated code so the host - // can recognize the self-healing failure and retry; the nonce values - // survive in the Display message. + // A definitively-failed address-nonce race (reaches the blanket impl + // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing + // provided/expected nonce as structured out-fields is INTENTIONALLY + // out of scope: `PlatformWalletFFIResult` is by-value / ABI-frozen, so + // the values travel in the message string and an FFI retry re-fetches + // the nonce. PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } @@ -712,10 +713,15 @@ mod tests { msg, rendered, "Display payload must survive the FFI boundary verbatim" ); - // The nonce values the host needs must be present in the message. + // Pin the EXACT rendered substrings, not bare digits, so a + // provided/expected transposition would fail the test. assert!( - msg.contains('1') && msg.contains('2'), - "nonce values must survive in the message" + msg.contains("submitted nonce 1"), + "submitted (provided) nonce must render exactly: {msg}" + ); + assert!( + msg.contains("Platform expected 2"), + "expected nonce must render exactly: {msg}" ); } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 7e89e681557..dbdead34c63 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1467,9 +1467,15 @@ mod tests { "shield nonce rejection must not regress to ErrorWalletOperation" ); let msg = message_of(&result); + // Pin the EXACT rendered substrings, not bare digits, so a + // provided/expected transposition would fail the test. assert!( - msg.contains('1') && msg.contains('2'), - "nonce values must survive in the message" + msg.contains("submitted nonce 1"), + "submitted (provided) nonce must render exactly: {msg}" + ); + assert!( + msg.contains("Platform expected 2"), + "expected nonce must render exactly: {msg}" ); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 9676ee38143..a38ba25a027 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -53,6 +53,14 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// of double-spending; the reservation TTL or a sync reconciles the /// outcome. Do NOT auto-retry. case errorTransactionBroadcastUnconfirmed = 20 + /// Definitively-failed address-nonce race: Platform rejected an + /// address-funds transition (shield, or identity top-up-from-addresses) + /// because the submitted address nonce raced Platform's expected value + /// (a lagging DAPI replica read). The transition did NOT execute and any + /// notes were released (a shield reserves none) — safe to retry; the retry + /// re-fetches the nonce and self-heals. The submitted/expected nonce values + /// travel in the message string, not as structured fields. + case errorAddressNonceMismatch = 21 case notFound = 98 case errorUnknown = 99 @@ -100,6 +108,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedNoRecordedAnchor case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_TRANSACTION_BROADCAST_UNCONFIRMED: self = .errorTransactionBroadcastUnconfirmed + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: + self = .errorAddressNonceMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -208,6 +218,13 @@ public enum PlatformWalletError: LocalizedError { /// reservation TTL or a later sync reconciles the outcome. Do NOT /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. case transactionBroadcastUnconfirmed(String) + /// Definitively-failed address-nonce race (shield, or identity + /// top-up-from-addresses): Platform rejected the transition because the + /// submitted address nonce raced its expected value. The transition did + /// NOT execute and any notes were released (a shield reserves none) — safe + /// to retry, and the retry re-fetches the address nonce so the mismatch + /// self-heals. The submitted/expected nonce values are in the message. + case addressNonceMismatch(String) case notFound(String) case unknown(String) @@ -225,6 +242,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), + .addressNonceMismatch(let m), .notFound(let m), .unknown(let m): return m } @@ -258,6 +276,8 @@ public enum PlatformWalletError: LocalizedError { case .errorShieldedNoRecordedAnchor: self = .shieldedNoRecordedAnchor(detail) case .errorTransactionBroadcastUnconfirmed: self = .transactionBroadcastUnconfirmed(detail) + case .errorAddressNonceMismatch: + self = .addressNonceMismatch(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From 062caeaad99a5ef24f4027ca16a447da41333557 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:02:09 +0000 Subject: [PATCH 085/108] chore(deps): bump rust-dashcore pin to key-wallet asset-lock override branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Points the 8 rust-dashcore workspace deps at dashpay/rust-dashcore#850 (feat/key-wallet-asset-lock-explicit-utxo-override @ d42081e7), which adds an explicit-UTXO-override escape hatch for asset-lock funding. Updates the one build_asset_lock_with_signer call site in this branch for the new optional override parameter (passed as None — this branch doesn't use the override). TODO(pin) left in Cargo.toml: bump again once #850 merges to dev. Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 24 +++++++++---------- Cargo.toml | 18 +++++++------- .../src/wallet/asset_lock/build.rs | 1 + 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a46c8e326b..8a84ef48aab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "bincode", "dashcore-private", @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" [[package]] name = "glob" @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=647fa9820f3614090e4e5f5f2b709961d68e538b#647fa9820f3614090e4e5f5f2b709961d68e538b" +source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 3fbbd5c2552..d82c7f08039 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,16 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "647fa9820f3614090e4e5f5f2b709961d68e538b" } +# TODO(pin): tracks dashpay/rust-dashcore#850 (draft) — asset-lock explicit +# UTXO override. Bump to the merged `dev` SHA once that PR lands. +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 5a3444a3159..89a1fa9a7c0 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -98,6 +98,7 @@ impl AssetLockManager { vec![funding], DEFAULT_FEE_PER_KB, signer, + None, ) .await .map_err(|e| { From a985d2359a3744194e9a5c074f7384ba8d1ecc95 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:33:02 +0000 Subject: [PATCH 086/108] chore(deps): switch rust-dashcore pin from PR #850 to PR #851 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #850 (asset-lock explicit UTXO override) and PR #851 (key-wallet out-of-order UTXO spend fix, #649) are disjoint branches off `dev`. Re-point the pin at #851's head, which this crate's watch-only rehydration work actually needs. The only call site depending on #850's API (`build_asset_lock_with_signer`'s `override_utxo` param) was passing `None` — a no-op default — so dropping the argument to match #851's 5-arg signature is a pure mechanical revert, no behavior change. Co-Authored-By: Claude Sonnet 4.5 --- Cargo.lock | 46 +++++++++---------- Cargo.toml | 21 +++++---- .../src/wallet/asset_lock/build.rs | 1 - 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a84ef48aab..2752f46c379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1206,7 +1206,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode", "bincode_derive", @@ -1648,7 +1648,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "dash-network", ] @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "chrono", @@ -1754,7 +1754,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "anyhow", "base64-compat", @@ -1780,12 +1780,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "dashcore-rpc-json", "hex", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode", "dashcore", @@ -1813,7 +1813,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "bincode", "dashcore-private", @@ -2428,7 +2428,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2489,7 +2489,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2867,7 +2867,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" [[package]] name = "glob" @@ -3803,7 +3803,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4034,7 +4034,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "aes", "async-trait", @@ -4063,7 +4063,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4079,7 +4079,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f#d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" +source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" dependencies = [ "async-trait", "bincode", @@ -4596,7 +4596,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5698,7 +5698,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6489,7 +6489,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6502,7 +6502,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6561,7 +6561,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7421,7 +7421,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -8870,7 +8870,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d82c7f08039..7fcb00ef8f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,16 +50,17 @@ members = [ ] [workspace.dependencies] -# TODO(pin): tracks dashpay/rust-dashcore#850 (draft) — asset-lock explicit -# UTXO override. Bump to the merged `dev` SHA once that PR lands. -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "d42081e7bc916fb5c4f994cdfa1c01cf5ede9e0f" } +# TODO(pin): tracks dashpay/rust-dashcore#851 (draft) — key-wallet +# out-of-order UTXO spend fix (#649). Bump to the merged `dev` SHA once +# that PR lands. +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } tokio-metrics = "0.5" diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 89a1fa9a7c0..5a3444a3159 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -98,7 +98,6 @@ impl AssetLockManager { vec![funding], DEFAULT_FEE_PER_KB, signer, - None, ) .await .map_err(|e| { From b70062bd4f3689857e0d3ae00da778fa36c6e543 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:17:40 +0000 Subject: [PATCH 087/108] test(swift-sdk): replace removed sendToAddresses with builder+broadcast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ManagedCoreWallet.sendToAddresses` is gone; three integration tests still called it and would fail to compile under `-warnings-as-errors`. Route them through the canonical `CoreTransactionBuilder` → `buildSigned` → `broadcastTransaction` flow via a shared `TestWalletWrapper.send(...)` helper (BIP44 account 0), mirroring `SendViewModel`'s `.coreToCore` case. Test intent is preserved verbatim: the two `waitForNewTxid` sites keep discarding the result, and the persister-restart test derives its txid from the signed tx's consensus-serialized `data` — the same `sha256d(tx_bytes)` identity it asserted before. Co-Authored-By: Claude Opus 4.8 --- .../Core/CoreSendIntegrationTests.swift | 4 +--- ...RestartClassificationIntegrationTests.swift | 6 ++---- .../Core/SpvRestartIntegrationTests.swift | 4 +--- .../Support/TestWallet.swift | 18 ++++++++++++++++++ 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift index 00ce6de6c4e..f22cdaea95f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/CoreSendIntegrationTests.swift @@ -32,9 +32,7 @@ final class CoreSendIntegrationTests: IntegrationTestCase { let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress() let beforeTxids = try await readTxids() - _ = try sender.getCoreWallet().sendToAddresses( - recipients: [(address: recipientAddress, amountDuffs: amount)] - ) + try sender.send(toAddress: recipientAddress, amountDuffs: amount) guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { XCTFail("send PersistentTransaction row never appeared on iteration \(i)") return diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift index b5d264bc16f..8202ae9d403 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/PersisterRestartClassificationIntegrationTests.swift @@ -19,10 +19,8 @@ final class PersisterRestartClassificationIntegrationTests: IntegrationTestCase try await alice.waitForSpendable(exactly: fundingDuffs, timeout: 90) let aliceSecondAddr = try alice.getCoreWallet().nextReceiveAddress() - let sendTxData = try alice.getCoreWallet().sendToAddresses( - recipients: [(address: aliceSecondAddr, amountDuffs: sendAmount)] - ) - let sendTxid = Self.txid(ofRawTx: sendTxData) + let signedTx = try alice.send(toAddress: aliceSecondAddr, amountDuffs: sendAmount) + let sendTxid = Self.txid(ofRawTx: signedTx.data) try await waitForTxRow(sendTxid) // First sighting must already classify as Internal / -fee. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift index ed823c40681..f9ba3a80b7e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Core/SpvRestartIntegrationTests.swift @@ -64,9 +64,7 @@ final class SpvRestartIntegrationTests: IntegrationTestCase { let recipientAddress = try receiver.getCoreWallet().nextReceiveAddress() let beforeTxids = try await readTxids() - _ = try sender.getCoreWallet().sendToAddresses( - recipients: [(address: recipientAddress, amountDuffs: amount)] - ) + try sender.send(toAddress: recipientAddress, amountDuffs: amount) guard let sendTxid = try await waitForNewTxid(notIn: beforeTxids) else { XCTFail("send PersistentTransaction row never appeared") return diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift index 7c20954cb20..c72aa154e27 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKIntegrationTests/Support/TestWallet.swift @@ -28,6 +28,24 @@ final class TestWalletWrapper { try wallet.balance().spendable == duffs } } + + /// Build, sign, and broadcast a single-recipient send from BIP44 account 0, + /// mirroring `SendViewModel`'s `.coreToCore` flow (the SDK no longer exposes + /// a one-shot `sendToAddresses`). Returns the signed transaction so callers + /// can read its consensus-serialized `data` or derive the broadcast txid. + @discardableResult + func send(toAddress address: String, amountDuffs: UInt64) throws -> CoreTransaction { + let builder = try CoreTransactionBuilder(network: core.network()) + try builder.addOutput(address: address, amountDuffs: amountDuffs) + try builder.setFunding(wallet: wallet, accountType: .bip44, accountIndex: 0) + let signedTx = try builder.buildSigned( + wallet: wallet, + accountType: .bip44, + accountIndex: 0 + ) + _ = try core.broadcastTransaction(signedTx) + return signedTx + } } enum Wait { From dc77493086c3b1422708f51ca57e80d959a092a8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:17:51 +0000 Subject: [PATCH 088/108] fix(swift-sdk): stop funding core-to-core sends from a Platform-Payment index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For every non-`platformToPlatform` flow, `senderAccountIndex` was derived from key-class-0 Platform Payment accounts (`addressBalances.filter { keyClass == 0 }`) and then fed — for `.coreToCore` — into `CoreTransactionBuilder.setFunding(accountType: .bip44, accountIndex:)`. That crosses two distinct account namespaces: a Platform-Payment index is not a BIP44 Core account index, so the wrong Core account could fund the payment. Core-to-Core has no account picker and funds the default BIP44 account, so resolve the non-platform path to account 0. The adjacent comment claimed every other flow "ignores senderAccountIndex" — false for `.coreToCore`; rewrite it to document both consumers and their separate namespaces. Co-Authored-By: Claude Opus 4.8 --- .../Core/Views/SendTransactionView.swift | 51 ++++++++++--------- 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift index 9f27c215072..354b1369d01 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/SendTransactionView.swift @@ -248,26 +248,34 @@ struct SendTransactionView: View { // be the one that was last created. let managed = walletManager.wallet(for: wallet.walletId) let platformAddressWallet = try? managed?.platformAddressWallet() - // Pick the account that will FUND a platform → - // platform transfer. The Rust Auto selector - // resolves the source via - // `platform_payment_managed_account_at_index` - // (key class 0) and selects its inputs WITHIN - // that single account — it does not span - // accounts. `canSend` only gates on the - // aggregate platform balance, so with multiple - // key-class-0 Platform Payment accounts we must - // choose an account whose OWN balance covers the - // requested amount + fee; otherwise we'd enable a - // send Rust rejects. The selection is factored - // into the pure, unit-tested - // `PlatformPaymentAccountSelection` helper. + // Resolve the account that FUNDS the send. Two + // consumers read `senderAccountIndex`, in two + // DISTINCT account namespaces: // - // Only the platform → platform path needs this - // coverage-aware pick; every other flow ignores - // `senderAccountIndex`, so the prior - // "first key-class-0 positive balance, else 0" - // behaviour is preserved for them. + // • platform → platform: a key-class-0 Platform + // Payment account. The Rust Auto selector + // resolves the source via + // `platform_payment_managed_account_at_index` + // and selects inputs WITHIN that single account + // (it does not span accounts). `canSend` gates + // only on the aggregate platform balance, so we + // must pick an account whose OWN balance covers + // amount + fee, else Rust rejects the send — + // done by the unit-tested + // `PlatformPaymentAccountSelection` helper. + // + // • core → core: a BIP44 Core account index, fed + // into `CoreTransactionBuilder.setFunding( + // accountType: .bip44, ...)`. That namespace is + // SEPARATE from key-class Platform Payment + // accounts — a Platform-Payment index must never + // leak into it. The Core send UI has no account + // picker and funds the default BIP44 account, so + // resolve to account 0. + // + // Every other flow (shielded / platform → shielded + // / core → shielded) ignores this value and + // resolves its own funding, so 0 is harmless there. let senderAccountIndex: UInt32 if viewModel.detectedFlow == .platformToPlatform { guard let resolved = resolvePlatformSenderAccountIndex() else { @@ -276,10 +284,7 @@ struct SendTransactionView: View { } senderAccountIndex = resolved } else { - senderAccountIndex = addressBalances - .filter { $0.account?.keyClass == 0 } - .first(where: { $0.balance > 0 })? - .accountIndex ?? 0 + senderAccountIndex = 0 } // Input selection and surplus handling are owned // by the Rust Auto path (surplus stays on the From eb3c7ae835b1df9388fdf5e3d0d2c7c14fea2611 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:18:02 +0000 Subject: [PATCH 089/108] fix(platform-wallet-ffi): surface persister-load retry class across the FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PlatformWalletError::PersisterLoad(PersistenceError)` had no dedicated arm in `From for PlatformWalletFFIResult`, so it fell to `ErrorUnknown` and Swift only ever saw `.unknown(String)` — losing the transient-vs-fatal distinction the Rust side already proves (rehydration_load.rs: RT-PersisterLoad-Transient / -Permanent). Add two by-value codes — `ErrorPersisterLoadTransient` (22) and `ErrorPersisterLoadFatal` (23) — split on `PersistenceError::is_transient()` (Fatal/Constraint/LockPoisoned all read non-transient, i.e. do-not-retry). The typed Display still travels in the message string; no ABI/out-param change, matching the `AddressNonceMismatch` precedent. Mirror both codes and their `PlatformWalletError` cases into the Swift binding; cbindgen regenerates the `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_*` constants from the enum. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-ffi/src/error.rs | 76 +++++++++++++++++++ .../PlatformWallet/PlatformWalletResult.swift | 28 +++++++ 2 files changed, 104 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 5bad1aa9183..cd5b24fcc62 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -159,6 +159,26 @@ pub enum PlatformWalletFFIResultCode { /// `Display`); they are not exposed as structured out-fields (that would /// require an ABI-breaking change to `PlatformWalletFFIResult`). ErrorAddressNonceMismatch = 21, + /// Maps `PlatformWalletError::PersisterLoad` when the wrapped + /// [`PersistenceError`] classifies as transient + /// (`is_transient() == true`, e.g. `SQLITE_BUSY`). Rehydration could not + /// load the persisted client state, but the backend reports the failure + /// as recoverable, so the host MAY retry the load with backoff. Split + /// from [`Self::ErrorPersisterLoadFatal`] so the retry classification + /// proven on the Rust side survives the boundary instead of flattening + /// to `ErrorUnknown`. + /// + /// [`PersistenceError`]: platform_wallet::changeset::PersistenceError + ErrorPersisterLoadTransient = 22, + /// Maps `PlatformWalletError::PersisterLoad` when the wrapped + /// [`PersistenceError`] is non-transient (a `Fatal` or `Constraint` + /// backend failure, or a poisoned lock). Rehydration could not load the + /// persisted client state and the failure is unrecoverable, so the host + /// MUST NOT retry the same load. Sibling of + /// [`Self::ErrorPersisterLoadTransient`]. + /// + /// [`PersistenceError`]: platform_wallet::changeset::PersistenceError + ErrorPersisterLoadFatal = 23, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -308,6 +328,18 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AddressNonceMismatch { .. } => { PlatformWalletFFIResultCode::ErrorAddressNonceMismatch } + // A rehydration persister-load failure. The Rust side already proves + // the transient-vs-fatal classification (rehydration_load.rs), so + // split the code on `is_transient()` — flattening to `ErrorUnknown` + // would erase the retry signal. Fatal/Constraint/LockPoisoned all + // read as non-transient, i.e. do-not-retry. + PlatformWalletError::PersisterLoad(inner) => { + if inner.is_transient() { + PlatformWalletFFIResultCode::ErrorPersisterLoadTransient + } else { + PlatformWalletFFIResultCode::ErrorPersisterLoadFatal + } + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -725,6 +757,50 @@ mod tests { ); } + /// A rehydration `PersisterLoad` failure keeps its transient-vs-fatal + /// classification across the boundary: a transient backend failure maps to + /// `ErrorPersisterLoadTransient` (retryable) and every non-transient case + /// (`Fatal`, `Constraint`, `LockPoisoned`) maps to `ErrorPersisterLoadFatal` + /// (do-not-retry), rather than both flattening to `ErrorUnknown`. The typed + /// Display rendering survives verbatim as the message. + #[test] + fn persister_load_maps_by_retry_classification() { + use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; + + let transient: Vec = vec![PlatformWalletError::PersisterLoad( + PersistenceError::backend_with_kind(PersistenceErrorKind::Transient, "database busy"), + )]; + let fatal: Vec = vec![ + PlatformWalletError::PersisterLoad(PersistenceError::backend("schema corrupt")), + PlatformWalletError::PersisterLoad(PersistenceError::backend_with_kind( + PersistenceErrorKind::Constraint, + "foreign key violation", + )), + PlatformWalletError::PersisterLoad(PersistenceError::LockPoisoned), + ]; + + for (cases, expected) in [ + ( + transient, + PlatformWalletFFIResultCode::ErrorPersisterLoadTransient, + ), + (fatal, PlatformWalletFFIResultCode::ErrorPersisterLoadFatal), + ] { + for err in cases { + let rendered = err.to_string(); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, expected, + "PersisterLoad must map by retry classification (rendered: {rendered})" + ); + let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert_eq!(msg, rendered, "Display payload must survive verbatim"); + } + } + } + /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index a38ba25a027..8a1c0681643 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -61,6 +61,17 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// re-fetches the nonce and self-heals. The submitted/expected nonce values /// travel in the message string, not as structured fields. case errorAddressNonceMismatch = 21 + /// Rehydration could not load the persisted client state, but the storage + /// backend classified the failure as transient (e.g. a busy database), so + /// the caller MAY retry the load with backoff. Distinct from + /// `errorPersisterLoadFatal` so the retry classification survives the FFI + /// boundary instead of flattening to `errorUnknown`. + case errorPersisterLoadTransient = 22 + /// Rehydration could not load the persisted client state and the failure is + /// unrecoverable (corruption, constraint violation, or a poisoned lock), so + /// the caller MUST NOT retry the same load. Sibling of + /// `errorPersisterLoadTransient`. + case errorPersisterLoadFatal = 23 case notFound = 98 case errorUnknown = 99 @@ -110,6 +121,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorTransactionBroadcastUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ADDRESS_NONCE_MISMATCH: self = .errorAddressNonceMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_TRANSIENT: + self = .errorPersisterLoadTransient + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_FATAL: + self = .errorPersisterLoadFatal case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -225,6 +240,14 @@ public enum PlatformWalletError: LocalizedError { /// to retry, and the retry re-fetches the address nonce so the mismatch /// self-heals. The submitted/expected nonce values are in the message. case addressNonceMismatch(String) + /// Rehydration could not load the persisted client state, but the backend + /// classified the failure as transient (e.g. a busy database). Safe to + /// retry the load with backoff. Distinct from `persisterLoadFatal`. + case persisterLoadTransient(String) + /// Rehydration could not load the persisted client state and the failure is + /// unrecoverable (corruption, constraint violation, or a poisoned lock). Do + /// NOT retry the same load. + case persisterLoadFatal(String) case notFound(String) case unknown(String) @@ -243,6 +266,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedNoRecordedAnchor(let m), .transactionBroadcastUnconfirmed(let m), .addressNonceMismatch(let m), + .persisterLoadTransient(let m), .persisterLoadFatal(let m), .notFound(let m), .unknown(let m): return m } @@ -278,6 +302,10 @@ public enum PlatformWalletError: LocalizedError { self = .transactionBroadcastUnconfirmed(detail) case .errorAddressNonceMismatch: self = .addressNonceMismatch(detail) + case .errorPersisterLoadTransient: + self = .persisterLoadTransient(detail) + case .errorPersisterLoadFatal: + self = .persisterLoadFatal(detail) case .notFound: self = .notFound(detail) case .errorUnknown: self = .unknown(detail) } From c8c6a93d935efdadc024a0d9977bf6c1e58bf243 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:10:29 +0000 Subject: [PATCH 090/108] refactor(platform-wallet): apply PR #3692 self-review notes Addresses three self-review action items left on PR #3692: - Rename the `ClientWalletStartState::core_wallet_info` field back to `wallet_info` across the crate, the FFI persister, load path, tests and README. The field is a pure Rust identifier (struct derives only `Debug`, no serde, no `#[serde(rename)]`, no byte-key encoding), so the rename is cosmetic and touches no on-disk/wire format. - Condense `manager/rehydrate.rs` commentary from 53 to 22 comment lines, cutting narrative/redundant prose while preserving the load-bearing trust-boundary security rationale. - Rewrite the `tests/rehydration_load.rs` module doc to be self-contained, dropping the "Item E" / "after the seedless rework" historical framing. No behavior change. fmt/check/clippy (-D warnings) clean; the 14 rehydration_load tests and rehydrate unit tests pass. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014Vq23cSxwwL4zSimo491Dr --- .../rs-platform-wallet-ffi/src/persistence.rs | 2 +- packages/rs-platform-wallet/README.md | 2 +- .../changeset/client_wallet_start_state.rs | 2 +- .../rs-platform-wallet/src/manager/load.rs | 8 +-- .../src/manager/rehydrate.rs | 65 +++++-------------- .../tests/rehydration_load.rs | 33 +++++----- 6 files changed, 40 insertions(+), 72 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 98ee8fe337e..a6f501d1ec6 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3563,7 +3563,7 @@ fn build_wallet_start_state( network, birth_height: entry.birth_height, account_manifest, - core_wallet_info: Box::new(wallet_info), + wallet_info: Box::new(wallet_info), identity_manager, unused_asset_locks, }; diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index f5fde97c067..e873e968125 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -172,7 +172,7 @@ operational state that only lives in platform-wallet's memory: and fresh-receive-address (`used`) state. Persisters that can reconstruct the full keyless snapshot hand it back -as `ClientWalletStartState::core_wallet_info` (consumed verbatim, per +as `ClientWalletStartState::wallet_info` (consumed verbatim, per invariant 4). The flattened projection fields (`core_state`/`used_core_addresses`) are a transitional fallback for persisters that cannot build a snapshot yet, and are slated for diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index ecb115ace8c..28f23124935 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -47,7 +47,7 @@ pub struct ClientWalletStartState { /// the manifest — preserving per-account attribution, the full SPV /// watch set, and pool used-state verbatim, without re-deriving /// anything. The FFI/iOS persister populates this. - pub core_wallet_info: Box, + pub wallet_info: Box, /// Lean snapshot of this wallet's /// [`IdentityManager`](crate::wallet::identity::IdentityManager). pub identity_manager: IdentityManagerStartState, diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 91eed9fbd67..7cfdfad2f53 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -25,7 +25,7 @@ impl PlatformWalletManager

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

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

{ // the exact pool contents (derived-but-unused addresses stay in // the SPV watch set), and per-index used flags. let wallet_info = { - let mut info = *core_wallet_info; + let mut info = *wallet_info; // The snapshot must describe this row's wallet and its // account set must agree with the manifest that built the // watch-only wallet above. Either mismatch is a wrong-row @@ -406,7 +406,7 @@ mod rollback_tests { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest, - core_wallet_info: Box::new(info), + wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), }, diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet/src/manager/rehydrate.rs index 1d71791d8b8..578710d3dca 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -1,18 +1,8 @@ //! Watch-only wallet reconstruction from the keyless account manifest. //! -//! Load is **seedless** (see [`load_from_persistor`]). For each -//! persisted wallet we build a watch-only [`Wallet`] from its keyless -//! `AccountRegistrationEntry` manifest; the manager then consumes the -//! carried [`ManagedWalletInfo`](key_wallet::wallet::managed_wallet_info::ManagedWalletInfo) -//! snapshot directly. No seed, no signing-key derivation. -//! -//! Because load never touches the seed, it performs no wrong-seed check. -//! Wrong-seed validation lives in the resolver-backed signing -//! entrypoints (`sign_with_mnemonic_resolver` and the FFI resolver sign -//! path), which fail-closed gate the resolver-supplied seed against the -//! loaded `wallet_id`; the seedless load path here never sees the seed. -//! -//! [`load_from_persistor`]: super::PlatformWalletManager::load_from_persistor +//! Load is seedless — each wallet is rebuilt watch-only from its manifest and +//! the manager consumes the carried snapshot directly, so no wrong-seed check +//! runs here; that gate lives in the resolver-backed signing entrypoints. use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::Account; @@ -22,40 +12,21 @@ use key_wallet::Network; use crate::changeset::AccountRegistrationEntry; use crate::manager::load_outcome::CorruptKind; -/// Build a watch-only [`Wallet`] from the keyless account manifest. -/// -/// Each `AccountRegistrationEntry` becomes an [`Account::from_xpub`] -/// (watch-only) keyed to `expected_wallet_id`; the assembled -/// [`AccountCollection`] is handed to [`Wallet::new_watch_only`] under -/// the same id. No key material crosses this function. -/// -/// Returns [`CorruptKind`] when the row is structurally unusable -/// (caller wraps it in a per-row [`SkipReason`]). -/// -/// [`SkipReason`]: crate::manager::load_outcome::SkipReason +/// Build a watch-only [`Wallet`] from the keyless account manifest, stamping +/// `expected_wallet_id` onto the reconstructed [`AccountCollection`]. Returns +/// [`CorruptKind`] when the row is structurally unusable; no key material +/// crosses this function. /// /// # Trust boundary /// -/// `expected_wallet_id` is stamped onto the reconstructed [`Wallet`] -/// verbatim and is **not** cryptographically bound to the manifest: the -/// id hashes the *root* xpub, but only account-level (hardened, one-way) -/// xpubs are persisted, so the root cannot be recovered here to re-derive -/// and verify the id. Only a structural decode runs, so a well-formed but -/// **wrong** `account_xpub` is accepted. -/// -/// Concretely, the attack this leaves open: an attacker who can write to -/// the backing store (or a malicious/rolled-back backup restored into it) -/// substitutes a valid xpub of their own for a wallet's `account_xpub`, -/// leaving `expected_wallet_id` unchanged. The wallet is rebuilt under the -/// original id but now derives its receive addresses from the attacker's -/// key, so future incoming funds are silently redirected — the id looks -/// unchanged to the user while the money flows elsewhere. This crate -/// **does not** defend against it: closing the gap requires the storage -/// layer to authenticate the manifest (a persisted commitment/MAC over -/// `{wallet_id, network, manifest}`, verified fail-closed on load), which -/// is a storage-schema change tracked in the `platform-wallet-storage` -/// crate. See the trust-boundary note on -/// [`PlatformWalletPersistence::load`](crate::changeset::PlatformWalletPersistence::load). +/// `expected_wallet_id` is **not** cryptographically bound to the manifest: the +/// id hashes the *root* xpub, but only account-level xpubs are persisted, so the +/// root cannot be recovered here to re-verify it. A well-formed but **wrong** +/// `account_xpub` is therefore accepted — anyone able to write the backing store +/// can swap in their own xpub under the unchanged id and silently redirect +/// incoming funds. Closing this needs storage-layer manifest authentication (a +/// MAC over `{wallet_id, network, manifest}`, verified fail-closed on load), +/// tracked in the `platform-wallet-storage` crate. pub(super) fn build_watch_only_wallet( network: Network, expected_wallet_id: [u8; 32], @@ -66,9 +37,8 @@ pub(super) fn build_watch_only_wallet( } let mut accounts = AccountCollection::new(); for entry in manifest { - // NOTE: `Account::from_xpub` is infallible in the pinned key-wallet rev - // (unconditional `Ok`); this map_err is a defensive guard for when its - // signature becomes fallible (e.g. xpub/type validation). + // `Account::from_xpub` is infallible in the pinned key-wallet rev; this + // map_err is a defensive guard for when that signature becomes fallible. let account = Account::from_xpub( Some(expected_wallet_id), entry.account_type, @@ -118,7 +88,6 @@ mod tests { let restored = build_watch_only_wallet(Network::Testnet, id, &manifest).unwrap(); assert_eq!(restored.wallet_id, id); assert_eq!(restored.compute_wallet_id(), id); - // Every manifest account survives the round trip (count, types). let restored_types: Vec<_> = restored .accounts .all_accounts() diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs index f4fcabca2d0..000d17a31e5 100644 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -1,12 +1,11 @@ -//! Item E — `load_from_persistor` (seedless / watch-only) end-to-end -//! through a real `PlatformWalletManager`. +//! End-to-end coverage of `PlatformWalletManager::load_from_persistor`, the +//! seedless / watch-only load path, through a real `PlatformWalletManager`. //! -//! Scope after the seedless rework: load reconstructs every persisted -//! wallet **watch-only** from its keyless account manifest. The load -//! path never touches the seed, so it performs no wrong-seed check; -//! wrong-seed validation lives in the resolver-backed signing -//! entrypoints, not in this load path. Per-row decode failures surface -//! as [`SkipReason::CorruptPersistedRow`] without aborting the batch. +//! Load reconstructs every persisted wallet **watch-only** from its keyless +//! account manifest. The load path never touches the seed, so it performs no +//! wrong-seed check; wrong-seed validation lives in the resolver-backed signing +//! entrypoints, not here. Per-row decode failures surface as +//! [`SkipReason::CorruptPersistedRow`] without aborting the batch. //! //! RT cases here: //! - RT-WO: round-trip — watch-only wallet is registered after reload. @@ -15,7 +14,7 @@ //! fires on the registered handler, `load` returns `Ok`. //! - RT-Z: no key/seed material in any `LoadOutcome` / `SkipReason` //! surface (the structural-only contract). -//! - RT-Snapshot: a carried `core_wallet_info` snapshot is consumed +//! - RT-Snapshot: a carried `wallet_info` snapshot is consumed //! verbatim — per-account UTXO attribution and derived-but-unused //! deep pool addresses survive the reload; a snapshot whose //! `wallet_id` mismatches its row is skipped as corrupt. @@ -77,7 +76,7 @@ impl PlatformWalletPersistence for FixedLoadPersister { network: w.network, birth_height: w.birth_height, account_manifest: w.account_manifest.clone(), - core_wallet_info: w.core_wallet_info.clone(), + wallet_info: w.wallet_info.clone(), identity_manager: Default::default(), unused_asset_locks: Default::default(), }, @@ -175,7 +174,7 @@ fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { network: key_wallet::Network::Testnet, birth_height: 1, account_manifest, - core_wallet_info: Box::new(info), + wallet_info: Box::new(info), identity_manager: Default::default(), unused_asset_locks: Default::default(), }, @@ -407,7 +406,7 @@ async fn rt_z_secret_hygiene_surfaces() { } } -/// RT-Snapshot: a carried `core_wallet_info` snapshot is consumed +/// RT-Snapshot: a carried `wallet_info` snapshot is consumed /// verbatim. Two properties the projection replay could NOT provide: /// - per-account UTXO attribution — a CoinJoin-account UTXO stays on the /// CoinJoin account (the fallback path routed every UTXO to the first @@ -532,7 +531,7 @@ async fn rt_snapshot_preserves_attribution_and_pools() { info.update_balance(); let (_, mut s) = slice(seed); - s.core_wallet_info = Box::new(info); + s.wallet_info = Box::new(info); let p = Arc::new(FixedLoadPersister::new()); let h = Arc::new(RecordingHandler::default()); let mut st = ClientStartState::default(); @@ -592,7 +591,7 @@ async fn rt_snapshot_wallet_id_mismatch_is_skipped() { WalletAccountCreationOptions::Default, ) .unwrap(); - s.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1)); + s.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_b, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_a, s); @@ -646,7 +645,7 @@ async fn rt_snapshot_account_set_mismatch_is_skipped() { let (_, mut s) = slice(seed); s.account_manifest = truncated_manifest; - s.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1)); + s.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_a, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_a, s); @@ -696,7 +695,7 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { .unwrap(); let id_ok = wallet_ok.compute_wallet_id(); let (_, mut s_ok) = slice(seed_ok); - s_ok.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1)); + s_ok.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_ok, 1)); // Mismatched row: keyed by wallet BAD, snapshot built from wallet OTHER. let (id_bad, mut s_bad) = slice(seed_bad); @@ -706,7 +705,7 @@ async fn rt_snapshot_mismatch_skip_coexists_with_healthy_load() { WalletAccountCreationOptions::Default, ) .unwrap(); - s_bad.core_wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1)); + s_bad.wallet_info = Box::new(ManagedWalletInfo::from_wallet(&wallet_other, 1)); let mut st = ClientStartState::default(); st.wallets.insert(id_ok, s_ok); From 0503f80df5529b1d4f91e58c49abe08cde7b283f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:49:02 +0200 Subject: [PATCH 091/108] refactor: move persistance to platform wallet storage - human work --- .../src/core_wallet_types.rs | 6 +- packages/rs-platform-wallet-ffi/src/error.rs | 137 --- .../rs-platform-wallet-ffi/src/manager.rs | 250 +--- .../rs-platform-wallet-ffi/src/persistence.rs | 285 +---- .../src/shielded_send.rs | 40 - .../src/wallet_restore_types.rs | 11 +- .../src/sqlite/error.rs | 50 +- .../src/sqlite/persister.rs | 26 +- .../src/sqlite/schema/identities.rs | 78 +- .../src/sqlite/util/mod.rs | 1 + .../src/sqlite/util/wallet.rs} | 101 +- .../tests/sqlite_core_state_reader.rs | 2 +- .../tests/sqlite_dashpay_overlay_contract.rs | 2 +- .../tests/sqlite_error_classification.rs | 29 + .../tests/sqlite_load_wiring.rs | 33 +- .../tests/sqlite_used_core_addresses.rs | 4 +- packages/rs-platform-wallet/README.md | 93 -- .../src/changeset/changeset.rs | 8 +- .../src/changeset/client_start_state.rs | 39 +- .../changeset/client_wallet_start_state.rs | 62 +- .../src/changeset/core_bridge.rs | 131 +-- .../changeset/identity_manager_start_state.rs | 339 +----- .../src/changeset/traits.rs | 14 - packages/rs-platform-wallet/src/error.rs | 246 ---- packages/rs-platform-wallet/src/events.rs | 20 - packages/rs-platform-wallet/src/lib.rs | 2 - .../rs-platform-wallet/src/manager/load.rs | 462 ++------ .../rs-platform-wallet/src/manager/mod.rs | 22 +- .../src/manager/wallet_lifecycle.rs | 1 - .../rs-platform-wallet/src/test_support.rs | 9 +- .../rs-platform-wallet/src/wallet/apply.rs | 169 ++- .../identity/network/top_up_from_addresses.rs | 10 +- .../wallet/identity/state/manager/apply.rs | 98 +- .../src/wallet/platform_wallet.rs | 1 - .../src/wallet/shielded/operations.rs | 9 +- .../tests/rehydration_load.rs | 1004 ----------------- 36 files changed, 513 insertions(+), 3281 deletions(-) rename packages/{rs-platform-wallet/src/manager/rehydrate.rs => rs-platform-wallet-storage/src/sqlite/util/wallet.rs} (95%) delete mode 100644 packages/rs-platform-wallet/tests/rehydration_load.rs 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 1d3dc910e02..c8ebc1348fe 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -964,11 +964,7 @@ fn tx_record_to_ffi( } } -/// 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 { +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/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index cd5b24fcc62..cec0966b9c2 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -145,40 +145,6 @@ pub enum PlatformWalletFFIResultCode { /// observing the transaction reconciles the outcome. The host must NOT /// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`]. ErrorTransactionBroadcastUnconfirmed = 20, - /// Maps `PlatformWalletError::AddressNonceMismatch`. Platform rejected an - /// address-funds transition (shield, or identity top-up-from-addresses) - /// because the submitted address nonce raced Platform's expected next - /// value (a lagging DAPI replica stale read; consensus code 40603). Same - /// definitively-failed / notes-released / safe-to-retry contract as - /// [`Self::ErrorShieldedBroadcastFailed`] — the transition did NOT execute - /// and any note reservations were released (a shield reserves none) — but - /// as its OWN code so hosts can recognize this specific, self-healing - /// failure and retry: the retry re-fetches the address nonce, resolving - /// the mismatch without host intervention. The submitted and Platform- - /// expected nonce values travel in the result `message` (the typed - /// `Display`); they are not exposed as structured out-fields (that would - /// require an ABI-breaking change to `PlatformWalletFFIResult`). - ErrorAddressNonceMismatch = 21, - /// Maps `PlatformWalletError::PersisterLoad` when the wrapped - /// [`PersistenceError`] classifies as transient - /// (`is_transient() == true`, e.g. `SQLITE_BUSY`). Rehydration could not - /// load the persisted client state, but the backend reports the failure - /// as recoverable, so the host MAY retry the load with backoff. Split - /// from [`Self::ErrorPersisterLoadFatal`] so the retry classification - /// proven on the Rust side survives the boundary instead of flattening - /// to `ErrorUnknown`. - /// - /// [`PersistenceError`]: platform_wallet::changeset::PersistenceError - ErrorPersisterLoadTransient = 22, - /// Maps `PlatformWalletError::PersisterLoad` when the wrapped - /// [`PersistenceError`] is non-transient (a `Fatal` or `Constraint` - /// backend failure, or a poisoned lock). Rehydration could not load the - /// persisted client state and the failure is unrecoverable, so the host - /// MUST NOT retry the same load. Sibling of - /// [`Self::ErrorPersisterLoadTransient`]. - /// - /// [`PersistenceError`]: platform_wallet::changeset::PersistenceError - ErrorPersisterLoadFatal = 23, NotFound = 98, // Used exclusively for all the Option that are retuned as errors ErrorUnknown = 99, @@ -319,27 +285,6 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::TransactionBroadcastUnconfirmed(..) => { PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed } - // A definitively-failed address-nonce race (reaches the blanket impl - // via identity `top_up_from_addresses` → `?`/`.into()`). Exposing - // provided/expected nonce as structured out-fields is INTENTIONALLY - // out of scope: `PlatformWalletFFIResult` is by-value / ABI-frozen, so - // the values travel in the message string and an FFI retry re-fetches - // the nonce. - PlatformWalletError::AddressNonceMismatch { .. } => { - PlatformWalletFFIResultCode::ErrorAddressNonceMismatch - } - // A rehydration persister-load failure. The Rust side already proves - // the transient-vs-fatal classification (rehydration_load.rs), so - // split the code on `is_transient()` — flattening to `ErrorUnknown` - // would erase the retry signal. Fatal/Constraint/LockPoisoned all - // read as non-transient, i.e. do-not-retry. - PlatformWalletError::PersisterLoad(inner) => { - if inner.is_transient() { - PlatformWalletFFIResultCode::ErrorPersisterLoadTransient - } else { - PlatformWalletFFIResultCode::ErrorPersisterLoadFatal - } - } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; PlatformWalletFFIResult::err(code, error.to_string()) @@ -719,88 +664,6 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } - /// `AddressNonceMismatch` maps to the dedicated `ErrorAddressNonceMismatch` - /// FFI code through the blanket `From` impl (the path identity - /// `top_up_from_addresses` takes via `?`/`.into()`) rather than flattening - /// to `ErrorUnknown`. The typed Display rendering — carrying the submitted - /// and expected nonce values — survives across the boundary as the message. - #[test] - fn address_nonce_mismatch_maps_to_dedicated_code() { - let err = PlatformWalletError::AddressNonceMismatch { - address: dpp::address_funds::PlatformAddress::P2pkh([7u8; 20]), - provided_nonce: 1, - expected_nonce: 2, - }; - let rendered = err.to_string(); - let result: PlatformWalletFFIResult = err.into(); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, - "AddressNonceMismatch should map to ErrorAddressNonceMismatch (rendered: {rendered})" - ); - let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned(); - assert_eq!( - msg, rendered, - "Display payload must survive the FFI boundary verbatim" - ); - // Pin the EXACT rendered substrings, not bare digits, so a - // provided/expected transposition would fail the test. - assert!( - msg.contains("submitted nonce 1"), - "submitted (provided) nonce must render exactly: {msg}" - ); - assert!( - msg.contains("Platform expected 2"), - "expected nonce must render exactly: {msg}" - ); - } - - /// A rehydration `PersisterLoad` failure keeps its transient-vs-fatal - /// classification across the boundary: a transient backend failure maps to - /// `ErrorPersisterLoadTransient` (retryable) and every non-transient case - /// (`Fatal`, `Constraint`, `LockPoisoned`) maps to `ErrorPersisterLoadFatal` - /// (do-not-retry), rather than both flattening to `ErrorUnknown`. The typed - /// Display rendering survives verbatim as the message. - #[test] - fn persister_load_maps_by_retry_classification() { - use platform_wallet::changeset::{PersistenceError, PersistenceErrorKind}; - - let transient: Vec = vec![PlatformWalletError::PersisterLoad( - PersistenceError::backend_with_kind(PersistenceErrorKind::Transient, "database busy"), - )]; - let fatal: Vec = vec![ - PlatformWalletError::PersisterLoad(PersistenceError::backend("schema corrupt")), - PlatformWalletError::PersisterLoad(PersistenceError::backend_with_kind( - PersistenceErrorKind::Constraint, - "foreign key violation", - )), - PlatformWalletError::PersisterLoad(PersistenceError::LockPoisoned), - ]; - - for (cases, expected) in [ - ( - transient, - PlatformWalletFFIResultCode::ErrorPersisterLoadTransient, - ), - (fatal, PlatformWalletFFIResultCode::ErrorPersisterLoadFatal), - ] { - for err in cases { - let rendered = err.to_string(); - let result: PlatformWalletFFIResult = err.into(); - assert_eq!( - result.code, expected, - "PersisterLoad must map by retry classification (rendered: {rendered})" - ); - let msg = unsafe { std::ffi::CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned(); - assert_eq!(msg, rendered, "Display payload must survive verbatim"); - } - } - } - /// Other wallet-error variants without a dedicated FFI arm still /// fall through to `ErrorUnknown` while carrying the typed /// Display rendering as the message. Pin this so the catch-all diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 64cb08052c9..7e553a64bc0 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -176,93 +176,6 @@ 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`: 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; -/// `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). -/// The load path is seedless and watch-only, so this is the only skip -/// reason. `reason_code` is per-`CorruptKind` family — see its table. -#[repr(C)] -#[derive(Debug, Clone, Copy)] -pub struct SkippedWalletFFI { - /// The (public) 32-byte wallet id that was skipped. - pub wallet_id: [u8; 32], - /// 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), - /// [`LOAD_SKIP_REASON_OTHER`] (200), or - /// [`LOAD_SKIP_REASON_ALREADY_REGISTERED`] (300). No secret material - /// is ever carried. - pub reason_code: u32, -} - -/// C-visible summary of one `load_from_persistor` pass so the host can -/// see which wallets loaded and which were skipped (and why) instead -/// of the outcome being silently discarded. -/// -/// 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. -#[repr(C)] -#[derive(Debug)] -pub struct LoadOutcomeFFI { - /// Number of wallets fully reconstructed + registered. - pub loaded_count: usize, - /// Length of the `skipped` array. - pub skipped_count: usize, - /// Heap-allocated skipped-wallet array (null iff `skipped_count` - /// is 0). Owned by Rust until `platform_wallet_load_outcome_free`. - pub skipped: *mut SkippedWalletFFI, -} - -fn skip_reason_code(reason: &platform_wallet::SkipReason) -> u32 { - use platform_wallet::manager::load_outcome::CorruptKind; - match reason { - platform_wallet::SkipReason::CorruptPersistedRow { kind } => match kind { - CorruptKind::MissingManifest => 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. - _ => 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, - } -} - /// Create a wallet from raw seed bytes (64 bytes). /// /// On success, `out_wallet_handle` is set to a `PlatformWallet` handle and @@ -385,115 +298,23 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit /// /// Triggers `on_load_wallet_list_fn` on the persistence callbacks to /// fetch the persisted wallet list from the client side (SwiftData), -/// builds a keyless reconstruction payload per wallet, then registers -/// each one as a **watch-only** wallet. No signing keys are derived -/// here — signing happens later, on demand, via the configured -/// `MnemonicResolverHandle` (`sign_with_mnemonic_resolver` and its -/// siblings), which fail-closed gate the resolver-supplied seed -/// against the loaded `wallet_id`. Does not produce wallet handles — -/// follow up with [`platform_wallet_manager_get_wallet`] per -/// `wallet_id`. -/// -/// A wallet whose persisted row is structurally corrupt is -/// **skipped**, not failed: the call still returns `Success`, every -/// skipped `(wallet_id, reason)` is logged, and — when `out_outcome` -/// is non-null — surfaced through it. -/// -/// # Safety -/// - `out_outcome` may be null (caller doesn't want the summary); -/// otherwise it must point to writable `LoadOutcomeFFI` storage and -/// the caller must later release it via -/// [`platform_wallet_load_outcome_free`]. +/// reconstructs each wallet as **watch-only** via its stored root + +/// per-account xpubs, and registers them inside the manager. Does not +/// produce wallet handles — the caller should follow up with +/// [`platform_wallet_manager_get_wallet`] per `wallet_id` it knows +/// about. #[no_mangle] 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()) }); let result = unwrap_option_or_return!(option); - let outcome = unwrap_result_or_return!(result); - - // Never silently drop the outcome: log a structured summary plus - // one line per skipped wallet (the host can inspect / clear the - // corrupt rows). 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(), - "platform_wallet_manager_load_from_persistor complete" - ); - for (wid, reason) in outcome.skipped() { - tracing::warn!( - wallet_id = %hex::encode(wid), - reason = %reason, - "load_from_persistor skipped a persisted wallet" - ); - } - - if !out_outcome.is_null() { - let skipped_vec: Vec = outcome - .skipped() - .iter() - .map(|(wid, reason)| SkippedWalletFFI { - wallet_id: *wid, - reason_code: skip_reason_code(reason), - }) - .collect(); - let skipped_count = skipped_vec.len(); - let skipped_ptr = crate::core_wallet_types::vec_to_ptr(skipped_vec); - std::ptr::write( - out_outcome, - LoadOutcomeFFI { - loaded_count: outcome.loaded().len(), - skipped_count, - skipped: skipped_ptr, - }, - ); - } + unwrap_result_or_return!(result); PlatformWalletFFIResult::ok() } -/// Release the heap `skipped` array a successful -/// [`platform_wallet_manager_load_from_persistor`] wrote into a -/// `LoadOutcomeFFI`. Idempotent: nulls the pointer after freeing, and -/// a null `outcome` (or already-freed array) is a no-op. -/// -/// # Safety -/// `outcome` must point to a `LoadOutcomeFFI` previously populated by -/// `platform_wallet_manager_load_from_persistor`, not freed already. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_load_outcome_free(outcome: *mut LoadOutcomeFFI) { - if outcome.is_null() { - return; - } - let o = &mut *outcome; - if !o.skipped.is_null() && o.skipped_count > 0 { - let slice = std::slice::from_raw_parts_mut(o.skipped, o.skipped_count); - drop(Box::from_raw(slice as *mut [SkippedWalletFFI])); - } - o.skipped = std::ptr::null_mut(); - o.skipped_count = 0; -} - /// Get a `PlatformWallet` handle for a wallet registered in the /// manager. Returns `NotFound` if no wallet with the given /// id is currently held. @@ -596,63 +417,4 @@ 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_SNAPSHOT_IDENTITY_MISMATCH, 103); - 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::SnapshotIdentityMismatch)), - LOAD_SKIP_REASON_SNAPSHOT_IDENTITY_MISMATCH - ); - 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) }; - } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index a6f501d1ec6..4d614581c2f 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -23,7 +23,6 @@ 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; @@ -157,10 +156,11 @@ pub struct PersistenceCallbacks { ) -> i32, >, /// Invoked on [`FFIPersister::load`] to pull the persisted wallet - /// 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. + /// 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.) /// /// Implementations must set `*out_entries` to a Swift-allocated /// array of `WalletRestoreEntryFFI` and `*out_count` to the @@ -1611,36 +1611,11 @@ impl PlatformWalletPersistence for FFIPersister { // fires before we leave this function. let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; for entry in entries { - 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 persisted row must never abort the whole - // restore. Errors from `build_wallet_start_state` are - // inherently per-row (decode / projection of THIS entry, - // e.g. a malformed account xpub), so record the wallet as - // 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: corrupt_kind_from_build_err(&e), - }, - )); - } + 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); } } @@ -2847,50 +2822,12 @@ impl Drop for LoadGuard { } } -/// 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 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 { - 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. +/// 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. fn build_wallet_start_state( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -2941,32 +2878,25 @@ 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(MalformedXpubError(e.to_string())))?; - // Same xpub-failure family as the bincode-decode above: a - // well-decoded xpub that `Account::from_xpub` still rejects is a - // malformed key, so mark it so `corrupt_kind_from_build_err` - // classifies it as MalformedXpub (101), not the generic - // decode-error reason code (102). + bincode::decode_from_slice(xpub_bytes, config::standard()).map_err(|e| { + PersistenceError::backend(format!("failed to decode account xpub: {}", e)) + })?; let account = Account::from_xpub(Some(entry.wallet_id), account_type, account_xpub, network) .map_err(|e| { - PersistenceError::backend(MalformedXpubError(format!( - "Account::from_xpub failed: {e:?}" - ))) + PersistenceError::backend(format!("Account::from_xpub failed: {:?}", e)) })?; accounts.insert(account).map_err(|e| { PersistenceError::backend(format!("AccountCollection::insert failed: {}", e)) })?; } - // 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). + // 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). let wallet = Wallet::new_external_signable(network, entry.wallet_id, accounts); // Stamp the persisted core-chain sync metadata onto the rebuilt @@ -2987,24 +2917,14 @@ fn build_wallet_start_state( } // Persisted `last_applied_chain_lock` — bincode-decoded from the - // 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 - // own `best_chainlock` independently; this is the symmetric + // bytes Swift handed back. Restoring this before the wallet + // enters the manager means the asset-lock-resume CL-from-metadata + // fallback (`proof.rs`) can fire immediately at app launch on + // any tracked lock whose funding block height is `<= cl.block_height`, + // without waiting for SPV to re-apply a fresh CL. SPV persists + // its own `best_chainlock` independently; this is the symmetric // 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, @@ -3531,39 +3451,9 @@ fn build_wallet_start_state( // status without rebroadcasting. let unused_asset_locks = build_unused_asset_locks(entry)?; - // Hand the fully-restored `wallet_info` across as the keyless - // snapshot: no `Wallet`, seed, private key, unencrypted seed, or - // password ever crosses `load()` — `ManagedWalletInfo` carries only - // balances / pools / UTXOs, never key material. The manager rebuilds - // a watch-only wallet from the - // manifest via `Wallet::new_watch_only` and consumes this snapshot - // directly, so everything the decode blocks above restored survives - // verbatim: per-account UTXO and tx-record attribution (including - // 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() - .into_iter() - .map(|a| AccountRegistrationEntry { - account_type: a.account_type, - account_xpub: a.account_xpub, - }) - .collect(); - - // Identity PUBLIC keys and DashPay contacts are already restored - // into `identity_manager` by `build_wallet_identity_bucket` - // (contacts inline via `restore_dashpay_contacts`). let wallet_state = ClientWalletStartState { - network, - birth_height: entry.birth_height, - account_manifest, - wallet_info: Box::new(wallet_info), + wallet, + wallet_info, identity_manager, unused_asset_locks, }; @@ -3769,10 +3659,6 @@ fn status_from_u8(b: u8) -> Result Result, PersistenceError> { @@ -4424,17 +4310,13 @@ fn is_legacy_removed_account_tag(type_tag: u8) -> bool { /// Read `len` bytes from a Swift-owned pointer as a `&[u8]`. /// -/// A host-supplied `len` exceeding `isize::MAX` (the `from_raw_parts` -/// bound) is treated as a corrupt length and yields an empty slice rather -/// than being handed to `from_raw_parts` (UB). -/// /// # Safety /// /// `ptr` must point to at least `len` valid bytes for the duration of /// the callback. Caller holds the callback window open via /// `LoadGuard`. unsafe fn slice_from_raw<'a>(ptr: *const u8, len: usize) -> &'a [u8] { - if ptr.is_null() || len == 0 || len > isize::MAX as usize { + if ptr.is_null() || len == 0 { &[] } else { slice::from_raw_parts(ptr, len) @@ -4603,103 +4485,6 @@ 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() { - // A boxed `MalformedXpubError` must be recovered by downcast, - // independently of its human-readable `Display` text. - let xpub_err = - 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 — - // 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), - CorruptKind::DecodeError(_) - ), - "non-xpub failures must stay DecodeError (code 102)" - ); - } - - /// A `len` past `isize::MAX` is a corrupt host-supplied length — - /// handing it to `from_raw_parts` would be UB, so the guard yields an - /// empty slice. The pointer is non-null but never dereferenced: the - /// guard short-circuits before any read. - #[test] - fn slice_from_raw_rejects_overflowing_len() { - let ptr = std::ptr::NonNull::::dangling().as_ptr() as *const u8; - let slice = unsafe { slice_from_raw(ptr, isize::MAX as usize + 1) }; - assert_eq!( - slice, - &[] as &[u8], - "a len exceeding isize::MAX must yield an empty slice, not touch the pointer" - ); - } - - /// End-to-end coverage of the malformed-xpub classification at its - /// real call site: `build_wallet_start_state` fed a well-formed - /// buffer that is not a decodable `ExtendedPubKey` must surface a - /// `MalformedXpub` (code 101), not the generic decode-error family. - #[test] - fn build_wallet_start_state_malformed_xpub_classifies_as_malformed() { - let bad_xpub: [u8; 4] = [0xde, 0xad, 0xbe, 0xef]; - let spec = AccountSpecFFI { - type_tag: AccountTypeTagFFI::Standard as u8, - standard_tag: StandardAccountTypeTagFFI::Bip44 as u8, - index: 0, - registration_index: 0, - key_class: 0, - user_identity_id: [0u8; 32], - friend_identity_id: [0u8; 32], - account_xpub_bytes: bad_xpub.as_ptr(), - account_xpub_bytes_len: bad_xpub.len(), - }; - let entry = WalletRestoreEntryFFI { - wallet_id: [0u8; 32], - network: FFINetwork::Testnet, - accounts: &spec, - accounts_count: 1, - platform_address_balances: std::ptr::null(), - platform_address_balances_count: 0, - platform_sync_height: 0, - platform_sync_timestamp: 0, - platform_last_known_recent_block: 0, - identities: std::ptr::null(), - identities_count: 0, - birth_height: 0, - synced_height: 0, - last_processed_height: 0, - last_synced: 0, - utxos: std::ptr::null(), - utxos_count: 0, - tracked_asset_locks: std::ptr::null(), - tracked_asset_locks_count: 0, - unresolved_asset_lock_tx_records: std::ptr::null(), - unresolved_asset_lock_tx_records_count: 0, - core_address_pools: std::ptr::null(), - core_address_pools_count: 0, - last_applied_chain_lock_bytes: std::ptr::null(), - last_applied_chain_lock_bytes_len: 0, - }; - - let err = build_wallet_start_state(&entry) - .expect_err("a malformed account xpub must fail the rebuild"); - assert_eq!( - corrupt_kind_from_build_err(&err), - CorruptKind::MalformedXpub, - "a malformed account xpub must classify as MalformedXpub (code 101) end-to-end" - ); - } - /// Regression: restored pool addresses must be tagged with the /// WALLET's network, not the network the base58 string parses as. /// Devnet shares testnet's base58 prefixes, so a devnet wallet's diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index dbdead34c63..94fbbf1269a 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -516,15 +516,6 @@ fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, format!("{operation} failed: {e}"), ), - // Definitively failed on an address-nonce race (a shield spends platform - // address funds; a shield reserves no notes). Its own code carries the - // safe-to-retry contract AND lets the host recognize the self-healing - // nonce mismatch — a plain retry re-fetches the nonce. Without this arm - // it would regress to the generic `ErrorWalletOperation` below. - Err(e @ PlatformWalletError::AddressNonceMismatch { .. }) => PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, - format!("{operation} failed: {e}"), - ), Err(e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorWalletOperation, format!("{operation} failed: {e}"), @@ -1447,35 +1438,4 @@ mod tests { PlatformWalletFFIResultCode::Success ); } - - /// A shield (Type 15) definitively rejected on an address-nonce race must - /// map to the dedicated `ErrorAddressNonceMismatch` — NOT regress to the - /// generic `ErrorWalletOperation` — so hosts keep the safe-to-retry signal. - /// The submitted/expected nonce values must survive in the message. - #[test] - fn map_spend_result_maps_address_nonce_mismatch_to_dedicated_code() { - let mismatch: Result<(), PlatformWalletError> = - Err(PlatformWalletError::AddressNonceMismatch { - address: PlatformAddress::P2pkh([7u8; 20]), - provided_nonce: 1, - expected_nonce: 2, - }); - let result = map_spend_result(mismatch, "shielded shield"); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, - "shield nonce rejection must not regress to ErrorWalletOperation" - ); - let msg = message_of(&result); - // Pin the EXACT rendered substrings, not bare digits, so a - // provided/expected transposition would fail the test. - assert!( - msg.contains("submitted nonce 1"), - "submitted (provided) nonce must render exactly: {msg}" - ); - assert!( - msg.contains("Platform expected 2"), - "expected nonce must render exactly: {msg}" - ); - } } diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 0be67e2ff60..95629084629 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -5,11 +5,12 @@ //! 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 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. +//! `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.) //! //! All `*const u8` pointers must stay valid for the duration of the //! load callback. Swift owns the allocation and is asked to free it diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index f78cfd6e731..e585793989c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -205,6 +205,22 @@ pub enum WalletStorageError { )] AccountRegistrationEntryMismatch, + /// Account was rejected by the wallet manager (e.g. `account_type` is unknown, or + /// `account_index` is out of range). The `cause` is a static string describing the reason. + #[error("account rejected by wallet manager: {cause}")] + AccountRejected { cause: String }, + + /// An `account_registrations` row is missing for a given `(account_type, account_index)`. + #[error("required account information is missing for wallet_id {wallet_id:?}")] + MissingAccount { wallet_id: [u8; 32] }, + + /// Account record is invalid + #[error("account record is corrupted or invalid: {e}")] + AccountRecordInvalid { + #[source] + e: key_wallet::error::Error, + }, + /// An `asset_locks` row's typed-column `(outpoint, account_index)` /// disagreed with the lifecycle blob's. Rejected at decode time rather /// than mis-bucketing the lock under the wrong account. @@ -295,6 +311,28 @@ pub enum WalletStorageError { #[source] source: rusqlite::Error, }, + + /// Rehydration's discovery probes don't mirror the real account's + /// address pools 1:1 (`probes.len() != pools.len()`) — a structural + /// invariant break, not user-reachable. Fail-closed rather than apply a + /// probe's discovered depth to the wrong pool by position. + #[error( + "rehydration pool count mismatch: expected {expected} probe pool(s), found {found}" + )] + RehydrationPoolMismatch { expected: usize, found: usize }, + + /// Rehydration's discovery probes mirror the real account's pools by + /// count but not by chain identity at `position` — applying the + /// probe's discovered depth here would misattribute derivation to the + /// wrong pool. + #[error( + "rehydration pool type mismatch at position {position}: expected {expected:?}, found {found:?}" + )] + RehydrationPoolTypeMismatch { + position: usize, + expected: key_wallet::managed_account::address_pool::AddressPoolType, + found: key_wallet::managed_account::address_pool::AddressPoolType, + }, } impl From for PersistenceError { @@ -375,9 +413,14 @@ impl WalletStorageError { | Self::IdentityKeyEntryMismatch | Self::IdentityEntryIdMismatch | Self::AccountRegistrationEntryMismatch + | Self::AccountRecordInvalid { .. } + | Self::MissingAccount { .. } + | Self::AccountRejected { .. } | Self::AssetLockEntryMismatch { .. } | Self::BlobTooLarge { .. } - | Self::IntegerOverflow { .. } => false, + | Self::IntegerOverflow { .. } + | Self::RehydrationPoolMismatch { .. } + | Self::RehydrationPoolTypeMismatch { .. } => false, } } @@ -451,10 +494,15 @@ impl WalletStorageError { Self::AlreadyOpen { .. } => "already_open", Self::IdentityKeyEntryMismatch => "identity_key_entry_mismatch", Self::IdentityEntryIdMismatch => "identity_entry_id_mismatch", + Self::AccountRecordInvalid { .. } => "account_record_invalid", + Self::MissingAccount { .. } => "missing_account_registration_entry", + Self::AccountRejected { .. } => "account_rejected", Self::AccountRegistrationEntryMismatch => "account_registration_entry_mismatch", Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::BlobTooLarge { .. } => "blob_too_large", Self::IntegerOverflow { .. } => "integer_overflow", + Self::RehydrationPoolMismatch { .. } => "rehydration_pool_mismatch", + Self::RehydrationPoolTypeMismatch { .. } => "rehydration_pool_type_mismatch", } } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 603f874779f..fec3b10b651 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -19,6 +19,7 @@ use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; use crate::sqlite::schema; use crate::sqlite::util::permissions::{apply_secure_permissions, precreate_secure}; use crate::sqlite::util::safe_cast; +use crate::sqlite::util::wallet::{apply_persisted_core_state, build_wallet}; /// Persisted-but-not-rehydrated areas, surfaced in the structured /// `tracing::info!` summary on every `load()`. @@ -969,7 +970,7 @@ impl PlatformWalletPersistence for SqlitePersister { // chainlock, used-address pool depth) onto it. The manager consumes // this directly — the old skeleton + core_state replay fallback is // gone. - let watch_only = if account_manifest.is_empty() { + let wallet = if account_manifest.is_empty() { // Placeholder empty wallet: the manager re-checks the empty // manifest and skips this wallet as MissingManifest one layer // up (see rt_corrupt_row_skipped_and_other_loads). It exists so @@ -984,31 +985,26 @@ impl PlatformWalletPersistence for SqlitePersister { // TTL-based cleanup, a re-registration entry point, or a surfaced // "orphaned wallet" diagnostic), or is silent-skip-forever acceptable? // Awaiting product decision; not addressed in this change. - key_wallet::wallet::Wallet::new_watch_only( + key_wallet::wallet::Wallet::new_external_signable( network, wallet_id, key_wallet::account::account_collection::AccountCollection::new(), ) } else { - platform_wallet::rehydrate::build_watch_only_wallet( - network, - wallet_id, - &account_manifest, - ) - .map_err(|e| { + build_wallet(network, wallet_id, &account_manifest).map_err(|e| { PersistenceError::backend(format!( "watch-only wallet rebuild failed for {}: {e}", hex::encode(wallet_id) )) })? }; - let mut core_wallet_info = + let mut wallet_info = key_wallet::wallet::managed_wallet_info::ManagedWalletInfo::from_wallet( - &watch_only, + &wallet, birth_height, ); - platform_wallet::rehydrate::apply_persisted_core_state( - &mut core_wallet_info, + apply_persisted_core_state( + &mut wallet_info, &account_manifest, &core_state, &used_core_addresses, @@ -1023,10 +1019,8 @@ impl PlatformWalletPersistence for SqlitePersister { state.wallets.insert( wallet_id, platform_wallet::changeset::ClientWalletStartState { - network, - birth_height, - account_manifest, - core_wallet_info: Box::new(core_wallet_info), + wallet, + wallet_info, identity_manager, unused_asset_locks, }, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 784991e69ee..673c6ed0ec8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -1,9 +1,14 @@ //! `identities` table writer. +use std::collections::HashMap; + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; +use platform_wallet::{ContactChangeSet, IdentityKeysChangeSet, ManagedIdentity}; use rusqlite::{params, Transaction}; -use platform_wallet::changeset::IdentityChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::{changeset::IdentityChangeSet, IdentityManagerStartState}; use {platform_wallet::changeset::IdentityEntry, rusqlite::Connection}; @@ -221,7 +226,7 @@ pub fn load_prekeyed( established: records.established, ..Default::default() }; - state.merge_contacts_and_keys(contacts, identity_keys); + merge_contacts_and_keys(&mut state, contacts, identity_keys); Ok(state) } @@ -321,6 +326,75 @@ pub fn ensure_exists( Ok(()) } +/// Fold persisted PUBLIC keys and contact state onto the already-built +/// managed identities so `Identity.public_keys` and the contact maps +/// are populated at load time — the FFI persister's pre-keyed shape, +/// with no separate changeset layered on afterwards. +/// +/// Entries route by owner `identity_id` across BOTH buckets; one whose +/// owner is absent (e.g. a tombstoned identity's orphaned rows) is +/// logged and skipped, never fatal. Only key `upserts` and the +/// `sent` / `incoming` / `established` maps are routed; `removed_*` +/// (insert-only feed) and `ignored` / `unignored` (restored in the +/// identity reader from the `ignored_senders` table) are skipped. No +/// `Network` needed — key insert is network-independent. +pub fn merge_contacts_and_keys( + state: &mut IdentityManagerStartState, + contacts: ContactChangeSet, + identity_keys: IdentityKeysChangeSet, +) { + // One transient id → &mut ManagedIdentity view over both buckets so + // routing is O(1) per entry rather than a per-entry bucket scan. The + // two buckets are disjoint fields, so their mutable borrows coexist. + let mut by_id: HashMap = HashMap::new(); + for managed in state.out_of_wallet_identities.values_mut() { + by_id.insert(managed.identity.id(), managed); + } + for inner in state.wallet_identities.values_mut() { + for managed in inner.values_mut() { + by_id.insert(managed.identity.id(), managed); + } + } + + for (_key, entry) in identity_keys.upserts { + match by_id.get_mut(&entry.identity_id) { + Some(managed) => managed.identity.add_public_key(entry.public_key), + None => tracing::warn!( + identity = %entry.identity_id, + key_id = entry.key_id, + "skipping identity key during rehydration merge: owner identity not loaded" + ), + } + } + for (key, entry) in contacts.sent_requests { + match by_id.get_mut(&key.owner_id) { + Some(managed) => managed.apply_sent_contact_request(entry.request), + None => tracing::warn!( + owner = %key.owner_id, + "skipping sent contact request during rehydration merge: owner identity not loaded" + ), + } + } + for (key, entry) in contacts.incoming_requests { + match by_id.get_mut(&key.owner_id) { + Some(managed) => managed.apply_incoming_contact_request(entry.request), + None => tracing::warn!( + owner = %key.owner_id, + "skipping incoming contact request during rehydration merge: owner identity not loaded" + ), + } + } + for (key, established) in contacts.established { + match by_id.get_mut(&key.owner_id) { + Some(managed) => managed.apply_established_contact(established), + None => tracing::warn!( + owner = %key.owner_id, + "skipping established contact during rehydration merge: owner identity not loaded" + ), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs index 921ef15f9a4..8859fd21dcb 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs @@ -2,3 +2,4 @@ pub mod permissions; pub mod safe_cast; +pub(super) mod wallet; diff --git a/packages/rs-platform-wallet/src/manager/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs similarity index 95% rename from packages/rs-platform-wallet/src/manager/rehydrate.rs rename to packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index 14a9cedff9d..988194af527 100644 --- a/packages/rs-platform-wallet/src/manager/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -1,4 +1,4 @@ -//! Watch-only wallet reconstruction + persisted core-state application. +//! External-signable wallet reconstruction //! //! Load is seedless — each wallet is rebuilt watch-only from its manifest and //! the manager consumes the carried snapshot directly, so no wrong-seed check @@ -10,32 +10,20 @@ 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; +use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; -/// Build a watch-only [`Wallet`] from the keyless account manifest, stamping -/// `expected_wallet_id` onto the reconstructed [`AccountCollection`]. Returns -/// [`CorruptKind`] when the row is structurally unusable; no key material -/// crosses this function. -/// -/// # Trust boundary -/// -/// `expected_wallet_id` is **not** cryptographically bound to the manifest: the -/// id hashes the *root* xpub, but only account-level xpubs are persisted, so the -/// root cannot be recovered here to re-verify it. A well-formed but **wrong** -/// `account_xpub` is therefore accepted — anyone able to write the backing store -/// can swap in their own xpub under the unchanged id and silently redirect -/// incoming funds. Closing this needs storage-layer manifest authentication (a -/// MAC over `{wallet_id, network, manifest}`, verified fail-closed on load), -/// tracked in the `platform-wallet-storage` crate. -pub(super) fn build_watch_only_wallet( +use crate::WalletStorageError; + +/// Build a [`Wallet`] that will be provided to the platform-wallet during rehydration. +pub(crate) fn build_wallet( network: Network, expected_wallet_id: [u8; 32], manifest: &[AccountRegistrationEntry], -) -> Result { +) -> Result { if manifest.is_empty() { - return Err(CorruptKind::MissingManifest); + return Err(WalletStorageError::MissingAccount { + wallet_id: expected_wallet_id, + }); } let mut accounts = AccountCollection::new(); for entry in manifest { @@ -47,12 +35,12 @@ pub(super) fn build_watch_only_wallet( entry.account_xpub, network, ) - .map_err(|_| CorruptKind::MalformedXpub)?; + .map_err(|e| WalletStorageError::AccountRecordInvalid { e })?; accounts .insert(account) - .map_err(|e| CorruptKind::DecodeError(e.to_string()))?; + .map_err(|_| WalletStorageError::AccountRegistrationEntryMismatch)?; } - Ok(Wallet::new_watch_only( + Ok(Wallet::new_external_signable( network, expected_wallet_id, accounts, @@ -101,7 +89,7 @@ pub(super) fn build_watch_only_wallet( /// # Reconstructed when the persister supplies it /// /// - **`last_applied_chain_lock`**: restored from `core` when the -/// supplied [`CoreChangeSet`](crate::changeset::CoreChangeSet) carries +/// supplied [`CoreChangeSet`](platform_wallet::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 @@ -140,19 +128,19 @@ pub(super) fn build_watch_only_wallet( /// /// # 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`. +/// [`WalletStorageError::MissingAccount`] 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, + core: &CoreChangeSet, used_pool_addresses: &[key_wallet::Address], -) -> Result<(), PlatformWalletError> { +) -> Result<(), WalletStorageError> { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; // Captured before the mutable account borrow below so it can flow into @@ -175,7 +163,7 @@ pub fn apply_persisted_core_state( wallet_info.metadata.last_applied_chain_lock = Some(cl.clone()); } - // TODO(rehydration gaps): `core` also carries instant-send locks and + // INTENTIONAL(rehydration gaps): `core` also carries instant-send locks and // transaction records, but neither can be replayed here — // `ManagedWalletInfo.instant_send_locks` is `pub(crate)` with no public // setter, and there is no public API to inject tx records. Both re-warm on @@ -232,10 +220,7 @@ pub fn apply_persisted_core_state( )?; } None => { - return Err(PlatformWalletError::RehydrationTopologyUnsupported { - wallet_id, - utxo_count: unspent.len(), - }); + return Err(WalletStorageError::MissingAccount { wallet_id }); } } } else if !addresses_to_mark.is_empty() { @@ -303,7 +288,7 @@ const REHYDRATION_DEEP_SCAN_WARN_INDEX: u32 = 1_000; /// /// # Errors /// -/// [`PlatformWalletError::RehydrationPoolMismatch`] if the discovery probes +/// [`WalletStorageError::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. @@ -314,7 +299,7 @@ fn extend_pools_for_restored_addresses( manifest: &[AccountRegistrationEntry], restored_addresses: &[key_wallet::Address], wallet_id: [u8; 32], -) -> Result<(), PlatformWalletError> { +) -> Result<(), WalletStorageError> { use key_wallet::managed_account::address_pool::{AddressPool, KeySource}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use std::collections::HashSet; @@ -445,7 +430,7 @@ fn extend_pools_for_restored_addresses( // 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 { + return Err(WalletStorageError::RehydrationPoolMismatch { expected: probes.len(), found: pools.len(), }); @@ -461,7 +446,7 @@ fn extend_pools_for_restored_addresses( // `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 { + return Err(WalletStorageError::RehydrationPoolTypeMismatch { position, expected: probe.pool_type, found: pool.pool_type, @@ -581,7 +566,7 @@ mod tests { let id = w.compute_wallet_id(); let manifest = manifest_for(&w); - let restored = build_watch_only_wallet(Network::Testnet, id, &manifest).unwrap(); + let restored = build_wallet(Network::Testnet, id, &manifest).unwrap(); assert_eq!(restored.wallet_id, id); assert_eq!(restored.compute_wallet_id(), id); let restored_types: Vec<_> = restored @@ -599,9 +584,9 @@ mod tests { #[test] fn empty_manifest_is_missing_manifest() { - let err = build_watch_only_wallet(Network::Testnet, [0u8; 32], &[]) + let err = build_wallet(Network::Testnet, [0u8; 32], &[]) .expect_err("empty manifest must be MissingManifest"); - assert!(matches!(err, CorruptKind::MissingManifest)); + assert!(matches!(err, WalletStorageError::MissingAccount { .. })); } /// Regression: after restart-in-place the watch-only pools eagerly @@ -707,7 +692,7 @@ mod tests { utxo(deep_change.clone(), 300_000, 4), ]; let expected_total: u64 = new_utxos.iter().map(|u| u.value()).sum(); - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { new_utxos, last_processed_height: Some(1), synced_height: Some(1), @@ -867,7 +852,7 @@ mod tests { let foreign_val = 200_000u64; let expected_total = normal_val + foreign_val; - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { new_utxos: vec![ utxo(normal_addr, normal_val, 1), utxo(foreign_addr, foreign_val, 2), @@ -1010,7 +995,7 @@ mod tests { is_trusted: false, }; - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { new_utxos: vec![utxo], last_processed_height: Some(1), synced_height: Some(1), @@ -1097,7 +1082,7 @@ mod tests { p.address_at_index(3).unwrap() }; - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { new_utxos: vec![Utxo { outpoint: OutPoint { txid: Txid::from([1u8; 32]), @@ -1159,7 +1144,7 @@ mod tests { /// (idx 30), and asserts the empty-snapshot baseline does NOT mark them. #[test] fn rehydration_used_state_survives_spent_utxo() { - use crate::changeset::CoreChangeSet; + use platform_wallet::changeset::CoreChangeSet; use dashcore::blockdata::transaction::txout::TxOut; use dashcore::{OutPoint, Txid}; use key_wallet::bip32::DerivationPath; @@ -1363,7 +1348,7 @@ mod tests { let wedge_used = derive(45); // No UTXOs at all — only the persisted pool used-state. - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { last_processed_height: Some(1), synced_height: Some(1), ..Default::default() @@ -1468,7 +1453,7 @@ mod tests { }; let value = 500_000u64; - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { new_utxos: vec![Utxo { outpoint: OutPoint { txid: Txid::from([4u8; 32]), @@ -1563,7 +1548,7 @@ mod tests { Network::Testnet, Payload::PubkeyHash(PubkeyHash::from_byte_array([9u8; 20])), ); - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { new_utxos: vec![Utxo { outpoint: OutPoint { txid: Txid::from([2u8; 32]), @@ -1589,10 +1574,10 @@ mod tests { 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"); + WalletStorageError::MissingAccount { wallet_id: id } => { + assert_eq!(id, wallet_info.wallet_id, "wallet_id must match the rehydrated wallet"); } - other => panic!("expected RehydrationTopologyUnsupported, got {other:?}"), + other => panic!("expected MissingAccount, got {other:?}"), } } @@ -1618,7 +1603,7 @@ mod tests { let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); assert!(wallet_info.accounts.all_funding_accounts().is_empty()); - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { last_processed_height: Some(1), synced_height: Some(1), ..Default::default() @@ -1658,7 +1643,7 @@ mod tests { block_hash: BlockHash::from_byte_array([7u8; 32]), signature: [9u8; 96].into(), }; - let core = crate::changeset::CoreChangeSet { + let core = platform_wallet::changeset::CoreChangeSet { last_applied_chain_lock: Some(cl.clone()), ..Default::default() }; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index 3cd6426ca7a..a25cf6f9777 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -387,7 +387,7 @@ fn b5_last_applied_chain_lock_round_trips() { .expect("wallet must be in load output"); assert_eq!( wallet_start - .core_wallet_info + .wallet_info .metadata .last_applied_chain_lock .as_ref(), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs index 6e2e4f2c8ba..2b50173b838 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_dashpay_overlay_contract.rs @@ -102,7 +102,7 @@ fn overlay_only_write_does_not_corrupt_load() { .get(&w) .expect("wallet present in loaded state"); assert_eq!( - wallet.core_wallet_info.metadata.synced_height, 99, + wallet.wallet_info.metadata.synced_height, 99, "core state must rehydrate intact alongside an unread overlay" ); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs index 56c31887d7b..8da59af21f7 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_error_classification.rs @@ -171,6 +171,24 @@ fn samples() -> Vec { value: u64::MAX, target: SafeCastTarget::U64, }, + WalletStorageError::MissingAccount { + wallet_id: [3u8; 32], + }, + WalletStorageError::AccountRecordInvalid { + e: key_wallet::error::Error::WatchOnly, + }, + WalletStorageError::AccountRejected { + cause: "unknown account_type".into(), + }, + WalletStorageError::RehydrationPoolMismatch { + expected: 2, + found: 1, + }, + WalletStorageError::RehydrationPoolTypeMismatch { + position: 0, + expected: key_wallet::managed_account::address_pool::AddressPoolType::External, + found: key_wallet::managed_account::address_pool::AddressPoolType::Internal, + }, WalletStorageError::FlushRetryable { wallet_id: [0xAB; 32], source: SqlErr::SqliteFailure( @@ -254,6 +272,17 @@ fn tc_p2_005_is_transient_table() { WalletStorageError::AccountRegistrationEntryMismatch => { (false, "account_registration_entry_mismatch") } + WalletStorageError::MissingAccount { .. } => { + (false, "missing_account_registration_entry") + } + WalletStorageError::AccountRecordInvalid { .. } => (false, "account_record_invalid"), + WalletStorageError::AccountRejected { .. } => (false, "account_rejected"), + WalletStorageError::RehydrationPoolMismatch { .. } => { + (false, "rehydration_pool_mismatch") + } + WalletStorageError::RehydrationPoolTypeMismatch { .. } => { + (false, "rehydration_pool_type_mismatch") + } } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs index a5334bf2a6b..a56487a1cfd 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_load_wiring.rs @@ -105,26 +105,31 @@ fn c1_load_populates_keyless_wallet_payload() { assert_eq!(state.wallets.len(), 1, "the wallet must be in the payload"); let slice = state.wallets.get(&w).expect("wallet slice"); - assert_eq!(slice.network, key_wallet::Network::Testnet); - assert_eq!(slice.birth_height, 7); + assert_eq!(slice.wallet.network, key_wallet::Network::Testnet); + assert_eq!(slice.wallet_info.metadata.birth_height, 7); // Every persisted account round-trips: the registration PK carries the // full discriminator set (account_type, index, key_class, dashpay ids), - // so distinct variants never collapse onto one row. The manifest is a - // faithful read of what is on disk — non-empty, containing the primary - // BIP44 account. - assert!(!slice.account_manifest.is_empty()); + // so distinct variants never collapse onto one row. The rebuilt wallet's + // account collection is a faithful read of what is on disk — non-empty, + // containing the primary BIP44 account. + assert!(!slice.wallet.accounts.all_accounts().is_empty()); assert!( - slice.account_manifest.iter().any(|e| matches!( - e.account_type, - key_wallet::account::AccountType::Standard { .. } - )), + slice + .wallet + .accounts + .all_accounts() + .into_iter() + .any(|a| matches!( + a.account_type, + key_wallet::account::AccountType::Standard { .. } + )), "BIP44 account must be in the manifest" ); // Core state now lives inside the assembled `core_wallet_info`: the single // confirmed 777_000-duff UTXO restores as the wallet balance and the sync // watermark carries over. - assert_eq!(slice.core_wallet_info.balance.total(), 777_000); - assert_eq!(slice.core_wallet_info.metadata.last_processed_height, 50); + assert_eq!(slice.wallet_info.balance.total(), 777_000); + assert_eq!(slice.wallet_info.metadata.last_processed_height, 50); } /// Empty DB → empty `wallets`, no error (the `load()` doctest contract). @@ -149,6 +154,6 @@ fn c3_metadata_only_wallet_present() { let p2 = reopen(&path); let state = p2.load().unwrap(); let slice = state.wallets.get(&w).expect("metadata-only wallet present"); - assert!(slice.account_manifest.is_empty()); - assert_eq!(slice.core_wallet_info.balance.total(), 0); + assert!(slice.wallet.accounts.all_accounts().is_empty()); + assert_eq!(slice.wallet_info.balance.total(), 0); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs b/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs index b7ad4f2794e..b8225ad2802 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_used_core_addresses.rs @@ -122,12 +122,12 @@ fn spent_utxo_address_is_marked_used() { // Spent UTXO: contributes no balance, but its address is still marked used // in the assembled wallet's pool so it is never handed out as fresh again. assert_eq!( - slice.core_wallet_info.balance.total(), + slice.wallet_info.balance.total(), 0, "the spent UTXO must not contribute balance" ); let funds = slice - .core_wallet_info + .wallet_info .accounts .all_funding_accounts() .into_iter() diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index e873e968125..d91bafa525e 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -85,99 +85,6 @@ 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 *may* render persisted rows directly - and reactively. Display therefore never blocks on platform-wallet - 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** (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. -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::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/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index ea2d6df3c1d..8dc2c705427 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1178,12 +1178,8 @@ pub struct PlatformWalletChangeSet { /// the merge policy (plain `Vec::extend`, dedup is the apply-side /// caller's job). pub account_registrations: Vec, - /// Full address-pool snapshots: emitted once at wallet registration and - /// on later pool extension / used-flag flips. Incremental derivations - /// also arrive via `core.addresses_derived` (the `WalletEvent` bus / FFI - /// path). The storage persister expands these into per-index - /// `core_address_pool` rows (per-index `used` state + owning account for - /// UTXO attribution); the reader restores the used-set from them verbatim. + /// Address-pool snapshots emitted at wallet create (initial + /// gap-limit population) and on any pool extension / "used" flip. /// See [`AccountAddressPoolEntry`] for the merge policy. pub account_address_pools: Vec, /// Deferred contact-crypto ops enqueued by the seedless background sweep 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 e8e1ed962b7..c63e5a262be 100644 --- a/packages/rs-platform-wallet/src/changeset/client_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_start_state.rs @@ -13,7 +13,6 @@ 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 @@ -33,13 +32,6 @@ 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 / @@ -50,11 +42,7 @@ pub struct ClientStartState { impl ClientStartState { pub fn is_empty(&self) -> bool { - // 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(); + let core_empty = self.platform_addresses.is_empty() && self.wallets.is_empty(); #[cfg(feature = "shielded")] { core_empty && self.shielded.is_empty() @@ -65,28 +53,3 @@ 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" - ); - } -} 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 28f23124935..83b6d860742 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -1,58 +1,36 @@ //! Per-wallet portion of [`ClientStartState`](crate::changeset::ClientStartState). //! -//! **Keyless by type.** This carries everything needed to *reconstruct* -//! a watch-only wallet — network, birth height, the account manifest, -//! 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 -//! derivation to the on-demand sign path (`rs-platform-wallet-ffi`'s -//! `dash_sdk_sign_with_mnemonic_resolver_and_path` and its siblings). +//! Everything a single wallet contributes to the startup snapshot: the +//! key-wallet [`Wallet`] + [`ManagedWalletInfo`] pair, a lean +//! identity-manager snapshot, and still-unused asset locks bucketed by +//! account index. use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; -use crate::changeset::AccountRegistrationEntry; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::OutPoint; -use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; -use key_wallet::Network; +use key_wallet::wallet::ManagedWalletInfo; +use key_wallet::Wallet; -/// Keyless per-wallet slice of the startup snapshot. +/// Per-wallet slice of the startup snapshot. /// -/// Used as the value type in -/// [`ClientStartState::wallets`](crate::changeset::ClientStartState::wallets). -/// The structural absence of a `Wallet`/seed field is the SECRETS.md -/// boundary, enforced by type rather than convention. +/// Used as the value type in [`ClientStartState::wallets`](crate::changeset::ClientStartState::wallets). #[derive(Debug)] pub struct ClientWalletStartState { - /// Network the wallet is bound to. - pub network: Network, - /// Best estimate of the chain tip at creation time (`0` = scan - /// from genesis / unknown). - pub birth_height: u32, - /// Keyless account manifest — the account-set oracle for building the - /// watch-only wallet (one watch-only account per entry's xpub). - pub account_manifest: Vec, - /// 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. - /// - /// 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 wallet_info: Box, + /// The key-wallet [`Wallet`] to rehydrate on startup. Carries the + /// HD key material and account configuration the rest of the + /// per-wallet state hangs off of. + pub wallet: Wallet, + /// Managed wallet info holding non-key-material state (balances, + /// account metadata, UTXO set, etc.) for this wallet. + pub wallet_info: ManagedWalletInfo, /// Lean snapshot of this wallet's - /// [`IdentityManager`](crate::wallet::identity::IdentityManager). + /// [`IdentityManager`](crate::wallet::identity::IdentityManager): + /// owned + watched identities, primary selection, and the + /// gap-limit scan watermark. pub identity_manager: IdentityManagerStartState, - /// Asset locks not yet consumed by an identity registration / - /// top-up, keyed by account index → outpoint. Terminal `Consumed` - /// rows are already filtered out by the asset-lock reader. + /// Asset locks that have not yet been consumed by an identity + /// registration / top-up, keyed by account index → outpoint. pub unused_asset_locks: BTreeMap>, } diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 389490ce882..3cf5dd28f13 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -135,9 +135,12 @@ async fn build_core_changeset( new_utxos: derive_new_utxos(record), spent_utxos: derive_spent_utxos(record), records: vec![(**record).clone()], - // Forward the upstream-emitted derived addresses to the - // persister; the FFI layer feeds the iOS address registry - // from this delta. See `CoreChangeSet.addresses_derived`. + // Mirror the upstream-emitted derived addresses + // through to the persister so newly-extended pool + // rows are written transactionally with the tx that + // triggered the extension. See + // `CoreChangeSet.addresses_derived` for the cascade- + // link rationale. addresses_derived: addresses_derived.clone(), ..CoreChangeSet::default() } @@ -168,10 +171,7 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous - // ones — always project. Per-account attribution is resolved by - // the storage layer via the address→account_index lookup over - // `addresses_derived` (forwarded below). + // Inserted records bring fresh UTXOs and may consume previous ones. for r in inserted { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); @@ -357,120 +357,3 @@ impl CoreChangeSet { && self.addresses_derived.is_empty() } } - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use dashcore::blockdata::transaction::Transaction; - use dashcore::hashes::Hash; - use key_wallet::account::{AccountType, StandardAccountType}; - use key_wallet::managed_account::transaction_record::{ - OutputDetail, TransactionDirection, TransactionRecord, - }; - use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; - use key_wallet::WalletCoreBalance; - - use super::*; - - fn standard(index: u32) -> AccountType { - AccountType::Standard { - index, - standard_account_type: StandardAccountType::BIP44Account, - } - } - - /// A throwaway testnet P2PKH address keyed off `seed`. - fn p2pkh(seed: u8) -> dashcore::Address { - use dashcore::address::Payload; - use dashcore::PubkeyHash; - dashcore::Address::new( - dashcore::Network::Testnet, - Payload::PubkeyHash(PubkeyHash::from_byte_array([seed; 20])), - ) - } - - /// A confirmed `TransactionRecord` owned by `account_type` carrying a - /// single `Received` output worth `value` at `addr`, so - /// `derive_new_utxos` yields exactly one UTXO. - fn record_with_received_output( - account_type: AccountType, - addr: &dashcore::Address, - value: u64, - ) -> TransactionRecord { - let tx = Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![dashcore::TxOut { - value, - script_pubkey: addr.script_pubkey(), - }], - special_transaction_payload: None, - }; - TransactionRecord::new( - tx, - account_type, - TransactionContext::InChainLockedBlock(BlockInfo::new( - 42, - dashcore::BlockHash::from_byte_array([3u8; 32]), - 1_735_689_600, - )), - TransactionType::Standard, - TransactionDirection::Incoming, - Vec::new(), - vec![OutputDetail { - index: 0, - role: OutputRole::Received, - address: Some(addr.clone()), - value, - }], - value as i64, - ) - } - - /// Project a `TransactionDetected` for `record` through the real bridge - /// path. `balance`/`account_balances` are unused by the projection. - async fn changeset_for(record: TransactionRecord) -> CoreChangeSet { - let wm = Arc::new(RwLock::new(WalletManager::::new( - key_wallet::Network::Testnet, - ))); - let event = WalletEvent::TransactionDetected { - wallet_id: [0u8; 32], - record: Box::new(record), - balance: WalletCoreBalance::default(), - account_balances: BTreeMap::new(), - addresses_derived: Vec::new(), - }; - build_core_changeset(&wm, &event).await - } - - /// A default-account (index 0) UTXO is projected into the changeset. - #[tokio::test] - async fn default_account_utxo_persists() { - let addr = p2pkh(0x11); - let cs = changeset_for(record_with_received_output(standard(0), &addr, 500_000)).await; - assert_eq!( - cs.new_utxos.len(), - 1, - "the default-account UTXO must be projected" - ); - assert_eq!(cs.new_utxos[0].value(), 500_000); - } - - /// REGRESSION (fund-loss): a non-default-account (index != 0) UTXO is - /// 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() { - let addr = p2pkh(0x22); - let cs = changeset_for(record_with_received_output(standard(7), &addr, 900_000)).await; - assert_eq!( - cs.new_utxos.len(), - 1, - "a non-default-account UTXO must NOT be dropped" - ); - assert_eq!(cs.new_utxos[0].value(), 900_000, "funds preserved"); - } -} diff --git a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs index 1433aa08528..fbb42fa9e09 100644 --- a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs @@ -4,12 +4,10 @@ //! struct — no methods, no invariants, no live handles — so persisters //! can round-trip it without dragging in the manager's business logic. -use std::collections::{BTreeMap, HashMap}; +use std::collections::BTreeMap; -use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; -use crate::changeset::{ContactChangeSet, IdentityKeysChangeSet}; use crate::wallet::identity::ManagedIdentity; use crate::wallet::identity::RegistrationIndex; use crate::wallet::platform_wallet::WalletId; @@ -29,338 +27,3 @@ pub struct IdentityManagerStartState { /// inner-keyed by BIP-9 registration index. pub wallet_identities: BTreeMap>, } - -impl IdentityManagerStartState { - /// Fold persisted PUBLIC keys and contact state onto the already-built - /// managed identities so `Identity.public_keys` and the contact maps - /// are populated at load time — the FFI persister's pre-keyed shape, - /// with no separate changeset layered on afterwards. - /// - /// Entries route by owner `identity_id` across BOTH buckets; one whose - /// owner is absent (e.g. a tombstoned identity's orphaned rows) is - /// logged and skipped, never fatal. Only key `upserts` and the - /// `sent` / `incoming` / `established` maps are routed; `removed_*` - /// (insert-only feed) and `ignored` / `unignored` (restored in the - /// identity reader from the `ignored_senders` table) are skipped. No - /// `Network` needed — key insert is network-independent. - pub fn merge_contacts_and_keys( - &mut self, - contacts: ContactChangeSet, - identity_keys: IdentityKeysChangeSet, - ) { - // One transient id → &mut ManagedIdentity view over both buckets so - // routing is O(1) per entry rather than a per-entry bucket scan. The - // two buckets are disjoint fields, so their mutable borrows coexist. - let mut by_id: HashMap = HashMap::new(); - for managed in self.out_of_wallet_identities.values_mut() { - by_id.insert(managed.identity.id(), managed); - } - for inner in self.wallet_identities.values_mut() { - for managed in inner.values_mut() { - by_id.insert(managed.identity.id(), managed); - } - } - - for (_key, entry) in identity_keys.upserts { - match by_id.get_mut(&entry.identity_id) { - Some(managed) => managed.identity.add_public_key(entry.public_key), - None => tracing::warn!( - identity = %entry.identity_id, - key_id = entry.key_id, - "skipping identity key during rehydration merge: owner identity not loaded" - ), - } - } - for (key, entry) in contacts.sent_requests { - match by_id.get_mut(&key.owner_id) { - Some(managed) => managed.apply_sent_contact_request(entry.request), - None => tracing::warn!( - owner = %key.owner_id, - "skipping sent contact request during rehydration merge: owner identity not loaded" - ), - } - } - for (key, entry) in contacts.incoming_requests { - match by_id.get_mut(&key.owner_id) { - Some(managed) => managed.apply_incoming_contact_request(entry.request), - None => tracing::warn!( - owner = %key.owner_id, - "skipping incoming contact request during rehydration merge: owner identity not loaded" - ), - } - } - for (key, established) in contacts.established { - match by_id.get_mut(&key.owner_id) { - Some(managed) => managed.apply_established_contact(established), - None => tracing::warn!( - owner = %key.owner_id, - "skipping established contact during rehydration merge: owner identity not loaded" - ), - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::changeset::{ - ContactRequestEntry, IdentityKeyEntry, ReceivedContactRequestKey, SentContactRequestKey, - }; - use crate::wallet::identity::{ContactRequest, EstablishedContact}; - use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::v0::IdentityV0; - use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; - use dpp::platform_value::BinaryData; - - fn identity(id_byte: u8) -> Identity { - Identity::V0(IdentityV0 { - id: Identifier::from([id_byte; 32]), - public_keys: BTreeMap::new(), - balance: 1_000, - revision: 1, - }) - } - - fn wallet_identity(state: &mut IdentityManagerStartState, w: WalletId, idx: u32, id_byte: u8) { - let mut managed = ManagedIdentity::new(identity(id_byte), idx); - managed.wallet_id = Some(w); - state - .wallet_identities - .entry(w) - .or_default() - .insert(idx, managed); - } - - fn key_entry( - id_byte: u8, - key_id: u32, - data_byte: u8, - security: SecurityLevel, - ) -> IdentityKeyEntry { - IdentityKeyEntry { - identity_id: Identifier::from([id_byte; 32]), - key_id, - public_key: IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: key_id, - purpose: Purpose::AUTHENTICATION, - security_level: security, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: BinaryData::new(vec![data_byte; 33]), - disabled_at: None, - }), - public_key_hash: [data_byte; 20], - wallet_id: None, - derivation_indices: None, - } - } - - fn keys(entries: impl IntoIterator) -> IdentityKeysChangeSet { - let mut cs = IdentityKeysChangeSet::default(); - for e in entries { - cs.upserts.insert((e.identity_id, e.key_id), e); - } - cs - } - - fn request(sender: u8, recipient: u8) -> ContactRequest { - ContactRequest { - sender_id: Identifier::from([sender; 32]), - recipient_id: Identifier::from([recipient; 32]), - sender_key_index: 0, - recipient_key_index: 0, - account_reference: 0, - encrypted_account_label: None, - encrypted_public_key: vec![7; 96], - auto_accept_proof: None, - core_height_created_at: 11, - created_at: 22, - } - } - - /// A key upsert whose owner is a wallet-bucket identity lands in that - /// identity's `public_keys` map, keyed by `KeyID`. - #[test] - fn merge_routes_key_into_wallet_identity() { - let w: WalletId = [0xAA; 32]; - let mut state = IdentityManagerStartState::default(); - wallet_identity(&mut state, w, 5, 0x01); - - state.merge_contacts_and_keys( - ContactChangeSet::default(), - keys([key_entry(0x01, 0, 0xAB, SecurityLevel::HIGH)]), - ); - - let managed = &state.wallet_identities[&w][&5]; - let pk = managed - .identity - .public_keys() - .get(&0) - .expect("key routed onto identity"); - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - assert_eq!(pk.data().as_slice(), &[0xAB; 33]); - } - - /// Out-of-wallet identities are covered by the merge too — a naive - /// implementation that only walked `wallet_identities` would drop - /// their keys. - #[test] - fn merge_routes_key_into_out_of_wallet_identity() { - let id = Identifier::from([0x02; 32]); - let mut state = IdentityManagerStartState::default(); - state - .out_of_wallet_identities - .insert(id, ManagedIdentity::new_out_of_wallet(identity(0x02))); - - state.merge_contacts_and_keys( - ContactChangeSet::default(), - keys([key_entry(0x02, 3, 0xCD, SecurityLevel::CRITICAL)]), - ); - - assert!(state.out_of_wallet_identities[&id] - .identity - .public_keys() - .contains_key(&3)); - } - - /// Sent / incoming / established contacts each route to their own map. - #[test] - fn merge_routes_contacts_to_correct_maps() { - let w: WalletId = [0xAA; 32]; - let owner = 0x01; - let mut state = IdentityManagerStartState::default(); - wallet_identity(&mut state, w, 0, owner); - - let owner_id = Identifier::from([owner; 32]); - let mut contacts = ContactChangeSet::default(); - contacts.sent_requests.insert( - SentContactRequestKey { - owner_id, - recipient_id: Identifier::from([0x22; 32]), - }, - ContactRequestEntry { - request: request(owner, 0x22), - }, - ); - contacts.incoming_requests.insert( - ReceivedContactRequestKey { - owner_id, - sender_id: Identifier::from([0x33; 32]), - }, - ContactRequestEntry { - request: request(0x33, owner), - }, - ); - let contact_c = Identifier::from([0x44; 32]); - contacts.established.insert( - SentContactRequestKey { - owner_id, - recipient_id: contact_c, - }, - EstablishedContact { - contact_identity_id: contact_c, - outgoing_request: request(owner, 0x44), - incoming_request: request(0x44, owner), - alias: Some("c".into()), - note: None, - is_hidden: false, - accepted_accounts: vec![1], - payment_channel_broken: false, - contact_account_label: None, - external_account_reference: None, - }, - ); - - state.merge_contacts_and_keys(contacts, IdentityKeysChangeSet::default()); - - let managed = &state.wallet_identities[&w][&0]; - assert!(managed - .dashpay() - .sent_contact_requests() - .contains_key(&Identifier::from([0x22; 32]))); - assert!(managed - .dashpay() - .incoming_contact_requests() - .contains_key(&Identifier::from([0x33; 32]))); - assert!(managed - .dashpay() - .established_contacts() - .contains_key(&contact_c)); - } - - /// Two identities with the same numeric `KeyID` but different owners - /// keep disjoint key maps — the group-by must not misattribute. - #[test] - fn merge_does_not_leak_keys_across_identities() { - let w: WalletId = [0xAA; 32]; - let mut state = IdentityManagerStartState::default(); - wallet_identity(&mut state, w, 0, 0x01); - wallet_identity(&mut state, w, 1, 0x02); - - state.merge_contacts_and_keys( - ContactChangeSet::default(), - keys([ - key_entry(0x01, 0, 0xA0, SecurityLevel::HIGH), - key_entry(0x02, 0, 0xB0, SecurityLevel::HIGH), - ]), - ); - - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - let a = &state.wallet_identities[&w][&0]; - let b = &state.wallet_identities[&w][&1]; - assert_eq!(a.identity.public_keys().len(), 1); - assert_eq!(b.identity.public_keys().len(), 1); - assert_eq!(a.identity.public_keys()[&0].data().as_slice(), &[0xA0; 33]); - assert_eq!(b.identity.public_keys()[&0].data().as_slice(), &[0xB0; 33]); - } - - /// An entry whose owner is absent from both buckets is logged and - /// skipped — never a panic. - #[test] - fn merge_skips_orphan_entries() { - let w: WalletId = [0xAA; 32]; - let mut state = IdentityManagerStartState::default(); - wallet_identity(&mut state, w, 0, 0x01); - - let mut contacts = ContactChangeSet::default(); - contacts.sent_requests.insert( - SentContactRequestKey { - owner_id: Identifier::from([0xEE; 32]), - recipient_id: Identifier::from([0xFF; 32]), - }, - ContactRequestEntry { - request: request(0xEE, 0xFF), - }, - ); - // Key for an identity that isn't present in either bucket. - state.merge_contacts_and_keys( - contacts, - keys([key_entry(0xEE, 0, 0x01, SecurityLevel::HIGH)]), - ); - - let managed = &state.wallet_identities[&w][&0]; - assert!(managed.identity.public_keys().is_empty()); - assert!(managed.dashpay().sent_contact_requests().is_empty()); - } - - /// Empty changesets are a no-op. - #[test] - fn merge_empty_changesets_is_noop() { - let w: WalletId = [0xAA; 32]; - let mut state = IdentityManagerStartState::default(); - wallet_identity(&mut state, w, 0, 0x01); - - state.merge_contacts_and_keys( - ContactChangeSet::default(), - IdentityKeysChangeSet::default(), - ); - - let managed = &state.wallet_identities[&w][&0]; - assert!(managed.identity.public_keys().is_empty()); - assert!(managed.dashpay().sent_contact_requests().is_empty()); - assert!(managed.dashpay().incoming_contact_requests().is_empty()); - assert!(managed.dashpay().established_contacts().is_empty()); - } -} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index dbd2e374c90..9ed22a5542e 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -255,20 +255,6 @@ 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 - /// - /// This trait does not authenticate the [`ClientStartState`] it - /// returns: nothing here binds a wallet's account manifest to the - /// `wallet_id` it is returned under, and this contract neither - /// mandates nor performs such a check. Verifying manifest integrity - /// against a tampered or untrusted-backup store is the storage - /// layer's responsibility (a persisted commitment over the manifest, - /// keyed to the store) — see the manifest-integrity work in the - /// `platform-wallet-storage` crate. Implementors and callers must not - /// assume a manifest handed back here has been verified authentic; - /// `build_watch_only_wallet` in the `rehydrate` module documents the - /// concrete key-substitution risk this leaves open. fn load(&self) -> Result; /// Look up a single core transaction record by `txid` for `wallet_id`. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index b41893d5427..473e82e5491 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1,10 +1,7 @@ use dpp::address_funds::PlatformAddress; -use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::fee::Credits; use dpp::identifier::Identifier; -use dpp::prelude::AddressNonce; use key_wallet::account::StandardAccountType; -use key_wallet::managed_account::address_pool::AddressPoolType; use key_wallet::Network; /// Errors that can occur in platform wallet operations @@ -13,18 +10,6 @@ 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), - #[error("Wallet not found: {0}")] WalletNotFound(String), @@ -117,26 +102,6 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), - /// Platform rejected an address-funds transition because a spent - /// address's provided nonce did not equal Platform's expected next value - /// (DPP consensus code 40603, `AddressInvalidNonceError`): the optimistic - /// `fetched + 1` nonce raced a lagging DAPI replica's stale read. - /// - /// The rejection is by design — the address-nonce path is optimistic and - /// the caller owns the retry. This variant delivers Platform's - /// `expected_nonce` verbatim so the caller can rebuild the transition with - /// it (no re-fetch needed) instead of parsing a flattened string. - #[error( - "Address nonce mismatch for {address}: submitted nonce {provided_nonce}, \ - Platform expected {expected_nonce}; retry the operation with the \ - expected nonce" - )] - AddressNonceMismatch { - address: PlatformAddress, - provided_nonce: AddressNonce, - expected_nonce: AddressNonce, - }, - #[error("Address sync failed: {0}")] AddressSync(String), @@ -333,56 +298,6 @@ pub enum PlatformWalletError { #[error("Shielded sub-wallet not bound: call bind_shielded first")] ShieldedNotBound, - - /// The persisted wallet has UTXOs to restore but no funds-bearing - /// account in its reconstructed account collection to hold them. - /// Fail-closed rather than reconstructing a silent zero balance — - /// the no-silent-zero mandate. Carries only the (public) wallet id - /// and the dropped-UTXO count, never key material. - #[error( - "rehydration topology unsupported for wallet {}: {utxo_count} persisted UTXO(s) but no funds-bearing account", - hex::encode(wallet_id) - )] - RehydrationTopologyUnsupported { - /// The wallet whose topology could not hold the persisted UTXOs. - wallet_id: [u8; 32], - /// How many persisted UTXOs would have been silently dropped. - utxo_count: usize, - }, - - /// The deep-index discovery probes did not mirror the account's real - /// address pools 1:1 during rehydration, so applying probe depths by - /// position would index the wrong pool. Fail-closed instead of risking - /// a misattributed derivation — the probes are built directly from the - /// same `address_pools()` enumeration, so a mismatch is a structural - /// invariant break, not user-reachable. - #[error( - "rehydration pool/probe mismatch: expected {expected} address pool(s) to mirror the discovery probes, found {found}" - )] - RehydrationPoolMismatch { - /// Number of discovery probes built from `address_pools()`. - expected: usize, - /// Number of real address pools from `address_pools_mut()`. - found: usize, - }, - - /// During rehydration a discovery probe and the real address pool it maps - /// to **by position** disagreed on `pool_type`, so applying the probe's - /// discovered depth would target the wrong chain. Fail-closed rather than - /// misattribute a derivation depth. The probes are built from the same - /// `address_pools()` enumeration, so a mismatch is a structural invariant - /// break, not user-reachable. - #[error( - "rehydration pool/probe chain-order mismatch at position {position}: real pool is {found:?} but probe is {expected:?}" - )] - RehydrationPoolTypeMismatch { - /// Index into the account's address-pool list where the mismatch was found. - position: usize, - /// The probe's pool type (discovery order). - expected: AddressPoolType, - /// The real pool's pool type at the same position. - found: AddressPoolType, - }, } /// Check whether an SDK error indicates that an InstantSend lock proof was @@ -482,164 +397,3 @@ pub fn as_asset_lock_proof_cl_height_too_low( _ => None, } } - -/// Extract the `AddressInvalidNonceError` (DPP consensus code 40603) from an -/// SDK error if Platform rejected an address-funds transition because a spent -/// address's provided nonce did not equal its expected next value. -/// -/// Returns `Some(&error)` for both `dash_sdk::Error` shapes that carry a -/// consensus verdict — `StateTransitionBroadcastError` (wait-stream rejection) -/// and `Protocol(ProtocolError::ConsensusError)` (CheckTx rejection) — -/// exposing `address()`, `provided_nonce()`, and `expected_nonce()`. Returns -/// `None` for everything else. -/// -/// The address-nonce path is optimistic by design: the client submits -/// `fetched + 1` and Platform rejects a stale/replayed value under a lagging -/// replica read. This extractor lets a caller recover `expected_nonce` and -/// retry per that contract. Recurses through -/// [`dash_sdk::Error::NoAvailableAddressesToRetry`] so it stays in lockstep -/// with its sibling `broadcast_definitely_failed`; re-audit if a future -/// `dash_sdk::Error` variant starts carrying consensus errors through yet -/// another shape. -pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { - use dpp::consensus::state::state_error::StateError; - use dpp::consensus::ConsensusError; - - let consensus_error = match error { - dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { - broadcast_err.cause.as_ref() - } - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), - // A consensus rejection can arrive wrapped when the dapi-client - // exhausted every address mid-retry; recurse so this predicate stays - // in lockstep with `broadcast_definitely_failed`. - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { - return as_address_invalid_nonce(inner) - } - _ => None, - }; - match consensus_error { - Some(ConsensusError::StateError(StateError::AddressInvalidNonceError(e))) => Some(e), - _ => None, - } -} - -/// Promote a nonce-rejection SDK error to the typed -/// [`PlatformWalletError::AddressNonceMismatch`] so callers can recover -/// `expected_nonce` and retry, instead of receiving the rejection flattened -/// to a string. -/// -/// Returns `None` for any error [`as_address_invalid_nonce`] does not match, -/// leaving the caller free to keep its existing fallback mapping. -pub fn promote_address_nonce_error(error: &dash_sdk::Error) -> Option { - as_address_invalid_nonce(error).map(|e| PlatformWalletError::AddressNonceMismatch { - address: *e.address(), - provided_nonce: e.provided_nonce(), - expected_nonce: e.expected_nonce(), - }) -} - -#[cfg(test)] -mod address_nonce_tests { - use super::*; - use dash_sdk::error::StateTransitionBroadcastError; - - const ADDR_BYTES: [u8; 20] = [7u8; 20]; - - /// An `AddressInvalidNonceError` wrapped as a `ConsensusError`, plus the - /// address it names, for asserting round-trip field fidelity. - fn nonce_consensus_error( - provided: AddressNonce, - expected: AddressNonce, - ) -> (PlatformAddress, dpp::consensus::ConsensusError) { - let address = PlatformAddress::P2pkh(ADDR_BYTES); - let err = AddressInvalidNonceError::new(address, provided, expected); - (address, err.into()) - } - - /// `Protocol(ConsensusError)` — the CheckTx-rejection shape. - fn protocol_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { - let (_, cause) = nonce_consensus_error(provided, expected); - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) - } - - /// `StateTransitionBroadcastError` — the wait-stream-rejection shape. - fn broadcast_shape(provided: AddressNonce, expected: AddressNonce) -> dash_sdk::Error { - let (_, cause) = nonce_consensus_error(provided, expected); - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 40603, - message: "invalid address nonce".to_string(), - cause: Some(cause), - }) - } - - #[test] - fn extracts_nonce_error_from_protocol_shape() { - let err = protocol_shape(1, 2); - let got = as_address_invalid_nonce(&err).expect("protocol shape must match"); - assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); - assert_eq!(got.provided_nonce(), 1); - assert_eq!(got.expected_nonce(), 2); - } - - #[test] - fn extracts_nonce_error_from_broadcast_shape() { - let err = broadcast_shape(5, 6); - let got = as_address_invalid_nonce(&err).expect("broadcast shape must match"); - assert_eq!(*got.address(), PlatformAddress::P2pkh(ADDR_BYTES)); - assert_eq!(got.provided_nonce(), 5); - assert_eq!(got.expected_nonce(), 6); - } - - #[test] - fn ignores_unrelated_and_causeless_errors() { - // A plainly unrelated SDK error. - assert!(as_address_invalid_nonce(&dash_sdk::Error::Generic("boom".to_string())).is_none()); - // The DAPI wait-timeout shape: a broadcast error with no consensus - // cause must NOT be misread as a nonce rejection. - let causeless = - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 0, - message: "timeout".to_string(), - cause: None, - }); - assert!(as_address_invalid_nonce(&causeless).is_none()); - } - - #[test] - fn promotes_both_shapes_to_typed_variant() { - for err in [protocol_shape(1, 2), broadcast_shape(1, 2)] { - match promote_address_nonce_error(&err) { - Some(PlatformWalletError::AddressNonceMismatch { - address, - provided_nonce, - expected_nonce, - }) => { - assert_eq!(address, PlatformAddress::P2pkh(ADDR_BYTES)); - assert_eq!(provided_nonce, 1); - assert_eq!(expected_nonce, 2); - } - other => panic!("expected AddressNonceMismatch, got {other:?}"), - } - } - } - - #[test] - fn promotion_leaves_unrelated_errors_for_the_fallback() { - assert!( - promote_address_nonce_error(&dash_sdk::Error::Generic("boom".to_string())).is_none() - ); - } - - #[test] - fn extracts_nonce_error_wrapped_in_no_available_addresses_to_retry() { - // The dapi-client wraps the last rejection in `NoAvailableAddressesToRetry` - // when every address is exhausted mid-retry; the extractor must recurse - // into it (lockstep with `broadcast_definitely_failed`). - let inner = Box::new(protocol_shape(9, 10)); - let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(inner); - let got = as_address_invalid_nonce(&wrapped).expect("must unwrap the retry envelope"); - assert_eq!(got.provided_nonce(), 9); - assert_eq!(got.expected_nonce(), 10); - } -} diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs index d24b137b76b..9ac256e8730 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -16,11 +16,9 @@ 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; /// Extension of [`EventHandler`] for platform-wallet consumers. /// @@ -46,13 +44,6 @@ 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. - /// - /// 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 /// cumulative count of encrypted notes scanned so far in the @@ -151,17 +142,6 @@ 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. - 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 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 6086b0ada60..fb1077a1ae7 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -47,12 +47,10 @@ 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, }; -pub use manager::rehydrate; pub use manager::PlatformWalletManager; pub use spv::SpvRuntime; pub use wallet::asset_lock::manager::AssetLockManager; diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 7cfdfad2f53..f355eca497a 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -3,11 +3,8 @@ 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::{CorruptKind, LoadOutcome, SkipReason}; use crate::wallet::core::WalletBalance; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -16,192 +13,80 @@ use crate::wallet::PlatformWallet; use super::PlatformWalletManager; impl PlatformWalletManager

{ - /// 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 managed core state is restored, and the result is - /// registered into the manager. - /// - /// Core state arrives as a full keyless snapshot - /// ([`ClientWalletStartState::wallet_info`]) — consumed directly, - /// preserving per-account UTXO/record attribution and exact pool - /// contents — after its `wallet_id`/`network`/account-set are - /// validated against the row. - /// - /// The load path never touches the seed, so it performs no wrong-seed - /// check. Signing happens later, on demand, via the configured - /// `MnemonicResolverHandle` (`rs-sdk-ffi`). - /// - /// # Skip vs hard-fail + /// Load the full [`ClientStartState`] from the configured persister + /// and rehydrate the manager's `wallet_manager` and `wallets` maps. /// - /// 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). + /// 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`. /// - /// - **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) - /// 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` therefore mutates no state and returns - /// `Ok(LoadOutcome)` — a repeat where every wallet is already - /// registered is a [`NoneUsable`](LoadOutcome::NoneUsable) no-op, not a - /// failure — and the caller can tell an already-present wallet from one - /// freshly loaded via [`loaded`](LoadOutcome::loaded) / - /// [`skipped`](LoadOutcome::skipped). - /// - **Whole-load failure** (persister I/O, programmer error, - /// registering a persisted wallet in `WalletManager`): - /// `Err(_)` — every wallet inserted earlier in this pass is - /// rolled back. Skipped wallets never entered the maps so the - /// rollback path never sees them. + /// 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). /// - /// 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. - /// - /// # 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. - pub async fn load_from_persistor(&self) -> Result { + /// [`WalletManager`]: key_wallet_manager::WalletManager + pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { 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")] shielded: _, - } = self.persister.load()?; + } = self.persister.load().map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to load persisted client state: {}", + e + )) + })?; let persister_dyn: Arc = Arc::clone(&self.persister) as _; - // 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. + // 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. let mut inserted_in_manager: Vec = Vec::new(); let mut inserted_in_wallets: Vec = Vec::new(); let mut load_error: Option = None; - 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 - // 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); - skipped.push((wallet_id, reason)); - } 'load: for (expected_wallet_id, wallet_state) in wallets { let ClientWalletStartState { - network, - // The carried snapshot supplies its own sync metadata, so - // the row's birth height is not needed on this path. - birth_height: _, - account_manifest, + wallet, wallet_info, identity_manager, unused_asset_locks, } = wallet_state; - // Idempotency, checked FIRST: a wallet already registered (a - // 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() { - let reason = SkipReason::AlreadyRegistered; - self.event_manager - .on_wallet_skipped_on_load(expected_wallet_id, &reason); - skipped.push((expected_wallet_id, reason)); - 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 - // a degraded placeholder. - let wallet = match super::rehydrate::build_watch_only_wallet( - network, - expected_wallet_id, - &account_manifest, - ) { - Ok(w) => w, - Err(kind) => { - let reason = SkipReason::CorruptPersistedRow { kind }; - skipped.push((expected_wallet_id, reason.clone())); - self.event_manager - .on_wallet_skipped_on_load(expected_wallet_id, &reason); - continue 'load; - } - }; - - // 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 = *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, - }; - 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 - }; - - // Flatten the (account → outpoint → lock) map. + // Flatten the (account → outpoint → lock) map into the flat + // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` + // holds today. 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(), @@ -209,36 +94,21 @@ impl PlatformWalletManager

{ core_balance.immature(), core_balance.locked(), ); - // Build the identity manager from the snapshot; public keys - // and contacts are already reconstructed into it upstream by - // the FFI persister (`build_wallet_identity_bucket`). - let identity_manager = IdentityManager::from(identity_manager); let platform_info = PlatformWalletInfo { core_wallet: wallet_info, balance: Arc::clone(&balance), - identity_manager, + identity_manager: IdentityManager::from(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) { Ok(id) => id, - Err(key_wallet_manager::WalletError::WalletExists(_)) => { - // 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) => { load_error = Some(PlatformWalletError::WalletCreation(format!( "Failed to register persisted wallet in WalletManager: {}", @@ -250,6 +120,15 @@ 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, ))); @@ -263,6 +142,10 @@ 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() @@ -284,10 +167,13 @@ impl PlatformWalletManager

{ wallets_guard.insert(wallet_id, platform_wallet); drop(wallets_guard); inserted_in_wallets.push(wallet_id); - 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 { @@ -309,220 +195,6 @@ impl PlatformWalletManager

{ return Err(err); } - Ok(LoadOutcome::from_parts(loaded, skipped)) - } -} - -/// Whether the snapshot's account set matches the row's account manifest. -/// -/// 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` / -/// 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 -} - -#[cfg(test)] -mod rollback_tests { - //! In-crate because constructing the mid-batch hard-fail needs - //! `PerAccountPlatformAddressState` + `bimap::BiBTreeMap`, which are - //! not reachable from the external integration-test crate. - - use std::sync::Arc; - - use bimap::BiBTreeMap; - use key_wallet::wallet::initialization::WalletAccountCreationOptions; - use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; - use key_wallet::wallet::Wallet; - - use crate::changeset::{ - AccountRegistrationEntry, ClientStartState, ClientWalletStartState, PersistenceError, - PlatformWalletChangeSet, PlatformWalletPersistence, - }; - use crate::error::PlatformWalletError; - use crate::events::{EventHandler, PlatformEventHandler}; - use crate::wallet::platform_wallet::WalletId; - use crate::wallet::{PerAccountPlatformAddressState, PerWalletPlatformAddressState}; - use crate::{PlatformAddressSyncStartState, PlatformWalletManager}; - - fn slice(seed: [u8; 64]) -> (WalletId, ClientWalletStartState) { - let wallet = Wallet::from_seed_bytes( - seed, - key_wallet::Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - let id = wallet.compute_wallet_id(); - let account_manifest: Vec = wallet - .accounts - .all_accounts() - .into_iter() - .map(|a| AccountRegistrationEntry { - account_type: a.account_type, - account_xpub: a.account_xpub, - }) - .collect(); - let info = ManagedWalletInfo::from_wallet(&wallet, 1); - ( - id, - ClientWalletStartState { - network: key_wallet::Network::Testnet, - birth_height: 1, - account_manifest, - wallet_info: Box::new(info), - identity_manager: Default::default(), - unused_asset_locks: Default::default(), - }, - ) - } - - /// Two healthy wallets plus a platform-address restore state for the - /// second that references an account index the wallet does not have - /// as a platform-payment account, so `initialize_from_persisted` - /// hard-fails mid-batch. Rebuilt fresh on every `load` — the platform - /// address state types are not `Clone`. - struct RollbackPersister { - healthy_seed: [u8; 64], - fail_seed: [u8; 64], - } - - impl PlatformWalletPersistence for RollbackPersister { - fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { - Ok(()) - } - fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - let mut st = ClientStartState::default(); - let (healthy_id, healthy) = slice(self.healthy_seed); - let (fail_id, fail) = slice(self.fail_seed); - let bogus_xpub = fail.account_manifest[0].account_xpub; - st.wallets.insert(healthy_id, healthy); - st.wallets.insert(fail_id, fail); - - // A per-account restore entry keyed to an account index the - // wallet has no platform-payment account for: `from_persisted` - // returns `AddressSync`, which the manager treats as a hard - // failure (not a per-row skip). - let mut per_account = PerWalletPlatformAddressState::new(); - per_account.insert( - 9_999, - PerAccountPlatformAddressState::from_persisted( - bogus_xpub, - BiBTreeMap::new(), - std::collections::BTreeMap::new(), - ), - ); - st.platform_addresses.insert( - fail_id, - PlatformAddressSyncStartState { - per_account, - sync_height: 0, - sync_timestamp: 0, - last_known_recent_block: 0, - }, - ); - Ok(st) - } - } - - struct NoopHandler; - impl EventHandler for NoopHandler {} - impl PlatformEventHandler for NoopHandler {} - - /// A hard failure part-way through a batch must roll back every wallet - /// already inserted this pass — from BOTH `self.wallets` and - /// `wallet_manager` — and surface as `Err`. Nothing asserted this - /// before. - #[tokio::test] - async fn hard_fail_rolls_back_already_loaded_healthy_wallet() { - // Order the seeds so the healthy wallet sorts BEFORE the failing - // one (wallets load in BTreeMap key order), guaranteeing the - // healthy wallet is fully inserted before the failure aborts. - let (id_a, _) = slice([0xA1; 64]); - let (id_b, _) = slice([0xB2; 64]); - let (healthy_seed, fail_seed) = if id_a < id_b { - ([0xA1; 64], [0xB2; 64]) - } else { - ([0xB2; 64], [0xA1; 64]) - }; - let (healthy_id, _) = slice(healthy_seed); - - let persister = Arc::new(RollbackPersister { - healthy_seed, - fail_seed, - }); - let sdk = Arc::new(dash_sdk::Sdk::new_mock()); - let handler: Arc = Arc::new(NoopHandler); - let mgr = Arc::new(PlatformWalletManager::new(sdk, persister, handler)); - - let err = mgr - .load_from_persistor() - .await - .expect_err("a mid-batch hard failure must surface as Err"); - assert!( - matches!(err, PlatformWalletError::WalletCreation(_)), - "a platform-address restore failure is a WalletCreation hard error, got {err:?}" - ); - - // The healthy wallet was fully inserted, then rolled back on the - // failure — gone from self.wallets... - assert!( - mgr.get_wallet(&healthy_id).await.is_none(), - "the already-loaded healthy wallet must be evicted from self.wallets" - ); - assert!( - mgr.wallet_ids().await.is_empty(), - "self.wallets must be fully rolled back" - ); - // ...and from wallet_manager (read via the blocking accessor). - let rows = { - let mgr = Arc::clone(&mgr); - tokio::task::spawn_blocking(move || mgr.account_balances_blocking(&healthy_id)) - .await - .unwrap() - }; - assert!( - rows.is_empty(), - "the already-loaded healthy wallet must be evicted from wallet_manager too" - ); + Ok(()) } } diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 989d2a7e6bb..7962d6551f1 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -4,10 +4,8 @@ pub mod accessors; pub mod dashpay_sync; pub mod identity_sync; mod load; -pub mod load_outcome; mod loop_cancel; pub mod platform_address_sync; -pub mod rehydrate; #[cfg(feature = "shielded")] pub mod shielded_sync; mod wallet_lifecycle; @@ -86,19 +84,14 @@ pub struct PlatformWalletManager { #[cfg(feature = "shielded")] pub(super) shielded_coordinator: Arc>>>, - /// Shared `PlatformEventManager`, retained on the manager for the - /// two callers that fan out platform-wallet events directly: - /// `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 + /// 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 /// (`SpvRuntime`, `PlatformAddressSyncManager`, etc.) hold their - /// own clones already. Retained unconditionally because - /// `load_from_persistor` reads it regardless of the `shielded` - /// feature. + /// own clones already, so `configure_shielded` is the only reader of + /// this retained handle — hence it is `shielded`-gated. + #[cfg(feature = "shielded")] pub(super) event_manager: Arc, pub(super) persister: Arc

, /// Cancellation token + join handle for the wallet-event adapter @@ -194,6 +187,7 @@ 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/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index c6fb50b8e23..e8a38200715 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -375,7 +375,6 @@ 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/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index bfaa4006f58..51b76637af9 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -156,12 +156,11 @@ pub(crate) async fn funded_wallet_manager( } }; - // Funded as a chain-locked (confirmed) UTXO, not mempool: since - // rust-dashcore#836, asset-lock building excludes unconfirmed/non- - // InstantSend inputs (`require_final_inputs`), so a mempool-only fixture - // would starve `build_asset_lock`'s coin selection before ever reaching - // the broadcast-rejection paths these tests exist to exercise. let funding_tx = Transaction::dummy(&receive_address, 0..1, &[10_000_000]); + // Chain-locked funding, not `Mempool`: asset-lock builders only + // select final (confirmed / InstantSend-locked) inputs since + // rust-dashcore#836, so a mempool-funded fixture leaves the + // asset-lock tests with no eligible UTXO. let result = ctx .check_transaction( &funding_tx, diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index a2f82d6779c..6100e19d57c 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -154,17 +154,114 @@ impl PlatformWalletInfo { } } - // 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, - ); + // 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. Every map mutation goes through the + // `apply_*` replay methods — the relationship maps are sealed + // to the state layer, and the replay methods reproduce + // persisted state without re-running the live invariants + // (`apply_established_contact` additionally 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, + ignored, + unignored, + } = contact_cs; + + for (key, entry) in sent_requests { + match self.identity_manager.managed_identity_mut(&key.owner_id) { + Some(managed) => { + managed.apply_sent_contact_request(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.apply_incoming_contact_request(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.apply_removed_sent(&key.recipient_id); + } + } + for key in removed_incoming { + if let Some(managed) = self.identity_manager.managed_identity_mut(&key.owner_id) { + managed.apply_removed_incoming(&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" + ), + } + } + // Ignored senders (per-sender mute, local-only). Restore the + // in-memory suppression set so the sync ingest path won't + // resurrect an ignored sender's requests after a restart. + // `unignored` is applied AFTER `ignored` so an un-ignore in the + // same delta wins (the sender ends up not ignored). Orphan + // owners are logged and skipped. + for (owner_id, sender_id) in ignored { + match self.identity_manager.managed_identity_mut(&owner_id) { + Some(managed) => { + managed.apply_ignored_sender(sender_id); + } + None => tracing::warn!( + owner = %owner_id, + "skipping ignored sender during apply: owner identity not in wallet" + ), + } + } + for (owner_id, sender_id) in unignored { + if let Some(managed) = self.identity_manager.managed_identity_mut(&owner_id) { + managed.apply_unignored_sender(&sender_id); + } + } } // 3b. DashPay profile/payment overlays. Applied AFTER identities @@ -700,56 +797,6 @@ mod tests { } } - /// TC-3692-025: a `ContactChangeSet` carrying `ignored` / `unignored` - /// replayed through `apply_changeset` must reach the owner's - /// ignored-sender set. Guards against `apply_contacts_and_keys` - /// destructuring `contacts` with `..` — which compiles clean but - /// silently drops per-sender mute state (dev-plan risk #1). - #[test] - fn apply_contacts_threads_ignored_and_unignored_through() { - let mut wallet = build_test_wallet(); - let mut info = empty_info(&wallet); - - // Register the owning identity so contact routing has a target. - let owner = Identifier::from([1u8; 32]); - let sender = Identifier::from([2u8; 32]); - let mut managed = ManagedIdentity::new(make_test_identity(1, 0), 0); - managed.wallet_id = Some([9u8; 32]); - let mut id_cs = IdentityChangeSet::default(); - id_cs - .identities - .insert(owner, IdentityEntry::from_managed(&managed)); - let mut cs = PlatformWalletChangeSet::default(); - cs.identities = Some(id_cs); - info.apply_changeset(&mut wallet, cs).expect("seed owner"); - - // Replay an `ignored` delta — the sender must land in the mute set. - let mut contact_cs = ContactChangeSet::default(); - contact_cs.ignored.insert((owner, sender)); - info.apply_changeset(&mut wallet, wrap_contacts(contact_cs)) - .expect("apply ignored"); - assert!(info - .identity_manager - .managed_identity(&owner) - .expect("owner present") - .dashpay() - .ignored_senders() - .contains(&sender)); - - // Replay an `unignored` delta for the same pair — it must clear. - let mut contact_cs = ContactChangeSet::default(); - contact_cs.unignored.insert((owner, sender)); - info.apply_changeset(&mut wallet, wrap_contacts(contact_cs)) - .expect("apply unignored"); - assert!(!info - .identity_manager - .managed_identity(&owner) - .expect("owner present") - .dashpay() - .ignored_senders() - .contains(&sender)); - } - /// Wallet id used by every wallet-owned round-trip test below. /// Same value on A and B so the manager's two-bucket lookup hits /// the same slot on both sides. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs index 35f79e8ac37..da15c81cd8d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/top_up_from_addresses.rs @@ -76,12 +76,10 @@ impl IdentityWallet { .top_up_from_addresses(&self.sdk, inputs, address_signer, settings) .await .map_err(|e| { - crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to top up identity from addresses: {}", - e - )) - }) + PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity from addresses: {}", + e + )) })?; // Update the identity's balance in the local manager and diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 6c2001bc015..35c95eca81d 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,9 +15,10 @@ //! [`IdentityManager::apply_identity_key_entry`]. use super::{IdentityLocation, IdentityManager}; -use crate::changeset::{ContactChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet}; +use crate::changeset::{IdentityEntry, IdentityKeyEntry}; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, KeyID}; use dpp::prelude::Identifier; @@ -175,10 +176,16 @@ impl IdentityManager { /// keys changeset was persisted without its scalar sibling, or the /// owner was removed since), the entry is logged and skipped. pub(crate) fn apply_identity_key_entry(&mut self, entry: IdentityKeyEntry, _network: Network) { + // `add_public_key` lives on `IdentityFactory` / V0 setter trait; + // bring it into scope here. + use dpp::identity::accessors::IdentitySettersV0; + if let Some(managed) = self.locate_mut(&entry.identity_id) { // Insert into the DPP `Identity`'s `public_keys` map by id; // replay-safe (idempotent overwrite). - managed.identity.add_public_key(entry.public_key); + let mut keys = managed.identity.public_keys().clone(); + keys.insert(entry.public_key.id(), entry.public_key.clone()); + managed.identity.set_public_keys(keys); } else { tracing::warn!( identity = %entry.identity_id, @@ -196,91 +203,4 @@ impl IdentityManager { managed.identity.public_keys_mut().remove(&key_id); } } - - /// Layer a [`ContactChangeSet`] + [`IdentityKeysChangeSet`] onto the - /// already-restored managed identities, for the runtime - /// changeset-replay path - /// ([`apply_changeset`](crate::wallet::PlatformWalletInfo::apply_changeset)). - /// Identity keys are applied first so a contact entry never lands - /// before its owner's keys; orphan entries (owner not in the - /// wallet) are logged and skipped, never fatal. `removed_*` and - /// `ignored`/`unignored` are honoured. - 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, - ignored, - unignored, - } = contacts; - for (key, entry) in sent_requests { - match self.managed_identity_mut(&key.owner_id) { - Some(managed) => managed.apply_sent_contact_request(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.apply_incoming_contact_request(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.apply_removed_sent(&key.recipient_id); - } - } - for key in removed_incoming { - if let Some(managed) = self.managed_identity_mut(&key.owner_id) { - managed.apply_removed_incoming(&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" - ), - } - } - // `ignored` is applied before `unignored` so a same-delta - // un-ignore wins (last-write-wins). Orphan owners are logged - // and skipped; un-ignore of an absent owner is a silent no-op. - for (owner_id, sender_id) in ignored { - match self.managed_identity_mut(&owner_id) { - Some(managed) => managed.apply_ignored_sender(sender_id), - None => tracing::warn!( - owner = %owner_id, - "skipping ignored sender: owner identity not in wallet" - ), - } - } - for (owner_id, sender_id) in unignored { - if let Some(managed) = self.managed_identity_mut(&owner_id) { - managed.apply_unignored_sender(&sender_id); - } - } - } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 2e12993f4c5..3b1e1ab2740 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1195,7 +1195,6 @@ impl PlatformWallet { let ClientStartState { mut platform_addresses, wallets: _, - skipped: _, #[cfg(feature = "shielded")] shielded: _, } = self.load_persisted()?; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 0c0b6020d41..9fe357f431e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -556,8 +556,7 @@ pub async fn shield, P: OrchardPr format_addresses_with_info(rich.addresses_with_info(), network), )) } else { - crate::error::promote_address_nonce_error(e) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) } }; @@ -2336,8 +2335,7 @@ async fn broadcast_shielded_spend( match state_transition.broadcast(sdk, None).await { Ok(()) => {} Err(e) if broadcast_definitely_failed(&e) => { - return Err(crate::error::promote_address_nonce_error(&e) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); + return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } Err(e) => { warn!( @@ -2445,8 +2443,7 @@ fn classify_spend_wait_failure( wait_err: &dash_sdk::Error, ) -> PlatformWalletError { if carries_consensus_rejection(wait_err) { - crate::error::promote_address_nonce_error(wait_err) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) + PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string()) } else { warn!( operation, diff --git a/packages/rs-platform-wallet/tests/rehydration_load.rs b/packages/rs-platform-wallet/tests/rehydration_load.rs deleted file mode 100644 index 000d17a31e5..00000000000 --- a/packages/rs-platform-wallet/tests/rehydration_load.rs +++ /dev/null @@ -1,1004 +0,0 @@ -//! End-to-end coverage of `PlatformWalletManager::load_from_persistor`, the -//! seedless / watch-only load path, through a real `PlatformWalletManager`. -//! -//! Load reconstructs every persisted wallet **watch-only** from its keyless -//! account manifest. The load path never touches the seed, so it performs no -//! wrong-seed check; wrong-seed validation lives in the resolver-backed signing -//! entrypoints, not here. Per-row decode failures surface as -//! [`SkipReason::CorruptPersistedRow`] without aborting the batch. -//! -//! RT cases here: -//! - RT-WO: round-trip — watch-only wallet is registered after reload. -//! - RT-Corrupt: a row with an empty manifest is skipped with -//! `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). -//! - RT-Snapshot: a carried `wallet_info` snapshot is consumed -//! verbatim — per-account UTXO attribution and derived-but-unused -//! deep pool addresses survive the reload; a snapshot whose -//! `wallet_id` mismatches its row is skipped as corrupt. - -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; - -use dpp::prelude::Identifier; -use key_wallet::wallet::initialization::WalletAccountCreationOptions; -use key_wallet::wallet::Wallet; -use platform_wallet::changeset::{ - AccountRegistrationEntry, ClientStartState, ClientWalletStartState, IdentityManagerStartState, - PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, -}; -use platform_wallet::error::PlatformWalletError; -use platform_wallet::events::{EventHandler, PlatformEventHandler}; -use platform_wallet::manager::load_outcome::CorruptKind; -use platform_wallet::wallet::platform_wallet::WalletId; -use platform_wallet::ManagedIdentity; -use platform_wallet::{LoadOutcome, PlatformWalletManager, SkipReason}; - -// ---- test doubles ---- - -/// 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(), - wallet_info: w.wallet_info.clone(), - identity_manager: Default::default(), - unused_asset_locks: Default::default(), - }, - ); - } - out.skipped = s.skipped.clone(); - Ok(out) - } - } - } -} - -/// 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 { - 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 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, - wallet_info: Box::new(info), - identity_manager: Default::default(), - unused_asset_locks: 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].as_slice()); - 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-Idem: a second `load_from_persistor` with the wallet already -/// registered (a repeat restore, or a wallet created at runtime) must be -/// idempotent — never a hard error. The already-present wallet is -/// reported as an `AlreadyRegistered` skip (not a fresh load), so the -/// caller can tell it apart from one this pass genuinely loaded. -#[tokio::test] -async fn rt_idempotent_repeat_restore() { - let seed = [0x55; 64]; - 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].as_slice()); - assert!(first.skipped().is_empty()); - assert!( - matches!(first, LoadOutcome::Loaded { .. }), - "a clean first load is the Loaded variant" - ); - - // Second load: the wallet is already registered. Must NOT hard-error; - // the row is reported as an AlreadyRegistered skip, not freshly loaded. - let second = mgr - .load_from_persistor() - .await - .expect("repeat load must be idempotent, not a hard error"); - assert!( - !second.loaded().contains(&id), - "an already-registered wallet is not reported as freshly loaded" - ); - assert_eq!( - second.skipped(), - [(id, SkipReason::AlreadyRegistered)].as_slice(), - "the already-present wallet surfaces as an AlreadyRegistered skip" - ); - assert!( - matches!(second, LoadOutcome::NoneUsable { .. }), - "nothing freshly loaded on the repeat pass" - ); - assert!( - mgr.get_wallet(&id).await.is_some(), - "wallet still present after the repeat load" - ); - 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`; `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]; - 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, 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); - 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, mut corrupt) = slice(seed); - - // Corrupt row to force a skip and inspect every public surface. - corrupt.account_manifest = Vec::new(); - 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))); - } -} - -/// RT-Snapshot: a carried `wallet_info` snapshot is consumed -/// verbatim. Two properties the projection replay could NOT provide: -/// - per-account UTXO attribution — a CoinJoin-account UTXO stays on the -/// CoinJoin account (the fallback path routed every UTXO to the first -/// 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.wallet_info = 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].as_slice()); - 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 `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; - - 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.wallet_info = 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::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.wallet_info = 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.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); - let wallet_other = Wallet::from_seed_bytes( - seed_other, - key_wallet::Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); - s_bad.wallet_info = 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].as_slice(), - "only the healthy row loads" - ); - assert_eq!(outcome.skipped().len(), 1); - let (skipped_id, reason) = &outcome.skipped()[0]; - assert_eq!(*skipped_id, id_bad); - assert!(matches!( - reason, - 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:?}"), - } -} - -/// RT-EmptyFirstRun: a genuinely fresh install — the persister returns -/// `ClientStartState::default()` with no rows at all — loads cleanly as -/// `LoadOutcome::Loaded` with empty loaded/skipped and fires zero skip -/// handlers. This is the condition every first launch hits. -#[tokio::test] -async fn rt_empty_first_run_is_clean_with_no_handler_calls() { - // `FixedLoadPersister::new()` without `set()` returns the default - // (empty) ClientStartState from `load()`. - let p = Arc::new(FixedLoadPersister::new()); - let h = Arc::new(RecordingHandler::default()); - - let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; - let outcome = mgr - .load_from_persistor() - .await - .expect("an empty store must load cleanly"); - - assert!( - matches!(outcome, LoadOutcome::Loaded { .. }), - "an empty store is a full (Loaded) outcome, not partial/none-usable" - ); - assert!( - outcome.loaded().is_empty(), - "nothing to load on a fresh install" - ); - assert!( - outcome.skipped().is_empty(), - "nothing to skip on a fresh install" - ); - assert!(mgr.wallet_ids().await.is_empty(), "no wallets registered"); - assert!( - h.skipped.lock().unwrap().is_empty(), - "no skip handler fires on a fresh install" - ); -} - -/// RT-Concurrent: two concurrent `load_from_persistor` passes against the -/// same manager and multi-wallet snapshot. Both must complete without -/// error, every wallet must register exactly once (the idempotency check -/// releases its read lock before the write-lock insert — a check-then-act -/// window), and the outcomes must be consistent: each wallet is freshly -/// loaded by exactly one pass and reported `AlreadyRegistered` by the -/// other (never a corruption skip, never a hard error). -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn rt_concurrent_loads_register_each_wallet_exactly_once() { - let p = Arc::new(FixedLoadPersister::new()); - let h = Arc::new(RecordingHandler::default()); - let (id1, s1) = slice([0xC1; 64]); - let (id2, s2) = slice([0xC2; 64]); - let mut st = ClientStartState::default(); - st.wallets.insert(id1, s1); - st.wallets.insert(id2, s2); - p.set(st); - - let mgr = manager(Arc::clone(&p), Arc::clone(&h)).await; - let m1 = Arc::clone(&mgr); - let m2 = Arc::clone(&mgr); - let t1 = tokio::spawn(async move { m1.load_from_persistor().await }); - let t2 = tokio::spawn(async move { m2.load_from_persistor().await }); - let o1 = t1.await.unwrap().expect("first concurrent load Ok"); - let o2 = t2.await.unwrap().expect("second concurrent load Ok"); - - // Each wallet registered exactly once, in both maps. - assert!(mgr.get_wallet(&id1).await.is_some()); - assert!(mgr.get_wallet(&id2).await.is_some()); - assert_eq!( - mgr.wallet_ids().await.len(), - 2, - "each wallet registered exactly once despite the concurrent passes" - ); - - // Every wallet is freshly loaded by exactly one of the two passes. - let mut loaded_union: std::collections::BTreeSet = - o1.loaded().iter().copied().collect(); - for id in o2.loaded() { - assert!( - loaded_union.insert(*id), - "a wallet must be freshly loaded by at most one pass" - ); - } - assert!( - loaded_union.contains(&id1) && loaded_union.contains(&id2), - "both wallets loaded across the two passes" - ); - - // Any overlap surfaces as an AlreadyRegistered skip, never corruption. - for outcome in [&o1, &o2] { - for (_, reason) in outcome.skipped() { - assert_eq!( - *reason, - SkipReason::AlreadyRegistered, - "a concurrent overlap is an AlreadyRegistered skip, not a corrupt row" - ); - } - } -} - -/// Persister for TC-3692-011. Rebuilds — fresh on each `load()`, since the -/// identity-manager snapshot is intentionally not `Clone` — a -/// `ClientStartState` carrying one wallet-owned identity whose -/// `Identity.public_keys` already holds a CRITICAL / AUTHENTICATION / -/// ECDSA_SECP256K1 key, the shape the FFI/iOS path produces via -/// `build_wallet_identity_bucket` (never through an `IdentityKeysChangeSet`). -struct IdentityKeyedLoadPersister { - seed: [u8; 64], - identity_id: Identifier, -} - -impl PlatformWalletPersistence for IdentityKeyedLoadPersister { - fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { - Ok(()) - } - fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::identity_public_key::{IdentityPublicKey, Purpose}; - use dpp::identity::v0::IdentityV0; - use dpp::identity::{Identity, KeyType, SecurityLevel}; - - let (id, mut s) = slice(self.seed); - - let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: 0, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::CRITICAL, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: dpp::platform_value::BinaryData::new(vec![2u8; 33]), - disabled_at: None, - }); - let mut public_keys = BTreeMap::new(); - public_keys.insert(0, key); - let identity = Identity::V0(IdentityV0 { - id: self.identity_id, - public_keys, - balance: 0, - revision: 0, - }); - - let mut inner = BTreeMap::new(); - inner.insert(0u32, ManagedIdentity::new(identity, 0)); - let mut ims = IdentityManagerStartState::default(); - ims.wallet_identities.insert(id, inner); - s.identity_manager = ims; - - let mut st = ClientStartState::default(); - st.wallets.insert(id, s); - Ok(st) - } -} - -/// TC-3692-011 [CRITICAL]: the restored identity's signing key is -/// selectable via the exact `get_first_public_key_matching` predicate the -/// real signing path uses (`contract.rs`, `dpns.rs`, ...) immediately -/// after `load_from_persistor`, with zero sync. The FFI/iOS path populates -/// `Identity.public_keys` at construction, so dropping -/// `ClientWalletStartState.identity_keys` must not change this — the key -/// must sit in the exact map slot the lookup reads, with no reliance on -/// `apply_identity_key_entry` ever running on the load path. -#[tokio::test] -async fn rt_signing_key_selectable_immediately_after_load() { - use dpp::identity::accessors::IdentityGettersV0; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dpp::identity::identity_public_key::Purpose; - use dpp::identity::{KeyType, SecurityLevel}; - - let seed = [0x5A; 64]; - let identity_id = Identifier::from([0x11; 32]); - let p = Arc::new(IdentityKeyedLoadPersister { seed, identity_id }); - let h = Arc::new(RecordingHandler::default()); - let sdk = Arc::new(dash_sdk::Sdk::new_mock()); - let mgr = Arc::new(PlatformWalletManager::new(sdk, Arc::clone(&p), h)); - - let outcome = mgr.load_from_persistor().await.expect("Ok"); - assert!( - outcome.skipped().is_empty(), - "identity row must not be skipped" - ); - let id = outcome - .loaded() - .first() - .copied() - .expect("exactly one wallet loaded"); - - // Immediately — no SPV sync, no identity refresh — drive the exact - // predicate a real signing flow uses to pick its authentication key. - let wallet = mgr.get_wallet(&id).await.expect("wallet registered"); - let wm = wallet.wallet_manager().read().await; - let info = wm.get_wallet_info(&id).expect("wallet info present"); - let managed = info - .identity_manager - .identity(&identity_id) - .expect("identity restored into the manager"); - let selected = managed - .identity - .get_first_public_key_matching( - Purpose::AUTHENTICATION, - [SecurityLevel::CRITICAL].into(), - [KeyType::ECDSA_SECP256K1].into(), - false, - ) - .expect("CRITICAL/AUTH/ECDSA signing key must be selectable post-load, zero sync"); - assert_eq!(selected.id(), 0, "the exact installed key is selected"); -} From 4e8a1222685754f5bcab5b652bb90f043cdd509e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:10:07 +0000 Subject: [PATCH 092/108] fix(platform-wallet-storage): pre-materialization size gate on outpoint + txid rehydration reads (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the file's read-before-cap BLOB discipline to two fixed-reference columns that were materialized before any length check: - asset_locks `outpoint` (load_state / load_unconsumed / list_active): the bincode-varint outpoint blob is now gated with `blob::check_size` before `row.get`, matching the sibling `lifecycle_blob` gate and core_state's unspent-UTXO reader. A tampered oversize value is rejected before the Vec is allocated instead of after. - core_instant_locks `txid` (load_state): a raw 32-byte hash, now gated with `blob::check_fixed_width(.., 32, ..)` — an oversize column raises BlobTooLarge ahead of the alloc rather than materializing then failing in Txid::from_slice. Mirrors the platform_addresses fixed-width gate. Adds a RED-first repro (`load_state_rejects_oversize_instant_lock_txid`): without the gate an oversize txid surfaces as BlobDecode after a multi-MiB allocation; with it, BlobTooLarge before the alloc. Co-Authored-By: Claude Sonnet 5 --- .../src/sqlite/schema/asset_locks.rs | 39 ++++++++-------- .../src/sqlite/schema/core_state.rs | 46 +++++++++++++++++-- 2 files changed, 62 insertions(+), 23 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index 297a2e3f388..1687cf54760 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -158,17 +158,18 @@ pub fn load_state( wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out: AssetLocksByAccount = BTreeMap::new(); while let Some(row) = rows.next()? { - let op_bytes: Vec = row.get(0)?; - let account_index: i64 = row.get(1)?; - blob::check_size(row.get::<_, i64>(2)?)?; - let blob_bytes: Vec = row.get(3)?; - let status: String = row.get(4)?; + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let account_index: i64 = row.get(2)?; + blob::check_size(row.get::<_, i64>(3)?)?; + let blob_bytes: Vec = row.get(4)?; + let status: String = row.get(5)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } @@ -186,17 +187,18 @@ pub fn load_unconsumed( wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1 AND status NOT IN ('consumed')", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out: AssetLocksByAccount = BTreeMap::new(); while let Some(row) = rows.next()? { - let op_bytes: Vec = row.get(0)?; - let account_index: i64 = row.get(1)?; - blob::check_size(row.get::<_, i64>(2)?)?; - let blob_bytes: Vec = row.get(3)?; - let status: String = row.get(4)?; + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let account_index: i64 = row.get(2)?; + blob::check_size(row.get::<_, i64>(3)?)?; + let blob_bytes: Vec = row.get(4)?; + let status: String = row.get(5)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } @@ -213,17 +215,18 @@ pub fn list_active( wallet_id: &WalletId, ) -> Result { let mut stmt = conn.prepare( - "SELECT outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status \ FROM asset_locks WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut out: AssetLocksByAccount = BTreeMap::new(); while let Some(row) = rows.next()? { - let op_bytes: Vec = row.get(0)?; - let account_index: i64 = row.get(1)?; - blob::check_size(row.get::<_, i64>(2)?)?; - let blob_bytes: Vec = row.get(3)?; - let status: String = row.get(4)?; + blob::check_size(row.get::<_, i64>(0)?)?; + let op_bytes: Vec = row.get(1)?; + let account_index: i64 = row.get(2)?; + blob::check_size(row.get::<_, i64>(3)?)?; + let blob_bytes: Vec = row.get(4)?; + let status: String = row.get(5)?; let (acct, outpoint, tracked) = decode_row(&op_bytes, account_index, &blob_bytes, &status)?; out.entry(acct).or_default().insert(outpoint, tracked); } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index e6fae4bdd04..0583e9f7245 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -370,17 +370,21 @@ pub fn load_state( } { - // Same pre-read length gate as `record_blob` above. + // Same pre-read length gate as `record_blob` above. `txid` is a raw + // 32-byte hash, so its width is gated fixed before materializing — + // an oversize column raises `BlobTooLarge` ahead of the `Vec` alloc + // rather than materializing then failing in `Txid::from_slice`. let mut stmt = conn.prepare( - "SELECT txid, length(islock_blob), islock_blob \ + "SELECT length(txid), txid, length(islock_blob), islock_blob \ FROM core_instant_locks WHERE wallet_id = ?1", )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; while let Some(row) = rows.next()? { use dashcore::hashes::Hash; - let txid_bytes: Vec = row.get(0)?; - blob::check_size(row.get::<_, i64>(1)?)?; - let blob_bytes: Vec = row.get(2)?; + blob::check_fixed_width(row.get::<_, i64>(0)?, 32, "core_instant_locks.txid")?; + let txid_bytes: Vec = row.get(1)?; + blob::check_size(row.get::<_, i64>(2)?)?; + let blob_bytes: Vec = row.get(3)?; let txid = dashcore::Txid::from_slice(&txid_bytes) .map_err(|_| WalletStorageError::blob_decode("core_instant_locks.txid"))?; let islock: dashcore::ephemerealdata::instant_lock::InstantLock = @@ -566,6 +570,38 @@ mod tests { } } + /// A tampered `core_instant_locks.txid` that overflows the blob cap must + /// raise `BlobTooLarge` from the fixed-width gate BEFORE the oversize `Vec` + /// is materialized — not `BlobDecode` after `Txid::from_slice` on a + /// multi-megabyte allocation. + #[test] + fn load_state_rejects_oversize_instant_lock_txid() { + let mut conn = rusqlite::Connection::open_in_memory().unwrap(); + crate::sqlite::migrations::run(&mut conn).unwrap(); + let w = [0xABu8; 32]; + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![&w[..]], + ) + .unwrap(); + + // Plant a txid one byte past the 16 MiB cap; islock_blob content is + // irrelevant — the txid gate fires before it is read. + let oversize_txid = vec![0u8; crate::SIZE_LIMIT_BYTES + 1]; + conn.execute( + "INSERT INTO core_instant_locks (wallet_id, txid, islock_blob) VALUES (?1, ?2, ?3)", + params![&w[..], oversize_txid.as_slice(), &[0u8; 4][..]], + ) + .unwrap(); + + let err = load_state(&conn, &w, dashcore::Network::Testnet) + .expect_err("load_state must reject an oversize instant-lock txid"); + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "expected BlobTooLarge from the pre-materialization gate, got {err:?}" + ); + } + #[test] fn chain_lock_height_rejects_trailing_bytes() { let bytes = encode_chain_lock(&sample_chain_lock(100_000)).expect("encode"); From a9c500f869cd43ba7fde8f29bed4cdc629d08166 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:10:26 +0000 Subject: [PATCH 093/108] style(platform-wallet-storage): fix doc_lazy_continuation in rehydration rustdoc A leading `+` in a doc-list continuation line was parsed as a nested markdown list marker, tripping `clippy::doc_lazy_continuation` under the pinned 1.92 toolchain. Reword `+ wallet totals` to `and wallet totals` (semantically identical) to clear the lint at its root. Co-Authored-By: Claude Sonnet 5 --- packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index 988194af527..8eb47eb71e1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -71,7 +71,7 @@ pub(crate) fn build_wallet( /// /// - **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 +/// and 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 From f503797c28c2eabe31f414b5bb198cc295fed44e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:24:32 +0000 Subject: [PATCH 094/108] fix(platform-wallet-storage): move RUSTSEC-2025-0141 ack to root audit.toml (#3968 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rustsec/audit-check runs from the repo root and only reads the root .cargo/audit.toml — a crate-local audit.toml is never consulted by CI, so the existing ignore in packages/rs-platform-wallet-storage/.cargo/audit.toml did nothing to suppress the nightly security-audit-rust.yml workflow. Move the RUSTSEC-2025-0141 (bincode unmaintained) acknowledgement, with its full rationale, to the root config where CI actually reads it. Delete the now-redundant crate-local file rather than leave a dead config that looks authoritative but isn't. Co-Authored-By: Claude Opus 4.8 --- .cargo/audit.toml | 27 ++++++++++++++++++- .../.cargo/audit.toml | 26 ------------------ 2 files changed, 26 insertions(+), 27 deletions(-) delete mode 100644 packages/rs-platform-wallet-storage/.cargo/audit.toml diff --git a/.cargo/audit.toml b/.cargo/audit.toml index c922c98cbf8..7ebaa9b62ab 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -2,4 +2,29 @@ # Advisory IDs to ignore, e.g. ["RUSTSEC-2019-0001", ...]. Each entry # must point at a live advisory in the resolved graph and carry a dated # rationale; an entry matching nothing trains reviewers to skim the list. -ignore = [] +# +# rustsec/audit-check runs from the repo root (.github/workflows/security-audit-rust.yml) +# and only reads THIS file — a crate-local .cargo/audit.toml is never consulted by CI. +ignore = [ + # RUSTSEC-2025-0141 — bincode is unmaintained (informational advisory, + # not an exploitable CVE; published 2025-12-16, no patched release). + # Acknowledged 2026-06-08. + # + # Why this is acceptable for now: + # - bincode 2.0.1 is the BLOB-codec trust boundary for every + # persisted column and backup in rs-platform-wallet-storage. The + # known risk class for an unmaintained deserializer is unbounded + # allocation (OOM) on a crafted/corrupt input. + # - In-crate size bounds defang that class: MAX_VALUE_LEN / + # BLOB_SIZE_LIMIT_BYTES (kv/blob), the per-secret SecretTooLarge + # write cap, and the MAX_VAULT_SIZE_BYTES read ceiling all reject + # oversized inputs before bincode ever allocates from them. + # - load() is fail-hard: a malformed/over-large row aborts the call + # with a typed error rather than silently over-allocating. + # + # Residual risk: a future bincode defect would go unpatched upstream. + # Remediation plan: migrate the BLOB codec to a maintained equivalent + # (postcard / bitcode candidates) once the wire format is frozen at + # release; revisit this ignore at that time. + "RUSTSEC-2025-0141", +] diff --git a/packages/rs-platform-wallet-storage/.cargo/audit.toml b/packages/rs-platform-wallet-storage/.cargo/audit.toml deleted file mode 100644 index 6e07a8c19dd..00000000000 --- a/packages/rs-platform-wallet-storage/.cargo/audit.toml +++ /dev/null @@ -1,26 +0,0 @@ -[advisories] -# Each entry MUST point at a live advisory in this crate's resolved graph -# and carry a dated rationale + remediation plan. Do not blanket-ignore. -ignore = [ - # RUSTSEC-2025-0141 — bincode is unmaintained (informational advisory, - # not an exploitable CVE; published 2025-12-16, no patched release). - # Acknowledged 2026-06-08. - # - # Why this is acceptable for now: - # - bincode 2.0.1 is the BLOB-codec trust boundary for every - # persisted column and backup. The known risk class for an - # unmaintained deserializer is unbounded allocation (OOM) on a - # crafted/corrupt input. - # - In-crate size bounds defang that class: MAX_VALUE_LEN / - # BLOB_SIZE_LIMIT_BYTES (kv/blob), the per-secret SecretTooLarge - # write cap, and the MAX_VAULT_SIZE_BYTES read ceiling all reject - # oversized inputs before bincode ever allocates from them. - # - load() is fail-hard: a malformed/over-large row aborts the call - # with a typed error rather than silently over-allocating. - # - # Residual risk: a future bincode defect would go unpatched upstream. - # Remediation plan: migrate the BLOB codec to a maintained equivalent - # (postcard / bitcode candidates) once the wire format is frozen at - # release; revisit this ignore at that time. - "RUSTSEC-2025-0141", -] From 4ce099e36cdc75b1c12e378eb5e194aec8b09011 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:56:15 +0000 Subject: [PATCH 095/108] style(platform-wallet-storage): satisfy rustfmt in error.rs + wallet.rs tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the RehydrationPoolMismatch #[error(...)] attribute onto one line, alphabetize the platform_wallet import in rehydration_used_state_survives_spent_utxo, and wrap the over-width assert_eq! in the missing-account test — the three diffs cargo fmt --check flagged on the macOS CI job. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-storage/src/sqlite/error.rs | 4 +--- .../rs-platform-wallet-storage/src/sqlite/util/wallet.rs | 7 +++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index e585793989c..b444c871b47 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -316,9 +316,7 @@ pub enum WalletStorageError { /// address pools 1:1 (`probes.len() != pools.len()`) — a structural /// invariant break, not user-reachable. Fail-closed rather than apply a /// probe's discovered depth to the wrong pool by position. - #[error( - "rehydration pool count mismatch: expected {expected} probe pool(s), found {found}" - )] + #[error("rehydration pool count mismatch: expected {expected} probe pool(s), found {found}")] RehydrationPoolMismatch { expected: usize, found: usize }, /// Rehydration's discovery probes mirror the real account's pools by diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index 8eb47eb71e1..7daf7423fd8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -1144,7 +1144,6 @@ mod tests { /// (idx 30), and asserts the empty-snapshot baseline does NOT mark them. #[test] fn rehydration_used_state_survives_spent_utxo() { - use platform_wallet::changeset::CoreChangeSet; use dashcore::blockdata::transaction::txout::TxOut; use dashcore::{OutPoint, Txid}; use key_wallet::bip32::DerivationPath; @@ -1153,6 +1152,7 @@ mod tests { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::{Address, Utxo}; + use platform_wallet::changeset::CoreChangeSet; let wallet = Wallet::from_seed_bytes( [42u8; 64], @@ -1575,7 +1575,10 @@ mod tests { .expect_err("must fail closed when no funds account can hold the UTXOs"); match err { WalletStorageError::MissingAccount { wallet_id: id } => { - assert_eq!(id, wallet_info.wallet_id, "wallet_id must match the rehydrated wallet"); + assert_eq!( + id, wallet_info.wallet_id, + "wallet_id must match the rehydrated wallet" + ); } other => panic!("expected MissingAccount, got {other:?}"), } From 362988409bac552bca759b4e6b5f05e9a78e14b9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:15:57 +0000 Subject: [PATCH 096/108] fix(platform-wallet-storage): compile rehydration-apply test target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence move relocated apply_persisted_core_state into this crate as a pub(super) helper, but the integration test still called the vanished platform_wallet::manager::rehydrate:: path — a compile error only under --all-features (the macOS coverage job), invisible to default-feature clippy. Re-export the helper publicly behind the rehydration-apply feature (kept crate-private in normal builds), point the two test call sites at the new platform_wallet_storage::sqlite::util path, and correct the now-inaccurate "manager-apply leg" comments — the function no longer lives in the manager. Co-Authored-By: Claude Opus 4.8 --- .../src/sqlite/util/mod.rs | 6 + .../tests/sqlite_core_state_reader.rs | 16 +- .../src/manager/load_outcome.rs | 146 ------------------ 3 files changed, 14 insertions(+), 154 deletions(-) delete mode 100644 packages/rs-platform-wallet/src/manager/load_outcome.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs index 8859fd21dcb..870174b50f1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/mod.rs @@ -3,3 +3,9 @@ pub mod permissions; pub mod safe_cast; pub(super) mod wallet; + +/// Integration-test access to the crate-internal core-state reconstruction +/// helper. Gated so the normal build keeps `apply_persisted_core_state` +/// private to the crate. +#[cfg(feature = "rehydration-apply")] +pub use wallet::apply_persisted_core_state; diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index a25cf6f9777..05cbee6e05f 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -114,15 +114,15 @@ fn rt2_nonzero_balance_survives_reopen() { assert_eq!(core.last_processed_height, Some(200)); assert_eq!(core.synced_height, Some(200)); - // End-to-end: apply onto a freshly minted skeleton (the manager's - // rehydration path) and assert the wallet balance is the persisted - // amount — NOT a silent zero. The manager-apply leg drives #3692's - // `apply_persisted_core_state`, gated behind `rehydration-apply`; the - // storage `load_state` assertions above run standalone regardless. + // End-to-end: apply the loaded state onto a freshly minted skeleton and + // assert the wallet balance is the persisted amount — NOT a silent zero. + // The apply leg drives `apply_persisted_core_state`, gated behind + // `rehydration-apply`; the storage `load_state` assertions above run + // standalone regardless. #[cfg(feature = "rehydration-apply")] { let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); - platform_wallet::manager::rehydrate::apply_persisted_core_state( + platform_wallet_storage::sqlite::util::apply_persisted_core_state( &mut info, &manifest_for(&wallet), &core, @@ -286,13 +286,13 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { drop(conn); assert_eq!(core.new_utxos.len(), 1); - // Manager-apply leg (#3692 `apply_persisted_core_state`) gated behind + // Apply leg (`apply_persisted_core_state`) gated behind // `rehydration-apply`; the storage `load_state` assertions above run // standalone regardless. #[cfg(feature = "rehydration-apply")] { let mut info = ManagedWalletInfo::from_wallet(&wallet, 1); - platform_wallet::manager::rehydrate::apply_persisted_core_state( + platform_wallet_storage::sqlite::util::apply_persisted_core_state( &mut info, &manifest_for(&wallet), &core, diff --git a/packages/rs-platform-wallet/src/manager/load_outcome.rs b/packages/rs-platform-wallet/src/manager/load_outcome.rs deleted file mode 100644 index 6c284544e72..00000000000 --- a/packages/rs-platform-wallet/src/manager/load_outcome.rs +++ /dev/null @@ -1,146 +0,0 @@ -//! 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 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 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 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`]. -/// -/// 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)] -#[non_exhaustive] -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, - /// 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. - 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::SnapshotIdentityMismatch => { - f.write_str("snapshot does not match its persisted row") - } - Self::DecodeError(s) => write!(f, "decode error: {s}"), - } - } -} - -/// Aggregate, synchronous view of one -/// [`load_from_persistor`](super::PlatformWalletManager::load_from_persistor) -/// pass that did not hard-fail. -/// -/// 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. -/// -/// Inspect the outcome via [`loaded`](Self::loaded) / [`skipped`](Self::skipped): -/// the skip reasons are what separate a harmless repeat -/// ([`AlreadyRegistered`](SkipReason::AlreadyRegistered)) from genuine -/// corruption ([`CorruptPersistedRow`](SkipReason::CorruptPersistedRow)) — a -/// distinction a flat `Result` shape would erase. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum LoadOutcome { - /// Full success: every persisted wallet was reconstructed and - /// registered. Also the empty-store first run — `loaded` is then - /// empty and nothing was skipped. - Loaded { - /// Wallets reconstructed and registered, in load order. - loaded: Vec, - }, - /// Some wallets loaded, at least one was skipped (a corrupt row, or - /// one already registered). The batch did not abort. - Partial { - /// Wallets reconstructed and registered, in load order. - loaded: Vec, - /// Wallets skipped, in load order. - skipped: Vec<(WalletId, SkipReason)>, - }, - /// Nothing usable: the persister returned rows but every one was - /// skipped, so no wallet loaded even though the persister succeeded. - NoneUsable { - /// Wallets skipped, in load order. - skipped: Vec<(WalletId, SkipReason)>, - }, -} - -impl LoadOutcome { - /// Pick the variant matching the `(loaded, skipped)` tallies of a pass. - pub(crate) fn from_parts(loaded: Vec, skipped: Vec<(WalletId, SkipReason)>) -> Self { - match (loaded.is_empty(), skipped.is_empty()) { - (_, true) => Self::Loaded { loaded }, - (true, false) => Self::NoneUsable { skipped }, - (false, false) => Self::Partial { loaded, skipped }, - } - } - - /// Wallets reconstructed and registered this pass, in load order. - pub fn loaded(&self) -> &[WalletId] { - match self { - Self::Loaded { loaded } | Self::Partial { loaded, .. } => loaded, - Self::NoneUsable { .. } => &[], - } - } - - /// Wallets skipped this pass (corrupt row, or already registered). - pub fn skipped(&self) -> &[(WalletId, SkipReason)] { - match self { - Self::Partial { skipped, .. } | Self::NoneUsable { skipped } => skipped, - Self::Loaded { .. } => &[], - } - } -} From dd39ce5077c4a5b76177ebd10df8ed91ba4719a3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:16:11 +0000 Subject: [PATCH 097/108] docs(platform-wallet-storage): correct stale rehydration docs post-move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README claimed the manager-side rebuild was "not yet wired" and "lands in #3692" — false: load_from_persistor reconstructs and registers every persisted wallet today. Rewrite to present state. Update the three Wallet::new_watch_only references (README, wallet_lifecycle comment, Swift loadWalletList doc) to the renamed Wallet::new_external_signable. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet-storage/README.md | 14 +++++++------- .../src/manager/wallet_lifecycle.rs | 2 +- .../PlatformWalletPersistenceHandler.swift | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet-storage/README.md b/packages/rs-platform-wallet-storage/README.md index 88a0522b173..35d2820feec 100644 --- a/packages/rs-platform-wallet-storage/README.md +++ b/packages/rs-platform-wallet-storage/README.md @@ -162,13 +162,13 @@ reconstructed from these per-area readers: | `contacts` | `schema::contacts::load_changeset` | | `identity_keys` | `schema::identity_keys::load_state` | -The payload carries **no** `Wallet` and no key material. On this -storage-only build the **manager-side rebuild is not yet wired**: -`PlatformWalletManager::load_from_persistor` returns a typed error rather -than reconstructing wallets. The keyless rebuild (watch-only via -`Wallet::new_watch_only` from the manifest, then on-demand signing-key -derivation through the `sign_with_mnemonic_resolver` path) lands in #3692. -`load()` itself already reconstructs the full keyless payload. +The persisted payload stores **no** `Wallet` and no key material. `load()` +reconstructs the full keyless payload, rebuilding each wallet +external-signable (`Wallet::new_external_signable` from the manifest) with +on-demand signing-key derivation through the `sign_with_mnemonic_resolver` +path. `PlatformWalletManager::load_from_persistor` then rehydrates the +manager's wallet maps from that payload, reconstructing and registering +every persisted wallet. Loading is **fail-hard**: any row that fails to decode, or a stored `wallet_id` that is not exactly 32 bytes, aborts the whole call with a typed diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index e8a38200715..784d051be3e 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -170,7 +170,7 @@ impl PlatformWalletManager

{ // Snapshot per-account xpubs and address-pool entries BEFORE // the wallet / managed-info are moved into insert_wallet. The // persister sees everything needed to rebuild the wallet - // watch-only (via `Wallet::new_watch_only`) plus populate + // external-signable (via `Wallet::new_external_signable`) plus populate // SwiftData's address table on next launch. let account_specs: Vec<( key_wallet::account::AccountType, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c7f67912379..9893d2f152f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3857,7 +3857,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// A wallet is "restorable" when it has at least one /// `PersistentAccount` row with non-empty /// `accountExtendedPubKeyBytes`. The Rust side reconstructs the - /// watch-only `Wallet` via `Wallet::new_watch_only(network, + /// external-signable `Wallet` via `Wallet::new_external_signable(network, /// wallet_id, accounts)`; accounts come directly from the spec /// array, wallet id from the top-level struct. /// From 79e20d25af8e6fc1c06378fcca3d76cc449aefd0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:23:17 +0000 Subject: [PATCH 098/108] fix(swift-sdk): reconcile PlatformWallet load FFI to shipped 1-arg contract The LoadOutcome feature was removed Rust-side; Swift was the straggler, calling FFI symbols that no longer exist and referencing result codes 22/23 that the Rust `PlatformWalletFFIResultCode` never defines (it stops at ErrorAddressNonceMismatch = 21, then NotFound = 98 / ErrorUnknown = 99). - PlatformWalletManager.swift: `loadFromPersistor()` now calls the real 1-arg `platform_wallet_manager_load_from_persistor(handle)` and checks its returned `PlatformWalletFFIResult`. Dropped the phantom `LoadOutcomeFFI` out-param, `platform_wallet_load_outcome_free`, the skipped-wallet collection loop, the `SkippedWalletOnLoad` struct, and the now-unpopulatable `lastLoadSkippedWallets` published property. - PlatformWalletResult.swift: removed the `errorPersisterLoadTransient` (22) / `errorPersisterLoadFatal` (23) result-code and error cases plus their `PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PERSISTER_LOAD_*` mappings, which map to cbindgen constants that are never generated. Swift is not compilable in this environment; symbols matched by hand against the Rust `#[no_mangle] extern "C"` signatures. CI's Swift build is the compile check. Co-Authored-By: Claude Opus 4.8 --- .../PlatformWalletManager.swift | 84 ++----------------- .../PlatformWallet/PlatformWalletResult.swift | 28 ------- 2 files changed, 5 insertions(+), 107 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index c8982df7377..fac3976959d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -180,13 +180,6 @@ 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. @@ -412,35 +405,6 @@ public class PlatformWalletManager: ObservableObject { // MARK: - Watch-only restore from persister - /// One wallet Rust skipped during `load_from_persistor` because its - /// persisted row was skipped. `reasonCode` is one of the Rust-side - /// `LOAD_SKIP_REASON_*` constants (100 missing manifest, 101 malformed - /// xpub, 102 decode error, 103 snapshot identity mismatch, 199 other - /// corrupt row, 200 other skip, 300 already registered); - /// [`reasonDescription`] renders it for display. - public struct SkippedWalletOnLoad { - public let walletId: Data - public let reasonCode: UInt32 - - /// Human-readable rendering of `reasonCode`, mirroring the Rust - /// `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" - case 300: return "already registered" - default: return "unknown skip reason (\(reasonCode))" - } - } - } - /// Rehydrate wallets from SwiftData on app launch. /// /// Calls `platform_wallet_manager_load_from_persistor` which fires @@ -467,46 +431,7 @@ public class PlatformWalletManager: ObservableObject { public func loadFromPersistor() throws -> [ManagedPlatformWallet] { try ensureConfigured() - // 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) } - - // Collect the ids Rust skipped (a corrupt row, or one already - // registered). These are non-fatal on the Rust side, so they must - // not reach `lastError`: they are surfaced through - // `lastLoadSkippedWallets` and their ids are excluded from the - // per-id restore loop below (a skipped id is still in SwiftData, so - // `get_wallet` would return NotFound for it). - // - // Rust writes `out_outcome` on every path (zeroed on a hard - // failure), so assign `lastLoadSkippedWallets` BEFORE the throwing - // `.check()` — a failed load then clears stale skip state from a - // prior load instead of leaving it behind. - var skippedIds = Set() - var skippedWallets: [SkippedWalletOnLoad] = [] - if let skipped = outcome.skipped { - skippedWallets.reserveCapacity(Int(outcome.skipped_count)) - for i in 0.. Date: Thu, 9 Jul 2026 10:26:21 +0000 Subject: [PATCH 099/108] test(platform-wallet-storage): sync prepare_cached allow-list to gated SQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tc_p1_003_prepare_cached_in_writers` regressed because this PR added `length()` pre-read size gates to SELECTs in two schema files but the `READ_ONLY_PREPARE_ALLOWED` substrings still matched the pre-gate SQL: - asset_locks.rs: all three readers (load_active / load_unconsumed / list_active) now read `SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status`; the old `SELECT outpoint, account_index` substring no longer matched. - core_state.rs: the instant-lock reader gained a `length(txid)` gate, so `SELECT length(txid), txid, length(islock_blob), islock_blob` replaces the stale `SELECT txid, length(islock_blob), islock_blob`. Self-check accuracy only — the gate intent is preserved, no logic change. Co-Authored-By: Claude Opus 4.8 --- .../tests/sqlite_compile_time.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index 93687643b46..fbf7d7403ad 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -37,7 +37,12 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "wallets.rs", "SELECT network, birth_height FROM wallets WHERE wallet_id", ), - ("asset_locks.rs", "SELECT outpoint, account_index"), + // asset_locks readers (load_active / load_unconsumed / list_active) — + // pre-read length() gates on outpoint and lifecycle_blob. + ( + "asset_locks.rs", + "SELECT length(outpoint), outpoint, account_index, length(lifecycle_blob), lifecycle_blob, status", + ), ("platform_addrs.rs", "SELECT account_index, address_index"), // Grouped bulk readers driving `load()` — fixed scans over the whole // table, not per-wallet fan-out. @@ -83,7 +88,7 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ ), ( "core_state.rs", - "SELECT txid, length(islock_blob), islock_blob", + "SELECT length(txid), txid, length(islock_blob), islock_blob", ), ( "core_state.rs", From 258b184280c13976fcbd7d9ef88f55c2a3eb533c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:29:23 +0000 Subject: [PATCH 100/108] docs(platform-wallet-storage): correct stale core-attribution rehydration docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `core_address_pool` table (V003) landed in THIS PR, and the writer now resolves `core_utxos.account_index` against it. Several docs still described the pre-table world: - wallet.rs `apply_persisted_core_state`: dropped the false "attribution is lost at write time (account_index is always 0)" framing and the stale `TODO(#3986)` blocker. The mapping IS persisted; this reader just doesn't read `account_index` back yet — restated as a deliberate present-state follow-up. - core_state.rs `load_state`: `account_index` IS carried by `core_utxos`; corrected the "not carried" claim and split the flag list accordingly. - sqlite_account_zero_attribution.rs: removed the false "no address→account lookup table" / "hardcoded" claims — the table exists; a fresh gap-limit-edge address simply has no pool row, so the writer falls back to account 0, which is exactly what this test pins. Doc-only; no behavior change — the reader still restores present-state balances onto a single funds account, preserving the zero-attribution test contract. Co-Authored-By: Claude Opus 4.8 --- .../src/sqlite/schema/core_state.rs | 10 ++++-- .../src/sqlite/util/wallet.rs | 32 ++++++++++--------- .../tests/sqlite_account_zero_attribution.rs | 21 ++++++------ 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 0583e9f7245..9570ebc1302 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -295,9 +295,13 @@ fn upsert_sync_state( /// /// # Deferred to the first post-load `sync` (safe re-warm) /// -/// - **Per-account UTXO attribution / `is_coinbase` / `is_instantlocked` / -/// `is_trusted` / `used` flags**: not carried by `core_utxos`; defaulted and -/// refreshed on the next scan. The wallet *total* balance is unaffected. +/// - **Per-account UTXO attribution**: `core_utxos.account_index` is persisted +/// (resolved against `core_address_pool` at write time), but this reader does +/// not select it yet, so restored UTXOs are bucketed under a single account +/// and re-attributed on the next scan. Wiring the read-back is a deliberate +/// follow-up. The wallet *total* balance is unaffected. +/// - **`is_coinbase` / `is_instantlocked` / `is_trusted` / `used` flags**: not +/// carried by `core_utxos`; defaulted and refreshed on the next scan. pub fn load_state( conn: &Connection, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index 7daf7423fd8..60a2a17a034 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -100,11 +100,11 @@ pub(crate) fn build_wallet( /// # 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). +/// persisted (resolved against the `core_address_pool` table at write +/// time), but this reader does not consult it yet, so UTXOs are restored +/// against the wallet's first funds-bearing account and re-attributed on +/// the next scan. Wiring the read-back is a deliberate follow-up. 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. @@ -170,16 +170,18 @@ pub fn apply_persisted_core_state( // the next sync (no regression vs. the prior loader); populating them at // load needs an upstream key_wallet change. - // 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. - // TODO(#3986): route each restored UTXO to its true owning account via the - // persisted per-account attribution once PR #3986's core_address_pool table - // lands, instead of collapsing every UTXO onto account 0 here. + // Restore the UTXO set. `core_utxos.account_index` is persisted (resolved + // against the `core_address_pool` table at write time) but this reader does + // not read it back yet, 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. + // TODO: route each restored UTXO to its true owning account by reading back + // the persisted `core_utxos.account_index` (populated from core_address_pool, + // added V003) instead of collapsing every UTXO onto the first funds account. + // Present-state restore only; per-account attribution is a deliberate + // follow-up. let spent_outpoints: std::collections::HashSet = core.spent_utxos.iter().map(|u| u.outpoint).collect(); let unspent: Vec<&key_wallet::Utxo> = core diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs index d604c48aa87..0b07619e238 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_account_zero_attribution.rs @@ -1,16 +1,17 @@ #![allow(clippy::field_reassign_with_default)] -//! Genesis-rescan regression for the hardcoded `account_index = 0` design +//! Genesis-rescan regression for the account-attribution fallback default //! (PR #3828). //! //! Before: a UTXO landing on a freshly-derived gap-limit-edge address could //! race address-derivation persistence and be mis-attributed or dropped, so -//! the bridge smuggled a full pool snapshot in-band to resolve it. Now UTXO -//! attribution is hardcoded to the default account (index 0) at the storage -//! writer — no in-band snapshot, no address→account lookup table. This test -//! pins that a UTXO on a real gap-limit-edge address persists directly with -//! `account_index == 0`, contributes the exact balance, and never aborts -//! the flush. +//! the bridge smuggled a full pool snapshot in-band to resolve it. Now the +//! storage writer resolves each UTXO's owning account by matching its script +//! against the `core_address_pool` lookup table, falling back to account +//! index 0 when no pool row covers the script — no in-band snapshot. A fresh +//! gap-limit-edge address has no pool row yet, so this test pins that such a +//! UTXO persists directly under the `account_index == 0` fallback, contributes +//! the exact balance, and never aborts the flush. mod common; @@ -78,9 +79,9 @@ fn utxo_at(addr: &dashcore::Address, vout: u32, value: u64) -> key_wallet::Utxo } } -/// A UTXO on a freshly-derived gap-limit-edge address persists directly with -/// the hardcoded `account_index == 0`: no snapshot, no lookup, no flush -/// abort, and the unspent balance is exact. +/// A UTXO on a freshly-derived gap-limit-edge address (no `core_address_pool` +/// row) persists directly under the `account_index == 0` fallback: no snapshot, +/// no flush abort, and the unspent balance is exact. #[test] fn utxo_on_fresh_gap_limit_address_persists_under_account_zero() { let (persister, _tmp, _path) = fresh_persister(); From b68c5a8c6ba8e24097715c5ba4fc1de513ad7a21 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:43:39 +0000 Subject: [PATCH 101/108] docs(platform-wallet-storage): correct empty-manifest rehydration comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed the manager skips empty-manifest wallets "as MissingManifest" and cited rt_corrupt_row_skipped_and_other_loads — a test that does not exist. The real behavior (locked in by tc043 / tc_p4_006 / tc_p4_007) is to register the wallet as an external-signable placeholder: an empty account_manifest is not necessarily orphaned — a platform-only wallet (identity + contacts, no core accounts) legitimately has one, and its platform-side state must still rehydrate. State that truth; keep the task #14 TODO as the deferred orphaned-row-recovery product decision. Co-Authored-By: Claude Opus 4.8 --- .../src/sqlite/persister.rs | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index fec3b10b651..30ac2dc80c8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -971,20 +971,25 @@ impl PlatformWalletPersistence for SqlitePersister { // this directly — the old skeleton + core_state replay fallback is // gone. let wallet = if account_manifest.is_empty() { - // Placeholder empty wallet: the manager re-checks the empty - // manifest and skips this wallet as MissingManifest one layer - // up (see rt_corrupt_row_skipped_and_other_loads). It exists so - // one unregistered wallet doesn't abort load() for all others via `?`. + // No core (spending) accounts for this wallet. An empty manifest + // is NOT necessarily an orphaned row: a platform-only wallet — a + // Platform identity plus contacts, with no core accounts — + // legitimately has one. Register it as an external-signable + // placeholder (empty AccountCollection) that still carries its + // platform-side state (identities, contacts); the manager + // registers it like any other wallet. The genuinely-orphaned + // case (a crash between the wallet-row write and the first + // account write) also lands here and is harmless — it rehydrates + // as an empty wallet. // - // TODO(product decision needed, task #14): a crash between wallet-row - // creation and first-account-registration leaves this row with a - // permanently empty manifest. It is not corrupted or lost — every - // future load correctly skips it as MissingManifest — but there is no - // recovery path today: no re-registration flow, no eviction, no - // surfacing to the user. Open question: does this need one (e.g. a - // TTL-based cleanup, a re-registration entry point, or a surfaced - // "orphaned wallet" diagnostic), or is silent-skip-forever acceptable? - // Awaiting product decision; not addressed in this change. + // TODO(product decision needed, task #14): the orphaned variant + // leaves a permanently empty manifest. It is not corrupted or + // lost, but there is no recovery path today: no re-registration + // flow, no eviction, no surfacing to the user. Open question: + // does this need one (a TTL-based cleanup, a re-registration + // entry point, or a surfaced "orphaned wallet" diagnostic), or is + // register-empty-forever acceptable? Awaiting product decision; + // not addressed here. key_wallet::wallet::Wallet::new_external_signable( network, wallet_id, From 8ec2ea33062554481953f6d1cdfb4c8089dc88ee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:06:30 +0000 Subject: [PATCH 102/108] ci(swift-sdk): tolerate runner with no user default keychain in run_tests.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `security default-keychain -d user` exits non-zero on a self-hosted runner that has no user default keychain; under `set -euo pipefail` the command substitution aborted the whole script before any Swift build ran, failing the Swift SDK job. Make the capture non-fatal — the restore trap already skips an empty previous-default value, so capturing empty is harmless. Surfaced now because this PR's Swift FFI changes make the Swift SDK build run (previously skipped); the keychain preamble itself is unchanged base CI infra. Co-Authored-By: Claude Opus 4.8 --- packages/swift-sdk/run_tests.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/run_tests.sh b/packages/swift-sdk/run_tests.sh index d157e2765ef..80149b7e660 100755 --- a/packages/swift-sdk/run_tests.sh +++ b/packages/swift-sdk/run_tests.sh @@ -25,7 +25,10 @@ cd "$SCRIPT_DIR" || exit 1 # restored on exit so the persistent runner is left unchanged. if [ -n "${CI:-}${GITHUB_ACTIONS:-}" ]; then CI_KEYCHAIN="$HOME/Library/Keychains/dash-ci-tests.keychain-db" - PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//')" + # `security default-keychain -d user` exits non-zero on a runner with no + # user default keychain; tolerate it (restore below already skips an empty + # value) so `set -euo pipefail` doesn't abort the run before any build. + PREV_DEFAULT_KEYCHAIN="$(security default-keychain -d user 2>/dev/null | sed -E 's/^[[:space:]]*"?//;s/"?[[:space:]]*$//' || true)" restore_default_keychain() { if [ -n "${PREV_DEFAULT_KEYCHAIN:-}" ] && [ -e "$PREV_DEFAULT_KEYCHAIN" ]; then security default-keychain -s "$PREV_DEFAULT_KEYCHAIN" || true From 728f480fa0f8b87b6c4f1286977dec1140427f3b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:29 +0000 Subject: [PATCH 103/108] fix(platform-wallet): restore From for PlatformWalletError Re-add the PlatformWalletError::PersisterLoad(#[from] PersistenceError) variant dropped in the load-outcome cleanup. thiserror's #[from] regenerates the From conversion that downstream consumers (dash-evo-tool) rely on to `?`-propagate persister-load failures, and the variant keeps the typed retry classification (PersistenceErrorKind) instead of flattening to a string. Maps to ErrorUnknown across the FFI via the existing catch-all arm. Co-Authored-By: Claude Opus 4.8 --- packages/rs-platform-wallet/src/error.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8d71c044aac..dd9666f890e 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -12,6 +12,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), + #[error("Wallet not found: {0}")] WalletNotFound(String), From a503bd7b62ea64fc96c7749ad855637bb6cc595b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:29 +0000 Subject: [PATCH 104/108] ci: accept platform-wallet-storage as a conventional-commit scope Add platform-wallet-storage to the allowed PR-title scopes so titles like feat(platform-wallet-storage): ... validate. Note: pr.yml runs on pull_request_target, so this takes effect for PR-title checks only once it lands on the base branch. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pr.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index af787939bb2..777142c0dbe 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -52,6 +52,7 @@ jobs: release wasm-sdk platform-wallet + platform-wallet-storage wallet-storage swift-example-app kotlin-sdk From f2779cafe01f29deb7c0bca9aefb0278cbc18124 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:48:23 +0000 Subject: [PATCH 105/108] chore(deps): bump rust-dashcore pin to #851 head; fix importWallet birth_height arg Bump the rust-dashcore git pin from 48a07d3 to b3fb8244 (head of dashpay/rust-dashcore#851) across all workspace refs. The newer key-wallet-ffi `wallet_manager_import_wallet_from_bytes` gains a `birth_height: u32` parameter, so update the Swift caller in WalletManager.swift to pass it (defaulted to 0 = full rescan, keeping `importWallet(from:)` source-compatible). Co-Authored-By: Claude Sonnet 5 --- Cargo.lock | 24 +++++++++---------- Cargo.toml | 16 ++++++------- .../KeyWallet/WalletManager.swift | 6 ++++- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d685c8c3b8..be91c6203e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1638,7 +1638,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "bincode", "bincode_derive", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "dash-network", ] @@ -1726,7 +1726,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "async-trait", "chrono", @@ -1755,7 +1755,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "anyhow", "base64-compat", @@ -1781,12 +1781,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "dashcore-rpc-json", "hex", @@ -1799,7 +1799,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "bincode", "dashcore", @@ -1814,7 +1814,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "bincode", "dashcore-private", @@ -2868,7 +2868,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" [[package]] name = "glob" @@ -4035,7 +4035,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "aes", "async-trait", @@ -4064,7 +4064,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4080,7 +4080,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff#48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" +source = "git+https://github.com/dashpay/rust-dashcore?rev=b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5#b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" dependencies = [ "async-trait", "bincode", diff --git a/Cargo.toml b/Cargo.toml index 7fcb00ef8f7..d6d7e9898b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ # TODO(pin): tracks dashpay/rust-dashcore#851 (draft) — key-wallet # out-of-order UTXO spend fix (#649). Bump to the merged `dev` SHA once # that PR lands. -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "48a07d3fcd2f8d37e109f969eb5da0a8bbbb9aff" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "b3fb82445a3a0ac1a9c1ff6f8c18c7201d08c1e5" } tokio-metrics = "0.5" diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift index f3f0662b992..9363dbb02d4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/WalletManager.swift @@ -651,8 +651,11 @@ public class WalletManager { /// Import a wallet from serialized bytes /// - Parameters: /// - walletBytes: The serialized wallet data + /// - birthHeight: Block height to start scanning from. Defaults to 0 + /// (genesis), a safe full rescan; pass the wallet's known birth + /// height to skip pre-birth blocks. /// - Returns: The wallet ID of the imported wallet - public func importWallet(from walletBytes: Data) throws -> Data { + public func importWallet(from walletBytes: Data, birthHeight: UInt32 = 0) throws -> Data { guard !walletBytes.isEmpty else { throw KeyWalletError.invalidInput("Wallet bytes cannot be empty") } @@ -665,6 +668,7 @@ public class WalletManager { handle, bytes.bindMemory(to: UInt8.self).baseAddress, size_t(walletBytes.count), + birthHeight, &walletId, &error ) From 95d28a63a12a2a62126c6aa7b8bbc74f60eae6a5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:45:06 +0000 Subject: [PATCH 106/108] fix(platform-wallet-storage): route restored UTXOs to their true owning account on rehydration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On seedless rehydration the reader dropped each UTXO's owning account, so `apply_persisted_core_state` collapsed every restored unspent UTXO onto the first funding account. Per-account balance, coin selection, and reservations were wrong after restart for any wallet holding UTXOs in more than one funds account. `account_index` alone is ambiguous: a `Default` wallet carries Standard BIP44, BIP32, and CoinJoin accounts all at numeric index 0, and DashPay accounts all persist index 0 (told apart only by the identity pair). `load_state` now resolves each unspent UTXO's full owning-account identity — account_type label, index, and DashPay identity pair — from `core_address_pool` (same PK-ordered tie-break the writer uses) and returns it as a per-outpoint side channel alongside the `CoreChangeSet`. `apply_persisted_core_state` consumes it to route each UTXO to its true funds account, falling back to the first account for a UTXO whose script matched no pool row (deep/sparse or non-funds addresses re-warm on the next sync). Fail-closed (`MissingAccount`) and funds-less-wallet handling are preserved. All changes stay inside the storage crate. Co-Authored-By: Claude Sonnet 5 --- .../src/sqlite/persister.rs | 6 +- .../src/sqlite/schema/core_pool.rs | 77 +++- .../src/sqlite/schema/core_state.rs | 34 +- .../src/sqlite/util/wallet.rs | 337 ++++++++++++++---- .../tests/sqlite_core_state_reader.rs | 23 +- 5 files changed, 370 insertions(+), 107 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 30ac2dc80c8..bea5ef42a54 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -931,8 +931,9 @@ impl PlatformWalletPersistence for SqlitePersister { let account_manifest = schema::accounts::load_state(&conn, &wallet_id).map_err(PersistenceError::from)?; - let core_state = schema::core_state::load_state(&conn, &wallet_id, network) - .map_err(PersistenceError::from)?; + let (core_state, utxo_accounts) = + schema::core_state::load_state(&conn, &wallet_id, network) + .map_err(PersistenceError::from)?; // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader // already carrying its own public keys + contact state (matching // the FFI persister), so signing works immediately post-load @@ -1012,6 +1013,7 @@ impl PlatformWalletPersistence for SqlitePersister { &mut wallet_info, &account_manifest, &core_state, + &utxo_accounts, &used_core_addresses, ) .map_err(|e| { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs index b6a4015d103..4983b835316 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -11,7 +11,7 @@ //! from the `account_address_pools` changeset snapshots; the UTXO writer reads //! it back to attribute an outpoint to its owning account. -use rusqlite::{params, OptionalExtension, Transaction}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; use platform_wallet::changeset::AccountAddressPoolEntry; use platform_wallet::wallet::platform_wallet::WalletId; @@ -91,31 +91,82 @@ pub fn apply_pools( Ok(()) } -/// Owning account index for a UTXO, matched by its `script_pubkey` against a -/// pool row. `None` when no pool row covers the script — the UTXO writer -/// then falls back to account 0 (the one-way historical-attribution default, -/// R7): funds are never dropped, only conservatively bucketed. -pub fn account_index_for_script( - tx: &Transaction<'_>, +/// Identity of the funds account that owns an address, matched against a +/// `core_address_pool` row. Carries the same discriminators the writer keys +/// pool/registration rows on — enough to select one account among funding +/// accounts that share a numeric `account_index` (Standard BIP44/BIP32 and +/// CoinJoin can all sit at index 0; DashPay accounts all persist index 0 and +/// are told apart by the identity pair). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OwningAccount { + /// `account_type` DB label (see [`accounts::account_type_db_label`]). + pub account_type: String, + /// Numeric account index within its type. + pub account_index: u32, + /// DashPay owner identity (all-zero for non-DashPay accounts). + pub user_identity_id: [u8; 32], + /// DashPay contact identity (all-zero for non-DashPay accounts). + pub friend_identity_id: [u8; 32], +} + +/// Full owning-account identity for a UTXO, matched by its `script_pubkey` +/// against a pool row. `None` when no pool row covers the script — the caller +/// then falls back to the one-way historical-attribution default (R7): funds +/// are never dropped, only conservatively bucketed. +pub(crate) fn owning_account_for_script( + conn: &Connection, wallet_id: &WalletId, script: &[u8], -) -> Result, WalletStorageError> { +) -> Result, WalletStorageError> { // A script can appear under several pool rows (distinct account_type / // key_class / identity pair / pool_type share the same `script_pubkey` // for reused keys); an explicit PK-ordered tie-break makes the pick // deterministic instead of relying on SQLite's arbitrary `LIMIT 1` row. - let idx: Option = tx + let row = conn .prepare_cached( - "SELECT account_index FROM core_address_pool \ + "SELECT account_type, account_index, user_identity_id, friend_identity_id \ + FROM core_address_pool \ WHERE wallet_id = ?1 AND script = ?2 \ ORDER BY account_type, account_index, key_class, user_identity_id, \ friend_identity_id, pool_type, address_index ASC \ LIMIT 1", )? - .query_row(params![wallet_id.as_slice(), script], |row| row.get(0)) + .query_row(params![wallet_id.as_slice(), script], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Vec>(2)?, + row.get::<_, Vec>(3)?, + )) + }) .optional()?; - idx.map(|v| crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", v)) - .transpose() + let Some((account_type, index, user, friend)) = row else { + return Ok(None); + }; + let account_index = + crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", index)?; + let user_identity_id = <[u8; 32]>::try_from(user.as_slice()) + .map_err(|_| WalletStorageError::blob_decode("core_address_pool.user_identity_id"))?; + let friend_identity_id = <[u8; 32]>::try_from(friend.as_slice()) + .map_err(|_| WalletStorageError::blob_decode("core_address_pool.friend_identity_id"))?; + Ok(Some(OwningAccount { + account_type, + account_index, + user_identity_id, + friend_identity_id, + })) +} + +/// Owning account index for a UTXO, matched by its `script_pubkey` against a +/// pool row. `None` when no pool row covers the script — the UTXO writer +/// then falls back to account 0 (the one-way historical-attribution default, +/// R7): funds are never dropped, only conservatively bucketed. +pub fn account_index_for_script( + tx: &Transaction<'_>, + wallet_id: &WalletId, + script: &[u8], +) -> Result, WalletStorageError> { + Ok(owning_account_for_script(tx, wallet_id, script)?.map(|o| o.account_index)) } /// Used addresses for a wallet, read verbatim from `core_address_pool` diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 9570ebc1302..bbf89624381 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -14,6 +14,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; +use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; // PUBLIC material only: core-chain state reaching `record_blob` / // `islock_blob` (transaction records + InstantLocks are public chain data). @@ -283,8 +284,17 @@ fn upsert_sync_state( } /// Bulk-reconstruct the keyless [`CoreChangeSet`] projection for one wallet -/// from the `core_*` tables. PUBLIC material only; mints no `Wallet`. `network` -/// (from `wallets`) turns a persisted `script` back into an `Address`. +/// from the `core_*` tables, plus the per-outpoint owning-account side channel. +/// PUBLIC material only; mints no `Wallet`. `network` (from `wallets`) turns a +/// persisted `script` back into an `Address`. +/// +/// [`CoreChangeSet::new_utxos`] cannot carry each UTXO's owning account (it is a +/// bare `Vec`), so the returned map surfaces, per unspent outpoint, the +/// funds account that owns it — resolved by matching the UTXO's script against +/// `core_address_pool`. [`apply_persisted_core_state`](crate::sqlite::util::apply_persisted_core_state) +/// consumes it to route each UTXO to its true account. An outpoint whose script +/// matches no pool row is absent from the map and falls back to the first funds +/// account (the one-way historical-attribution default; re-warms on next sync). /// /// # Reconstructed (safety-critical-correct) /// @@ -295,19 +305,22 @@ fn upsert_sync_state( /// /// # Deferred to the first post-load `sync` (safe re-warm) /// -/// - **Per-account UTXO attribution**: `core_utxos.account_index` is persisted -/// (resolved against `core_address_pool` at write time), but this reader does -/// not select it yet, so restored UTXOs are bucketed under a single account -/// and re-attributed on the next scan. Wiring the read-back is a deliberate -/// follow-up. The wallet *total* balance is unaffected. /// - **`is_coinbase` / `is_instantlocked` / `is_trusted` / `used` flags**: not /// carried by `core_utxos`; defaulted and refreshed on the next scan. pub fn load_state( conn: &Connection, wallet_id: &WalletId, network: dashcore::Network, -) -> Result { +) -> Result< + ( + CoreChangeSet, + std::collections::HashMap, + ), + WalletStorageError, +> { let mut cs = CoreChangeSet::default(); + let mut utxo_accounts: std::collections::HashMap = + std::collections::HashMap::new(); // Unspent UTXOs → new_utxos (the balance source). // Pre-read `length()` gates on `outpoint` and `script` before materializing @@ -336,6 +349,9 @@ pub fn load_state( Some(h) => crate::sqlite::util::safe_cast::i64_to_u32("core_utxos.height", h)?, }; let script = dashcore::ScriptBuf::from_bytes(script_bytes); + if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { + utxo_accounts.insert(outpoint, owner); + } let address = dashcore::Address::from_script(&script, network) .map_err(|_| WalletStorageError::blob_decode("core_utxos.script not an address"))?; let confirmed = height.map(|h| h > 0).unwrap_or(false); @@ -426,7 +442,7 @@ pub fn load_state( } } - Ok(cs) + Ok((cs, utxo_accounts)) } /// Every address that has ever held a `core_utxos` row for this wallet — diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index 60a2a17a034..82b43aad9d8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -12,6 +12,8 @@ use key_wallet::Network; use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; +use crate::sqlite::schema::accounts; +use crate::sqlite::schema::core_pool::OwningAccount; use crate::WalletStorageError; /// Build a [`Wallet`] that will be provided to the platform-wallet during rehydration. @@ -61,6 +63,12 @@ pub(crate) fn build_wallet( /// account (no xpub → no derivation possible); already-derived in-window /// addresses are still marked used. /// - `core`: the persisted core-state changeset to apply. +/// - `utxo_accounts`: per-outpoint owning-account side channel from +/// [`load_state`](crate::sqlite::schema::core_state::load_state) — the +/// `CoreChangeSet` cannot carry it. Each restored unspent UTXO is routed +/// to the funds account whose identity matches its entry; a UTXO absent +/// from the map (its script matched no pool row) falls back to the first +/// funds account and re-warms on the next sync. /// - `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 @@ -74,9 +82,11 @@ pub(crate) fn build_wallet( /// and 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). +/// - **UTXO set**: every unspent persisted outpoint is restored into its +/// owning funds-bearing account (matched via `utxo_accounts` across any +/// topology — BIP44, BIP32, CoinJoin, DashPay), so per-account balance, +/// coin selection, and reservations are correct after restart. An +/// outpoint that resolves to no account falls back to the first. /// - **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 @@ -99,12 +109,6 @@ pub(crate) fn build_wallet( /// /// # Deferred to the first post-load `sync` (safe re-warm) /// -/// - **Per-account UTXO attribution**: `core_utxos.account_index` is -/// persisted (resolved against the `core_address_pool` table at write -/// time), but this reader does not consult it yet, so UTXOs are restored -/// against the wallet's first funds-bearing account and re-attributed on -/// the next scan. Wiring the read-back is a deliberate follow-up. 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. @@ -139,6 +143,7 @@ pub fn apply_persisted_core_state( wallet_info: &mut ManagedWalletInfo, manifest: &[AccountRegistrationEntry], core: &CoreChangeSet, + utxo_accounts: &std::collections::HashMap, used_pool_addresses: &[key_wallet::Address], ) -> Result<(), WalletStorageError> { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -170,18 +175,13 @@ pub fn apply_persisted_core_state( // the next sync (no regression vs. the prior loader); populating them at // load needs an upstream key_wallet change. - // Restore the UTXO set. `core_utxos.account_index` is persisted (resolved - // against the `core_address_pool` table at write time) but this reader does - // not read it back yet, 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. - // TODO: route each restored UTXO to its true owning account by reading back - // the persisted `core_utxos.account_index` (populated from core_address_pool, - // added V003) instead of collapsing every UTXO onto the first funds account. - // Present-state restore only; per-account attribution is a deliberate - // follow-up. + // Restore the UTXO set, routing each unspent outpoint to its true owning + // funds account via `utxo_accounts` (matched on the same account identity + // the writer keyed the pool row on). An outpoint absent from the map — its + // script matched no pool row — falls back to the first funds account and + // re-attributes on the next sync. A wallet with unspent UTXOs but no funds + // account at all fails closed rather than silently reconstructing 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 @@ -190,54 +190,48 @@ pub fn apply_persisted_core_state( .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. + let mut funding = wallet_info.accounts.all_funding_accounts_mut(); + if !unspent.is_empty() && funding.is_empty() { + return Err(WalletStorageError::MissingAccount { wallet_id }); + } + if !funding.is_empty() { + let account_keys: Vec = + funding.iter().map(|a| owning_account_of(a)).collect(); + + // Per-account addresses to derive-and-mark-used: each account gets only + // its own restored UTXO addresses, so `extend_pools_for_restored_addresses` + // never scans another account's keys as "unresolved". + let mut per_account_addrs: Vec> = vec![Vec::new(); funding.len()]; + + for utxo in &unspent { + let target = utxo_accounts + .get(&utxo.outpoint) + .and_then(|owner| account_keys.iter().position(|k| k == owner)) + .unwrap_or(0); + funding[target].utxos.insert(utxo.outpoint, (*utxo).clone()); + per_account_addrs[target].push(utxo.address.clone()); + } + + // The persisted pool used-state 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). It carries no per-address owner, so it + // re-marks on the first funds account (unchanged from the prior loader); + // any cross-account used address re-warms on the next sync. Empty + // `used_pool_addresses` preserves the prior unspent-only behaviour. + per_account_addrs[0].extend(used_pool_addresses.iter().cloned()); + + // Eager derivation covers only `0..gap_limit`; extend each chain to + // cover restored / used addresses at deeper indices. + for i in 0..funding.len() { + if !per_account_addrs[i].is_empty() { extend_pools_for_restored_addresses( - account, + funding[i], manifest, - &addresses_to_mark, + &per_account_addrs[i], wallet_id, )?; } - None => { - return Err(WalletStorageError::MissingAccount { wallet_id }); - } - } - } 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)?; } } @@ -248,6 +242,26 @@ pub fn apply_persisted_core_state( Ok(()) } +/// Owning-account identity of a funds account, keyed on the same +/// discriminators the UTXO side channel resolves from `core_address_pool`: +/// the `account_type` label, numeric index, and DashPay identity pair. Enough +/// to pick one account among funding accounts that share a numeric index +/// (Standard BIP44/BIP32 and CoinJoin can all sit at index 0; DashPay accounts +/// all carry index 0 and differ only by the identity pair). +fn owning_account_of( + account: &key_wallet::managed_account::ManagedCoreFundsAccount, +) -> OwningAccount { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let at = account.managed_account_type().to_account_type(); + let (user_identity_id, friend_identity_id) = accounts::account_dashpay_ids(&at); + OwningAccount { + account_type: accounts::account_type_db_label(&at).to_string(), + account_index: accounts::account_index(&at), + user_identity_id, + friend_identity_id, + } +} + /// 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 @@ -701,7 +715,8 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) + .unwrap(); // The wallet total is exact regardless (a sum over the UTXO set). assert_eq!(wallet_info.balance.total(), expected_total); @@ -751,6 +766,157 @@ mod tests { ); } + /// Regression (dashpay/platform#3968): restored unspent UTXOs must land in + /// their TRUE owning funds account, not collapse onto the first. A `Default` + /// wallet carries Standard BIP44, BIP32, and CoinJoin accounts all at numeric + /// index 0, so routing by bare `account_index` is ambiguous — the + /// owning-account side channel disambiguates by account type. Asserts each + /// account holds only its own UTXO and its per-account balance is exact. + #[test] + fn rehydration_routes_utxos_to_their_owning_account() { + 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::HashMap; + + let seed = [11u8; 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); + + // The two funds accounts that share numeric index 0 but differ by type. + let bip44_type = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + let coinjoin_type = wallet_info + .accounts + .coinjoin_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + + // Derive external index-0 address from a given account xpub; `base_path` + // is record-keeping only and does not affect the derived address. + let derive = |at: key_wallet::account::AccountType| -> Address { + let xpub = manifest + .iter() + .find(|e| e.account_type == at) + .map(|e| e.account_xpub) + .expect("account xpub in manifest"); + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(0).unwrap() + }; + + 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 bip44_utxo = utxo(derive(bip44_type), 5_000, 1); + let coinjoin_utxo = utxo(derive(coinjoin_type), 7_000, 2); + let bip44_op = bip44_utxo.outpoint; + let coinjoin_op = coinjoin_utxo.outpoint; + + // Side channel attributing each outpoint to its true owning account — + // keyed exactly as production resolves it from `core_address_pool`. + let mut utxo_accounts: HashMap = HashMap::new(); + utxo_accounts.insert( + bip44_op, + owning_account_of( + wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap(), + ), + ); + utxo_accounts.insert( + coinjoin_op, + owning_account_of(wallet_info.accounts.coinjoin_accounts.get(&0).unwrap()), + ); + + let core = platform_wallet::changeset::CoreChangeSet { + new_utxos: vec![bip44_utxo, coinjoin_utxo], + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &utxo_accounts, &[]) + .unwrap(); + + let bip44 = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap(); + let coinjoin = wallet_info.accounts.coinjoin_accounts.get(&0).unwrap(); + + assert!( + bip44.utxos.contains_key(&bip44_op), + "BIP44 UTXO must route to the BIP44 account" + ); + assert!( + !bip44.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must NOT collapse onto the first (BIP44) account" + ); + assert!( + coinjoin.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must route to the CoinJoin account" + ); + assert!(!coinjoin.utxos.contains_key(&bip44_op)); + + assert_eq!( + bip44.balance.total(), + 5_000, + "per-account BIP44 balance must be exact" + ); + assert_eq!( + coinjoin.balance.total(), + 7_000, + "per-account CoinJoin balance must be exact, not zero" + ); + assert_eq!( + wallet_info.balance.total(), + 12_000, + "wallet total is the sum across accounts" + ); + } + /// 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), @@ -865,7 +1031,8 @@ 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, &Default::default(), &[]) + .unwrap(); // Total balance is exact — foreign UTXO is in the set regardless. assert_eq!( @@ -1004,7 +1171,8 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) + .unwrap(); // Balance is exact. assert_eq!( @@ -1107,7 +1275,8 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) + .unwrap(); let funds = wallet_info .accounts @@ -1227,7 +1396,8 @@ mod tests { // 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, &manifest, &core, &Default::default(), &[]) + .unwrap(); let funds = baseline .accounts .all_funding_accounts() @@ -1248,8 +1418,14 @@ mod tests { // With the persisted used-state passed as `used_pool_addresses`, both // come back used. let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &used_core_addresses) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used_core_addresses, + ) + .unwrap(); // The spent UTXO contributes no balance — the used flag is NOT a // side effect of a live UTXO. @@ -1359,6 +1535,7 @@ mod tests { &mut wallet_info, &manifest, &core, + &Default::default(), &[in_window_used.clone(), wedge_used.clone()], ) .unwrap(); @@ -1478,7 +1655,8 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) + .unwrap(); // The wallet total is exact regardless (a sum over the UTXO set). assert_eq!(wallet_info.balance.total(), value); @@ -1573,8 +1751,14 @@ mod tests { ..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"); + let err = apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &[], + ) + .expect_err("must fail closed when no funds account can hold the UTXOs"); match err { WalletStorageError::MissingAccount { wallet_id: id } => { assert_eq!( @@ -1613,7 +1797,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, &Default::default(), &[]) .expect("empty UTXO set must be Ok even with no funds account"); } @@ -1653,7 +1837,8 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &[]).unwrap(); + apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) + .unwrap(); assert_eq!( wallet_info.metadata.last_applied_chain_lock.as_ref(), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index 05cbee6e05f..bc5fb104e7a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -104,7 +104,9 @@ fn rt2_nonzero_balance_survives_reopen() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).expect("load_state"); + #[cfg_attr(not(feature = "rehydration-apply"), allow(unused_variables))] + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet).expect("load_state"); drop(conn); // The persisted UTXO round-trips by outpoint + value. @@ -126,6 +128,7 @@ fn rt2_nonzero_balance_survives_reopen() { &mut info, &manifest_for(&wallet), &core, + &utxo_accounts, &[], ) .expect("BIP44 reconstruction must not error"); @@ -170,7 +173,8 @@ fn b2_spent_utxo_excluded() { drop(persister); let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); + let (core, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); drop(conn); let ops: Vec<_> = core.new_utxos.iter().map(|u| u.outpoint).collect(); assert!(ops.contains(&u_unspent.outpoint)); @@ -282,7 +286,9 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); + #[cfg_attr(not(feature = "rehydration-apply"), allow(unused_variables))] + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); drop(conn); assert_eq!(core.new_utxos.len(), 1); @@ -296,6 +302,7 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { &mut info, &manifest_for(&wallet), &core, + &utxo_accounts, &[], ) .expect("CoinJoin-only reconstruction must not error"); @@ -318,7 +325,8 @@ fn b4_empty_core_state_is_ok() { drop(persister); let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let core = core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); + let (core, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet).unwrap(); drop(conn); assert!(core.new_utxos.is_empty()); assert!(core.records.is_empty()); @@ -365,8 +373,9 @@ fn b5_last_applied_chain_lock_round_trips() { let p2 = reopen(&path); { let conn = p2.lock_conn_for_test(); - let loaded = core_state::load_state(&conn, &w, key_wallet::Network::Testnet) - .expect("load_state must succeed"); + let (loaded, _utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet) + .expect("load_state must succeed"); assert_eq!( loaded.last_applied_chain_lock.as_ref(), Some(&cl), @@ -438,7 +447,7 @@ fn chain_lock_does_not_regress_on_lower_height_update() { let p2 = reopen(&path); let conn = p2.lock_conn_for_test(); - let loaded = core_state::load_state(&conn, &w, key_wallet::Network::Testnet) + let (loaded, _utxo_accounts) = core_state::load_state(&conn, &w, key_wallet::Network::Testnet) .expect("load_state must succeed"); assert_eq!( loaded.last_applied_chain_lock.as_ref(), From 5283236c6816044e180e599ae8b4063f065d7ba1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:09:13 +0000 Subject: [PATCH 107/108] fix(platform-wallet-storage): warn on orphaned UTXO owner; add end-to-end SQL-resolver rehydration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups on the per-account UTXO routing: - Distinguish the two rehydration miss cases that share the first-account fallback: (a) no side-channel entry (script matched no pool row — expected, silent) vs (b) a side-channel entry whose owner is not among the wallet's funds accounts (store drift). Case (b) is now collected and surfaced with a single greppable `tracing::warn!` (wallet_id + orphaned owners + count) after the loop — best-effort route to the first account, never fail-closed and never silent. The zero-funds-accounts case stays fail-closed (`MissingAccount`). - Add `rehydration_routes_via_real_sql_resolver`: persists a Default wallet with UTXOs owned by BIP44[0] and CoinJoin[0] (colliding on index 0) plus their `core_address_pool` snapshots through the real writer, reopens, and drives `load_state` -> `apply_persisted_core_state`. Exercises the actual `owning_account_for_script` query (column order, ORDER BY tie-break, [u8;32] identity decode), asserting exact per-account balances — the coverage the hand-built unit test bypassed. - Correct the `OwningAccount` doc: state the intentional `key_class` omission and why it is safe (funds accounts all map to the key_class=0 sentinel). - Restate two history-narrating comments as present-state facts. Co-Authored-By: Claude Sonnet 5 --- .../src/sqlite/schema/core_pool.rs | 16 +- .../src/sqlite/util/wallet.rs | 50 +++-- .../tests/sqlite_core_state_reader.rs | 174 ++++++++++++++++++ 3 files changed, 223 insertions(+), 17 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs index 4983b835316..d0155c0262a 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -92,11 +92,17 @@ pub fn apply_pools( } /// Identity of the funds account that owns an address, matched against a -/// `core_address_pool` row. Carries the same discriminators the writer keys -/// pool/registration rows on — enough to select one account among funding -/// accounts that share a numeric `account_index` (Standard BIP44/BIP32 and -/// CoinJoin can all sit at index 0; DashPay accounts all persist index 0 and -/// are told apart by the identity pair). +/// `core_address_pool` row. Enough to select one account among funding accounts +/// that share a numeric `account_index` (Standard BIP44/BIP32 and CoinJoin can +/// all sit at index 0; DashPay accounts all persist index 0 and are told apart +/// by the identity pair). +/// +/// Carries four of the writer's five pool-PK account discriminators +/// (`UPSERT_POOL_SQL`); `key_class` is intentionally omitted. Every funds +/// account maps to the `key_class = 0` sentinel — only the non-funds +/// `PlatformPayment` account carries a real class, and it is never a funding +/// account — so `key_class` cannot distinguish two funds accounts and adds +/// nothing here. #[derive(Debug, Clone, PartialEq, Eq)] pub struct OwningAccount { /// `account_type` DB label (see [`accounts::account_type_db_label`]). diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index 82b43aad9d8..cef0787ffe6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -177,11 +177,17 @@ pub fn apply_persisted_core_state( // Restore the UTXO set, routing each unspent outpoint to its true owning // funds account via `utxo_accounts` (matched on the same account identity - // the writer keyed the pool row on). An outpoint absent from the map — its - // script matched no pool row — falls back to the first funds account and - // re-attributes on the next sync. A wallet with unspent UTXOs but no funds - // account at all fails closed rather than silently reconstructing a zero - // balance. + // the writer keyed the pool row on). Two miss cases share the first-account + // best-effort fallback but differ in signal: + // (a) no side-channel entry — the UTXO's script matched no pool row; an + // expected fallback (deep/sparse or non-funds address) that re-warms + // on the next sync, no log. + // (b) a side-channel entry whose owner is not among this wallet's funds + // accounts — a pool row references an unregistered account (store + // drift). Counted and surfaced via `tracing::warn!` after the loop so + // the misattribution is never silent. + // A wallet with unspent UTXOs but no funds account at all fails closed + // rather than silently reconstructing 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 @@ -203,22 +209,42 @@ pub fn apply_persisted_core_state( // never scans another account's keys as "unresolved". let mut per_account_addrs: Vec> = vec![Vec::new(); funding.len()]; + // Owners that resolve to no funds account (case (b) above), collected in + // the loop and warned once after it — never log per iteration. + let mut orphaned_owners: Vec = Vec::new(); for utxo in &unspent { - let target = utxo_accounts - .get(&utxo.outpoint) - .and_then(|owner| account_keys.iter().position(|k| k == owner)) - .unwrap_or(0); + let target = match utxo_accounts.get(&utxo.outpoint) { + None => 0, + Some(owner) => account_keys + .iter() + .position(|k| k == owner) + .unwrap_or_else(|| { + orphaned_owners + .push(format!("{}[{}]", owner.account_type, owner.account_index)); + 0 + }), + }; funding[target].utxos.insert(utxo.outpoint, (*utxo).clone()); per_account_addrs[target].push(utxo.address.clone()); } + if !orphaned_owners.is_empty() { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + orphaned_owners = ?orphaned_owners, + count = orphaned_owners.len(), + "rehydration: restored UTXO(s) reference a funds account not present \ + in this wallet — routed to the first account as best effort; the \ + per-account view re-warms on the next sync" + ); + } // The persisted pool used-state 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). It carries no per-address owner, so it - // re-marks on the first funds account (unchanged from the prior loader); - // any cross-account used address re-warms on the next sync. Empty - // `used_pool_addresses` preserves the prior unspent-only behaviour. + // re-marks on the first funds account; a cross-account used address + // re-warms on the next sync. An empty `used_pool_addresses` marks only + // the unspent-UTXO addresses. per_account_addrs[0].extend(used_pool_addresses.iter().cloned()); // Eager derivation covers only `0..gap_limit`; extend each chain to diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index bc5fb104e7a..1a638821efe 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -455,3 +455,177 @@ fn chain_lock_does_not_regress_on_lower_height_update() { "a lower-height chain lock must not regress the stored higher one" ); } + +/// First external `AddressInfo` of the account matching `pred` in `wallet`, +/// sorted by derivation index — a genuine script that round-trips. +#[cfg(feature = "rehydration-apply")] +fn first_external_info( + wallet: &Wallet, + pred: impl Fn(&key_wallet::account::AccountType) -> bool, +) -> key_wallet::AddressInfo { + use key_wallet::managed_account::address_pool::AddressPoolType; + let info = ManagedWalletInfo::from_wallet(wallet, 0); + for managed in info.all_managed_accounts() { + if !pred(&managed.managed_account_type().to_account_type()) { + continue; + } + for pool in managed.managed_account_type().address_pools() { + if pool.pool_type != AddressPoolType::External || pool.addresses.is_empty() { + continue; + } + let mut infos: Vec = + pool.addresses.values().cloned().collect(); + infos.sort_by_key(|a| a.index); + return infos.into_iter().next().unwrap(); + } + } + panic!("wallet must expose the requested account with a non-empty external pool"); +} + +/// End-to-end regression (dashpay/platform#3968) exercising the REAL SQL +/// resolver. Persists a `Default` wallet through the actual writer with unspent +/// UTXOs owned by Standard BIP44[0] and CoinJoin[0] — colliding on numeric +/// index 0 — plus their `core_address_pool` snapshots, reopens the DB, then +/// drives `load_state` → `apply_persisted_core_state`. Unlike the hand-built +/// unit test, the owning-account side channel here is produced by +/// `owning_account_for_script` (column order, `ORDER BY` tie-break, `[u8;32]` +/// identity decode), so a broken query would be caught. Each UTXO must land in +/// its TRUE account with exact per-account balances, not just the wallet total. +#[cfg(feature = "rehydration-apply")] +#[test] +fn rehydration_routes_via_real_sql_resolver() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::AddressPoolType; + use platform_wallet::changeset::AccountAddressPoolEntry; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC1); + ensure_wallet_meta(&persister, &w); + + let wallet = Wallet::from_seed_bytes( + [0x9A; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + + let bip44_info = first_external_info(&wallet, |at| { + matches!( + at, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + ) + }); + let coinjoin_info = first_external_info(&wallet, |at| { + matches!(at, AccountType::CoinJoin { index: 0 }) + }); + assert_ne!( + bip44_info.script_pubkey, coinjoin_info.script_pubkey, + "BIP44[0] and CoinJoin[0] must derive distinct scripts" + ); + + let utxo_on = |info: &key_wallet::AddressInfo, value: u64, n: u8| Utxo { + outpoint: OutPoint { + txid: Txid::from_byte_array([n; 32]), + vout: 0, + }, + txout: dashcore::TxOut { + value, + script_pubkey: info.script_pubkey.clone(), + }, + address: info.address.clone(), + height: 5, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + let bip44_utxo = utxo_on(&bip44_info, 5_000, 1); + let coinjoin_utxo = utxo_on(&coinjoin_info, 7_000, 2); + let bip44_op = bip44_utxo.outpoint; + let coinjoin_op = coinjoin_utxo.outpoint; + + let pool_entry = |account_type, info: &key_wallet::AddressInfo| AccountAddressPoolEntry { + account_type, + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + &bip44_info, + ), + pool_entry(AccountType::CoinJoin { index: 0 }, &coinjoin_info), + ], + core: Some(CoreChangeSet { + new_utxos: vec![bip44_utxo, coinjoin_utxo], + last_processed_height: Some(5), + synced_height: Some(5), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("store must persist UTXOs + pool snapshots"); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet).expect("load_state"); + drop(conn); + + // The real resolver populated the side channel from `core_address_pool`. + assert_eq!( + utxo_accounts.len(), + 2, + "owning_account_for_script must resolve both UTXOs from the persisted pool" + ); + + let mut managed = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::util::apply_persisted_core_state( + &mut managed, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &[], + ) + .expect("apply must not error"); + + let bip44 = managed.accounts.standard_bip44_accounts.get(&0).unwrap(); + let coinjoin = managed.accounts.coinjoin_accounts.get(&0).unwrap(); + assert!( + bip44.utxos.contains_key(&bip44_op), + "BIP44 UTXO must route to the BIP44 account" + ); + assert!( + !bip44.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must NOT collapse onto the first (BIP44) account" + ); + assert!( + coinjoin.utxos.contains_key(&coinjoin_op), + "CoinJoin UTXO must route to the CoinJoin account" + ); + assert!(!coinjoin.utxos.contains_key(&bip44_op)); + assert_eq!( + bip44.balance.total(), + 5_000, + "per-account BIP44 balance exact" + ); + assert_eq!( + coinjoin.balance.total(), + 7_000, + "per-account CoinJoin balance exact, not zero" + ); + assert_eq!(managed.balance.total(), 12_000, "wallet total is the sum"); +} From 7cdaa65228987c6c3e555719d2e90605398729e9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:58:53 +0000 Subject: [PATCH 108/108] fix(platform-wallet-storage): route restored used addresses to their true owning account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rehydration reuse-guard collapsed every restored used address onto the first funds account, so a used address owned by a non-first account (e.g. CoinJoin[0] while BIP44[0] is first) was never marked used on its own pool and could be re-issued as a fresh receive address — an address-reuse privacy leak. Carry owner attribution end to end: - core_pool::load_used_addresses returns Vec<(Address, OwningAccount)>, reading the four owner columns the pool row already stores (PK-ordered tie-break per script, matching owning_account_for_script). - core_state::load_used_addresses returns Vec<(Address, Option)>, resolving each core_utxos script via owning_account_for_script. - The persister unions both sources into a HashMap> keyed by address; the pool source is authoritative on owner (a None from the core_utxos source never overrides it), and a resolved-but-disagreeing owner is kept-pool + warned rather than crashing rehydration. - apply_persisted_core_state takes the owner map and routes each used address to its owning funds account via a shared route_to_funds_account helper (also used by the UTXO loop); orphaned owners from both loops funnel into one post-loop warning. A None or absent owner falls back to the first account. Co-Authored-By: Claude Sonnet 5 --- .../src/sqlite/persister.rs | 52 ++- .../src/sqlite/schema/core_pool.rs | 69 +++- .../src/sqlite/schema/core_state.rs | 46 ++- .../src/sqlite/util/wallet.rs | 306 +++++++++++++++--- .../tests/sqlite_compile_time.rs | 5 +- .../tests/sqlite_core_state_reader.rs | 154 ++++++++- .../tests/sqlite_migration_execution.rs | 6 +- .../tests/sqlite_pool_reader.rs | 20 +- 8 files changed, 554 insertions(+), 104 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index bea5ef42a54..f14f36e9898 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -944,22 +944,56 @@ impl PlatformWalletPersistence for SqlitePersister { let unused_asset_locks = schema::asset_locks::load_unconsumed(&conn, &wallet_id) .map_err(PersistenceError::from)?; // Used addresses drive the reuse guard: a used-then-emptied - // address must never be handed back as a fresh receive address. - // Union the verbatim `core_address_pool` used-set with the - // `core_utxos`-derived set (spent + unspent). The guard is + // address must never be handed back as a fresh receive address, + // and must come back used on ITS OWN account so it is never + // re-issued as a fresh receive address from that account. Union + // the verbatim `core_address_pool` used-set (known owner) with the + // `core_utxos`-derived set (spent + unspent; owner resolved per + // script, `None` when no pool row covers it). The guard is // monotonic, so a mixed store — historical UTXOs plus a later // partial pool snapshot that never enumerates them — must surface - // both; neither source may shadow the other. Deduped by script. + // both; neither source may shadow the other. Keyed by address; the + // pool source is authoritative on owner, so a `None` from the + // `core_utxos` source never overrides a resolved pool owner. Two + // resolved-but-disagreeing owners for one script means DB drift — + // keep the pool owner and warn rather than crash rehydration. let used_core_addresses = { - let mut seen = std::collections::HashSet::new(); - let mut union = Vec::new(); + let mut union: std::collections::HashMap< + dashcore::Address, + Option, + > = std::collections::HashMap::new(); let pool = schema::core_pool::load_used_addresses(&conn, &wallet_id, network) .map_err(PersistenceError::from)?; + for (addr, owner) in pool { + union.entry(addr).or_insert(Some(owner)); + } let utxo = schema::core_state::load_used_addresses(&conn, &wallet_id, network) .map_err(PersistenceError::from)?; - for addr in pool.into_iter().chain(utxo) { - if seen.insert(addr.script_pubkey().to_bytes()) { - union.push(addr); + for (addr, owner) in utxo { + match union.entry(addr) { + std::collections::hash_map::Entry::Occupied(existing) => { + if let (Some(pool_owner), Some(utxo_owner)) = (existing.get(), &owner) { + if pool_owner != utxo_owner { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + pool_owner = %format!( + "{}[{}]", + pool_owner.account_type, pool_owner.account_index + ), + utxo_owner = %format!( + "{}[{}]", + utxo_owner.account_type, utxo_owner.account_index + ), + "rehydration: used address resolves to different owning \ + accounts in core_address_pool vs core_utxos — keeping the \ + pool owner (authoritative); likely store drift" + ); + } + } + } + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(owner); + } } } union diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs index d0155c0262a..88d534d0b43 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_pool.rs @@ -176,23 +176,30 @@ pub fn account_index_for_script( } /// Used addresses for a wallet, read verbatim from `core_address_pool` -/// (`used = 1`) with no re-derivation. Possibly empty. The caller **unions** -/// this with the `core_utxos`-derived set — the reuse guard is monotonic, so -/// a mixed store (historical UTXOs a later partial pool snapshot never -/// enumerates) must surface both sources, never drop the historical ones. +/// (`used = 1`), each paired with its owning account. This is the +/// *known-owner* reuse-guard source: the pool row carries the account +/// discriminators directly, so no per-script round-trip is needed. Possibly +/// empty. The caller **unions** this with the `core_utxos`-derived set — the +/// reuse guard is monotonic, so a mixed store (historical UTXOs a later +/// partial pool snapshot never enumerates) must surface both sources, never +/// drop the historical ones. /// -/// `network` turns each stored `script` back into an [`Address`]; a script -/// that isn't a valid address is a hard error — corruption is never silently -/// dropped, matching [`crate::sqlite::schema::core_state::load_used_addresses`]. +/// A `script` reused across accounts yields several rows; the owner is picked +/// by the same PK-ordered tie-break as [`owning_account_for_script`] +/// (`account_type` first), so both resolvers attribute a shared script +/// identically. `network` turns each stored `script` back into an +/// [`Address`](dashcore::Address); a script that isn't a valid address is a +/// hard error — corruption is never silently dropped, matching +/// [`crate::sqlite::schema::core_state::load_used_addresses`]. pub fn load_used_addresses( conn: &rusqlite::Connection, wallet_id: &WalletId, network: dashcore::Network, -) -> Result, WalletStorageError> { +) -> Result, WalletStorageError> { // Gate the largest stored `script` with a cheap aggregate BEFORE the - // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a - // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB - // cap) rather than SQLite's own `TooBig` mid-sort, and never OOMs the host. + // ordered read materializes or sorts any blob, so a corrupt/oversize + // column raises a typed `BlobTooLarge` (the crate's 16 MiB cap) rather + // than SQLite's own `TooBig` mid-sort, and never OOMs the host. let max_script_len: Option = conn.query_row( "SELECT MAX(length(script)) FROM core_address_pool \ WHERE wallet_id = ?1 AND used = 1", @@ -202,20 +209,50 @@ pub fn load_used_addresses( if let Some(len) = max_script_len { blob::check_size(len)?; } + // Order by script then the pool PK so the first row per script is the + // tie-break winner; a `HashSet` on script drops the trailing duplicates. let mut stmt = conn.prepare( - "SELECT DISTINCT script FROM core_address_pool \ - WHERE wallet_id = ?1 AND used = 1 ORDER BY script", + "SELECT script, account_type, account_index, user_identity_id, friend_identity_id \ + FROM core_address_pool \ + WHERE wallet_id = ?1 AND used = 1 \ + ORDER BY script, account_type, account_index, key_class, user_identity_id, \ + friend_identity_id, pool_type, address_index", )?; let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - row.get::<_, Vec>(0) + Ok(( + row.get::<_, Vec>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, Vec>(3)?, + row.get::<_, Vec>(4)?, + )) })?; + let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); for r in rows { - let script = dashcore::ScriptBuf::from_bytes(r?); + let (raw_script, account_type, index, user, friend) = r?; + if !seen.insert(raw_script.clone()) { + continue; + } + let script = dashcore::ScriptBuf::from_bytes(raw_script); let address = dashcore::Address::from_script(&script, network).map_err(|_| { WalletStorageError::blob_decode("core_address_pool.script not an address") })?; - out.push(address); + let account_index = + crate::sqlite::util::safe_cast::i64_to_u32("core_address_pool.account_index", index)?; + let user_identity_id = <[u8; 32]>::try_from(user.as_slice()) + .map_err(|_| WalletStorageError::blob_decode("core_address_pool.user_identity_id"))?; + let friend_identity_id = <[u8; 32]>::try_from(friend.as_slice()) + .map_err(|_| WalletStorageError::blob_decode("core_address_pool.friend_identity_id"))?; + out.push(( + address, + OwningAccount { + account_type, + account_index, + user_identity_id, + friend_identity_id, + }, + )); } Ok(out) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index bbf89624381..5e1f2222191 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -446,17 +446,23 @@ pub fn load_state( } /// Every address that has ever held a `core_utxos` row for this wallet — -/// spent **and** unspent — deduplicated. The rehydration address-reuse -/// guard: an address whose UTXO was since spent must still be marked used -/// so it's never handed back out as a fresh receive address. `network` -/// turns each persisted `script` back into an [`Address`](dashcore::Address); -/// a script that isn't a valid address is a hard error (corruption is never -/// silently dropped), matching [`load_state`]'s unspent-UTXO handling. +/// spent **and** unspent — deduplicated, each paired with its resolved +/// owning account. The rehydration address-reuse guard: an address whose +/// UTXO was since spent must still be marked used so it's never handed back +/// out as a fresh receive address. +/// +/// `core_utxos` carries no unambiguous account attribution, so ownership is +/// resolved per script via [`owning_account_for_script`]; the result is +/// `None` when the script matches no pool row (the caller then routes to the +/// first funds account). `network` turns each persisted `script` back into an +/// [`Address`](dashcore::Address); a script that isn't a valid address is a +/// hard error (corruption is never silently dropped), matching [`load_state`]'s +/// unspent-UTXO handling. pub fn load_used_addresses( conn: &Connection, wallet_id: &WalletId, network: dashcore::Network, -) -> Result, WalletStorageError> { +) -> Result)>, WalletStorageError> { // Gate the largest stored `script` with a cheap aggregate BEFORE the // `DISTINCT ... ORDER BY script` read materializes or sorts any blob, so a // corrupt/oversize column raises a typed `BlobTooLarge` (the crate's 16 MiB @@ -471,17 +477,25 @@ pub fn load_used_addresses( if let Some(len) = max_script_len { blob::check_size(len)?; } - let mut stmt = conn - .prepare("SELECT DISTINCT script FROM core_utxos WHERE wallet_id = ?1 ORDER BY script")?; - let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { - row.get::<_, Vec>(0) - })?; - let mut out = Vec::new(); - for r in rows { - let script = dashcore::ScriptBuf::from_bytes(r?); + // Materialize the scripts before resolving ownership: `owning_account_for_script` + // prepares its own statement on `conn`, so the reader statement must be + // finished first. + let scripts: Vec> = { + let mut stmt = conn.prepare( + "SELECT DISTINCT script FROM core_utxos WHERE wallet_id = ?1 ORDER BY script", + )?; + let rows = stmt.query_map(params![wallet_id.as_slice()], |row| { + row.get::<_, Vec>(0) + })?; + rows.collect::>()? + }; + let mut out = Vec::with_capacity(scripts.len()); + for raw in scripts { + let owner = owning_account_for_script(conn, wallet_id, &raw)?; + let script = dashcore::ScriptBuf::from_bytes(raw); let address = dashcore::Address::from_script(&script, network) .map_err(|_| WalletStorageError::blob_decode("core_utxos.script not an address"))?; - out.push(address); + out.push((address, owner)); } Ok(out) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs index cef0787ffe6..fd0342374cd 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/util/wallet.rs @@ -70,10 +70,14 @@ pub(crate) fn build_wallet( /// from the map (its script matched no pool row) falls back to the first /// funds account and re-warms on the next sync. /// - `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. +/// used, each mapped to its owning account (`None` when the script matched +/// no pool row). Each is routed to its owning funds account — via the same +/// identity match as `utxo_accounts` — and marked used there, 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 +/// from its own account (address-reuse guard). An owner absent from this +/// wallet's funds accounts, or a `None` owner, falls back to the first +/// account. Empty = no pool used-state carried. /// /// # Reconstructed (safety-critical-correct) /// @@ -144,7 +148,7 @@ pub fn apply_persisted_core_state( manifest: &[AccountRegistrationEntry], core: &CoreChangeSet, utxo_accounts: &std::collections::HashMap, - used_pool_addresses: &[key_wallet::Address], + used_pool_addresses: &std::collections::HashMap>, ) -> Result<(), WalletStorageError> { use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -209,44 +213,45 @@ pub fn apply_persisted_core_state( // never scans another account's keys as "unresolved". let mut per_account_addrs: Vec> = vec![Vec::new(); funding.len()]; - // Owners that resolve to no funds account (case (b) above), collected in - // the loop and warned once after it — never log per iteration. + // Owners that resolve to no funds account (case (b) above), plus used + // addresses with an owner absent from this wallet — collected across + // both routing loops and warned once after them, never per iteration. let mut orphaned_owners: Vec = Vec::new(); for utxo in &unspent { - let target = match utxo_accounts.get(&utxo.outpoint) { - None => 0, - Some(owner) => account_keys - .iter() - .position(|k| k == owner) - .unwrap_or_else(|| { - orphaned_owners - .push(format!("{}[{}]", owner.account_type, owner.account_index)); - 0 - }), - }; + let target = route_to_funds_account( + &account_keys, + utxo_accounts.get(&utxo.outpoint), + &mut orphaned_owners, + ); funding[target].utxos.insert(utxo.outpoint, (*utxo).clone()); per_account_addrs[target].push(utxo.address.clone()); } + + // The persisted pool used-state 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). Each is routed to its owning funds + // account (same identity match as the UTXOs), so a used address on a + // non-first account is marked used on ITS OWN pool. A `None` owner, or + // one absent from this wallet, falls back to the first account. An + // empty map marks only the unspent-UTXO addresses. + for (addr, owner) in used_pool_addresses { + let target = + route_to_funds_account(&account_keys, owner.as_ref(), &mut orphaned_owners); + per_account_addrs[target].push(addr.clone()); + } + if !orphaned_owners.is_empty() { tracing::warn!( wallet_id = %hex::encode(wallet_id), orphaned_owners = ?orphaned_owners, count = orphaned_owners.len(), - "rehydration: restored UTXO(s) reference a funds account not present \ - in this wallet — routed to the first account as best effort; the \ - per-account view re-warms on the next sync" + "rehydration: restored UTXO(s) or used address(es) reference a funds \ + account not present in this wallet — routed to the first account as \ + best effort; the per-account view re-warms on the next sync" ); } - // The persisted pool used-state 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). It carries no per-address owner, so it - // re-marks on the first funds account; a cross-account used address - // re-warms on the next sync. An empty `used_pool_addresses` marks only - // the unspent-UTXO addresses. - per_account_addrs[0].extend(used_pool_addresses.iter().cloned()); - // Eager derivation covers only `0..gap_limit`; extend each chain to // cover restored / used addresses at deeper indices. for i in 0..funding.len() { @@ -268,6 +273,29 @@ pub fn apply_persisted_core_state( Ok(()) } +/// Resolve an owning account to its position among `account_keys`, or fall +/// back to the first funds account. A `None` owner (no attribution available) +/// falls back silently; an owner not present in `account_keys` (store drift) +/// falls back too but is recorded in `orphaned_owners` for a single post-loop +/// `tracing::warn!`. Shared by the UTXO and used-address routing loops so both +/// bucket funds and used-state by the exact same identity match. +fn route_to_funds_account( + account_keys: &[OwningAccount], + owner: Option<&OwningAccount>, + orphaned_owners: &mut Vec, +) -> usize { + match owner { + None => 0, + Some(owner) => account_keys + .iter() + .position(|k| k == owner) + .unwrap_or_else(|| { + orphaned_owners.push(format!("{}[{}]", owner.account_type, owner.account_index)); + 0 + }), + } +} + /// Owning-account identity of a funds account, keyed on the same /// discriminators the UTXO side channel resolves from `core_address_pool`: /// the `account_type` label, numeric index, and DashPay identity pair. Enough @@ -741,8 +769,14 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); // The wallet total is exact regardless (a sum over the UTXO set). assert_eq!(wallet_info.balance.total(), expected_total); @@ -902,8 +936,14 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &utxo_accounts, &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &utxo_accounts, + &Default::default(), + ) + .unwrap(); let bip44 = wallet_info .accounts @@ -943,6 +983,113 @@ mod tests { ); } + /// Regression (dashpay/platform#3968): a restored *used* address (its funds + /// since spent, so no unspent UTXO anchors it) owned by a non-first funds + /// account must be marked used on ITS OWN account's pool — not collapsed + /// onto the first account. On a `Default` wallet CoinJoin[0] is not first + /// (Standard BIP44[0] is), so a used CoinJoin address routed by owner must + /// land `used` in the CoinJoin pool and be absent from the BIP44 pool — + /// otherwise it stays "unused" on CoinJoin and could be re-issued as a + /// fresh receive address (the address-reuse privacy leak). + #[test] + fn rehydration_routes_used_address_to_owning_account() { + 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 std::collections::HashMap; + + let wallet = Wallet::from_seed_bytes( + [12u8; 64], + Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let manifest = manifest_for(&wallet); + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet, 1); + + let coinjoin_type = wallet_info + .accounts + .coinjoin_accounts + .get(&0) + .unwrap() + .managed_account_type() + .to_account_type(); + + // CoinJoin external index-0 address (in the eager window, already + // derived in the CoinJoin pool) — a previously-used receive address. + let coinjoin_used: Address = { + let xpub = manifest + .iter() + .find(|e| e.account_type == coinjoin_type) + .map(|e| e.account_xpub) + .expect("coinjoin xpub in manifest"); + let mut p = AddressPool::new_without_generation( + DerivationPath::master(), + AddressPoolType::External, + DEFAULT_EXTERNAL_GAP_LIMIT, + Network::Testnet, + ); + p.generate_addresses(1, &KeySource::Public(xpub), true) + .unwrap(); + p.address_at_index(0).unwrap() + }; + + // Known owner: CoinJoin[0], exactly as the pool resolver attributes it. + let mut used: HashMap> = HashMap::new(); + used.insert( + coinjoin_used.clone(), + Some(owning_account_of( + wallet_info.accounts.coinjoin_accounts.get(&0).unwrap(), + )), + ); + + // No UTXOs — only the persisted pool used-state. + let core = platform_wallet::changeset::CoreChangeSet { + last_processed_height: Some(1), + synced_height: Some(1), + ..Default::default() + }; + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &used, + ) + .unwrap(); + + let coinjoin = wallet_info.accounts.coinjoin_accounts.get(&0).unwrap(); + let cj_external = coinjoin + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin External pool"); + assert!( + cj_external + .address_info(&coinjoin_used) + .expect("used address must be present in the CoinJoin pool") + .used, + "used CoinJoin address must be marked used on the CoinJoin pool, not account 0" + ); + + // It must NOT have been (mis)routed onto the first (BIP44) account. + let bip44 = wallet_info + .accounts + .standard_bip44_accounts + .get(&0) + .unwrap(); + for pool in bip44.managed_account_type().address_pools() { + assert!( + pool.address_info(&coinjoin_used).is_none(), + "the CoinJoin used address must not appear in any BIP44 pool" + ); + } + } + /// 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), @@ -1057,8 +1204,14 @@ mod tests { }; // Must not panic. tracing::warn! fires for the unresolved count. - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); // Total balance is exact — foreign UTXO is in the set regardless. assert_eq!( @@ -1197,8 +1350,14 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); // Balance is exact. assert_eq!( @@ -1301,8 +1460,14 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); let funds = wallet_info .accounts @@ -1415,15 +1580,26 @@ mod tests { }; // Pool used-state carried for both addresses (the reuse guard the - // SQLite persister feeds via `core_state::load_used_addresses`). - let used_core_addresses = vec![in_window_used.clone(), deep_used.clone()]; + // SQLite persister feeds via `core_state::load_used_addresses`). Single + // funds account, so a `None` owner routes to it. + let used_core_addresses: std::collections::HashMap> = + [in_window_used.clone(), deep_used.clone()] + .into_iter() + .map(|a| (a, None)) + .collect(); // 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, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut baseline, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); let funds = baseline .accounts .all_funding_accounts() @@ -1551,18 +1727,24 @@ mod tests { let in_window_used = derive(20); let wedge_used = derive(45); - // No UTXOs at all — only the persisted pool used-state. + // No UTXOs at all — only the persisted pool used-state. Single funds + // account, so a `None` owner routes to it. let core = platform_wallet::changeset::CoreChangeSet { last_processed_height: Some(1), synced_height: Some(1), ..Default::default() }; + let used: std::collections::HashMap> = + [in_window_used.clone(), wedge_used.clone()] + .into_iter() + .map(|a| (a, None)) + .collect(); apply_persisted_core_state( &mut wallet_info, &manifest, &core, &Default::default(), - &[in_window_used.clone(), wedge_used.clone()], + &used, ) .unwrap(); @@ -1681,8 +1863,14 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); // The wallet total is exact regardless (a sum over the UTXO set). assert_eq!(wallet_info.balance.total(), value); @@ -1782,7 +1970,7 @@ mod tests { &manifest, &core, &Default::default(), - &[], + &Default::default(), ) .expect_err("must fail closed when no funds account can hold the UTXOs"); match err { @@ -1823,8 +2011,14 @@ mod tests { synced_height: Some(1), ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .expect("empty UTXO set must be Ok even with no funds account"); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .expect("empty UTXO set must be Ok even with no funds account"); } /// Regression: a `last_applied_chain_lock` carried in the persisted @@ -1863,8 +2057,14 @@ mod tests { ..Default::default() }; - apply_persisted_core_state(&mut wallet_info, &manifest, &core, &Default::default(), &[]) - .unwrap(); + apply_persisted_core_state( + &mut wallet_info, + &manifest, + &core, + &Default::default(), + &Default::default(), + ) + .unwrap(); assert_eq!( wallet_info.metadata.last_applied_chain_lock.as_ref(), diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs index fbf7d7403ad..152343c9aaf 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_compile_time.rs @@ -72,10 +72,11 @@ const READ_ONLY_PREPARE_ALLOWED: &[(&str, &str)] = &[ "SELECT length(outpoint), outpoint, value, length(script), script, height", ), ("core_state.rs", "SELECT DISTINCT script FROM core_utxos"), - // Pool reader: verbatim used-set, a one-shot read-only scan per wallet. + // Pool reader: verbatim used-set with owner columns, a one-shot read-only + // scan per wallet. ( "core_pool.rs", - "SELECT DISTINCT script FROM core_address_pool", + "SELECT script, account_type, account_index, user_identity_id, friend_identity_id", ), // Full-rehydration readers — one-shot SELECTs in `load_state`. ( diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs index 1a638821efe..efabe64cf2d 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_core_state_reader.rs @@ -129,7 +129,7 @@ fn rt2_nonzero_balance_survives_reopen() { &manifest_for(&wallet), &core, &utxo_accounts, - &[], + &Default::default(), ) .expect("BIP44 reconstruction must not error"); let bal = WalletInfoInterface::balance(&info); @@ -303,7 +303,7 @@ fn f2_no_bip44_wallet_nonzero_balance_survives_reopen() { &manifest_for(&wallet), &core, &utxo_accounts, - &[], + &Default::default(), ) .expect("CoinJoin-only reconstruction must not error"); let bal = WalletInfoInterface::balance(&info); @@ -598,7 +598,7 @@ fn rehydration_routes_via_real_sql_resolver() { &manifest_for(&wallet), &core, &utxo_accounts, - &[], + &Default::default(), ) .expect("apply must not error"); @@ -629,3 +629,151 @@ fn rehydration_routes_via_real_sql_resolver() { ); assert_eq!(managed.balance.total(), 12_000, "wallet total is the sum"); } + +/// End-to-end regression (dashpay/platform#3968) for the address-reuse guard, +/// exercising the REAL SQL resolver. Persists a `Default` wallet with a *used* +/// address (via a `core_address_pool` snapshot with `used = true`) owned by +/// CoinJoin[0] — which is NOT the first funds account (Standard BIP44[0] is) — +/// with no unspent UTXO anchoring it. Reopens the DB, unions the two +/// used-address sources exactly as the persister does (so +/// `core_pool::load_used_addresses` carries the owner), then drives +/// `apply_persisted_core_state`. The used address must land `used` on the +/// CoinJoin pool specifically — never collapsed onto BIP44 — or it stays +/// "unused" on CoinJoin and could be re-issued as a fresh receive address. +#[cfg(feature = "rehydration-apply")] +#[test] +fn rehydration_routes_used_addresses_to_owning_account() { + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::AddressPoolType; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use platform_wallet::changeset::AccountAddressPoolEntry; + use platform_wallet_storage::sqlite::schema::core_pool::OwningAccount; + use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; + use std::collections::HashMap; + + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xC2); + ensure_wallet_meta(&persister, &w); + + let wallet = Wallet::from_seed_bytes( + [0x9B; 64], + key_wallet::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + + // CoinJoin[0] external index-0 address, marked used in the snapshot. + let mut coinjoin_used = first_external_info(&wallet, |at| { + matches!(at, AccountType::CoinJoin { index: 0 }) + }); + coinjoin_used.used = true; + let bip44_info = first_external_info(&wallet, |at| { + matches!( + at, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + ) + }); + assert_ne!( + bip44_info.script_pubkey, coinjoin_used.script_pubkey, + "BIP44[0] and CoinJoin[0] must derive distinct scripts" + ); + + let pool_entry = |account_type, info: &key_wallet::AddressInfo| AccountAddressPoolEntry { + account_type, + pool_type: AddressPoolType::External, + addresses: vec![info.clone()], + }; + persister + .store( + w, + PlatformWalletChangeSet { + account_address_pools: vec![ + pool_entry( + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + &bip44_info, + ), + pool_entry(AccountType::CoinJoin { index: 0 }, &coinjoin_used), + ], + ..Default::default() + }, + ) + .expect("store must persist the pool snapshot"); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let (core, utxo_accounts) = + core_state::load_state(&conn, &w, key_wallet::Network::Testnet).expect("load_state"); + + // Union the two used-address sources exactly as the persister does — the + // pool source carries the known owner (CoinJoin), authoritative on conflict. + let used: HashMap> = { + let mut map: HashMap> = HashMap::new(); + for (addr, owner) in core_pool::load_used_addresses(&conn, &w, key_wallet::Network::Testnet) + .expect("core_pool used addresses") + { + map.entry(addr).or_insert(Some(owner)); + } + for (addr, owner) in + core_state::load_used_addresses(&conn, &w, key_wallet::Network::Testnet) + .expect("core_utxos used addresses") + { + map.entry(addr).or_insert(owner); + } + map + }; + drop(conn); + + // The real pool resolver attributed the used address to CoinJoin[0]. + assert_eq!( + used.get(&coinjoin_used.address), + Some(&Some(OwningAccount { + account_type: "coinjoin".to_string(), + account_index: 0, + user_identity_id: [0u8; 32], + friend_identity_id: [0u8; 32], + })), + "the used address must resolve to the CoinJoin owner from the pool" + ); + + let mut managed = ManagedWalletInfo::from_wallet(&wallet, 1); + platform_wallet_storage::sqlite::util::apply_persisted_core_state( + &mut managed, + &manifest_for(&wallet), + &core, + &utxo_accounts, + &used, + ) + .expect("apply must not error"); + + // The used address is marked used on the CoinJoin pool specifically. + let coinjoin = managed.accounts.coinjoin_accounts.get(&0).unwrap(); + let cj_external = coinjoin + .managed_account_type() + .address_pools() + .into_iter() + .find(|p| p.pool_type == AddressPoolType::External) + .expect("CoinJoin External pool"); + assert!( + cj_external + .address_info(&coinjoin_used.address) + .expect("used address present in the CoinJoin pool") + .used, + "used CoinJoin address must be marked used on the CoinJoin pool, not BIP44" + ); + + // It must NOT have been (mis)routed onto the first (BIP44) account. + let bip44 = managed.accounts.standard_bip44_accounts.get(&0).unwrap(); + for pool in bip44.managed_account_type().address_pools() { + assert!( + pool.address_info(&coinjoin_used.address).is_none(), + "the CoinJoin used address must not appear in any BIP44 pool" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs index d56f8658db4..51a717306ac 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_migration_execution.rs @@ -38,7 +38,11 @@ fn used_set(persister: &SqlitePersister, w: &WalletId) -> Vec drop(conn); let mut seen = std::collections::HashSet::new(); let mut union = Vec::new(); - for addr in pool.into_iter().chain(utxo) { + for addr in pool + .into_iter() + .map(|(addr, _owner)| addr) + .chain(utxo.into_iter().map(|(addr, _owner)| addr)) + { if seen.insert(addr.script_pubkey().to_bytes()) { union.push(addr); } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs index 1faa7b58e4d..235b595c14a 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_pool_reader.rs @@ -36,16 +36,24 @@ use platform_wallet_storage::sqlite::schema::{core_pool, core_state}; use platform_wallet_storage::SqlitePersister; /// Verbatim `core_address_pool` `used=1` addresses — the pool half of the -/// reuse-guard set `load()` reads. +/// reuse-guard set `load()` reads (owner dropped; these tests assert addresses). fn pool_used(persister: &SqlitePersister, w: &WalletId) -> Vec

{ let conn = persister.lock_conn_for_test(); - core_pool::load_used_addresses(&conn, w, Network::Testnet).expect("pool used-set") + core_pool::load_used_addresses(&conn, w, Network::Testnet) + .expect("pool used-set") + .into_iter() + .map(|(addr, _owner)| addr) + .collect() } /// `core_utxos`-derived used addresses (spent + unspent) — the UTXO half. fn utxo_used(persister: &SqlitePersister, w: &WalletId) -> Vec
{ let conn = persister.lock_conn_for_test(); - core_state::load_used_addresses(&conn, w, Network::Testnet).expect("utxo used-set") + core_state::load_used_addresses(&conn, w, Network::Testnet) + .expect("utxo used-set") + .into_iter() + .map(|(addr, _owner)| addr) + .collect() } /// The assembled reuse-guard set `load()` hands the manager: pool ∪ UTXO, @@ -57,7 +65,11 @@ fn used_set(persister: &SqlitePersister, w: &WalletId) -> Vec
{ drop(conn); let mut seen = std::collections::HashSet::new(); let mut union = Vec::new(); - for addr in pool.into_iter().chain(utxo) { + for addr in pool + .into_iter() + .map(|(addr, _owner)| addr) + .chain(utxo.into_iter().map(|(addr, _owner)| addr)) + { if seen.insert(addr.script_pubkey().to_bytes()) { union.push(addr); }