diff --git a/Cargo.lock b/Cargo.lock index 3c17fb0a008..a3fc0adb609 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1230,7 +1230,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1663,7 +1663,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "bincode", "bincode_derive", @@ -1674,7 +1674,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "dash-network", ] @@ -1769,7 +1769,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "async-trait", "chrono", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "anyhow", "base64-compat", @@ -1824,12 +1824,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" [[package]] name = "dashcore-rpc" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "dashcore-rpc-json", "hex", @@ -1842,7 +1842,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "bincode", "dashcore", @@ -1857,7 +1857,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "bincode", "dashcore-private", @@ -2496,7 +2496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2926,7 +2926,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" [[package]] name = "glob" @@ -3631,7 +3631,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.4", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3882,7 +3882,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4138,7 +4138,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "aes", "async-trait", @@ -4167,7 +4167,7 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "cbindgen 0.29.4", "dash-network", @@ -4183,7 +4183,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.45.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd#93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" +source = "git+https://github.com/dashpay/rust-dashcore?rev=697bfb7251123e5ad12848631b819668119ef722#697bfb7251123e5ad12848631b819668119ef722" dependencies = [ "async-trait", "bincode", @@ -4705,7 +4705,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]] @@ -5769,7 +5769,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2 0.6.4", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -5807,7 +5807,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.4", + "socket2 0.5.10", "tracing", "windows-sys 0.59.0", ] @@ -6630,7 +6630,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6689,7 +6689,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -7540,7 +7540,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -8986,7 +8986,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2ccbaf3a976..16cb05e44aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,14 +53,14 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "93260bf39bac5d9d09e89bfb45e9ea3ff7fdcbcd" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "697bfb7251123e5ad12848631b819668119ef722" } tokio-metrics = "0.5" # Size-tuned profile for the iOS `rs-unified-sdk-ffi` staticlib, which 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 e8e717d0a18..68e3dd8ff8a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -41,6 +41,19 @@ impl From<&dashcore::OutPoint> for OutPointFFI { } } +impl From<&OutPointFFI> for dashcore::OutPoint { + /// The inverse of [`OutPointFFI::new`] — the one authority for reading + /// an outpoint a host hands back (a page cursor, a classification + /// query), so the byte order round-trips exactly. + fn from(outpoint: &OutPointFFI) -> Self { + use dashcore::hashes::Hash as _; + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array(outpoint.txid), + vout: outpoint.vout, + } + } +} + /// Outpoint of a TXO that was spent, paired with the spending /// transaction's txid. Replaces the bare `OutPointFFI` on /// `AccountChangeSetFFI.utxos_spent` so the Swift persister can @@ -54,6 +67,73 @@ pub struct SpentOutPointFFI { pub spending_txid: [u8; 32], } +/// `UtxoCreditVerdictFFI::verdict`: the wallet observed a block at +/// `spent_at_height` spending the outpoint before the output was +/// recognised, so the engine never credited it (rust-dashcore#649 skip; +/// the spender may be unrecorded — rust-dashcore#992). +pub const UTXO_CREDIT_VERDICT_OBSERVED_SPENT: u8 = 1; +/// `UtxoCreditVerdictFFI::verdict`: the record is an unconfirmed +/// transaction whose input a block already spent; nothing it created was +/// credited and no sweep will delete its row. +pub const UTXO_CREDIT_VERDICT_DOOMED: u8 = 2; +/// `UtxoCreditVerdictFFI::verdict`: not credited for a reason the bridge +/// cannot name (spent, abandoned or swept between emit and drain, or an +/// account-level spent mark). Carries no context: a persister must not +/// hand the coin back as unspent on this delivery, and must not mark it +/// spent on this evidence alone. +pub const UTXO_CREDIT_VERDICT_UNCREDITED: u8 = 3; + +/// The engine's verdict on one `Received` / `Change` output that this +/// round's records carry but the engine did NOT credit to the owning +/// account — delivered through the size-negotiated extension slot +/// `on_persist_wallet_changeset_utxo_verdicts_fn`, BEFORE the round's +/// changeset callback, so a persister can consult it while it +/// materialises the round's `utxos_added` entries. Absence of an outpoint +/// here means credited: the ordinary case. +/// +/// Rides the extension rather than `WalletChangeSetFFI` / `UtxoEntryFFI` +/// for the layout reason documented on `WalletChangeSetFFI`: both cross +/// by bare pointer, so a field appended to either cannot be proven present +/// to a consumer built after a producer, while the extension's +/// `struct_size` is exactly that proof. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct UtxoCreditVerdictFFI { + /// The output the verdict is about. + pub outpoint: OutPointFFI, + /// One of the `UTXO_CREDIT_VERDICT_*` constants. + pub verdict: u8, + /// Height of the block observed spending the outpoint when `verdict` + /// is [`UTXO_CREDIT_VERDICT_OBSERVED_SPENT`]; 0 otherwise. + pub spent_at_height: u32, +} + +/// Project a changeset's credit verdicts into their C mirrors for the +/// extension slot, in outpoint order (the map's own ordering — stable, +/// so a host log of a round is reproducible). +pub(crate) fn build_utxo_credit_verdicts_for_callback( + cs: &platform_wallet::changeset::CoreChangeSet, +) -> Vec { + use platform_wallet::changeset::changeset::UtxoCreditVerdict; + cs.utxo_credit_verdicts + .iter() + .map(|(outpoint, verdict)| { + let (code, spent_at_height) = match verdict { + UtxoCreditVerdict::ObservedSpent { height } => { + (UTXO_CREDIT_VERDICT_OBSERVED_SPENT, *height) + } + UtxoCreditVerdict::Doomed => (UTXO_CREDIT_VERDICT_DOOMED, 0), + UtxoCreditVerdict::Uncredited => (UTXO_CREDIT_VERDICT_UNCREDITED, 0), + }; + UtxoCreditVerdictFFI { + outpoint: OutPointFFI::from(outpoint), + verdict: code, + spent_at_height, + } + }) + .collect() +} + // --------------------------------------------------------------------------- // Chain state // --------------------------------------------------------------------------- @@ -2084,3 +2164,92 @@ mod tests { unsafe { crate::wallet::platform_wallet_manager_free_masternodes_v2(v2, 2) }; } } + +// --------------------------------------------------------------------------- +// Wallet UTXO inventory and outpoint classification (store reconcile) +// --------------------------------------------------------------------------- + +/// One row of a wallet's UTXO inventory page — the C mirror of +/// `platform_wallet::manager::accessors::WalletUtxoRow`, with the owning +/// account projected into the same flat tag layout `AccountSpecFFI` and +/// `AccountBalanceEntryFFI` use, so a store that keys rows by account can +/// file a healed row under the right one. +/// +/// Returned by `platform_wallet_wallet_utxos_page`; every row's `address` +/// and `script_pubkey` allocations belong to Rust and are released by +/// `platform_wallet_wallet_utxos_page_free`. +#[repr(C)] +#[derive(Debug)] +pub struct WalletUtxoEntryFFI { + pub type_tag: crate::wallet_restore_types::AccountTypeTagFFI, + pub standard_tag: crate::wallet_restore_types::StandardAccountTypeTagFFI, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub outpoint: OutPointFFI, + pub value_duffs: u64, + /// Base58Check address of the output, as the engine holds it. Never + /// null; an empty string when the script has no address form. + pub address: *mut c_char, + /// Null when `script_pubkey_len == 0`. + pub script_pubkey: *mut u8, + pub script_pubkey_len: usize, + pub height: u32, + pub is_confirmed: bool, + pub is_instantlocked: bool, + pub is_coinbase: bool, + pub is_locked: bool, +} + +/// Cursor for `platform_wallet_wallet_utxos_page`: the owning account (the +/// raw tag layout of `AccountSpecFFI`, validated on the Rust side) and +/// outpoint of the LAST row of the previous page. Pass null to start. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct WalletUtxoCursorFFI { + pub type_tag: u8, + pub standard_tag: u8, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub outpoint: OutPointFFI, +} + +/// One store row handed to `platform_wallet_classify_outpoints`: the +/// account the store files the coin under (raw `AccountSpecFFI` tag +/// layout), the outpoint, and the script the store recorded for it. Every +/// pointer is valid for the duration of the call only. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct OutpointOwnershipQueryFFI { + pub type_tag: u8, + pub standard_tag: u8, + pub index: u32, + pub registration_index: u32, + pub key_class: u32, + pub user_identity_id: [u8; 32], + pub friend_identity_id: [u8; 32], + pub outpoint: OutPointFFI, + pub script_pubkey: *const u8, + pub script_pubkey_len: usize, +} + +/// `platform_wallet_classify_outpoints` answer: the engine has no opinion +/// (a funding transaction this session never processed — after a restart +/// the finalized set is empty, so absence proves nothing). +pub const OUTPOINT_CLASS_UNKNOWN: u8 = 0; +/// `platform_wallet_classify_outpoints` answer: the coin is in a funds +/// account's live UTXO set. +pub const OUTPOINT_CLASS_UNSPENT: u8 = 1; +/// `platform_wallet_classify_outpoints` answer: the owning account knows +/// the funding txid, owns the script, and does not hold the coin — the +/// engine skipped it for a spent reason or consumed it. The one class a +/// reconciler may act on. +pub const OUTPOINT_CLASS_KNOWN_UNCREDITED: u8 = 2; +/// `platform_wallet_classify_outpoints` answer: the owning account's pools +/// do not monitor the script; the engine could never have credited it. +pub const OUTPOINT_CLASS_NOT_OWNED: u8 = 3; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dfa716ff65c..94113fca25d 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -10,8 +10,8 @@ use crate::handle::*; use crate::persistence::{ FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, - PersistWalletChangesetSweepsFn, PersistenceCallbacks, PersistenceCallbacksExtension, - PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, + PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -264,6 +264,10 @@ unsafe fn persistence_extension_callbacks( on_persist_wallet_changeset_chain_lock_height_fn, PersistWalletChangesetChainLockHeightFn ), + wallet_changeset_utxo_verdicts: slot!( + on_persist_wallet_changeset_utxo_verdicts_fn, + PersistWalletChangesetUtxoVerdictsFn + ), } } @@ -881,6 +885,15 @@ mod tests { 0 } + unsafe extern "C" fn persist_wallet_changeset_utxo_verdicts( + _context: *mut c_void, + _wallet_id: *const u8, + _verdicts: *const crate::core_wallet_types::UtxoCreditVerdictFFI, + _verdicts_count: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn persist_tracked_masternodes( _context: *mut c_void, _network: *const std::os::raw::c_char, @@ -1232,11 +1245,48 @@ mod tests { assert!(read_short.persist_tracked_masternodes.is_none()); assert!(read_short.wallet_changeset_sweeps.is_none()); assert!(read_short.wallet_changeset_chain_lock_height.is_none()); + assert!(read_short.wallet_changeset_utxo_verdicts.is_none()); let read_unknown = unsafe { persistence_extension_callbacks(&unknown) }; assert!(read_unknown.dpns_name_states.is_none()); assert!(read_unknown.load_tracked_masternodes.is_none()); assert!(read_unknown.wallet_changeset_sweeps.is_none()); assert!(read_unknown.wallet_changeset_chain_lock_height.is_none()); + assert!(read_unknown.wallet_changeset_utxo_verdicts.is_none()); + } + + /// A host whose `struct_size` stops right after the chainlock-height + /// slot (built before the credit-verdict slot existed) keeps every + /// earlier slot and simply never has the verdict slot read; a host + /// declaring the full size yields it. + #[test] + fn utxo_verdict_slot_is_gated_by_struct_size() { + let without = PersistenceCallbacksExtension { + struct_size: std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_utxo_verdicts_fn + ), + on_persist_wallet_changeset_sweeps_fn: Some(persist_wallet_changeset_sweeps), + on_persist_wallet_changeset_chain_lock_height_fn: Some( + persist_wallet_changeset_chain_lock_height, + ), + on_persist_wallet_changeset_utxo_verdicts_fn: Some( + persist_wallet_changeset_utxo_verdicts, + ), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&without) }; + assert!(read.wallet_changeset_sweeps.is_some()); + assert!(read.wallet_changeset_chain_lock_height.is_some()); + assert!(read.wallet_changeset_utxo_verdicts.is_none()); + + let with = PersistenceCallbacksExtension { + on_persist_wallet_changeset_utxo_verdicts_fn: Some( + persist_wallet_changeset_utxo_verdicts, + ), + ..Default::default() + }; + let read = unsafe { persistence_extension_callbacks(&with) }; + assert!(read.wallet_changeset_utxo_verdicts.is_some()); } /// A caller whose `struct_size` covers only the dpns field (an @@ -1365,6 +1415,70 @@ mod tests { assert!(read.wallet_changeset_sweeps.is_some()); assert!(read.wallet_changeset_chain_lock_height.is_some()); } + /// A query whose account tag this build cannot map must not fail the + /// batch: the classifier's own answer for anything it cannot name is + /// `Unknown`, and the store row behind the query is durable, so a batch + /// failure would repeat on every reconcile run with the same page. + #[test] + fn classify_outpoints_answers_unknown_for_an_unmappable_account_tag() { + use crate::core_wallet_types::{OutPointFFI, OutpointOwnershipQueryFFI}; + use crate::manager_diagnostics::platform_wallet_classify_outpoints; + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let callbacks = persistence_callbacks(); + let event_cbs = event_callbacks(); + let mut handle = 0; + let created = unsafe { + platform_wallet_manager_create( + &sdk as *const Sdk as *const c_void, + &callbacks, + &event_cbs, + &mut handle, + ) + }; + assert_eq!(created.code, PlatformWalletFFIResultCode::Success); + let wallet_id = [0x11u8; 32]; + let script = [0x76u8, 0xa9, 0x14]; + let query = |type_tag: u8| OutpointOwnershipQueryFFI { + type_tag, + standard_tag: 0, + index: 0, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + outpoint: OutPointFFI { + txid: [0x22; 32], + vout: 0, + }, + script_pubkey: script.as_ptr(), + script_pubkey_len: script.len(), + }; + // An identity-key account tag, a mappable BIP44 tag, and a tag from + // the future, in one batch. + let queries = [query(15), query(0), query(200)]; + let mut out = [0xFFu8; 3]; + let result = unsafe { + platform_wallet_classify_outpoints( + handle, + wallet_id.as_ptr(), + queries.as_ptr(), + queries.len(), + out.as_mut_ptr(), + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "an unmappable tag degrades that query to Unknown, it does not fail the batch" + ); + assert_eq!( + out, + [0, 0, 0], + "every slot is answered, Unknown where nothing can be said" + ); + let destroyed = unsafe { platform_wallet_manager_destroy(handle) }; + assert_eq!(destroyed.code, PlatformWalletFFIResultCode::Success); + } } /// Wallet-generation teardown vs. the deferred-payment registry diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc9..5630ad287be 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -951,3 +951,267 @@ fn account_type_from_spec_ref( } }) } + +// --------------------------------------------------------------------------- +// Wallet UTXO inventory and outpoint classification (store reconcile) +// --------------------------------------------------------------------------- + +/// Build the xpub-less [`AccountSpecFFI`] the shared tag validator +/// (`account_type_from_spec_ref`) reads, from the raw tag fields a cursor +/// or query carries. +#[allow(clippy::too_many_arguments)] +fn account_spec_from_raw_tags( + type_tag: u8, + standard_tag: u8, + index: u32, + registration_index: u32, + key_class: u32, + user_identity_id: [u8; 32], + friend_identity_id: [u8; 32], +) -> AccountSpecFFI { + AccountSpecFFI { + type_tag, + standard_tag, + index, + registration_index, + key_class, + user_identity_id, + friend_identity_id, + account_xpub_bytes: std::ptr::null(), + account_xpub_bytes_len: 0, + } +} + +/// One page of a wallet's UTXO inventory across every funds account, in +/// `(account, outpoint)` order, starting strictly after `cursor` (null = +/// from the beginning). `limit` is clamped natively (0 = default page, +/// never more than the engine's maximum); `out_has_more` says whether a +/// further page exists. The rows and their strings/scripts are released by +/// `platform_wallet_wallet_utxos_page_free`. +/// +/// The wallet lock is taken OUTSIDE the handle registry's guard (see +/// `platform_wallet_manager_sync_progress` for why), so a caller parked +/// behind block processing never stalls `platform_wallet_manager_destroy`. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; `cursor` is null or points at a +/// live `WalletUtxoCursorFFI`; the out-pointers are writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( + manager_handle: Handle, + wallet_id: *const u8, + cursor: *const crate::core_wallet_types::WalletUtxoCursorFFI, + limit: usize, + out_rows: *mut *const crate::core_wallet_types::WalletUtxoEntryFFI, + out_count: *mut usize, + out_has_more: *mut bool, +) -> PlatformWalletFFIResult { + use crate::core_wallet_types::{account_type_to_tags, WalletUtxoEntryFFI}; + use platform_wallet::manager::accessors::{wallet_utxos_page, WalletUtxoCursor}; + + check_ptr!(wallet_id); + check_ptr!(out_rows); + check_ptr!(out_count); + check_ptr!(out_has_more); + *out_rows = std::ptr::null(); + *out_count = 0; + *out_has_more = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + + let after: Option = if cursor.is_null() { + None + } else { + let c = &*cursor; + let spec = account_spec_from_raw_tags( + c.type_tag, + c.standard_tag, + c.index, + c.registration_index, + c.key_class, + c.user_identity_id, + c.friend_identity_id, + ); + let account_type = match account_type_from_spec_ref(&spec) { + Ok(at) => at, + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("cursor: {e}"), + ); + } + }; + Some((account_type, dashcore::OutPoint::from(&c.outpoint))) + }; + + let Some(wallet_manager) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let (rows, has_more) = { + let wm = wallet_manager.blocking_read(); + wallet_utxos_page(&wm, &wid, after.as_ref(), limit) + }; + *out_has_more = has_more; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + + let entries: Vec = rows + .into_iter() + .map(|row| { + let tags = account_type_to_tags(&row.account_type); + let script_len = row.script_pubkey.len(); + let script_ptr = if script_len == 0 { + std::ptr::null_mut() + } else { + Box::into_raw(row.script_pubkey.into_boxed_slice()) as *mut u8 + }; + let address = CString::new(row.address) + .unwrap_or_else(|_| CString::new("").expect("empty string has no NUL")) + .into_raw(); + WalletUtxoEntryFFI { + type_tag: tags.type_tag, + standard_tag: tags.standard_tag, + index: tags.index, + registration_index: tags.registration_index, + key_class: tags.key_class, + user_identity_id: tags.user_identity_id, + friend_identity_id: tags.friend_identity_id, + outpoint: crate::core_wallet_types::OutPointFFI::from(&row.outpoint), + value_duffs: row.value_duffs, + address, + script_pubkey: script_ptr, + script_pubkey_len: script_len, + height: row.height, + is_confirmed: row.is_confirmed, + is_instantlocked: row.is_instantlocked, + is_coinbase: row.is_coinbase, + is_locked: row.is_locked, + } + }) + .collect(); + let count = entries.len(); + *out_rows = Box::into_raw(entries.into_boxed_slice()) as *const _; + *out_count = count; + PlatformWalletFFIResult::ok() +} + +/// Release a page returned by `platform_wallet_wallet_utxos_page`. +/// +/// # Safety +/// `rows`/`count` must be exactly what one call returned, released once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( + rows: *mut crate::core_wallet_types::WalletUtxoEntryFFI, + count: usize, +) { + if rows.is_null() || count == 0 { + return; + } + let slice = std::slice::from_raw_parts(rows, count); + for entry in slice { + if !entry.address.is_null() { + let _ = CString::from_raw(entry.address); + } + if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.script_pubkey, + entry.script_pubkey_len, + )); + } + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); +} + +/// Classify `count` store rows for `wallet_id` — see the +/// `OUTPOINT_CLASS_*` constants; `out_classes[i]` answers `queries[i]`, so +/// `out_classes` must have room for `count` bytes. A query whose account +/// tag this build cannot map keeps `OUTPOINT_CLASS_UNKNOWN` in its slot +/// and the remaining queries are still classified; the call fails only on +/// a bad handle or pointer. Cost is `count × accounts`, never the size of +/// the inventory. Same lock discipline as +/// `platform_wallet_wallet_utxos_page`. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; `queries` points at `count` +/// live entries whose script pointers are valid for the call; +/// `out_classes` is writable for `count` bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_classify_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + queries: *const crate::core_wallet_types::OutpointOwnershipQueryFFI, + count: usize, + out_classes: *mut u8, +) -> PlatformWalletFFIResult { + use platform_wallet::manager::accessors::{classify_outpoints, OutpointOwnershipQuery}; + + check_ptr!(wallet_id); + if count == 0 { + return PlatformWalletFFIResult::ok(); + } + check_ptr!(queries); + check_ptr!(out_classes); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + + // Every slot starts as `Unknown`, the classifier's own answer for + // anything it cannot name. A query whose account tag this build cannot + // map (an identity-key account, a forward-versioned tag, a stray + // standard tag) keeps that answer instead of failing the batch: the row + // behind it is durable, so a batch failure would repeat on every run, + // and the caller already leaves `Unknown` alone. + let out = std::slice::from_raw_parts_mut(out_classes, count); + out.fill(platform_wallet::manager::accessors::OutpointClass::Unknown.as_u8()); + let mut owned: Vec = Vec::with_capacity(count); + let mut positions: Vec = Vec::with_capacity(count); + for (i, q) in std::slice::from_raw_parts(queries, count) + .iter() + .enumerate() + { + let spec = account_spec_from_raw_tags( + q.type_tag, + q.standard_tag, + q.index, + q.registration_index, + q.key_class, + q.user_identity_id, + q.friend_identity_id, + ); + let account_type = match account_type_from_spec_ref(&spec) { + Ok(at) => at, + Err(_) => continue, + }; + positions.push(i); + let script_pubkey = if q.script_pubkey.is_null() || q.script_pubkey_len == 0 { + Vec::new() + } else { + std::slice::from_raw_parts(q.script_pubkey, q.script_pubkey_len).to_vec() + }; + owned.push(OutpointOwnershipQuery { + account_type, + outpoint: dashcore::OutPoint::from(&q.outpoint), + script_pubkey, + }); + } + + let Some(wallet_manager) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let classes = { + let wm = wallet_manager.blocking_read(); + classify_outpoints(&wm, &wid, &owned) + }; + for (position, class) in positions.into_iter().zip(classes) { + out[position] = class.as_u8(); + } + PlatformWalletFFIResult::ok() +} diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 52148306238..06096c1aa96 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -49,7 +49,8 @@ use crate::contact_persistence::{ }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ - build_sweep_batches_for_callback, free_wallet_changeset_ffi, SweepBatchFFI, WalletChangeSetFFI, + build_sweep_batches_for_callback, build_utxo_credit_verdicts_for_callback, + free_wallet_changeset_ffi, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, }; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ @@ -223,6 +224,26 @@ pub type PersistWalletChangesetSweepsFn = unsafe extern "C" fn( pub type PersistWalletChangesetChainLockHeightFn = unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8, chain_lock_height: u32) -> i32; +/// Carries the engine's credit verdicts for the round — every `Received` / +/// `Change` output of the round's records that the engine did NOT credit +/// to the owning account, with the reason (see [`UtxoCreditVerdictFFI`]). +/// Fired inside the round's begin/end bracket, BEFORE +/// `on_persist_wallet_changeset_fn`, so a persister that derives its UTXO +/// rows from record roles can consult the verdicts while it applies the +/// round's `utxos_added` entries — after the changeset callback would be +/// too late, the row would already be staged as unspent. Fired only on +/// rounds that carry at least one verdict; a round where every output was +/// credited never fires it, so ignoring the slot is exactly today's +/// behaviour. A non-zero return fails the round like any other per-kind +/// callback: a verdict silently dropped leaves a phantom coin the store +/// hands back to the engine at the next load. +pub type PersistWalletChangesetUtxoVerdictsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + verdicts: *const UtxoCreditVerdictFFI, + verdicts_count: usize, +) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -319,6 +340,21 @@ pub struct PersistenceCallbacksExtension { chain_lock_height: u32, ) -> i32, >, + /// The round's credit verdicts (see + /// [`PersistWalletChangesetUtxoVerdictsFn`]). Appended under the same + /// version for the same reason as the two slots above: `struct_size` + /// proves whether a host allocated it, and a host that did not simply + /// never has it read — which is exactly today's behaviour, since a + /// verdict only ever refines what the changeset callback would have + /// written on its own. + pub on_persist_wallet_changeset_utxo_verdicts_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + verdicts: *const UtxoCreditVerdictFFI, + verdicts_count: usize, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -333,6 +369,7 @@ impl Default for PersistenceCallbacksExtension { on_load_tracked_masternodes_free_fn: None, on_persist_wallet_changeset_sweeps_fn: None, on_persist_wallet_changeset_chain_lock_height_fn: None, + on_persist_wallet_changeset_utxo_verdicts_fn: None, } } } @@ -348,6 +385,7 @@ pub struct PersistenceExtensionCallbacks { pub load_tracked_masternodes_free: Option, pub wallet_changeset_sweeps: Option, pub wallet_changeset_chain_lock_height: Option, + pub wallet_changeset_utxo_verdicts: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1273,6 +1311,12 @@ pub struct FFIPersister { /// without it simply never collects sweep tombstones (safe — held, not /// leaked to the unspent set). wallet_changeset_chain_lock_height_callback: Option, + /// `Some` only when the host's extension `struct_size` proved the slot + /// was allocated. Carries the engine's credit verdicts for the round's + /// `Received` / `Change` outputs it did not credit; a host without it + /// materialises those rows unspent, as every host did before the slot + /// existed. + wallet_changeset_utxo_verdicts_callback: Option, /// Additive tracked-masternode persistence trio (persist / load / /// free), likewise extension-negotiated. tracked_masternodes_callbacks: PersistenceExtensionCallbacks, @@ -1394,6 +1438,7 @@ impl FFIPersister { wallet_changeset_sweeps_callback: extensions.wallet_changeset_sweeps, wallet_changeset_chain_lock_height_callback: extensions .wallet_changeset_chain_lock_height, + wallet_changeset_utxo_verdicts_callback: extensions.wallet_changeset_utxo_verdicts, tracked_masternodes_callbacks: extensions, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), @@ -1968,6 +2013,37 @@ impl PlatformWalletPersistence for FFIPersister { } } + // The engine's credit verdicts ride their own size-negotiated + // extension slot (see the layout note on `WalletChangeSetFFI`) + // and are fired BEFORE the changeset callback: a persister that + // derives its UTXO rows from record roles needs the verdicts in + // hand while it materialises this round's `utxos_added` + // entries, or it stages the very phantom row the verdict exists + // to prevent. Fired only when the round carries a verdict, so a + // host without the slot — or a round where every output was + // credited — behaves exactly as before. + if !core_cs.utxo_credit_verdicts.is_empty() { + if let Some(cb) = self.wallet_changeset_utxo_verdicts_callback { + let verdicts = build_utxo_credit_verdicts_for_callback(core_cs); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + verdicts.as_ptr(), + verdicts.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet changeset credit-verdict persistence callback returned error \ + code {}", + result + ); + outcome.record(result); + } + } + } + if let Some(cb) = self.callbacks.on_persist_wallet_changeset_fn { let ffi_cs = WalletChangeSetFFI::from_changeset(core_cs); let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), &ffi_cs) }; @@ -7506,6 +7582,137 @@ mod tests { drop(persister); } + /// The credit verdicts reach the host through their own + /// size-negotiated slot, BEFORE the changeset callback of the same + /// round (the persister needs them while it materialises the round's + /// UTXO rows), with outpoint, class and height intact; a round with no + /// verdict never fires the slot; and a host without the slot still + /// succeeds — it just materialises the row unspent, as every host did + /// before the slot existed. + #[test] + fn store_delivers_credit_verdicts_through_the_extension_slot_before_the_changeset() { + use dashcore::hashes::Hash as _; + use platform_wallet::changeset::changeset::UtxoCreditVerdict; + use platform_wallet::changeset::CoreChangeSet; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events.lock().unwrap().push("changeset".into()); + 0 + } + unsafe extern "C" fn record_verdicts( + ctx: *mut c_void, + _wallet_id: *const u8, + verdicts: *const UtxoCreditVerdictFFI, + verdicts_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let mut events = sink.events.lock().unwrap(); + for verdict in slice::from_raw_parts(verdicts, verdicts_count) { + events.push(format!( + "verdict txid={:02x} vout={} class={} height={}", + verdict.outpoint.txid[0], + verdict.outpoint.vout, + verdict.verdict, + verdict.spent_at_height + )); + } + 0 + } + fn outpoint(byte: u8, vout: u32) -> dashcore::OutPoint { + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([byte; 32]), + vout, + } + } + fn verdict_round() -> PlatformWalletChangeSet { + let mut core = CoreChangeSet::default(); + core.utxo_credit_verdicts.insert( + outpoint(0xAB, 1), + UtxoCreditVerdict::ObservedSpent { height: 2_402_896 }, + ); + core.utxo_credit_verdicts + .insert(outpoint(0xCD, 0), UtxoCreditVerdict::Doomed); + core.utxo_credit_verdicts + .insert(outpoint(0xEF, 2), UtxoCreditVerdict::Uncredited); + PlatformWalletChangeSet { + core: Some(core), + ..Default::default() + } + } + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + wallet_changeset_utxo_verdicts: Some(record_verdicts), + ..Default::default() + }, + ); + // A round with no verdict: the slot stays silent. + persister + .store( + [1u8; 32], + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + synced_height: Some(10), + ..Default::default() + }), + ..Default::default() + }, + ) + .expect("verdict-less round must succeed"); + // A round carrying verdicts: they cross first, in outpoint order. + persister + .store([1u8; 32], verdict_round()) + .expect("verdict round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + "verdict txid=ab vout=1 class=1 height=2402896".to_string(), + "verdict txid=cd vout=0 class=2 height=0".to_string(), + "verdict txid=ef vout=2 class=3 height=0".to_string(), + "changeset".to_string(), + ], + ); + drop(persister); + + // Host without the slot: the same round still succeeds. + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store([1u8; 32], verdict_round()) + .expect("slotless-host verdict round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + drop(persister); + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -7714,6 +7921,16 @@ mod tests { PersistenceCallbacksExtension, on_persist_wallet_changeset_chain_lock_height_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_utxo_verdicts_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_wallet_changeset_utxo_verdicts_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index f2f1b562330..46dec66c4b3 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -263,6 +263,69 @@ pub struct CoreChangeSet { /// attribute makes it upgrade-safe. #[cfg_attr(feature = "serde", serde(default))] pub sweeps: Vec, + + /// The engine's verdict on every `Received` / `Change` output this + /// batch's records carry that the engine did NOT credit to the owning + /// account's UTXO set, keyed by outpoint. Absence means credited — the + /// ordinary case, and exactly today's behaviour. + /// + /// A persister that derives its UTXO rows from record roles (the FFI + /// projection does: `record_new_utxos_ffi` walks `output_details`) + /// otherwise materialises an UNSPENT row for a coin the engine itself + /// never held. The engine skips a recognised output only when it has + /// already observed the outpoint spent in a block (#649), when the + /// record is a doomed mempool transaction whose input a block already + /// spent, or when the coin was consumed between emit and drain. In the + /// first shape the spender can be a transaction the wallet never + /// recorded at all — a coin spent by a transaction with no wallet-owned + /// output (a CoinJoin collateral burn: sole `OP_RETURN` output) that was + /// processed while the coin was not yet in `utxos` matches nothing and + /// is discarded (rust-dashcore#992) — so no later record, spend emit or + /// sweep ever corrects the row, and the store's own restore path hands + /// the phantom coin back to the engine on every launch. This map is the + /// only channel that carries the engine's decision to the store at the + /// moment the evidence exists: `observed_spent_outpoints` is pruned at + /// the finality boundary long before a scan ends. + /// + /// Merge: the newer changeset is authoritative for every record it + /// re-projects. Each event's verdicts are computed against the wallet + /// snapshot its bridge call took, so two events folded into one round + /// can disagree about a coin — an output uncredited under the older + /// snapshot and credited under the newer one is simply ABSENT from the + /// newer map (only uncredited outputs are recorded). The fold therefore + /// first drops the older verdicts for every outpoint of a record the + /// newer changeset carries, and for every outpoint the newer changeset + /// credits (`new_utxos`), and only then extends with the newer map. + /// `serde(default)` for the same backward-compatible reading as + /// [`Self::sweeps`]. + #[cfg_attr(feature = "serde", serde(default))] + pub utxo_credit_verdicts: BTreeMap, +} + +/// Why the engine did not credit a `Received` / `Change` output of a +/// record it emitted — see [`CoreChangeSet::utxo_credit_verdicts`]. +/// +/// A persister may treat [`Self::ObservedSpent`] and [`Self::Doomed`] as +/// positive evidence that the coin is not spendable and store its row as +/// spent; [`Self::Uncredited`] carries no context and only says "do not +/// hand this coin back as unspent on a re-delivery". +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum UtxoCreditVerdict { + /// Not in the owning account's `utxos`: the wallet observed a block at + /// `height` spending this outpoint before the output was recognised, + /// so `update_utxos` never inserted it (the #649 skip). + ObservedSpent { + /// Height of the block the wallet observed spending the outpoint. + height: u32, + }, + /// Not in `utxos`: the record is an unconfirmed transaction one of + /// whose inputs a block already spent, so it can never confirm and + /// nothing it created was credited (`doomed_by_a_settled_spend`). + Doomed, + /// Not in `utxos` for a reason the bridge cannot name — an account-level + /// spent mark, a spend, an abandon or a sweep between emit and drain. + Uncredited, } /// One `TransactionsSwept` event: the transactions it removed, the @@ -592,6 +655,48 @@ impl Merge for CoreChangeSet { // each scopes its release to the remaining losers' own inputs, or // withholds any outpoint a surviving record claims — and the // reinstating record is exactly such a claim. + // Credit verdicts, part 1: the newer changeset re-projected every + // record it carries against a NEWER wallet snapshot, and only records + // a verdict for outputs the wallet does not hold — so an older + // verdict for an output such a record walked that the newer map + // does not mention means "credited since", not "still uncredited". + // Drop those before the newer map is folded in below, along with any + // outpoint the newer changeset credits outright. + // + // Keyed on the OUTPUTS the newer records walked, not their txids: + // verdicts are produced per account slice, over that slice's + // `Received` / `Change` outputs. A transaction paying a CoinJoin + // account and a BIP44 change address is two slices; a newer + // changeset re-projecting only the BIP44 slice never walked the + // CoinJoin output and cannot restate its verdict, so a txid-keyed + // guard would erase it and the persister would materialise the very + // row the verdict exists to prevent. Computed here, before + // `other.records` is consumed. + if !self.utxo_credit_verdicts.is_empty() { + use key_wallet::managed_account::transaction_record::OutputRole; + let reprojected: std::collections::HashSet = other + .records + .iter() + .chain(other.account_records.iter()) + .flat_map(|record| { + record + .output_details + .iter() + .filter(|detail| { + matches!(detail.role, OutputRole::Received | OutputRole::Change) + }) + .map(move |detail| OutPoint { + txid: record.txid, + vout: detail.index, + }) + }) + .collect(); + let credited: std::collections::HashSet = + other.new_utxos.iter().map(|utxo| utxo.outpoint).collect(); + self.utxo_credit_verdicts.retain(|outpoint, _| { + !reprojected.contains(outpoint) && !credited.contains(outpoint) + }); + } if !other.records.is_empty() && !self.sweeps.is_empty() { let reinstated: std::collections::HashSet = other.records.iter().map(|record| record.txid).collect(); @@ -722,6 +827,11 @@ impl Merge for CoreChangeSet { // batch's decision to free it, and only replaying them in sequence // preserves that. self.sweeps.extend(other.sweeps); + + // Credit verdicts, part 2: newest wins per outpoint; the stale + // entries of records the newer changeset re-projected were dropped + // at the top of this merge. + self.utxo_credit_verdicts.extend(other.utxo_credit_verdicts); } fn is_empty(&self) -> bool { @@ -737,6 +847,7 @@ impl Merge for CoreChangeSet { && self.addresses_marked_used.is_empty() && self.account_highest_used.is_empty() && self.last_applied_chain_lock.is_none() + && self.utxo_credit_verdicts.is_empty() } } @@ -3206,3 +3317,234 @@ mod tests { assert_eq!(merged.internal, Some(1)); } } + +#[cfg(test)] +mod utxo_credit_verdict_merge_tests { + use super::*; + use dashcore::hashes::Hash; + + fn outpoint(byte: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([byte; 32]), + vout: 0, + } + } + + /// Verdicts fold by union, newest-wins per outpoint, and a changeset + /// carrying only verdicts is not empty — it must still reach the + /// persister. + #[test] + fn merge_unions_credit_verdicts_newest_wins() { + let mut older = CoreChangeSet::default(); + older + .utxo_credit_verdicts + .insert(outpoint(1), UtxoCreditVerdict::Uncredited); + older + .utxo_credit_verdicts + .insert(outpoint(2), UtxoCreditVerdict::ObservedSpent { height: 10 }); + let mut newer = CoreChangeSet::default(); + newer + .utxo_credit_verdicts + .insert(outpoint(1), UtxoCreditVerdict::ObservedSpent { height: 11 }); + newer + .utxo_credit_verdicts + .insert(outpoint(3), UtxoCreditVerdict::Doomed); + assert!(!Merge::is_empty(&newer)); + + older.merge(newer); + assert_eq!(older.utxo_credit_verdicts.len(), 3); + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint(1)), + Some(&UtxoCreditVerdict::ObservedSpent { height: 11 }) + ); + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint(2)), + Some(&UtxoCreditVerdict::ObservedSpent { height: 10 }) + ); + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint(3)), + Some(&UtxoCreditVerdict::Doomed) + ); + } + + fn outpoint_at(byte: u8, vout: u32) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([byte; 32]), + vout, + } + } + + /// One account slice of transaction `txid_byte`, classifying the given + /// outputs `(vout, role)` — the shape `utxo_credit_verdicts_from_wallet` + /// walks. `account_type` is the slice's owner. + fn slice_for( + txid_byte: u8, + account_type: key_wallet::account::AccountType, + outputs: &[( + u32, + key_wallet::managed_account::transaction_record::OutputRole, + )], + ) -> TransactionRecord { + use key_wallet::managed_account::transaction_record::OutputDetail; + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + account_type, + key_wallet::transaction_checking::TransactionContext::Mempool, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Incoming, + Vec::new(), + outputs + .iter() + .map(|(index, role)| OutputDetail { + index: *index, + role: *role, + address: None, + value: 1, + }) + .collect(), + 0, + ); + record.txid = Txid::from_byte_array([txid_byte; 32]); + record + } + + fn bip44() -> key_wallet::account::AccountType { + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + } + } + + /// A BIP44 slice whose output 0 is `Received`. + fn record_for(txid_byte: u8) -> TransactionRecord { + use key_wallet::managed_account::transaction_record::OutputRole; + slice_for(txid_byte, bip44(), &[(0, OutputRole::Received)]) + } + + /// Two events folded into one round are projected against two wallet + /// snapshots. A coin uncredited under the older one and credited under + /// the newer one is absent from the newer map, because only uncredited + /// outputs carry a verdict — so the newer changeset must erase the + /// older verdict for every record it re-projects, or the persister + /// writes a live coin spent at creation and the restore never brings it + /// back. Verdicts for records the newer changeset does not carry stay. + #[test] + fn merge_drops_older_verdicts_of_records_the_newer_changeset_reprojects() { + let mut older = CoreChangeSet::default(); + older.records.push(record_for(1)); + older.records.push(record_for(2)); + older + .utxo_credit_verdicts + .insert(outpoint(1), UtxoCreditVerdict::ObservedSpent { height: 10 }); + older + .utxo_credit_verdicts + .insert(outpoint(2), UtxoCreditVerdict::ObservedSpent { height: 10 }); + + // The newer projection of record 1 carries no verdict for its + // output: the coin is credited now. Record 2 is not re-projected. + let mut newer = CoreChangeSet::default(); + newer.records.push(record_for(1)); + older.merge(newer); + + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint(1)), + None, + "a re-projected record with no verdict means credited: the stale verdict must go" + ); + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint(2)), + Some(&UtxoCreditVerdict::ObservedSpent { height: 10 }), + "a record the newer changeset does not carry keeps its verdict" + ); + } + + /// Verdicts are produced per account slice, so the guard is keyed on + /// the outputs the newer slices actually walked. One transaction pays a + /// CoinJoin account (output 1) and BIP44 change (output 0); the newer + /// changeset re-projects only the BIP44 slice. It re-judged output 0 + /// and found it credited — that verdict goes — but it never walked + /// output 1 and cannot restate its verdict, so that one stays. An + /// output the slice lists as `Sent` (a counterparty's) re-judges + /// nothing either. + #[test] + fn merge_keeps_an_older_verdict_for_a_sibling_slice_the_newer_changeset_did_not_walk() { + use key_wallet::managed_account::transaction_record::OutputRole; + let coinjoin = key_wallet::account::AccountType::CoinJoin { index: 0 }; + let mut older = CoreChangeSet::default(); + older + .account_records + .push(slice_for(1, bip44(), &[(0, OutputRole::Change)])); + older + .account_records + .push(slice_for(1, coinjoin, &[(1, OutputRole::Received)])); + older.utxo_credit_verdicts.insert( + outpoint_at(1, 0), + UtxoCreditVerdict::ObservedSpent { height: 10 }, + ); + older.utxo_credit_verdicts.insert( + outpoint_at(1, 1), + UtxoCreditVerdict::ObservedSpent { height: 10 }, + ); + older + .utxo_credit_verdicts + .insert(outpoint_at(1, 2), UtxoCreditVerdict::Uncredited); + + let mut newer = CoreChangeSet::default(); + newer.account_records.push(slice_for( + 1, + bip44(), + &[(0, OutputRole::Change), (2, OutputRole::Sent)], + )); + newer.records = newer.account_records.clone(); + older.merge(newer); + + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint_at(1, 0)), + None, + "the BIP44 slice re-judged its change output and found it credited" + ); + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint_at(1, 1)), + Some(&UtxoCreditVerdict::ObservedSpent { height: 10 }), + "the CoinJoin slice was not re-projected: its verdict stands" + ); + assert_eq!( + older.utxo_credit_verdicts.get(&outpoint_at(1, 2)), + Some(&UtxoCreditVerdict::Uncredited), + "a `Sent` output is the counterparty's; listing it re-judges nothing" + ); + } + + /// A newer changeset that credits an outpoint outright (`new_utxos`) + /// beats an older verdict for it even when it carries no record. + #[test] + fn merge_drops_older_verdict_for_an_outpoint_the_newer_changeset_credits() { + let mut older = CoreChangeSet::default(); + older + .utxo_credit_verdicts + .insert(outpoint(1), UtxoCreditVerdict::Doomed); + let mut newer = CoreChangeSet::default(); + let script = + dashcore::ScriptBuf::new_p2pkh(&dashcore::PubkeyHash::from_byte_array([7u8; 20])); + let address = dashcore::Address::from_script(&script, dashcore::Network::Testnet).unwrap(); + newer.new_utxos.push(key_wallet::Utxo::new( + outpoint(1), + dashcore::TxOut { + value: 1, + script_pubkey: script, + }, + address, + 100, + false, + )); + older.merge(newer); + assert!(older.utxo_credit_verdicts.is_empty()); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 9b20a1658dc..3c7694d2e3f 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -52,6 +52,7 @@ use tokio_util::sync::CancellationToken; use crate::changeset::changeset::{ AssetLockChangeSet, CoreChangeSet, HighestUsedIndexes, PlatformWalletChangeSet, SweepBatch, + UtxoCreditVerdict, }; use crate::changeset::merge::Merge; use crate::changeset::persistence_capabilities::PersistenceCapabilities; @@ -1077,11 +1078,23 @@ async fn build_core_changeset( // stale slice supersede a complete fold earlier in this // drain's batch — the chainlock's own events carry the // row's finality forward. - let slices: Vec = - match wallet_slices_for_txid(wallet_manager, wallet_id, &record.txid).await { - Some(slices) => slices, - None => vec![(**record).clone()], - }; + // + // The credit verdicts are read under the SAME guard as the + // slices: a verdict is the engine's opinion on a record's + // outputs, and the record's own context is one of its inputs + // (an unconfirmed record whose input a block spent reads + // `Doomed`). Read from two snapshots, a funding that confirmed + // and lost its coin to a mempool child between them would be + // judged on a stale mempool context and marked spent for good. + let (slices, utxo_credit_verdicts): ( + Vec, + BTreeMap, + ) = match wallet_slices_and_verdicts_for_txid(wallet_manager, wallet_id, &record.txid) + .await + { + Some(read) => read, + None => (vec![(**record).clone()], BTreeMap::new()), + }; // A contact's watch-only chain never defines the wallet's // transaction row or its TXOs (see `is_contact_watch_only`); // the usage deltas below are still emitted, so the event @@ -1114,6 +1127,7 @@ async fn build_core_changeset( addresses_derived: addresses_derived.clone(), addresses_marked_used, account_highest_used, + utxo_credit_verdicts, ..CoreChangeSet::default() } } @@ -1196,6 +1210,17 @@ async fn build_core_changeset( collect_usage_deltas(wallet_manager, wallet_id, records).await; cs.addresses_marked_used = addresses_marked_used; cs.account_highest_used = account_highest_used; + // The engine's verdict on the outputs the persister is about to + // materialise from these records — see + // `CoreChangeSet::utxo_credit_verdicts`. Over the owned slices + // only (`account_records` is already filtered): a contact's + // watch-only chain never defines the wallet's TXOs. + cs.utxo_credit_verdicts = utxo_credit_verdicts( + wallet_manager, + wallet_id, + &cs.account_records.iter().collect::>(), + ) + .await; cs } WalletEvent::TransactionsSwept { @@ -1315,6 +1340,117 @@ async fn collect_usage_deltas( collect_usage_deltas_from_accounts(&info.core_wallet.accounts, &records) } +/// The engine's credit verdict for every `Received` / `Change` output of +/// `records` that the owning account does NOT hold — see +/// [`CoreChangeSet::utxo_credit_verdicts`] for what a persister does with +/// it. Empty when every output is credited, when the wallet is unknown +/// (raced a removal — the next round re-emits), or when `records` is empty. +/// +/// One read of the wallet lock per event, like [`collect_usage_deltas`]; +/// the walk itself is [`utxo_credit_verdicts_from_wallet`], factored so +/// tests can drive it against a bare `ManagedWalletInfo`. `BlockProcessed` +/// uses this over the event's own records (block records carry their +/// block context and cannot read `Doomed`); `TransactionDetected` reads +/// its slices and their verdicts under one guard through +/// [`wallet_slices_and_verdicts_for_txid`]. +async fn utxo_credit_verdicts( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + records: &[&TransactionRecord], +) -> BTreeMap { + if records.is_empty() { + return BTreeMap::new(); + } + let guard = wallet_manager.read().await; + let Some(info) = guard.get_wallet_info(wallet_id) else { + return BTreeMap::new(); + }; + utxo_credit_verdicts_from_wallet(&info.core_wallet, records) +} + +/// Synchronous core of [`utxo_credit_verdicts`]. +/// +/// For each record (a contact's watch-only slice excluded — its outputs +/// are the contact's coins and never become this wallet's TXOs) and each +/// output the record classifies `Received` / `Change`, the owning account +/// is resolved by the record's `account_type` and the outpoint looked up +/// in its live `utxos`: +/// +/// - present → credited, no verdict (the ordinary case); +/// - absent and the wallet's `observed_spent_outpoints` (#649) names the +/// outpoint → [`UtxoCreditVerdict::ObservedSpent`] with that height: +/// `update_utxos` skipped the insert because a block already spent it, +/// and the spender may be a transaction the wallet never recorded +/// (rust-dashcore#992); +/// - absent, the record unconfirmed, and one of its own inputs observed +/// spent → [`UtxoCreditVerdict::Doomed`]: `doomed_by_a_settled_spend` +/// credited nothing, and the conflict sweep that would delete the row +/// fired on the winner's arrival and will not fire again; +/// - absent otherwise → [`UtxoCreditVerdict::Uncredited`]: the coin was +/// taken between emit and drain (a spend, an abandon, a sweep) or holds +/// an account-level spent mark; the store learns the rest from the +/// spender's own record or the sweep callback. +/// +/// `utxos` membership is the gate, not the reason: a coin the engine holds +/// is credited whatever the observed-spent map says (an IS-locked loser +/// under DIP-10 precedence keeps its outputs credited, and must not be +/// flagged). The verdict is evaluated against the wallet as it is at drain +/// time, which is later than the record — that is the same lag every +/// other delta this bridge derives already has, and a coin spent in a +/// block since the record was built reads `ObservedSpent` by the same +/// evidence the engine used to drop it. The one thing that must NOT lag +/// is the record's context relative to the wallet it is judged against: +/// callers hand in records read from the same wallet snapshot (the +/// manager's own slices under one guard), never a clone from an earlier +/// one. +fn utxo_credit_verdicts_from_wallet( + core_wallet: &key_wallet::wallet::ManagedWalletInfo, + records: &[&TransactionRecord], +) -> BTreeMap { + let mut verdicts = BTreeMap::new(); + let observed = core_wallet.observed_spent_outpoints(); + let accounts = core_wallet.accounts.all_accounts(); + for record in records { + if is_contact_watch_only(record) { + continue; + } + let Some(funds) = accounts + .iter() + .find(|a| a.managed_account_type().to_account_type() == record.account_type) + .and_then(|a| a.as_funds()) + else { + continue; + }; + let doomed = matches!(record.context, TransactionContext::Mempool) + && record + .transaction + .input + .iter() + .any(|input| observed.contains_key(&input.previous_output)); + for detail in &record.output_details { + if !matches!(detail.role, OutputRole::Received | OutputRole::Change) { + continue; + } + let outpoint = OutPoint { + txid: record.txid, + vout: detail.index, + }; + if funds.utxos.contains_key(&outpoint) { + continue; + } + let verdict = if let Some(height) = observed.get(&outpoint) { + UtxoCreditVerdict::ObservedSpent { height: *height } + } else if doomed { + UtxoCreditVerdict::Doomed + } else { + UtxoCreditVerdict::Uncredited + }; + verdicts.insert(outpoint, verdict); + } + } + verdicts +} + /// Synchronous core of [`collect_usage_deltas`], factored over the /// account collection so tests can drive it without a `WalletManager`. /// @@ -1495,14 +1631,21 @@ async fn is_chain_locked( /// Every account slice the manager currently holds for `txid` in /// `wallet_id` — the authoritative "all accounts matched so far" /// snapshot behind the wallet-level fold (see the `TransactionDetected` -/// arm of [`build_core_changeset`]). Returns `None` when the manager -/// doesn't know the wallet at all, `Some(vec![])` when it does but no -/// account holds a record for the txid (e.g. pruned at chain-lock). -async fn wallet_slices_for_txid( +/// arm of [`build_core_changeset`]) — together with the credit verdicts +/// of the owned slices' outputs ([`utxo_credit_verdicts_from_wallet`]), +/// both read under ONE wallet read guard, so the records a verdict is +/// judged on and the wallet state it is judged against are the same +/// snapshot. Returns `None` when the manager doesn't know the wallet at +/// all, `Some((vec![], empty))` when it does but no account holds a record +/// for the txid (e.g. pruned at chain-lock). +async fn wallet_slices_and_verdicts_for_txid( wallet_manager: &Arc>>, wallet_id: &WalletId, txid: &dashcore::Txid, -) -> Option> { +) -> Option<( + Vec, + BTreeMap, +)> { let guard = wallet_manager.read().await; let info = guard.get_wallet_info(wallet_id)?; let mut slices = Vec::new(); @@ -1511,7 +1654,12 @@ async fn wallet_slices_for_txid( slices.push(record.clone()); } } - Some(slices) + let owned: Vec<&TransactionRecord> = slices + .iter() + .filter(|r| !is_contact_watch_only(r)) + .collect(); + let verdicts = utxo_credit_verdicts_from_wallet(&info.core_wallet, &owned); + Some((slices, verdicts)) } /// Is this record owned by a contact's watch-only DashPay chain? @@ -1724,6 +1872,7 @@ impl CoreChangeSet { && self.addresses_derived.is_empty() && self.addresses_marked_used.is_empty() && self.account_highest_used.is_empty() + && self.utxo_credit_verdicts.is_empty() } } @@ -2941,7 +3090,7 @@ mod contact_watch_only_projection_tests { let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); let manager = Arc::new(RwLock::new(wm)); - let slices = wallet_slices_for_txid(&manager, &wallet_id, &spend.txid()) + let (slices, _) = wallet_slices_and_verdicts_for_txid(&manager, &wallet_id, &spend.txid()) .await .expect("manager knows the wallet"); assert_eq!(slices.len(), 2, "both funding accounts hold a slice"); @@ -5654,3 +5803,386 @@ mod tests { ); } } + +#[cfg(test)] +mod utxo_credit_verdict_tests { + //! Coverage for [`utxo_credit_verdicts_from_wallet`] — the engine's + //! verdict on outputs a record classifies as ours but the engine never + //! credited. Drives a real `ManagedWalletInfo` through + //! `check_core_transaction` in the exact arrival orders that produce + //! each verdict, then runs the bridge's derivation over the + //! post-mutation state, as the event adapter does at runtime. + + use super::*; + use dashcore::hashes::Hash; + use dashcore::{BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::transaction_checking::{BlockInfo, WalletTransactionChecker}; + use key_wallet::WalletCoreBalance; + use key_wallet_manager::WalletManager; + + fn in_block(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_slice(&[9u8; 32]).expect("valid block hash"), + 1_234_567_890, + )) + } + + /// A P2PKH script the wallet does not monitor (the secp256k1 + /// generator point), for counterparty outputs. + fn foreign_script() -> ScriptBuf { + const TEST_PUBKEY_G: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, + 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, + 0x5b, 0x16, 0xf8, 0x17, 0x98, + ]; + let pubkey = + dashcore::PublicKey::from_slice(&TEST_PUBKEY_G).expect("generator point is valid"); + dashcore::Address::p2pkh(&pubkey, key_wallet::Network::Testnet).script_pubkey() + } + + fn input(previous_output: OutPoint) -> TxIn { + TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + } + } + + fn spend_to(previous_output: OutPoint, script_pubkey: ScriptBuf, value: u64) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![input(previous_output)], + output: vec![TxOut { + value, + script_pubkey, + }], + special_transaction_payload: None, + } + } + + /// The rust-dashcore#992 shape: one input (the coin) and a sole + /// zero-value `OP_RETURN` output — a CoinJoin collateral burn. It pays + /// nothing back to the wallet, so its only tie to us is the input. + fn collateral_burn(coin: OutPoint) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![input(coin)], + output: vec![TxOut { + value: 0, + script_pubkey: dashcore::blockdata::script::Builder::new() + .push_opcode(dashcore::opcodes::all::OP_RETURN) + .into_script(), + }], + special_transaction_payload: None, + } + } + + fn funding_of(receive_script: ScriptBuf, seed: u8) -> Transaction { + spend_to( + OutPoint { + txid: Txid::from_slice(&[seed; 32]).expect("valid txid"), + vout: 0, + }, + receive_script, + 19_549, + ) + } + + /// The field case: the burn is processed BEFORE the funding output is + /// recognised, matches nothing, and is discarded — but the #649 map + /// notes the spend. When the funding record then arrives it classifies + /// the output `Received` and the persister would materialise an unspent + /// row, while the engine skipped the credit. The verdict names the + /// observed spending height. + #[tokio::test] + async fn collateral_burn_seen_before_its_funding_yields_observed_spent() { + let TestWalletContext { + mut managed_wallet, + mut wallet, + receive_address, + .. + } = TestWalletContext::new_random(); + let fund_tx = funding_of(receive_address.script_pubkey(), 2); + let coin = OutPoint { + txid: fund_tx.txid(), + vout: 0, + }; + + let burn_result = managed_wallet + .check_core_transaction( + &collateral_burn(coin), + in_block(100_001), + &mut wallet, + true, + true, + ) + .await; + assert!( + !burn_result.is_relevant, + "a burn of an unknown coin matches nothing" + ); + assert!(burn_result.new_records.is_empty()); + assert!(burn_result.updated_records.is_empty()); + + let fund_result = managed_wallet + .check_core_transaction(&fund_tx, in_block(100_000), &mut wallet, true, true) + .await; + assert!(fund_result.is_relevant); + let funding_record = fund_result + .new_records + .first() + .expect("the funding transaction is recorded"); + assert!( + funding_record + .output_details + .iter() + .any(|d| d.index == 0 && d.role == OutputRole::Received), + "the record still classifies the output as ours" + ); + let funds = managed_wallet + .first_bip44_managed_account() + .expect("bip44 account"); + assert!( + !funds.utxos.contains_key(&coin), + "the engine never credited the coin" + ); + + let verdicts = utxo_credit_verdicts_from_wallet(&managed_wallet, &[funding_record]); + assert_eq!( + verdicts.get(&coin), + Some(&UtxoCreditVerdict::ObservedSpent { height: 100_001 }) + ); + assert_eq!(verdicts.len(), 1); + } + + /// The ordinary case carries no verdict at all: a credited output must + /// keep today's behaviour byte for byte. + #[tokio::test] + async fn credited_output_yields_no_verdict() { + let TestWalletContext { + mut managed_wallet, + mut wallet, + receive_address, + .. + } = TestWalletContext::new_random(); + let fund_tx = funding_of(receive_address.script_pubkey(), 3); + let fund_result = managed_wallet + .check_core_transaction(&fund_tx, in_block(100_000), &mut wallet, true, true) + .await; + let funding_record = fund_result.new_records.first().expect("funding record"); + let funds = managed_wallet + .first_bip44_managed_account() + .expect("bip44 account"); + assert!(funds.utxos.contains_key(&OutPoint { + txid: fund_tx.txid(), + vout: 0 + })); + + assert!(utxo_credit_verdicts_from_wallet(&managed_wallet, &[funding_record]).is_empty()); + } + + /// A mempool transaction whose input a block already spent is recorded + /// (history keeps the attempt) but credits nothing — and no sweep will + /// ever delete its row, since the winner arrived first. Its outputs + /// read `Doomed`. + #[tokio::test] + async fn doomed_mempool_record_yields_doomed() { + let TestWalletContext { + mut managed_wallet, + mut wallet, + receive_address, + .. + } = TestWalletContext::new_random(); + let fund_tx = funding_of(receive_address.script_pubkey(), 4); + let coin = OutPoint { + txid: fund_tx.txid(), + vout: 0, + }; + managed_wallet + .check_core_transaction(&fund_tx, in_block(100_000), &mut wallet, true, true) + .await; + // The winner: a block spend of the coin to a stranger. + let winner = spend_to(coin, foreign_script(), 19_000); + let winner_result = managed_wallet + .check_core_transaction(&winner, in_block(100_001), &mut wallet, true, true) + .await; + assert!(winner_result.is_relevant); + // The loser arrives afterwards from the mempool, paying us back. + let loser = spend_to(coin, receive_address.script_pubkey(), 18_000); + let loser_result = managed_wallet + .check_core_transaction(&loser, TransactionContext::Mempool, &mut wallet, true, true) + .await; + assert!(loser_result.is_relevant, "it pays one of our addresses"); + let loser_record = loser_result.new_records.first().expect("loser record"); + let loser_output = OutPoint { + txid: loser.txid(), + vout: 0, + }; + let funds = managed_wallet + .first_bip44_managed_account() + .expect("bip44 account"); + assert!(!funds.utxos.contains_key(&loser_output)); + + let verdicts = utxo_credit_verdicts_from_wallet(&managed_wallet, &[loser_record]); + assert_eq!( + verdicts.get(&loser_output), + Some(&UtxoCreditVerdict::Doomed) + ); + } + + /// End to end through the event bridge: the funding record's + /// `BlockProcessed` changeset carries the verdict next to the very + /// `new_utxos` entry the persister would otherwise trust, and a wallet + /// the manager does not know yields no verdict rather than a wrong one. + #[tokio::test] + async fn block_processed_changeset_carries_the_verdict() { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + + let mut ctx = TestWalletContext::new_random(); + let fund_tx = funding_of(ctx.receive_address.script_pubkey(), 5); + let coin = OutPoint { + txid: fund_tx.txid(), + vout: 0, + }; + let burn_result = ctx + .check_transaction(&collateral_burn(coin), in_block(100_001)) + .await; + assert!(!burn_result.is_relevant); + let fund_result = ctx.check_transaction(&fund_tx, in_block(100_000)).await; + let funding_record = fund_result + .new_records + .first() + .expect("funding record") + .clone(); + + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + observed_input_conflicts: Default::default(), + }; + let mut wm = WalletManager::::new(dashcore::Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + let manager = Arc::new(RwLock::new(wm)); + + let event = |wallet_id: WalletId| WalletEvent::BlockProcessed { + wallet_id, + height: 100_000, + chain_lock: None, + inserted: vec![funding_record.clone()], + updated: vec![], + matured: vec![], + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + + let cs = build_core_changeset(&manager, &event(wallet_id)).await; + assert_eq!( + cs.utxo_credit_verdicts.get(&coin), + Some(&UtxoCreditVerdict::ObservedSpent { height: 100_001 }) + ); + assert!( + cs.new_utxos.iter().any(|u| u.outpoint == coin), + "the additive projection is unchanged; the verdict rides beside it" + ); + assert!(!cs.is_empty_no_records()); + + let unknown = build_core_changeset(&manager, &event([0xEEu8; 32])).await; + assert!(unknown.utxo_credit_verdicts.is_empty()); + } + + /// `TransactionDetected` judges the outputs of the records it READS, + /// under the guard it reads them with — never the event's own record, + /// which is a clone taken at emit time and can be stale by drain time. + /// Here the funding was still in the mempool when emitted; since then + /// it confirmed (its inputs now sit in the observed-spent map) and a + /// mempool child took the coin. Judged on the stale clone the coin + /// reads `Doomed` — a durable spent mark for a spend that may never + /// confirm. Judged on the manager's own confirmed record it reads + /// `Uncredited`, and the store learns the rest from the child's record. + #[tokio::test] + async fn transaction_detected_judges_the_records_it_reads_not_the_stale_event_clone() { + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + + let mut ctx = TestWalletContext::new_random(); + let fund_tx = funding_of(ctx.receive_address.script_pubkey(), 6); + let coin = OutPoint { + txid: fund_tx.txid(), + vout: 0, + }; + let seen_in_mempool = ctx + .check_transaction(&fund_tx, TransactionContext::Mempool) + .await; + let stale_clone = seen_in_mempool + .new_records + .first() + .expect("mempool funding record") + .clone(); + assert!(matches!(stale_clone.context, TransactionContext::Mempool)); + // The funding confirms: its inputs enter the observed-spent map. + assert!( + ctx.check_transaction(&fund_tx, in_block(100_000)) + .await + .is_relevant + ); + // A mempool child takes the coin. + let child = spend_to(coin, foreign_script(), 19_000); + assert!( + ctx.check_transaction(&child, TransactionContext::Mempool) + .await + .is_relevant + ); + assert!(!ctx + .managed_wallet + .first_bip44_managed_account() + .expect("bip44 account") + .utxos + .contains_key(&coin)); + // The stale clone, judged against the wallet as it is now, WOULD + // read `Doomed`: that is the wrong verdict the bridge must not emit. + assert_eq!( + utxo_credit_verdicts_from_wallet(&ctx.managed_wallet, &[&stale_clone]).get(&coin), + Some(&UtxoCreditVerdict::Doomed) + ); + + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + observed_input_conflicts: Default::default(), + }; + let mut wm = WalletManager::::new(dashcore::Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + let manager = Arc::new(RwLock::new(wm)); + + let event = WalletEvent::TransactionDetected { + wallet_id, + record: Box::new(stale_clone), + balance: WalletCoreBalance::default(), + account_balances: BTreeMap::new(), + addresses_derived: vec![], + }; + let cs = build_core_changeset(&manager, &event).await; + assert_eq!( + cs.utxo_credit_verdicts.get(&coin), + Some(&UtxoCreditVerdict::Uncredited), + "judged on the manager's confirmed record: taken, not doomed" + ); + assert!( + cs.records.iter().all(|r| r.context.block_info().is_some()), + "the row is rebuilt from the manager's record, not the stale clone" + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ace055e60aa..316b66820e8 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -216,6 +216,261 @@ pub struct AccountUtxoSnapshot { pub is_locked: bool, } +/// One row of a wallet's UTXO inventory page — see [`wallet_utxos_page`]. +/// +/// Carries the owning account alongside the coin so a store that keys its +/// rows by account can file a healed row under the right one, and the +/// address the engine derived from the script so the store never has to +/// re-derive it. The flags are the engine's own (`Utxo` fields); a coin the +/// engine holds is by definition unspent from its point of view. +#[derive(Debug, Clone)] +pub struct WalletUtxoRow { + pub account_type: AccountType, + pub outpoint: OutPoint, + pub value_duffs: u64, + pub script_pubkey: Vec, + /// Base58Check address of `script_pubkey`, as the engine holds it. + pub address: String, + pub height: u32, + pub is_confirmed: bool, + pub is_instantlocked: bool, + pub is_coinbase: bool, + pub is_locked: bool, +} + +/// Cursor for [`wallet_utxos_page`]: the last row of the previous page. +/// The walk is ordered by `(AccountType, OutPoint)`, so an account is +/// exhausted before the next one starts and a cursor is exact — no row is +/// visited twice or skipped because a concurrent round inserted beside it. +pub type WalletUtxoCursor = (AccountType, OutPoint); + +/// Page size [`wallet_utxos_page`] uses when the caller passes 0. +pub const WALLET_UTXO_PAGE_DEFAULT: usize = 512; +/// Largest page [`wallet_utxos_page`] returns — enforced here, not trusted +/// from the caller, because the inventory's size is chain-controlled +/// (anyone who knows a watched address can grow it with dust). +pub const WALLET_UTXO_PAGE_MAX: usize = 4096; + +/// One store row the store asks the engine to classify — see +/// [`classify_outpoints`]. `account_type` and `script_pubkey` are the +/// store's own record of who owns the coin, which the verdict checks +/// against the engine's pools rather than trusting. +#[derive(Debug, Clone)] +pub struct OutpointOwnershipQuery { + pub account_type: AccountType, + pub outpoint: OutPoint, + pub script_pubkey: Vec, +} + +/// The engine's answer for one [`OutpointOwnershipQuery`]. +/// +/// Only [`Self::KnownUncredited`] is positive evidence a reconciler may act +/// on: the owning account recorded the funding transaction (its txid is in +/// the account's records or its finalized set), recognises the output's +/// script as its own, does not hold the coin, AND a funds account holds a +/// MINED record whose transaction spends the outpoint. The last condition +/// is what makes the answer durable. Absence from `utxos` alone is not: +/// `update_utxos` removes the inputs of a mempool spend that may never +/// confirm, and a conflict sweep releases a loser's other inputs without +/// reinserting their coins — both leave the coin absent with its funding +/// known, and both are states the store deliberately keeps restorable. +/// Everything else says nothing: `Unknown` covers those, a funding +/// transaction this session never processed (after a restart the finalized +/// set is empty), and a spender the engine never recorded at all (the +/// rust-dashcore#992 shape, which only the emit-time verdict can name); +/// `NotOwned` a script the account's pools do not monitor, which the engine +/// could never have credited in the first place. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutpointClass { + /// The engine has no opinion. + Unknown = 0, + /// The coin is in a funds account's live `utxos`. + Unspent = 1, + /// The owning account knows the funding txid, owns the script, does + /// not hold the coin, and a mined record spends the outpoint. + KnownUncredited = 2, + /// The owning account's pools do not monitor the script. + NotOwned = 3, +} + +impl OutpointClass { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// Whether `account_type` is a contact's watch-only chain +/// (`DashpayExternalAccount`): coins there belong to the contact, so the +/// inventory omits them and the classifier has no verdict for them. +pub fn is_watch_only_contact(account_type: &AccountType) -> bool { + matches!(account_type, AccountType::DashpayExternalAccount { .. }) +} + +/// One page of `wallet_id`'s UTXO inventory across every funds account +/// that is not a contact's watch-only chain, in `(AccountType, OutPoint)` +/// order, starting strictly after `after`. Returns the rows and whether +/// more follow. `limit` is clamped to `1..=WALLET_UTXO_PAGE_MAX`, with 0 +/// meaning [`WALLET_UTXO_PAGE_DEFAULT`]. An unknown wallet is an empty +/// terminal page. +/// +/// A UTXO set that moves between pages (a round landing mid-walk) can drop +/// a row out of ONE walk or repeat one; both are benign for the insert-only, +/// idempotent store reconcile this serves, which re-runs on a cadence. +pub fn wallet_utxos_page( + wm: &key_wallet_manager::WalletManager, + wallet_id: &WalletId, + after: Option<&WalletUtxoCursor>, + limit: usize, +) -> (Vec, bool) { + use std::ops::Bound; + + let limit = if limit == 0 { + WALLET_UTXO_PAGE_DEFAULT + } else { + limit.min(WALLET_UTXO_PAGE_MAX) + }; + let Some(info) = wm.get_wallet_info(wallet_id) else { + return (Vec::new(), false); + }; + let mut accounts: Vec<( + AccountType, + &key_wallet::managed_account::ManagedCoreFundsAccount, + )> = info + .core_wallet + .accounts + .all_accounts() + .iter() + .filter_map(|a| { + a.as_funds() + .map(|funds| (a.managed_account_type().to_account_type(), funds)) + }) + // A contact's watch-only chain is not this wallet's money: its + // coins never enter the inventory, so no store ever heals them in + // as the user's. Decided here, not by the store, so a renumbered + // tag or a new watch-only account type cannot repoint the gate. + .filter(|(account_type, _)| !is_watch_only_contact(account_type)) + .collect(); + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut rows = Vec::with_capacity(limit); + let mut has_more = false; + 'accounts: for (account_type, funds) in accounts { + let start = match after { + Some((cursor_account, cursor_outpoint)) => match account_type.cmp(cursor_account) { + std::cmp::Ordering::Less => continue, + std::cmp::Ordering::Equal => Bound::Excluded(*cursor_outpoint), + std::cmp::Ordering::Greater => Bound::Unbounded, + }, + None => Bound::Unbounded, + }; + for (outpoint, utxo) in funds.utxos.range((start, Bound::Unbounded)) { + if rows.len() == limit { + has_more = true; + break 'accounts; + } + rows.push(WalletUtxoRow { + account_type, + outpoint: *outpoint, + value_duffs: utxo.txout.value, + script_pubkey: utxo.txout.script_pubkey.as_bytes().to_vec(), + address: utxo.address.to_string(), + height: utxo.height, + is_confirmed: utxo.is_confirmed, + is_instantlocked: utxo.is_instantlocked, + is_coinbase: utxo.is_coinbase, + is_locked: utxo.is_locked, + }); + } + } + (rows, has_more) +} + +/// Classify each query's outpoint for `wallet_id` — see [`OutpointClass`] +/// for the verdicts and the one a reconciler may act on. Positional: +/// `result[i]` answers `queries[i]`. An unknown wallet answers `Unknown` +/// for every query. Cost is `queries × funds accounts`, never the size of +/// the inventory. +pub fn classify_outpoints( + wm: &key_wallet_manager::WalletManager, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], +) -> Vec { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let Some(info) = wm.get_wallet_info(wallet_id) else { + return vec![OutpointClass::Unknown; queries.len()]; + }; + let accounts: Vec<( + AccountType, + &key_wallet::managed_account::ManagedCoreFundsAccount, + )> = info + .core_wallet + .accounts + .all_accounts() + .iter() + .filter_map(|a| { + a.as_funds() + .map(|funds| (a.managed_account_type().to_account_type(), funds)) + }) + .collect(); + + // Durable spend evidence: the inputs of every MINED record in any funds + // account. A mempool spend, an IS-locked spend or a released loser input + // leaves a coin absent from `utxos` too, and none of those is a verdict. + let mined_spends: std::collections::HashSet = accounts + .iter() + .flat_map(|(_, funds)| funds.transactions().values()) + .filter(|record| record.context.block_info().is_some()) + .flat_map(|record| { + record + .transaction + .input + .iter() + .map(|input| input.previous_output) + }) + .collect(); + queries + .iter() + .map(|query| { + // A contact's watch-only chain gets no verdict at all — not + // even `Unspent`: its coins are the contact's to spend, never + // this wallet's to flip or to count. + if is_watch_only_contact(&query.account_type) { + return OutpointClass::Unknown; + } + // Unspent wins outright: a coin the engine holds in one of the + // WALLET's funds accounts is a coin, whichever of them the store + // filed it under. A contact account holding the outpoint says + // nothing about this wallet's row. + if accounts + .iter() + .filter(|(account_type, _)| !is_watch_only_contact(account_type)) + .any(|(_, funds)| funds.utxos.contains_key(&query.outpoint)) + { + return OutpointClass::Unspent; + } + let Some((_, owner)) = accounts + .iter() + .find(|(account_type, _)| *account_type == query.account_type) + else { + return OutpointClass::Unknown; + }; + let script = dashcore::ScriptBuf::from_bytes(query.script_pubkey.clone()); + if !owner.contains_script_pub_key(&script) { + return OutpointClass::NotOwned; + } + let txid = &query.outpoint.txid; + let funding_known = owner.has_transaction(txid) || owner.transaction_is_finalized(txid); + if funding_known && mined_spends.contains(&query.outpoint) { + OutpointClass::KnownUncredited + } else { + OutpointClass::Unknown + } + }) + .collect() +} + /// Snapshot of one transaction row inside an account. #[derive(Debug, Clone, Copy)] pub struct AccountTransactionSnapshot { @@ -809,6 +1064,45 @@ impl PlatformWalletManager

{ .collect() } + /// The shared wallet-manager lock, for callers that must take the + /// read lock OUTSIDE another guard — an FFI entry point holds the + /// handle registry's read guard only for the duration of its closure, + /// and waiting on this lock inside that closure would stall + /// `platform_wallet_manager_destroy` (a registry write) and, through + /// parking_lot's writer preference, every other registry reader. + pub fn wallet_manager_arc( + &self, + ) -> Arc< + tokio::sync::RwLock< + key_wallet_manager::WalletManager, + >, + > { + Arc::clone(&self.wallet_manager) + } + + /// [`wallet_utxos_page`] under this manager's read lock. Blocking; + /// call from a thread that may park, never from a runtime worker. + pub fn wallet_utxos_page_blocking( + &self, + wallet_id: &WalletId, + after: Option<&WalletUtxoCursor>, + limit: usize, + ) -> (Vec, bool) { + let wm = self.wallet_manager.blocking_read(); + wallet_utxos_page(&wm, wallet_id, after, limit) + } + + /// [`classify_outpoints`] under this manager's read lock. Blocking; + /// call from a thread that may park, never from a runtime worker. + pub fn classify_outpoints_blocking( + &self, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], + ) -> Vec { + let wm = self.wallet_manager.blocking_read(); + classify_outpoints(&wm, wallet_id, queries) + } + /// Snapshot of every UTXO row on one account. pub fn account_utxos_blocking( &self, @@ -1552,3 +1846,311 @@ mod computed_balance_tests { ); } } + +#[cfg(test)] +mod txo_inventory_tests { + //! Coverage for [`wallet_utxos_page`] and [`classify_outpoints`] — the + //! two engine reads a store reconcile is built on. Drives a real + //! `ManagedWalletInfo` through `check_core_transaction` in the exact + //! arrival orders that produce each classification. + + use std::collections::BTreeMap; + use std::sync::Arc; + + use dashcore::hashes::Hash; + use dashcore::{BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::test_utils::TestWalletContext; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + use key_wallet_manager::WalletManager; + + use super::{ + classify_outpoints, wallet_utxos_page, OutpointClass, OutpointOwnershipQuery, + WALLET_UTXO_PAGE_MAX, + }; + use crate::wallet::core::WalletGeneration; + use crate::wallet::identity::IdentityManager; + use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; + + fn bip44_account_0() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } + } + + fn in_block(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_slice(&[6u8; 32]).expect("valid block hash"), + 1_234_567_890, + )) + } + + fn input(previous_output: OutPoint) -> TxIn { + TxIn { + previous_output, + script_sig: ScriptBuf::new(), + sequence: 0xffffffff, + witness: Witness::new(), + } + } + + fn funding(script_pubkey: ScriptBuf, seed: u8, value: u64) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![input(OutPoint { + txid: Txid::from_slice(&[seed; 32]).expect("valid txid"), + vout: 0, + })], + output: vec![TxOut { + value, + script_pubkey, + }], + special_transaction_payload: None, + } + } + + /// The rust-dashcore#992 shape: one input, one zero-value `OP_RETURN`. + fn collateral_burn(coin: OutPoint) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![input(coin)], + output: vec![TxOut { + value: 0, + script_pubkey: dashcore::blockdata::script::Builder::new() + .push_opcode(dashcore::opcodes::all::OP_RETURN) + .into_script(), + }], + special_transaction_payload: None, + } + } + + fn foreign_script() -> ScriptBuf { + const TEST_PUBKEY_G: [u8; 33] = [ + 0x02, 0x79, 0xbe, 0x66, 0x7e, 0xf9, 0xdc, 0xbb, 0xac, 0x55, 0xa0, 0x62, 0x95, 0xce, + 0x87, 0x0b, 0x07, 0x02, 0x9b, 0xfc, 0xdb, 0x2d, 0xce, 0x28, 0xd9, 0x59, 0xf2, 0x81, + 0x5b, 0x16, 0xf8, 0x17, 0x98, + ]; + let pubkey = + dashcore::PublicKey::from_slice(&TEST_PUBKEY_G).expect("generator point is valid"); + dashcore::Address::p2pkh(&pubkey, key_wallet::Network::Testnet).script_pubkey() + } + + fn manager_with(ctx: TestWalletContext) -> (WalletManager, WalletId) { + let info = PlatformWalletInfo { + core_wallet: ctx.managed_wallet, + generation: Arc::new(WalletGeneration::new()), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + dpns_name_states: BTreeMap::new(), + observed_input_conflicts: Default::default(), + }; + let mut wm = WalletManager::::new(dashcore::Network::Testnet); + let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); + (wm, wallet_id) + } + + fn query( + account_type: AccountType, + outpoint: OutPoint, + script: &ScriptBuf, + ) -> OutpointOwnershipQuery { + OutpointOwnershipQuery { + account_type, + outpoint, + script_pubkey: script.as_bytes().to_vec(), + } + } + + #[tokio::test] + async fn pages_walk_every_coin_once_in_order_and_terminate() { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + let address = ctx.receive_address.to_string(); + let mut coins = Vec::new(); + for (seed, value) in [(11u8, 1_000u64), (12, 2_000), (13, 3_000)] { + let tx = funding(script.clone(), seed, value); + assert!( + ctx.check_transaction(&tx, in_block(100_000 + seed as u32)) + .await + .is_relevant + ); + coins.push(OutPoint { + txid: tx.txid(), + vout: 0, + }); + } + let (wm, wallet_id) = manager_with(ctx); + + let mut walked = Vec::new(); + let mut cursor = None; + let mut pages = 0; + loop { + let (rows, has_more) = wallet_utxos_page(&wm, &wallet_id, cursor.as_ref(), 2); + pages += 1; + for row in &rows { + assert_eq!(row.account_type, bip44_account_0()); + assert_eq!(row.address, address); + assert_eq!(row.script_pubkey, script.as_bytes()); + assert!(row.is_confirmed); + assert!(row.height >= 100_011); + walked.push(row.outpoint); + } + match rows.last() { + Some(last) if has_more => cursor = Some((last.account_type, last.outpoint)), + _ => break, + } + } + assert_eq!(pages, 2, "three coins at two per page"); + let mut expected = coins.clone(); + expected.sort(); + assert_eq!(walked, expected, "every coin once, in outpoint order"); + + // Limit 0 means the default page; an oversized limit is clamped. + let (all, more) = wallet_utxos_page(&wm, &wallet_id, None, 0); + assert_eq!(all.len(), 3); + assert!(!more); + let (all, _) = wallet_utxos_page(&wm, &wallet_id, None, WALLET_UTXO_PAGE_MAX * 4); + assert_eq!(all.len(), 3); + // An unknown wallet is an empty terminal page. + let (none, more) = wallet_utxos_page(&wm, &[0xEEu8; 32], None, 10); + assert!(none.is_empty()); + assert!(!more); + } + + /// The four answers, each from the arrival order that produces it. + /// `KnownUncredited` needs a mined spender on record: a coin funded and + /// then burned while held. The field case — a collateral burn processed + /// before its funding is never recorded, the funding is, the coin is + /// absent — is `Unknown` here: no record spends it, so absence is not + /// durable evidence (the emit-time verdict covers that shape). A coin + /// spent only in the mempool is `Unknown` too: `update_utxos` removed + /// it, but the spend may never confirm and the store keeps it + /// restorable. + #[tokio::test] + async fn classifies_unspent_known_uncredited_not_owned_and_unknown() { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + + // A coin the engine holds. + let held = funding(script.clone(), 21, 5_000); + assert!( + ctx.check_transaction(&held, in_block(100_000)) + .await + .is_relevant + ); + let held_coin = OutPoint { + txid: held.txid(), + vout: 0, + }; + + // The #992 shape: burn first (irrelevant, unrecorded), funding after. + let burned = funding(script.clone(), 22, 19_549); + let burned_coin = OutPoint { + txid: burned.txid(), + vout: 0, + }; + assert!( + !ctx.check_transaction(&collateral_burn(burned_coin), in_block(100_002)) + .await + .is_relevant + ); + assert!( + ctx.check_transaction(&burned, in_block(100_001)) + .await + .is_relevant + ); + + // A coin spent the ordinary way: funded, then burned while held. + let spent = funding(script.clone(), 23, 7_000); + let spent_coin = OutPoint { + txid: spent.txid(), + vout: 0, + }; + assert!( + ctx.check_transaction(&spent, in_block(100_003)) + .await + .is_relevant + ); + assert!( + ctx.check_transaction(&collateral_burn(spent_coin), in_block(100_004)) + .await + .is_relevant + ); + + // A coin spent only in the mempool: absent from `utxos`, funding + // known, spender unconfirmed. + let mempool_spent = funding(script.clone(), 24, 9_000); + let mempool_spent_coin = OutPoint { + txid: mempool_spent.txid(), + vout: 0, + }; + assert!( + ctx.check_transaction(&mempool_spent, in_block(100_005)) + .await + .is_relevant + ); + assert!( + ctx.check_transaction( + &collateral_burn(mempool_spent_coin), + key_wallet::transaction_checking::TransactionContext::Mempool + ) + .await + .is_relevant + ); + let (wm, wallet_id) = manager_with(ctx); + let never_seen = OutPoint { + txid: Txid::from_slice(&[0x99u8; 32]).expect("valid txid"), + vout: 0, + }; + let queries = vec![ + query(bip44_account_0(), held_coin, &script), + query(bip44_account_0(), burned_coin, &script), + query(bip44_account_0(), spent_coin, &script), + query(bip44_account_0(), mempool_spent_coin, &script), + query(bip44_account_0(), burned_coin, &foreign_script()), + query(bip44_account_0(), never_seen, &script), + // The right coin filed under the wrong account: the CoinJoin + // account exists but its pools never monitored a BIP44 script, + // so ownership fails before the txid is even consulted. + query(AccountType::CoinJoin { index: 0 }, burned_coin, &script), + // A coin filed under an account the wallet does not have at all. + query(AccountType::CoinJoin { index: 7 }, burned_coin, &script), + // A held coin filed under a contact's watch-only chain: the + // guard answers before the unspent search does. + query( + AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [0x11u8; 32], + friend_identity_id: [0x22u8; 32], + }, + held_coin, + &script, + ), + ]; + let classes = classify_outpoints(&wm, &wallet_id, &queries); + assert_eq!( + classes, + vec![ + OutpointClass::Unspent, + OutpointClass::Unknown, + OutpointClass::KnownUncredited, + OutpointClass::Unknown, + OutpointClass::NotOwned, + OutpointClass::Unknown, + OutpointClass::NotOwned, + OutpointClass::Unknown, + OutpointClass::Unknown, + ] + ); + // An unknown wallet has no opinion about anything. + assert_eq!( + classify_outpoints(&wm, &[0xEEu8; 32], &queries[..2]), + vec![OutpointClass::Unknown, OutpointClass::Unknown] + ); + assert!(classify_outpoints(&wm, &wallet_id, &[]).is_empty()); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift new file mode 100644 index 00000000000..4497e2f75ad --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -0,0 +1,358 @@ +import DashSDKFFI +import Foundation + +// MARK: - Engine-side values + +/// The seven-field identity the engine files a coin under — the same tuple +/// `AccountSpecFFI`, `AccountBalanceEntryFFI` and the persisted +/// `PersistentAccount` row carry, so a coin can be matched to its store +/// account without going through an address. +public struct CoreAccountKey: Hashable, Sendable { + public var typeTag: UInt8 + public var standardTag: UInt8 + public var index: UInt32 + public var registrationIndex: UInt32 + public var keyClass: UInt32 + /// 32 bytes; all zero for accounts that carry no identity. + public var userIdentityId: Data + /// 32 bytes; all zero for accounts that carry no identity. + public var friendIdentityId: Data + + public init( + typeTag: UInt8, + standardTag: UInt8, + index: UInt32, + registrationIndex: UInt32, + keyClass: UInt32, + userIdentityId: Data, + friendIdentityId: Data + ) { + self.typeTag = typeTag + self.standardTag = standardTag + self.index = index + self.registrationIndex = registrationIndex + self.keyClass = keyClass + self.userIdentityId = userIdentityId + self.friendIdentityId = friendIdentityId + } + +} + +/// One coin the engine holds, as one row of the paged inventory +/// (`platform_wallet_wallet_utxos_page`). +public struct CoreEngineUtxo: Equatable, Sendable { + public var account: CoreAccountKey + /// 32-byte txid in wire orientation — the same bytes the changeset + /// path hands the persister. + public var txid: Data + public var vout: UInt32 + public var amount: UInt64 + /// Base58Check address the engine derived for the output; empty when + /// the script has no address form. + public var address: String + public var scriptPubKey: Data + public var height: UInt32 + public var isConfirmed: Bool + public var isInstantLocked: Bool + public var isCoinbase: Bool + public var isLocked: Bool + + public init( + account: CoreAccountKey, + txid: Data, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data, + height: UInt32, + isConfirmed: Bool, + isInstantLocked: Bool, + isCoinbase: Bool, + isLocked: Bool + ) { + self.account = account + self.txid = txid + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isConfirmed = isConfirmed + self.isInstantLocked = isInstantLocked + self.isCoinbase = isCoinbase + self.isLocked = isLocked + } + + /// The 36-byte `PersistentTxo.outpoint` key of this coin. + public var outpoint: Data { + PersistentTxo.makeOutpoint(txid: txid, vout: vout) + } +} + +/// One store row the reconcile asks the engine about +/// (`platform_wallet_classify_outpoints`): the account the store files +/// the coin under and the script it recorded, which the engine checks +/// against its own pools rather than trusting. +public struct CoreOutpointOwnershipQuery: Equatable, Sendable { + public var account: CoreAccountKey + public var txid: Data + public var vout: UInt32 + public var scriptPubKey: Data + + public init(account: CoreAccountKey, txid: Data, vout: UInt32, scriptPubKey: Data) { + self.account = account + self.txid = txid + self.vout = vout + self.scriptPubKey = scriptPubKey + } + + public var outpoint: Data { + PersistentTxo.makeOutpoint(txid: txid, vout: vout) + } +} + +/// The engine's answer for one `CoreOutpointOwnershipQuery` — the +/// `OUTPOINT_CLASS_*` constants of the FFI. +/// +/// Only `knownUncredited` is positive evidence the reconcile acts on: the +/// owning account recorded the funding transaction, recognises the +/// output's script as its own, does not hold the coin, AND a funds account +/// holds a mined record whose transaction spends the outpoint. Absence +/// from the engine's `utxos` alone is not durable evidence: a mempool-only +/// or IS-locked spend, a released conflict loser, and a spender the engine +/// never recorded (the emit-time verdict covers that shape) all answer +/// `unknown`. `unknown` also covers every funding transaction this session +/// never processed (after a restart the engine's finalized set is empty), +/// so absence proves nothing and is never acted on. +public enum CoreOutpointClass: UInt8, Sendable { + case unknown = 0 + case unspent = 1 + case knownUncredited = 2 + case notOwned = 3 +} + +/// The engine reads the store reconcile is built on. Production wraps the +/// two FFIs (`FFICoreTxoEngineInventory`); tests inject a fake so every +/// verdict the reconcile can reach is reproducible without a native +/// engine. Both reads park the calling thread on the engine's wallet lock, +/// so they run on the manager's reconcile queue — never on the persistence +/// queue (a persistence callback can be waiting on the same lock from the +/// other side) and never on a Swift Concurrency pool thread. +protocol CoreTxoEngineInventory: Sendable { + /// One page of the wallet's UTXO inventory strictly after `after` + /// (`nil` starts the walk), at most `limit` rows, and whether more + /// follow. + func utxoPage(after: CoreEngineUtxo?, limit: Int) throws -> (rows: [CoreEngineUtxo], hasMore: Bool) + /// Positional: `result[i]` answers `queries[i]`. + func classify(_ queries: [CoreOutpointOwnershipQuery]) throws -> [CoreOutpointClass] +} + +// MARK: - Outcome + +/// What one reconcile run did. Counts only — no outpoint, address or +/// amount of an individual coin leaves the store through this type or +/// the events built from it. +public struct CoreTxoReconcileReport: Equatable, Sendable { + // Heal pass (engine → store, insert-only). + /// Engine inventory rows walked. + public var engineRows = 0 + /// Rows the store already held. + public var alreadyPresent = 0 + /// Rows inserted. + public var inserted = 0 + public var insertedDuffs: UInt64 = 0 + /// Engine coins the store lacked whose row the pending-input drain wrote + /// spent on insert: the divergence is recorded, not repaired. + public var healedSpent = 0 + /// Engine rows below the confirmation gate. + public var skippedImmature = 0 + /// Engine rows whose account has no store row to file them under. + public var skippedUnresolvedAccount = 0 + /// Engine rows the store could not validate (malformed txid, no + /// script, no address). + public var skippedInvalid = 0 + + // Classify pass (store → engine, flip-only). + /// Unspent store rows of this wallet handed to the engine. + public var storeRows = 0 + /// Store rows the engine reported unspent — consistent. + public var unspent = 0 + /// Store rows flipped to spent on the engine's positive verdict. + public var flipped = 0 + public var flippedDuffs: UInt64 = 0 + /// Store rows the engine has no opinion about — left untouched. + public var unknown = 0 + /// Store rows whose script the engine does not monitor — left + /// untouched, reported. + public var notOwned = 0 + + // Run shape. + /// Steps deferred because a Rust persistence round was open. + public var retries = 0 + /// Pages classified again because a persistence round committed between + /// their read and their apply. + public var staleRetries = 0 + /// Engine reads that failed; the run stops at the first. + public var transportFailures = 0 + /// Store writes that failed to save; the run stops at the first. + public var storeFailures = 0 + /// `false` when the run stopped early (cancelled, a failed read or + /// write, or too many deferrals); what landed before the stop stays. + public var completed = true + + public init() {} + + /// Rows this run wrote: healed, flipped, and healed rows the drain wrote + /// spent on insert — the last is a divergence recorded, not repaired, + /// but it is a row the store did not have before. + public var mutations: Int { inserted + flipped + healedSpent } +} + +/// Why `reconcileCoreTxoStore(for:)` did not run. +public enum CoreTxoReconcileSkipReason: String, Sendable { + case notConfigured + case shutdownRequested + case walletUnknown + case alreadyRunning + case spvNotRunning + case notSteadyState + case tipUnavailable + case syncFaultDetected + case walletBehindTip +} + +public enum CoreTxoReconcileOutcome: Equatable, Sendable { + case skipped(CoreTxoReconcileSkipReason) + case reconciled(CoreTxoReconcileReport) +} + +// MARK: - FFI adapter + +/// The production `CoreTxoEngineInventory`: the two paged FFI reads on +/// one manager handle, for one wallet. +struct FFICoreTxoEngineInventory: CoreTxoEngineInventory { + let handle: Handle + let walletId: Data + + func utxoPage(after: CoreEngineUtxo?, limit: Int) throws -> (rows: [CoreEngineUtxo], hasMore: Bool) { + var cursor = WalletUtxoCursorFFI() + if let after { + cursor.type_tag = after.account.typeTag + cursor.standard_tag = after.account.standardTag + cursor.index = after.account.index + cursor.registration_index = after.account.registrationIndex + cursor.key_class = after.account.keyClass + Self.copy(after.account.userIdentityId, into: &cursor.user_identity_id) + Self.copy(after.account.friendIdentityId, into: &cursor.friend_identity_id) + Self.copy(after.txid, into: &cursor.outpoint.txid) + cursor.outpoint.vout = after.vout + } + let hasCursor = after != nil + var rowsPtr: UnsafePointer? + var count: UInt = 0 + var hasMore = false + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + withUnsafePointer(to: &cursor) { cursorPtr in + platform_wallet_wallet_utxos_page( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + hasCursor ? cursorPtr : nil, + UInt(max(limit, 0)), + &rowsPtr, + &count, + &hasMore + ) + } + } + try result.check() + guard let rowsPtr, count > 0 else { return ([], hasMore) } + defer { platform_wallet_wallet_utxos_page_free(UnsafeMutablePointer(mutating: rowsPtr), count) } + var rows: [CoreEngineUtxo] = [] + rows.reserveCapacity(Int(count)) + for i in 0.. [CoreOutpointClass] { + guard !queries.isEmpty else { return [] } + // Every script must stay addressable for the whole call: copy each + // into its own allocation rather than nesting `withUnsafeBytes` + // closures `queries.count` deep. + var buffers: [UnsafeMutablePointer] = [] + defer { buffers.forEach { $0.deallocate() } } + var ffiQueries: [OutpointOwnershipQueryFFI] = [] + ffiQueries.reserveCapacity(queries.count) + for query in queries { + var entry = OutpointOwnershipQueryFFI() + entry.type_tag = query.account.typeTag + entry.standard_tag = query.account.standardTag + entry.index = query.account.index + entry.registration_index = query.account.registrationIndex + entry.key_class = query.account.keyClass + Self.copy(query.account.userIdentityId, into: &entry.user_identity_id) + Self.copy(query.account.friendIdentityId, into: &entry.friend_identity_id) + Self.copy(query.txid, into: &entry.outpoint.txid) + entry.outpoint.vout = query.vout + let scriptCount = query.scriptPubKey.count + let buffer = UnsafeMutablePointer.allocate(capacity: max(scriptCount, 1)) + query.scriptPubKey.copyBytes(to: buffer, count: scriptCount) + buffers.append(buffer) + entry.script_pubkey = scriptCount > 0 ? UnsafePointer(buffer) : nil + entry.script_pubkey_len = UInt(scriptCount) + ffiQueries.append(entry) + } + var classes = [UInt8](repeating: 0, count: queries.count) + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + ffiQueries.withUnsafeBufferPointer { queriesPtr in + classes.withUnsafeMutableBufferPointer { classesPtr in + platform_wallet_classify_outpoints( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + queriesPtr.baseAddress, + UInt(queries.count), + classesPtr.baseAddress + ) + } + } + } + try result.check() + return classes.map { CoreOutpointClass(rawValue: $0) ?? .unknown } + } + + /// Copy up to 32 bytes of `data` into a C `uint8_t[32]` field, + /// zero-filling the rest. + private static func copy(_ data: Data, into field: inout T) { + withUnsafeMutableBytes(of: &field) { raw in + raw.initializeMemory(as: UInt8.self, repeating: 0) + let count = min(raw.count, data.count) + data.copyBytes(to: raw.bindMemory(to: UInt8.self), count: count) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index dbac9df6306..5b8fb30f59d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -596,7 +596,7 @@ public class PlatformWalletManager: ObservableObject { /// synchronous overloads — so the drain below can terminate without a /// synchronous op entering while the MainActor is reentrant at an /// `await`. - private var shutdownRequested = false + private(set) var shutdownRequested = false /// Async native entrypoints (`createWallet`, `loadFromPersistor`) /// between admission and the end of their MainActor epilogue. @@ -669,6 +669,28 @@ public class PlatformWalletManager: ObservableObject { /// answers `NotFound` once `destroy` ran. internal var pollDrainTimeout: Duration = .milliseconds(250) + // MARK: Core TXO store reconcile state (see PlatformWalletManagerTxoReconcile.swift) + + /// Wallets with a reconcile run in flight; a second run for the same + /// wallet is refused rather than overlapped. + var coreTxoReconcileInFlight: Set = [] + /// When each wallet's last automatic run was scheduled, for the cadence. + var coreTxoReconcileLastRunAt: [Data: ContinuousClock.Instant] = [:] + /// Whether the last progress tick was in steady state, for the + /// rising-edge trigger. + var coreTxoReconcileWasSteady = false + /// Bumped by [`shutdown()`] and [`deleteWallet`]; an in-flight run + /// re-checks it between pages and stops. + nonisolated let coreTxoReconcileEpoch = SyncGenerationCounter() + /// Dedicated serial queue for the reconcile's engine reads — they park + /// on the wallet lock like the poll reads, so never the main thread or + /// a cooperative-pool thread, and not [`pollQueue`], whose ticks feed + /// the sync indicator and must not wait behind a page walk. + nonisolated let coreTxoReconcileQueue = DispatchQueue( + label: "org.dash.platform-wallet.txo-reconcile", + qos: .utility + ) + /// Dedicated serial queue for the poller's native reads. They park the /// calling thread the way teardown and create do — `sync_progress`, /// `spv_connected_peers`, `spv_tip_unix_seconds` and the per-wallet @@ -861,6 +883,8 @@ public class PlatformWalletManager: ObservableObject { // The bounded queue drain therefore happens INSIDE the task, before // the teardown, where a suspension is safe. pollEpoch.bump() + // An in-flight store reconcile stops between pages the same way. + coreTxoReconcileEpoch.bump() let calls = nativeTeardownCalls let queue = pollQueue @@ -2516,6 +2540,11 @@ public class PlatformWalletManager: ObservableObject { // with the same deterministic id doesn't inherit a stale banner (the // poller would also prune it, but not until the next tick). dashPayUnlockStatus.removeValue(forKey: walletId) + // A store reconcile in flight for any wallet stops between pages: + // its next step would read rows `deleteWalletData` is about to + // remove. Coarse on purpose — the cadence re-runs the others. + coreTxoReconcileEpoch.bump() + coreTxoReconcileLastRunAt.removeValue(forKey: walletId) try persistenceHandler.deleteWalletData(walletId: walletId) @@ -3008,8 +3037,14 @@ public class PlatformWalletManager: ObservableObject { ) { guard handle != NULL_HANDLE else { return } if let value = snapshot.spvProgress, - spvProgress == baseline.spvProgress, value != spvProgress { - spvProgress = value + spvProgress == baseline.spvProgress { + if value != spvProgress { + spvProgress = value + } + // Every accepted read, not only a changed one: the note is the + // reconcile's only clock, and a quiet steady-state wallet's + // progress does not change for the whole cadence. + noteSpvProgressForCoreTxoReconcile(value) } if let value = snapshot.spvIsRunning, spvIsRunning == baseline.spvIsRunning, value != spvIsRunning { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift new file mode 100644 index 00000000000..19ad5006dda --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -0,0 +1,410 @@ +import DashSDKFFI +import Foundation + +// MARK: - Core TXO store reconcile +// +// Why this exists: the SwiftData TXO store is rebuilt from the engine's +// per-round deltas and is itself the source the engine is restored from +// at every launch (`loadWalletList` hands back every `isSpent == false` +// row). Two failure classes leave the two sides disagreeing after a full +// scan — a coin the engine credited whose row the store never got, and a +// row the store holds unspent for a coin the engine never credited +// (rust-dashcore#992: the spender had no wallet-owned output and was +// discarded before the coin was known). The restore then hands the +// phantom back, and the engine shows a balance it had just corrected +// (dashpay/platform#4575). This pass compares the store with the engine +// once the scan has reached a trustworthy steady state and repairs +// exactly what positive evidence supports: +// +// - insert an engine-held coin the store lacks (validated, owned, +// ≥ 100 confirmations); +// - mark a store row spent when the engine says the owning account +// recorded its funding transaction, owns its script, and does not hold +// the coin (`CoreOutpointClass.knownUncredited`). +// +// It never deletes a row, never un-marks a spent row, and never acts on +// absence alone. It is idempotent (a consistent store yields a report with +// zero mutations), wallet-scoped, paged in both directions, and stops +// between pages when the manager shuts down or the wallet is deleted. + +extension PlatformWalletManager { + /// Confirmations an engine coin needs before the heal pass inserts it. + /// Coinbase maturity; also well past any plausible reorg. + nonisolated static let coreTxoReconcileMinConfirmations: UInt32 = 100 + /// How far the wallet's durable scan watermark may trail the scan tip + /// for the scan to count as complete for this wallet. + nonisolated static let coreTxoReconcileTipMargin: UInt32 = 6 + /// Cadence of the automatic run while the client stays in steady state. + nonisolated static let coreTxoReconcileCadence: Duration = .seconds(30 * 60) + /// Rows per engine page / per store page. + nonisolated static let coreTxoReconcilePageSize = 512 + /// How many times a step is deferred behind open Rust rounds before the + /// run gives up (each deferral waits `coreTxoReconcileRetryDelay`). + nonisolated static let coreTxoReconcileMaxRetries = 200 + nonisolated static let coreTxoReconcileRetryDelay: TimeInterval = 0.05 + /// How many times in a row one page is classified again because a + /// persistence round committed between its read and its apply, before + /// the run gives up. + nonisolated static let coreTxoReconcileMaxStaleRetries = 5 + + /// Reconcile the SwiftData TXO store of `walletId` against the engine, + /// once the SPV scan has reached a trustworthy steady state — see the + /// file comment for what it repairs and what it refuses to touch. + /// + /// Gates, each a `.skipped` outcome rather than an error: the manager + /// must be configured and not shutting down, the wallet loaded and not + /// already being reconciled, SPV running and in steady state (dash-spv's + /// fully-synced state is `waitForEvents` with the filter phase at its + /// target; `.synced` is transient), no sync fault latched + /// (`syncFaultDetected()` — a rejected round means rows are missing by + /// design and a rescan is pending), and the wallet's own durable scan + /// watermark within `coreTxoReconcileTipMargin` of the scan tip. The + /// engine reads run on `coreTxoReconcileQueue` (they park on the wallet + /// lock); the store writes run on the persistence queue, one step per + /// closure, deferred while a Rust round is open. The run stops between + /// pages when `shutdown()` or `deleteWallet` bumps + /// `coreTxoReconcileEpoch`, reporting `completed == false`. + /// + /// Runs automatically on the steady-state transition and every + /// `coreTxoReconcileCadence`; hosts may also call it directly. + public func reconcileCoreTxoStore(for walletId: Data) async throws -> CoreTxoReconcileOutcome { + guard isConfigured, handle != NULL_HANDLE, let handler = persistence else { + return logSkip(.notConfigured, walletId: walletId) + } + guard !shutdownRequested else { return logSkip(.shutdownRequested, walletId: walletId) } + guard walletId.count == 32, wallets[walletId] != nil else { + return logSkip(.walletUnknown, walletId: walletId) + } + guard !coreTxoReconcileInFlight.contains(walletId) else { + return logSkip(.alreadyRunning, walletId: walletId) + } + guard spvIsRunning else { return logSkip(.spvNotRunning, walletId: walletId) } + let progress = spvProgress + guard Self.isSteadySyncState(progress) else { return logSkip(.notSteadyState, walletId: walletId) } + guard let tipHeight = Self.scanTipHeight(progress) else { + return logSkip(.tipUnavailable, walletId: walletId) + } + if try syncFaultDetected() { return logSkip(.syncFaultDetected, walletId: walletId) } + + coreTxoReconcileInFlight.insert(walletId) + defer { coreTxoReconcileInFlight.remove(walletId) } + + let epoch = coreTxoReconcileEpoch + let generation = epoch.current() + let managerHandle = handle + let queue = coreTxoReconcileQueue + + // The wallet's own durable watermark, read off-main: the scan tip + // says how far the CLIENT got, not how far this wallet's rows are + // committed, and a wallet added behind the tip is still being + // scanned. + let state: CoreWalletStateFFI? = await withCheckedContinuation { continuation in + queue.async { + guard epoch.current() == generation else { + continuation.resume(returning: nil) + return + } + continuation.resume(returning: Self.readCoreWalletState(managerHandle, walletId: walletId)) + } + } + guard handle != NULL_HANDLE, !shutdownRequested else { + return logSkip(.shutdownRequested, walletId: walletId) + } + guard let state else { return logSkip(.walletUnknown, walletId: walletId) } + guard state.synced_height &+ Self.coreTxoReconcileTipMargin >= tipHeight else { + return logSkip(.walletBehindTip, walletId: walletId) + } + + let engine = FFICoreTxoEngineInventory(handle: managerHandle, walletId: walletId) + let report: CoreTxoReconcileReport = await withCheckedContinuation { continuation in + queue.async { + let report = Self.runCoreTxoReconcile( + walletId: walletId, + tipHeight: tipHeight, + engine: engine, + handler: handler, + isCancelled: { epoch.current() != generation } + ) + continuation.resume(returning: report) + } + } + coreTxoReconcileLastRunAt[walletId] = ContinuousClock.now + Self.logSummary(report, walletId: walletId) + return .reconciled(report) + } + + /// dash-spv's steady state for a fully synced client is `waitForEvents` + /// with the filter phase at its target height; `.synced` is the + /// transient window before it. Either counts. + nonisolated static func isSteadySyncState(_ progress: PlatformSpvSyncProgress) -> Bool { + switch progress.overallState { + case .synced: + return true + case .waitForEvents: + guard let filters = progress.filters else { return false } + return filters.targetHeight > 0 && filters.currentHeight >= filters.targetHeight + case .waitingForConnections, .syncing, .error: + return false + } + } + + /// The scan tip: the filter phase's height (the wallet-relevant one), + /// falling back to the header tip when the filter phase is absent. + nonisolated static func scanTipHeight(_ progress: PlatformSpvSyncProgress) -> UInt32? { + if let filters = progress.filters, filters.currentHeight > 0 { + return filters.currentHeight + } + if let headers = progress.headers, headers.currentHeight > 0 { + return headers.currentHeight + } + return nil + } + + /// The wallet's durable core scan state, or `nil` when the manager does + /// not know the wallet. Parks on the wallet lock — call off-main. + nonisolated static func readCoreWalletState(_ handle: Handle, walletId: Data) -> CoreWalletStateFFI? { + guard walletId.count == 32 else { return nil } + var state = CoreWalletStateFFI() + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + platform_wallet_core_wallet_state( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &state + ) + } + return PlatformWalletResult(result).isSuccess ? state : nil + } + + /// The reconcile itself: engine reads on the calling thread (the + /// reconcile queue), store steps on the persistence queue. Synchronous + /// and `nonisolated static` so tests can drive it with an injected + /// engine and handler, without a native manager. + nonisolated static func runCoreTxoReconcile( + walletId: Data, + tipHeight: UInt32, + minConfirmations: UInt32 = coreTxoReconcileMinConfirmations, + pageSize: Int = coreTxoReconcilePageSize, + engine: any CoreTxoEngineInventory, + handler: PlatformWalletPersistenceHandler, + isCancelled: @Sendable () -> Bool + ) -> CoreTxoReconcileReport { + var report = CoreTxoReconcileReport() + + // Pass A — heal: every engine coin the store lacks. + var cursor: CoreEngineUtxo? + while true { + if isCancelled() { + report.completed = false + return report + } + let page: (rows: [CoreEngineUtxo], hasMore: Bool) + do { + page = try engine.utxoPage(after: cursor, limit: pageSize) + } catch { + report.transportFailures += 1 + report.completed = false + return report + } + report.engineRows += page.rows.count + if !page.rows.isEmpty { + guard let counts = withRoundRetry(&report, isCancelled: isCancelled, { + handler.reconcileHealMissingTxos( + walletId: walletId, + rows: page.rows, + tipHeight: tipHeight, + minConfirmations: minConfirmations + ) + }) else { return report } + report.inserted += counts.inserted + report.insertedDuffs = report.insertedDuffs.addingReportingOverflow(counts.insertedDuffs).0 + report.alreadyPresent += counts.alreadyPresent + report.healedSpent += counts.healedSpent + report.skippedImmature += counts.skippedImmature + report.skippedUnresolvedAccount += counts.skippedUnresolvedAccount + report.skippedInvalid += counts.skippedInvalid + } + guard page.hasMore, let last = page.rows.last else { break } + cursor = last + } + + // Pass B — classify: every unspent store row of the wallet. A page + // whose verdicts were read before a round committed is classified + // again (`staleGeneration`), at most `coreTxoReconcileMaxStaleRetries` + // times in a row. + var offset = 0 + var staleRetries = 0 + while true { + if isCancelled() { + report.completed = false + return report + } + guard let page = withRoundRetry(&report, isCancelled: isCancelled, { + handler.reconcileUnspentTxoPage(walletId: walletId, offset: offset, limit: pageSize) + }) else { return report } + report.storeRows += page.rows.count + var flippedThisPage = 0 + if !page.rows.isEmpty { + let classes: [CoreOutpointClass] + do { + classes = try engine.classify(page.rows.map(\.query)) + } catch { + report.transportFailures += 1 + report.completed = false + return report + } + guard classes.count == page.rows.count else { + report.transportFailures += 1 + report.completed = false + return report + } + guard let counts = withRoundRetry(&report, isCancelled: isCancelled, { + handler.reconcileApplyEngineClasses( + walletId: walletId, + rows: page.rows, + classes: classes, + expectedGeneration: page.generation + ) + }) else { return report } + if counts.staleGeneration { + staleRetries += 1 + report.staleRetries += 1 + guard staleRetries <= coreTxoReconcileMaxStaleRetries else { + report.completed = false + return report + } + // Same offset: nothing was written, the page is re-read + // and classified against the store as it is now. + report.storeRows -= page.rows.count + continue + } + staleRetries = 0 + report.flipped += counts.flipped + report.flippedDuffs = report.flippedDuffs.addingReportingOverflow(counts.flippedDuffs).0 + report.unspent += counts.unspent + report.unknown += counts.unknown + report.notOwned += counts.notOwned + flippedThisPage = counts.flipped + } + guard page.hasMore else { break } + // Flipped rows left the `isSpent == false` predicate, so the + // next page starts that many rows earlier — at the same offset + // when the whole page flipped, which still makes progress: the + // rows now at that offset are ones this walk has not seen. + offset += page.fetched - flippedThisPage + } + return report + } + + /// Run one store step, retrying while a Rust round is open. `nil` when + /// the run must stop: cancelled, the step failed, or the round never + /// closed within the retry budget — `report` says which. + private nonisolated static func withRoundRetry( + _ report: inout CoreTxoReconcileReport, + isCancelled: @Sendable () -> Bool, + _ step: () -> CoreTxoReconcileStep + ) -> T? { + var attempts = 0 + while true { + if isCancelled() { + report.completed = false + return nil + } + switch step() { + case .done(let value): + return value + case .failed: + report.storeFailures += 1 + report.completed = false + return nil + case .retryLater: + attempts += 1 + report.retries += 1 + if attempts > coreTxoReconcileMaxRetries { + report.completed = false + return nil + } + Thread.sleep(forTimeInterval: coreTxoReconcileRetryDelay) + } + } + } + + /// Automatic trigger, fed by the manager's 1 Hz progress poll: run for + /// every loaded wallet on the transition into steady state, and again + /// every `coreTxoReconcileCadence` while it lasts. Never inline — the + /// tick is budget-sensitive — and never while a run for the same wallet + /// is in flight. Called on the main actor from `applyManagerSnapshot`. + func noteSpvProgressForCoreTxoReconcile(_ progress: PlatformSpvSyncProgress) { + let steady = Self.isSteadySyncState(progress) + let rising = steady && !coreTxoReconcileWasSteady + coreTxoReconcileWasSteady = steady + guard steady, !shutdownRequested, spvIsRunning else { return } + let now = ContinuousClock.now + for walletId in wallets.keys { + guard !coreTxoReconcileInFlight.contains(walletId) else { continue } + let due = rising || coreTxoReconcileLastRunAt[walletId].map { + now - $0 >= Self.coreTxoReconcileCadence + } ?? true + guard due else { continue } + // Stamped at schedule time so the next tick does not schedule + // the same wallet again while this run is still gating. + coreTxoReconcileLastRunAt[walletId] = now + Task { [weak self] in + guard let self else { return } + do { + _ = try await self.reconcileCoreTxoStore(for: walletId) + } catch { + SDKLogger.event( + "persistence_txo_reconcile_failed", + category: .persistence, + severity: .warning, + fields: ["wallet_reference": .reference(walletId)], + error: error + ) + } + } + } + } + + private func logSkip(_ reason: CoreTxoReconcileSkipReason, walletId: Data) -> CoreTxoReconcileOutcome { + SDKLogger.event( + "persistence_txo_reconcile_skipped", + category: .persistence, + severity: .debug, + fields: [ + "reason": .publicText(reason.rawValue), + "wallet_reference": .reference(walletId), + ] + ) + return .skipped(reason) + } + + private nonisolated static func logSummary(_ report: CoreTxoReconcileReport, walletId: Data) { + SDKLogger.event( + "persistence_txo_reconcile_summary", + category: .persistence, + severity: report.mutations == 0 && report.completed ? .info : .warning, + fields: [ + "already_present_count": .integer(Int64(report.alreadyPresent)), + "completed": .boolean(report.completed), + "engine_row_count": .integer(Int64(report.engineRows)), + "flipped_count": .integer(Int64(report.flipped)), + "flipped_value_duffs": .unsignedInteger(report.flippedDuffs), + "healed_spent_count": .integer(Int64(report.healedSpent)), + "inserted_count": .integer(Int64(report.inserted)), + "inserted_value_duffs": .unsignedInteger(report.insertedDuffs), + "not_owned_count": .integer(Int64(report.notOwned)), + "retry_count": .integer(Int64(report.retries)), + "stale_retry_count": .integer(Int64(report.staleRetries)), + "skipped_immature_count": .integer(Int64(report.skippedImmature)), + "skipped_invalid_count": .integer(Int64(report.skippedInvalid)), + "skipped_unresolved_account_count": .integer(Int64(report.skippedUnresolvedAccount)), + "store_failure_count": .integer(Int64(report.storeFailures)), + "store_row_count": .integer(Int64(report.storeRows)), + "transport_failure_count": .integer(Int64(report.transportFailures)), + "unknown_count": .integer(Int64(report.unknown)), + "unspent_count": .integer(Int64(report.unspent)), + "wallet_reference": .reference(walletId), + ] + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 74b7446411e..e5e67eb4ab1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -246,6 +246,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] } private var roundIndex: ChangesetRoundIndex? + /// Number of persistence rounds committed by this handler, read and + /// compared on `serialQueue`. The store reconcile classifies rows off + /// this queue and applies the verdicts on it; a round committed in + /// between may have re-credited one of them (a reorg of the spender + /// delivers the coin back in `utxos_added`), so a flip is refused when + /// the count moved since the rows were read, and the page is + /// classified again. + private(set) var committedRoundGeneration: UInt64 = 0 /// Set when the open round advanced either half of the tombstone /// finality boundary — `syncedHeight` through the changeset callback or @@ -256,6 +264,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Confined to `serialQueue` like all other mutable handler state. private var roundAdvancedFinalityBoundary = false + /// The engine's credit verdicts for the open round — every `Received` / + /// `Change` output this round's records carry that the engine did NOT + /// credit to the owning account — keyed by the 36-byte outpoint. + /// Delivered through the extension's + /// `on_persist_wallet_changeset_utxo_verdicts_fn` BEFORE the changeset + /// callback, so `upsertUtxo` consults it while it materialises the + /// round's `utxos_added`: a row for a coin the engine never held would + /// otherwise be written unspent, restored into the engine at the next + /// launch, and show as a phantom balance (rust-dashcore#992). An + /// outpoint absent here is credited — the ordinary case. Kept outside + /// `ChangesetRoundIndex` so it survives an unindexed round. Cleared by + /// `beginChangeset` and `endChangeset`. Confined to `serialQueue`. + private var roundUtxoCreditVerdicts: [Data: UtxoCreditVerdictFFI] = [:] + + /// How `upsertUtxo` applied the round's verdicts — logged once, as + /// counts, by `endChangeset`. Confined to `serialQueue`. + private var roundUtxoCreditTally = UtxoCreditVerdictTally() + /// Breadcrumb backfills that arrived on the serial queue while a /// changeset round was open. The backfill both mutates /// `backgroundContext` and saves it, so running it mid-round would @@ -1414,6 +1440,36 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// would have Rust clear the sweep while the dead row survives to be /// replayed at the next load. @discardableResult + /// Stage the round's credit verdicts (see `roundUtxoCreditVerdicts`). + /// Fired by Rust inside the begin/end bracket BEFORE the changeset + /// callback, only on rounds that carry at least one verdict. Nothing is + /// written here — the verdicts are applied by `upsertUtxo` when the + /// round's `utxos_added` entries arrive, and a verdict for an outpoint + /// no entry names is simply dropped with the round. + func persistWalletChangesetUtxoVerdicts( + walletId: Data, + verdicts: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "wallet_changeset_utxo_verdicts") { + case .failed: return false + case .absent: return true + case .found: break + } + guard count > 0, let verdictsPtr = verdicts else { return true } + for i in 0..?, @@ -2313,6 +2369,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Resolve a `PersistentTxo` by its unique 36-byte `outpoint`. private func fetchTxoRow(outpoint: Data) -> PersistentTxo? { + try? fetchTxoRowChecked(outpoint: outpoint) + } + + /// Throwing core of `fetchTxoRow`: `nil` is a successful miss, a read + /// that fails throws. Round writers take the `nil` (a miss and a failed + /// read both mean "write the row"); the reconcile passes must not, + /// because for them a miss is an insert and a failed read is a stop. + private func fetchTxoRowChecked(outpoint: Data) throws -> PersistentTxo? { if let known = roundIndex?.txosByOutpoint[outpoint] { return known.isDeleted ? nil : known } @@ -2321,7 +2385,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) descriptor.fetchLimit = 1 if roundIndex != nil { descriptor.includePendingChanges = false } - guard let row = (try? backgroundContext.fetch(descriptor))?.first, + guard let row = try modelFetcher.fetch(descriptor, in: backgroundContext).first, !row.isDeleted else { return nil } roundIndex?.txosByOutpoint[outpoint] = row return row @@ -2774,7 +2838,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // linked spender with context at or above InstantSend-locked: // confirmed evidence on record is never displaced by a re-delivery // (the pending-input resolve and the spend emit own that link). - if redelivered, record.isSpent { + // The engine's verdict on this very output, if it did NOT credit + // it (see `roundUtxoCreditVerdicts`). Any verdict vetoes the + // recovery clear below: the wallet is not handing this coin back + // as unspent — its record merely still names the output as ours. + let creditVerdict = roundUtxoCreditVerdicts[outpoint] + if creditVerdict == nil, redelivered, record.isSpent { let settledSpender = record.spendingTransaction.map { $0.context >= TransactionContextType.instantSend.rawValue } ?? false @@ -2786,6 +2855,33 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } } + if let creditVerdict { + switch creditVerdict.verdict { + case UtxoCreditVerdictCode.observedSpent, UtxoCreditVerdictCode.doomed: + // Block-context evidence that a coin is not spendable: the + // wallet observed a block spending it before the output was + // recognised (the spender may never have been recorded — + // rust-dashcore#992), or the record can never confirm. The + // row is written spent with no spender link; `isSpent` is + // monotonic, so a row already spent is left as it is. + if record.isSpent { + roundUtxoCreditTally.alreadySpent += 1 + } else { + record.isSpent = true + if creditVerdict.verdict == UtxoCreditVerdictCode.observedSpent { + roundUtxoCreditTally.observedSpent += 1 + } else { + roundUtxoCreditTally.doomed += 1 + } + } + default: + // No context: the coin was taken between emit and drain, or + // carries an account-level spent mark. The spender's own + // record or the sweep callback settles it; here the verdict + // only kept the recovery clear from resurrecting the row. + roundUtxoCreditTally.uncredited += 1 + } + } // Attach the `PersistentCoreAddress` row, if we have one. The // address-emit pass typically runs ahead of the SPV-utxo pass @@ -2799,15 +2895,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - // Resolve any deferred spend signal that landed before this - // TXO existed. `upsertTransaction` writes a - // `PersistentPendingInput` row for every input outpoint - // whose previous-output isn't in SwiftData yet; the matching - // upsert here drains those rows and stamps `isSpent` on the - // TXO. Symmetric with the resolve path in - // `upsertTransaction`, so the spend signal is order- - // independent at this layer regardless of which side arrives - // first. + drainPendingInputs(into: record, resolvedWalletId: resolvedWalletId) + } + + /// Resolve any deferred spend signal that landed before this TXO + /// existed. `upsertTransaction` writes a `PersistentPendingInput` row + /// for every input outpoint whose previous-output isn't in SwiftData + /// yet; the matching upsert here drains those rows and stamps + /// `isSpent` on the TXO. Symmetric with the resolve path in + /// `upsertTransaction`, so the spend signal is order-independent at + /// this layer regardless of which side arrives first. Shared by + /// `upsertUtxo` and the store reconcile's heal path + /// (`reconcileHealMissingTxos`), so both writers honour the same + /// tombstone precedence and spender-adoption rules. + private func drainPendingInputs(into record: PersistentTxo, resolvedWalletId: Data) { let pendingRows = pendingInputRows(outpoint: record.outpoint) if !pendingRows.isEmpty { // A tombstone is not an observation — it is a sweep's settled @@ -3092,6 +3193,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // boundary `min(chainlockHeight, syncedHeight)` needs the number. extensionCallbacks.on_persist_wallet_changeset_chain_lock_height_fn = persistWalletChangesetChainLockHeightCallback + // The engine's credit verdicts — the outputs a round's records call + // ours that the engine did not credit — ride a slot of their own + // for the same reason, and are fired BEFORE the changeset callback + // so `upsertUtxo` has them in hand (see `roundUtxoCreditVerdicts`). + extensionCallbacks.on_persist_wallet_changeset_utxo_verdicts_fn = + persistWalletChangesetUtxoVerdictsCallback return extensionCallbacks } @@ -3173,6 +3280,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func beginChangeset(walletId: Data) { onQueue { self.inChangeset = true + self.roundUtxoCreditVerdicts = [:] + self.roundUtxoCreditTally = UtxoCreditVerdictTally() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -3240,6 +3349,24 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // rows the store fetch finds on its own, and after a rollback the // context has un-inserted every one of them. defer { + if self.roundUtxoCreditTally.total > 0 { + // Counts only: the outpoints themselves are wallet + // history and never leave the store. + SDKLogger.event( + "persistence_txo_credit_verdicts", + category: .persistence, + fields: [ + "already_spent_count": .integer(Int64(self.roundUtxoCreditTally.alreadySpent)), + "doomed_count": .integer(Int64(self.roundUtxoCreditTally.doomed)), + "observed_spent_count": .integer(Int64(self.roundUtxoCreditTally.observedSpent)), + "round_success": .boolean(success), + "uncredited_count": .integer(Int64(self.roundUtxoCreditTally.uncredited)), + "wallet_reference": .reference(walletId), + ] + ) + } + self.roundUtxoCreditVerdicts = [:] + self.roundUtxoCreditTally = UtxoCreditVerdictTally() self.roundIndex = nil self.roundAdvancedFinalityBoundary = false self.inChangeset = false @@ -3285,6 +3412,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } do { try backgroundContext.save() + committedRoundGeneration &+= 1 SDKLogger.event( "persistence_changeset_committed", category: .persistence, @@ -8988,6 +9116,36 @@ private func persistWalletChangesetChainLockHeightCallback( ) ? 0 : 1 } +/// C shim for the extension's +/// `on_persist_wallet_changeset_utxo_verdicts_fn` — the engine's credit +/// verdicts for the round, fired inside the same begin/end bracket BEFORE +/// the changeset callback so `upsertUtxo` can consult them while it +/// materialises the round's UTXO rows. Same non-zero-fails-the-round +/// contract as its siblings: a verdict dropped here would leave a phantom +/// coin the store hands back to the engine at the next load. +private func persistWalletChangesetUtxoVerdictsCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + verdictsPtr: UnsafePointer?, + verdictsCount: UInt +) -> Int32 { + guard let context = context, + let walletIdPtr = walletIdPtr else { + return 0 + } + + let handler = Unmanaged + .fromOpaque(context) + .takeUnretainedValue() + + let walletId = Data(bytes: walletIdPtr, count: 32) + return handler.persistWalletChangesetUtxoVerdicts( + walletId: walletId, + verdicts: verdictsPtr, + count: verdictsCount + ) ? 0 : 1 +} + /// C shim for `on_changeset_begin_fn`. Forwards to /// `PlatformWalletPersistenceHandler.beginChangeset` so the handler /// can prep any wallet-scope batching it needs for the round. @@ -10635,3 +10793,441 @@ private func persistDashpayPaymentsCallback( handler.persistDashpayPayments(walletId: walletId, entriesByOwner: entriesByOwner) return 0 } + +// MARK: - Credit verdicts + +/// The `verdict` codes of `UtxoCreditVerdictFFI`, mirroring the +/// `UTXO_CREDIT_VERDICT_*` constants in +/// `rs-platform-wallet-ffi/src/core_wallet_types.rs`. Kept as typed Swift +/// constants so the `switch` in `upsertUtxo` compares like with like. +private enum UtxoCreditVerdictCode { + /// The wallet observed a block at `spent_at_height` spending the + /// outpoint before the output was recognised (rust-dashcore#649 skip; + /// the spender may be unrecorded — rust-dashcore#992). + static let observedSpent: UInt8 = 1 + /// The record is unconfirmed and one of its inputs was already spent + /// in a block; nothing it created was credited. + static let doomed: UInt8 = 2 + /// Not credited for a reason the bridge cannot name; no context. + static let uncredited: UInt8 = 3 +} + +/// Per-round tally of how `upsertUtxo` applied the engine's credit +/// verdicts. Logged as counts by `endChangeset`. +private struct UtxoCreditVerdictTally { + /// Rows written spent on an observed-spent verdict. + var observedSpent = 0 + /// Rows written spent on a doomed verdict. + var doomed = 0 + /// Rows that were already spent when their verdict arrived. + var alreadySpent = 0 + /// Context-free verdicts, which only vetoed the recovery clear. + var uncredited = 0 + + var total: Int { observedSpent + doomed + alreadySpent + uncredited } +} + +// MARK: - Core TXO store reconcile + +/// One step of the store reconcile, run on `serialQueue` as its own +/// closure so a Rust persistence round is never interleaved with a +/// half-applied step (see `PlatformWalletManager.reconcileCoreTxoStore`). +enum CoreTxoReconcileStep: Sendable { + /// A Rust changeset round is open; nothing was read or written. The + /// caller retries shortly — saving mid-round would commit the round's + /// staged rows early. + case retryLater + /// The step's writes failed to save and were rolled back. + case failed + case done(T) +} + +/// One unspent store row of the wallet, with the query the engine +/// classifies it by. +struct CoreTxoStoreUnspentRow: Sendable { + let outpoint: Data + let amount: UInt64 + let query: CoreOutpointOwnershipQuery +} + +/// A page of `CoreTxoStoreUnspentRow`s. `fetched` counts every row the +/// page read before the wallet filter, so an offset walk can advance +/// exactly. +struct CoreTxoStoreUnspentPage: Sendable { + let rows: [CoreTxoStoreUnspentRow] + let fetched: Int + let hasMore: Bool + /// `committedRoundGeneration` as read together with the rows. + let generation: UInt64 +} + +/// Counts from one heal step. +struct CoreTxoHealCounts: Sendable { + var inserted = 0 + var insertedDuffs: UInt64 = 0 + /// Rows the drain of pending inputs wrote spent on insert — not repairs. + var healedSpent = 0 + var alreadyPresent = 0 + var skippedImmature = 0 + var skippedUnresolvedAccount = 0 + var skippedInvalid = 0 +} + +/// Counts from one classify-apply step. +struct CoreTxoFlipCounts: Sendable { + var flipped = 0 + var flippedDuffs: UInt64 = 0 + var unspent = 0 + var unknown = 0 + var notOwned = 0 + /// Rows that changed under the walk (already spent, or gone). + var stale = 0 + /// A round committed between the read and the apply: nothing written, + /// the page is classified again. + var staleGeneration = false +} + +extension PlatformWalletPersistenceHandler { + /// Heal pass: insert every engine coin in `rows` that the store lacks, + /// validated and gated — never touch a row that exists. + /// + /// A row is inserted exactly as `upsertUtxo` would insert it (stub + /// parent transaction when the record is absent, account relationship, + /// wallet denorm, address link, pending-input drain) so both writers + /// honour the same rules. Gates, in order: a malformed row (txid not + /// 32 bytes, empty script or address) is skipped; a coin the engine + /// does not call confirmed, or below `minConfirmations` at `tipHeight`, + /// is skipped (the inventory + /// carries the engine's own flags, but a fresh coin can still reorg or, + /// for coinbase, be immature — it ages into a later run); a coin whose + /// owning account has no store row is skipped and counted rather than + /// filed unowned, because the restore loader routes by account and an + /// unowned row would be dropped at the next launch, recreating the loss. + /// Inserted rows are `isConfirmed == true` — the gate guarantees it. + /// A store read that fails is not a skip: the step fails and the run + /// stops, like the unspent-page read in the classify pass. + func reconcileHealMissingTxos( + walletId: Data, + rows: [CoreEngineUtxo], + tipHeight: UInt32, + minConfirmations: UInt32 + ) -> CoreTxoReconcileStep { + onQueue { + guard !inChangeset else { return .retryLater } + var counts = CoreTxoHealCounts() + for row in rows { + guard row.txid.count == 32, !row.scriptPubKey.isEmpty, !row.address.isEmpty else { + counts.skippedInvalid += 1 + continue + } + // The engine's own confirmation flag first, then the depth + // this store requires before it materialises a coin it never + // saw arrive. Which accounts may be healed at all is the + // engine's call: `wallet_utxos_page` omits a contact's + // watch-only chain. + guard row.isConfirmed, row.height > 0, tipHeight >= row.height, + tipHeight - row.height + 1 >= minConfirmations + else { + counts.skippedImmature += 1 + continue + } + let outpoint = row.outpoint + let present: PersistentTxo? + do { + present = try fetchTxoRowChecked(outpoint: outpoint) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } + if present != nil { + counts.alreadyPresent += 1 + continue + } + let accountRow: PersistentAccount? + do { + accountRow = try findAccountRow(walletId: walletId, key: row.account) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } + guard let account = accountRow else { + counts.skippedUnresolvedAccount += 1 + continue + } + let parentTx: PersistentTransaction + if let existing = fetchTransactionRow(txid: row.txid) { + parentTx = existing + } else { + // Stub row, exactly as `upsertUtxo` does: empty bytes read + // back as a miss on the persister-fallback decode path, + // and the real record overwrites every field when it + // arrives. + parentTx = PersistentTransaction(txid: row.txid, transactionData: Data()) + backgroundContext.insert(parentTx) + } + let record = PersistentTxo( + transaction: parentTx, + vout: row.vout, + amount: row.amount, + address: row.address, + scriptPubKey: row.scriptPubKey, + height: row.height + ) + record.account = account + record.walletId = walletId + record.isCoinbase = row.isCoinbase + record.isConfirmed = true + record.isInstantLocked = row.isInstantLocked + record.isLocked = row.isLocked + backgroundContext.insert(record) + if let coreAddr = coreAddressRow(address: row.address) { + record.coreAddress = coreAddr + } + drainPendingInputs(into: record, resolvedWalletId: walletId) + if record.isSpent { + // A pending-input claim or a swept tombstone already + // covered this outpoint, so the drain wrote the row spent + // on the spot. That is not a repair of the divergence the + // engine reported — the engine holds the coin, the store + // now says spent, and nothing un-marks a spent row — so it + // is counted on its own for the operator to see. + counts.healedSpent += 1 + SDKLogger.event( + "persistence_txo_reconcile_item", + category: .persistence, + fields: [ + "action": .publicText("healed_spent"), + "outpoint_reference": .reference(outpoint), + "wallet_reference": .reference(walletId), + ] + ) + continue + } + counts.inserted += 1 + counts.insertedDuffs = counts.insertedDuffs.addingReportingOverflow(row.amount).0 + SDKLogger.event( + "persistence_txo_reconcile_item", + category: .persistence, + fields: [ + "action": .publicText("healed"), + "outpoint_reference": .reference(outpoint), + "wallet_reference": .reference(walletId), + ] + ) + } + guard counts.inserted + counts.healedSpent == 0 + || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) + else { + return .failed + } + return .done(counts) + } + } + + /// Classify pass, read half: the wallet's `isSpent == false` rows from + /// `offset`, at most `limit`, each with the query the engine classifies + /// it by. Rows without an account or a well-formed txid are skipped: + /// the engine could not name their account, and a spend it cannot + /// attribute is not a verdict. Rows of other wallets are read past + /// (`fetched` counts them) — the walk is an offset walk over every + /// unspent row, ordered by creation, because the outpoint key is not + /// comparable in a SwiftData predicate; rows flipped by the apply half + /// leave the predicate, and the caller advances by `fetched - flipped`. + func reconcileUnspentTxoPage( + walletId: Data, + offset: Int, + limit: Int + ) -> CoreTxoReconcileStep { + onQueue { + guard !inChangeset else { return .retryLater } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.isSpent == false }, + sortBy: [SortDescriptor(\.createdAt)] + ) + descriptor.fetchOffset = offset + descriptor.fetchLimit = limit + descriptor.relationshipKeyPathsForPrefetching = [\.account] + let fetched: [PersistentTxo] + do { + fetched = try modelFetcher.fetch(descriptor, in: backgroundContext) + } catch { + SDKLogger.event( + "persistence_txo_reconcile_read_failed", + category: .persistence, + severity: .error, + fields: ["wallet_reference": .reference(walletId)], + error: error + ) + return .failed + } + var rows: [CoreTxoStoreUnspentRow] = [] + for txo in fetched { + guard Self.resolvedWalletId(of: txo) == walletId, + let account = txo.account, + let typeTag = UInt8(exactly: account.accountType) + else { continue } + let txid = txo.txid + guard txid.count == 32 else { continue } + let key = CoreAccountKey( + typeTag: typeTag, + standardTag: account.standardTag, + index: account.accountIndex, + registrationIndex: account.registrationIndex, + keyClass: account.keyClass, + userIdentityId: account.userIdentityId, + friendIdentityId: account.friendIdentityId + ) + rows.append(CoreTxoStoreUnspentRow( + outpoint: txo.outpoint, + amount: txo.amount, + query: CoreOutpointOwnershipQuery( + account: key, + txid: txid, + vout: txo.vout, + scriptPubKey: txo.scriptPubKey + ) + )) + } + return .done(CoreTxoStoreUnspentPage( + rows: rows, + fetched: fetched.count, + hasMore: fetched.count == limit, + generation: committedRoundGeneration + )) + } + } + + /// Classify pass, write half: apply the engine's verdicts to the rows + /// they were asked about. Only `knownUncredited` writes: the row is + /// marked spent with no spender link (the spender may never have been + /// recorded — rust-dashcore#992), any pending-input claims on it are + /// dropped, and `isSpent` is monotonic so a row already spent is left + /// alone. `unspent`, `unknown` and `notOwned` are counted, never acted + /// on: absence of a coin from the engine proves nothing, and a spent + /// row is never un-marked by anything here. A verdict is applied only + /// against the store it was read from: the engine was asked off this + /// queue, and a round committed since the rows were read + /// (`expectedGeneration`) may have re-credited one of them, so then + /// nothing is written and the caller classifies the page again. + func reconcileApplyEngineClasses( + walletId: Data, + rows: [CoreTxoStoreUnspentRow], + classes: [CoreOutpointClass], + expectedGeneration: UInt64 + ) -> CoreTxoReconcileStep { + onQueue { + guard !inChangeset else { return .retryLater } + guard committedRoundGeneration == expectedGeneration else { + var stale = CoreTxoFlipCounts() + stale.staleGeneration = true + return .done(stale) + } + var counts = CoreTxoFlipCounts() + for (row, verdict) in zip(rows, classes) { + switch verdict { + case .unspent: + counts.unspent += 1 + case .unknown: + counts.unknown += 1 + case .notOwned: + counts.notOwned += 1 + case .knownUncredited: + let current: PersistentTxo? + do { + current = try fetchTxoRowChecked(outpoint: row.outpoint) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } + guard let txo = current, !txo.isSpent else { + counts.stale += 1 + continue + } + txo.isSpent = true + txo.lastUpdated = Date() + removePendingInputs(for: row.outpoint) + counts.flipped += 1 + counts.flippedDuffs = counts.flippedDuffs.addingReportingOverflow(txo.amount).0 + SDKLogger.event( + "persistence_txo_reconcile_item", + category: .persistence, + fields: [ + "action": .publicText("flipped_spent"), + "outpoint_reference": .reference(row.outpoint), + "wallet_reference": .reference(walletId), + ] + ) + } + } + guard counts.flipped == 0 || reconcileSave(operation: "txo_reconcile_flip", walletId: walletId) else { + return .failed + } + return .done(counts) + } + } + + /// Non-creating lookup of the store's account row for an engine + /// account key — the same tuple match `applyAccountChangeset` performs, + /// minus the insert on miss. `nil` is a successful miss; a read that + /// fails throws, so the caller can tell the two apart. + private func findAccountRow(walletId: Data, key: CoreAccountKey) throws -> PersistentAccount? { + let typeTag = UInt32(key.typeTag) + let accountIndex = key.index + let descriptor = FetchDescriptor( + predicate: #Predicate { + $0.wallet.walletId == walletId + && $0.accountType == typeTag + && $0.accountIndex == accountIndex + } + ) + let rows = try modelFetcher.fetch(descriptor, in: backgroundContext) + // A row that predates the identity columns carries `Data()` where + // the engine projects 32 zero bytes; both mean "no identity". + func identity(_ data: Data) -> Data { + data.isEmpty ? Data(count: 32) : data + } + return rows.first { row in + row.standardTag == key.standardTag + && row.registrationIndex == key.registrationIndex + && row.keyClass == key.keyClass + && identity(row.userIdentityId) == identity(key.userIdentityId) + && identity(row.friendIdentityId) == identity(key.friendIdentityId) + } + } + + /// A store read failed inside a reconcile step: log it, drop whatever + /// the step had staged so the next Rust round starts on a clean + /// context, and fail the step — the run stops and counts a store + /// failure. A failed read is never a miss. + private func reconcileReadFailed(walletId: Data, error: Error) -> CoreTxoReconcileStep { + SDKLogger.event( + "persistence_txo_reconcile_read_failed", + category: .persistence, + severity: .error, + fields: ["wallet_reference": .reference(walletId)], + error: error + ) + backgroundContext.rollback() + return .failed + } + + /// Save one reconcile step's writes, or roll them back so the next + /// Rust round starts on a clean context (`beginChangeset` runs a dirty + /// round unindexed). Returns whether the save landed. + private func reconcileSave(operation: String, walletId: Data) -> Bool { + do { + try backgroundContext.save() + return true + } catch { + backgroundContext.rollback() + SDKLogger.event( + "persistence_txo_reconcile_save_failed", + category: .persistence, + severity: .error, + fields: [ + "operation": .publicText(operation), + "wallet_reference": .reference(walletId), + ], + error: error + ) + return false + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index c24f81295da..b9aca49a5bc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -30,16 +30,22 @@ import DashSDKFFI /// Serves every read live except the one model type it is told to fault, /// and records the reads it saw so a test can prove which fetch failed. -private final class FetchFaultInjector: ModelFetching, @unchecked Sendable { +/// Shared by every test that needs one model's read to fail. +final class FetchFaultInjector: ModelFetching, @unchecked Sendable { struct ReadFault: Error {} private let live = LiveModelFetcher() private let faulted: ObjectIdentifier + private let served: Int private let lock = NSLock() private var reads: [String] = [] + private var faultedTypeReads = 0 - init(faulting model: any PersistentModel.Type) { + /// Faults every read of `model` after the first `served` reads of it + /// have been answered live — `0` faults the first one. + init(faulting model: any PersistentModel.Type, afterServing served: Int = 0) { faulted = ObjectIdentifier(model) + self.served = served } /// Model names in the order they were read, the faulted one included. @@ -55,8 +61,13 @@ private final class FetchFaultInjector: ModelFetching, @unchecked Sendable { ) throws -> [T] { lock.lock() reads.append(String(describing: T.self)) + var fault = false + if ObjectIdentifier(T.self) == faulted { + faultedTypeReads += 1 + fault = faultedTypeReads > served + } lock.unlock() - guard ObjectIdentifier(T.self) != faulted else { throw ReadFault() } + guard !fault else { throw ReadFault() } return try live.fetch(descriptor, in: context) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift new file mode 100644 index 00000000000..2db8719b9d3 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift @@ -0,0 +1,300 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Coverage for the engine's credit verdicts at the changeset seam — the +/// extension's `on_persist_wallet_changeset_utxo_verdicts_fn`, fired +/// BEFORE a round's changeset callback. +/// +/// The shape they exist for (rust-dashcore#992, dashpay/platform#4575): a +/// coin is spent by a transaction with no wallet-owned output (a CoinJoin +/// collateral burn — sole `OP_RETURN` output) that the engine processed +/// while the coin was not yet in its UTXO set, so the spender matched +/// nothing and was discarded. When the funding record is (re)emitted it +/// still classifies the output `Received`, the persister derives a UTXO +/// row from that role, and — without the verdict — writes it UNSPENT for a +/// coin the engine never held. The restore path then hands that row back +/// to the engine on every launch, and the balance the engine had corrected +/// returns as a phantom. With the verdict the row is written spent at +/// creation, and the restore emits nothing for it. +@MainActor +final class BornSpentTxoPersistTests: XCTestCase { + private let walletId = Data(repeating: 0x21, count: 32) + private let fundingTxid = Data(repeating: 0x61, count: 32) + private let otherTxid = Data(repeating: 0x62, count: 32) + + private static let observedSpent: UInt8 = 1 + private static let doomed: UInt8 = 2 + private static let uncredited: UInt8 = 3 + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try seedWallet(in: container) + return (handler, container) + } + + /// File-backed variant, so a restart (a fresh handler over the same + /// on-disk store) can be simulated. + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// A restorable wallet: one BIP44 account row carrying xpub bytes, the + /// shape the load path requires before it emits the wallet at all. + private func seedWallet(in container: ModelContainer) throws { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount(wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "Standard { index: 0 }") + account.accountExtendedPubKeyBytes = Data(repeating: 0xEE, count: 78) + account.userIdentityId = Data(count: 32) + account.friendIdentityId = Data(count: 32) + context.insert(account) + try context.save() + } + + private func round(_ handler: PlatformWalletPersistenceHandler, _ body: () -> Bool) { + handler.beginChangeset(walletId: walletId) + let success = body() + _ = handler.endChangeset(walletId: walletId, success: success) + } + + /// The verdict slot alone, inside whatever bracket the caller opened. + private func stageVerdicts( + _ handler: PlatformWalletPersistenceHandler, + _ verdicts: [(txid: Data, vout: UInt32, verdict: UInt8, height: UInt32)] + ) -> Bool { + var entries: [UtxoCreditVerdictFFI] = verdicts.map { verdict in + var entry = UtxoCreditVerdictFFI() + Swift.withUnsafeMutableBytes(of: &entry.outpoint.txid) { dst in + verdict.txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + entry.outpoint.vout = verdict.vout + entry.verdict = verdict.verdict + entry.spent_at_height = verdict.height + return entry + } + return entries.withUnsafeMutableBufferPointer { ptr in + handler.persistWalletChangesetUtxoVerdicts( + walletId: walletId, + verdicts: UnsafePointer(ptr.baseAddress), + count: UInt(ptr.count) + ) + } + } + + /// The changeset callback alone: one `utxos_added` entry for + /// `txid:vout`, inside whatever bracket the caller opened. + private func stageUtxoAdded( + _ handler: PlatformWalletPersistenceHandler, + txid: Data, + vout: UInt32, + amount: UInt64 = 19_549, + height: UInt32 = 2_391_743 + ) -> Bool { + let name = strdup("Standard { index: 0 }") + let address = strdup("yBornSpentFixtureAddr") + defer { + free(name) + free(address) + } + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + txid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.outpoint.vout = vout + utxo.amount = amount + utxo.address = address + utxo.height = height + utxo.is_confirmed = true + var applied = false + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + applied = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + return applied + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32) throws -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + let context = ModelContext(container) + return try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.outpoint == outpoint }) + ).first + } + + /// Drives the FFI load path and returns the wallet entry's restored + /// UTXO count — what the engine would be handed at launch. + private func restoredUtxoCount(_ handler: PlatformWalletPersistenceHandler) throws -> Int { + let (entries, count, errored) = handler.loadWalletList() + XCTAssertFalse(errored) + XCTAssertEqual(count, 1) + let entriesPtr = try XCTUnwrap(entries) + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entriesPtr)) } + return Int(entriesPtr[0].utxos_count) + } + + // MARK: - The field case + + /// The funding record's output arrives with an observed-spent verdict: + /// the row is created, spent, unlinked — and the restore hands nothing + /// back for it. + func testObservedSpentVerdictWritesTheRowSpentAndKeepsItOutOfTheRestore() throws { + let (handler, container) = try makeHandler() + round(handler) { + stageVerdicts(handler, [(fundingTxid, 0, Self.observedSpent, 2_402_896)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + let coin = try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)) + XCTAssertTrue(coin.isSpent) + XCTAssertNil(coin.spendingTransaction, "the spender was never recorded") + XCTAssertNil(coin.supersededByTxid) + XCTAssertEqual(coin.amount, 19_549) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + + func testDoomedVerdictWritesTheRowSpent() throws { + let (handler, container) = try makeHandler() + round(handler) { + stageVerdicts(handler, [(fundingTxid, 0, Self.doomed, 0)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + + /// A context-free verdict never marks the row spent: the engine only + /// said "not credited", and the spender's own record or the sweep + /// callback settles it. What it does change is the redelivery clear. + func testUncreditedVerdictLeavesANewRowUnspent() throws { + let (handler, container) = try makeHandler() + round(handler) { + stageVerdicts(handler, [(fundingTxid, 0, Self.uncredited, 0)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertFalse(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 1) + } + + /// Without a verdict, a redelivery of a spent, unlinked row clears the + /// flag (the wallet is handing the coin back as unspent — today's + /// recovery rule). With ANY verdict the clear is vetoed: the record + /// merely still names the output as ours, the engine does not hold it. + func testAVerdictVetoesTheRedeliveryClearAndItsAbsenceDoesNot() throws { + let (handler, container) = try makeHandler() + round(handler) { + stageVerdicts(handler, [(fundingTxid, 0, Self.observedSpent, 2_402_896)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + + // Redelivered with a context-free verdict: stays spent. + round(handler) { + stageVerdicts(handler, [(fundingTxid, 0, Self.uncredited, 0)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + + // Redelivered with no verdict at all: the engine credited it again + // (a reorg of the spender), and the row follows the wallet. + round(handler) { + stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertFalse(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 1) + } + + /// Verdicts are round-scoped: one for an outpoint the round never + /// delivers is dropped with the round, and a later round delivering + /// that outpoint without a verdict writes it unspent as ever. + func testVerdictsDoNotOutliveTheirRound() throws { + let (handler, container) = try makeHandler() + round(handler) { + stageVerdicts(handler, [(otherTxid, 0, Self.observedSpent, 2_402_896)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertFalse(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertNil(try txo(container, txid: otherTxid, vout: 0)) + + round(handler) { + stageUtxoAdded(handler, txid: otherTxid, vout: 0) + } + XCTAssertFalse(try XCTUnwrap(txo(container, txid: otherTxid, vout: 0)).isSpent) + } + + /// A verdict with no matching wallet row is accepted and ignored — the + /// same contract as every other per-kind callback for an unknown wallet. + func testVerdictForAnUnknownWalletIsAcceptedAndIgnored() throws { + let (handler, _) = try makeHandler() + let stranger = Data(repeating: 0x7e, count: 32) + var entry = UtxoCreditVerdictFFI() + entry.verdict = Self.observedSpent + let accepted = withUnsafePointer(to: &entry) { ptr in + handler.persistWalletChangesetUtxoVerdicts(walletId: stranger, verdicts: ptr, count: 1) + } + XCTAssertTrue(accepted) + } + + /// A rolled-back round leaves neither the row nor the verdict behind. + func testARolledBackRoundLeavesNoRow() throws { + let (handler, container) = try makeHandler() + round(handler) { + _ = stageVerdicts(handler, [(fundingTxid, 0, Self.observedSpent, 2_402_896)]) + _ = stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + return false + } + XCTAssertNil(try txo(container, txid: fundingTxid, vout: 0)) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + + // MARK: - Restart + + /// The acceptance shape: after the round that wrote the row spent, a + /// relaunch (a fresh handler over the same on-disk store) restores zero + /// coins for the wallet — and so does the relaunch after that. The + /// phantom never comes back. + func testRestartRestoresNothingForABornSpentRow() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("born-spent-\(UUID().uuidString).sqlite") + defer { + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + suffix)) + } + } + do { + let (handler, container) = try makeHandler(url: url) + try seedWallet(in: container) + round(handler) { + stageVerdicts(handler, [(fundingTxid, 0, Self.observedSpent, 2_402_896)]) + && stageUtxoAdded(handler, txid: fundingTxid, vout: 0) + } + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + for _ in 0..<2 { + let (handler, container) = try makeHandler(url: url) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: fundingTxid, vout: 0)).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 0, "a relaunch restores nothing for the burned coin") + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift new file mode 100644 index 00000000000..d74ec5b810d --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift @@ -0,0 +1,173 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Every event the credit-verdict seam and the store reconcile emit goes +/// through the SDK's file sink here, with fixture values shaped like the +/// real thing, and the rendered log is checked for them: no address, no +/// txid or outpoint in either byte orientation, no script, and — as a +/// backstop against any future field — no long hex or Base58 run at all. +/// References are 12 hex characters; counts and duffs are numbers. +@MainActor +final class CoreTxoReconcilePrivacyTests: XCTestCase { + private let walletId = Data(repeating: 0x71, count: 32) + /// A realistic testnet-length Base58 address. + private let fixtureAddress = "yTestPrivacyFixtureAddress12345678" + private let fixtureScript = Data([0x76, 0xa9, 0x14] + [UInt8](repeating: 0x5c, count: 20) + [0x88, 0xac]) + private let healedTxid = Data((0..<32).map { UInt8(0xa0 + $0 % 16) }) + private let flippedTxid = Data((0..<32).map { UInt8(0x30 + $0 % 16) }) + private let bornSpentTxid = Data((0..<32).map { UInt8(0xc0 + $0 % 16) }) + + private var bip44: CoreAccountKey { + CoreAccountKey( + typeTag: 0, standardTag: 0, index: 0, registrationIndex: 0, keyClass: 0, + userIdentityId: Data(count: 32), friendIdentityId: Data(count: 32) + ) + } + + private func hex(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + func testTheEmittedEventsCarryNoWalletHistory() throws { + let sessionDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("txo-reconcile-privacy-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: sessionDirectory) } + XCTAssertTrue(SDKLogger.installFileSink(at: sessionDirectory, includeDebug: true)) + + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + do { + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount(wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "BIP44 Account") + account.userIdentityId = Data(count: 32) + account.friendIdentityId = Data(count: 32) + context.insert(account) + let tx = PersistentTransaction( + txid: flippedTxid, transactionData: Data(repeating: 0x04, count: 10), + context: 3, blockHeight: 2_391_743, netAmount: 19_549 + ) + context.insert(tx) + let txo = PersistentTxo( + transaction: tx, vout: 1, amount: 19_549, address: fixtureAddress, + scriptPubKey: fixtureScript, height: 2_391_743 + ) + txo.account = account + txo.walletId = walletId + txo.isConfirmed = true + context.insert(txo) + try context.save() + } + + // The seam: a round with an observed-spent verdict for a delivered coin. + handler.beginChangeset(walletId: walletId) + var verdict = UtxoCreditVerdictFFI() + Swift.withUnsafeMutableBytes(of: &verdict.outpoint.txid) { dst in + bornSpentTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + verdict.verdict = 1 + verdict.spent_at_height = 2_402_896 + let staged = withUnsafePointer(to: &verdict) { ptr in + handler.persistWalletChangesetUtxoVerdicts(walletId: walletId, verdicts: ptr, count: 1) + } + XCTAssertTrue(staged) + let name = strdup("Standard { index: 0 }") + let address = strdup(fixtureAddress) + defer { + free(name) + free(address) + } + var utxo = UtxoEntryFFI() + Swift.withUnsafeMutableBytes(of: &utxo.outpoint.txid) { dst in + bornSpentTxid.withUnsafeBytes { src in dst.copyMemory(from: src) } + } + utxo.amount = 19_549 + utxo.address = address + utxo.height = 2_391_786 + utxo.is_confirmed = true + var applied = false + withUnsafeMutablePointer(to: &utxo) { utxoPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.utxos_added = utxoPtr + account.utxos_added_count = 1 + withUnsafeMutablePointer(to: &account) { accountPtr in + var cs = WalletChangeSetFFI() + cs.accounts = accountPtr + cs.accounts_count = 1 + withUnsafePointer(to: &cs) { csPtr in + applied = handler.persistWalletChangeset(walletId: walletId, changeset: csPtr) + } + } + } + XCTAssertTrue(applied) + _ = handler.endChangeset(walletId: walletId, success: true) + + // The reconcile: one heal, one flip, one row the engine cannot classify. + let healed = CoreEngineUtxo( + account: bip44, txid: healedTxid, vout: 0, amount: 100_001, address: fixtureAddress, + scriptPubKey: fixtureScript, height: 2_402_986, isConfirmed: true, + isInstantLocked: false, isCoinbase: false, isLocked: false + ) + let flipped = PersistentTxo.makeOutpoint(txid: flippedTxid, vout: 1) + let engine = FakeCoreTxoEngine(inventory: [healed], verdicts: [flipped: .knownUncredited]) + let report = PlatformWalletManager.runCoreTxoReconcile( + walletId: walletId, tipHeight: 2_535_898, engine: engine, handler: handler, + isCancelled: { false } + ) + XCTAssertEqual(report.inserted, 1) + XCTAssertEqual(report.flipped, 1) + SDKLogger.flush() + + let logURL = sessionDirectory.appendingPathComponent("swift").appendingPathComponent("run.log") + let log = try String(contentsOf: logURL, encoding: .utf8) + XCTAssertTrue(log.contains("event=persistence_txo_credit_verdicts")) + XCTAssertTrue(log.contains("event=persistence_txo_reconcile_item")) + XCTAssertTrue(log.contains("observed_spent_count=1")) + XCTAssertTrue(log.contains("action=\"healed\"")) + XCTAssertFalse( + log.contains("amount_duffs="), + "the value of an individual coin never leaves the store through these events" + ) + XCTAssertTrue(log.contains("action=\"flipped_spent\"")) + + let forbidden: [(String, String)] = [ + ("address", fixtureAddress), + ("script", hex(fixtureScript)), + ("healed txid", hex(healedTxid)), + ("healed txid reversed", hex(Data(healedTxid.reversed()))), + ("flipped txid", hex(flippedTxid)), + ("flipped txid reversed", hex(Data(flippedTxid.reversed()))), + ("born-spent txid", hex(bornSpentTxid)), + ("born-spent txid reversed", hex(Data(bornSpentTxid.reversed()))), + ("flipped outpoint", hex(flipped)), + ("wallet id", hex(walletId)), + ] + for (label, value) in forbidden { + XCTAssertFalse( + log.range(of: value, options: .caseInsensitive) != nil, + "the log must not carry the \(label)" + ) + } + let longHex = try NSRegularExpression(pattern: "[0-9a-fA-F]{32,}") + XCTAssertNil( + longHex.firstMatch(in: log, range: NSRange(log.startIndex..., in: log)), + "no 32+ character hex run may appear in any event" + ) + let base58Run = try NSRegularExpression(pattern: "[1-9A-HJ-NP-Za-km-z]{26,}") + let lines = log.split(separator: "\n").filter { + $0.contains("persistence_txo_") || $0.contains("txo_reconcile") + } + XCTAssertFalse(lines.isEmpty) + for line in lines { + let text = String(line) + XCTAssertNil( + base58Run.firstMatch(in: text, range: NSRange(text.startIndex..., in: text)), + "no address-length Base58 run may appear: \(text)" + ) + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift new file mode 100644 index 00000000000..238aec3e0c0 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -0,0 +1,147 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// Shutdown and race coverage for the store reconcile: it stops between +/// pages when cancelled, it defers a step while a Rust persistence round is +/// open and completes once the round closes, and the manager refuses it +/// once shutdown has begun. +@MainActor +final class CoreTxoReconcileShutdownTests: XCTestCase { + private let walletId = Data(repeating: 0x51, count: 32) + private let fixtureScript = Data([0x76, 0xa9, 0x14] + [UInt8](repeating: 0x5b, count: 20) + [0x88, 0xac]) + + private var bip44: CoreAccountKey { + CoreAccountKey( + typeTag: 0, standardTag: 0, index: 0, registrationIndex: 0, keyClass: 0, + userIdentityId: Data(count: 32), friendIdentityId: Data(count: 32) + ) + } + + private func txid(_ byte: UInt8) -> Data { Data(repeating: byte, count: 32) } + + private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount(wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "BIP44 Account") + account.userIdentityId = Data(count: 32) + account.friendIdentityId = Data(count: 32) + context.insert(account) + try context.save() + return (handler, container) + } + + private func engineUtxo(_ byte: UInt8) -> CoreEngineUtxo { + CoreEngineUtxo( + account: bip44, txid: txid(byte), vout: 0, amount: 19_549, + address: "yShutdownFixtureAddr", scriptPubKey: fixtureScript, height: 2_391_743, + isConfirmed: true, isInstantLocked: false, isCoinbase: false, isLocked: false + ) + } + + private func txoCount(_ container: ModelContainer) throws -> Int { + try ModelContext(container).fetchCount(FetchDescriptor()) + } + + func testACancelledRunDoesNothingAndSaysSo() throws { + let (handler, container) = try makeHandler() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x11)]) + + let report = PlatformWalletManager.runCoreTxoReconcile( + walletId: walletId, tipHeight: 2_535_898, engine: engine, handler: handler, + isCancelled: { true } + ) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.mutations, 0) + XCTAssertEqual(engine.pageCalls, 0, "cancellation is checked before the first engine read") + XCTAssertEqual(try txoCount(container), 0) + } + + func testARunCancelledMidWayStopsAtThePageBoundaryAndKeepsWhatLanded() throws { + let (handler, container) = try makeHandler() + let engine = FakeCoreTxoEngine(inventory: (0..<6).map { engineUtxo(0x20 + UInt8($0)) }) + let checks = Counter() + + let report = PlatformWalletManager.runCoreTxoReconcile( + walletId: walletId, tipHeight: 2_535_898, pageSize: 2, engine: engine, handler: handler, + // The run checks once before each engine read and once before + // each store step: the first two admit page one and its heal, + // the third (before page two) reports the epoch bumped. + isCancelled: { checks.next() >= 3 } + ) + + XCTAssertFalse(report.completed) + XCTAssertEqual(engine.pageCalls, 1) + XCTAssertEqual(report.inserted, 2, "the page that landed stays") + XCTAssertEqual(try txoCount(container), 2) + } + + /// A Rust round open on the persistence queue defers every store step: + /// the run retries until the round closes, then completes normally. + func testAStepDeferredBehindAnOpenRoundCompletesOnceTheRoundCloses() throws { + let (handler, container) = try makeHandler() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x31)]) + + handler.beginChangeset(walletId: walletId) + let finished = expectation(description: "reconcile finished") + let box = ReportBox() + let walletId = self.walletId + DispatchQueue.global(qos: .utility).async { + let report = PlatformWalletManager.runCoreTxoReconcile( + walletId: walletId, tipHeight: 2_535_898, engine: engine, handler: handler, + isCancelled: { false } + ) + box.set(report) + finished.fulfill() + } + // Let the run hit the open round a few times, then close it. + Thread.sleep(forTimeInterval: 0.3) + XCTAssertNil(box.get(), "the run must not have completed while the round was open") + _ = handler.endChangeset(walletId: walletId, success: true) + wait(for: [finished], timeout: 15) + + let report = try XCTUnwrap(box.get()) + XCTAssertTrue(report.completed) + XCTAssertGreaterThan(report.retries, 0) + XCTAssertEqual(report.inserted, 1) + XCTAssertEqual(try txoCount(container), 1) + } + + func testTheManagerRefusesAReconcileOnceShutdownHasBegun() async throws { + let ok: @Sendable (Handle) -> PlatformWalletFFIResult = { _ in + PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) + } + let manager = PlatformWalletManager.makeForTesting( + handle: 42, + calls: PlatformWalletNativeTeardownCalls( + spvStop: ok, platformAddressSyncStop: ok, shieldedSyncStop: ok, + dashPaySyncStop: ok, dpnsSyncStop: ok, destroy: ok + ) + ) + let epochBefore = manager.coreTxoReconcileEpoch.current() + _ = await manager.shutdown() + PlatformWalletManager.destroyQueue.sync {} + XCTAssertGreaterThan(manager.coreTxoReconcileEpoch.current(), epochBefore, "shutdown stales in-flight runs") + + let outcome = try await manager.reconcileCoreTxoStore(for: walletId) + XCTAssertEqual(outcome, .skipped(.notConfigured)) + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func next() -> Int { lock.withLock { value += 1; return value } } + } + + private final class ReportBox: @unchecked Sendable { + private let lock = NSLock() + private var report: CoreTxoReconcileReport? + func set(_ report: CoreTxoReconcileReport) { lock.withLock { self.report = report } } + func get() -> CoreTxoReconcileReport? { lock.withLock { report } } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift new file mode 100644 index 00000000000..eac6a57af07 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -0,0 +1,762 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// The engine reads the reconcile is built on, faked: a fixed inventory +/// served in pages behind a cursor, and a verdict per outpoint. +final class FakeCoreTxoEngine: CoreTxoEngineInventory, @unchecked Sendable { + private let lock = NSLock() + private var _inventory: [CoreEngineUtxo] + private var _verdicts: [Data: CoreOutpointClass] + private var _failPages = false + private var _failClassify = false + private(set) var pageCalls = 0 + private(set) var classifyCalls = 0 + private(set) var classified: [CoreOutpointOwnershipQuery] = [] + /// Runs before each `classify`, outside the engine's lock — a test's + /// stand-in for the world moving while the reconcile is between its + /// engine read and its store write (e.g. a persistence round committing). + var onClassify: (@Sendable () -> Void)? + + init(inventory: [CoreEngineUtxo] = [], verdicts: [Data: CoreOutpointClass] = [:]) { + _inventory = inventory + _verdicts = verdicts + } + + var failPages: Bool { + get { lock.withLock { _failPages } } + set { lock.withLock { _failPages = newValue } } + } + + var failClassify: Bool { + get { lock.withLock { _failClassify } } + set { lock.withLock { _failClassify = newValue } } + } + + struct Failure: Error {} + + func utxoPage(after: CoreEngineUtxo?, limit: Int) throws -> (rows: [CoreEngineUtxo], hasMore: Bool) { + try lock.withLock { + pageCalls += 1 + if _failPages { throw Failure() } + var start = 0 + if let after, let index = _inventory.firstIndex(where: { $0.outpoint == after.outpoint }) { + start = index + 1 + } + let end = min(start + limit, _inventory.count) + let rows = start < end ? Array(_inventory[start.. [CoreOutpointClass] { + onClassify?() + return try lock.withLock { + classifyCalls += 1 + if _failClassify { throw Failure() } + classified.append(contentsOf: queries) + return queries.map { _verdicts[$0.outpoint] ?? .unknown } + } + } +} + +/// Coverage for the post-scan store reconcile +/// (`PlatformWalletManager.runCoreTxoReconcile`) against a fake engine, +/// driven exactly the way the manager drives it — engine reads on the +/// calling thread, store steps on the persistence queue. +/// +/// The safety properties under test: a row is marked spent only on the +/// engine's positive `knownUncredited` verdict; a coin the store lacks is +/// inserted only when validated, owned, and mature; absence from either +/// side never changes a row; nothing is deleted, nothing is un-marked; the +/// run is idempotent and wallet-scoped; and a repaired store restores +/// nothing for the repaired coin across relaunches. +@MainActor +final class CoreTxoReconcileTests: XCTestCase { + private let walletId = Data(repeating: 0x31, count: 32) + private let otherWalletId = Data(repeating: 0x32, count: 32) + private let tipHeight: UInt32 = 2_535_898 + private let fixtureAddress = "yReconcileFixtureAddr" + private let fixtureScript = Data([0x76, 0xa9, 0x14] + [UInt8](repeating: 0x5a, count: 20) + [0x88, 0xac]) + + private var bip44: CoreAccountKey { + CoreAccountKey( + typeTag: 0, standardTag: 0, index: 0, registrationIndex: 0, keyClass: 0, + userIdentityId: Data(count: 32), friendIdentityId: Data(count: 32) + ) + } + + private var coinJoin: CoreAccountKey { + CoreAccountKey( + typeTag: 1, standardTag: 0, index: 0, registrationIndex: 0, keyClass: 0, + userIdentityId: Data(count: 32), friendIdentityId: Data(count: 32) + ) + } + + private func txid(_ byte: UInt8) -> Data { Data(repeating: byte, count: 32) } + + private func makeHandler( + modelFetcher: ModelFetching = LiveModelFetcher() + ) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet, + modelFetcher: modelFetcher + ) + return (handler, container) + } + + private func makeHandler(url: URL) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let configuration = ModelConfiguration(schema: DashModelContainer.schema, url: url) + let container = try ModelContainer( + for: DashModelContainer.schema, + migrationPlan: DashMigrationPlan.self, + configurations: [configuration] + ) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + return (handler, container) + } + + /// A wallet row with a BIP44 account (and, when asked, a CoinJoin one). + @discardableResult + private func seedWallet( + in container: ModelContainer, + walletId: Data? = nil, + withCoinJoinAccount: Bool = false + ) throws -> PersistentWallet { + let walletId = walletId ?? self.walletId + let context = ModelContext(container) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + context.insert(wallet) + let account = PersistentAccount(wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "BIP44 Account") + // Xpub bytes make the wallet restorable: the load path emits a + // wallet only through accounts it can rebuild keys for. + account.accountExtendedPubKeyBytes = Data(repeating: walletId[0], count: 78) + account.userIdentityId = Data(count: 32) + account.friendIdentityId = Data(count: 32) + context.insert(account) + if withCoinJoinAccount { + let cj = PersistentAccount(wallet: wallet, accountType: 1, accountIndex: 0, accountTypeName: "CoinJoin") + cj.accountExtendedPubKeyBytes = Data(repeating: walletId[0] &+ 1, count: 78) + cj.userIdentityId = Data(count: 32) + cj.friendIdentityId = Data(count: 32) + context.insert(cj) + } + try context.save() + return wallet + } + + /// An unspent, confirmed TXO row of `walletId` under its BIP44 account. + private func seedUnspentTxo( + in container: ModelContainer, + walletId: Data? = nil, + txid: Data, + vout: UInt32 = 0, + amount: UInt64 = 19_549, + isSpent: Bool = false, + pendingSpender: Data? = nil + ) throws { + let walletId = walletId ?? self.walletId + let context = ModelContext(container) + let accounts = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.wallet.walletId == walletId && $0.accountType == 0 } + )) + let account = try XCTUnwrap(accounts.first) + let tx = PersistentTransaction( + txid: txid, + transactionData: Data(repeating: 0x04, count: 10), + context: 3, + blockHeight: 2_391_743, + netAmount: Int64(amount) + ) + context.insert(tx) + let txo = PersistentTxo( + transaction: tx, + vout: vout, + amount: amount, + address: fixtureAddress, + scriptPubKey: fixtureScript, + height: 2_391_743 + ) + txo.account = account + txo.walletId = walletId + txo.isConfirmed = true + txo.isSpent = isSpent + context.insert(txo) + if let pendingSpender { + context.insert(PersistentPendingInput( + outpoint: txo.outpoint, + inputIndex: 0, + spendingTxid: pendingSpender, + spendingTransaction: nil, + walletId: walletId + )) + } + try context.save() + } + + private func engineUtxo( + account: CoreAccountKey? = nil, + txid: Data, + vout: UInt32 = 0, + amount: UInt64 = 19_549, + height: UInt32 = 2_391_743, + address: String? = nil, + script: Data? = nil, + isConfirmed: Bool = true + ) -> CoreEngineUtxo { + CoreEngineUtxo( + account: account ?? bip44, + txid: txid, + vout: vout, + amount: amount, + address: address ?? fixtureAddress, + scriptPubKey: script ?? fixtureScript, + height: height, + isConfirmed: isConfirmed, + isInstantLocked: false, + isCoinbase: false, + isLocked: false + ) + } + + private func txo(_ container: ModelContainer, txid: Data, vout: UInt32 = 0) throws -> PersistentTxo? { + let outpoint = PersistentTxo.makeOutpoint(txid: txid, vout: vout) + return try ModelContext(container).fetch( + FetchDescriptor(predicate: #Predicate { $0.outpoint == outpoint }) + ).first + } + + private func txoCount(_ container: ModelContainer) throws -> Int { + try ModelContext(container).fetchCount(FetchDescriptor()) + } + + private func pendingCount(_ container: ModelContainer) throws -> Int { + try ModelContext(container).fetchCount(FetchDescriptor()) + } + + private func run( + _ handler: PlatformWalletPersistenceHandler, + engine: FakeCoreTxoEngine, + walletId: Data? = nil, + pageSize: Int = 2, + isCancelled: @Sendable @escaping () -> Bool = { false } + ) -> CoreTxoReconcileReport { + PlatformWalletManager.runCoreTxoReconcile( + walletId: walletId ?? self.walletId, + tipHeight: tipHeight, + pageSize: pageSize, + engine: engine, + handler: handler, + isCancelled: isCancelled + ) + } + + /// Drives the FFI load path and returns the restored UTXO count of the + /// single wallet entry — what the engine would be handed at launch. + private func restoredUtxoCount(_ handler: PlatformWalletPersistenceHandler) throws -> Int { + let (entries, count, errored) = handler.loadWalletList() + XCTAssertFalse(errored) + XCTAssertEqual(count, 1) + let entriesPtr = try XCTUnwrap(entries) + defer { handler.loadWalletListFree(entries: UnsafeRawPointer(entriesPtr)) } + return Int(entriesPtr[0].utxos_count) + } + + // MARK: 1. Positive engine evidence marks a local unspent row spent + + func testPositiveEngineVerdictMarksALocalUnspentRowSpent() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x71), pendingSpender: txid(0x7f)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x71), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.storeRows, 1) + XCTAssertEqual(report.flipped, 1) + XCTAssertEqual(report.flippedDuffs, 19_549) + XCTAssertEqual(report.inserted, 0) + let coin = try XCTUnwrap(txo(container, txid: txid(0x71))) + XCTAssertTrue(coin.isSpent) + XCTAssertNil(coin.spendingTransaction, "no spender is invented") + XCTAssertEqual(try pendingCount(container), 0, "claims on a settled coin are dropped") + XCTAssertEqual(try restoredUtxoCount(handler), 0) + // The engine was asked about exactly this coin, with the store's + // own account and script — the ownership it checks. + XCTAssertEqual(engine.classified.count, 1) + XCTAssertEqual(engine.classified.first?.account, bip44) + XCTAssertEqual(engine.classified.first?.scriptPubKey, fixtureScript) + } + + /// The engine is asked off the persistence queue and the verdict is + /// applied on it; a persistence round that opens AND commits in that gap + /// can re-credit the very coin (a reorg of its spender hands it back in + /// `utxos_added`). The apply must refuse a verdict read before that + /// round, and the page is classified again against the store as it is + /// now — here the engine holds the coin again, so nothing is flipped. + func testAVerdictReadBeforeAnInterveningRoundIsNotAppliedAndThePageIsReclassified() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x72)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x72), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let walletId = self.walletId + let classifies = Counter() + engine.onClassify = { + // Only the first classify sees the world move: a round commits + // between this read and the apply, and after it the engine + // holds the coin again. + guard classifies.next() == 1 else { return } + handler.beginChangeset(walletId: walletId) + _ = handler.endChangeset(walletId: walletId, success: true) + engine.setVerdict(outpoint, .unspent) + } + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.staleRetries, 1, "the first verdict was read before the round and refused") + XCTAssertEqual(engine.classifyCalls, 2, "the page is classified again after the refusal") + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(report.unspent, 1) + XCTAssertEqual(report.storeRows, 1) + let coin = try XCTUnwrap(txo(container, txid: txid(0x72))) + XCTAssertFalse(coin.isSpent, "a coin the engine re-credited in the gap stays unspent") + } + + private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func next() -> Int { lock.withLock { value += 1; return value } } + } + + // MARK: 2. Absence from both inventories changes nothing + + func testARowAbsentFromBothInventoriesIsLeftUnchanged() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x72)) + try seedUnspentTxo(in: container, txid: txid(0x73)) + let notOwned = PersistentTxo.makeOutpoint(txid: txid(0x73), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [notOwned: .notOwned]) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.storeRows, 2) + XCTAssertEqual(report.unknown, 1) + XCTAssertEqual(report.notOwned, 1) + XCTAssertEqual(report.mutations, 0) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x72))).isSpent) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x73))).isSpent) + XCTAssertEqual(try txoCount(container), 2, "nothing is ever deleted") + XCTAssertEqual(try restoredUtxoCount(handler), 2) + } + + // MARK: 3. Engine coin missing from the store is inserted, validated + + func testAnEngineCoinMissingFromTheStoreIsInsertedWhenValid() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container, withCoinJoinAccount: true) + let valid = engineUtxo(txid: txid(0x74)) + let coinJoinValid = engineUtxo(account: coinJoin, txid: txid(0x75), amount: 100_001) + let engine = FakeCoreTxoEngine(inventory: [valid, coinJoinValid]) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.engineRows, 2) + XCTAssertEqual(report.inserted, 2) + XCTAssertEqual(report.insertedDuffs, 19_549 + 100_001) + let coin = try XCTUnwrap(txo(container, txid: txid(0x74))) + XCTAssertFalse(coin.isSpent) + XCTAssertTrue(coin.isConfirmed) + XCTAssertEqual(coin.amount, 19_549) + XCTAssertEqual(coin.address, fixtureAddress) + XCTAssertEqual(coin.scriptPubKey, fixtureScript) + XCTAssertEqual(coin.height, 2_391_743) + XCTAssertEqual(coin.walletId, walletId) + XCTAssertEqual(coin.account?.accountType, 0) + XCTAssertEqual(coin.transaction?.txid, txid(0x74), "a stub parent row holds the relationship") + XCTAssertEqual(coin.transaction?.transactionData, Data()) + let mixed = try XCTUnwrap(txo(container, txid: txid(0x75))) + XCTAssertEqual(mixed.account?.accountType, 1, "filed under the account the engine named") + XCTAssertEqual(try restoredUtxoCount(handler), 2) + } + + func testTheHealPassRefusesImmatureUnconfirmedUnresolvedAndMalformedCoins() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) // BIP44 only: no CoinJoin account row + let immature = engineUtxo(txid: txid(0x76), height: tipHeight - 50) + let atGate = engineUtxo(txid: txid(0x77), height: tipHeight - 99) // exactly 100 confirmations + // Deep enough, but the engine itself does not call it confirmed. + let engineUnconfirmed = engineUtxo(txid: txid(0x78), isConfirmed: false) + let unresolved = engineUtxo(account: coinJoin, txid: txid(0x79)) + let noScript = engineUtxo(txid: txid(0x7a), script: Data()) + let noAddress = engineUtxo(txid: txid(0x7b), address: "") + let unconfirmed = engineUtxo(txid: txid(0x7c), height: 0) + let engine = FakeCoreTxoEngine( + inventory: [immature, atGate, engineUnconfirmed, unresolved, noScript, noAddress, unconfirmed] + ) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.engineRows, 7) + XCTAssertEqual(report.inserted, 1) + XCTAssertEqual(report.skippedImmature, 3) + XCTAssertEqual(report.skippedUnresolvedAccount, 1) + XCTAssertEqual(report.skippedInvalid, 2) + XCTAssertNotNil(try txo(container, txid: txid(0x77))) + for byte: UInt8 in [0x76, 0x78, 0x79, 0x7a, 0x7b, 0x7c] { + XCTAssertNil(try txo(container, txid: txid(byte)), "coin \(byte) must not be healed") + } + } + + /// A coin the engine holds and the store lacks, whose outpoint a + /// pending-input claim already names: the heal inserts the row and the + /// drain writes it spent on the spot. Nothing was repaired — the engine + /// still holds the coin — but a row was written, so the run reports it + /// as a mutation rather than as a clean, all-zero pass. + func testAHealTheDrainWritesSpentIsReportedAsAMutation() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + // The spender is already a confirmed row of the store; only the coin + // it consumed is missing (the funding record never reached the + // store), so its input claim is still pending. + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x7d), vout: 0) + let context = ModelContext(container) + let spender = PersistentTransaction( + txid: txid(0x7e), + transactionData: Data(repeating: 0x05, count: 10), + context: 3, + blockHeight: 2_391_800, + netAmount: -19_549 + ) + context.insert(spender) + context.insert(PersistentPendingInput( + outpoint: outpoint, + inputIndex: 0, + spendingTxid: txid(0x7e), + spendingTransaction: spender, + walletId: walletId + )) + try context.save() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x7d))]) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.engineRows, 1) + XCTAssertEqual(report.healedSpent, 1) + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(report.mutations, 1, "a row was written, even though nothing was repaired") + let coin = try XCTUnwrap(txo(container, txid: txid(0x7d))) + XCTAssertTrue(coin.isSpent) + XCTAssertEqual(try pendingCount(container), 0, "the claim was consumed by the drain") + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + + /// A store read that fails inside the heal pass is a failure, not a + /// skip: the account lookup cannot tell "no such account" from "could + /// not read", so it must not answer the former when the latter + /// happened. The run stops, counts the store failure, inserts nothing + /// — and reports it as incomplete rather than as an unresolved account. + func testTheRunStopsWhenTheAccountLookupCannotRead() throws { + let injector = FetchFaultInjector(faulting: PersistentAccount.self) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x83))]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.skippedUnresolvedAccount, 0, "a failed read is not a missing account") + XCTAssertEqual(report.inserted, 0) + XCTAssertNil(try txo(container, txid: txid(0x83))) + XCTAssertTrue(injector.observedReads.contains("PersistentAccount")) + } + + /// The heal pass asks the store whether it already holds each engine + /// coin. A read that fails there must not read as "absent": that would + /// insert a row the store may well hold. The step fails and the run + /// stops before any insert. + func testTheRunStopsWhenTheHealCannotReadTheStore() throws { + let injector = FetchFaultInjector(faulting: PersistentTxo.self) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x84))]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.alreadyPresent, 0, "a failed read is not a hit either") + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(try txoCount(container), 0) + } + + /// The classify pass re-reads each `knownUncredited` row before it + /// flips it. A read that fails there must not read as "stale" and let + /// the step report `.done`: the step fails, nothing staged is kept, and + /// the row stays as it was. The page read itself is served; only the + /// per-row lookup behind it faults. + func testTheRunStopsWhenTheFlipLookupCannotRead() throws { + let injector = FetchFaultInjector(faulting: PersistentTxo.self, afterServing: 1) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x85)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x85), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.storeRows, 1, "the page itself was read") + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(report.staleRetries, 0, "a failed read is not a stale page either") + let coin = try XCTUnwrap(txo(container, txid: txid(0x85))) + XCTAssertFalse(coin.isSpent) + } + + // MARK: 4. Nothing runs before the scan is complete + + func testTheSteadyStateGateRefusesAnUnfinishedScan() { + func progress(_ state: PlatformSpvSyncState, filters: (UInt32, UInt32)?) -> PlatformSpvSyncProgress { + PlatformSpvSyncProgress( + overallState: state, + overallPercentage: 0, + headers: PlatformSpvSubProgress(state: state, currentHeight: 2_535_898, targetHeight: 2_535_898, percentage: 1), + filterHeaders: nil, + filters: filters.map { + PlatformSpvSubProgress(state: state, currentHeight: $0.0, targetHeight: $0.1, percentage: 0) + }, + masternodes: nil + ) + } + XCTAssertFalse(PlatformWalletManager.isSteadySyncState(progress(.syncing, filters: (199_999, 2_535_898)))) + XCTAssertFalse(PlatformWalletManager.isSteadySyncState(progress(.waitingForConnections, filters: nil))) + XCTAssertFalse(PlatformWalletManager.isSteadySyncState(progress(.error, filters: (2_535_898, 2_535_898)))) + XCTAssertFalse( + PlatformWalletManager.isSteadySyncState(progress(.waitForEvents, filters: (2_535_800, 2_535_898))), + "waiting for events with the filter phase behind its target is a scan still running" + ) + XCTAssertFalse( + PlatformWalletManager.isSteadySyncState(progress(.waitForEvents, filters: nil)), + "no filter phase at all proves nothing" + ) + XCTAssertTrue(PlatformWalletManager.isSteadySyncState(progress(.synced, filters: (2_535_898, 2_535_898)))) + XCTAssertTrue( + PlatformWalletManager.isSteadySyncState(progress(.waitForEvents, filters: (2_535_898, 2_535_898))), + "dash-spv's fully synced steady state" + ) + XCTAssertEqual(PlatformWalletManager.scanTipHeight(progress(.synced, filters: (2_535_898, 2_535_898))), 2_535_898) + XCTAssertEqual( + PlatformWalletManager.scanTipHeight(progress(.synced, filters: nil)), + 2_535_898, + "falls back to the header tip" + ) + } + + func testTheManagerRefusesToReconcileAnUnknownOrUnconfiguredWallet() async throws { + let unconfigured = PlatformWalletManager() + let before = try await unconfigured.reconcileCoreTxoStore(for: walletId) + XCTAssertEqual(before, .skipped(.notConfigured)) + + let manager = PlatformWalletManager.makeForTesting( + handle: 42, + calls: PlatformWalletNativeTeardownCalls( + spvStop: { _ in PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) }, + platformAddressSyncStop: { _ in PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) }, + shieldedSyncStop: { _ in PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) }, + dashPaySyncStop: { _ in PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) }, + dpnsSyncStop: { _ in PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) }, + destroy: { _ in PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) } + ) + ) + // Configured, but no persistence handler and no loaded wallet: the + // gate refuses before any native read. + let outcome = try await manager.reconcileCoreTxoStore(for: walletId) + XCTAssertEqual(outcome, .skipped(.notConfigured)) + _ = await manager.shutdown() + PlatformWalletManager.destroyQueue.sync {} + } + + // MARK: 5. Idempotent + + func testASecondRunChangesNothing() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x81)) + let flipped = PersistentTxo.makeOutpoint(txid: txid(0x81), vout: 0) + let healed = engineUtxo(txid: txid(0x82)) + let engine = FakeCoreTxoEngine(inventory: [healed], verdicts: [flipped: .knownUncredited]) + + let first = run(handler, engine: engine) + XCTAssertEqual(first.mutations, 2) + + let second = run(handler, engine: engine) + XCTAssertTrue(second.completed) + XCTAssertEqual(second.mutations, 0) + XCTAssertEqual(second.alreadyPresent, 1) + XCTAssertEqual(second.storeRows, 1, "the healed coin is the only unspent row left") + XCTAssertEqual(second.unknown, 1, "and the fake has no verdict for it") + XCTAssertEqual(try txoCount(container), 2) + } + + // MARK: 6. Wallet- and account-scoped + + func testTheReconcileTouchesOnlyTheWalletItWasAskedAbout() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedWallet(in: container, walletId: otherWalletId) + try seedUnspentTxo(in: container, txid: txid(0x91)) + try seedUnspentTxo(in: container, walletId: otherWalletId, txid: txid(0x92)) + let mine = PersistentTxo.makeOutpoint(txid: txid(0x91), vout: 0) + let theirs = PersistentTxo.makeOutpoint(txid: txid(0x92), vout: 0) + // The fake would flip both if asked; only one may be asked. + let engine = FakeCoreTxoEngine(verdicts: [mine: .knownUncredited, theirs: .knownUncredited]) + + let report = run(handler, engine: engine) + + XCTAssertEqual(report.storeRows, 1) + XCTAssertEqual(report.flipped, 1) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0x91))).isSpent) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x92))).isSpent, "the other wallet's coin is untouched") + XCTAssertEqual(engine.classified.map(\.outpoint), [mine]) + } + + // MARK: 8. Repeated restart without rescan does not resurrect repaired funds + + func testRepeatedRelaunchesRestoreNothingForARepairedCoin() throws { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("txo-reconcile-\(UUID().uuidString).sqlite") + defer { + for suffix in ["", "-wal", "-shm"] { + try? FileManager.default.removeItem(at: URL(fileURLWithPath: url.path + suffix)) + } + } + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0xa1), vout: 0) + do { + let (handler, container) = try makeHandler(url: url) + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0xa1)) + XCTAssertEqual(try restoredUtxoCount(handler), 1, "the phantom the engine would be handed") + let report = run(handler, engine: FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited])) + XCTAssertEqual(report.flipped, 1) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + for _ in 0..<2 { + let (handler, container) = try makeHandler(url: url) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0xa1))).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 0, "a relaunch without a rescan restores nothing") + } + } + + // MARK: 9. A correct wallet stays untouched + + func testAConsistentStoreYieldsZeroMutations() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0xb1)) + try seedUnspentTxo(in: container, txid: txid(0xb2), isSpent: true) + let unspent = PersistentTxo.makeOutpoint(txid: txid(0xb1), vout: 0) + // The engine holds exactly the store's unspent coin, and says so. + let engine = FakeCoreTxoEngine( + inventory: [engineUtxo(txid: txid(0xb1))], + verdicts: [unspent: .unspent] + ) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.mutations, 0) + XCTAssertEqual(report.alreadyPresent, 1) + XCTAssertEqual(report.unspent, 1) + XCTAssertEqual(report.storeRows, 1, "spent rows are never even asked about") + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0xb1))).isSpent) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0xb2))).isSpent, "never un-marked") + XCTAssertEqual(try txoCount(container), 2) + } + + // MARK: Never un-mark, never delete + + func testASpentRowTheEngineStillHoldsIsNeverUnmarked() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0xc1), isSpent: true) + // The engine claims to hold the coin the store says is spent: a live + // spend racing the engine is indistinguishable from lost residue, + // and un-marking mid-payment would let the wallet double-spend. + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0xc1))]) + + let report = run(handler, engine: engine) + + XCTAssertEqual(report.alreadyPresent, 1) + XCTAssertEqual(report.mutations, 0) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0xc1))).isSpent) + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } + + // MARK: Run shape + + func testTheRunStopsAtTheFirstFailedEngineReadAndKeepsWhatLanded() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0xd1)) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0xd2))]) + engine.failClassify = true + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.transportFailures, 1) + XCTAssertEqual(report.inserted, 1, "the heal pass landed before the classify pass failed") + XCTAssertNotNil(try txo(container, txid: txid(0xd2))) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0xd1))).isSpent) + } + + func testTheHealPassWalksEveryPageOfTheInventory() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + let inventory = (0..<5).map { engineUtxo(txid: txid(0xe0 + UInt8($0))) } + let engine = FakeCoreTxoEngine(inventory: inventory) + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertEqual(engine.pageCalls, 3) + XCTAssertEqual(report.engineRows, 5) + XCTAssertEqual(report.inserted, 5) + XCTAssertEqual(try txoCount(container), 5) + } + + func testTheClassifyPassWalksEveryUnspentRowAcrossFlips() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + var verdicts: [Data: CoreOutpointClass] = [:] + for i in 0..<5 { + try seedUnspentTxo(in: container, txid: txid(0xf0 + UInt8(i))) + verdicts[PersistentTxo.makeOutpoint(txid: txid(0xf0 + UInt8(i)), vout: 0)] = .knownUncredited + } + let engine = FakeCoreTxoEngine(verdicts: verdicts) + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.storeRows, 5) + XCTAssertEqual(report.flipped, 5, "flipping rows out of the page does not skip the next ones") + XCTAssertEqual(try restoredUtxoCount(handler), 0) + } +}