diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 438f543a11..65a110dca7 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 48b4938127..f7097690a3 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 73cf040c4a..d51b62759a 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 8b3cc25e91..8c0e869c2b 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 37a3d18e8d..7068b05a62 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 08b77bb62d..df0098c918 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))"