From c098ac1c80fa07743f09e116c08c249b105e5289 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 9 Sep 2026 14:01:40 +0200 Subject: [PATCH 01/12] feat(platform-wallet): carry the engine's credit verdicts through the persistence seam and expose a store-reconcile inventory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A persister that derives its UTXO rows from record roles writes an UNSPENT row for an output the engine never credited — the coin was spent by a transaction with no wallet-owned output (a CoinJoin collateral burn) that was discarded before the coin was known (rust-dashcore#992) — and its own restore path then hands the phantom back to the engine on every launch (dashpay/platform#4575). - `CoreChangeSet::utxo_credit_verdicts`: for every Received/Change output the owning account does not hold, why (observed spent at a height, doomed, uncredited), computed by the event bridge under its existing read lock. Absence means credited: today's behaviour. - A size-negotiated persistence extension slot, `on_persist_wallet_changeset_utxo_verdicts_fn`, fired BEFORE the changeset callback so the host has the verdicts while it materialises the round's `utxos_added`. `WalletChangeSetFFI` is frozen. - `wallet_utxos_page` / `classify_outpoints` accessors and their FFI, for a store reconcile after a full scan: a paged wallet inventory carrying the owning-account tuple, and a per-row verdict whose only actionable class — known-uncredited-owned — is the engine's own decision, not an absence. Both take the wallet lock outside the handle registry guard. Depends on dashpay/rust-dashcore#979 for the primary engine-side repair; compiles against the current pin. Co-Authored-By: Claude Fable 5.1 --- .../src/core_wallet_types.rs | 169 ++++++ .../rs-platform-wallet-ffi/src/manager.rs | 53 +- .../src/manager_diagnostics.rs | 256 +++++++++ .../rs-platform-wallet-ffi/src/persistence.rs | 221 +++++++- .../src/changeset/changeset.rs | 118 ++++ .../src/changeset/core_bridge.rs | 411 ++++++++++++++ .../src/manager/accessors.rs | 504 ++++++++++++++++++ 7 files changed, 1730 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index e8e717d0a18..a89b78e5c8c 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 84ef9bddac9..369da63d52f 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -10,7 +10,8 @@ use crate::handle::*; use crate::persistence::{ FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, - PersistWalletChangesetSweepsFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, + PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; @@ -264,6 +265,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 + ), } } @@ -876,6 +881,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, @@ -1227,11 +1241,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 diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 77381873dc9..dde775f43ad 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -951,3 +951,259 @@ 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. The whole call is +/// rejected when any query carries an unknown account tag, so a partially +/// answered batch never reaches the caller. 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]); + + let mut owned: 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(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("query {i}: {e}"), + ); + } + }; + 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) + }; + let out = std::slice::from_raw_parts_mut(out_classes, count); + for (slot, class) in out.iter_mut().zip(classes) { + *slot = 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 2b0b23dbd49..14d3492ad91 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -45,7 +45,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::{ @@ -219,6 +220,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`] @@ -315,6 +336,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 { @@ -329,6 +365,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, } } } @@ -344,6 +381,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, } /// C callback vtable for wallet persistence. @@ -1135,6 +1173,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, @@ -1256,6 +1300,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()), @@ -1801,6 +1846,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 + ); + round_success = false; + } + } + } + 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) }; @@ -7298,6 +7374,139 @@ 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 { @@ -7506,6 +7715,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 b95ff429398..15e773dbf9a 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -263,6 +263,62 @@ 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 is `extend` (newest wins per outpoint); all verdicts in one + /// drain are computed against the same wallet snapshot, so they agree. + /// `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 @@ -722,6 +778,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: newest wins per outpoint. Every verdict in a + // drain is computed against the same wallet snapshot, so two + // batches folding together cannot disagree about a coin. + self.utxo_credit_verdicts.extend(other.utxo_credit_verdicts); } fn is_empty(&self) -> bool { @@ -737,6 +798,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() } } @@ -3184,3 +3246,59 @@ 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) + ); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 83421a9d831..cf07b7ccb41 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; @@ -1024,6 +1025,9 @@ async fn build_core_changeset( .collect(); let (addresses_marked_used, account_highest_used) = collect_usage_deltas(wallet_manager, wallet_id, vec![&**record]).await; + let utxo_credit_verdicts = + utxo_credit_verdicts(wallet_manager, wallet_id, &owned.iter().collect::>()) + .await; let mut folded = owned.clone(); crate::changeset::changeset::fold_same_txid_records(&mut folded); CoreChangeSet { @@ -1044,6 +1048,7 @@ async fn build_core_changeset( addresses_derived: addresses_derived.clone(), addresses_marked_used, account_highest_used, + utxo_credit_verdicts, ..CoreChangeSet::default() } } @@ -1126,6 +1131,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 { @@ -1245,6 +1261,111 @@ 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`. +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. +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`. /// @@ -1654,6 +1775,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() } } @@ -5394,3 +5516,292 @@ 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()); + } +} diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index ace055e60aa..e79bd91a062 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -216,6 +216,212 @@ 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, and does not hold the coin. Under `update_utxos`'s +/// rules an owned output of a known record is absent from `utxos` only +/// because the engine skipped it for a spent reason (a block was observed +/// spending it, or the record is doomed) or consumed it. Everything else +/// says nothing: `Unknown` covers a funding transaction this session never +/// processed — after a restart the finalized set is empty, so absence +/// proves nothing — and `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, and + /// does not hold the coin. + 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 + } +} + +/// One page of `wallet_id`'s UTXO inventory across every funds account, 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)) + }) + .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(); + + queries + .iter() + .map(|query| { + // Unspent wins outright: a coin the engine holds is a coin, + // whichever account the store filed it under. + if accounts + .iter() + .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; + if owner.has_transaction(txid) || owner.transaction_is_finalized(txid) { + OutpointClass::KnownUncredited + } else { + OutpointClass::Unknown + } + }) + .collect() +} + /// Snapshot of one transaction row inside an account. #[derive(Debug, Clone, Copy)] pub struct AccountTransactionSnapshot { @@ -809,6 +1015,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 +1797,262 @@ 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 — + /// including the field case: a collateral burn processed before its + /// funding is never recorded, the funding is, and the coin is absent + /// from `utxos`, which is exactly `KnownUncredited`. + #[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); + + 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(), 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, + ), + ]; + let classes = classify_outpoints(&wm, &wallet_id, &queries); + assert_eq!( + classes, + vec![ + OutpointClass::Unspent, + OutpointClass::KnownUncredited, + OutpointClass::KnownUncredited, + OutpointClass::NotOwned, + OutpointClass::Unknown, + OutpointClass::NotOwned, + 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()); + } +} From b6197b611424a55ae3d6cbdfdf64bade214f1462 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 9 Sep 2026 14:01:56 +0200 Subject: [PATCH 02/12] fix(swift-sdk): write born-spent TXO rows spent, and reconcile the store against the engine after a full scan Consumes the credit-verdict slot: an output the engine skipped because a block was observed spending it (or whose record is doomed) is written spent at creation, and any verdict vetoes the redelivery clear that would otherwise resurrect it. Adds `reconcileCoreTxoStore(for:)`, gated on the SPV steady state, no latched sync fault, and the wallet's own watermark; it inserts validated, owned, mature engine coins the store lacks and marks a row spent only on the engine's known-uncredited-owned verdict. Never deletes, never un-marks, never acts on absence; idempotent, wallet-scoped, paged, deferred behind open Rust rounds, stopped by shutdown and delete. Runs automatically on the steady-state transition and every 30 minutes. Swift half not yet compiled on this branch: the xcframework build was interrupted by a full disk. Tests are written for the seam, the reconcile and its shutdown behaviour; a privacy test over the events is still to be added. Co-Authored-By: Claude Fable 5.1 --- .../CoreTxoReconcileTypes.swift | 360 +++++++++++ .../PlatformWalletManager.swift | 32 +- .../PlatformWalletManagerTxoReconcile.swift | 385 +++++++++++ .../PlatformWalletPersistenceHandler.swift | 525 ++++++++++++++- .../BornSpentTxoPersistTests.swift | 292 +++++++++ .../CoreTxoReconcileShutdownTests.swift | 146 +++++ .../CoreTxoReconcileTests.swift | 599 ++++++++++++++++++ 7 files changed, 2328 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift 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..ee213058647 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -0,0 +1,360 @@ +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 + } + + /// `ACCOUNT_TYPE_TAG_FFI_DASHPAY_EXTERNAL_ACCOUNT` in the generated + /// header: a contact's watch-only chain. Its coins are the contact's, + /// never this wallet's, so the reconcile neither heals nor classifies + /// them. + static let dashpayExternalAccountTag: UInt8 = 13 + + /// Whether this account is a contact's watch-only chain. + public var isWatchOnlyContactAccount: Bool { + typeTag == Self.dashpayExternalAccountTag + } +} + +/// 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, and does not hold the coin — under the +/// engine's `update_utxos` rules an owned output of a known record is +/// absent only because the engine skipped it for a spent reason or +/// consumed it. `unknown` includes 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 rows below the confirmation gate. + public var skippedImmature = 0 + /// Engine rows on a contact's watch-only chain. + public var skippedForeign = 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 + /// 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 changed. + public var mutations: Int { inserted + flipped } +} + +/// 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..8c145ae33d1 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) @@ -3010,6 +3039,7 @@ public class PlatformWalletManager: ObservableObject { if let value = snapshot.spvProgress, spvProgress == baseline.spvProgress, value != spvProgress { spvProgress = value + 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..25ffedc31e4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -0,0 +1,385 @@ +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. + 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. + static let coreTxoReconcileTipMargin: UInt32 = 6 + /// Cadence of the automatic run while the client stays in steady state. + static let coreTxoReconcileCadence: Duration = .seconds(30 * 60) + /// Rows per engine page / per store page. + static let coreTxoReconcilePageSize = 512 + /// How many times a step is deferred behind open Rust rounds before the + /// run gives up (each deferral waits `coreTxoReconcileRetryDelay`). + static let coreTxoReconcileMaxRetries = 200 + static let coreTxoReconcileRetryDelay: TimeInterval = 0.05 + + /// 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.skippedImmature += counts.skippedImmature + report.skippedForeign += counts.skippedForeign + 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. + var offset = 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 + ) + }) else { return report } + 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. + offset += max(page.fetched - flippedThisPage, 1) + } + 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), + "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)), + "skipped_foreign_count": .integer(Int64(report.skippedForeign)), + "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 49e97964602..3c423778008 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -226,6 +226,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 @@ -1384,6 +1402,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..?, @@ -2744,7 +2792,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 @@ -2756,6 +2809,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 @@ -2769,15 +2849,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 @@ -3062,6 +3147,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 } @@ -3143,6 +3234,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, @@ -3202,6 +3295,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 @@ -8950,6 +9061,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. @@ -10597,3 +10738,367 @@ 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 +} + +/// Counts from one heal step. +struct CoreTxoHealCounts: Sendable { + var inserted = 0 + var insertedDuffs: UInt64 = 0 + var alreadyPresent = 0 + var skippedImmature = 0 + var skippedForeign = 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 +} + +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 contact's + /// watch-only chain is skipped — its coins are the contact's; a coin + /// 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. + 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 + } + if row.account.isWatchOnlyContactAccount { + counts.skippedForeign += 1 + continue + } + guard row.height > 0, tipHeight >= row.height, + tipHeight - row.height + 1 >= minConfirmations + else { + counts.skippedImmature += 1 + continue + } + let outpoint = row.outpoint + if fetchTxoRow(outpoint: outpoint) != nil { + counts.alreadyPresent += 1 + continue + } + guard let account = findAccountRow(walletId: walletId, key: row.account) 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) + counts.inserted += 1 + counts.insertedDuffs = counts.insertedDuffs.addingReportingOverflow(row.amount).0 + SDKLogger.event( + "persistence_txo_reconcile_item", + category: .persistence, + fields: [ + "action": .publicText("healed"), + "amount_duffs": .unsignedInteger(row.amount), + "outpoint_reference": .reference(outpoint), + "wallet_reference": .reference(walletId), + ] + ) + } + guard counts.inserted == 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 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 + )) + } + } + + /// 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. + func reconcileApplyEngineClasses( + walletId: Data, + rows: [CoreTxoStoreUnspentRow], + classes: [CoreOutpointClass] + ) -> CoreTxoReconcileStep { + onQueue { + guard !inChangeset else { return .retryLater } + 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: + guard let txo = fetchTxoRow(outpoint: row.outpoint), !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"), + "amount_duffs": .unsignedInteger(txo.amount), + "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. + private func findAccountRow(walletId: Data, key: CoreAccountKey) -> 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? backgroundContext.fetch(descriptor)) ?? [] + // 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) + } + } + + /// 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/BornSpentTxoPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift new file mode 100644 index 00000000000..69704d193e5 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift @@ -0,0 +1,292 @@ +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) + } + + private func seedWallet(in container: ModelContainer) throws { + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + 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/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift new file mode 100644 index 00000000000..aa442a0ba2a --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -0,0 +1,146 @@ +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 first check admits the first page; the second one (before + // the second page) reports the epoch bumped. + isCancelled: { checks.next() >= 2 } + ) + + 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..28ea8650aa0 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -0,0 +1,599 @@ +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] = [] + + 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] { + 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 var watchOnlyContact: CoreAccountKey { + CoreAccountKey( + typeTag: CoreAccountKey.dashpayExternalAccountTag, standardTag: 0, index: 0, + registrationIndex: 0, keyClass: 0, + userIdentityId: Data(repeating: 0x0a, count: 32), friendIdentityId: Data(repeating: 0x0b, 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) + 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") + 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.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 + ) -> CoreEngineUtxo { + CoreEngineUtxo( + account: account ?? bip44, + txid: txid, + vout: vout, + amount: amount, + address: address ?? fixtureAddress, + scriptPubKey: script ?? fixtureScript, + height: height, + isConfirmed: true, + 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) + } + + // 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 testTheHealPassRefusesImmatureForeignUnresolvedAndMalformedCoins() 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 + let foreign = engineUtxo(account: watchOnlyContact, txid: txid(0x78)) + 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, foreign, unresolved, noScript, noAddress, unconfirmed] + ) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.engineRows, 7) + XCTAssertEqual(report.inserted, 1) + XCTAssertEqual(report.skippedImmature, 2) + XCTAssertEqual(report.skippedForeign, 1) + 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") + } + } + + // 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) + } +} From d7515acefda3890b7af88319c02df66b180a5902 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Wed, 9 Sep 2026 14:09:06 +0200 Subject: [PATCH 03/12] test(swift-sdk): verify the credit-verdict seam and the store reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes the reconcile constants nonisolated so the synchronous runner can read them off the main actor, calls the static wallet resolver through the type, and advances the classify walk by the rows a page really left behind — a page that flipped entirely re-reads the same offset, which now holds rows the walk has not seen. Adds the privacy test over every new event (no address, txid, outpoint, script, long hex or Base58 run) and the shutdown/race tests, and makes the fixtures restorable the way the load path requires. Co-Authored-By: Claude Fable 5.1 --- .../PlatformWalletManagerTxoReconcile.swift | 18 +- .../PlatformWalletPersistenceHandler.swift | 2 +- .../BornSpentTxoPersistTests.swift | 10 +- .../CoreTxoReconcilePrivacyTests.swift | 169 ++++++++++++++++++ .../CoreTxoReconcileShutdownTests.swift | 7 +- .../CoreTxoReconcileTests.swift | 4 + 6 files changed, 197 insertions(+), 13 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index 25ffedc31e4..ea41d0b0e75 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -30,18 +30,18 @@ import Foundation extension PlatformWalletManager { /// Confirmations an engine coin needs before the heal pass inserts it. /// Coinbase maturity; also well past any plausible reorg. - static let coreTxoReconcileMinConfirmations: UInt32 = 100 + 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. - static let coreTxoReconcileTipMargin: UInt32 = 6 + nonisolated static let coreTxoReconcileTipMargin: UInt32 = 6 /// Cadence of the automatic run while the client stays in steady state. - static let coreTxoReconcileCadence: Duration = .seconds(30 * 60) + nonisolated static let coreTxoReconcileCadence: Duration = .seconds(30 * 60) /// Rows per engine page / per store page. - static let coreTxoReconcilePageSize = 512 + 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`). - static let coreTxoReconcileMaxRetries = 200 - static let coreTxoReconcileRetryDelay: TimeInterval = 0.05 + nonisolated static let coreTxoReconcileMaxRetries = 200 + nonisolated static let coreTxoReconcileRetryDelay: TimeInterval = 0.05 /// Reconcile the SwiftData TXO store of `walletId` against the engine, /// once the SPV scan has reached a trustworthy steady state — see the @@ -265,8 +265,10 @@ extension PlatformWalletManager { } guard page.hasMore else { break } // Flipped rows left the `isSpent == false` predicate, so the - // next page starts that many rows earlier. - offset += max(page.fetched - flippedThisPage, 1) + // 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 } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 3c423778008..b22a7ae8644 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -10964,7 +10964,7 @@ extension PlatformWalletPersistenceHandler { } var rows: [CoreTxoStoreUnspentRow] = [] for txo in fetched { - guard resolvedWalletId(of: txo) == walletId, + guard Self.resolvedWalletId(of: txo) == walletId, let account = txo.account, let typeTag = UInt8(exactly: account.accountType) else { continue } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift index 69704d193e5..2db8719b9d3 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/BornSpentTxoPersistTests.swift @@ -48,9 +48,17 @@ final class BornSpentTxoPersistTests: XCTestCase { 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) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + 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() } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift new file mode 100644 index 00000000000..fec18c27a12 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift @@ -0,0 +1,169 @@ +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\"")) + 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 index aa442a0ba2a..238aec3e0c0 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -69,9 +69,10 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { let report = PlatformWalletManager.runCoreTxoReconcile( walletId: walletId, tipHeight: 2_535_898, pageSize: 2, engine: engine, handler: handler, - // The first check admits the first page; the second one (before - // the second page) reports the epoch bumped. - isCancelled: { checks.next() >= 2 } + // 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) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 28ea8650aa0..84c83e8d933 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -128,11 +128,15 @@ final class CoreTxoReconcileTests: XCTestCase { 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) From e71f639f9a4472ae67b8ee94165d948b4eed59f6 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 08:35:20 +0200 Subject: [PATCH 04/12] =?UTF-8?q?fix(platform-wallet,=20swift-sdk):=20dura?= =?UTF-8?q?ble=20evidence=20only=20=E2=80=94=20merge,=20classify=20and=20a?= =?UTF-8?q?pply=20of=20the=20TXO=20reconcile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three ways the reconcile could write a live coin spent, each closed: - Changeset merge: verdicts of two events folded into one round come from two wallet snapshots, and only uncredited outputs carry a verdict — so a coin credited by the newer snapshot is absent from the newer map and the older `ObservedSpent` survived `extend`. The merge now drops the older verdicts for every record the newer changeset re-projects and for every outpoint it credits, then extends. Denial-to-credit regressions added. - `classify_outpoints`: absence from `utxos` with a known funding was `KnownUncredited`, but `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. `KnownUncredited` now also requires a MINED record in a funds account that spends the outpoint; everything else is `Unknown`. The rust-dashcore#992 shape (spender never recorded) is therefore `Unknown` here — the emit-time verdict covers it. Test updated, mempool-spend case added. - Swift apply: the engine is asked off the persistence queue and the verdict applied on it; a round that opened and committed in between may have re-credited the coin. The handler counts committed rounds (`committedRoundGeneration`), the page carries the count it was read under, and the apply refuses to write when it moved; the run classifies the page again, at most five times in a row. Regression test drives a round commit from inside `classify`. Also rustfmt for the files the CI formatting check flagged. Co-Authored-By: Claude Fable 5.1 --- .../src/core_wallet_types.rs | 6 +- .../rs-platform-wallet-ffi/src/manager.rs | 3 +- .../src/manager_diagnostics.rs | 5 +- .../rs-platform-wallet-ffi/src/persistence.rs | 4 +- .../src/changeset/changeset.rs | 160 +++++++++++++--- .../src/changeset/core_bridge.rs | 30 +-- .../src/manager/accessors.rs | 172 ++++++++++++------ .../CoreTxoReconcileTypes.swift | 3 + .../PlatformWalletManagerTxoReconcile.swift | 27 ++- .../PlatformWalletPersistenceHandler.swift | 31 +++- .../CoreTxoReconcileTests.swift | 53 +++++- 11 files changed, 388 insertions(+), 106 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index a89b78e5c8c..68e3dd8ff8a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -119,9 +119,9 @@ pub(crate) fn build_utxo_credit_verdicts_for_callback( .iter() .map(|(outpoint, verdict)| { let (code, spent_at_height) = match verdict { - UtxoCreditVerdict::ObservedSpent { - height, - } => (UTXO_CREDIT_VERDICT_OBSERVED_SPENT, *height), + UtxoCreditVerdict::ObservedSpent { height } => { + (UTXO_CREDIT_VERDICT_OBSERVED_SPENT, *height) + } UtxoCreditVerdict::Doomed => (UTXO_CREDIT_VERDICT_DOOMED, 0), UtxoCreditVerdict::Uncredited => (UTXO_CREDIT_VERDICT_UNCREDITED, 0), }; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 369da63d52f..536057e7a93 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -11,8 +11,7 @@ use crate::persistence::{ FFIPersister, FreeTrackedMasternodesFn, LoadTrackedMasternodesFn, PersistDpnsNameStatesFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistenceCallbacks, - PersistenceCallbacksExtension, - PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index dde775f43ad..e654dc35c0f 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -1158,7 +1158,10 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); let mut owned: Vec = Vec::with_capacity(count); - for (i, q) in std::slice::from_raw_parts(queries, count).iter().enumerate() { + 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, diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 14d3492ad91..177ff89e4e4 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -7429,9 +7429,7 @@ mod tests { let mut core = CoreChangeSet::default(); core.utxo_credit_verdicts.insert( outpoint(0xAB, 1), - UtxoCreditVerdict::ObservedSpent { - height: 2_402_896, - }, + UtxoCreditVerdict::ObservedSpent { height: 2_402_896 }, ); core.utxo_credit_verdicts .insert(outpoint(0xCD, 0), UtxoCreditVerdict::Doomed); diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 15e773dbf9a..f034e1715e8 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -287,8 +287,15 @@ pub struct CoreChangeSet { /// moment the evidence exists: `observed_spent_outpoints` is pruned at /// the finality boundary long before a scan ends. /// - /// Merge is `extend` (newest wins per outpoint); all verdicts in one - /// drain are computed against the same wallet snapshot, so they agree. + /// 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))] @@ -648,6 +655,27 @@ 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 of such a record 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. Computed here, + // before `other.records` is consumed. + if !self.utxo_credit_verdicts.is_empty() { + let reprojected: std::collections::HashSet = other + .records + .iter() + .chain(other.account_records.iter()) + .map(|record| record.txid) + .collect(); + let credited: std::collections::HashSet = + other.new_utxos.iter().map(|utxo| utxo.outpoint).collect(); + self.utxo_credit_verdicts.retain(|outpoint, _| { + !reprojected.contains(&outpoint.txid) && !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(); @@ -779,9 +807,9 @@ impl Merge for CoreChangeSet { // preserves that. self.sweeps.extend(other.sweeps); - // Credit verdicts: newest wins per outpoint. Every verdict in a - // drain is computed against the same wallet snapshot, so two - // batches folding together cannot disagree about a coin. + // 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); } @@ -3265,40 +3293,122 @@ mod utxo_credit_verdict_merge_tests { #[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, - }, - ); + 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); + 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 - }) + Some(&UtxoCreditVerdict::ObservedSpent { height: 11 }) ); assert_eq!( older.utxo_credit_verdicts.get(&outpoint(2)), - Some(&UtxoCreditVerdict::ObservedSpent { - height: 10 - }) + Some(&UtxoCreditVerdict::ObservedSpent { height: 10 }) ); assert_eq!( older.utxo_credit_verdicts.get(&outpoint(3)), Some(&UtxoCreditVerdict::Doomed) ); } + + fn record_for(txid_byte: u8) -> TransactionRecord { + let tx = Transaction { + version: 2, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }; + let mut record = TransactionRecord::new( + tx, + key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + key_wallet::transaction_checking::TransactionContext::Mempool, + key_wallet::transaction_checking::transaction_router::TransactionType::Standard, + key_wallet::managed_account::transaction_record::TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ); + record.txid = Txid::from_byte_array([txid_byte; 32]); + record + } + + /// 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" + ); + } + + /// 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 cf07b7ccb41..76109530282 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1352,9 +1352,7 @@ fn utxo_credit_verdicts_from_wallet( continue; } let verdict = if let Some(height) = observed.get(&outpoint) { - UtxoCreditVerdict::ObservedSpent { - height: *height, - } + UtxoCreditVerdict::ObservedSpent { height: *height } } else if doomed { UtxoCreditVerdict::Doomed } else { @@ -5627,9 +5625,18 @@ mod utxo_credit_verdict_tests { }; let burn_result = managed_wallet - .check_core_transaction(&collateral_burn(coin), in_block(100_001), &mut wallet, true, true) + .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.is_relevant, + "a burn of an unknown coin matches nothing" + ); assert!(burn_result.new_records.is_empty()); assert!(burn_result.updated_records.is_empty()); @@ -5659,9 +5666,7 @@ mod utxo_credit_verdict_tests { let verdicts = utxo_credit_verdicts_from_wallet(&managed_wallet, &[funding_record]); assert_eq!( verdicts.get(&coin), - Some(&UtxoCreditVerdict::ObservedSpent { - height: 100_001 - }) + Some(&UtxoCreditVerdict::ObservedSpent { height: 100_001 }) ); assert_eq!(verdicts.len(), 1); } @@ -5735,7 +5740,10 @@ mod utxo_credit_verdict_tests { 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)); + assert_eq!( + verdicts.get(&loser_output), + Some(&UtxoCreditVerdict::Doomed) + ); } /// End to end through the event bridge: the funding record's @@ -5791,9 +5799,7 @@ mod utxo_credit_verdict_tests { let cs = build_core_changeset(&manager, &event(wallet_id)).await; assert_eq!( cs.utxo_credit_verdicts.get(&coin), - Some(&UtxoCreditVerdict::ObservedSpent { - height: 100_001 - }) + Some(&UtxoCreditVerdict::ObservedSpent { height: 100_001 }) ); assert!( cs.new_utxos.iter().any(|u| u.outpoint == coin), diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index e79bd91a062..a64ebc187d5 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -267,14 +267,19 @@ pub struct 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, and does not hold the coin. Under `update_utxos`'s -/// rules an owned output of a known record is absent from `utxos` only -/// because the engine skipped it for a spent reason (a block was observed -/// spending it, or the record is doomed) or consumed it. Everything else -/// says nothing: `Unknown` covers a funding transaction this session never -/// processed — after a restart the finalized set is empty, so absence -/// proves nothing — and `NotOwned` a script the account's pools do not -/// monitor, which the engine could never have credited in the first place. +/// 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 { @@ -282,8 +287,8 @@ pub enum OutpointClass { Unknown = 0, /// The coin is in a funds account's live `utxos`. Unspent = 1, - /// The owning account knows the funding txid, owns the script, and - /// does not hold the coin. + /// 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, @@ -320,16 +325,19 @@ pub fn wallet_utxos_page( 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)) - }) - .collect(); + 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)) + }) + .collect(); accounts.sort_by(|a, b| a.0.cmp(&b.0)); let mut rows = Vec::with_capacity(limit); @@ -380,7 +388,10 @@ pub fn classify_outpoints( 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 + let accounts: Vec<( + AccountType, + &key_wallet::managed_account::ManagedCoreFundsAccount, + )> = info .core_wallet .accounts .all_accounts() @@ -391,6 +402,21 @@ pub fn classify_outpoints( }) .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| { @@ -413,7 +439,8 @@ pub fn classify_outpoints( return OutpointClass::NotOwned; } let txid = &query.outpoint.txid; - if owner.has_transaction(txid) || owner.transaction_is_finalized(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 @@ -1904,7 +1931,11 @@ mod txo_inventory_tests { (wm, wallet_id) } - fn query(account_type: AccountType, outpoint: OutPoint, script: &ScriptBuf) -> OutpointOwnershipQuery { + fn query( + account_type: AccountType, + outpoint: OutPoint, + script: &ScriptBuf, + ) -> OutpointOwnershipQuery { OutpointOwnershipQuery { account_type, outpoint, @@ -1920,7 +1951,11 @@ mod txo_inventory_tests { 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); + assert!( + ctx.check_transaction(&tx, in_block(100_000 + seed as u32)) + .await + .is_relevant + ); coins.push(OutPoint { txid: tx.txid(), vout: 0, @@ -1964,10 +1999,15 @@ mod txo_inventory_tests { assert!(!more); } - /// The four answers, each from the arrival order that produces it — - /// including the field case: a collateral burn processed before its - /// funding is never recorded, the funding is, and the coin is absent - /// from `utxos`, which is exactly `KnownUncredited`. + /// 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(); @@ -1975,7 +2015,11 @@ mod txo_inventory_tests { // 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); + assert!( + ctx.check_transaction(&held, in_block(100_000)) + .await + .is_relevant + ); let held_coin = OutPoint { txid: held.txid(), vout: 0, @@ -1987,11 +2031,16 @@ mod txo_inventory_tests { 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); + 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); @@ -1999,12 +2048,37 @@ mod txo_inventory_tests { 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); + 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"), @@ -2014,34 +2088,24 @@ mod txo_inventory_tests { 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, - ), + 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, - ), + query(AccountType::CoinJoin { index: 7 }, burned_coin, &script), ]; let classes = classify_outpoints(&wm, &wallet_id, &queries); assert_eq!( classes, vec![ OutpointClass::Unspent, + OutpointClass::Unknown, OutpointClass::KnownUncredited, - OutpointClass::KnownUncredited, + OutpointClass::Unknown, OutpointClass::NotOwned, OutpointClass::Unknown, OutpointClass::NotOwned, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index ee213058647..165ec2aef21 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -196,6 +196,9 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { // 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. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index ea41d0b0e75..2697fabac46 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -42,6 +42,10 @@ extension PlatformWalletManager { /// 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 @@ -223,8 +227,12 @@ extension PlatformWalletManager { cursor = last } - // Pass B — classify: every unspent store row of the wallet. + // 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 @@ -253,9 +261,23 @@ extension PlatformWalletManager { handler.reconcileApplyEngineClasses( walletId: walletId, rows: page.rows, - classes: classes + 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 @@ -371,6 +393,7 @@ extension PlatformWalletManager { "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_foreign_count": .integer(Int64(report.skippedForeign)), "skipped_immature_count": .integer(Int64(report.skippedImmature)), "skipped_invalid_count": .integer(Int64(report.skippedInvalid)), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b22a7ae8644..1622b763475 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -216,6 +216,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 @@ -3358,6 +3366,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } do { try backgroundContext.save() + committedRoundGeneration &+= 1 SDKLogger.event( "persistence_changeset_committed", category: .persistence, @@ -10802,6 +10811,8 @@ 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. @@ -10824,6 +10835,9 @@ struct CoreTxoFlipCounts: Sendable { 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 { @@ -10993,7 +11007,8 @@ extension PlatformWalletPersistenceHandler { return .done(CoreTxoStoreUnspentPage( rows: rows, fetched: fetched.count, - hasMore: fetched.count == limit + hasMore: fetched.count == limit, + generation: committedRoundGeneration )) } } @@ -11005,14 +11020,24 @@ extension PlatformWalletPersistenceHandler { /// 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. + /// 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] + 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 { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 84c83e8d933..d447a9cb77d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -14,6 +14,10 @@ final class FakeCoreTxoEngine: CoreTxoEngineInventory, @unchecked Sendable { 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 @@ -46,8 +50,13 @@ final class FakeCoreTxoEngine: CoreTxoEngineInventory, @unchecked Sendable { } } + func setVerdict(_ outpoint: Data, _ verdict: CoreOutpointClass) { + lock.withLock { _verdicts[outpoint] = verdict } + } + func classify(_ queries: [CoreOutpointOwnershipQuery]) throws -> [CoreOutpointClass] { - try lock.withLock { + onClassify?() + return try lock.withLock { classifyCalls += 1 if _failClassify { throw Failure() } classified.append(contentsOf: queries) @@ -289,6 +298,48 @@ final class CoreTxoReconcileTests: XCTestCase { 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 { From 7ce3573bb7a397227e619e2fad699c6dfd2a670d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 09:03:18 +0200 Subject: [PATCH 05/12] fix(platform-wallet-ffi): report a failing credit-verdict slot through the round outcome v4.2-dev (#4586) replaced the round's `round_success` flag with the typed `RoundOutcome`; the verdict slot fired before the changeset callback still cleared the old flag, which the merge left dangling. Record the callback's error code on the outcome like every other slot does. Co-Authored-By: Claude Fable 5.1 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index f4a603c6e16..06096c1aa96 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -2039,7 +2039,7 @@ impl PlatformWalletPersistence for FFIPersister { code {}", result ); - round_success = false; + outcome.record(result); } } } From b19989b0afcd9c04eb9b5c149da176bec060c2fe Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 09:14:07 +0200 Subject: [PATCH 06/12] fix(platform-wallet, swift-sdk): decide heal eligibility in the engine, and clock the reconcile on every progress read - `wallet_utxos_page` omits a contact's watch-only chain (`DashpayExternalAccount`) and `classify_outpoints` returns no verdict for one, so whether an account's coins may be healed or flipped is decided in Rust; the Swift tag comparison and its `skippedForeign` counter are gone. - The heal pass also requires the engine's own `is_confirmed` before the store's confirmation-depth gate, instead of deriving maturity from height alone. - `applyManagerSnapshot` feeds the reconcile trigger on every accepted progress read, not only when the value changed: the note is the reconcile's only clock, and a quiet steady-state wallet's progress does not change for the whole cadence. Co-Authored-By: Claude Fable 5.1 --- .../src/manager/accessors.rs | 17 ++++++++++++++ .../CoreTxoReconcileTypes.swift | 11 --------- .../PlatformWalletManager.swift | 9 ++++++-- .../PlatformWalletManagerTxoReconcile.swift | 2 -- .../PlatformWalletPersistenceHandler.swift | 12 +++++----- .../CoreTxoReconcileTests.swift | 23 +++++++------------ 6 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index a64ebc187d5..201b28ab9e2 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -309,6 +309,13 @@ impl OutpointClass { /// 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. +/// 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 { .. }) +} + pub fn wallet_utxos_page( wm: &key_wallet_manager::WalletManager, wallet_id: &WalletId, @@ -337,6 +344,11 @@ pub fn wallet_utxos_page( 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)); @@ -428,6 +440,11 @@ pub fn classify_outpoints( { return OutpointClass::Unspent; } + // A contact's watch-only chain gets no verdict at all: its + // coins are the contact's to spend, never this wallet's to flip. + if is_watch_only_contact(&query.account_type) { + return OutpointClass::Unknown; + } let Some((_, owner)) = accounts .iter() .find(|(account_type, _)| *account_type == query.account_type) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 165ec2aef21..4e9e00c224c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -36,16 +36,6 @@ public struct CoreAccountKey: Hashable, Sendable { self.friendIdentityId = friendIdentityId } - /// `ACCOUNT_TYPE_TAG_FFI_DASHPAY_EXTERNAL_ACCOUNT` in the generated - /// header: a contact's watch-only chain. Its coins are the contact's, - /// never this wallet's, so the reconcile neither heals nor classifies - /// them. - static let dashpayExternalAccountTag: UInt8 = 13 - - /// Whether this account is a contact's watch-only chain. - public var isWatchOnlyContactAccount: Bool { - typeTag == Self.dashpayExternalAccountTag - } } /// One coin the engine holds, as one row of the paged inventory @@ -172,7 +162,6 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { /// Engine rows below the confirmation gate. public var skippedImmature = 0 /// Engine rows on a contact's watch-only chain. - public var skippedForeign = 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 8c145ae33d1..5b8fb30f59d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -3037,8 +3037,13 @@ 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, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index 2697fabac46..d74179d9251 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -219,7 +219,6 @@ extension PlatformWalletManager { report.insertedDuffs = report.insertedDuffs.addingReportingOverflow(counts.insertedDuffs).0 report.alreadyPresent += counts.alreadyPresent report.skippedImmature += counts.skippedImmature - report.skippedForeign += counts.skippedForeign report.skippedUnresolvedAccount += counts.skippedUnresolvedAccount report.skippedInvalid += counts.skippedInvalid } @@ -394,7 +393,6 @@ extension PlatformWalletManager { "not_owned_count": .integer(Int64(report.notOwned)), "retry_count": .integer(Int64(report.retries)), "stale_retry_count": .integer(Int64(report.staleRetries)), - "skipped_foreign_count": .integer(Int64(report.skippedForeign)), "skipped_immature_count": .integer(Int64(report.skippedImmature)), "skipped_invalid_count": .integer(Int64(report.skippedInvalid)), "skipped_unresolved_account_count": .integer(Int64(report.skippedUnresolvedAccount)), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index fa301ce34d5..151f59606aa 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -10859,7 +10859,6 @@ struct CoreTxoHealCounts: Sendable { var insertedDuffs: UInt64 = 0 var alreadyPresent = 0 var skippedImmature = 0 - var skippedForeign = 0 var skippedUnresolvedAccount = 0 var skippedInvalid = 0 } @@ -10909,11 +10908,12 @@ extension PlatformWalletPersistenceHandler { counts.skippedInvalid += 1 continue } - if row.account.isWatchOnlyContactAccount { - counts.skippedForeign += 1 - continue - } - guard row.height > 0, tipHeight >= row.height, + // 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index d447a9cb77d..96468f5fe01 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -98,14 +98,6 @@ final class CoreTxoReconcileTests: XCTestCase { ) } - private var watchOnlyContact: CoreAccountKey { - CoreAccountKey( - typeTag: CoreAccountKey.dashpayExternalAccountTag, standardTag: 0, index: 0, - registrationIndex: 0, keyClass: 0, - userIdentityId: Data(repeating: 0x0a, count: 32), friendIdentityId: Data(repeating: 0x0b, count: 32) - ) - } - private func txid(_ byte: UInt8) -> Data { Data(repeating: byte, count: 32) } private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { @@ -210,7 +202,8 @@ final class CoreTxoReconcileTests: XCTestCase { amount: UInt64 = 19_549, height: UInt32 = 2_391_743, address: String? = nil, - script: Data? = nil + script: Data? = nil, + isConfirmed: Bool = true ) -> CoreEngineUtxo { CoreEngineUtxo( account: account ?? bip44, @@ -220,7 +213,7 @@ final class CoreTxoReconcileTests: XCTestCase { address: address ?? fixtureAddress, scriptPubKey: script ?? fixtureScript, height: height, - isConfirmed: true, + isConfirmed: isConfirmed, isInstantLocked: false, isCoinbase: false, isLocked: false @@ -394,18 +387,19 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(try restoredUtxoCount(handler), 2) } - func testTheHealPassRefusesImmatureForeignUnresolvedAndMalformedCoins() throws { + 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 - let foreign = engineUtxo(account: watchOnlyContact, txid: txid(0x78)) + // 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, foreign, unresolved, noScript, noAddress, unconfirmed] + inventory: [immature, atGate, engineUnconfirmed, unresolved, noScript, noAddress, unconfirmed] ) let report = run(handler, engine: engine) @@ -413,8 +407,7 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertTrue(report.completed) XCTAssertEqual(report.engineRows, 7) XCTAssertEqual(report.inserted, 1) - XCTAssertEqual(report.skippedImmature, 2) - XCTAssertEqual(report.skippedForeign, 1) + XCTAssertEqual(report.skippedImmature, 3) XCTAssertEqual(report.skippedUnresolvedAccount, 1) XCTAssertEqual(report.skippedInvalid, 2) XCTAssertNotNil(try txo(container, txid: txid(0x77))) From f8e84603df8709290a33df91f513ea9496339e4f Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 16:44:45 +0200 Subject: [PATCH 07/12] chore: pin rust-dashcore to integration/v4.2-pin-plus-989 (current pin + dashpay/rust-dashcore#989) The pin cannot move to dev head yet: dashpay/rust-dashcore#1005 (GroveDB bincode) needs dashpay/platform#4635 first. Until then the pin points at dashpay/rust-dashcore@697bfb72, which is the current pin 93260bf plus the cherry-picked #989 fix (dash-spv collects the scripts derived by every application of a block). Verified end to end on the support wallet: a from-seed rebuild ends with 0 phantom coins. Co-Authored-By: Claude Fable 5.1 --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 16 ++++++++-------- 2 files changed, 31 insertions(+), 31 deletions(-) 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 From a74e78b420fe46f406a80049ea3229d432178f0b Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 16:58:08 +0200 Subject: [PATCH 08/12] fix(platform-wallet-ffi, swift-sdk): degrade unmappable tags to Unknown, count a heal the drain spent, log no coin values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `platform_wallet_classify_outpoints`: a query whose account tag this build cannot map (identity-key accounts, a forward-versioned tag, a stray standard tag) no longer fails the whole batch. The slot is answered `Unknown` — the classifier's own answer for anything it cannot name — and the rest of the batch is classified. The row behind such a query is durable, so a batch failure repeated on every run. Regression test. - Heal pass: `drainPendingInputs` can write the freshly inserted row spent on the spot (a pending-input claim or an unstamped swept tombstone on the outpoint). That is not a repair of the divergence the engine reported, so it is counted as `healedSpent` (`healed_spent_count`, item action `healed_spent`) instead of `inserted`. - `persistence_txo_reconcile_item` no longer carries `amount_duffs`: the report's invariant is counts and references only, and per-denomination CoinJoin values are a fingerprint in a support export. The aggregates keep the operational number. The privacy test now asserts it. Co-Authored-By: Claude Fable 5.1 --- .../rs-platform-wallet-ffi/src/manager.rs | 64 +++++++++++++++++++ .../src/manager_diagnostics.rs | 22 ++++--- .../CoreTxoReconcileTypes.swift | 3 + .../PlatformWalletManagerTxoReconcile.swift | 2 + .../PlatformWalletPersistenceHandler.swift | 27 +++++++- .../CoreTxoReconcilePrivacyTests.swift | 4 ++ 6 files changed, 110 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 94c2caeed38..94113fca25d 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1415,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 e654dc35c0f..7fbcbfe4426 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -1157,7 +1157,16 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( 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() @@ -1173,13 +1182,9 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( ); let account_type = match account_type_from_spec_ref(&spec) { Ok(at) => at, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("query {i}: {e}"), - ); - } + Err(_) => continue, }; + positions.push(i); let script_pubkey = if q.script_pubkey.is_null() || q.script_pubkey_len == 0 { Vec::new() } else { @@ -1204,9 +1209,8 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( let wm = wallet_manager.blocking_read(); classify_outpoints(&wm, &wid, &owned) }; - let out = std::slice::from_raw_parts_mut(out_classes, count); - for (slot, class) in out.iter_mut().zip(classes) { - *slot = class.as_u8(); + for (position, class) in positions.into_iter().zip(classes) { + out[position] = class.as_u8(); } PlatformWalletFFIResult::ok() } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 4e9e00c224c..6aceadb95ea 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -159,6 +159,9 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { /// 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 on a contact's watch-only chain. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index d74179d9251..19ad5006dda 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -218,6 +218,7 @@ extension PlatformWalletManager { 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 @@ -388,6 +389,7 @@ extension PlatformWalletManager { "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)), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 151f59606aa..70b899ff263 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -10857,6 +10857,8 @@ struct CoreTxoStoreUnspentPage: Sendable { 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 @@ -10958,6 +10960,25 @@ extension PlatformWalletPersistenceHandler { 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( @@ -10965,13 +10986,14 @@ extension PlatformWalletPersistenceHandler { category: .persistence, fields: [ "action": .publicText("healed"), - "amount_duffs": .unsignedInteger(row.amount), "outpoint_reference": .reference(outpoint), "wallet_reference": .reference(walletId), ] ) } - guard counts.inserted == 0 || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) else { + guard counts.inserted + counts.healedSpent == 0 + || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) + else { return .failed } return .done(counts) @@ -11100,7 +11122,6 @@ extension PlatformWalletPersistenceHandler { category: .persistence, fields: [ "action": .publicText("flipped_spent"), - "amount_duffs": .unsignedInteger(txo.amount), "outpoint_reference": .reference(row.outpoint), "wallet_reference": .reference(walletId), ] diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift index fec18c27a12..d74ec5b810d 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift @@ -128,6 +128,10 @@ final class CoreTxoReconcilePrivacyTests: XCTestCase { 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)] = [ From 0b77da9a2403a590f8bd44302e79a1e7927a91c7 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 22:00:39 +0200 Subject: [PATCH 09/12] fix(platform-wallet, swift-sdk): judge verdicts on one snapshot, guard the merge per output, count drain-spent heals - `TransactionDetected` reads its slices and their credit verdicts under one wallet read guard (`wallet_slices_and_verdicts_for_txid`), so a verdict is never judged on a record context older than the wallet it is judged against; a funding that confirmed and lost its coin to a mempool child between two guards read `Doomed` and was written spent for good. Regression test drives a stale mempool clone through the bridge. - The merge drops older verdicts per re-projected OUTPUT (`Received` / `Change` details of the newer slices), not per txid: a newer changeset re-projecting only the BIP44 slice of a two-account transaction never walked the CoinJoin output and cannot restate its verdict. - `classify_outpoints` answers `Unknown` for a contact's watch-only chain before the unspent search, and contact accounts are out of that search. - `CoreTxoReconcileReport.mutations` includes `healedSpent`, so a run that wrote rows the drain stamped spent no longer logs as the idempotent case. - FFI doc for `platform_wallet_classify_outpoints` matches the per-slot `Unknown` degrade. Co-Authored-By: Claude Fable 5.1 --- .../src/manager_diagnostics.rs | 9 +- .../src/changeset/changeset.rs | 142 +++++++++++++++-- .../src/changeset/core_bridge.rs | 148 ++++++++++++++++-- .../src/manager/accessors.rs | 30 +++- .../CoreTxoReconcileTypes.swift | 7 +- .../CoreTxoReconcileTests.swift | 45 ++++++ 6 files changed, 342 insertions(+), 39 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index 7fbcbfe4426..5630ad287be 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -1129,10 +1129,11 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( /// 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. The whole call is -/// rejected when any query carries an unknown account tag, so a partially -/// answered batch never reaches the caller. Cost is `count × accounts`, -/// never the size of the inventory. Same lock discipline as +/// `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 diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index a0b7ad35b61..99cb718bdca 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -658,22 +658,43 @@ impl Merge for CoreChangeSet { // 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 of such a record 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. Computed here, - // before `other.records` is consumed. + // 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() { - let reprojected: std::collections::HashSet = other + use key_wallet::managed_account::transaction_record::OutputRole; + let reprojected: std::collections::HashSet = other .records .iter() .chain(other.account_records.iter()) - .map(|record| record.txid) + .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.txid) && !credited.contains(outpoint) + !reprojected.contains(outpoint) && !credited.contains(outpoint) }); } if !other.records.is_empty() && !self.sweeps.is_empty() { @@ -3346,7 +3367,25 @@ mod utxo_credit_verdict_merge_tests { ); } - fn record_for(txid_byte: u8) -> TransactionRecord { + 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, @@ -3356,21 +3395,39 @@ mod utxo_credit_verdict_merge_tests { }; let mut record = TransactionRecord::new( tx, - key_wallet::account::AccountType::Standard { - index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, - }, + 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(), - Vec::new(), + outputs + .iter() + .map(|(index, role)| OutputDetail { + index: *index, + role: role.clone(), + 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 @@ -3408,6 +3465,63 @@ mod utxo_credit_verdict_merge_tests { ); } + /// 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] diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 0a873274f4f..db7215c116d 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1078,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 @@ -1095,9 +1107,6 @@ async fn build_core_changeset( .collect(); let (addresses_marked_used, account_highest_used) = collect_usage_deltas(wallet_manager, wallet_id, vec![&**record]).await; - let utxo_credit_verdicts = - utxo_credit_verdicts(wallet_manager, wallet_id, &owned.iter().collect::>()) - .await; let mut folded = owned.clone(); crate::changeset::changeset::fold_same_txid_records(&mut folded); CoreChangeSet { @@ -1339,7 +1348,11 @@ async fn collect_usage_deltas( /// /// 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`. +/// 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, @@ -1385,7 +1398,11 @@ async fn utxo_credit_verdicts( /// 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. +/// 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], @@ -1622,6 +1639,24 @@ async fn wallet_slices_for_txid( wallet_id: &WalletId, txid: &dashcore::Txid, ) -> Option> { + wallet_slices_and_verdicts_for_txid(wallet_manager, wallet_id, txid) + .await + .map(|(slices, _)| slices) +} + +/// [`wallet_slices_for_txid`] plus 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. Same `None` / +/// `Some(vec![])` contract; the verdicts of an empty slice set are empty. +async fn wallet_slices_and_verdicts_for_txid( + wallet_manager: &Arc>>, + wallet_id: &WalletId, + txid: &dashcore::Txid, +) -> Option<( + Vec, + BTreeMap, +)> { let guard = wallet_manager.read().await; let info = guard.get_wallet_info(wallet_id)?; let mut slices = Vec::new(); @@ -1630,7 +1665,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? @@ -6070,4 +6110,90 @@ mod utxo_credit_verdict_tests { 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 201b28ab9e2..db280645a4e 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -432,19 +432,23 @@ pub fn classify_outpoints( queries .iter() .map(|query| { - // Unspent wins outright: a coin the engine holds is a coin, - // whichever account the store filed it under. + // 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; } - // A contact's watch-only chain gets no verdict at all: its - // coins are the contact's to spend, never this wallet's to flip. - if is_watch_only_contact(&query.account_type) { - return OutpointClass::Unknown; - } let Some((_, owner)) = accounts .iter() .find(|(account_type, _)| *account_type == query.account_type) @@ -2114,6 +2118,17 @@ mod txo_inventory_tests { 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!( @@ -2127,6 +2142,7 @@ mod txo_inventory_tests { OutpointClass::Unknown, OutpointClass::NotOwned, OutpointClass::Unknown, + OutpointClass::Unknown, ] ); // An unknown wallet has no opinion about anything. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 6aceadb95ea..104079369ee 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -164,7 +164,6 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { public var healedSpent = 0 /// Engine rows below the confirmation gate. public var skippedImmature = 0 - /// Engine rows on a contact's watch-only chain. /// 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 @@ -201,8 +200,10 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { public init() {} - /// Rows this run changed. - public var mutations: Int { inserted + flipped } + /// 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. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 96468f5fe01..1305f5d74b5 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -416,6 +416,51 @@ final class CoreTxoReconcileTests: XCTestCase { } } + /// 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) + } + // MARK: 4. Nothing runs before the scan is complete func testTheSteadyStateGateRefusesAnUnfinishedScan() { From da84e31638edc688f64e6b37cd242b4ec9505bd4 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Thu, 10 Sep 2026 22:15:27 +0200 Subject: [PATCH 10/12] fix(platform-wallet): drop the test-only slices wrapper, copy `OutputRole` `wallet_slices_for_txid` had no production caller once the `TransactionDetected` arm reads slices and verdicts together; the one test now reads through `wallet_slices_and_verdicts_for_txid`. `OutputRole` is `Copy`. Both were clippy errors under `-D warnings` on CI. Co-Authored-By: Claude Fable 5.1 --- .../src/changeset/changeset.rs | 2 +- .../src/changeset/core_bridge.rs | 27 ++++++------------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 99cb718bdca..46dec66c4b3 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -3404,7 +3404,7 @@ mod utxo_credit_verdict_merge_tests { .iter() .map(|(index, role)| OutputDetail { index: *index, - role: role.clone(), + role: *role, address: None, value: 1, }) diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index db7215c116d..3c7694d2e3f 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1631,24 +1631,13 @@ 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( - wallet_manager: &Arc>>, - wallet_id: &WalletId, - txid: &dashcore::Txid, -) -> Option> { - wallet_slices_and_verdicts_for_txid(wallet_manager, wallet_id, txid) - .await - .map(|(slices, _)| slices) -} - -/// [`wallet_slices_for_txid`] plus 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. Same `None` / -/// `Some(vec![])` contract; the verdicts of an empty slice set are empty. +/// 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, @@ -3101,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"); From 2b61dca2ba5093fb73f9f09b3aeba56b6aa63528 Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 11 Sep 2026 07:10:01 +0200 Subject: [PATCH 11/12] fix(swift-sdk, platform-wallet): fail the heal on an unreadable account lookup, fix two docs - `findAccountRow` reads through the `ModelFetching` seam and throws on a failed fetch; the heal pass logs `persistence_txo_reconcile_read_failed`, rolls back and returns `.failed`, so a store read failure stops the run as a store failure instead of being counted as a missing account. Test reuses `FetchFaultInjector` (promoted from file-private). - `CoreOutpointClass` doc states the durable-spender requirement behind `knownUncredited` instead of the earlier absence-based reasoning; the heal doc no longer lists a watch-only gate the engine now applies. - `wallet_utxos_page`'s rustdoc sits on `wallet_utxos_page`, not on `is_watch_only_contact`. Co-Authored-By: Claude Fable 5.1 --- .../src/manager/accessors.rs | 19 ++++++------ .../CoreTxoReconcileTypes.swift | 10 +++--- .../PlatformWalletPersistenceHandler.swift | 31 ++++++++++++++----- .../AssetLockSpendVisibilityTests.swift | 3 +- .../CoreTxoReconcileTests.swift | 31 +++++++++++++++++-- 5 files changed, 71 insertions(+), 23 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index db280645a4e..316b66820e8 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -300,15 +300,6 @@ impl OutpointClass { } } -/// One page of `wallet_id`'s UTXO inventory across every funds account, 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. /// 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. @@ -316,6 +307,16 @@ 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, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 104079369ee..4497e2f75ad 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -116,10 +116,12 @@ public struct CoreOutpointOwnershipQuery: Equatable, Sendable { /// /// Only `knownUncredited` is positive evidence the reconcile acts on: the /// owning account recorded the funding transaction, recognises the -/// output's script as its own, and does not hold the coin — under the -/// engine's `update_utxos` rules an owned output of a known record is -/// absent only because the engine skipped it for a spent reason or -/// consumed it. `unknown` includes every funding transaction this session +/// 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 { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 70b899ff263..782c746ffa5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -10887,15 +10887,17 @@ extension PlatformWalletPersistenceHandler { /// 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 contact's - /// watch-only chain is skipped — its coins are the contact's; a coin - /// below `minConfirmations` at `tipHeight` is skipped (the inventory + /// 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], @@ -10926,7 +10928,21 @@ extension PlatformWalletPersistenceHandler { counts.alreadyPresent += 1 continue } - guard let account = findAccountRow(walletId: walletId, key: row.account) else { + let accountRow: PersistentAccount? + do { + accountRow = try findAccountRow(walletId: walletId, key: row.account) + } catch { + SDKLogger.event( + "persistence_txo_reconcile_read_failed", + category: .persistence, + severity: .error, + fields: ["wallet_reference": .reference(walletId)], + error: error + ) + backgroundContext.rollback() + return .failed + } + guard let account = accountRow else { counts.skippedUnresolvedAccount += 1 continue } @@ -11137,8 +11153,9 @@ extension PlatformWalletPersistenceHandler { /// 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. - private func findAccountRow(walletId: Data, key: CoreAccountKey) -> PersistentAccount? { + /// 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( @@ -11148,7 +11165,7 @@ extension PlatformWalletPersistenceHandler { && $0.accountIndex == accountIndex } ) - let rows = (try? backgroundContext.fetch(descriptor)) ?? [] + 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 { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index c24f81295da..4ce020b4e4f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -30,7 +30,8 @@ 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() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 1305f5d74b5..73d458ddf33 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -100,9 +100,15 @@ final class CoreTxoReconcileTests: XCTestCase { private func txid(_ byte: UInt8) -> Data { Data(repeating: byte, count: 32) } - private func makeHandler() throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + private func makeHandler( + modelFetcher: ModelFetching = LiveModelFetcher() + ) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { let container = try DashModelContainer.createInMemory() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, + network: .testnet, + modelFetcher: modelFetcher + ) return (handler, container) } @@ -461,6 +467,27 @@ final class CoreTxoReconcileTests: XCTestCase { 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")) + } + // MARK: 4. Nothing runs before the scan is complete func testTheSteadyStateGateRefusesAnUnfinishedScan() { From 0b87b4bba66be35b70eeab90b2d2fc7b6a12422d Mon Sep 17 00:00:00 2001 From: Bartosz Rozwarski Date: Fri, 11 Sep 2026 07:27:36 +0200 Subject: [PATCH 12/12] fix(swift-sdk): a failed TXO lookup fails the reconcile step instead of reading as a miss `fetchTxoRow` turned a failed fetch into `nil`; in the heal pass that read as "the store lacks this coin" (insert), in the classify pass as "the row went stale" (`.done`). Both passes now read through the throwing `fetchTxoRowChecked` (the `ModelFetching` seam), and a failed read logs `persistence_txo_reconcile_read_failed`, rolls back and returns `.failed` through one helper shared with the account lookup. Round writers keep the `nil` behaviour through `fetchTxoRow`, now a `try?` over the same core. `FetchFaultInjector` can fault the faulted type after serving N reads, so the classify test faults the per-row lookup behind a served page read. Co-Authored-By: Claude Fable 5.1 --- .../PlatformWalletPersistenceHandler.swift | 52 ++++++++++++++----- .../AssetLockSpendVisibilityTests.swift | 14 ++++- .../CoreTxoReconcileTests.swift | 43 +++++++++++++++ 3 files changed, 95 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 782c746ffa5..e5e67eb4ab1 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2369,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 } @@ -2377,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 @@ -10924,7 +10932,13 @@ extension PlatformWalletPersistenceHandler { continue } let outpoint = row.outpoint - if fetchTxoRow(outpoint: outpoint) != nil { + let present: PersistentTxo? + do { + present = try fetchTxoRowChecked(outpoint: outpoint) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } + if present != nil { counts.alreadyPresent += 1 continue } @@ -10932,15 +10946,7 @@ extension PlatformWalletPersistenceHandler { do { accountRow = try findAccountRow(walletId: walletId, key: row.account) } catch { - SDKLogger.event( - "persistence_txo_reconcile_read_failed", - category: .persistence, - severity: .error, - fields: ["wallet_reference": .reference(walletId)], - error: error - ) - backgroundContext.rollback() - return .failed + return reconcileReadFailed(walletId: walletId, error: error) } guard let account = accountRow else { counts.skippedUnresolvedAccount += 1 @@ -11124,7 +11130,13 @@ extension PlatformWalletPersistenceHandler { case .notOwned: counts.notOwned += 1 case .knownUncredited: - guard let txo = fetchTxoRow(outpoint: row.outpoint), !txo.isSpent else { + 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 } @@ -11180,6 +11192,22 @@ extension PlatformWalletPersistenceHandler { } } + /// 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. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index 4ce020b4e4f..b9aca49a5bc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -36,11 +36,16 @@ final class FetchFaultInjector: ModelFetching, @unchecked Sendable { 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. @@ -56,8 +61,13 @@ 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/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 73d458ddf33..eac6a57af07 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -488,6 +488,49 @@ final class CoreTxoReconcileTests: XCTestCase { 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() {