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/Cargo.lock b/Cargo.lock index 5148cee60c1..458bfbef527 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]] @@ -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", @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index c7703f6b683..84d8a4f2b94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,14 +50,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "991c6ebe24d7ea8ba7d900a052b25be8c5498409" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "a8a096838b829cf5bec3c2374a23511640a0c35c" } 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-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 { 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 5930c1c4db6..65a110dca70 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -174,6 +174,83 @@ 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; + +/// 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 — 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. + 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 => 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, + }, + // `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 @@ -296,23 +373,112 @@ 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 { + // 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); - 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 = 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, + }, + ); + } 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. @@ -415,4 +581,63 @@ 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 86b5561518c..ccd9875a050 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; @@ -148,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 @@ -1541,11 +1541,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: corrupt_kind_from_build_err(&e), + }, + )); + } } } @@ -2752,12 +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. +/// 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. fn build_wallet_start_state( entry: &WalletRestoreEntryFFI, ) -> Result< @@ -2808,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!("failed to decode account xpub: {}", 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| { @@ -2821,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 @@ -2847,14 +2910,25 @@ 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. // + // 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, @@ -3377,11 +3451,56 @@ 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 (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() + .into_iter() + .map(|a| AccountRegistrationEntry { + account_type: a.account_type, + account_xpub: a.account_xpub, + }) + .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. + // + // `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 { - wallet, - wallet_info, + network, + birth_height: entry.birth_height, + account_manifest, + 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: Vec::new(), }; let platform_address_state = if per_account.is_empty() @@ -4038,6 +4157,33 @@ 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)" + ); + } + /// 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/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 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/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_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_start_state.rs index c63e5a262be..e8e1ed962b7 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 / @@ -42,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() @@ -53,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" + ); + } +} 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..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 @@ -1,36 +1,98 @@ //! 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 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) +//! 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). 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::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::{Address, 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. + 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 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 + /// `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, /// 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, + /// 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/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/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`. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index c94cb7093d1..f7097690a3c 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; /// Errors that can occur in platform wallet operations @@ -10,6 +11,68 @@ 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 — + /// 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/events.rs b/packages/rs-platform-wallet/src/events.rs index 9ac256e8730..d24b137b76b 100644 --- a/packages/rs-platform-wallet/src/events.rs +++ b/packages/rs-platform-wallet/src/events.rs @@ -16,9 +16,11 @@ 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. /// @@ -44,6 +46,13 @@ 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 @@ -142,6 +151,17 @@ 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 289a71378fd..2c899cf25d9 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -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/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e7af9be1c7..d51b62759aa 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::{CorruptKind, LoadOutcome, SkipReason}; use crate::wallet::core::WalletBalance; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -13,80 +16,209 @@ 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 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 + /// `MnemonicResolverHandle` (`rs-sdk-ffi`). + /// + /// # Skip vs hard-fail /// - /// 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/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 + /// [`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)): + /// `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. /// - /// 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. /// - /// [`WalletManager`]: key_wallet_manager::WalletManager - pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { + /// # 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 { 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().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 _; - // 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(); + + // 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 { - wallet, - wallet_info, + network, + birth_height, + account_manifest, + core_wallet_info, + core_state, identity_manager, unused_asset_locks, + contacts, + identity_keys, + used_core_addresses, } = wallet_state; - // Flatten the (account → outpoint → lock) map into the flat - // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` - // holds today. + // 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 + // 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 }; + outcome.skipped.push((expected_wallet_id, reason.clone())); + self.event_manager + .on_wallet_skipped_on_load(expected_wallet_id, &reason); + continue '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 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 + } + // 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(); 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,21 +226,34 @@ 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) { 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: {}", @@ -120,15 +265,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 +278,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 +299,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 +318,49 @@ impl PlatformWalletManager

{ return Err(err); } - Ok(()) + 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 new file mode 100644 index 00000000000..8c0e869c2b8 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/load_outcome.rs @@ -0,0 +1,89 @@ +//! 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` +/// (`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). +#[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. + /// `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)] +#[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. +/// +/// `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)] +#[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)>, +} diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3d04ca086d0..57fa3784348 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,19 @@ 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 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 /// (`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 +168,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..2b7238e1cef --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/rehydrate.rs @@ -0,0 +1,1706 @@ +//! 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. +//! 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 + +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. +/// +/// 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 +/// +/// # 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], + manifest: &[AccountRegistrationEntry], +) -> Result { + if manifest.is_empty() { + return Err(CorruptKind::MissingManifest); + } + 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, + entry.account_xpub, + network, + ) + .map_err(|_| CorruptKind::MalformedXpub)?; + accounts + .insert(account) + .map_err(|e| CorruptKind::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_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::*; + 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, 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/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/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/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 new file mode 100644 index 00000000000..7068b05a62c --- /dev/null +++ b/packages/rs-platform-wallet/tests/rehydration_load.rs @@ -0,0 +1,790 @@ +//! 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; +//! 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. +//! - 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 `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}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::Wallet; +use platform_wallet::changeset::{ + AccountRegistrationEntry, ClientStartState, ClientWalletStartState, CoreChangeSet, + 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::{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_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(), + }, + ); + } + 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 (manifest, id) = manifest_and_id(seed); + ( + id, + 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(), + contacts: Default::default(), + identity_keys: Default::default(), + used_core_addresses: 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-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-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, _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 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_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 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 `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 `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.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::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/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index ade11828594..6bedd522d8f 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -39,23 +39,23 @@ //! # 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` 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 -//! [`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. +//! - **[`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 +//! `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 +//! 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 +//! 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. @@ -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,38 @@ 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, derive the BIP-32 + /// extended private key at `path`, and hand it *by reference* to + /// `extract`, returning whatever `extract` produces. /// - /// 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 + /// + /// 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 + /// `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 + /// (`ExtendedPubKey`) or a `Zeroizing` scalar copy; the caller wipes the + /// latter on its own drop. The mnemonic and seed buffers are plain arrays + /// and ride [`Zeroizing`] wrappers for the same guarantee. + /// + /// # 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> { + extract: impl FnOnce(&ExtendedPrivKey) -> T, + ) -> Result { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); } @@ -291,30 +311,30 @@ impl MnemonicResolverCoreSigner { drop(mnemonic); let secp = Secp256k1::new(); - let mut master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) + let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - let mut derived = master + let 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`. - master.private_key.non_secure_erase(); - derived.private_key.non_secure_erase(); - - Ok(bytes) + 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. 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> { + // `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()) + }) } } @@ -362,6 +382,24 @@ 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 secp = Secp256k1::new(); + // `ExtendedPubKey` carries only public material (chain code + point); + // the borrowed private key never leaves `resolve_and_derive`. + self.resolve_and_derive(path, |derived| ExtendedPubKey::from_priv(&secp, derived)) + } } #[cfg(test)] @@ -456,6 +494,51 @@ 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); + + // 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) }; + } + #[tokio::test] async fn missing_resolver_surfaces_not_found_error() { let resolver = make_resolver(missing_resolve); 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()); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 0e433d368ef..df0098c918a 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,19 +326,49 @@ 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); + /// [`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" + default: return "unknown skip reason (\(reasonCode))" + } + } + } + /// Rehydrate wallets from SwiftData on app launch. /// /// 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 +377,40 @@ public class PlatformWalletManager: ObservableObject { public func loadFromPersistor() throws -> [ManagedPlatformWallet] { try ensureConfigured() - try platform_wallet_manager_load_from_persistor(handle).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..