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 2943926d9f8..5e5c28d9546 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -2306,9 +2306,9 @@ mod tests { /// `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`. +/// Returned in `platform_wallet_txo_reconcile_heal_page`'s rows; every +/// row's `address` and `script_pubkey` allocations belong to Rust and are +/// released by `platform_wallet_txo_reconcile_heal_page_free`. #[repr(C)] #[derive(Debug)] pub struct WalletUtxoEntryFFI { @@ -2334,23 +2334,8 @@ pub struct WalletUtxoEntryFFI { 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 +/// One store row handed to +/// `platform_wallet_txo_reconcile_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. @@ -2369,18 +2354,22 @@ pub struct OutpointOwnershipQueryFFI { pub script_pubkey_len: usize, } -/// `platform_wallet_classify_outpoints` answer: the engine has no opinion +/// `platform_wallet_txo_reconcile_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 +/// `platform_wallet_txo_reconcile_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. +/// `platform_wallet_txo_reconcile_classify_outpoints` answer: the owning account knows +/// the funding txid, owns the script, and does not hold the coin, AND a +/// funds account holds a record whose transaction spends the outpoint in a +/// block the wallet's last applied ChainLock covers (or held one the +/// ChainLock already dropped), so no fork can give the coin back. A coin +/// whose spender is not final yet — mempool, InstantSend-locked, or in a +/// block no ChainLock covers — is [`OUTPOINT_CLASS_UNKNOWN`]. 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 +/// `platform_wallet_txo_reconcile_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/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 762a8e35668..6b40e25b42c 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1383,6 +1383,56 @@ impl From for PlatformWalletFFIResult { } } +/// Render a caught panic payload as a human-readable string. +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the +/// operation's contract-appropriate result `code` (with `guidance` appended to the message) +/// instead of letting it reach the `extern "C"` frame. +/// +/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer +/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but +/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the +/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it +/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, +/// note bookkeeping, the SDK) re-panics right here inside the export. +/// +/// The `code` must be chosen per operation to preserve that operation's result contract — a +/// panic can strike after side effects (note reservation, a broadcast) have happened, so the +/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See +/// `shielded_send::catch_spend_panic` and the per-export call sites. +/// +/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and +/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles +/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size +/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the +/// process before this guard can see it. +pub(crate) fn catch_panic_to_code( + operation: &str, + code: PlatformWalletFFIResultCode, + guidance: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { + Ok(result) => result, + Err(payload) => PlatformWalletFFIResult::err( + code, + format!( + "{operation} panicked: {}. {guidance}", + panic_payload_message(payload.as_ref()) + ), + ), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 4734b414279..11a36b71023 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -91,6 +91,7 @@ pub mod token_persistence; pub mod tokens; pub mod tracked_masternode; pub mod tx_decode; +pub mod txo_reconcile; pub mod types; pub mod utils; pub mod wallet; @@ -170,6 +171,7 @@ pub use spv::*; pub use token_persistence::*; pub use tokens::*; pub use tx_decode::*; +pub use txo_reconcile::*; pub use types::*; pub use utils::*; pub use wallet::*; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dd0675fca9f..2f1c65f59ec 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1583,7 +1583,11 @@ mod tests { #[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; + use crate::txo_reconcile::{ + platform_wallet_txo_reconcile_classify_outpoints, + platform_wallet_txo_reconcile_held_verdicts_free, TxoReconcileHeldVerdictsFFI, + }; + use platform_wallet::manager::txo_reconcile::TxoReconcileFinish; let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); let callbacks = persistence_callbacks(); let event_cbs = event_callbacks(); @@ -1617,26 +1621,52 @@ mod tests { // 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)]; + // The classifier answers only a wallet that holds a run claim, and + // holds nothing otherwise; every slot still reads Unknown. let mut out = [0xFFu8; 3]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); let result = unsafe { - platform_wallet_classify_outpoints( + platform_wallet_txo_reconcile_classify_outpoints( handle, wallet_id.as_ptr(), queries.as_ptr(), queries.len(), out.as_mut_ptr(), + &mut false, + &mut held, ) }; 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" + PlatformWalletFFIResultCode::ErrorWalletOperation ); + assert_eq!(out, [0, 0, 0]); + assert!(held.is_null()); + + // With a run claim, an unmappable tag degrades that query to + // Unknown rather than failing the batch, every slot is answered and + // the guard comes back. + let reconciler = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| m.txo_reconciler()) + .expect("manager"); + assert!(reconciler.claim_without_gate(&wallet_id, 0).is_some()); + let mut out = [0xFFu8; 3]; + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + queries.as_ptr(), + queries.len(), + out.as_mut_ptr(), + &mut false, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(out, [0, 0, 0]); + assert!(!held.is_null()); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); let destroyed = unsafe { platform_wallet_manager_destroy(handle) }; assert_eq!(destroyed.code, PlatformWalletFFIResultCode::Success); } diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index f4c0ab87fc3..12c330a6340 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -20,16 +20,18 @@ use platform_wallet::manager::accessors::{ AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, AccountMetadataSnapshot, AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, - PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, - TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + OutpointClass, OutpointOwnershipQuery, PlatformAddressProviderStateSnapshot, + PlatformAddressSyncConfigSnapshot, TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + WalletUtxoRow, }; use crate::check_ptr; use crate::core_wallet_types::{ AccountAddressPoolEntryFFI, AccountMetadataFFI, AccountTransactionEntryFFI, AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, - IdentitySyncConfigFFI, IdentityWalletStateFFI, PlatformAddressProviderStateFFI, - PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, WalletIdentityRowFFI, + IdentitySyncConfigFFI, IdentityWalletStateFFI, OutpointOwnershipQueryFFI, + PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, + WalletIdentityRowFFI, WalletUtxoEntryFFI, }; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; @@ -982,174 +984,89 @@ fn account_spec_from_raw_tags( } } -/// 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]); +/// The C row for one inventory row. Its `address` and `script_pubkey` +/// allocations move to the caller and are released by +/// [`release_wallet_utxo_entry`]. +pub(crate) fn wallet_utxo_entry_ffi(row: WalletUtxoRow) -> WalletUtxoEntryFFI { + use crate::core_wallet_types::{account_type_to_tags, OutPointFFI}; - let after: Option = if cursor.is_null() { - None + 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 { - 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))) + Box::into_raw(row.script_pubkey.into_boxed_slice()) as *mut u8 }; - - 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 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: 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, } - - 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`. +/// Release the allocations one [`wallet_utxo_entry_ffi`] row owns (not the +/// row itself). /// /// # 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; +/// `entry` must come from [`wallet_utxo_entry_ffi`], released once. +pub(crate) unsafe fn release_wallet_utxo_entry(entry: &WalletUtxoEntryFFI) { + if !entry.address.is_null() { + let _ = CString::from_raw(entry.address); } - 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, - )); - } + if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.script_pubkey, + entry.script_pubkey_len, + )); } - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); } -/// Classify `count` store rows for `wallet_id` — see the -/// `OUTPOINT_CLASS_*` constants; `out_classes[i]` answers `queries[i]`, so -/// `out_classes` must have room for `count` bytes. A query whose account -/// tag this build cannot map keeps `OUTPOINT_CLASS_UNKNOWN` in its slot -/// and the remaining queries are still classified; the call fails only on -/// a bad handle or pointer. Cost is `count × accounts`, never the size of -/// the inventory. Same lock discipline as -/// `platform_wallet_wallet_utxos_page`. +/// The error for a manager handle that is not live. +pub(crate) fn invalid_manager_handle() -> PlatformWalletFFIResult { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ) +} + +/// The marshalling of `platform_wallet_txo_reconcile_classify_outpoints`: +/// read `count` store rows, hand the ones this build can map to +/// `classify`, which makes the one engine call, and write its answers back +/// to their positions in `out_classes`. A query whose account tag this +/// build cannot map keeps `OUTPOINT_CLASS_UNKNOWN` in its slot. An `Err` +/// from `classify` is returned as is. /// /// # 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, +pub(crate) unsafe fn classify_outpoint_rows( wallet_id: *const u8, - queries: *const crate::core_wallet_types::OutpointOwnershipQueryFFI, + queries: *const OutpointOwnershipQueryFFI, count: usize, out_classes: *mut u8, + classify: impl FnOnce( + [u8; 32], + Vec, + ) -> Result, PlatformWalletFFIResult>, ) -> PlatformWalletFFIResult { - use platform_wallet::manager::accessors::{classify_outpoints, OutpointOwnershipQuery}; - check_ptr!(wallet_id); if count == 0 { return PlatformWalletFFIResult::ok(); @@ -1165,7 +1082,7 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( // 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()); + out.fill(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) @@ -1198,17 +1115,9 @@ pub unsafe extern "C" fn platform_wallet_classify_outpoints( }); } - 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 classes = match classify(wid, owned) { + Ok(classes) => classes, + Err(error) => return error, }; for (position, class) in positions.into_iter().zip(classes) { out[position] = class.as_u8(); diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 232d8a9a9c8..774ec13c2d2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -681,56 +681,6 @@ fn is_preserved_signer_key_unavailable(error: &PlatformWalletError) -> bool { ) } -/// Render a caught panic payload as a human-readable string. -fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { - if let Some(s) = payload.downcast_ref::<&'static str>() { - (*s).to_string() - } else if let Some(s) = payload.downcast_ref::() { - s.clone() - } else { - "".to_string() - } -} - -/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the -/// operation's contract-appropriate result `code` (with `guidance` appended to the message) -/// instead of letting it reach the `extern "C"` frame. -/// -/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer -/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but -/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the -/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it -/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, -/// note bookkeeping, the SDK) re-panics right here inside the export. -/// -/// The `code` must be chosen per operation to preserve that operation's result contract — a -/// panic can strike after side effects (note reservation, a broadcast) have happened, so the -/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See -/// [`catch_spend_panic`] and the per-export call sites. -/// -/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and -/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles -/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size -/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the -/// process before this guard can see it. -pub(crate) fn catch_panic_to_code( - operation: &str, - code: PlatformWalletFFIResultCode, - guidance: &str, - body: impl FnOnce() -> PlatformWalletFFIResult, -) -> PlatformWalletFFIResult { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { - Ok(result) => result, - Err(payload) => PlatformWalletFFIResult::err( - code, - format!( - "{operation} panicked: {}. {guidance}", - panic_payload_message(payload.as_ref()) - ), - ), - } -} - /// Post-panic guidance for the note-spending exports. Paired with /// `ErrorShieldedSpendUnconfirmed` in [`catch_spend_panic`]. const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast — do NOT \ diff --git a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs index 8cc05ce8f8b..c5c0bfb3ec9 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -13,12 +13,10 @@ use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle}; use crate::check_ptr; use crate::core_wallet_types::OutPointFFI; -use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::error::{catch_panic_to_code, PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::Handle; use crate::runtime::block_on_worker; -use crate::shielded_send::{ - catch_funding_panic, catch_panic_to_code, resolve_wallet_and_coordinator, -}; +use crate::shielded_send::{catch_funding_panic, resolve_wallet_and_coordinator}; use std::sync::Arc; diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs new file mode 100644 index 00000000000..1efff04981a --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -0,0 +1,1250 @@ +//! C ABI over `platform_wallet::manager::txo_reconcile`: the schedule, +//! start gate, heal verdicts, spend verdicts and stale-page retry of the +//! host TXO-store reconcile (dashpay/platform#4638). Each export forwards +//! to one Rust operation; the decisions, thresholds, cadence, the page +//! budget and resume point, and the lock that keeps a spend verdict true +//! until it is written live in `platform-wallet`. + +use std::panic::{catch_unwind, AssertUnwindSafe}; + +use platform_wallet::manager::accessors::OutpointClass; +use platform_wallet::manager::txo_reconcile::{ + txo_reconcile_retry_stale_page, TxoReconcileFinish, TxoReconcileHeldVerdicts, TxoReconcileRead, + TxoReconcileStart, +}; + +use crate::check_ptr; +use crate::core_wallet_types::{OutpointOwnershipQueryFFI, WalletUtxoEntryFFI}; +use crate::error::{catch_panic_to_code, PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; +use crate::manager_diagnostics::{ + classify_outpoint_rows, invalid_manager_handle, release_wallet_utxo_entry, + wallet_utxo_entry_ffi, +}; +use crate::runtime::block_on_worker; +use crate::types::IdentifierArray; + +/// What a host should know after a reconcile export panicked: nothing was +/// written to its store by this call. +const RECONCILE_PANIC_GUIDANCE: &str = "The reconcile wrote nothing to the store in this call; \ + stop the run and let the schedule run it again."; + +/// Run a reconcile export's body with a panic turned into an +/// `ErrorWalletOperation` result instead of unwinding into the C caller — +/// JNI's panic guard sits on the far side of this ABI and never sees it. +/// Every claim, guard and allocation the body took is released while it +/// unwinds. +fn catch_reconcile_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorWalletOperation, + RECONCILE_PANIC_GUIDANCE, + body, + ) +} + +/// [`catch_reconcile_panic`] for an export that hands out an engine guard +/// in `*out_held`: a failure holds nothing, even one that struck after the +/// guard was handed out. +/// +/// # Safety +/// `out_held` is writable, and null or a guard `body` handed out when the +/// body returns. +unsafe fn catch_held_read_panic( + operation: &str, + out_held: *mut *mut TxoReconcileHeldVerdictsFFI, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + let result = catch_reconcile_panic(operation, body); + if result.code != PlatformWalletFFIResultCode::Success { + platform_wallet_txo_reconcile_held_verdicts_free(*out_held); + *out_held = std::ptr::null_mut(); + } + result +} + +/// `platform_wallet_manager_txo_reconcile_begin`: the run may start. +pub const TXO_RECONCILE_GATE_RUN: u8 = 0; +/// `platform_wallet_manager_txo_reconcile_begin`: the manager does not hold +/// the wallet. +pub const TXO_RECONCILE_SKIP_WALLET_UNKNOWN: u8 = 1; +/// `platform_wallet_manager_txo_reconcile_begin`: a run for this wallet is +/// already in flight. +pub const TXO_RECONCILE_SKIP_ALREADY_RUNNING: u8 = 2; +/// `platform_wallet_manager_txo_reconcile_begin`: SPV is not running, or +/// not in its steady state. +pub const TXO_RECONCILE_SKIP_NOT_STEADY_STATE: u8 = 3; +/// `platform_wallet_manager_txo_reconcile_begin`: the progress carries no +/// scan tip. +pub const TXO_RECONCILE_SKIP_TIP_UNAVAILABLE: u8 = 4; +/// `platform_wallet_manager_txo_reconcile_begin`: the manager latched a +/// sync fault; rows are missing by design and a rescan is pending. +pub const TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED: u8 = 5; +/// `platform_wallet_manager_txo_reconcile_begin`: the wallet's scan +/// watermark trails the scan tip; it is still being scanned. +pub const TXO_RECONCILE_SKIP_WALLET_BEHIND_TIP: u8 = 6; + +/// `platform_wallet_manager_txo_reconcile_finish`'s `outcome`: the run did +/// not go ahead; the wallet's cadence keeps the restart its claim stamped. +pub const TXO_RECONCILE_FINISH_NOT_RUN: u8 = 0; +/// `platform_wallet_manager_txo_reconcile_finish`'s `outcome`: the run went +/// ahead, even if it stopped early; the cadence restarts now, unless the +/// run used its page budget with the cycle unfinished, which leaves the +/// wallet due on the next poll. +pub const TXO_RECONCILE_FINISH_RAN: u8 = 1; +/// `platform_wallet_manager_txo_reconcile_finish`'s `outcome`: the run +/// stopped because the host's store deferred it (a persistence round was +/// open) and the page was not applied; the wallet stays due, so the next +/// poll runs it again. +pub const TXO_RECONCILE_FINISH_DEFERRED: u8 = 2; + +/// `TxoHealRowFFI::verdict`: no script or no address; the store could not +/// file the row. +pub const TXO_HEAL_VERDICT_INVALID: u8 = 0; +/// `TxoHealRowFFI::verdict`: not confirmed by the engine, or not deep +/// enough yet; it ages into a later run. +pub const TXO_HEAL_VERDICT_IMMATURE: u8 = 1; +/// `TxoHealRowFFI::verdict`: may be inserted unspent, if the store lacks +/// the outpoint and resolves the account that owns it. +pub const TXO_HEAL_VERDICT_ELIGIBLE: u8 = 2; + +/// `*out_start` of `platform_wallet_manager_txo_reconcile_begin`: a new +/// cycle, at the heal pass's first page. +pub const TXO_RECONCILE_START_HEAL: u8 = 0; +/// The heal pass, where an earlier run stopped (the reconciler keeps the +/// cursor; the host passes none). +pub const TXO_RECONCILE_START_HEAL_RESUMED: u8 = 1; +/// Straight to the classify pass, after the store cursor +/// `*out_store_after` (empty: the first row). +pub const TXO_RECONCILE_START_CLASSIFY: u8 = 2; + +/// The longest store cursor +/// `platform_wallet_txo_reconcile_classify_page_applied` keeps, and the +/// size of `platform_wallet_manager_txo_reconcile_begin`'s +/// `out_store_after` buffer. +/// +/// A literal, so the generated C header carries it. +pub const TXO_RECONCILE_STORE_CURSOR_MAX: usize = 64; +const _: () = assert!( + TXO_RECONCILE_STORE_CURSOR_MAX + == platform_wallet::manager::txo_reconcile::TXO_RECONCILE_STORE_CURSOR_MAX +); + +/// One row of a heal page: an inventory row and whether the store may heal +/// it in. Its `utxo` allocations are released by +/// `platform_wallet_txo_reconcile_heal_page_free`. +#[repr(C)] +#[derive(Debug)] +pub struct TxoHealRowFFI { + pub utxo: WalletUtxoEntryFFI, + /// One of the `TXO_HEAL_VERDICT_*` constants. + pub verdict: u8, +} + +/// The wallets whose reconcile is due now. Call on every SPV progress poll, +/// running or not: the poll is the schedule's clock, and a stopped client +/// arms the next steady-state edge. Reporting a wallet consumes nothing — +/// its cadence restarts when `platform_wallet_manager_txo_reconcile_begin` +/// claims it — so one the host skips is reported again on the next poll. +/// `*out_wallet_ids` is released by +/// `platform_wallet_identifier_array_free`. +/// +/// # Safety +/// `out_wallet_ids` must be writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( + manager_handle: Handle, + out_wallet_ids: *mut IdentifierArray, +) -> PlatformWalletFFIResult { + catch_reconcile_panic("platform_wallet_manager_txo_reconcile_due_wallets", || { + check_ptr!(out_wallet_ids); + *out_wallet_ids = IdentifierArray::empty(); + // Looked up under the registry guard, waited on outside it — see + // `platform_wallet_manager_sync_progress`. + let Some(reconciler) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()) + else { + return invalid_manager_handle(); + }; + let due = block_on_worker(async move { reconciler.due_wallets().await }); + *out_wallet_ids = IdentifierArray::from_hashes(due); + PlatformWalletFFIResult::ok() + }) +} + +/// Claim `wallet_id` for one reconcile run and check that it may start. +/// `*out_gate` is [`TXO_RECONCILE_GATE_RUN`] or one of the +/// `TXO_RECONCILE_SKIP_*` reasons; on `RUN` the claim holds until +/// `platform_wallet_manager_txo_reconcile_finish`. The claim keeps the +/// scan tip the run is measured against; the host never sees it. A skip +/// holds no claim. Either way the wallet's cadence restarts now, unless +/// another run already held it — or the skip is +/// [`TXO_RECONCILE_SKIP_NOT_STEADY_STATE`], which leaves the wallet due as +/// soon as SPV is steady again, or [`TXO_RECONCILE_SKIP_WALLET_UNKNOWN`], +/// which leaves a wallet registered under the id due at once. +/// +/// On `RUN`, `*out_start` (a `TXO_RECONCILE_START_*`) says where the run +/// starts, from where the wallet's last run stopped; for +/// [`TXO_RECONCILE_START_CLASSIFY`] the store cursor to start after is +/// written to `out_store_after` (`*out_store_after_len` bytes; 0 is the +/// store's first row), exactly as the host handed it to +/// `platform_wallet_txo_reconcile_classify_page_applied`. The run may read +/// a fixed budget of pages; see `platform_wallet_txo_reconcile_heal_page`. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; `out_store_after` is writable +/// for [`TXO_RECONCILE_STORE_CURSOR_MAX`] bytes; the other out-pointers are +/// writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( + manager_handle: Handle, + wallet_id: *const u8, + out_gate: *mut u8, + out_start: *mut u8, + out_store_after: *mut u8, + out_store_after_len: *mut usize, +) -> PlatformWalletFFIResult { + catch_reconcile_panic("platform_wallet_manager_txo_reconcile_begin", || { + check_ptr!(wallet_id); + check_ptr!(out_gate); + check_ptr!(out_start); + check_ptr!(out_store_after); + check_ptr!(out_store_after_len); + *out_gate = TXO_RECONCILE_SKIP_WALLET_UNKNOWN; + *out_start = TXO_RECONCILE_START_HEAL; + *out_store_after_len = 0; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(reconciler) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()) + else { + return invalid_manager_handle(); + }; + match block_on_worker(async move { reconciler.begin(&wid).await }) { + Ok(run) => { + *out_gate = TXO_RECONCILE_GATE_RUN; + *out_start = match run.start { + TxoReconcileStart::Heal { resumed: false } => TXO_RECONCILE_START_HEAL, + TxoReconcileStart::Heal { resumed: true } => TXO_RECONCILE_START_HEAL_RESUMED, + TxoReconcileStart::Classify { after } => { + // The reconciler keeps no cursor longer than this. + let len = after.len().min(TXO_RECONCILE_STORE_CURSOR_MAX); + std::ptr::copy_nonoverlapping(after.as_ptr(), out_store_after, len); + *out_store_after_len = len; + TXO_RECONCILE_START_CLASSIFY + } + }; + } + Err(skip) => *out_gate = skip.as_u8(), + } + PlatformWalletFFIResult::ok() + }) +} + +/// Release the claim a `RUN` from +/// `platform_wallet_manager_txo_reconcile_begin` granted. `outcome` is how +/// the run ended, one of the `TXO_RECONCILE_FINISH_*` constants, which +/// says when the wallet is due again: [`TXO_RECONCILE_FINISH_RAN`] +/// restarts its cadence (unless the run used its budget mid-cycle), +/// [`TXO_RECONCILE_FINISH_DEFERRED`] leaves it due on the next poll, and +/// [`TXO_RECONCILE_FINISH_NOT_RUN`] keeps the claim's stamp. Any other +/// value counts as `RAN`: the claim is still released, and the cadence +/// restarts, so a host that sends a code this build does not know never +/// strands the wallet's claim or retries it on every poll. Also releases, as a backstop, an engine guard the +/// host failed to free — but this resolves the manager handle, so a host +/// frees each page's guard first +/// (`platform_wallet_txo_reconcile_held_verdicts_free`). +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_finish( + manager_handle: Handle, + wallet_id: *const u8, + outcome: u8, +) -> PlatformWalletFFIResult { + catch_reconcile_panic("platform_wallet_manager_txo_reconcile_finish", || { + check_ptr!(wallet_id); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let outcome = finish_outcome(outcome); + let Some(()) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.txo_reconciler().finish(&wid, outcome)) + else { + return invalid_manager_handle(); + }; + PlatformWalletFFIResult::ok() + }) +} + +/// The [`TxoReconcileFinish`] a `TXO_RECONCILE_FINISH_*` code names; an +/// unknown code is `Ran`. +fn finish_outcome(code: u8) -> TxoReconcileFinish { + match code { + TXO_RECONCILE_FINISH_NOT_RUN => TxoReconcileFinish::NotRun, + TXO_RECONCILE_FINISH_DEFERRED => TxoReconcileFinish::Deferred, + _ => TxoReconcileFinish::Ran, + } +} + +/// One page of the wallet's UTXO inventory across every funds account, in +/// `(account, outpoint)` order — `limit` is clamped natively (0 = default +/// page, never more than the engine's maximum) — each row with its +/// `TXO_HEAL_VERDICT_*`, for the reconcile run the wallet holds a claim +/// for. The run keeps its own place: the page starts after the last page +/// reported applied (`platform_wallet_txo_reconcile_heal_page_applied`), +/// or where the wallet's last run stopped, so the caller passes no cursor; +/// a page read again before it is reported applied is the same page. +/// Confirmations are counted to the scan tip the run's +/// `platform_wallet_manager_txo_reconcile_begin` measured, or to the +/// wallet's processed height under this page's guard when that is lower; +/// the caller passes no height. `*out_has_more` is whether another heal +/// page follows; it is false on an empty page. Contact watch-only coins +/// are never offered. The rows are released by +/// `platform_wallet_txo_reconcile_heal_page_free`. +/// +/// The engine's wallet-manager read lock stays held when this returns, in +/// `*out_held`, so no fork can drop a coin the page offers before the host +/// has written it: a row healed after such a fork would sit unspent in the +/// store for a coin the engine no longer owns. Release `*out_held` with +/// `platform_wallet_txo_reconcile_held_verdicts_free` once the page is +/// written, under the same rules as +/// `platform_wallet_txo_reconcile_classify_outpoints`' guard. +/// +/// Each call takes one page of the run's budget. `*out_budget_spent` is +/// true, with nothing read and nothing held, once the run has used it: +/// the host stops the run, and the wallet's next run picks up where it +/// stopped. +/// +/// Fails with `*out_held` null — nothing held — when the wallet holds no +/// run claim. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; the out-pointers are writable. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page( + manager_handle: Handle, + wallet_id: *const u8, + limit: usize, + out_rows: *mut *const TxoHealRowFFI, + out_count: *mut usize, + out_has_more: *mut bool, + out_budget_spent: *mut bool, + out_held: *mut *mut TxoReconcileHeldVerdictsFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_held); + *out_held = std::ptr::null_mut(); + catch_held_read_panic("platform_wallet_txo_reconcile_heal_page", out_held, || { + check_ptr!(wallet_id); + check_ptr!(out_rows); + check_ptr!(out_count); + check_ptr!(out_has_more); + check_ptr!(out_budget_spent); + *out_rows = std::ptr::null(); + *out_count = 0; + *out_has_more = false; + *out_budget_spent = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + + // Resolved under the registry guard, waited on outside it, with no + // guard held — see `platform_wallet_txo_reconcile_classify_outpoints`. + let Some(reconciler) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()) + else { + return invalid_manager_handle(); + }; + let read = block_on_worker(async move { reconciler.heal_page_held(&wid, limit).await }); + let ((rows, has_more), held) = match read { + Some(TxoReconcileRead::Page(page, held)) => (page, held), + Some(TxoReconcileRead::BudgetSpent) => { + *out_budget_spent = true; + return PlatformWalletFFIResult::ok(); + } + None => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "the wallet holds no reconcile run claim".to_string(), + ); + } + }; + *out_held = Box::into_raw(Box::new(TxoReconcileHeldVerdictsFFI { _held: held })); + *out_has_more = has_more; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } + let entries: Vec = rows + .into_iter() + .map(|(row, verdict)| TxoHealRowFFI { + utxo: wallet_utxo_entry_ffi(row), + verdict: verdict.as_u8(), + }) + .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_txo_reconcile_heal_page`. +/// +/// # Safety +/// `rows`/`count` must be exactly what one call returned, released once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_free( + rows: *mut TxoHealRowFFI, + count: usize, +) { + if rows.is_null() || count == 0 { + return; + } + // No result to carry a panic: it is contained here, at worst leaking + // the page, rather than crossing the C ABI. + let _ = catch_unwind(AssertUnwindSafe(|| { + for row in std::slice::from_raw_parts(rows, count) { + release_wallet_utxo_entry(&row.utxo); + } + let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); + })); +} + +/// The engine guard one page's heal or spend verdicts are held under — see +/// `platform_wallet_txo_reconcile_heal_page` and +/// `platform_wallet_txo_reconcile_classify_outpoints`. Opaque; released by +/// `platform_wallet_txo_reconcile_held_verdicts_free`. +pub struct TxoReconcileHeldVerdictsFFI { + _held: TxoReconcileHeldVerdicts, +} + +/// Classify `count` store rows of `wallet_id` for the spend pass of the +/// reconcile run it holds a claim for — see the `OUTPOINT_CLASS_*` +/// constants; `out_classes[i]` answers `queries[i]`, and a query whose +/// account tag this build cannot map keeps `OUTPOINT_CLASS_UNKNOWN` — and +/// keep the engine's wallet-manager read lock held when this returns, in +/// `*out_held`, so no fork can drop a spender these answers name before +/// the host has written them. Only `OUTPOINT_CLASS_KNOWN_UNCREDITED` marks +/// a row spent. +/// +/// Release `*out_held` with +/// `platform_wallet_txo_reconcile_held_verdicts_free` as soon as the page +/// is applied, and before the next call for this wallet or its +/// `platform_wallet_manager_txo_reconcile_finish`: those resolve the +/// manager handle, and the handle registry must never be waited for while +/// the guard is held (every other path takes the registry before the +/// engine lock). Finish releases a guard the host failed to, as a +/// backstop; so does manager destroy, but only once its SPV stop has +/// succeeded — while SPV may still apply a fork the guard stays with this +/// handle, and destroy's SPV stop waits (under its deadline) for the host's +/// write. Call this outside the host's +/// persistence-callback exclusion and never from inside a persistence +/// callback: the engine calls those callbacks under its write lock. The +/// registry is read only to resolve the manager, with no guard held; the +/// wait for the new guard happens after it is released. +/// +/// Classify every store page the run reads, an empty one (`count` 0) +/// too: each call takes one page of the run's budget and names the page +/// `platform_wallet_txo_reconcile_classify_page_applied` reports. Once the +/// run has used its budget, `*out_budget_spent` is true, every class is +/// `OUTPOINT_CLASS_UNKNOWN` and nothing is held. +/// +/// Fails with `*out_held` null — nothing held — when the wallet holds no +/// run claim. +/// +/// # Safety +/// `wallet_id` points at 32 +/// readable bytes; `queries` points at `count` live entries whose script +/// pointers are valid for the call (or is anything when `count` is 0); +/// `out_classes` is writable for `count` bytes. `out_budget_spent` and +/// `out_held` are writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_outpoints( + manager_handle: Handle, + wallet_id: *const u8, + queries: *const OutpointOwnershipQueryFFI, + count: usize, + out_classes: *mut u8, + out_budget_spent: *mut bool, + out_held: *mut *mut TxoReconcileHeldVerdictsFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_held); + check_ptr!(out_budget_spent); + *out_held = std::ptr::null_mut(); + *out_budget_spent = false; + catch_held_read_panic( + "platform_wallet_txo_reconcile_classify_outpoints", + out_held, + || { + let classify = |wid: [u8; 32], owned: Vec<_>| { + let reconciler = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.txo_reconciler()) + .ok_or_else(invalid_manager_handle)?; + let asked = owned.len(); + match block_on_worker(async move { reconciler.classify_held(&wid, &owned).await }) { + Some(TxoReconcileRead::Page(classes, held)) => { + *out_held = + Box::into_raw(Box::new(TxoReconcileHeldVerdictsFFI { _held: held })); + Ok(classes) + } + Some(TxoReconcileRead::BudgetSpent) => { + *out_budget_spent = true; + Ok(vec![OutpointClass::Unknown; asked]) + } + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "the wallet holds no reconcile run claim".to_string(), + )), + } + }; + if count == 0 { + // `classify_outpoint_rows` answers an empty batch without a + // classification; the run still reads it, as one page. + check_ptr!(wallet_id); + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + return match classify(wid, Vec::new()) { + Ok(_) => PlatformWalletFFIResult::ok(), + Err(error) => error, + }; + } + classify_outpoint_rows(wallet_id, queries, count, out_classes, classify) + }, + ) +} + +/// Release the engine guard `platform_wallet_txo_reconcile_heal_page` or +/// `platform_wallet_txo_reconcile_classify_outpoints` handed out. Touches no handle registry and never blocks, so it can run +/// whatever else is queued on the registry or the engine. Null is a no-op. +/// +/// # Safety +/// `held` is null or a pointer that call returned, released exactly once. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_held_verdicts_free( + held: *mut TxoReconcileHeldVerdictsFFI, +) { + if !held.is_null() { + // No result to carry a panic: it is contained here rather than + // crossing the C ABI. + let _ = catch_unwind(AssertUnwindSafe(|| drop(Box::from_raw(held)))); + } +} + +/// The host has applied the heal page it last read +/// (`platform_wallet_txo_reconcile_heal_page`), an empty one included: the +/// run's next heal page starts after it, and so does the wallet's next run +/// if this one stops here. `*out_may_continue` is whether the run has +/// budget left for another page. Call it after freeing the page's guard: +/// it resolves the manager handle. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; `out_may_continue` is writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_applied( + manager_handle: Handle, + wallet_id: *const u8, + out_may_continue: *mut bool, +) -> PlatformWalletFFIResult { + catch_reconcile_panic("platform_wallet_txo_reconcile_heal_page_applied", || { + check_ptr!(wallet_id); + check_ptr!(out_may_continue); + *out_may_continue = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + let Some(may_continue) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.txo_reconciler().heal_page_applied(&wid) + }) else { + return invalid_manager_handle(); + }; + *out_may_continue = may_continue; + PlatformWalletFFIResult::ok() + }) +} + +/// The host has applied the store page it last classified +/// (`platform_wallet_txo_reconcile_classify_outpoints`). `is_last` says it +/// was the store's last page, which ends the cycle: the wallet's next run +/// starts again at the heal pass. Otherwise the next run starts at the +/// classify pass after the host's own store cursor `next_after` +/// (`next_after_len` bytes, at most [`TXO_RECONCILE_STORE_CURSOR_MAX`]; a +/// longer one is neither read nor kept, and the page still counts as +/// applied), handed back by +/// `platform_wallet_manager_txo_reconcile_begin` as it was given. +/// `*out_may_continue` is whether the run has budget left for another +/// page. Call it after freeing the page's guard. +/// +/// # Safety +/// `wallet_id` points at 32 readable bytes; `next_after` points at +/// `next_after_len` readable bytes (or is anything when that is 0, or when +/// `is_last`); `out_may_continue` is writable. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_page_applied( + manager_handle: Handle, + wallet_id: *const u8, + next_after: *const u8, + next_after_len: usize, + is_last: bool, + out_may_continue: *mut bool, +) -> PlatformWalletFFIResult { + catch_reconcile_panic( + "platform_wallet_txo_reconcile_classify_page_applied", + || { + check_ptr!(wallet_id); + check_ptr!(out_may_continue); + *out_may_continue = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); + // Borrowed, not copied: the reconciler drops a cursor longer + // than TXO_RECONCILE_STORE_CURSOR_MAX, so an oversized one costs + // no allocation. Nor is it read: it stands in as a static one + // the reconciler drops the same way, so no slice is formed over + // a length the host's buffer may not have (one past + // `isize::MAX` cannot be a slice at all). + static OVERSIZED: [u8; TXO_RECONCILE_STORE_CURSOR_MAX + 1] = + [0; TXO_RECONCILE_STORE_CURSOR_MAX + 1]; + let after: Option<&[u8]> = if is_last { + None + } else if next_after_len == 0 { + Some(&[]) + } else { + check_ptr!(next_after); + if next_after_len > TXO_RECONCILE_STORE_CURSOR_MAX { + Some(&OVERSIZED) + } else { + Some(std::slice::from_raw_parts(next_after, next_after_len)) + } + }; + let Some(may_continue) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| { + m.txo_reconciler().classify_page_applied(&wid, after) + }) + else { + return invalid_manager_handle(); + }; + *out_may_continue = may_continue; + PlatformWalletFFIResult::ok() + }, + ) +} + +/// Whether a reconcile run classifies a page again after it came back +/// stale for the `stale_in_a_row`-th time in a row (counting this one), or +/// stops incomplete. +#[no_mangle] +pub extern "C" fn platform_wallet_txo_reconcile_retry_stale_page(stale_in_a_row: u32) -> bool { + txo_reconcile_retry_stale_page(stale_in_a_row) +} + +#[cfg(test)] +mod tests { + use super::*; + + use platform_wallet::manager::txo_reconcile::{TxoHealVerdict, TxoReconcileSkip}; + + use crate::core_wallet_types::{OutPointFFI, OUTPOINT_CLASS_UNKNOWN}; + + #[test] + fn the_c_codes_match_the_rust_enums() { + for (code, skip) in [ + ( + TXO_RECONCILE_SKIP_WALLET_UNKNOWN, + TxoReconcileSkip::WalletUnknown, + ), + ( + TXO_RECONCILE_SKIP_ALREADY_RUNNING, + TxoReconcileSkip::AlreadyRunning, + ), + ( + TXO_RECONCILE_SKIP_NOT_STEADY_STATE, + TxoReconcileSkip::NotSteadyState, + ), + ( + TXO_RECONCILE_SKIP_TIP_UNAVAILABLE, + TxoReconcileSkip::TipUnavailable, + ), + ( + TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED, + TxoReconcileSkip::SyncFaultDetected, + ), + ( + TXO_RECONCILE_SKIP_WALLET_BEHIND_TIP, + TxoReconcileSkip::WalletBehindTip, + ), + ] { + assert_eq!(code, skip.as_u8(), "{skip:?}"); + assert_ne!(code, TXO_RECONCILE_GATE_RUN, "{skip:?}"); + } + for (code, outcome) in [ + (TXO_RECONCILE_FINISH_NOT_RUN, TxoReconcileFinish::NotRun), + (TXO_RECONCILE_FINISH_RAN, TxoReconcileFinish::Ran), + (TXO_RECONCILE_FINISH_DEFERRED, TxoReconcileFinish::Deferred), + ] { + assert_eq!(code, outcome.as_u8(), "{outcome:?}"); + assert_eq!(finish_outcome(code), outcome, "{outcome:?}"); + } + assert_eq!( + finish_outcome(3), + TxoReconcileFinish::Ran, + "an unknown outcome releases the claim and restarts the cadence" + ); + for (code, verdict) in [ + (TXO_HEAL_VERDICT_INVALID, TxoHealVerdict::Invalid), + (TXO_HEAL_VERDICT_IMMATURE, TxoHealVerdict::Immature), + (TXO_HEAL_VERDICT_ELIGIBLE, TxoHealVerdict::Eligible), + ] { + assert_eq!(code, verdict.as_u8(), "{verdict:?}"); + } + } + + #[test] + fn the_stale_page_export_forwards_the_retry_limit() { + assert!(platform_wallet_txo_reconcile_retry_stale_page(5)); + assert!(!platform_wallet_txo_reconcile_retry_stale_page(6)); + } + + #[test] + fn an_invalid_handle_is_an_error_and_leaves_the_out_params_initialized() { + let wallet_id = [0u8; 32]; + let mut gate = 0xFFu8; + let mut start = 0xFFu8; + let mut store_after = [0u8; TXO_RECONCILE_STORE_CURSOR_MAX]; + let mut store_after_len = 9usize; + let result = unsafe { + platform_wallet_manager_txo_reconcile_begin( + Handle::MAX, + wallet_id.as_ptr(), + &mut gate, + &mut start, + store_after.as_mut_ptr(), + &mut store_after_len, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(gate, TXO_RECONCILE_SKIP_WALLET_UNKNOWN); + assert_eq!(start, TXO_RECONCILE_START_HEAL); + assert_eq!(store_after_len, 0); + + let mut may_continue = true; + let result = unsafe { + platform_wallet_txo_reconcile_heal_page_applied( + Handle::MAX, + wallet_id.as_ptr(), + &mut may_continue, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(!may_continue); + let mut may_continue = true; + let result = unsafe { + platform_wallet_txo_reconcile_classify_page_applied( + Handle::MAX, + wallet_id.as_ptr(), + std::ptr::null(), + 0, + true, + &mut may_continue, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(!may_continue); + + let result = unsafe { + platform_wallet_manager_txo_reconcile_finish( + Handle::MAX, + wallet_id.as_ptr(), + TXO_RECONCILE_FINISH_RAN, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + + let mut ids = IdentifierArray::empty(); + let result = + unsafe { platform_wallet_manager_txo_reconcile_due_wallets(Handle::MAX, &mut ids) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(ids.items.is_null()); + assert_eq!(ids.count, 0); + + let mut rows: *const TxoHealRowFFI = std::ptr::null(); + let mut count = 0usize; + let mut has_more = true; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::dangling_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_heal_page( + Handle::MAX, + wallet_id.as_ptr(), + 0, + &mut rows, + &mut count, + &mut has_more, + &mut false, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert!(rows.is_null()); + assert_eq!(count, 0); + assert!(!has_more); + assert!(held.is_null(), "nothing is held on a failure"); + + let script = [0x76u8, 0xa9, 0x14]; + let query = OutpointOwnershipQueryFFI { + type_tag: 0, + 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(), + }; + let mut classes = [0xFFu8; 1]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::dangling_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + Handle::MAX, + wallet_id.as_ptr(), + &query, + 1, + classes.as_mut_ptr(), + &mut false, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); + assert_eq!(classes, [OUTPOINT_CLASS_UNKNOWN]); + assert!(held.is_null(), "nothing is held on a failure"); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + } + + /// The run's place and budget across the exports: an empty classify + /// page still takes a page and holds a guard, an applied store cursor + /// comes back from the next run's start, and a run that has used its + /// budget is told so with nothing held. + #[test] + fn the_applied_exports_move_the_runs_place_and_the_budget_stops_it() { + use platform_wallet::manager::txo_reconcile::{ + TxoReconcileStart, TXO_RECONCILE_PAGE_BUDGET, + }; + + use crate::test_fixtures::mock_manager; + + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(mock_manager()); + let wallet_id = [0x6cu8; 32]; + let reconciler = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| m.txo_reconciler()) + .expect("manager"); + let classify_empty = || { + let mut spent = true; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + &mut spent, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + (spent, !held.is_null()) + }; + + assert!(reconciler.claim_without_gate(&wallet_id, 0).is_some()); + assert_eq!(classify_empty(), (false, true), "a page, held"); + let cursor = [0x36u8; 36]; + let mut may_continue = false; + let result = unsafe { + platform_wallet_txo_reconcile_classify_page_applied( + handle, + wallet_id.as_ptr(), + cursor.as_ptr(), + cursor.len(), + false, + &mut may_continue, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(may_continue); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + let run = reconciler + .claim_without_gate(&wallet_id, 0) + .expect("claimed"); + assert_eq!( + run.start, + TxoReconcileStart::Classify { + after: cursor.to_vec() + } + ); + for _ in 1..TXO_RECONCILE_PAGE_BUDGET { + classify_empty(); + } + let mut may_continue = true; + let result = unsafe { + platform_wallet_txo_reconcile_heal_page_applied( + handle, + wallet_id.as_ptr(), + &mut may_continue, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(may_continue, "one page left"); + classify_empty(); + assert_eq!( + classify_empty(), + (true, false), + "budget spent, nothing held" + ); + + let mut rows: *const TxoHealRowFFI = std::ptr::null(); + let mut count = 0usize; + let mut has_more = true; + let mut spent = false; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_heal_page( + handle, + wallet_id.as_ptr(), + 10, + &mut rows, + &mut count, + &mut has_more, + &mut spent, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(spent); + assert!(held.is_null() && rows.is_null() && !has_more); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + let destroyed = unsafe { crate::manager::platform_wallet_manager_destroy(handle) }; + assert_eq!(destroyed.code, PlatformWalletFFIResultCode::Success); + } + + /// A store cursor longer than the reconciler keeps is never read, even + /// one whose length is past `isize::MAX` (no slice can span it): the + /// page still counts as applied and the next run starts a new cycle. + #[test] + fn an_oversized_store_cursor_is_applied_unread_and_not_kept() { + use platform_wallet::manager::txo_reconcile::TxoReconcileStart; + + use crate::test_fixtures::mock_manager; + + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(mock_manager()); + let wallet_id = [0x6du8; 32]; + let reconciler = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| m.txo_reconciler()) + .expect("manager"); + assert!(reconciler.claim_without_gate(&wallet_id, 0).is_some()); + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + std::ptr::null(), + 0, + std::ptr::null_mut(), + &mut false, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + + // Non-null but pointing at nothing: a read of it would be UB. + let unreadable = std::ptr::NonNull::::dangling().as_ptr(); + let mut may_continue = false; + let result = unsafe { + platform_wallet_txo_reconcile_classify_page_applied( + handle, + wallet_id.as_ptr(), + unreadable, + usize::MAX, + false, + &mut may_continue, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(may_continue, "one page of the budget used"); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + let run = reconciler + .claim_without_gate(&wallet_id, 0) + .expect("claimed"); + assert_eq!( + run.start, + TxoReconcileStart::Heal { resumed: false }, + "the oversized cursor was not kept" + ); + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); + let destroyed = unsafe { crate::manager::platform_wallet_manager_destroy(handle) }; + assert_eq!(destroyed.code, PlatformWalletFFIResultCode::Success); + } + + /// A heal page holds the engine for the run that claimed the wallet, and + /// for no one else. + #[test] + fn a_heal_page_is_held_only_under_a_run_claim() { + use crate::test_fixtures::mock_manager; + + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(mock_manager()); + let wallet_id = [0x6bu8; 32]; + let (reconciler, engine) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| (m.txo_reconciler(), m.wallet_manager_arc())) + .expect("manager"); + let heal_page = |held: &mut *mut TxoReconcileHeldVerdictsFFI| { + let mut rows: *const TxoHealRowFFI = std::ptr::null(); + let mut count = 0usize; + let mut has_more = true; + let result = unsafe { + platform_wallet_txo_reconcile_heal_page( + handle, + wallet_id.as_ptr(), + 10, + &mut rows, + &mut count, + &mut has_more, + &mut false, + held, + ) + }; + assert!(rows.is_null(), "the wallet holds no coins"); + assert_eq!(count, 0); + assert!(!has_more); + result + }; + + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::dangling_mut(); + let mut result = heal_page(&mut held); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation, + "no run claim" + ); + assert!(held.is_null()); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + + assert!(reconciler.claim_without_gate(&wallet_id, 0).is_some()); + let result = heal_page(&mut held); + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!held.is_null()); + assert!( + engine.try_write().is_err(), + "the engine is held until the page is released" + ); + unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; + assert!(engine.try_write().is_ok()); + + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); + drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(handle)); + } + + /// Set in the child process that runs [`held_verdicts_registry_scenario`]. + const HELD_VERDICTS_CHILD: &str = "PLATFORM_WALLET_FFI_HELD_VERDICTS_CHILD"; + const WAIT: std::time::Duration = std::time::Duration::from_secs(10); + + /// The lock cycle the per-page release must not join. A run holds one + /// page's engine guard; an engine writer (a block, a fork) queues + /// behind it; a native read that resolved the manager under the + /// registry's read guard parks on the engine behind that writer; a + /// registry writer (a manager create or destroy) queues behind that + /// reader, and parking_lot then parks every new registry reader. A + /// release that looked the manager up in the registry would wait on + /// the registry writer, it on the reader, the reader on the engine + /// writer, and that writer on the guard. Freeing the held verdicts + /// touches no registry, so the guard goes, then the writer, the reader + /// and the registry writer in turn. + /// + /// The scenario runs in a child process (this test binary, re-invoked + /// on this test alone with [`HELD_VERDICTS_CHILD`] set), as + /// `spv_start_registry_tests` does: a regression forms a real deadlock + /// that would hold the process-wide registry and wedge every other + /// registry-backed test. The parent bounds the child and kills it. + #[test] + fn freeing_held_verdicts_completes_with_a_registry_writer_queued_behind_the_engine() { + if std::env::var_os(HELD_VERDICTS_CHILD).is_some() { + held_verdicts_registry_scenario(); + return; + } + let test_name = format!( + "{}::freeing_held_verdicts_completes_with_a_registry_writer_queued_behind_the_engine", + module_path!() + .split_once("::") + .map(|(_, path)| path) + .expect("test module path below the crate root") + ); + let mut child = std::process::Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", &test_name, "--nocapture"]) + .env(HELD_VERDICTS_CHILD, "1") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("spawn the scenario child"); + let deadline = std::time::Instant::now() + 2 * WAIT; + let status = loop { + if let Some(status) = child.try_wait().expect("child status") { + break Some(status); + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + break None; + } + std::thread::sleep(std::time::Duration::from_millis(20)); + }; + let output = child.wait_with_output().expect("reap the scenario child"); + let transcript = format!( + "--- child stdout ---\n{}\n--- child stderr ---\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + match status { + Some(status) => assert!( + status.success(), + "the scenario failed in the child process ({status})\n{transcript}" + ), + None => { + panic!("the scenario deadlocked: the child was killed\n{transcript}") + } + } + } + + fn held_verdicts_registry_scenario() { + use std::sync::mpsc; + use std::sync::Arc; + + use crate::test_fixtures::mock_manager; + + /// Poll `condition` until it holds, failing after [`WAIT`]. + fn wait_for(what: &str, condition: impl Fn() -> bool) { + let deadline = std::time::Instant::now() + WAIT; + while !condition() { + assert!(std::time::Instant::now() < deadline, "{what}"); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + + let handle = PLATFORM_WALLET_MANAGER_STORAGE.insert(mock_manager()); + let wallet_id = [0x5au8; 32]; + let (reconciler, engine) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |m| (m.txo_reconciler(), m.wallet_manager_arc())) + .expect("manager"); + assert!(reconciler.claim_without_gate(&wallet_id, 0).is_some()); + + // One page's verdicts, held. + let script = [0x76u8, 0xa9, 0x14]; + let query = OutpointOwnershipQueryFFI { + type_tag: 0, + 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(), + }; + let mut classes = [0xFFu8; 1]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + let result = unsafe { + platform_wallet_txo_reconcile_classify_outpoints( + handle, + wallet_id.as_ptr(), + &query, + 1, + classes.as_mut_ptr(), + &mut false, + &mut held, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!held.is_null()); + + // An engine writer queued behind the guard. With the guard held, a + // fresh engine read is refused exactly while a writer waits. + let writer_engine = Arc::clone(&engine); + let writer = std::thread::spawn(move || drop(writer_engine.blocking_write())); + wait_for("the engine writer never queued behind the guard", || { + engine.try_read().is_err() + }); + + // A native read holding the registry's read guard, parked on the + // engine behind that writer. + let (reading_tx, reading_rx) = mpsc::channel::<()>(); + let reader = std::thread::spawn(move || { + PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + let engine = manager.wallet_manager_arc(); + reading_tx.send(()).expect("test observes the reader"); + drop(engine.blocking_read()); + }) + }); + reading_rx + .recv_timeout(WAIT) + .expect("the reader holds the registry's read guard"); + + // A registry writer queued behind that reader. + let other = mock_manager(); + let registry_writer = + std::thread::spawn(move || PLATFORM_WALLET_MANAGER_STORAGE.insert(other)); + wait_for("the registry writer never queued behind the reader", || { + PLATFORM_WALLET_MANAGER_STORAGE.try_read_refused() + }); + + // The per-page release. + let held_address = held as usize; + let (freed_tx, freed_rx) = mpsc::channel::<()>(); + std::thread::spawn(move || { + unsafe { + platform_wallet_txo_reconcile_held_verdicts_free( + held_address as *mut TxoReconcileHeldVerdictsFFI, + ) + }; + freed_tx.send(()).expect("test observes the release"); + }); + freed_rx + .recv_timeout(WAIT) + .expect("the release completes without the registry"); + + writer.join().expect("engine writer"); + assert!(reader.join().expect("registry reader").is_some()); + let other = registry_writer.join().expect("registry writer"); + + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); + drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(other)); + drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(handle)); + } + + #[test] + fn a_panic_in_a_reconcile_export_is_an_error_result() { + let mut result = catch_reconcile_panic("platform_wallet_txo_reconcile_heal_page", || { + panic!("worker panicked") + }); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorWalletOperation + ); + let message = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert!( + message.contains("platform_wallet_txo_reconcile_heal_page panicked: worker panicked"), + "{message}" + ); + unsafe { crate::error::platform_wallet_ffi_result_free(&mut result) }; + + let result = catch_reconcile_panic("x", PlatformWalletFFIResult::ok); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "results pass through" + ); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 407ad4325d5..026eb36591e 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -4466,6 +4466,7 @@ mod contact_watch_only_projection_tests { dashpay_backfill: Default::default(), rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), } } @@ -5674,6 +5675,7 @@ mod tests { let ctx = TestWalletContext::new_random(); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -5726,6 +5728,7 @@ mod tests { let ctx = TestWalletContext::new_random(); let mut info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -5791,6 +5794,7 @@ mod tests { let ctx = TestWalletContext::new_random(); let mut info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -5871,6 +5875,7 @@ mod tests { let ctx = TestWalletContext::new_random(); let mut info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -6100,6 +6105,7 @@ mod tests { let retired = durable.retired().remove(&wallet_id).expect("retired"); let mut successor = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ManagedWalletInfo::from_wallet(&wallet, 0), generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), @@ -9045,6 +9051,7 @@ mod utxo_credit_verdict_tests { dashpay_backfill: Default::default(), rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); @@ -9141,6 +9148,7 @@ mod utxo_credit_verdict_tests { dashpay_backfill: Default::default(), rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), }; let mut wm = WalletManager::::new(dashcore::Network::Testnet); let wallet_id = wm.insert_wallet(ctx.wallet, info).expect("insert wallet"); diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 6915cccf86c..b1389f98fe2 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -269,8 +269,10 @@ pub struct OutpointOwnershipQuery { /// on: the owning account recorded the funding transaction (its txid is in /// the account's records or its finalized set), recognises the output's /// script as its own, does not hold the coin, AND a funds account holds a -/// MINED record whose transaction spends the outpoint. The last condition -/// is what makes the answer durable. Absence from `utxos` alone is not: +/// record whose transaction spends the outpoint, in a block the wallet's +/// last applied ChainLock covers. The last condition is what makes the +/// answer durable: no fork the engine follows can drop that spender (see +/// `final_spends_among`). 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 @@ -289,7 +291,8 @@ pub enum OutpointClass { /// The coin is in a funds account's live `utxos`. Unspent = 1, /// The owning account knows the funding txid, owns the script, does - /// not hold the coin, and a mined record spends the outpoint. + /// not hold the coin, and a record in a ChainLocked block spends the + /// outpoint. KnownUncredited = 2, /// The owning account's pools do not monitor the script. NotOwned = 3, @@ -390,14 +393,22 @@ pub fn wallet_utxos_page( /// 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. +/// for every query. +/// +/// Cost is `queries × funds accounts`, never the size of the inventory, +/// except for a query left a candidate for [`OutpointClass::KnownUncredited`] +/// — an owned coin the engine does not hold, whose funding it knows. Those +/// need the wallet's history: one walk of it per call, looking only for +/// the call's own candidates and stopping once each has a spender, so the +/// memory it takes is bounded by the call, not the history. A reconciled +/// store offers no candidates, so a run over one walks no history. pub fn classify_outpoints( wm: &key_wallet_manager::WalletManager, wallet_id: &WalletId, queries: &[OutpointOwnershipQuery], ) -> Vec { use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; let Some(info) = wm.get_wallet_info(wallet_id) else { return vec![OutpointClass::Unknown; queries.len()]; @@ -416,24 +427,13 @@ 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 + // The candidates for a spend verdict, by position; each is `Unknown` + // until the history shows a final spender for it. + let mut candidates = Vec::new(); + let mut classes: Vec = queries .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| { + .enumerate() + .map(|(position, query)| { // A contact's watch-only chain gets no verdict at all — not // even `Unspent`: its coins are the contact's to spend, never // this wallet's to flip or to count. @@ -462,14 +462,87 @@ pub fn classify_outpoints( return OutpointClass::NotOwned; } let txid = &query.outpoint.txid; - let funding_known = owner.has_transaction(txid) || owner.transaction_is_finalized(txid); - if funding_known && mined_spends.contains(&query.outpoint) { - OutpointClass::KnownUncredited - } else { - OutpointClass::Unknown + if owner.has_transaction(txid) || owner.transaction_is_finalized(txid) { + candidates.push(position); } + OutpointClass::Unknown }) - .collect() + .collect(); + // A spender the ChainLock already dropped left its inputs behind + // (`finalized_spends`); only the rest need the history. + let (dropped, rest): (Vec, Vec) = candidates + .into_iter() + .partition(|&position| info.finalized_spends.contains(&queries[position].outpoint)); + for position in dropped { + classes[position] = OutpointClass::KnownUncredited; + } + if rest.is_empty() { + return classes; + } + let Some(chain_locked) = info.last_applied_chain_lock().map(|lock| lock.block_height) else { + // No record is final before the wallet's first ChainLock. + return classes; + }; + let funds: Vec<_> = accounts.iter().map(|(_, funds)| *funds).collect(); + let spent = final_spends_among( + &funds, + rest.iter() + .map(|&position| queries[position].outpoint) + .collect(), + chain_locked, + ); + for position in rest { + if spent.contains(&queries[position].outpoint) { + classes[position] = OutpointClass::KnownUncredited; + } + } + classes +} + +/// Which of `wanted` an input of a record in `accounts` spends, where that +/// record's block is at or below `chain_locked` — the wallet's last applied +/// ChainLock. Records the ChainLock already dropped are not here: what +/// they spent is in `PlatformWalletInfo::finalized_spends`. Such a spend is +/// final: `truncate_above` refuses to drop a +/// block the wallet's ChainLock covers, so no fork can take the spender +/// away and give the coin back after the store has marked it spent (a +/// truncation reaches no persistence round, dashpay/platform#5380, so a +/// store row marked spent for a spender the fork dropped would never be +/// marked unspent again). A mempool spend, an IS-locked spend, a spend in +/// a block no ChainLock covers yet, and a released loser input all leave a +/// coin absent from `utxos` too, and none of those is a verdict; the last +/// but one earns it in a later run, once a ChainLock reaches its block. +/// +/// One walk of the history, which ends once every wanted outpoint has a +/// spender. +fn final_spends_among( + accounts: &[&key_wallet::managed_account::ManagedCoreFundsAccount], + mut wanted: std::collections::HashSet, + chain_locked: u32, +) -> std::collections::HashSet { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + + let mut spent = std::collections::HashSet::new(); + let final_records = accounts + .iter() + .flat_map(|funds| funds.transactions().values()) + .filter(|record| { + record + .context + .block_info() + .is_some_and(|block| block.height() <= chain_locked) + }); + for record in final_records { + for input in &record.transaction.input { + if wanted.remove(&input.previous_output) { + spent.insert(input.previous_output); + } + } + if wanted.is_empty() { + break; + } + } + spent } /// Snapshot of one transaction row inside an account. @@ -1859,7 +1932,7 @@ mod computed_balance_tests { } #[cfg(test)] -mod txo_inventory_tests { +pub(crate) 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 @@ -1882,14 +1955,14 @@ mod txo_inventory_tests { use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; - fn bip44_account_0() -> AccountType { + pub(crate) fn bip44_account_0() -> AccountType { AccountType::Standard { index: 0, standard_account_type: StandardAccountType::BIP44Account, } } - fn in_block(height: u32) -> TransactionContext { + pub(crate) fn in_block(height: u32) -> TransactionContext { TransactionContext::InBlock(BlockInfo::new( height, BlockHash::from_slice(&[6u8; 32]).expect("valid block hash"), @@ -1906,7 +1979,7 @@ mod txo_inventory_tests { } } - fn funding(script_pubkey: ScriptBuf, seed: u8, value: u64) -> Transaction { + pub(crate) fn funding(script_pubkey: ScriptBuf, seed: u8, value: u64) -> Transaction { Transaction { version: 2, lock_time: 0, @@ -1923,7 +1996,7 @@ mod txo_inventory_tests { } /// The rust-dashcore#992 shape: one input, one zero-value `OP_RETURN`. - fn collateral_burn(coin: OutPoint) -> Transaction { + pub(crate) fn collateral_burn(coin: OutPoint) -> Transaction { Transaction { version: 2, lock_time: 0, @@ -1949,7 +2022,9 @@ mod txo_inventory_tests { dashcore::Address::p2pkh(&pubkey, key_wallet::Network::Testnet).script_pubkey() } - fn manager_with(ctx: TestWalletContext) -> (WalletManager, WalletId) { + pub(crate) fn manager_with( + ctx: TestWalletContext, + ) -> (WalletManager, WalletId) { let info = PlatformWalletInfo { core_wallet: ctx.managed_wallet, generation: Arc::new(WalletGeneration::new()), @@ -1959,13 +2034,14 @@ mod txo_inventory_tests { dashpay_backfill: Default::default(), rewind_barrier: Default::default(), observed_input_conflicts: Default::default(), + finalized_spends: 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( + pub(crate) fn query( account_type: AccountType, outpoint: OutPoint, script: &ScriptBuf, @@ -2034,8 +2110,8 @@ mod txo_inventory_tests { } /// 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 + /// `KnownUncredited` needs a spender on record in a block the wallet's + /// ChainLock covers: 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 @@ -2113,6 +2189,10 @@ mod txo_inventory_tests { .await .is_relevant ); + // A ChainLock over the ordinary spend's block: the spend is final. + ctx.managed_wallet.metadata.last_applied_chain_lock = Some( + dashcore::ephemerealdata::chain_lock::ChainLock::dummy(100_005), + ); let (wm, wallet_id) = manager_with(ctx); let never_seen = OutPoint { txid: Txid::from_slice(&[0x99u8; 32]).expect("valid txid"), @@ -2165,4 +2245,117 @@ mod txo_inventory_tests { ); assert!(classify_outpoints(&wm, &wallet_id, &[]).is_empty()); } + + /// A funded and spent coin: the funding and the spend both mined, the + /// spend not yet covered by a ChainLock. + async fn wallet_with_a_spent_coin() -> ( + WalletManager, + WalletId, + OutpointOwnershipQuery, + Transaction, + ) { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + let funded = funding(script.clone(), 41, 6_000); + assert!( + ctx.check_transaction(&funded, in_block(100_000)) + .await + .is_relevant + ); + let coin = OutPoint { + txid: funded.txid(), + vout: 0, + }; + let spend = collateral_burn(coin); + let (wm, wallet_id) = manager_with(ctx); + ( + wm, + wallet_id, + query(bip44_account_0(), coin, &script), + spend, + ) + } + + fn spend_record_kept( + wm: &WalletManager, + wallet_id: &WalletId, + spend: &Transaction, + ) -> bool { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + wm.get_wallet_info(wallet_id) + .expect("wallet") + .core_wallet + .accounts + .all_accounts() + .into_iter() + .filter_map(|account| account.as_funds()) + .any(|funds| funds.transactions().contains_key(&spend.txid())) + } + + /// The ChainLock promotion drops the spender's record (with + /// `keep-finalized-transactions` off), and the spend still counts: the + /// wallet kept what the record spent. + #[tokio::test] + async fn a_spend_counts_after_the_chain_lock_drops_its_record() { + use key_wallet::transaction_checking::WalletTransactionChecker; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + let (mut wm, wallet_id, coin, spend) = wallet_with_a_spent_coin().await; + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + assert!( + info.check_core_transaction(&spend, in_block(100_001), wallet, true, true) + .await + .is_relevant + ); + info.apply_chain_lock(dashcore::ephemerealdata::chain_lock::ChainLock::dummy( + 100_001, + )); + + #[cfg(not(feature = "keep-finalized-transactions"))] + assert!( + !spend_record_kept(&wm, &wallet_id, &spend), + "the promotion dropped the spender" + ); + assert_eq!( + classify_outpoints(&wm, &wallet_id, std::slice::from_ref(&coin)), + vec![OutpointClass::KnownUncredited] + ); + } + + /// A spend first seen in a ChainLocked block is dropped as it is + /// recorded, before any ChainLock reaches the wallet's metadata. The + /// engine can no longer give that coin back, so the spend counts too. + #[tokio::test] + async fn a_spend_first_seen_chain_locked_counts() { + use key_wallet::transaction_checking::WalletTransactionChecker; + + let (mut wm, wallet_id, coin, spend) = wallet_with_a_spent_coin().await; + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + let block = in_block(100_001).block_info().copied().expect("mined"); + assert!( + info.check_core_transaction( + &spend, + TransactionContext::InChainLockedBlock(block), + wallet, + true, + true + ) + .await + .is_relevant + ); + + // With `keep-finalized-transactions` on the record stays, and no + // ChainLock in the wallet's metadata covers its block yet: + // `truncate_above` could still drop it, so it is no verdict. + let expected = if cfg!(feature = "keep-finalized-transactions") { + OutpointClass::Unknown + } else { + assert!(!spend_record_kept(&wm, &wallet_id, &spend)); + OutpointClass::KnownUncredited + }; + assert_eq!( + classify_outpoints(&wm, &wallet_id, std::slice::from_ref(&coin)), + vec![expected] + ); + } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1b612536087..0dbae0ab3f6 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -242,6 +242,7 @@ impl PlatformWalletManager

{ ); let mut platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), @@ -644,6 +645,9 @@ impl PlatformWalletManager

{ } continue; } + // As `remove_wallet` does, while this load's wallet + // still holds the id. + self.forget_txo_reconcile(id); if let Err(e) = self.remove_from_wallet_manager(&mut wm, id) { tracing::warn!( wallet_id = %hex::encode(id), diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 63de4e4d49b..61ce479b798 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -12,6 +12,7 @@ pub mod platform_address_sync; #[cfg(feature = "shielded")] pub mod shielded_sync; pub mod startup; +pub mod txo_reconcile; mod wallet_lifecycle; pub(crate) use persistence_load::run_blocking_load; @@ -42,6 +43,7 @@ use crate::manager::identity_sync::IdentitySyncManager; use crate::manager::platform_address_sync::PlatformAddressSyncManager; #[cfg(feature = "shielded")] use crate::manager::shielded_sync::ShieldedSyncManager; +use crate::manager::txo_reconcile::TxoReconcileSchedule; use crate::spv::SpvRuntime; use crate::wallet::asset_lock::LockNotifyHandler; use crate::wallet::core::{BalanceUpdateHandler, SpendObservationHandler}; @@ -515,6 +517,10 @@ pub struct PlatformWalletManager { pub(super) in_broadcast_fences: std::sync::Mutex< std::collections::BTreeMap>, >, + /// The host TXO-store reconcile's steady-state edge, runs in flight and + /// per-wallet cadence — see [`txo_reconcile`]. A `std::sync::Mutex`, + /// never held across an await. + pub(super) txo_reconcile_schedule: Arc>, } /// Keep `record`'s coverage only for the DashPay receival accounts `core` @@ -695,6 +701,7 @@ impl PlatformWalletManager

{ sync_fault, durable_cursors, in_broadcast_fences: std::sync::Mutex::new(std::collections::BTreeMap::new()), + txo_reconcile_schedule: Arc::default(), } } @@ -1131,7 +1138,10 @@ impl PlatformWalletManager

{ /// Ordering matters and is fourfold: /// 1. SPV is stopped and joined FIRST so it cannot dispatch more wallet /// events, then payment-task admission is closed and all admitted - /// DashPay payment-hook work is joined. + /// DashPay payment-hook work is joined. TXO-reconcile engine guards a + /// host still holds are force-released only after SPV stopped + /// cleanly, never before, so no fork can land between a host's read + /// and its in-flight store write; a failed stop leaves them held. /// 2. `quiesce()` each coordinator. Cancel-only `stop()` would /// let a pass already inside `sync_now` keep running and call /// `persister.store(...)` / fire a host completion callback after @@ -1159,6 +1169,10 @@ impl PlatformWalletManager

{ /// (its live handle is re-parked on timeout so a retry re-joins it). /// Past those SPV lock waits, a wedged await therefore surfaces as a /// non-clean report instead of hanging the FFI's `destroy` forever. + /// A host still writing a TXO-reconcile page holds its engine guard + /// through SPV stop, so SPV teardown (and anything queued on the engine + /// behind it) waits for that write; a write that never ends turns into a + /// timed-out SPV stop, not a hang. /// /// Returns a [`ShutdownReport`] keyed by [`WalletWorker`] — including /// the non-registry workers [`WalletWorker::Spv`], @@ -1174,7 +1188,16 @@ impl PlatformWalletManager

{ // `stop` bounds the wait for teardown but never aborts it, so a // timeout means SPV work is still live. Its outcome lands in the // report — a failed stop must not hide behind a clean coordinator join. - let spv_status = match self.spv_manager.stop().await { + // + // A TXO reconcile's engine guard is held across one host store write + // and released by the host, which may still be writing when this + // runs. It is released here only AFTER SPV stopped cleanly — SPV is + // what applies forks, and a fork landing under a revoked guard + // before the host's write lands is exactly what the guard prevents. + // Until then an SPV writer simply waits for the host's write, under + // the stop's own deadline. A failed or timed-out stop releases + // nothing (fail closed): the guard stays with the host's handle. + let spv_status = match self.stop_spv_then_release_txo_reconcile_holds().await { Ok(()) => WorkerStatus::Ok, Err(error) => { tracing::warn!(?error, "SPV shutdown failed"); diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs new file mode 100644 index 00000000000..47d453b7d5b --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -0,0 +1,2666 @@ +//! Policy of the host TXO-store reconcile (dashpay/platform#4638). +//! +//! A host keeps a write-behind mirror of the wallet's coins and restores +//! the engine from it at every launch, so a coin the mirror lost, or a row +//! it holds unspent for a coin the engine proves spent, outlives a full +//! scan. The repair walks the host's own store, but every decision in it +//! lives here, so each host asks the same questions and gets the same +//! answers: +//! +//! - **when to run** — [`TxoReconciler::due_wallets`] on every SPV progress +//! poll: each wallet on the rising edge into steady state, then again on +//! [`TXO_RECONCILE_CADENCE`] while it lasts, and on the next poll while +//! its cycle is unfinished ([`TxoReconcileFinish`]); +//! - **whether a run may start** — [`TxoReconciler::begin`]: one run per +//! wallet at a time, SPV in steady state with a scan tip, no latched sync +//! fault, and the wallet's own scan watermark within +//! [`TXO_RECONCILE_TIP_MARGIN`] blocks of the tip; +//! - **which engine coins may be healed into the store** — +//! [`TxoReconciler::heal_page_held`]: [`txo_heal_page`] tags each +//! inventory row with a [`TxoHealVerdict`], measured against the run's +//! own tip and the engine's processed height, with the engine held still +//! until the host has written the page; +//! - **which store rows to mark spent** — [`TxoReconciler::classify_held`]: +//! [`classify_outpoints`](crate::manager::accessors::classify_outpoints)'s +//! verdicts, held the same way; a spend counts only once a ChainLock +//! covers its block; +//! - **how long to retry a page a persistence round staled** — +//! [`txo_reconcile_retry_stale_page`]; +//! - **how much one run does, and where the next one picks up** — each run +//! reads at most [`TXO_RECONCILE_PAGE_BUDGET`] pages, and the wallet's +//! checkpoint, moved only by a page the host reports applied +//! ([`TxoReconciler::heal_page_applied`], +//! [`TxoReconciler::classify_page_applied`]), says where the next run +//! starts ([`TxoReconcileStart`]). A run that used its budget with the +//! cycle unfinished leaves the wallet due, so the next poll continues it. +//! +//! The host keeps only what needs its store: paging its rows, resolving the +//! account that owns each, its transactions, and the fences that keep its +//! persistence callbacks and teardown out of a run's writes. Its store +//! cursor is opaque here: the reconciler keeps it for the next run and +//! hands it back unread. +//! +//! # Lock order +//! +//! A run touches, in this order and never the other way round: +//! +//! 1. a host's handle registry (the C API's manager registry, a fair +//! `parking_lot` lock), read side only and only transiently, to resolve +//! the manager for one call — never while a run holds an engine guard; +//! 2. the wallet manager's `RwLock`, read side only, taken by +//! [`TxoReconciler::heal_page_held`] or [`TxoReconciler::classify_held`] +//! and kept, in a [`TxoReconcileHeldVerdicts`], until the host's apply +//! has committed; +//! 3. the host's callback exclusion (Kotlin's `callbackExclusion`), taken +//! for the apply; +//! 4. the host's store transaction (one Room transaction for one page). +//! +//! The engine calls the persistence callbacks, which take the callback +//! exclusion, while it holds the wallet manager's write lock — the same +//! order, 2 before 3. So a host must take the guard before the exclusion +//! (never wait for the engine while holding the exclusion), must never +//! take a held read inside a persistence callback, and must release the guard +//! after the exclusion. Every other C API path also takes the registry +//! before the engine lock (a blocking read inside a registry read, or a +//! manager create or destroy that writes the registry), so **no path that +//! holds the engine guard ever waits for the registry**: dropping a +//! [`TxoReconcileHeldVerdicts`] touches only the guard's own slot, and the +//! host drops a page's guard before it resolves the manager again for the +//! next page or for [`TxoReconciler::finish`]. A held read resolves the +//! manager with no guard held, then waits for its new guard outside the +//! registry. The schedule mutex and a guard's slot mutex are leaves, +//! taken in that order when both are: each is held only for a few map or +//! slot operations, never across an await or another lock. SPV progress is +//! read only by [`TxoReconciler::due_wallets`] and [`TxoReconciler::begin`], +//! before a run holds anything. +//! +//! tokio's `RwLock` is fair: a writer waiting behind the held guard (a fork +//! in `truncate_above`, a block being applied) queues every later reader +//! behind it, and `parking_lot`'s registry lock likewise parks new readers +//! behind a queued writer. That cannot close a cycle here. The writer holds +//! nothing the guard holder waits for — the guard holder waits only for the +//! callback exclusion and the store, and whatever holds the exclusion is +//! either a callback that already holds its engine lock (a read lock taken +//! before the writer queued; a write lock cannot be held beside the guard) +//! or the host's own code, which makes no engine call under it. A registry +//! reader parked on the engine behind that writer, and a registry writer +//! queued behind that reader, wait on the guard, never the other way +//! round. The one reader that could queue behind such a writer while +//! holding a guard is the same run asking for its next page, which is why +//! a host releases each page's guard first and both held reads drop one it +//! failed to release before they wait for a new one. + +use std::collections::BTreeMap; +use std::fmt; +use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet_manager::WalletManager; +use tokio::sync::{OwnedRwLockReadGuard, RwLock}; + +use crate::changeset::PlatformWalletPersistence; +use crate::error::PlatformWalletError; +use crate::manager::accessors::{ + classify_outpoints, wallet_utxos_page, OutpointClass, OutpointOwnershipQuery, WalletUtxoCursor, + WalletUtxoRow, +}; +use crate::manager::PlatformWalletManager; +use crate::spv::{ProgressPercentage, SpvRuntime, SyncProgress, SyncState}; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use crate::wallet::PlatformWallet; + +/// Confirmations an engine coin needs before a host may heal it into its +/// store: coinbase maturity, and well past any plausible reorg. +pub const TXO_RECONCILE_MIN_CONFIRMATIONS: u32 = 100; + +/// How far a wallet's scan watermark may trail the scan tip for the scan to +/// count as complete for that wallet. +pub const TXO_RECONCILE_TIP_MARGIN: u32 = 6; + +/// How often each wallet is reconciled again while SPV stays in steady +/// state. +pub const TXO_RECONCILE_CADENCE: Duration = Duration::from_secs(30 * 60); + +/// Times in a row one page may be classified again because a persistence +/// round committed between its read and its apply, or was still open at +/// it, before the run gives up. +pub const TXO_RECONCILE_MAX_STALE_RETRIES: u32 = 5; + +/// Pages one run may read, the heal pass's engine pages and the classify +/// pass's store pages together (a page read again counts again). Anyone +/// can send dust to a watched address, so the inventory can grow without +/// the owner doing anything; a run that always walked all of it would turn +/// that growth into work on every automatic run. Pages are whatever size +/// the host asks for (a heal page at most +/// [`crate::manager::accessors::WALLET_UTXO_PAGE_MAX`] rows), so one run +/// covers at most this many of them: a wallet whose heal and classify +/// passes fit in that finishes a whole cycle in one run, and a larger one +/// is covered over several on consecutive polls (the budget bounds one +/// run, not the cycle), then waits [`TXO_RECONCILE_CADENCE`] for the next +/// cycle. +pub const TXO_RECONCILE_PAGE_BUDGET: u32 = 16; + +/// The longest store cursor a host may hand +/// [`TxoReconciler::classify_page_applied`]: a 36-byte outpoint, an +/// offset, with room to spare. +pub const TXO_RECONCILE_STORE_CURSOR_MAX: usize = 64; + +/// Where a run starts, from the wallet's checkpoint. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TxoReconcileStart { + /// The heal pass, then the classify pass from the store's first row. + /// `resumed` when an earlier run stopped in the heal pass: its next + /// page is where that run stopped, which the reconciler keeps, so the + /// host passes no cursor ([`TxoReconciler::heal_page_held`]). + Heal { resumed: bool }, + /// Straight to the classify pass, after the store cursor `after` an + /// earlier run's last applied page left (empty: the first row). The + /// cursor is the host's own, handed back as it gave it. + Classify { after: Vec }, +} + +/// A run's claim, as [`TxoReconciler::begin`] grants it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TxoReconcileRun { + /// Where the run starts. + pub start: TxoReconcileStart, +} + +/// One held read of a run: the page, held until the host has applied it, +/// or no read at all because the run has used its +/// [`TXO_RECONCILE_PAGE_BUDGET`]. +#[derive(Debug)] +pub enum TxoReconcileRead { + Page(T, TxoReconcileHeldVerdicts), + BudgetSpent, +} + +/// Where a wallet's next run picks up: in the heal pass after an engine +/// cursor, or in the classify pass after a host store cursor. No entry is +/// a new cycle. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Checkpoint { + Heal(WalletUtxoCursor), + Classify(Vec), +} + +/// What an applied page does to its wallet's checkpoint. +enum CheckpointMove { + /// The page was not the kind the host reported (or its cursor is too + /// long to keep): nothing moves. + Ignore, + /// The wallet's next run starts here. + Set(Checkpoint), + /// The cycle is over: the wallet's next run starts a new one. + Clear, +} + +/// What a held read sees of its run's claim, copied out before the read +/// waits for the engine. +struct RunView { + tip: u32, + heal_after: Option, +} + +/// A page a run read and its host has not yet reported applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PendingPage { + /// A heal page; the cursor after it, or `None` for the last page. + Heal(Option), + Classify, +} + +/// One SPV phase's heights, as dash-spv reports them for progress. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PhaseHeights { + pub current: u32, + pub target: u32, +} + +/// The part of an SPV progress read the reconcile schedule depends on. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScanProgress { + pub state: SyncState, + /// Absent until the filter manager has started. + pub filters: Option, + /// Absent until the header manager has started. + pub headers: Option, +} + +impl ScanProgress { + pub fn from_sync_progress(progress: &SyncProgress) -> Self { + let heights = |current: u32, target: u32| PhaseHeights { current, target }; + Self { + state: progress.state(), + filters: progress + .filters() + .ok() + .map(|f| heights(f.current_height(), f.target_height())), + headers: progress + .headers() + .ok() + .map(|h| heights(h.current_height(), h.target_height())), + } + } + + /// Whether the scan is finished. 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 — + /// anything else is a scan still running, or none at all. + pub fn is_steady(&self) -> bool { + match self.state { + SyncState::Synced => true, + SyncState::WaitForEvents => self + .filters + .is_some_and(|f| f.target > 0 && f.current >= f.target), + SyncState::WaitingForConnections | SyncState::Syncing | SyncState::Error => false, + } + } + + /// The height the scan reached: the filter phase's (the wallet-relevant + /// one), falling back to the header tip when the filter phase has no + /// height; `None` when neither has one. + pub fn scan_tip(&self) -> Option { + let positive = |phase: Option| phase.map(|p| p.current).filter(|h| *h > 0); + positive(self.filters).or_else(|| positive(self.headers)) + } +} + +/// Why [`TxoReconciler::begin`] refused a run. Nothing was read or written. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxoReconcileSkip { + /// The manager does not hold the wallet. + WalletUnknown = 1, + /// A run for this wallet is already in flight. + AlreadyRunning = 2, + /// SPV is not running, or not in its steady state + /// ([`ScanProgress::is_steady`]). + NotSteadyState = 3, + /// The progress carries no scan tip. + TipUnavailable = 4, + /// A persistence round was rejected: rows are missing by design and a + /// rescan is pending ([`PlatformWalletManager::sync_fault_detected`]). + SyncFaultDetected = 5, + /// The wallet's scan watermark trails the scan tip by more than + /// [`TXO_RECONCILE_TIP_MARGIN`] blocks: it is still being scanned. + WalletBehindTip = 6, +} + +impl TxoReconcileSkip { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// How a run [`TxoReconciler::begin`] granted ended, as its host reports it +/// to [`TxoReconciler::finish`]: what the wallet's cadence does next. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxoReconcileFinish { + /// The run did not go ahead: the cadence keeps the restart its claim + /// stamped. + NotRun = 0, + /// The run went ahead, even if it stopped early on a failure: the + /// cadence restarts now, so a persistent failure is not retried on + /// every poll. Except when the run applied its whole + /// [`TXO_RECONCILE_PAGE_BUDGET`] with the cycle unfinished (a + /// checkpoint still set): then the wallet stays due, and the next poll + /// continues the cycle where the run stopped. + Ran = 1, + /// The run stopped because the host's store deferred it (a persistence + /// round was open): the page was not applied. The wallet stays due, so + /// the next poll runs it again. + Deferred = 2, +} + +impl TxoReconcileFinish { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// The SPV half of the start gate: steady state, a scan tip, and no latched +/// sync fault, in that order. Returns the scan tip a run is measured +/// against. +pub fn txo_reconcile_scan_gate( + progress: Option, + sync_fault: bool, +) -> Result { + let progress = progress + .filter(ScanProgress::is_steady) + .ok_or(TxoReconcileSkip::NotSteadyState)?; + let tip = progress + .scan_tip() + .ok_or(TxoReconcileSkip::TipUnavailable)?; + if sync_fault { + return Err(TxoReconcileSkip::SyncFaultDetected); + } + Ok(tip) +} + +/// The wallet half of the start gate, against the wallet's scan watermark +/// as the engine holds it (`None` when the engine does not hold the +/// wallet). The tip says how far the CLIENT got; a wallet added behind it +/// is still being scanned. The engine's watermark, not a host's persisted +/// copy: that trails the engine by up to one persistence round, and the +/// transition into steady state lands inside that window. +pub fn txo_reconcile_wallet_gate( + wallet_synced_height: Option, + tip_height: u32, +) -> Result<(), TxoReconcileSkip> { + let synced = wallet_synced_height.ok_or(TxoReconcileSkip::WalletUnknown)?; + if synced.saturating_add(TXO_RECONCILE_TIP_MARGIN) >= tip_height { + Ok(()) + } else { + Err(TxoReconcileSkip::WalletBehindTip) + } +} + +/// Whether a host may heal one engine coin into its store — see +/// [`txo_heal_verdict`]. +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TxoHealVerdict { + /// No script or no address: the store could not file the row. + Invalid = 0, + /// The engine does not call it confirmed, or it is fewer than + /// [`TXO_RECONCILE_MIN_CONFIRMATIONS`] deep at the tip. A fresh coin can + /// still reorg out (or, for coinbase, be immature); it ages into a later + /// run. + Immature = 1, + /// May be inserted unspent, if the store lacks the outpoint and can + /// resolve the account that owns it. + Eligible = 2, +} + +impl TxoHealVerdict { + pub fn as_u8(self) -> u8 { + self as u8 + } +} + +/// The verdict on one inventory row at `tip_height`. The watch-only contact +/// exclusion is not repeated here: [`wallet_utxos_page`] never offers a +/// contact's coin. +pub fn txo_heal_verdict(row: &WalletUtxoRow, tip_height: u32) -> TxoHealVerdict { + if row.script_pubkey.is_empty() || row.address.is_empty() { + return TxoHealVerdict::Invalid; + } + let deep_enough = row.height > 0 + && tip_height >= row.height + && tip_height - row.height + 1 >= TXO_RECONCILE_MIN_CONFIRMATIONS; + if row.is_confirmed && deep_enough { + TxoHealVerdict::Eligible + } else { + TxoHealVerdict::Immature + } +} + +/// One page of `wallet_id`'s inventory ([`wallet_utxos_page`], same order, +/// cursor and bounds), each row with its [`TxoHealVerdict`] at +/// `tip_height`. A host reads it through [`TxoReconciler::heal_page_held`], +/// which picks the height ([`txo_heal_reference_height`]) and holds the +/// engine still until the page is written. +pub fn txo_heal_page( + wm: &WalletManager, + wallet_id: &WalletId, + after: Option<&WalletUtxoCursor>, + limit: usize, + tip_height: u32, +) -> (Vec<(WalletUtxoRow, TxoHealVerdict)>, bool) { + let (rows, has_more) = wallet_utxos_page(wm, wallet_id, after, limit); + let rows = rows + .into_iter() + .map(|row| { + let verdict = txo_heal_verdict(&row, tip_height); + (row, verdict) + }) + .collect(); + (rows, has_more) +} + +/// The cursor after one heal page, or `None` when it is the heal pass's +/// last. An empty page is the last whatever the walk reported: it moves no +/// cursor, so another read would return it again. The page's `has_more` +/// is whether this is `Some`, so a host can stop at an empty page. +fn heal_page_next( + rows: &[(WalletUtxoRow, TxoHealVerdict)], + has_more: bool, +) -> Option { + rows.last() + .filter(|_| has_more) + .map(|(row, _)| (row.account_type, row.outpoint)) +} + +/// The height one heal page's confirmations are counted to: the scan tip +/// the run's start gate measured, or the wallet's last processed height in +/// `wm` when that is lower. The tip is the run's, never the host's, so a +/// host cannot pass a height that makes a fresh coin look deep. The +/// processed height is read under the same guard as the page: a fork that +/// truncated the engine since the gate lowered it with the coins it +/// dropped, so a coin is not called deep against blocks the engine no +/// longer has. A wallet the engine does not hold counts to 0, which calls +/// every row immature. +pub fn txo_heal_reference_height( + wm: &WalletManager, + wallet_id: &WalletId, + run_tip: u32, +) -> u32 { + wm.get_wallet_info(wallet_id) + .map_or(0, |info| info.last_processed_height()) + .min(run_tip) +} + +/// Whether a run classifies a page again after it came back stale for the +/// `stale_in_a_row`-th time in a row (counting this one), or stops +/// incomplete. A round committing between a page's read and its apply may +/// have re-credited one of its coins, so nothing is written and the page is +/// read again; a store under constant write load must not hold the run +/// forever. +pub fn txo_reconcile_retry_stale_page(stale_in_a_row: u32) -> bool { + stale_in_a_row <= TXO_RECONCILE_MAX_STALE_RETRIES +} + +/// The scheduling state behind [`TxoReconciler`]: the steady-state edge, +/// the runs in flight and each wallet's last run. +#[derive(Debug, Default)] +pub struct TxoReconcileSchedule { + /// The SPV run session steady state was last seen in; `None` while the + /// client is not steady. A new session is a new steady state even when + /// no non-steady read was seen in between (a stop and restart quicker + /// than the host's poll). + steady_session: Option, + in_flight: BTreeMap, + last_run: BTreeMap, + /// Where each wallet's next run picks up. A run that used its budget + /// with the cycle unfinished leaves the wallet due ([`Self::finish`]), + /// so the cycle continues on the next poll and finishes within seconds + /// of consecutive runs, not one budget per cadence. In memory: a fresh + /// process starts a new cycle and finishes it the same way, so even a + /// session shorter than the cadence completes one. + checkpoints: BTreeMap, + /// The id the next claim gets. + next_claim: u64, +} + +/// The read side of the wallet manager's lock, owned so it can outlive the +/// call that took it — see [`TxoReconciler::classify_held`]. +type EngineReadGuard = OwnedRwLockReadGuard>; + +/// Where one page's engine guard lives: shared by the run's claim, which +/// empties it on [`TxoReconcileSchedule::finish`] and at shutdown once SPV +/// has stopped ([`release_txo_holds_after_stop`]), and the +/// host's [`TxoReconcileHeldVerdicts`], which empties it on drop. Whichever +/// comes first releases the guard; the other finds the slot empty. +type HeldSlot = Arc>>; + +/// Empty `slot`, releasing the guard outside its mutex. +fn release_slot(slot: &HeldSlot) { + let guard = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + drop(guard); +} + +/// The engine guard one page's heal or spend verdicts were answered under +/// ([`TxoReconciler::heal_page_held`], [`TxoReconciler::classify_held`]). +/// Dropping it releases the guard; that +/// touches nothing but the guard's own slot — no schedule lookup, no +/// manager, no host registry — so the host can release it whatever else is +/// queued. Drop it once the page is applied. +pub struct TxoReconcileHeldVerdicts { + slot: HeldSlot, +} + +impl fmt::Debug for TxoReconcileHeldVerdicts { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TxoReconcileHeldVerdicts") + .finish_non_exhaustive() + } +} + +impl Drop for TxoReconcileHeldVerdicts { + fn drop(&mut self) { + release_slot(&self.slot); + } +} + +/// One run in flight. +#[derive(Default)] +struct ReconcileClaim { + /// Tells this claim from a later one for the same wallet, so a + /// classification that waited across a finish and a new begin does not + /// hand its guard to the new run. + id: u64, + /// The scan tip the run's start gate measured, set when + /// [`TxoReconciler::begin`] hands the claim over (0 until then). Every + /// heal page of the run is measured against it + /// ([`txo_heal_reference_height`]). + tip: u32, + /// Pages the run may still read ([`TXO_RECONCILE_PAGE_BUDGET`]). + pages_left: u32, + /// Where the run's next heal page starts: `None` at the first page. + heal_after: Option, + /// The page last read, until the host reports it applied. + pending: Option, + /// The wallet was removed while the run was in flight + /// ([`TxoReconcileSchedule::forget`]). The run still holds the wallet + /// until it finishes, but its finish records nothing: the cadence it + /// would restart belongs to a wallet that no longer exists. + forgotten: bool, + /// The slot of the guard the run's last classification is held under + /// until its host has applied the verdicts. Emptied by + /// [`TxoReconcileSchedule::finish`], so a run never outlives its guard. + held: Option, +} + +impl fmt::Debug for ReconcileClaim { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ReconcileClaim") + .field("id", &self.id) + .field("tip", &self.tip) + .field("pages_left", &self.pages_left) + .field("pending", &self.pending) + .field("forgotten", &self.forgotten) + .field("holds_engine", &self.held.is_some()) + .finish() + } +} + +impl TxoReconcileSchedule { + /// Feed one progress observation (every poll, not only a changed one: + /// this is the schedule's only clock, and a quiet steady-state client's + /// progress does not change for the whole cadence). Returns the wallets + /// to run now: each wallet that has not run in this steady state, or + /// whose last run is at least [`TXO_RECONCILE_CADENCE`] old — never one + /// already in flight. The rising edge into steady state forgets every + /// wallet's last run, so all of `wallets` are due from then on. + /// Reporting a wallet consumes nothing: its cadence restarts only when a + /// run claims it ([`Self::try_begin`]) or finishes ([`Self::finish`]), + /// so a wallet the host cannot run yet is reported again on the next + /// poll, on the edge or not. + pub fn due_wallets( + &mut self, + steady: bool, + spv_session: u64, + wallets: impl IntoIterator, + now: Instant, + ) -> Vec { + if !steady { + self.steady_session = None; + return Vec::new(); + } + if self.steady_session != Some(spv_session) { + // A new steady state: every wallet is due until a claim stamps + // it, so one the host skips on this poll is reported again on + // the next. A run in flight across the edge stamps its own + // finish if it went ahead. + self.last_run.clear(); + self.steady_session = Some(spv_session); + } + wallets + .into_iter() + .filter(|wallet_id| !self.in_flight.contains_key(wallet_id)) + .filter(|wallet_id| { + self.last_run.get(wallet_id).is_none_or(|last| { + now.saturating_duration_since(*last) >= TXO_RECONCILE_CADENCE + }) + }) + .collect() + } + + /// Claim the wallet for one run at `now`; `false` when a run is already + /// in flight. The claim restarts the wallet's cadence whether or not the + /// run goes ahead, so a run its gate refuses is not retried on every + /// poll — except a refusal for [`TxoReconcileSkip::NotSteadyState`] or + /// [`TxoReconcileSkip::WalletUnknown`], whose release + /// ([`TxoReconcileFinish::Deferred`]) leaves the wallet due for the + /// steady state to come, or for a wallet registered under the id. + pub fn try_begin(&mut self, wallet_id: WalletId, now: Instant) -> bool { + if self.in_flight.contains_key(&wallet_id) { + return false; + } + self.next_claim += 1; + let claim = ReconcileClaim { + id: self.next_claim, + ..ReconcileClaim::default() + }; + self.in_flight.insert(wallet_id, claim); + self.last_run.insert(wallet_id, now); + true + } + + /// Release the claim [`Self::try_begin`] took, at `now`, as `outcome` + /// says the run ended ([`TxoReconcileFinish`]): `NotRun` keeps the + /// claim's stamp, `Ran` restarts the wallet's cadence from `now`, and + /// `Deferred` leaves the wallet due. So does `Ran` when the run read its + /// whole budget, applied its last page, and left the wallet's + /// checkpoint set: it stopped for the budget, mid-cycle, and the next + /// poll continues the cycle. A run that stopped on a page it never + /// applied restarts the cadence: each continuation has applied a page + /// past the last, so the wallet is never due again for no progress. A + /// claim whose wallet was forgotten meanwhile records nothing. An + /// engine guard the run still holds is released. + pub fn finish(&mut self, wallet_id: &WalletId, outcome: TxoReconcileFinish, now: Instant) { + let Some(claim) = self.in_flight.remove(wallet_id) else { + return; + }; + if let Some(slot) = &claim.held { + release_slot(slot); + } + if claim.forgotten { + return; + } + let cycle_unfinished = claim.pages_left == 0 + && claim.pending.is_none() + && self.checkpoints.contains_key(wallet_id); + match outcome { + TxoReconcileFinish::NotRun => {} + TxoReconcileFinish::Ran if !cycle_unfinished => { + self.last_run.insert(*wallet_id, now); + } + TxoReconcileFinish::Ran | TxoReconcileFinish::Deferred => { + self.last_run.remove(wallet_id); + } + } + } + + /// Forget a removed wallet's cadence and checkpoint, so one re-added + /// under the same id is due at once and starts a new cycle. A run still + /// in flight keeps the wallet until it finishes, but cannot bring + /// either back. + pub fn forget(&mut self, wallet_id: &WalletId) { + self.last_run.remove(wallet_id); + self.checkpoints.remove(wallet_id); + if let Some(claim) = self.in_flight.get_mut(wallet_id) { + claim.forgotten = true; + } + } +} + +/// Lock the schedule, recovering from poisoning: every method leaves it +/// consistent at each step, so a panicking holder cannot tear it. +fn lock_schedule(schedule: &Mutex) -> MutexGuard<'_, TxoReconcileSchedule> { + schedule.lock().unwrap_or_else(PoisonError::into_inner) +} + +/// A claim [`TxoReconciler::begin`] took and has not handed to its caller +/// yet. Dropped while still armed — the gate refused, or the `begin` +/// future was dropped mid-await — it releases the claim as a run that did +/// not go ahead, so no caller is left owing a [`TxoReconciler::finish`] +/// it was never told about. Both the hand-over and the release act on this +/// claim only, by its id: a stray finish that released it while the gate +/// waited may have let another run claim the wallet. +struct ProvisionalClaim<'a> { + schedule: &'a Mutex, + wallet_id: WalletId, + id: u64, + armed: bool, +} + +impl<'a> ProvisionalClaim<'a> { + /// Claim `wallet_id`; `None` when a run is already in flight. + fn take( + schedule: &'a Mutex, + wallet_id: WalletId, + now: Instant, + ) -> Option { + // Built only once the claim is taken: an armed value dropped on the + // refusal would release the claim of the run already in flight. + let mut locked = lock_schedule(schedule); + if !locked.try_begin(wallet_id, now) { + return None; + } + let id = locked.in_flight.get(&wallet_id)?.id; + Some(Self { + schedule, + wallet_id, + id, + armed: true, + }) + } + + /// The caller now holds the claim, measured against `tip` and starting + /// where the wallet's checkpoint says, and releases it with + /// [`TxoReconciler::finish`]. + fn hand_over(mut self, tip: u32) -> TxoReconcileRun { + self.armed = false; + let mut schedule = lock_schedule(self.schedule); + let checkpoint = schedule.checkpoints.get(&self.wallet_id).cloned(); + let (heal_after, start) = match checkpoint { + None => (None, TxoReconcileStart::Heal { resumed: false }), + Some(Checkpoint::Heal(after)) => { + (Some(after), TxoReconcileStart::Heal { resumed: true }) + } + Some(Checkpoint::Classify(after)) => (None, TxoReconcileStart::Classify { after }), + }; + if let Some(claim) = schedule + .in_flight + .get_mut(&self.wallet_id) + .filter(|claim| claim.id == self.id) + { + claim.tip = tip; + claim.pages_left = TXO_RECONCILE_PAGE_BUDGET; + claim.heal_after = heal_after; + } + TxoReconcileRun { start } + } +} + +impl ProvisionalClaim<'_> { + /// Release the claim, if still armed, as a run that ended as `outcome` + /// says. + fn release(&mut self, outcome: TxoReconcileFinish) { + if !self.armed { + return; + } + self.armed = false; + let mut schedule = lock_schedule(self.schedule); + if schedule + .in_flight + .get(&self.wallet_id) + .is_some_and(|claim| claim.id == self.id) + { + schedule.finish(&self.wallet_id, outcome, Instant::now()); + } + } +} + +impl Drop for ProvisionalClaim<'_> { + fn drop(&mut self) { + self.release(TxoReconcileFinish::NotRun); + } +} + +/// A manager's reconcile schedule together with the state its gates read. +/// Cheap to clone; obtained from [`PlatformWalletManager::txo_reconciler`] +/// so a caller can wait on SPV without borrowing the manager. +#[derive(Clone)] +pub struct TxoReconciler { + schedule: Arc>, + spv: Arc, + wallet_manager: Arc>>, + wallets: Arc>>>, + sync_fault: Arc, +} + +impl TxoReconciler { + /// The wallets to reconcile now — see [`TxoReconcileSchedule::due_wallets`]. + /// SPV not running counts as not steady, which arms the next rising + /// edge. Call on every progress poll, running or not. + pub async fn due_wallets(&self) -> Vec { + let steady = self.scan_progress().await.is_some_and(|p| p.is_steady()); + // Read after the steady-state read, never before: a restart between + // the two then pairs the new session with the steady read and costs + // at most one extra (idempotent) run, where the other order would + // pair the old session with the new client's steady state and miss + // its rising edge until the cadence came round. + let session = self.spv.run_session(); + let wallets: Vec = self.wallets.load().keys().copied().collect(); + lock_schedule(&self.schedule).due_wallets(steady, session, wallets, Instant::now()) + } + + /// Claim `wallet_id` for one run and check that it may start, in order: + /// no run already in flight, SPV in steady state with a scan tip, no + /// latched sync fault, and the wallet's scan watermark within + /// [`TXO_RECONCILE_TIP_MARGIN`] blocks of the tip. The claim keeps that + /// scan tip: the run's heal verdicts are measured against it + /// ([`Self::heal_page_held`]), and the host never sees it. Returns + /// where the run starts ([`TxoReconcileStart`]), from the wallet's + /// checkpoint, and gives the run [`TXO_RECONCILE_PAGE_BUDGET`] + /// pages. On `Ok` the claim holds until [`Self::finish`]; a refusal + /// releases it, and so does dropping this future before it completes. + /// A refusal keeps the claim's cadence stamp, except + /// [`TxoReconcileSkip::NotSteadyState`]: SPV left its steady state, so + /// the wallet is due again once it is back, not a cadence later; and + /// [`TxoReconcileSkip::WalletUnknown`]: the wallet is not loaded (a + /// begin racing its removal), so one registered under the id is due at + /// once, as [`TxoReconcileSchedule::forget`] promises. + pub async fn begin(&self, wallet_id: &WalletId) -> Result { + self.claim_across(wallet_id, self.gate(wallet_id)).await + } + + /// Claim `wallet_id`, then await `gate` holding the claim provisionally: + /// it passes to the caller only with `gate`'s `Ok`. `gate` is not + /// polled before the claim is taken. + async fn claim_across( + &self, + wallet_id: &WalletId, + gate: impl Future>, + ) -> Result { + let mut claim = ProvisionalClaim::take(&self.schedule, *wallet_id, Instant::now()) + .ok_or(TxoReconcileSkip::AlreadyRunning)?; + let tip = gate.await.inspect_err(|skip| { + // Not steady yet, or the wallet is not (or no longer) loaded: no + // stamp, so the wallet is due as soon as it is steady and loaded. + // A late begin for a wallet removal forgot would otherwise leave + // a fresh stamp that a same-id wallet re-added in this steady + // session inherits. Neither can loop: due_wallets reports + // nothing while not steady, and lists only loaded wallets, so it + // never reports an unknown one. Every other refusal keeps the + // claim's stamp when it drops. + if matches!( + skip, + TxoReconcileSkip::NotSteadyState | TxoReconcileSkip::WalletUnknown + ) { + claim.release(TxoReconcileFinish::Deferred); + } + })?; + Ok(claim.hand_over(tip)) + } + + /// [`classify_outpoints`] for the run `wallet_id` holds a claim for + /// ([`Self::begin`]), answered under a read guard on the wallet manager + /// that is kept when this returns: it comes back as a + /// [`TxoReconcileHeldVerdicts`], which the host drops once it has + /// written the verdicts to its store. [`Self::finish`] releases it too, + /// and so does the manager's shutdown once SPV has stopped cleanly + /// (never before, and not at all after a failed stop). Only [`OutpointClass::KnownUncredited`] + /// marks a row spent, and only a spend in a block the wallet's ChainLock + /// covers earns it. + /// + /// Two things make a verdict safe to write. The host's own fences see + /// its persistence rounds, not the engine, and the engine can move with + /// no round at all: dash-spv follows a fork through + /// `WalletManager::truncate_above` under the write lock, which drops the + /// spenders above the fork and restores the coins they spent, and the + /// truncation reaches no persistence round (the wider gap of + /// truncations no host persists is dashpay/platform#5380). Marking the + /// row spent for a spender such a fork drops would retire a live coin + /// for good: no later run looks at a spent row, and the restore stops + /// handing it back. The write outlives the guard, so the spender must + /// be one no fork can drop: `truncate_above` refuses to go below the + /// wallet's ChainLock, and a verdict names only a spender at or below + /// it. The guard keeps the engine still between the read and the + /// write, so the store never records a verdict the engine has already + /// moved past. + /// + /// The guard is held for one page's apply, so it must not wait on + /// anything slow; see the module's lock order for what a host may do + /// while holding it. A run holds one guard at a time: the host drops a + /// page's before it asks about the next, and one it failed to drop is + /// released here before this waits. + /// + /// Each call is one page of the run's budget, an empty one too: the + /// host classifies every store page it reads, so the page can be + /// reported applied ([`Self::classify_page_applied`]). A run that has + /// used its budget gets [`TxoReconcileRead::BudgetSpent`] and reads + /// nothing. + /// + /// `None` — nothing classified, nothing held — when the wallet holds no + /// run claim, or its claim was released while this waited (a new claim + /// taken meanwhile does not get this guard). + pub async fn classify_held( + &self, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], + ) -> Option>> { + self.hold_engine(wallet_id, |wm, _| { + ( + classify_outpoints(wm, wallet_id, queries), + PendingPage::Classify, + ) + }) + .await + } + + /// [`txo_heal_page`] for the run `wallet_id` holds a claim for, held as + /// [`Self::classify_held`] holds its verdicts: the host drops the + /// [`TxoReconcileHeldVerdicts`] once it has written the page's eligible + /// rows. A fork that truncates the engine between the read and the + /// write would otherwise leave the store holding, unspent, a coin the + /// engine no longer owns — the classify pass answers `Unknown` for it + /// and leaves it, and the next launch restores it as spendable. The + /// same lock order and one-guard-per-run rule apply. + /// + /// Confirmations are counted to [`txo_heal_reference_height`] for the + /// claim's tip, read under the page's own guard, so each page is + /// measured against the engine it was read from. The host passes no + /// height, and no cursor either: the page starts where the run's last + /// applied heal page ended ([`Self::heal_page_applied`]), or where the + /// wallet's checkpoint left an earlier run. A page read again before it + /// is reported applied is the same page. Each call is one page of the + /// run's budget; a run that has used it gets + /// [`TxoReconcileRead::BudgetSpent`]. The page's `bool` is whether + /// another heal page follows; it is false on an empty page. + /// + /// `None` — nothing read, nothing held — when the wallet holds no run + /// claim, or its claim was released while this waited. + pub async fn heal_page_held( + &self, + wallet_id: &WalletId, + limit: usize, + ) -> Option, bool)>> { + self.hold_engine(wallet_id, |wm, run| { + let height = txo_heal_reference_height(wm, wallet_id, run.tip); + let (rows, has_more) = + txo_heal_page(wm, wallet_id, run.heal_after.as_ref(), limit, height); + let next = heal_page_next(&rows, has_more); + ((rows, next.is_some()), PendingPage::Heal(next)) + }) + .await + } + + /// The host has applied the heal page it last read: the run's next heal + /// page starts after it, and so does the wallet's next run if this one + /// stops here. After the last heal page the wallet's next run starts + /// at the classify pass. Returns whether the run may read another + /// page: it has a claim and budget left. + pub fn heal_page_applied(&self, wallet_id: &WalletId) -> bool { + self.page_applied(wallet_id, |claim| match claim.pending { + Some(PendingPage::Heal(next)) => { + claim.heal_after = next; + CheckpointMove::Set(match next { + Some(after) => Checkpoint::Heal(after), + None => Checkpoint::Classify(Vec::new()), + }) + } + _ => CheckpointMove::Ignore, + }) + } + + /// The host has applied the store page it last classified: the + /// wallet's next run starts after `next_after`, the host's cursor past + /// that page, or, when `next_after` is `None` because that was the + /// store's last page, with a new cycle. A cursor longer than + /// [`TXO_RECONCILE_STORE_CURSOR_MAX`] is not kept. Returns whether the + /// run may read another page. + pub fn classify_page_applied(&self, wallet_id: &WalletId, next_after: Option<&[u8]>) -> bool { + self.page_applied(wallet_id, |claim| match claim.pending { + Some(PendingPage::Classify) => match next_after { + Some(after) if after.len() > TXO_RECONCILE_STORE_CURSOR_MAX => { + CheckpointMove::Ignore + } + Some(after) => CheckpointMove::Set(Checkpoint::Classify(after.to_vec())), + None => CheckpointMove::Clear, + }, + _ => CheckpointMove::Ignore, + }) + } + + /// The body of the two `*_page_applied` calls: `advance` turns the + /// claim's pending page into a [`CheckpointMove`] for the wallet. + fn page_applied( + &self, + wallet_id: &WalletId, + advance: impl FnOnce(&mut ReconcileClaim) -> CheckpointMove, + ) -> bool { + let mut schedule = lock_schedule(&self.schedule); + let Some(claim) = schedule.in_flight.get_mut(wallet_id) else { + return false; + }; + let moved = advance(claim); + claim.pending = None; + let (forgotten, may_continue) = (claim.forgotten, claim.pages_left > 0); + match moved { + _ if forgotten => {} + CheckpointMove::Set(checkpoint) => { + schedule.checkpoints.insert(*wallet_id, checkpoint); + } + CheckpointMove::Clear => { + schedule.checkpoints.remove(wallet_id); + } + CheckpointMove::Ignore => {} + } + may_continue + } + + /// Answer `read` under a read guard on the wallet manager that is kept + /// when this returns, attached to `wallet_id`'s run claim — the body of + /// [`Self::classify_held`] and [`Self::heal_page_held`]. `read` gets + /// the claim's [`RunView`] and names the page it read, which the claim + /// keeps until the host reports it applied. A guard the run still holds + /// is released before this waits for the new one. The page is taken + /// from the run's budget before the wait. + async fn hold_engine( + &self, + wallet_id: &WalletId, + read: impl FnOnce(&WalletManager, &RunView) -> (T, PendingPage), + ) -> Option> { + let (claim_id, run) = { + let mut schedule = lock_schedule(&self.schedule); + let claim = schedule.in_flight.get_mut(wallet_id)?; + if let Some(previous) = claim.held.take() { + release_slot(&previous); + } + if claim.pages_left == 0 { + return Some(TxoReconcileRead::BudgetSpent); + } + claim.pages_left -= 1; + let run = RunView { + tip: claim.tip, + heal_after: claim.heal_after, + }; + (claim.id, run) + }; + let engine = Arc::clone(&self.wallet_manager).read_owned().await; + let (answer, pending) = read(&engine, &run); + let held = TxoReconcileHeldVerdicts { + slot: Arc::new(Mutex::new(Some(engine))), + }; + let mut schedule = lock_schedule(&self.schedule); + let claim = schedule + .in_flight + .get_mut(wallet_id) + .filter(|claim| claim.id == claim_id)?; + claim.held = Some(Arc::clone(&held.slot)); + claim.pending = Some(pending); + Some(TxoReconcileRead::Page(answer, held)) + } + + /// Claim `wallet_id` as a [`Self::begin`] whose gate measured `tip` + /// would, with no gate: for tests that need a run without a steady SPV + /// client. + #[cfg(any(test, feature = "test-utils"))] + pub fn claim_without_gate(&self, wallet_id: &WalletId, tip: u32) -> Option { + ProvisionalClaim::take(&self.schedule, *wallet_id, Instant::now()) + .map(|claim| claim.hand_over(tip)) + } + + /// Release the claim [`Self::begin`] granted, and the engine guard it + /// may still hold. `outcome` is how the run ended, which says when the + /// wallet is due again ([`TxoReconcileFinish`]): a run that went ahead + /// restarts its cadence from now, unless it used its budget mid-cycle + /// or its store deferred it, which leave the wallet due on the next + /// poll. + pub fn finish(&self, wallet_id: &WalletId, outcome: TxoReconcileFinish) { + lock_schedule(&self.schedule).finish(wallet_id, outcome, Instant::now()); + } + + async fn gate(&self, wallet_id: &WalletId) -> Result { + let tip = txo_reconcile_scan_gate( + self.scan_progress().await, + self.sync_fault.load(Ordering::Relaxed), + )?; + let synced_height = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(wallet_id) + .map(|info| info.synced_height()) + }; + txo_reconcile_wallet_gate(synced_height, tip)?; + Ok(tip) + } + + /// The client's progress, or `None` when it is not running. + async fn scan_progress(&self) -> Option { + if !self.spv.is_running() { + return None; + } + self.spv + .sync_progress() + .await + .map(|progress| ScanProgress::from_sync_progress(&progress)) + } +} + +impl PlatformWalletManager

{ + /// This manager's TXO-store reconcile schedule and gates. + pub fn txo_reconciler(&self) -> TxoReconciler { + TxoReconciler { + schedule: Arc::clone(&self.txo_reconcile_schedule), + spv: Arc::clone(&self.spv_manager), + wallet_manager: Arc::clone(&self.wallet_manager), + wallets: Arc::clone(&self.wallets), + sync_fault: Arc::clone(&self.sync_fault), + } + } + + /// Drop a removed wallet's reconcile cadence. A run still applying + /// keeps its engine guard: the removal waits for that one page. + pub(super) fn forget_txo_reconcile(&self, wallet_id: &WalletId) { + lock_schedule(&self.txo_reconcile_schedule).forget(wallet_id); + } + + /// Stop SPV, then release every engine guard a reconcile run still + /// holds — only if the stop succeeded. Returns the stop's outcome. + /// + /// See [`release_txo_holds_after_stop`] for why the order matters. + pub(super) async fn stop_spv_then_release_txo_reconcile_holds( + &self, + ) -> Result<(), PlatformWalletError> { + release_txo_holds_after_stop(&self.txo_reconcile_schedule, self.spv_manager.stop()).await + } +} + +/// Run `stop_event_source` (the SPV stop), then, only once it has returned +/// `Ok`, release every engine guard a reconcile run in `schedule` still +/// holds. Returns the stop's outcome. +/// +/// The guard of a held page is what keeps a fork out of the engine while the +/// host writes that page; the host may still be writing it when teardown +/// starts (its store step passed its stop check before shutdown began). So a +/// guard is never revoked while anything that can apply a fork — SPV, the +/// only writer that truncates the engine — may still run: +/// +/// - **Before the stop**, nothing is released. An SPV writer that queues +/// behind a held guard waits for the host to finish its write and drop +/// the guard; the stop's own deadline bounds that wait, so a stalled host +/// becomes a non-clean stop, not a hang. +/// - **After an `Ok` stop**, SPV's run loop and its fork recovery have been +/// joined, so no fork can land under a released guard. A guard released +/// here is one the host failed to drop, and must not hold the teardown's +/// later writers off. +/// - **After a failed or timed-out stop**, nothing is released: SPV work is +/// still live and could apply a fork the moment the guard went. The slot +/// is shared with the host's [`TxoReconcileHeldVerdicts`], so the guard +/// lives until the host drops that (or forever, if it leaked it). That +/// fails closed; the stop's error already makes the shutdown non-clean, +/// and a later shutdown whose stop succeeds releases it. +pub(super) async fn release_txo_holds_after_stop( + schedule: &Mutex, + stop_event_source: impl Future>, +) -> Result<(), E> { + stop_event_source.await?; + let schedule = lock_schedule(schedule); + for slot in schedule + .in_flight + .values() + .filter_map(|claim| claim.held.as_ref()) + { + release_slot(slot); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::future::pending; + use std::pin::pin; + use std::task::{Context, Waker}; + + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::{OutPoint, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::test_utils::TestWalletContext; + use key_wallet_manager::WalletInterface; + use tokio::time::timeout; + + use crate::events::PlatformEventManager; + use crate::manager::accessors::classify_outpoints; + use crate::manager::accessors::txo_inventory_tests::{ + bip44_account_0, collateral_burn, funding, in_block, manager_with, query, + }; + + const TIP: u32 = 2_535_898; + + fn progress(state: SyncState, filters: Option<(u32, u32)>, headers: u32) -> ScanProgress { + ScanProgress { + state, + filters: filters.map(|(current, target)| PhaseHeights { current, target }), + headers: Some(PhaseHeights { + current: headers, + target: headers, + }), + } + } + + fn steady() -> ScanProgress { + progress(SyncState::WaitForEvents, Some((TIP, TIP)), TIP) + } + + fn syncing() -> ScanProgress { + progress(SyncState::Syncing, Some((199_999, TIP)), TIP) + } + + /// What a client that is not running reports. + fn not_running() -> ScanProgress { + ScanProgress { + state: SyncState::WaitForEvents, + filters: None, + headers: None, + } + } + + #[test] + fn the_steady_state_gate_refuses_an_unfinished_scan() { + assert!(!syncing().is_steady()); + assert!(!progress(SyncState::WaitingForConnections, None, TIP).is_steady()); + assert!(!progress(SyncState::Error, Some((TIP, TIP)), TIP).is_steady()); + assert!( + !progress(SyncState::WaitForEvents, Some((2_535_800, TIP)), TIP).is_steady(), + "waiting for events with the filter phase behind its target is a scan still running" + ); + assert!( + !progress(SyncState::WaitForEvents, None, TIP).is_steady(), + "no filter phase at all proves nothing" + ); + assert!( + !progress(SyncState::WaitForEvents, Some((0, 0)), TIP).is_steady(), + "a filter phase with no target proves nothing" + ); + assert!(!not_running().is_steady()); + assert!(progress(SyncState::Synced, Some((TIP, TIP)), TIP).is_steady()); + assert!(steady().is_steady(), "dash-spv's fully synced steady state"); + } + + #[test] + fn the_tip_is_the_filter_height_falling_back_to_the_header_tip() { + assert_eq!(steady().scan_tip(), Some(TIP)); + assert_eq!( + progress(SyncState::Synced, None, TIP).scan_tip(), + Some(TIP), + "falls back to the header tip" + ); + assert_eq!( + progress(SyncState::Synced, Some((0, 0)), 2_535_000).scan_tip(), + Some(2_535_000), + "a filter phase at height 0 falls back too" + ); + assert_eq!(not_running().scan_tip(), None); + } + + #[test] + fn a_default_progress_read_is_not_steady_and_has_no_tip() { + let read = ScanProgress::from_sync_progress(&SyncProgress::default()); + assert_eq!(read, not_running()); + } + + #[test] + fn the_scan_gate_checks_steady_state_then_the_tip_then_the_fault() { + assert_eq!(txo_reconcile_scan_gate(Some(steady()), false), Ok(TIP)); + assert_eq!( + txo_reconcile_scan_gate(None, false), + Err(TxoReconcileSkip::NotSteadyState), + "a client that is not running is not steady" + ); + assert_eq!( + txo_reconcile_scan_gate(Some(syncing()), true), + Err(TxoReconcileSkip::NotSteadyState) + ); + let synced_without_heights = ScanProgress { + state: SyncState::Synced, + filters: None, + headers: None, + }; + assert_eq!( + txo_reconcile_scan_gate(Some(synced_without_heights), true), + Err(TxoReconcileSkip::TipUnavailable) + ); + assert_eq!( + txo_reconcile_scan_gate(Some(steady()), true), + Err(TxoReconcileSkip::SyncFaultDetected), + "a rejected round means rows are missing by design" + ); + } + + #[test] + fn the_wallet_gate_uses_the_engines_watermark_within_the_tip_margin() { + // `synced_height + margin >= tip`. + assert_eq!( + txo_reconcile_wallet_gate(Some(1_565_211), 1_565_211), + Ok(()) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(1_565_205), 1_565_211), + Ok(()) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(1_565_204), 1_565_211), + Err(TxoReconcileSkip::WalletBehindTip) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(0), 1_565_211), + Err(TxoReconcileSkip::WalletBehindTip), + "a wallet added behind the tip is still being scanned" + ); + assert_eq!( + txo_reconcile_wallet_gate(None, 3), + Err(TxoReconcileSkip::WalletUnknown) + ); + assert_eq!( + txo_reconcile_wallet_gate(Some(u32::MAX), u32::MAX), + Ok(()), + "the margin saturates rather than overflowing" + ); + } + + fn row(height: u32, is_confirmed: bool) -> WalletUtxoRow { + let mut script = vec![0x76, 0xa9, 0x14]; + script.extend([0x5b; 20]); + script.extend([0x88, 0xac]); + WalletUtxoRow { + account_type: AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + outpoint: OutPoint { + txid: Txid::from_byte_array([0x11; 32]), + vout: 0, + }, + value_duffs: 19_549, + script_pubkey: script, + address: "yStxXHHzhAx58JhaPBNhn3xsH93UwBM2nd".to_owned(), + height, + is_confirmed, + is_instantlocked: false, + is_coinbase: false, + is_locked: false, + } + } + + #[test] + fn a_coin_is_healed_only_at_one_hundred_engine_confirmations() { + let tip = 1_000_000; + assert_eq!( + txo_heal_verdict(&row(tip - 99, true), tip), + TxoHealVerdict::Eligible, + "the tip block counts: 100 confirmations" + ); + assert_eq!( + txo_heal_verdict(&row(tip - 98, true), tip), + TxoHealVerdict::Immature, + "99 confirmations ages into a later run" + ); + assert_eq!( + txo_heal_verdict(&row(tip - 500, false), tip), + TxoHealVerdict::Immature, + "a coin the engine does not call confirmed is never healed, however deep" + ); + assert_eq!( + txo_heal_verdict(&row(0, true), tip), + TxoHealVerdict::Immature, + "no height" + ); + assert_eq!( + txo_heal_verdict(&row(tip + 1, true), tip), + TxoHealVerdict::Immature, + "above the tip" + ); + } + + #[test] + fn a_coin_the_store_could_not_file_is_invalid() { + let tip = 1_000_000; + let mut no_script = row(500_000, true); + no_script.script_pubkey.clear(); + assert_eq!(txo_heal_verdict(&no_script, tip), TxoHealVerdict::Invalid); + let mut no_address = row(500_000, true); + no_address.address.clear(); + assert_eq!(txo_heal_verdict(&no_address, tip), TxoHealVerdict::Invalid); + let mut immature_and_invalid = row(tip, false); + immature_and_invalid.address.clear(); + assert_eq!( + txo_heal_verdict(&immature_and_invalid, tip), + TxoHealVerdict::Invalid, + "validity is judged first" + ); + } + + #[test] + fn an_unknown_wallet_is_an_empty_terminal_heal_page() { + let wm = WalletManager::::new(dashcore::Network::Testnet); + let (rows, has_more) = txo_heal_page(&wm, &[0xEE; 32], None, 10, TIP); + assert!(rows.is_empty()); + assert!(!has_more); + } + + #[test] + fn an_empty_heal_page_is_always_the_last() { + assert_eq!(heal_page_next(&[], true), None); + assert_eq!(heal_page_next(&[], false), None); + let rows = [(row(TIP, true), TxoHealVerdict::Eligible)]; + assert_eq!(heal_page_next(&rows, false), None); + assert_eq!( + heal_page_next(&rows, true), + Some((rows[0].0.account_type, rows[0].0.outpoint)) + ); + } + + /// A run's empty heal page reports no more pages and moves the wallet + /// on to the classify pass. + #[tokio::test] + async fn an_empty_heal_page_ends_the_heal_pass() { + let reconciler = reconciler(); + assert!(reconciler.claim_without_gate(&A, TIP).is_some()); + let ((rows, has_more), held) = page(reconciler.heal_page_held(&A, 10).await); + drop(held); + assert!(rows.is_empty()); + assert!(!has_more); + assert!(reconciler.heal_page_applied(&A)); + assert_eq!( + lock_schedule(&reconciler.schedule).checkpoints.get(&A), + Some(&Checkpoint::Classify(Vec::new())) + ); + } + + #[test] + fn a_stale_page_is_retried_five_times_in_a_row_then_the_run_stops() { + for stale_in_a_row in 1..=5 { + assert!(txo_reconcile_retry_stale_page(stale_in_a_row)); + } + assert!(!txo_reconcile_retry_stale_page(6)); + assert_eq!(TXO_RECONCILE_MAX_STALE_RETRIES, 5); + } + + const A: WalletId = [0xA1; 32]; + const B: WalletId = [0xB2; 32]; + + /// What a host does with the wallets it is told are due: claims each at + /// `at` and finishes it there. + fn run(schedule: &mut TxoReconcileSchedule, due: &[WalletId], at: Instant) { + for wallet_id in due { + assert!(schedule.try_begin(*wallet_id, at)); + schedule.finish(wallet_id, TxoReconcileFinish::Ran, at); + } + } + + #[test] + fn every_wallet_runs_on_the_rising_edge_into_steady_state() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert!(schedule.due_wallets(false, 1, [A, B], t0).is_empty()); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + run(&mut schedule, &[A, B], t0); + assert!( + schedule + .due_wallets(true, 1, [A, B], t0 + Duration::from_secs(1)) + .is_empty(), + "the next poll inside the cadence schedules nothing" + ); + } + + #[test] + fn a_steady_client_runs_each_wallet_again_on_its_own_cadence() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let half = TXO_RECONCILE_CADENCE / 2; + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + // A wallet loaded while steady never ran: it is due at once, on its + // own clock. + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0 + half), vec![B]); + run(&mut schedule, &[B], t0 + half); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + TXO_RECONCILE_CADENCE), + vec![A] + ); + run(&mut schedule, &[A], t0 + TXO_RECONCILE_CADENCE); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + half + TXO_RECONCILE_CADENCE), + vec![B] + ); + run(&mut schedule, &[B], t0 + half + TXO_RECONCILE_CADENCE); + // A finished run restarts that wallet's cadence from its finish. + let a_ran = t0 + TXO_RECONCILE_CADENCE + half + half / 2; + assert!(schedule.try_begin(A, a_ran - Duration::from_secs(60))); + schedule.finish(&A, TxoReconcileFinish::Ran, a_ran); + assert!(schedule + .due_wallets(true, 1, [A, B], t0 + 2 * TXO_RECONCILE_CADENCE) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], a_ran + TXO_RECONCILE_CADENCE), + vec![A, B] + ); + } + + #[test] + fn a_wallet_with_a_run_in_flight_is_never_scheduled_or_started_again() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert!(schedule.try_begin(A, t0)); + assert!( + !schedule.try_begin(A, t0), + "a second run for the same wallet is refused" + ); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0), + vec![B], + "not even on the rising edge" + ); + schedule.finish(&A, TxoReconcileFinish::NotRun, Instant::now()); + assert!(schedule.try_begin(A, t0)); + } + + #[test] + fn a_wallet_the_host_skips_is_due_again_on_the_next_poll() { + // The host learns of a wallet the engine already holds before it + // can run it (its own wrapper is still being built), and skips it. + // Being reported spent none of its cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + run(&mut schedule, &[A], t0); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + second), + vec![B], + "the skipped wallet is reported again" + ); + assert_eq!( + schedule.due_wallets(true, 1, [A, B], t0 + 2 * second), + vec![B], + "and again, until a run claims it" + ); + run(&mut schedule, &[B], t0 + 2 * second); + assert!(schedule + .due_wallets(true, 1, [A, B], t0 + 3 * second) + .is_empty()); + } + + #[test] + fn a_wallet_the_host_skips_on_a_new_steady_state_is_due_until_claimed() { + // A wallet ran recently, then SPV restarts into steady state. The + // host skips it on the new session's first poll: it must not wait + // out the old run's cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + assert_eq!( + schedule.due_wallets(true, 2, [A], t0 + second), + vec![A], + "a new steady state makes it due" + ); + assert_eq!( + schedule.due_wallets(true, 2, [A], t0 + 2 * second), + vec![A], + "and the skip consumed nothing" + ); + run(&mut schedule, &[A], t0 + 2 * second); + assert!(schedule + .due_wallets(true, 2, [A], t0 + 3 * second) + .is_empty()); + } + + #[test] + fn a_run_in_flight_across_a_new_steady_state_stamps_its_own_finish() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + assert!(schedule.try_begin(A, t0)); + assert!(schedule.try_begin(B, t0)); + assert!( + schedule + .due_wallets(true, 2, [A, B], t0 + second) + .is_empty(), + "runs in flight are not reported" + ); + // A went ahead and restarts its cadence; B's gate refused it in the + // old session, so it never ran in the new one. + schedule.finish(&A, TxoReconcileFinish::Ran, t0 + 2 * second); + schedule.finish(&B, TxoReconcileFinish::NotRun, Instant::now()); + assert_eq!( + schedule.due_wallets(true, 2, [A, B], t0 + 3 * second), + vec![B] + ); + } + + #[test] + fn a_claim_the_gate_refuses_still_restarts_the_cadence() { + // A refused run is not retried on every poll: the claim itself + // restarts the cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + assert!(schedule.try_begin(A, t0)); + schedule.finish(&A, TxoReconcileFinish::NotRun, Instant::now()); + assert!(schedule + .due_wallets(true, 1, [A], t0 + Duration::from_secs(1)) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + TXO_RECONCILE_CADENCE), + vec![A] + ); + } + + #[test] + fn leaving_steady_state_arms_the_next_rising_edge() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + assert!(schedule + .due_wallets(false, 1, [A], t0 + Duration::from_secs(1)) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + Duration::from_secs(2)), + vec![A], + "back in steady state: due again, cadence or not" + ); + } + + #[test] + fn a_restart_between_two_polls_is_a_new_steady_state() { + // SPV stops and restarts straight into steady state between two + // polls: no poll saw it stopped, but the run session moved. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A, B], t0), vec![A, B]); + run(&mut schedule, &[A, B], t0); + assert!(schedule + .due_wallets(true, 1, [A, B], t0 + Duration::from_secs(1)) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 2, [A, B], t0 + Duration::from_secs(2)), + vec![A, B], + "the first steady read after the restart is a new steady state" + ); + run(&mut schedule, &[A, B], t0 + Duration::from_secs(2)); + assert!( + schedule + .due_wallets(true, 2, [A, B], t0 + Duration::from_secs(3)) + .is_empty(), + "and only the first" + ); + } + + #[test] + fn a_forgotten_wallet_is_due_at_once() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + run(&mut schedule, &[A], t0); + schedule.forget(&A); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + Duration::from_secs(1)), + vec![A], + "a re-added wallet is due at once" + ); + } + + #[test] + fn a_run_finishing_after_its_wallet_was_forgotten_restores_no_cadence() { + // The wallet is removed mid-run; the host stops the run and still + // reports it as having gone ahead. The same wallet id, re-added + // while SPV stays steady, must not wait out the old run's cadence. + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + assert!(schedule.try_begin(A, t0)); + schedule.forget(&A); + assert!( + !schedule.try_begin(A, t0 + second), + "the old run holds the wallet until it exits" + ); + assert!(schedule.due_wallets(true, 1, [A], t0 + second).is_empty()); + schedule.finish(&A, TxoReconcileFinish::Ran, t0 + 2 * second); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + 3 * second), + vec![A], + "the forgotten lifecycle's finish brought no history back" + ); + assert!(schedule.try_begin(A, t0 + 3 * second)); + schedule.finish(&A, TxoReconcileFinish::Ran, t0 + 4 * second); + assert!( + schedule + .due_wallets(true, 1, [A], t0 + 5 * second) + .is_empty(), + "the new lifecycle's own run restarts its cadence" + ); + } + + fn reconciler() -> TxoReconciler { + reconciler_over(WalletManager::::new( + dashcore::Network::Testnet, + )) + } + + fn reconciler_over(wallet_manager: WalletManager) -> TxoReconciler { + let wallet_manager = Arc::new(RwLock::new(wallet_manager)); + let spv = Arc::new(SpvRuntime::new( + Arc::clone(&wallet_manager), + Arc::new(PlatformEventManager::new(Vec::new())), + )); + TxoReconciler { + schedule: Arc::default(), + spv, + wallet_manager, + wallets: Arc::new(ArcSwap::from_pointee(BTreeMap::new())), + sync_fault: Arc::new(AtomicBool::new(false)), + } + } + + #[tokio::test] + async fn a_stopped_client_schedules_nothing_and_refuses_a_run() { + let reconciler = reconciler(); + assert!(reconciler.due_wallets().await.is_empty()); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState) + ); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState), + "a refused run releases its claim" + ); + } + + #[tokio::test] + async fn a_granted_claim_holds_until_finish() { + let reconciler = reconciler(); + assert!(lock_schedule(&reconciler.schedule).try_begin(A, Instant::now())); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::AlreadyRunning) + ); + reconciler.finish(&A, TxoReconcileFinish::Ran); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState) + ); + } + + #[tokio::test] + async fn a_begin_dropped_mid_gate_releases_its_claim() { + let reconciler = reconciler(); + { + let mut blocked = pin!(reconciler.claim_across(&A, pending())); + let mut cx = Context::from_waker(Waker::noop()); + assert!(blocked.as_mut().poll(&mut cx).is_pending()); + assert!( + !lock_schedule(&reconciler.schedule).try_begin(A, Instant::now()), + "the gate is awaited holding the claim" + ); + } + assert!( + lock_schedule(&reconciler.schedule).try_begin(A, Instant::now()), + "dropping the blocked begin released it" + ); + } + + #[tokio::test] + async fn only_a_gate_that_passes_hands_the_claim_over() { + let reconciler = reconciler(); + assert_eq!( + reconciler + .claim_across(&A, async { Err(TxoReconcileSkip::WalletBehindTip) }) + .await, + Err(TxoReconcileSkip::WalletBehindTip) + ); + let claim_tip = || { + lock_schedule(&reconciler.schedule) + .in_flight + .get(&A) + .map(|claim| claim.tip) + }; + assert!( + reconciler.claim_across(&A, async { Ok(TIP) }).await.is_ok(), + "the refusal released its claim" + ); + assert_eq!(claim_tip(), Some(TIP), "the claim keeps the gate's tip"); + assert_eq!( + reconciler.claim_across(&A, async { Ok(TIP + 1) }).await, + Err(TxoReconcileSkip::AlreadyRunning), + "the granted claim outlives the begin that took it" + ); + assert_eq!(claim_tip(), Some(TIP), "a refused claim leaves the tip"); + reconciler.finish(&A, TxoReconcileFinish::Ran); + assert!(reconciler + .claim_across(&A, async { Ok(TIP + 1) }) + .await + .is_ok()); + assert_eq!(claim_tip(), Some(TIP + 1)); + } + + /// A run reads at most its budget of pages, heal and classify pages + /// alike, then gets no read at all; the next run has a new budget. + #[tokio::test] + async fn a_run_reads_at_most_its_page_budget() { + let reconciler = reconciler(); + assert!(reconciler.claim_without_gate(&A, TIP).is_some()); + for _ in 0..TXO_RECONCILE_PAGE_BUDGET / 2 { + drop(page(reconciler.heal_page_held(&A, 10).await)); + drop(classify(&reconciler, &A, &[]).await); + } + assert!( + !reconciler.classify_page_applied(&A, Some(b"x")), + "the last page leaves nothing to read" + ); + assert!(matches!( + reconciler.heal_page_held(&A, 10).await, + Some(TxoReconcileRead::BudgetSpent) + )); + assert!(matches!( + reconciler.classify_held(&A, &[]).await, + Some(TxoReconcileRead::BudgetSpent) + )); + assert!( + !holds_engine(&reconciler, &A), + "a refused read holds nothing" + ); + reconciler.finish(&A, TxoReconcileFinish::Ran); + + assert!(reconciler.claim_without_gate(&A, TIP).is_some()); + drop(page(reconciler.heal_page_held(&A, 10).await)); + reconciler.finish(&A, TxoReconcileFinish::Ran); + } + + /// A wallet whose engine holds three coins. + async fn wallet_with_three_coins() -> (WalletManager, WalletId) { + wallet_with_coins(3).await + } + + /// A wallet whose engine holds `count` coins. + async fn wallet_with_coins(count: u8) -> (WalletManager, WalletId) { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + for seed in 51u8..51 + count { + assert!( + ctx.check_transaction(&funding(script.clone(), seed, 3_000), in_block(100_000)) + .await + .is_relevant + ); + } + manager_with(ctx) + } + + /// One heal page of one row, held; the row's outpoint and whether more + /// follow. + async fn heal_one(reconciler: &TxoReconciler, wallet_id: &WalletId) -> (OutPoint, bool) { + let ((rows, has_more), held) = page(reconciler.heal_page_held(wallet_id, 1).await); + drop(held); + (rows[0].0.outpoint, has_more) + } + + /// Each run starts where the last page its host reported applied left + /// off: a heal page read but not applied is read again, the heal pass's + /// end moves the wallet to the classify pass, the host's store cursor + /// comes back as it was given, and the classify pass's end starts a new + /// cycle. + #[tokio::test] + async fn the_next_run_resumes_after_the_last_applied_page() { + let (wm, wallet_id) = wallet_with_three_coins().await; + let reconciler = reconciler_over(wm); + let start = |reconciler: &TxoReconciler| { + reconciler + .claim_without_gate(&wallet_id, TIP) + .expect("claimed") + .start + }; + + assert_eq!( + start(&reconciler), + TxoReconcileStart::Heal { resumed: false } + ); + let (first, _) = heal_one(&reconciler, &wallet_id).await; + assert!(reconciler.heal_page_applied(&wallet_id)); + let (second, _) = heal_one(&reconciler, &wallet_id).await; + assert_ne!(first, second, "the run moved past the applied page"); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + assert_eq!( + start(&reconciler), + TxoReconcileStart::Heal { resumed: true } + ); + assert_eq!( + heal_one(&reconciler, &wallet_id).await, + (second, true), + "the page not reported applied is read again" + ); + assert!(reconciler.heal_page_applied(&wallet_id)); + let (_, more) = heal_one(&reconciler, &wallet_id).await; + assert!(!more, "the last heal page"); + assert!(reconciler.heal_page_applied(&wallet_id)); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + assert_eq!( + start(&reconciler), + TxoReconcileStart::Classify { after: Vec::new() } + ); + drop(classify(&reconciler, &wallet_id, &[]).await); + assert!(reconciler.classify_page_applied(&wallet_id, Some(b"store cursor"))); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + assert_eq!( + start(&reconciler), + TxoReconcileStart::Classify { + after: b"store cursor".to_vec() + } + ); + drop(classify(&reconciler, &wallet_id, &[]).await); + assert!(reconciler.classify_page_applied(&wallet_id, None)); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + assert_eq!( + start(&reconciler), + TxoReconcileStart::Heal { resumed: false } + ); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + } + + /// Whether the steady poll at `now` reports `wallet_id` due. The first + /// call is the rising edge into steady state. + fn due_at(reconciler: &TxoReconciler, wallet_id: &WalletId, now: Instant) -> bool { + lock_schedule(&reconciler.schedule) + .due_wallets(true, 1, [*wallet_id], now) + .contains(wallet_id) + } + + fn due_now(reconciler: &TxoReconciler, wallet_id: &WalletId) -> bool { + due_at(reconciler, wallet_id, Instant::now()) + } + + /// One run as a host does it: heal pages of one row each until the heal + /// pass ends, then the classify pass as one store page (its last), each + /// page applied, until the cycle ends or the budget does; then finish + /// as ran. Adds the outpoints it healed to `healed`; returns whether + /// the classify pass ran, which ends the cycle. + async fn host_run( + reconciler: &TxoReconciler, + wallet_id: &WalletId, + healed: &mut std::collections::BTreeSet, + ) -> bool { + let run = reconciler + .claim_without_gate(wallet_id, TIP) + .expect("claimed"); + let mut healing = matches!(run.start, TxoReconcileStart::Heal { .. }); + let mut classified = false; + loop { + let may_continue = if healing { + let Some(TxoReconcileRead::Page((rows, more), held)) = + reconciler.heal_page_held(wallet_id, 1).await + else { + break; + }; + drop(held); + healed.extend(rows.iter().map(|(row, _)| row.outpoint)); + healing = more; + reconciler.heal_page_applied(wallet_id) + } else { + let Some(TxoReconcileRead::Page(_, held)) = + reconciler.classify_held(wallet_id, &[]).await + else { + break; + }; + drop(held); + reconciler.classify_page_applied(wallet_id, None); + classified = true; + false + }; + if !may_continue { + break; + } + } + reconciler.finish(wallet_id, TxoReconcileFinish::Ran); + classified + } + + /// A run that used its budget mid-cycle leaves the wallet due on the + /// next poll, with no cadence to wait out; the run that ends the cycle + /// restarts the cadence. The edge: a heal pass that exactly fills the + /// budget, whose last page's ack moves the wallet to the classify pass + /// with no budget left. + #[tokio::test] + async fn a_run_that_spends_its_budget_mid_cycle_leaves_the_wallet_due() { + let (wm, wallet_id) = wallet_with_coins(TXO_RECONCILE_PAGE_BUDGET as u8).await; + let reconciler = reconciler_over(wm); + assert!(due_now(&reconciler, &wallet_id), "the rising edge"); + let mut healed = std::collections::BTreeSet::new(); + + assert!(!host_run(&reconciler, &wallet_id, &mut healed).await); + assert_eq!(healed.len(), TXO_RECONCILE_PAGE_BUDGET as usize); + assert_eq!( + lock_schedule(&reconciler.schedule) + .checkpoints + .get(&wallet_id), + Some(&Checkpoint::Classify(Vec::new())), + "the last heal page's ack moved the wallet to the classify pass" + ); + assert!( + due_now(&reconciler, &wallet_id), + "the cycle is unfinished: due again on the next poll" + ); + + assert!(host_run(&reconciler, &wallet_id, &mut healed).await); + let finished = Instant::now(); + assert!( + !due_now(&reconciler, &wallet_id), + "the cycle ended: the cadence restarted" + ); + assert!(due_at( + &reconciler, + &wallet_id, + finished + TXO_RECONCILE_CADENCE + )); + } + + /// An inventory larger than one budget is healed to its tail and the + /// store classified on consecutive polls, with no time passing; so is it + /// when the manager is recreated between two budget-spent runs, which + /// loses the in-memory checkpoint and starts a new cycle. + #[tokio::test] + async fn a_cycle_larger_than_the_budget_finishes_on_consecutive_polls() { + let coins = TXO_RECONCILE_PAGE_BUDGET as u8 + 4; + let (wm, wallet_id) = wallet_with_coins(coins).await; + let reconciler = reconciler_over(wm); + assert!(due_now(&reconciler, &wallet_id)); + let mut healed = std::collections::BTreeSet::new(); + assert!(!host_run(&reconciler, &wallet_id, &mut healed).await); + assert!(due_now(&reconciler, &wallet_id), "the heal pass continues"); + assert!(host_run(&reconciler, &wallet_id, &mut healed).await); + assert_eq!(healed.len(), coins as usize, "every coin, the tail too"); + assert!(!due_now(&reconciler, &wallet_id), "the cycle is over"); + + // The manager recreated after a budget-spent run: same engine, a + // fresh schedule. + let reconciler = TxoReconciler { + schedule: Arc::default(), + ..reconciler + }; + assert!(due_now(&reconciler, &wallet_id)); + let mut healed = std::collections::BTreeSet::new(); + assert!(!host_run(&reconciler, &wallet_id, &mut healed).await); + let recreated = TxoReconciler { + schedule: Arc::default(), + ..reconciler + }; + assert!( + due_now(&recreated, &wallet_id), + "a new process: due at once" + ); + let mut healed = std::collections::BTreeSet::new(); + let mut polls = 0; + while due_now(&recreated, &wallet_id) { + polls += 1; + assert!(polls <= 2, "the new cycle finishes in two runs"); + host_run(&recreated, &wallet_id, &mut healed).await; + } + assert_eq!(polls, 2); + assert_eq!(healed.len(), coins as usize, "every coin, the tail too"); + } + + /// How a run ended decides when its wallet is due again: a deferred run + /// leaves it due, a run that went ahead (its cycle over) restarts the + /// cadence at its finish, and one that did not go ahead keeps its + /// claim's stamp. + #[test] + fn the_finish_outcome_says_when_the_wallet_is_due_again() { + let mut schedule = TxoReconcileSchedule::default(); + let t0 = Instant::now(); + let second = Duration::from_secs(1); + let minutes = |m: u64| Duration::from_secs(60 * m); + assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); + + assert!(schedule.try_begin(A, t0)); + schedule.finish(&A, TxoReconcileFinish::Deferred, t0 + second); + assert_eq!( + schedule.due_wallets(true, 1, [A], t0 + 2 * second), + vec![A], + "deferred: due on the next poll" + ); + + let ran = t0 + 3 * second; + assert!(schedule.try_begin(A, ran)); + schedule.finish(&A, TxoReconcileFinish::Ran, ran + minutes(5)); + assert!(schedule + .due_wallets(true, 1, [A], ran + TXO_RECONCILE_CADENCE) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A], ran + minutes(5) + TXO_RECONCILE_CADENCE), + vec![A], + "ran: the cadence restarts at the finish" + ); + + let claimed = ran + minutes(5) + TXO_RECONCILE_CADENCE; + assert!(schedule.try_begin(A, claimed)); + schedule.finish(&A, TxoReconcileFinish::NotRun, claimed + minutes(5)); + assert!(schedule + .due_wallets(true, 1, [A], claimed + second) + .is_empty()); + assert_eq!( + schedule.due_wallets(true, 1, [A], claimed + TXO_RECONCILE_CADENCE), + vec![A], + "not run: the claim's stamp stands" + ); + } + + /// A begin refused because SPV is not steady leaves the wallet due, so a + /// new steady session runs it on its next poll rather than a cadence + /// later; any other refusal keeps the claim's stamp. + #[tokio::test] + async fn a_begin_refused_for_no_steady_state_leaves_the_wallet_due() { + let reconciler = reconciler(); + assert!(due_now(&reconciler, &A), "a new steady session"); + assert_eq!( + reconciler.begin(&A).await, + Err(TxoReconcileSkip::NotSteadyState), + "the client is not running yet" + ); + assert!( + due_now(&reconciler, &A), + "the steady poll reports the wallet due" + ); + + assert_eq!( + reconciler + .claim_across(&A, async { Err(TxoReconcileSkip::WalletBehindTip) }) + .await, + Err(TxoReconcileSkip::WalletBehindTip) + ); + assert!( + !due_now(&reconciler, &A), + "another refusal restarts the cadence" + ); + } + + /// A begin that arrives after the wallet's removal is refused as + /// unknown and leaves no stamp, so a wallet re-added under the same id + /// within the same steady session is due on the next poll, not a + /// cadence later. + #[tokio::test] + async fn a_begin_refused_for_an_unknown_wallet_leaves_a_re_added_wallet_due() { + let reconciler = reconciler(); + assert!(due_now(&reconciler, &A), "a new steady session"); + + // Removed: the manager forgets the wallet's cadence. + lock_schedule(&reconciler.schedule).forget(&A); + assert_eq!( + reconciler + .claim_across(&A, async { Err(TxoReconcileSkip::WalletUnknown) }) + .await, + Err(TxoReconcileSkip::WalletUnknown), + "a begin racing the removal" + ); + + // Re-added under the same id, with no new steady-state edge. + assert!( + due_now(&reconciler, &A), + "the re-added wallet is due on the same session's next poll" + ); + } + + /// A page reported applied moves nothing when it is not the kind of + /// page the run last read, when the store cursor is too long to keep, + /// or once the wallet was removed; and removing the wallet drops its + /// checkpoint. + #[tokio::test] + async fn only_an_applied_page_of_a_live_wallet_moves_its_checkpoint() { + let (wm, wallet_id) = wallet_with_three_coins().await; + let reconciler = reconciler_over(wm); + let checkpoint = |reconciler: &TxoReconciler| { + lock_schedule(&reconciler.schedule) + .checkpoints + .get(&wallet_id) + .cloned() + }; + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + drop(classify(&reconciler, &wallet_id, &[]).await); + reconciler.heal_page_applied(&wallet_id); + assert_eq!(checkpoint(&reconciler), None, "not a heal page"); + drop(classify(&reconciler, &wallet_id, &[]).await); + reconciler + .classify_page_applied(&wallet_id, Some(&[0u8; TXO_RECONCILE_STORE_CURSOR_MAX + 1])); + assert_eq!(checkpoint(&reconciler), None, "too long to keep"); + heal_one(&reconciler, &wallet_id).await; + assert!(reconciler.heal_page_applied(&wallet_id)); + assert!(matches!(checkpoint(&reconciler), Some(Checkpoint::Heal(_)))); + + lock_schedule(&reconciler.schedule).forget(&wallet_id); + assert_eq!(checkpoint(&reconciler), None, "removal drops it"); + heal_one(&reconciler, &wallet_id).await; + reconciler.heal_page_applied(&wallet_id); + assert_eq!( + checkpoint(&reconciler), + None, + "a removed wallet's run records nothing" + ); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + } + + /// The ChainLock the fork tests park on the wallet. + const CHAIN_LOCKED: u32 = 100_010; + + /// A wallet with three coins, each spent by a mined record: one block + /// below the ChainLock, at it, and one block above it. The ChainLock is + /// parked on the wallet without the promotion pass, which in a default + /// build (`keep-finalized-transactions` off) would drop the records it + /// covers. + async fn wallet_with_three_spent_coins() -> ( + WalletManager, + WalletId, + Vec, + ) { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + let mut queries = Vec::new(); + for (seed, spender_height) in [ + (31u8, CHAIN_LOCKED - 1), + (32, CHAIN_LOCKED), + (33, CHAIN_LOCKED + 1), + ] { + let funded = funding(script.clone(), seed, 7_000); + assert!( + ctx.check_transaction(&funded, in_block(100_000)) + .await + .is_relevant + ); + let coin = OutPoint { + txid: funded.txid(), + vout: 0, + }; + assert!( + ctx.check_transaction(&collateral_burn(coin), in_block(spender_height)) + .await + .is_relevant + ); + queries.push(query(bip44_account_0(), coin, &script)); + } + let (mut wm, wallet_id) = manager_with(ctx); + wm.update_wallet_last_processed_height(&wallet_id, CHAIN_LOCKED + 10); + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet") + .core_wallet + .metadata + .last_applied_chain_lock = Some(ChainLock::dummy(CHAIN_LOCKED)); + (wm, wallet_id, queries) + } + + /// A fork at the ChainLock, as dash-spv's `handle_fork` runs it: the + /// write lock, then `truncate_above`. + fn spawn_fork(reconciler: &TxoReconciler) -> tokio::task::JoinHandle<()> { + let engine = Arc::clone(&reconciler.wallet_manager); + tokio::spawn(async move { + engine + .write() + .await + .truncate_above(CHAIN_LOCKED) + .expect("a fork at the ChainLock is followed"); + }) + } + + /// How long a test waits to see that a fork has NOT gone ahead. A fork + /// that could go ahead would do so at once; one that cannot never + /// does, however long this is. + const BLOCKED: Duration = Duration::from_millis(50); + /// How long a test waits for a fork it expects to finish. + const PROCEEDS: Duration = Duration::from_secs(10); + + /// Classify under the run's claim, keeping the held verdicts. + async fn classify( + reconciler: &TxoReconciler, + wallet_id: &WalletId, + queries: &[OutpointOwnershipQuery], + ) -> (Vec, TxoReconcileHeldVerdicts) { + page(reconciler.classify_held(wallet_id, queries).await) + } + + /// A held read's page; the wallet holds a run claim with budget left. + fn page(read: Option>) -> (T, TxoReconcileHeldVerdicts) { + match read.expect("the wallet holds a run claim") { + TxoReconcileRead::Page(page, held) => (page, held), + TxoReconcileRead::BudgetSpent => panic!("the run has budget left"), + } + } + + fn holds_engine(reconciler: &TxoReconciler, wallet_id: &WalletId) -> bool { + lock_schedule(&reconciler.schedule).in_flight[wallet_id] + .held + .as_ref() + .is_some_and(|slot| slot.lock().expect("slot").is_some()) + } + + #[tokio::test] + async fn a_classification_needs_the_runs_claim() { + let reconciler = reconciler(); + assert!(reconciler.classify_held(&A, &[]).await.is_none()); + assert!(reconciler.claim_without_gate(&A, TIP).is_some()); + let (classes, held) = classify(&reconciler, &A, &[]).await; + assert!(classes.is_empty()); + assert!(holds_engine(&reconciler, &A)); + drop(held); + assert!( + !holds_engine(&reconciler, &A), + "dropping the verdicts released the guard" + ); + reconciler.finish(&A, TxoReconcileFinish::Ran); + } + + /// A heal page is held like a classification: it needs the run's claim, + /// and a fork waits until the host has written the page. + #[tokio::test] + async fn a_fork_waits_until_a_held_heal_page_is_released() { + let (wm, wallet_id, _) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.heal_page_held(&wallet_id, 10).await.is_none()); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + + let (_, held) = page(reconciler.heal_page_held(&wallet_id, 10).await); + assert!(holds_engine(&reconciler, &wallet_id)); + let mut fork = spawn_fork(&reconciler); + assert!( + timeout(BLOCKED, &mut fork).await.is_err(), + "the fork waits while the heal page is held" + ); + + drop(held); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once the page is released") + .expect("fork task"); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + } + + /// A provisional claim that a stray finish released while its gate + /// waited leaves the next run's claim alone, whether it is handed over + /// or dropped. + #[test] + fn a_released_provisional_claim_leaves_the_next_claim_alone() { + let schedule = Mutex::new(TxoReconcileSchedule::default()); + let claim_tip = || { + lock_schedule(&schedule) + .in_flight + .get(&A) + .map(|claim| claim.tip) + }; + + let stale = ProvisionalClaim::take(&schedule, A, Instant::now()).expect("claimed"); + lock_schedule(&schedule).finish(&A, TxoReconcileFinish::NotRun, Instant::now()); + assert!(lock_schedule(&schedule).try_begin(A, Instant::now())); + stale.hand_over(TIP); + assert_eq!(claim_tip(), Some(0), "the new claim keeps its own tip"); + + lock_schedule(&schedule).finish(&A, TxoReconcileFinish::NotRun, Instant::now()); + let stale = ProvisionalClaim::take(&schedule, A, Instant::now()).expect("claimed"); + lock_schedule(&schedule).finish(&A, TxoReconcileFinish::NotRun, Instant::now()); + assert!(lock_schedule(&schedule).try_begin(A, Instant::now())); + drop(stale); + assert_eq!(claim_tip(), Some(0), "the new claim is still in flight"); + } + + /// A heal page counts confirmations to the run's own tip, capped by the + /// engine's processed height under the page's guard: the host passes + /// no height to inflate, and a fork between pages lowers the next + /// page's. + #[tokio::test] + async fn a_heal_page_is_measured_against_the_runs_tip_and_the_engine() { + const FUNDED: u32 = 100_000; + let deep = FUNDED + TXO_RECONCILE_MIN_CONFIRMATIONS - 1; + let mut ctx = TestWalletContext::new_random(); + let funded = funding(ctx.receive_address.script_pubkey(), 41, 9_000); + assert!( + ctx.check_transaction(&funded, in_block(FUNDED)) + .await + .is_relevant + ); + let (mut wm, wallet_id) = manager_with(ctx); + wm.update_wallet_last_processed_height(&wallet_id, deep); + let reconciler = reconciler_over(wm); + type HealRead = TxoReconcileRead<(Vec<(WalletUtxoRow, TxoHealVerdict)>, bool)>; + let verdict = |read: Option| { + let ((rows, _), held) = page(read); + drop(held); + assert_eq!(rows.len(), 1); + rows[0].1 + }; + + assert!(reconciler + .claim_without_gate(&wallet_id, deep - 1) + .is_some()); + assert_eq!( + verdict(reconciler.heal_page_held(&wallet_id, 10).await), + TxoHealVerdict::Immature, + "the run's tip caps the depth, whatever the engine processed" + ); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + assert_eq!( + verdict(reconciler.heal_page_held(&wallet_id, 10).await), + TxoHealVerdict::Eligible, + "deep enough at the engine's processed height" + ); + reconciler + .wallet_manager + .write() + .await + .truncate_above(FUNDED + 50) + .expect("no ChainLock in the way"); + assert_eq!( + verdict(reconciler.heal_page_held(&wallet_id, 10).await), + TxoHealVerdict::Immature, + "the fork lowered the height the next page is measured against" + ); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + } + + /// The race the guard closes: verdicts read, then a fork drops a + /// spender and restores its coin before the host writes them, with no + /// persistence round for the host's fences to see. The fork now waits + /// for the write. + #[tokio::test] + async fn a_fork_waits_until_the_held_verdicts_are_released() { + use OutpointClass::{KnownUncredited, Unknown, Unspent}; + let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + + let (classes, held) = classify(&reconciler, &wallet_id, &queries).await; + assert_eq!( + classes, + vec![KnownUncredited, KnownUncredited, Unknown], + "a spender above the ChainLock earns no verdict" + ); + let mut fork = spawn_fork(&reconciler); + assert!( + timeout(BLOCKED, &mut fork).await.is_err(), + "the fork waits while the verdicts are held" + ); + + drop(held); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once they are released") + .expect("fork task"); + let wm = reconciler.wallet_manager.read().await; + assert_eq!( + classify_outpoints(&wm, &wallet_id, &queries), + vec![KnownUncredited, KnownUncredited, Unspent], + "the fork restored the coin whose spender sat above the ChainLock" + ); + } + + #[tokio::test] + async fn finishing_the_run_releases_its_held_verdicts() { + let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + let (_, held) = classify(&reconciler, &wallet_id, &queries).await; + let mut fork = spawn_fork(&reconciler); + assert!(timeout(BLOCKED, &mut fork).await.is_err()); + + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once the run finished") + .expect("fork task"); + drop(held); + } + + /// Shutdown's release waits for the event source: while SPV stop runs, + /// a heal page the host may still be writing keeps its guard, and only + /// a stop that succeeded lets it go — after which no fork can land. + #[tokio::test] + async fn a_held_page_is_released_only_after_spv_stopped() { + let (wm, wallet_id, _) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + let (_, held) = page(reconciler.heal_page_held(&wallet_id, 10).await); + + let stopped = release_txo_holds_after_stop(&reconciler.schedule, async { + assert!( + holds_engine(&reconciler, &wallet_id), + "the page stays guarded while SPV stops" + ); + Ok::<(), &str>(()) + }) + .await; + assert_eq!(stopped, Ok(())); + assert!( + !holds_engine(&reconciler, &wallet_id), + "a clean stop releases the guard the host still holds" + ); + timeout(PROCEEDS, spawn_fork(&reconciler)) + .await + .expect("teardown's writers are not held off") + .expect("fork task"); + drop(held); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + } + + /// A failed or timed-out SPV stop leaves SPV work live, so the guard of + /// a page the host may still be writing stays held: a fork waits for the + /// host's own release, as it would outside teardown. + #[tokio::test] + async fn a_failed_spv_stop_leaves_a_held_page_guarded() { + let (wm, wallet_id, _) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + let (_, held) = page(reconciler.heal_page_held(&wallet_id, 10).await); + + let stopped = release_txo_holds_after_stop(&reconciler.schedule, async { + Err::<(), _>("SPV teardown timed out") + }) + .await; + assert_eq!(stopped, Err("SPV teardown timed out")); + assert!( + holds_engine(&reconciler, &wallet_id), + "a failed stop releases nothing" + ); + let mut fork = spawn_fork(&reconciler); + assert!( + timeout(BLOCKED, &mut fork).await.is_err(), + "a fork still waits for the host's write" + ); + + drop(held); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once the host released the page") + .expect("fork task"); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); + } + + /// The manager's shutdown, with an SPV client that never started (its + /// stop succeeds at once), releases a guard the host never dropped, so + /// the teardown's writers are not held off. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn shutdown_with_a_stopped_spv_releases_a_held_page() { + let (manager, wallet_id) = crate::test_support::test_platform_wallet_manager().await; + let reconciler = manager.txo_reconciler(); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + let (_, held) = page(reconciler.heal_page_held(&wallet_id, 10).await); + assert!(holds_engine(&reconciler, &wallet_id)); + + let report = timeout(PROCEEDS, manager.shutdown()) + .await + .expect("shutdown is not held off by the host's guard"); + assert_eq!( + report.per_worker.get(&crate::manager::WalletWorker::Spv), + Some(&dash_async::WorkerStatus::Ok), + "{report:?}" + ); + assert!( + !holds_engine(&reconciler, &wallet_id), + "the stopped SPV let shutdown release the guard" + ); + let writer = timeout(PROCEEDS, reconciler.wallet_manager.write()) + .await + .expect("an engine writer goes ahead after shutdown"); + drop(writer); + drop(held); + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); + } + + /// The store keeps a verdict after the guard is gone, so a spend counts + /// only once a ChainLock covers its block: no fork can drop it then. A + /// wallet with no ChainLock yet has no final spends at all, and a spend + /// above the ChainLock earns its verdict once the next one reaches it. + #[tokio::test] + async fn a_spend_counts_once_a_chain_lock_covers_its_block() { + use OutpointClass::{KnownUncredited, Unknown}; + let (mut wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let lock_at = |wm: &mut WalletManager, height: Option| { + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet") + .core_wallet + .metadata + .last_applied_chain_lock = height.map(ChainLock::dummy); + }; + lock_at(&mut wm, None); + assert_eq!( + classify_outpoints(&wm, &wallet_id, &queries), + vec![Unknown; 3] + ); + lock_at(&mut wm, Some(CHAIN_LOCKED)); + assert_eq!( + classify_outpoints(&wm, &wallet_id, &queries), + vec![KnownUncredited, KnownUncredited, Unknown] + ); + lock_at(&mut wm, Some(CHAIN_LOCKED + 1)); + assert_eq!( + classify_outpoints(&wm, &wallet_id, &queries), + vec![KnownUncredited; 3] + ); + } + + /// tokio's `RwLock` queues a reader behind a waiting writer. A run that + /// asked for its next page while still holding the last one would wait + /// behind a fork that waits for it. A guard the host failed to release + /// is released first, so the fork goes ahead and the next page sees its + /// outcome; the stale verdicts' later drop is harmless. + #[tokio::test] + async fn the_next_page_does_not_deadlock_behind_a_waiting_fork() { + use OutpointClass::{KnownUncredited, Unspent}; + let (wm, wallet_id, queries) = wallet_with_three_spent_coins().await; + let reconciler = reconciler_over(wm); + assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); + let (_, stale) = classify(&reconciler, &wallet_id, &queries).await; + let mut fork = spawn_fork(&reconciler); + assert!(timeout(BLOCKED, &mut fork).await.is_err()); + + let (next, held) = timeout(PROCEEDS, classify(&reconciler, &wallet_id, &queries)) + .await + .expect("the next page is classified, not deadlocked"); + assert_eq!( + next, + vec![KnownUncredited, KnownUncredited, Unspent], + "the queued fork went first" + ); + timeout(PROCEEDS, fork) + .await + .expect("fork finished") + .expect("fork task"); + drop(stale); + assert!( + holds_engine(&reconciler, &wallet_id), + "dropping the stale verdicts leaves the new page's guard alone" + ); + drop(held); + assert!(!holds_engine(&reconciler, &wallet_id)); + } + + /// A classification that waits for the engine across its run's finish + /// and a new begin does not hand its guard to the new run. + #[tokio::test] + async fn a_classification_outliving_its_claim_holds_nothing() { + let reconciler = reconciler(); + assert!(reconciler.claim_without_gate(&A, TIP).is_some()); + let writer = Arc::clone(&reconciler.wallet_manager).write_owned().await; + let mut waiting = pin!(reconciler.classify_held(&A, &[])); + let mut cx = Context::from_waker(Waker::noop()); + assert!(waiting.as_mut().poll(&mut cx).is_pending()); + + reconciler.finish(&A, TxoReconcileFinish::Ran); + assert!( + reconciler.claim_without_gate(&A, TIP).is_some(), + "a new run for the wallet" + ); + drop(writer); + assert!( + timeout(PROCEEDS, waiting) + .await + .expect("classified") + .is_none(), + "the old run's claim is gone" + ); + assert!(!holds_engine(&reconciler, &A), "the new run holds no guard"); + } +} diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 02a91ece586..44f29731563 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -355,6 +355,7 @@ impl PlatformWalletManager

{ let platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: crate::wallet::identity::IdentityManager::new(), @@ -991,6 +992,13 @@ impl PlatformWalletManager

{ their identity-sync rows are left for the next launch to reconcile" ); } + // The reconcile schedule is keyed by id too: forget it while this + // generation still holds the id, so a same-id wallet added later + // is reconciled at once rather than waiting out this one's + // cadence, and no successor's checkpoint or claim is touched. A + // run in flight is marked forgotten and records nothing; one + // claimed from here on finds the wallet gone at its gate. + self.forget_txo_reconcile(wallet_id); if let Err(e) = self.remove_from_wallet_manager(&mut wm, wallet_id) { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -2497,6 +2505,15 @@ mod remove_versus_recreate_tests { .identity_sync_manager .register_identity(Identifier::new(SHARED_IDENTITY_ID), []) .await; + // G2 starts a reconcile run that is still in flight when + // the removal finishes. + assert!( + manager + .txo_reconciler() + .claim_without_gate(&id, 0) + .is_some(), + "the recreated wallet holds no run yet" + ); *recreated_slot.lock().expect("recreated slot") = Some(wallet); }) })); @@ -2582,5 +2599,33 @@ mod remove_versus_recreate_tests { "the removal's id-keyed unregister deleted the identity-sync row the recreated \ generation registered mid-removal" ); + + // 7. So did G2's reconcile run: the removal forgot G1's schedule + // while G1 still held the id, so G2's in-flight claim is not + // marked forgotten and the page it applies moves its checkpoint. + let reconciler = manager.txo_reconciler(); + let (_, held) = match reconciler.heal_page_held(&wallet_id, 0).await { + Some(crate::manager::txo_reconcile::TxoReconcileRead::Page(page, held)) => (page, held), + other => panic!("G2's claim must still read a heal page, got {other:?}"), + }; + drop(held); + reconciler.heal_page_applied(&wallet_id); + reconciler.finish( + &wallet_id, + crate::manager::txo_reconcile::TxoReconcileFinish::Ran, + ); + let next = reconciler + .claim_without_gate(&wallet_id, 0) + .expect("the run finished"); + assert_ne!( + next.start, + crate::manager::txo_reconcile::TxoReconcileStart::Heal { resumed: false }, + "the removal forgot the recreated wallet's in-flight run, so its applied page was \ + not kept" + ); + reconciler.finish( + &wallet_id, + crate::manager::txo_reconcile::TxoReconcileFinish::NotRun, + ); } } diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 2f4cb348f2e..4a07b3b19ec 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -2,7 +2,7 @@ use std::future::Future; use std::pin::pin; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::Duration; @@ -85,6 +85,8 @@ pub struct SpvRuntime { /// a lock other callers hold while they wait for `shutdown` would cycle. owner_released: AtomicBool, peer_tracker: Arc, + /// Successful [`start`](Self::start)s so far; see [`Self::run_session`]. + run_sessions: AtomicU64, /// Woken (`notify_waiters`) on every transition that can make /// [`Self::is_broadcast_ready`] true: a client stored by `start`, and a /// `PeersUpdated` snapshot stored by `peer_tracker`. Losing readiness @@ -132,6 +134,7 @@ impl SpvRuntime { shutdown: tokio::sync::Mutex::new(Shutdown::Idle), owner_released: AtomicBool::new(false), peer_tracker: Arc::new(PeerTracker::new(Arc::clone(&readiness_changed))), + run_sessions: AtomicU64::new(0), readiness_changed, } } @@ -273,6 +276,7 @@ impl SpvRuntime { let mut client = self.client.write().await; *client = Some(spv_client); *self.last_config.write().await = Some(retained_config); + self.run_sessions.fetch_add(1, Ordering::Relaxed); // Release the client before waking readiness waiters so they can // read it. drop(client); @@ -281,6 +285,14 @@ impl SpvRuntime { Ok(()) } + /// Identifies the current run of the client: it changes on every + /// successful [`start`](Self::start), so two reads that return the same + /// value saw no stop and restart between them, however quickly a host + /// cycled the client. 0 before the first start. + pub fn run_session(&self) -> u64 { + self.run_sessions.load(Ordering::Relaxed) + } + /// Refuse restart until all previous startup and shutdown work is joined. fn ensure_no_live_run_loop(&self) -> Result<(), PlatformWalletError> { let shutdown = self.shutdown.try_lock().map_err(|_| { diff --git a/packages/rs-platform-wallet/src/test_support.rs b/packages/rs-platform-wallet/src/test_support.rs index 25cbd8f5f5b..d622690c436 100644 --- a/packages/rs-platform-wallet/src/test_support.rs +++ b/packages/rs-platform-wallet/src/test_support.rs @@ -257,6 +257,7 @@ pub async fn funded_wallet_manager_with_outputs( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -388,6 +389,7 @@ pub(crate) async fn funded_wallet_manager_dual_standard( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -493,6 +495,7 @@ pub(crate) async fn funded_wallet_manager_with_contact( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -563,6 +566,7 @@ pub(crate) async fn funded_coinjoin_wallet_manager() -> ( let generation = Arc::new(WalletGeneration::new()); let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: Arc::clone(&generation), identity_manager: IdentityManager::new(), @@ -902,6 +906,7 @@ pub(crate) async fn mnemonic_wallet_manager( }; let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: managed_wallet, generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 25b8022e280..e1afdf7b376 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -469,6 +469,7 @@ mod tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), dpns_name_states: BTreeMap::new(), dashpay_backfill: Default::default(), rewind_barrier: Default::default(), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs index b1fa14d4d94..46b51ccf067 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/reconstruction.rs @@ -659,6 +659,7 @@ mod tests { let info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ctx.managed_wallet, generation: std::sync::Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index bbc92c1aa05..382aa643fca 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -2573,6 +2573,7 @@ mod tests { let restored_wallet = Wallet::new_external_signable(Network::Testnet, wallet_id, accounts); let mut restored_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), core_wallet: ManagedWalletInfo::from_wallet(&restored_wallet, 0), generation: Arc::new(WalletGeneration::new()), identity_manager: IdentityManager::new(), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 9d7c6664101..b4f6adf4a71 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4297,6 +4297,7 @@ mod sweep_tests { identity_manager: IdentityManager::new(), tracked_asset_locks: BTreeMap::new(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), dpns_name_states: BTreeMap::new(), dashpay_backfill: Default::default(), rewind_barrier: Default::default(), diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 50a564b04df..d3ff353f621 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -292,6 +292,18 @@ pub struct PlatformWalletInfo { /// runs under the manager's read lock; a poisoned mutex degrades to /// "no memory" rather than failing a resume. pub observed_input_conflicts: std::sync::Mutex>, + /// Outpoints of this wallet's coins that a ChainLocked transaction + /// spent, kept for the records the ChainLock dropped. With + /// `keep-finalized-transactions` off (the default) key-wallet drops a + /// record once a ChainLock covers it and keeps only its txid, so the + /// spender's inputs are gone with it; this is what is left of them, for + /// the store reconcile's spend verdicts + /// ([`classify_outpoints`](crate::manager::accessors::classify_outpoints)). + /// A ChainLocked spend is final: no fork the engine follows drops it. + /// In memory only, like key-wallet's own finalized txids: after a + /// restart neither the spender nor the funding is known, and the + /// verdict is `Unknown`. + pub finalized_spends: std::collections::HashSet, /// DPNS name states with sale price (username marketplace), keyed by /// domain document id. Session-lifetime working set for the /// marketplace sync/orchestration ops; the durable copy is the diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs index afed10a617f..92b64b01522 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -9,7 +9,7 @@ use async_trait::async_trait; use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::prelude::CoreBlockHeight; -use dashcore::{Address as DashAddress, ScriptBuf, Transaction, Txid}; +use dashcore::{Address as DashAddress, OutPoint, ScriptBuf, Transaction, Txid}; use key_wallet::account::AccountType; use key_wallet::bip32::ExtendedPubKey; @@ -41,6 +41,7 @@ impl WalletInfoInterface for PlatformWalletInfo { identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), dashpay_backfill: Default::default(), rewind_barrier: Default::default(), @@ -57,6 +58,7 @@ impl WalletInfoInterface for PlatformWalletInfo { identity_manager: super::identity::IdentityManager::new(), tracked_asset_locks: std::collections::BTreeMap::new(), observed_input_conflicts: Default::default(), + finalized_spends: Default::default(), dpns_name_states: std::collections::BTreeMap::new(), dashpay_backfill: Default::default(), rewind_barrier: Default::default(), @@ -271,8 +273,26 @@ impl WalletInfoInterface for PlatformWalletInfo { self.core_wallet.last_applied_chain_lock() } + /// Before forwarding, keeps what the records the ChainLock is about to + /// drop spent — see [`PlatformWalletInfo::finalized_spends`]. fn apply_chain_lock(&mut self, chain_lock: ChainLock) -> ApplyChainLockOutcome { + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let cl_height = chain_lock.block_height; + if DROPS_FINALIZED_RECORDS { + let promoted = self + .core_wallet + .accounts + .all_accounts() + .into_iter() + .filter_map(|account| account.as_funds()) + .flat_map(|funds| funds.transactions().values()) + .filter(|record| match &record.context { + TransactionContext::InBlock(info) => info.height() <= cl_height, + _ => false, + }); + self.finalized_spends.extend(owned_spends(promoted)); + } let outcome = self.core_wallet.apply_chain_lock(chain_lock); let total_promoted: usize = outcome.locked_transactions.values().map(|v| v.len()).sum(); tracing::debug!( @@ -294,6 +314,36 @@ impl WalletInfoInterface for PlatformWalletInfo { } } +/// Whether key-wallet drops a record once a ChainLock covers it, keeping +/// only its txid (the `keep-finalized-transactions` feature off). +const DROPS_FINALIZED_RECORDS: bool = cfg!(not(feature = "keep-finalized-transactions")); + +/// The outpoints of this wallet's coins that `records` spend. A record's +/// `input_details` name the inputs whose coin its account held (key-wallet +/// adds one when a funding arrives after its spender), so the foreign +/// inputs of a payment the wallet received, and the other outputs of a +/// transaction that paid it, are left out. A contact's watch-only chain +/// holds the contact's coins, not ours. Each record names only its own +/// account's inputs, so every outpoint comes once. +fn owned_spends<'a, I>(records: I) -> impl Iterator + 'a +where + I: IntoIterator, + I::IntoIter: 'a, +{ + records + .into_iter() + .filter(|record| !record.account_type.is_contact_owned()) + .flat_map(|record| { + record.input_details.iter().filter_map(|detail| { + record + .transaction + .input + .get(detail.index as usize) + .map(|input| input.previous_output) + }) + }) +} + // --------------------------------------------------------------------------- // WalletTransactionChecker — delegate to `self.core` // --------------------------------------------------------------------------- @@ -309,9 +359,27 @@ impl WalletTransactionChecker for PlatformWalletInfo { update_balance: bool, ) -> TransactionCheckResult { // TODO: some logic must here - restore - self.core_wallet + let result = self + .core_wallet .check_core_transaction(tx, context, wallet, update_state, update_balance) - .await + .await; + // A transaction first seen, or confirmed, in a ChainLocked block is + // dropped on the spot: keep what it spent, as `apply_chain_lock` + // does for the records it promotes. Gate on each record's own + // context, not this call's: a funding that arrives after its + // spender hands the spender back in `updated_records` with its own + // context unchanged, possibly mempool, and such a spender is not + // final. + if DROPS_FINALIZED_RECORDS && update_state && result.is_relevant { + self.finalized_spends.extend(owned_spends( + result + .new_records + .iter() + .chain(&result.updated_records) + .filter(|record| record.context.is_chain_locked()), + )); + } + result } } @@ -456,6 +524,191 @@ mod tests { } } + /// A transaction paying this wallet one output and a stranger another, + /// and a ChainLocked transaction spending both. Only the wallet's coin is + /// kept, by either path that drops the spender's record. + #[tokio::test] + async fn should_keep_only_the_wallets_own_coins_a_finalized_spender_spent() { + use crate::manager::accessors::txo_inventory_tests::{in_block, manager_with}; + + for first_seen_chain_locked in [false, true] { + let mut ctx = TestWalletContext::new_random(); + let mut paid = spending(OutPoint { + txid: Txid::from_slice(&[0x51; 32]).expect("valid txid"), + vout: 0, + }); + paid.output = vec![ + TxOut { + value: 6_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }, + TxOut { + value: 5_000, + script_pubkey: ScriptBuf::new_op_return(&[1]), + }, + ]; + assert!( + ctx.check_transaction(&paid, in_block(100)) + .await + .is_relevant + ); + let ours = OutPoint::new(paid.txid(), 0); + let theirs = OutPoint::new(paid.txid(), 1); + let mut spend = spending(ours); + spend.input.push(TxIn { + previous_output: theirs, + ..Default::default() + }); + let (mut wm, wallet_id) = manager_with(ctx); + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + + if first_seen_chain_locked { + let block = in_block(101).block_info().copied().expect("mined"); + let context = TransactionContext::InChainLockedBlock(block); + assert!( + info.check_core_transaction(&spend, context, wallet, true, true) + .await + .is_relevant + ); + } else { + assert!( + info.check_core_transaction(&spend, in_block(101), wallet, true, true) + .await + .is_relevant + ); + info.apply_chain_lock(ChainLock::dummy(101)); + } + + if DROPS_FINALIZED_RECORDS { + assert_eq!( + info.finalized_spends, + std::collections::HashSet::from([ours]), + "first seen ChainLocked: {first_seen_chain_locked}" + ); + } else { + assert!(info.finalized_spends.is_empty()); + } + } + } + + /// A mempool spender seen before its funding is handed back in + /// `updated_records` when the funding arrives ChainLocked, with its own + /// context still Mempool. That spender is not final, so its input must + /// not be kept as a finalized spend: the coin is no verdict yet. + #[tokio::test] + async fn should_not_finalize_a_mempool_spender_attributed_by_a_chain_locked_funding() { + use crate::manager::accessors::classify_outpoints; + use crate::manager::accessors::txo_inventory_tests::{ + bip44_account_0, funding, in_block, manager_with, query, + }; + + let ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + let funded = funding(script.clone(), 0x54, 6_000); + let coin = OutPoint::new(funded.txid(), 0); + let mut spender = spending(coin); + spender.output = vec![TxOut { + value: 5_000, + script_pubkey: script.clone(), + }]; + let (mut wm, wallet_id) = manager_with(ctx); + { + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + assert!( + info.check_core_transaction( + &spender, + TransactionContext::Mempool, + wallet, + true, + true + ) + .await + .is_relevant + ); + let block = in_block(100).block_info().copied().expect("mined"); + let result = info + .check_core_transaction( + &funded, + TransactionContext::InChainLockedBlock(block), + wallet, + true, + true, + ) + .await; + assert!(result.is_relevant); + assert!( + result.updated_records.iter().any( + |record| record.txid == spender.txid() && !record.context.is_chain_locked() + ), + "the funding attributes its input to the mempool spender" + ); + assert!( + !info.finalized_spends.contains(&coin), + "a mempool spender's input is not a finalized spend" + ); + } + assert_eq!( + classify_outpoints(&wm, &wallet_id, &[query(bip44_account_0(), coin, &script)]), + vec![crate::manager::accessors::OutpointClass::Unknown] + ); + } + + /// A spend drawing on two funds accounts names each coin once: each + /// account's record lists only its own inputs, and nothing walks the + /// transaction again per account. + #[tokio::test] + async fn should_name_each_coin_of_a_multi_account_spend_once() { + use crate::manager::accessors::txo_inventory_tests::{funding, in_block, manager_with}; + + let mut ctx = TestWalletContext::new_random(); + let bip32_xpub = ctx + .wallet + .accounts + .standard_bip32_accounts + .get(&0) + .expect("BIP32 account") + .account_xpub; + let bip32_address = ctx + .managed_wallet + .first_bip32_managed_account_mut() + .expect("BIP32 account") + .next_receive_address(Some(&bip32_xpub), true) + .expect("address"); + let to_bip44 = funding(ctx.receive_address.script_pubkey(), 0x52, 6_000); + let to_bip32 = funding(bip32_address.script_pubkey(), 0x53, 7_000); + assert!( + ctx.check_transaction(&to_bip44, in_block(100)) + .await + .is_relevant + ); + assert!( + ctx.check_transaction(&to_bip32, in_block(100)) + .await + .is_relevant + ); + let mut coins = vec![ + OutPoint::new(to_bip44.txid(), 0), + OutPoint::new(to_bip32.txid(), 0), + ]; + let mut spend = spending(coins[0]); + spend.input.push(TxIn { + previous_output: coins[1], + ..Default::default() + }); + let (mut wm, wallet_id) = manager_with(ctx); + let (wallet, info) = wm.get_wallet_mut_and_info_mut(&wallet_id).expect("wallet"); + + let result = info + .check_core_transaction(&spend, in_block(101), wallet, true, true) + .await; + assert_eq!(result.new_records.len(), 2, "one record per account"); + let mut named: Vec = + owned_spends(result.new_records.iter().chain(&result.updated_records)).collect(); + named.sort(); + coins.sort(); + assert_eq!(named, coins); + } + /// dash-spv notes a chain-lock height during the initial sync, before a /// chain lock is applied. Spends observed at or below the synced height /// are final from then on and are forgotten. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift index b083ae12383..8691035431b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Core/Services/SDKLogger.swift @@ -34,6 +34,21 @@ public enum SDKLogValue: Sendable { case referenceString(String) } +extension SDKLogValue { + /// A reference keyed with a random per-process key: lines of one + /// process can still be linked, but the value cannot be recovered by + /// hashing candidates. Use it for identifiers anyone can enumerate, + /// such as outpoints, which are public chain data. + /// + /// A factory rather than a case, so a downstream exhaustive `switch` + /// over `SDKLogValue` keeps compiling. The keyed value then goes through + /// the ordinary reference digest, so the line reads like any other + /// reference. + public static func keyedReference(_ data: Data) -> SDKLogValue { + .referenceString(SDKLogFormatter.keyedReference(data)) + } +} + /// Serial file sink used by the structured logger. All writes, including /// callbacks entering Swift concurrently from FFI threads, are protected by a /// single lock so a diagnostic line is appended atomically. @@ -127,6 +142,16 @@ enum SDKLogFormatter { reference(Data(string.utf8)) } + /// Generated once per process and never written anywhere. + private static let referenceKey = SymmetricKey(size: .bits256) + + static func keyedReference(_ data: Data) -> String { + HMAC.authenticationCode(for: data, using: referenceKey) + .prefix(6) + .map { String(format: "%02x", $0) } + .joined() + } + private static func render(_ value: SDKLogValue) -> String { switch value { case .publicText(let value): diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 4497e2f75ad..c01f3950850 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -39,7 +39,7 @@ public struct CoreAccountKey: Hashable, Sendable { } /// One coin the engine holds, as one row of the paged inventory -/// (`platform_wallet_wallet_utxos_page`). +/// (`platform_wallet_txo_reconcile_heal_page`). public struct CoreEngineUtxo: Equatable, Sendable { public var account: CoreAccountKey /// 32-byte txid in wire orientation — the same bytes the changeset @@ -90,9 +90,9 @@ public struct CoreEngineUtxo: Equatable, Sendable { } /// 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. +/// (`platform_wallet_txo_reconcile_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 @@ -117,9 +117,11 @@ 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, does not hold the coin, AND a funds account -/// holds a mined record whose transaction spends the outpoint. Absence +/// holds a record whose transaction spends the outpoint, in a block the +/// wallet's ChainLock covers (no fork can drop that spender). 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 +/// or IS-locked spend, a spend in a block no ChainLock covers yet, 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), @@ -131,20 +133,108 @@ public enum CoreOutpointClass: UInt8, Sendable { 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] +/// Whether the store may heal one engine coin in — the `TXO_HEAL_VERDICT_*` +/// constants. The verdict is `platform-wallet`'s (`txo_heal_verdict`: a +/// script and an address to file the row under, engine-confirmed, and deep +/// enough at the run's scan tip); the store only follows it. +enum CoreTxoHealVerdict: Equatable, Sendable { + /// No script or no address: the store could not file the row. + case invalid + /// Not confirmed by the engine, or not deep enough yet; it ages into a + /// later run. + case immature + /// May be inserted unspent, if the store lacks the outpoint and + /// resolves the account that owns it. + case eligible + + /// A code this build does not know is nothing the engine vouched for. + init(code: UInt8) { + switch code { + case UInt8(TXO_HEAL_VERDICT_ELIGIBLE): + self = .eligible + case UInt8(TXO_HEAL_VERDICT_IMMATURE): + self = .immature + default: + self = .invalid + } + } +} + +/// One row of a heal page: an engine coin and Rust's verdict on it. +struct CoreTxoHealRow: Equatable, Sendable { + let utxo: CoreEngineUtxo + let verdict: CoreTxoHealVerdict +} + +/// What one held read of a run answered: the page, held until its store +/// step is done, or nothing at all because the run has read as many pages +/// as `platform-wallet` allows one run (`TXO_RECONCILE_PAGE_BUDGET`). A +/// `budgetSpent` read holds no engine guard. +enum CoreTxoReconcileRead { + case page(Value) + case budgetSpent +} + +/// The engine side of one reconcile run — one wallet, claimed by +/// `platform_wallet_manager_txo_reconcile_begin`, at the scan tip that call +/// returned. Every decision a run makes is asked here: which engine coins +/// may be healed, which store rows the engine proves spent, whether a +/// stale page is classified again, how many pages the run may read, and +/// where the wallet's next run picks up. Production wraps the C API +/// (`FFICoreTxoReconcileEngine`); tests inject a fake so every path of the +/// run is reproducible without a native engine. +/// +/// The run's place and its page budget are native: each held read takes +/// one page of the budget (a page read again takes another), and the run +/// moves on only when the host reports the page applied. A page read again +/// before it is reported applied is the same page. +/// +/// The 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 CoreTxoReconcileEngine: Sendable { + /// The run's next page of the wallet's UTXO inventory — after the last + /// heal page reported applied, or where the wallet's last run stopped — + /// at most `limit` rows, each with its heal verdict, and whether more + /// follow (never on an empty page). The engine keeps its wallet lock held for reading when this + /// returns a page, so no fork can drop a coin the page offers before + /// the store has written it; `releaseVerdicts()` lets it go. A page + /// asked for while a previous guard is still held frees that one first. + func healPage(limit: Int) throws -> CoreTxoReconcileRead<(rows: [CoreTxoHealRow], hasMore: Bool)> + + /// Positional: `result[i]` answers `queries[i]`. Held like `healPage`: + /// no fork can drop a spender these verdicts name before the store has + /// written them. Asked for every store page the classify pass reads, an + /// empty one too: each is one page of the run's budget, and the page + /// `classifyPageApplied(nextAfter:)` reports. + func classifyHeld(_ queries: [CoreOutpointOwnershipQuery]) throws -> CoreTxoReconcileRead<[CoreOutpointClass]> + + /// Release the lock the last `healPage` or `classifyHeld` holds, if it + /// still does. + /// Idempotent, never blocks, and never resolves the manager handle, so + /// it can run whatever is queued on the handle registry or the engine. + func releaseVerdicts() + + /// Whether the run classifies a page again after it came back stale for + /// the `staleInARow`-th time in a row (counting this one), or stops + /// incomplete. + func retryStalePage(staleInARow: Int) -> Bool + + /// The last heal page's store step committed (an empty page counts): + /// the run's next heal page starts after it, and so does the wallet's + /// next run if this one stops here. Returns whether the run may read + /// another page. Resolves the manager handle, so it is called only + /// after the page's guard is released. + func healPageApplied() throws -> Bool + + /// The last classified store page was applied. `nextAfter` is the + /// store cursor past it, which the wallet's next run starts after if + /// this one stops here (at most `TXO_RECONCILE_STORE_CURSOR_MAX` + /// bytes); `nil` when it was the store's last page, which ends the + /// cycle. Returns whether the run may read another page. Called only + /// after the page's guard is released, like `healPageApplied()`. + func classifyPageApplied(nextAfter: Data?) throws -> Bool } // MARK: - Outcome @@ -164,12 +254,18 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { /// 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. + /// Engine rows the engine calls immature: not confirmed, or not deep + /// enough yet. They age into a later run. public var skippedImmature = 0 /// Engine rows whose account has no store row to file them under. public var skippedUnresolvedAccount = 0 - /// Engine rows the store could not validate (malformed txid, no - /// script, no address). + /// Engine rows whose address has no store row under the resolved + /// account: inserting them would leave a coin the restored address + /// pool has no derivation path for. A later run heals them once the + /// address is saved under the account. + public var skippedMissingAddress = 0 + /// Engine rows the engine calls invalid (no script, no address), or + /// whose txid is not 32 bytes. public var skippedInvalid = 0 // Classify pass (store → engine, flip-only). @@ -181,24 +277,51 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { public var flipped = 0 public var flippedDuffs: UInt64 = 0 /// Store rows the engine has no opinion about — left untouched. + /// Watch-only DIP-15 contact rows land here: the engine never gives a + /// contact's coin a verdict. public var unknown = 0 /// Store rows whose script the engine does not monitor — left /// untouched, reported. public var notOwned = 0 + /// Unspent store rows of this wallet with no account to name to the + /// engine (or no well-formed txid). They carry no claim the engine + /// could check, so they are counted, not asked about. + public var unresolvedAccount = 0 // Run shape. - /// Steps deferred because a Rust persistence round was open. + /// Store steps deferred because a Rust persistence round was open. The + /// run stops at the first (`stoppedOnOpenRound`), so this is 0 or 1. public var retries = 0 /// Pages classified again because a persistence round committed between - /// their read and their apply. + /// their read and their apply, or was still open at the 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. + /// Store reads or writes that failed; 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. + /// `false` when the run stopped before the end of the classify pass + /// (cancelled, a failed read or write, a store step deferred behind an + /// open persistence round — see `stoppedOnOpenRound` — a page that + /// stayed stale for longer than the engine allows, or the page budget — + /// see `stoppedOnBudget`); what landed before the stop stays. For a run + /// that `resumedFromCheckpoint`, `true` means it reached the end of the + /// cycle an earlier run began. public var completed = true + /// The run read as many pages as `platform-wallet` allows one run, the + /// heal pass's and the classify pass's together, before the end of the + /// cycle, and stopped cleanly between pages. Not a fault: the wallet's + /// next run picks up at the first page this one did not apply. + /// `completed` is `false`. + public var stoppedOnBudget = false + /// A store step found a Rust persistence round open, and the run + /// stopped there, between pages, with that page not reported applied. + /// Not a fault: `platform-wallet` keeps the wallet due, and its next + /// run picks up at that page. `completed` is `false`. + public var stoppedOnOpenRound = false + /// The run picked up where an earlier run of this wallet stopped, + /// rather than at the first heal page. Its counts cover only the pages + /// it walked itself. + public var resumedFromCheckpoint = false public init() {} @@ -208,17 +331,50 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { public var mutations: Int { inserted + flipped + healedSpent } } -/// Why `reconcileCoreTxoStore(for:)` did not run. +/// Why `reconcileCoreTxoStore(for:)` did not run. Every reason but +/// `notConfigured`, `shutdownRequested`, a wallet this manager has not +/// loaded and a request already in flight on this manager is +/// `platform-wallet`'s start gate answering +/// (`platform_wallet_manager_txo_reconcile_begin`). public enum CoreTxoReconcileSkipReason: String, Sendable { case notConfigured case shutdownRequested + /// The manager has not loaded the wallet, or the engine does not hold + /// it, or it was removed while the run waited to start. case walletUnknown + /// A run for the wallet is in flight: an earlier request on this + /// manager has not returned, or the engine holds the wallet's claim. case alreadyRunning + /// No longer reported: the start gate answers `notSteadyState` for a + /// client that is not running. Kept so existing `switch` statements + /// still compile. case spvNotRunning + /// SPV is not running, or not in its steady state. case notSteadyState case tipUnavailable case syncFaultDetected case walletBehindTip + + /// The reason a non-run code from the start gate names; `nil` for a + /// code this build does not know. + init?(gateCode: UInt8) { + switch gateCode { + case UInt8(TXO_RECONCILE_SKIP_WALLET_UNKNOWN): + self = .walletUnknown + case UInt8(TXO_RECONCILE_SKIP_ALREADY_RUNNING): + self = .alreadyRunning + case UInt8(TXO_RECONCILE_SKIP_NOT_STEADY_STATE): + self = .notSteadyState + case UInt8(TXO_RECONCILE_SKIP_TIP_UNAVAILABLE): + self = .tipUnavailable + case UInt8(TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED): + self = .syncFaultDetected + case UInt8(TXO_RECONCILE_SKIP_WALLET_BEHIND_TIP): + self = .walletBehindTip + default: + return nil + } + } } public enum CoreTxoReconcileOutcome: Equatable, Sendable { @@ -226,51 +382,216 @@ public enum CoreTxoReconcileOutcome: Equatable, Sendable { case reconciled(CoreTxoReconcileReport) } -// MARK: - FFI adapter +// MARK: - Native calls -/// The production `CoreTxoEngineInventory`: the two paged FFI reads on -/// one manager handle, for one wallet. -struct FFICoreTxoEngineInventory: CoreTxoEngineInventory { - let handle: Handle - let walletId: Data +/// Where a granted run starts — `platform-wallet`'s answer, from where the +/// wallet's last run stopped (`TXO_RECONCILE_START_*`). +enum CoreTxoReconcileStart: Equatable, Sendable { + /// The heal pass, then the classify pass from the store's first row. + /// `resumed` when an earlier run stopped in the heal pass: the engine + /// keeps that place, so the run passes no cursor. + case heal(resumed: Bool) + /// Straight to the classify pass, after `after`: the store cursor an + /// earlier run's last applied page left, handed back as it was given + /// (empty: the first row). + case classify(after: 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 + /// The start a `TXO_RECONCILE_START_*` code names; `nil` for a code + /// this build does not know. + init?(code: UInt8, storeAfter: Data) { + switch code { + case UInt8(TXO_RECONCILE_START_HEAL): + self = .heal(resumed: false) + case UInt8(TXO_RECONCILE_START_HEAL_RESUMED): + self = .heal(resumed: true) + case UInt8(TXO_RECONCILE_START_CLASSIFY): + self = .classify(after: storeAfter) + default: + return nil } - let hasCursor = after != nil - var rowsPtr: UnsafePointer? - var count: UInt = 0 - var hasMore = false + } + + /// Whether the run picks up where an earlier one stopped. + var isResumed: Bool { self != .heal(resumed: false) } +} + +/// What `platform_wallet_manager_txo_reconcile_begin` answered. +enum CoreTxoReconcileGate: Equatable, Sendable { + /// The run may start at `start`; the wallet is claimed until + /// `platform_wallet_manager_txo_reconcile_finish`. The native claim + /// keeps the scan tip the run is measured against. + case run(start: CoreTxoReconcileStart) + /// The gate's `TXO_RECONCILE_SKIP_*` code. Nothing is claimed. + case skip(code: UInt8) +} + +/// How a granted run ended, as `platform_wallet_manager_txo_reconcile_finish` +/// takes it (`TXO_RECONCILE_FINISH_*`). What follows from each — when the +/// wallet is next due, where its next run starts — is `platform-wallet`'s. +enum CoreTxoReconcileFinish: Equatable, Sendable { + /// The run never walked: it was stopped between its begin and its walk, + /// or the begin named a start this build does not know. + case notRun + /// The run walked, however far it got. + case ran + /// The run stopped on a store step deferred behind an open Rust + /// persistence round (`CoreTxoReconcileReport.stoppedOnOpenRound`), with + /// that page not reported applied. + case deferred + + /// The `TXO_RECONCILE_FINISH_*` code the finish call takes. + var code: UInt8 { + switch self { + case .notRun: + return UInt8(TXO_RECONCILE_FINISH_NOT_RUN) + case .ran: + return UInt8(TXO_RECONCILE_FINISH_RAN) + case .deferred: + return UInt8(TXO_RECONCILE_FINISH_DEFERRED) + } + } +} + +/// The native calls that bracket one reconcile run; same seam shape as +/// [`PlatformWalletNativePollCalls`]. Tests with a fake manager handle must +/// inject this table: handles come from a process-global registry. +struct PlatformWalletNativeTxoReconcileCalls: @unchecked Sendable { + /// Claim the wallet and run the start gate. + let begin: @Sendable (Handle, Data) throws -> CoreTxoReconcileGate + /// Release a `.run` claim, with how the run ended. + let finish: @Sendable (Handle, Data, CoreTxoReconcileFinish) throws -> Void + /// The engine side of one run `begin` granted. + let makeEngine: @Sendable (Handle, Data) -> any CoreTxoReconcileEngine + + static let live = PlatformWalletNativeTxoReconcileCalls( + begin: CoreTxoReconcileFFI.begin, + finish: CoreTxoReconcileFFI.finish, + makeEngine: { handle, walletId in + FFICoreTxoReconcileEngine(handle: handle, walletId: walletId) + } + ) +} + +/// Thin wrappers over the reconcile C API's schedule and claim calls. Each +/// parks the calling thread — call off-main. +enum CoreTxoReconcileFFI { + /// The size of `begin`'s store cursor buffer, and the longest cursor + /// `classifyPageApplied(nextAfter:)` may hand the engine: the FFI's + /// `TXO_RECONCILE_STORE_CURSOR_MAX`. The classify pass's cursor is 8 + /// bytes. + static let storeCursorMax = Int(TXO_RECONCILE_STORE_CURSOR_MAX) + + /// The loaded wallets whose reconcile is due now + /// (`platform_wallet_manager_txo_reconcile_due_wallets`). Called on every + /// progress poll, running or not: the poll is the schedule's clock. + static func dueWallets(_ handle: Handle) throws -> [Data] { + try readIdentifierArray { platform_wallet_manager_txo_reconcile_due_wallets(handle, &$0) } + } + + /// The start gate. On `RUN` the wallet is claimed; a start code this + /// build does not know is a contract break with the native library it + /// shipped with, so the claim is released (as a run that did not go + /// ahead, `.notRun`) and the call throws. + static func begin(_ handle: Handle, walletId: Data) throws -> CoreTxoReconcileGate { + guard walletId.count == 32 else { + return .skip(code: UInt8(TXO_RECONCILE_SKIP_WALLET_UNKNOWN)) + } + var gate = UInt8(TXO_RECONCILE_SKIP_WALLET_UNKNOWN) + var startCode = UInt8(TXO_RECONCILE_START_HEAL) + var storeAfter = [UInt8](repeating: 0, count: storeCursorMax) + var storeAfterLength: UInt = 0 let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in - withUnsafePointer(to: &cursor) { cursorPtr in - platform_wallet_wallet_utxos_page( + storeAfter.withUnsafeMutableBufferPointer { afterPtr in + platform_wallet_manager_txo_reconcile_begin( handle, raw.baseAddress?.assumingMemoryBound(to: UInt8.self), - hasCursor ? cursorPtr : nil, - UInt(max(limit, 0)), - &rowsPtr, - &count, - &hasMore + &gate, + &startCode, + afterPtr.baseAddress, + &storeAfterLength ) } } 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] = [] + guard gate == UInt8(TXO_RECONCILE_GATE_RUN) else { return .skip(code: gate) } + let after = Data(storeAfter.prefix(min(Int(storeAfterLength), storeCursorMax))) + guard let start = CoreTxoReconcileStart(code: startCode, storeAfter: after) else { + try? finish(handle, walletId: walletId, outcome: .notRun) + throw PlatformWalletError.walletOperation("unknown TXO reconcile start code \(startCode)") + } + return .run(start: start) + } + + static func finish(_ handle: Handle, walletId: Data, outcome: CoreTxoReconcileFinish) throws { + guard walletId.count == 32 else { return } + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + platform_wallet_manager_txo_reconcile_finish( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + outcome.code + ) + } + try result.check() + } +} + +// MARK: - FFI adapter + +/// The production `CoreTxoReconcileEngine`: the reconcile C API on one +/// manager handle, for one claimed wallet. The heal verdicts are measured +/// against the run's own tip, and the run's place and page budget are +/// counted, both by the native claim. +/// +/// Holds at most one engine guard — the token the last heal page or +/// classification handed out — and frees it in `releaseVerdicts()`, in the +/// next held read before it asks, or as a last resort in `deinit`. The free +/// takes no manager handle, so it is safe whatever else is queued. +final class FFICoreTxoReconcileEngine: CoreTxoReconcileEngine, @unchecked Sendable { + let handle: Handle + let walletId: Data + private let lock = NSLock() + private var held: OpaquePointer? + + init(handle: Handle, walletId: Data) { + self.handle = handle + self.walletId = walletId + } + + deinit { + releaseVerdicts() + } + + func healPage(limit: Int) throws -> CoreTxoReconcileRead<(rows: [CoreTxoHealRow], hasMore: Bool)> { + var rowsPtr: UnsafePointer? + var count: UInt = 0 + var hasMore = false + var budgetSpent = false + // A guard the caller failed to release goes before this asks for a + // new one, as in `classifyHeld`. + releaseVerdicts() + var token: OpaquePointer? + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + platform_wallet_txo_reconcile_heal_page( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(max(limit, 0)), + &rowsPtr, + &count, + &hasMore, + &budgetSpent, + &token + ) + } + keep(token) + try result.check() + if budgetSpent { return .budgetSpent } + guard let rowsPtr, count > 0 else { return .page(([], hasMore)) } + defer { platform_wallet_txo_reconcile_heal_page_free(UnsafeMutablePointer(mutating: rowsPtr), count) } + var rows: [CoreTxoHealRow] = [] rows.reserveCapacity(Int(count)) for i in 0.. [CoreOutpointClass] { - guard !queries.isEmpty else { return [] } + func classifyHeld(_ queries: [CoreOutpointOwnershipQuery]) throws -> CoreTxoReconcileRead<[CoreOutpointClass]> { + // An empty page is asked about too (`count` 0, no queries): it is + // one page of the run's budget, and holds a guard like any other. // Every script must stay addressable for the whole call: copy each // into its own allocation rather than nesting `withUnsafeBytes` // closures `queries.count` deep. @@ -328,22 +651,91 @@ struct FFICoreTxoEngineInventory: CoreTxoEngineInventory { entry.script_pubkey_len = UInt(scriptCount) ffiQueries.append(entry) } + // A guard the caller failed to release goes before this asks for a + // new one; Rust would drop it too, but the token is ours to free. + releaseVerdicts() var classes = [UInt8](repeating: 0, count: queries.count) + var budgetSpent = false + var token: OpaquePointer? let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in ffiQueries.withUnsafeBufferPointer { queriesPtr in classes.withUnsafeMutableBufferPointer { classesPtr in - platform_wallet_classify_outpoints( + platform_wallet_txo_reconcile_classify_outpoints( handle, raw.baseAddress?.assumingMemoryBound(to: UInt8.self), queriesPtr.baseAddress, UInt(queries.count), - classesPtr.baseAddress + classesPtr.baseAddress, + &budgetSpent, + &token ) } } } + // Kept before the result is checked, so a token handed out by a call + // that then failed is still released, never dropped. + keep(token) try result.check() - return classes.map { CoreOutpointClass(rawValue: $0) ?? .unknown } + if budgetSpent { return .budgetSpent } + return .page(classes.map { CoreOutpointClass(rawValue: $0) ?? .unknown }) + } + + func healPageApplied() throws -> Bool { + var mayContinue = false + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + platform_wallet_txo_reconcile_heal_page_applied( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + &mayContinue + ) + } + try result.check() + return mayContinue + } + + func classifyPageApplied(nextAfter: Data?) throws -> Bool { + var mayContinue = false + let cursor = nextAfter ?? Data() + let result = walletId.withUnsafeBytes { raw -> PlatformWalletFFIResult in + cursor.withUnsafeBytes { after -> PlatformWalletFFIResult in + platform_wallet_txo_reconcile_classify_page_applied( + handle, + raw.baseAddress?.assumingMemoryBound(to: UInt8.self), + after.baseAddress?.assumingMemoryBound(to: UInt8.self), + UInt(after.count), + nextAfter == nil, + &mayContinue + ) + } + } + try result.check() + return mayContinue + } + + func releaseVerdicts() { + let token = lock.withLock { () -> OpaquePointer? in + defer { held = nil } + return held + } + if let token { + platform_wallet_txo_reconcile_held_verdicts_free(token) + } + } + + func retryStalePage(staleInARow: Int) -> Bool { + platform_wallet_txo_reconcile_retry_stale_page(UInt32(clamping: staleInARow)) + } + + /// Hold `token` as the run's one guard. A guard already held is freed, + /// never overwritten. + private func keep(_ token: OpaquePointer?) { + let previous = lock.withLock { () -> OpaquePointer? in + defer { held = token } + return held + } + if let previous { + platform_wallet_txo_reconcile_held_verdicts_free(previous) + } } /// Copy up to 32 bytes of `data` into a C `uint8_t[32]` field, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index ed6400305fd..e8206d87778 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3779,27 +3779,6 @@ extension ManagedPlatformWallet { trackedAssetLocksCount: Int(ffi.tracked_asset_locks_count) ) } - - /// Shared array-marshalling body for the two id-list explorer - /// readers above. Both wrap the same `IdentifierArray` FFI shape + - /// free helper, so the per-method variance is just the FFI call - /// itself. - private func readIdentifierArray( - _ fetch: (inout IdentifierArray) -> PlatformWalletFFIResult - ) throws -> [Identifier] { - var array = IdentifierArray() - try fetch(&array).check() - defer { platform_wallet_identifier_array_free(&array) } - guard array.items != nil, array.count > 0 else { - return [] - } - var ids: [Identifier] = [] - ids.reserveCapacity(Int(array.count)) - for i in 0.. + /// The TXO-store reconcile schedule's clock: read every tick, SPV + /// running or not. + let txoReconcileDueWallets: Read<[Data]> static let live = PlatformWalletNativePollCalls( syncProgress: PlatformWalletManager.readSyncProgress, @@ -404,7 +409,8 @@ struct PlatformWalletNativePollCalls: @unchecked Sendable { isPlatformAddressSyncing: PlatformWalletManager.readIsPlatformAddressSyncing, isShieldedSyncing: PlatformWalletManager.readIsShieldedSyncing, isDashPaySyncing: PlatformWalletManager.readIsDashPaySyncing, - pendingAccountBuildCount: PlatformWalletManager.readPendingAccountBuildCount + pendingAccountBuildCount: PlatformWalletManager.readPendingAccountBuildCount, + txoReconcileDueWallets: CoreTxoReconcileFFI.dueWallets ) } @@ -722,14 +728,21 @@ public class PlatformWalletManager: ObservableObject { // 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. + /// Wallets whose automatically launched reconcile has not returned yet, + /// so the poll does not queue a second launch behind it. Bookkeeping of + /// this manager's own tasks only: whether and when a run may start is + /// the engine's schedule and start gate. + var coreTxoReconcileLaunched: Set = [] + /// Wallets with a `reconcileCoreTxoStore(for:)` call between its checks + /// and its return, whoever called it. Runs execute one at a time on the + /// serial `coreTxoReconcileQueue`, so a second request for the same + /// wallet would queue behind the first and run in full after it — the + /// engine's own one-run-per-wallet check never sees the two overlap. + /// This set answers that request `alreadyRunning` instead. 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 + /// Test seam for the native calls that bracket a reconcile run; same + /// contract as [`nativeTeardownCalls`]. + internal var nativeTxoReconcileCalls = PlatformWalletNativeTxoReconcileCalls.live /// Bumped by [`shutdown()`] and [`deleteWallet`]; an in-flight run /// re-checks it between pages and stops. nonisolated let coreTxoReconcileEpoch = SyncGenerationCounter() @@ -975,6 +988,7 @@ public class PlatformWalletManager: ObservableObject { let stopQueue = shieldedStopQueue let queue = pollQueue let walletQueue = walletPollQueue + let reconcileQueue = coreTxoReconcileQueue let drainTimeout = pollDrainTimeout let task = Task { let shieldedStop = await withCheckedContinuation { continuation in @@ -1002,6 +1016,11 @@ public class PlatformWalletManager: ObservableObject { // waits are bounded rather than unconditional. await Self.drainQueue(queue, within: drainTimeout) await Self.drainQueue(walletQueue, within: drainTimeout) + // A TXO-store reconcile staled by the epoch above stops at its + // next page boundary and releases its native claim; the same + // bounded window usually lets that release land before destroy, + // which would otherwise drop the claim with the manager. + await Self.drainQueue(reconcileQueue, within: drainTimeout) return await withCheckedContinuation { (continuation: CheckedContinuation) in Self.destroyQueue.async { continuation.resume(returning: Self.performNativeTeardown( @@ -1116,10 +1135,11 @@ public class PlatformWalletManager: ObservableObject { /// is the only production path that assigns a handle). static func makeForTesting( handle: Handle, - calls: PlatformWalletNativeTeardownCalls + calls: PlatformWalletNativeTeardownCalls, + persistenceHandler: PlatformWalletPersistenceHandler? = nil ) -> PlatformWalletManager { let manager = PlatformWalletManager() - try! manager.configureForTesting(handle: handle, calls: calls) + try! manager.configureForTesting(handle: handle, calls: calls, persistenceHandler: persistenceHandler) return manager } @@ -1134,13 +1154,17 @@ public class PlatformWalletManager: ObservableObject { /// separate from the factory lets tests cover shutdown-before-configure. func configureForTesting( handle: Handle, - calls: PlatformWalletNativeTeardownCalls + calls: PlatformWalletNativeTeardownCalls, + persistenceHandler: PlatformWalletPersistenceHandler? = nil ) throws { try ensureConfigurationAllowed() precondition(handle != NULL_HANDLE) self.handle = handle isConfigured = true nativeTeardownCalls = calls + // Not registered with Rust: only the paths that read the handler + // directly (the TXO-store reconcile) see it. + self.persistenceHandler = persistenceHandler } // MARK: - Configuration @@ -2723,9 +2747,9 @@ public class PlatformWalletManager: ObservableObject { 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. + // remove. Coarse on purpose — the cadence re-runs the others. The + // engine forgot this wallet's cadence when it removed the wallet. coreTxoReconcileEpoch.bump() - coreTxoReconcileLastRunAt.removeValue(forKey: walletId) try persistenceHandler.deleteWalletData(walletId: walletId) @@ -3155,6 +3179,11 @@ public class PlatformWalletManager: ObservableObject { } catch { snapshot.spvTipBlockTime = nil } + guard !isStale() else { return snapshot } + // Every tick, SPV running or not: the poll is the reconcile + // schedule's clock, and a stopped client arms its next steady-state + // edge. + snapshot.txoReconcileDueWallets = try? calls.txoReconcileDueWallets(handle) logSlowStage("manager", since: start, walletCount: 0) return snapshot } @@ -3222,10 +3251,6 @@ public class PlatformWalletManager: ObservableObject { if value != spvProgress { spvProgress = value } - // Every accepted read, not only a changed one: the note is the - // reconcile's only clock, and a quiet steady-state wallet's - // progress does not change for the whole cadence. - noteSpvProgressForCoreTxoReconcile(value) } if let value = snapshot.spvIsRunning, spvIsRunning == baseline.spvIsRunning, value != spvIsRunning { @@ -3254,6 +3279,9 @@ public class PlatformWalletManager: ObservableObject { spvTipBlockTime == baseline.spvTipBlockTime, value != spvTipBlockTime { spvTipBlockTime = value } + if let due = snapshot.txoReconcileDueWallets, !due.isEmpty { + launchDueCoreTxoReconciles(due) + } } /// Publishes the per-wallet needs-unlock counts, with the same diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index 19ad5006dda..2380bf1954a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -13,60 +13,98 @@ import Foundation // 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: +// and repairs exactly what positive evidence supports: // -// - insert an engine-held coin the store lacks (validated, owned, -// ≥ 100 confirmations); +// - insert an engine-held coin the store lacks, when the engine's heal +// verdict calls it eligible and the store resolves its account; // - 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`). +// the coin, and its spender is in a block the wallet's ChainLock covers +// (`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. +// between pages when the manager shuts down or a wallet is deleted. +// +// Every decision is `platform-wallet`'s (`manager::txo_reconcile`, through +// the reconcile C API) — the same answers the Kotlin SDK gets: which +// wallets are due and when, whether a run may start (one per wallet, SPV +// steady with a scan tip, no latched sync fault, the wallet scanned to the +// tip), which engine coins may be healed, which store rows are proven +// spent, how long a stale page is retried, how many pages one run may read +// (the heal pass's and the classify pass's together), and where the +// wallet's next run picks up. The begin says where this run starts; the +// engine keeps the run's place in its own inventory and counts its pages, +// and the run reports each page applied once its store step has committed +// (`healPageApplied`, `classifyPageApplied`). A run that has read its +// budget stops between pages (`stoppedOnBudget`), and the next one resumes +// at the first page it did not apply. So does a run whose store step found +// a Rust persistence round open: it stops there, with that page not +// reported applied (`stoppedOnOpenRound`), and finishes as deferred, which +// leaves the wallet due — the next 1 Hz poll's run reads the page again. +// Swift never waits for the round itself. This file keeps only what needs +// the store: paging its rows (by offset, which it hands the engine as its +// opaque store cursor), resolving the account that owns each, its writes, +// and the fences that keep Rust's persistence rounds and teardown out of a +// run's writes. +// +// Lock order, the engine's own: the native handle registry (read only +// inside one native call, to resolve the manager), the engine's wallet lock +// (held for reading by a page's classification until its apply is +// written), the persistence queue, the SwiftData save. The engine calls the +// persistence callbacks — which run on that queue — while it holds its +// wallet lock for writing, so a classification is asked for off the +// persistence queue, never from a callback, and its guard is released after +// the apply has left the queue and before anything resolves the manager +// again: the page's applied report, the next page's read, or the run's +// finish. extension PlatformWalletManager { - /// Confirmations an engine coin needs before the heal pass inserts it. - /// Coinbase maturity; also well past any plausible reorg. - nonisolated static let coreTxoReconcileMinConfirmations: UInt32 = 100 - /// How far the wallet's durable scan watermark may trail the scan tip - /// for the scan to count as complete for this wallet. - nonisolated static let coreTxoReconcileTipMargin: UInt32 = 6 - /// Cadence of the automatic run while the client stays in steady state. - nonisolated static let coreTxoReconcileCadence: Duration = .seconds(30 * 60) /// Rows per engine page / per store page. nonisolated static let coreTxoReconcilePageSize = 512 - /// How many times a step is deferred behind open Rust rounds before the - /// run gives up (each deferral waits `coreTxoReconcileRetryDelay`). - nonisolated static let coreTxoReconcileMaxRetries = 200 - nonisolated static let coreTxoReconcileRetryDelay: TimeInterval = 0.05 - /// How many times in a row one page is classified again because a - /// persistence round committed between its read and its apply, before - /// the run gives up. - nonisolated static let coreTxoReconcileMaxStaleRetries = 5 - /// Reconcile the SwiftData TXO store of `walletId` against the engine, - /// once the SPV scan has reached a trustworthy steady state — see the - /// file comment for what it repairs and what it refuses to touch. + /// Reconcile the SwiftData TXO store of `walletId` against the engine — + /// see the file comment for what it repairs and what it refuses to + /// touch. + /// + /// Whether the run may start is `platform-wallet`'s start gate + /// (`platform_wallet_manager_txo_reconcile_begin`): one run per wallet + /// at a time, SPV in steady state with a scan tip, no latched sync + /// fault, and the wallet's own scan watermark, as the engine holds it, + /// within the engine's margin of the tip. Each refusal is a `.skipped` + /// outcome rather than an error; so are an unconfigured or shutting-down + /// manager, a wallet this manager has not loaded, and a second request + /// for a wallet whose earlier request here has not returned + /// (`alreadyRunning`, from `coreTxoReconcileInFlight`: runs share one + /// serial queue, so the engine would only see the second after the + /// first had finished), checked here first. /// - /// 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`. + /// How much the run does is native too: it reads at most the engine's + /// page budget and stops between pages when that is spent, reporting + /// `stoppedOnBudget`; the wallet's next run picks up where it stopped + /// (`resumedFromCheckpoint`). A store step that finds a Rust + /// persistence round open stops the run the same way + /// (`stoppedOnOpenRound`), and the run is finished as deferred, so the + /// engine's schedule reports the wallet due again on the next poll. /// - /// Runs automatically on the steady-state transition and every - /// `coreTxoReconcileCadence`; hosts may also call it directly. + /// The whole run — begin, both passes, the release of the last engine + /// guard and the finish that releases the wallet's claim — is one + /// synchronous block on `coreTxoReconcileQueue`, so nothing comes + /// between a granted begin and its finish: not a cancelled caller, not + /// a failed page, not a shutdown. Store steps run on the persistence + /// queue, one step per closure. The run stops between pages when + /// `shutdown()` or `deleteWallet` bumps `coreTxoReconcileEpoch`, or the + /// calling task is cancelled, and reports `completed == false`; a + /// cancelled caller whose run never started gets `CancellationError`. + /// Each store step checks the stop again once it is on the persistence + /// queue, so a page whose engine guard teardown may already have + /// released writes nothing. + /// + /// Runs automatically whenever the engine's schedule reports the wallet + /// due (see `launchDueCoreTxoReconciles`); hosts may also call it. + /// Logs one skip event or one summary event — counts and keyed + /// references only. public func reconcileCoreTxoStore(for walletId: Data) async throws -> CoreTxoReconcileOutcome { guard isConfigured, handle != NULL_HANDLE, let handler = persistence else { return logSkip(.notConfigured, walletId: walletId) @@ -75,281 +113,541 @@ extension PlatformWalletManager { guard walletId.count == 32, wallets[walletId] != nil else { return logSkip(.walletUnknown, walletId: walletId) } + // A run for this wallet already requested here has not returned: + // it is running or queued on the serial reconcile queue, where a + // second one would only wait and then repeat it. 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 calls = nativeTxoReconcileCalls let queue = coreTxoReconcileQueue + // Moved when the calling task is cancelled; the run reads it + // between pages, like the epoch. + let callerCancelled = SyncGenerationCounter() - // 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 + // `withCheckedContinuation` is not interrupted by cancellation, so + // the run's result — and with it a granted begin — is never dropped + // on the way back. + let result: Result = await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + queue.async { + continuation.resume(returning: Result { + try Self.performCoreTxoReconcileRun( + handle: managerHandle, + walletId: walletId, + calls: calls, + handler: handler, + isCancelled: { epoch.current() != generation || callerCancelled.current() != 0 } + ) + }) } - 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) + } onCancel: { + callerCancelled.bump() } - 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) + switch try result.get() { + case .reconciled(let report): + return .reconciled(report) + case .skipped(let reason): + return logSkip(reason, walletId: walletId) + case nil: + // Stopped before the walk began: shut down, a wallet deleted + // meanwhile, or the caller cancelled. + if shutdownRequested || handle == NULL_HANDLE { + return logSkip(.shutdownRequested, walletId: walletId) } + if callerCancelled.current() != 0 { + throw CancellationError() + } + return logSkip(.walletUnknown, walletId: walletId) } - 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 + /// One run's native protocol, synchronous on the reconcile queue: claim + /// the wallet, walk it, release it. `nil` when `isCancelled` stopped it + /// before the walk began — a granted claim is released first. + /// + /// A `.run` begin is always followed by a finish, in a `defer`, after + /// the engine guard the last page may still hold is released: a claim + /// left behind would refuse every later run of the wallet as + /// `alreadyRunning`. The finish says how the run ended: `.notRun` when + /// it stopped before the walk began, `.deferred` when the walk stopped + /// on an open persistence round, `.ran` otherwise. A skip claims + /// nothing and is not finished. A gate code this build does not know is + /// a contract break with the native library it shipped with, not a + /// reason to guess, and throws. + /// `nonisolated static` so tests can drive it with injected native + /// calls and handler, without a native manager. + nonisolated static func performCoreTxoReconcileRun( + handle: Handle, + walletId: Data, + calls: PlatformWalletNativeTxoReconcileCalls, + handler: PlatformWalletPersistenceHandler, + pageSize: Int = coreTxoReconcilePageSize, + isCancelled: @Sendable () -> Bool + ) throws -> CoreTxoReconcileOutcome? { + guard !isCancelled() else { return nil } + let start: CoreTxoReconcileStart + switch try calls.begin(handle, walletId) { + case .skip(let code): + guard let reason = CoreTxoReconcileSkipReason(gateCode: code) else { + throw PlatformWalletError.walletOperation("unknown TXO reconcile gate code \(code)") + } + return .skipped(reason) + case .run(let granted): + start = granted } - } - /// 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 + let engine = calls.makeEngine(handle, walletId) + var finishOutcome = CoreTxoReconcileFinish.notRun + defer { + endCoreTxoReconcileRun(walletId: walletId, engine: engine, quiet: isCancelled()) { + try calls.finish(handle, walletId, finishOutcome) + } } - return nil + guard !isCancelled() else { return nil } + let report = runCoreTxoReconcile( + walletId: walletId, + pageSize: pageSize, + start: start, + engine: engine, + handler: handler, + isCancelled: isCancelled + ) + finishOutcome = report.stoppedOnOpenRound ? .deferred : .ran + return .reconciled(report) } - /// 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 + /// End a run: free the engine guard its last page may still hold, then + /// release the wallet's claim. The guard goes first and without the + /// handle registry — `finish` resolves the manager there, which must + /// never be waited for while a guard is held. A failed finish cannot + /// skip the release; it is logged by wallet reference and error only, + /// at debug when `quiet` (the run was staled: a shutdown that destroyed + /// the manager first already released the claim with it). + nonisolated static func endCoreTxoReconcileRun( + walletId: Data, + engine: any CoreTxoReconcileEngine, + quiet: Bool = false, + finish: () throws -> Void + ) { + engine.releaseVerdicts() + do { + try finish() + } catch { + SDKLogger.event( + "persistence_txo_reconcile_release_failed", + category: .persistence, + severity: quiet ? .debug : .warning, + fields: ["wallet_reference": .reference(walletId)], + error: error ) } - 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. + /// The walk of one claimed run, from `start` (the begin's answer): + /// engine reads on the calling thread (the reconcile queue), store steps + /// on the persistence queue. Logs the run's summary. Synchronous and + /// `nonisolated static` so tests can drive it with an injected engine + /// and handler, without a native manager. + /// + /// Each page goes: engine read, store step, guard released, then — only + /// when the step committed or there was nothing to write — the page + /// reported applied, whose answer says whether the run may read + /// another. A page that failed, was deferred, came back stale, or was + /// stopped is never reported applied, so the wallet's next run reads it + /// again. A store step deferred behind an open persistence round stops + /// the run there (`stopOnOpenRound`); it is never waited for or retried + /// here. The run stops on the budget when a read says the budget is + /// spent, or when an applied report says no budget is left before the + /// cycle's end; the classify pass's last store page is reported as the + /// last, which ends the cycle. nonisolated static func runCoreTxoReconcile( walletId: Data, - tipHeight: UInt32, - minConfirmations: UInt32 = coreTxoReconcileMinConfirmations, pageSize: Int = coreTxoReconcilePageSize, - engine: any CoreTxoEngineInventory, + start: CoreTxoReconcileStart = .heal(resumed: false), + engine: any CoreTxoReconcileEngine, handler: PlatformWalletPersistenceHandler, isCancelled: @Sendable () -> Bool ) -> CoreTxoReconcileReport { var report = CoreTxoReconcileReport() + report.resumedFromCheckpoint = start.isResumed + let limit = max(pageSize, 1) + switch start { + case .heal: + if healMissingTxos(walletId, limit, engine, handler, isCancelled, &report) { + classifyStoreRows(walletId, limit, 0, engine, handler, isCancelled, &report) + } + case .classify(let after): + classifyStoreRows( + walletId, limit, coreTxoStoreOffset(cursor: after), engine, handler, isCancelled, &report) + } + logSummary(report, walletId: walletId) + return report + } + + /// The store cursor the classify pass hands the engine for the page + /// starting at `offset`: the offset as 8 bytes, little-endian. The + /// engine keeps it unread and hands it back to the wallet's next run. + /// An offset resumed in a later run may skip or repeat a few rows when + /// the store changed between the runs (rows inserted, flipped or + /// written by another wallet ahead of it); the next cycle walks them. + nonisolated static func coreTxoStoreCursor(offset: Int) -> Data { + withUnsafeBytes(of: UInt64(clamping: max(offset, 0)).littleEndian) { Data($0) } + } + + /// The offset `coreTxoStoreCursor(offset:)` encoded; 0 — the store's + /// first row — for an empty or malformed cursor. + nonisolated static func coreTxoStoreOffset(cursor: Data) -> Int { + guard cursor.count == 8 else { return 0 } + let value = cursor.reduce(into: (value: UInt64(0), shift: UInt64(0))) { acc, byte in + acc.value |= UInt64(byte) << acc.shift + acc.shift += 8 + }.value + return Int(exactly: value) ?? 0 + } + + /// The run read its budget, or was told none is left, before the end of + /// the cycle: it stops cleanly between pages. + private nonisolated static func stopOnBudget(_ report: inout CoreTxoReconcileReport) { + report.stoppedOnBudget = true + report.completed = false + } + + /// A store step found a Rust persistence round open: the run stops + /// cleanly between pages, the page not reported applied, and is finished + /// as deferred, so `platform-wallet` keeps the wallet due and its next + /// run reads the page again. Saving mid-round would commit the round's + /// staged rows early, and waiting for the round here would be Swift + /// owning a retry policy. + private nonisolated static func stopOnOpenRound(_ report: inout CoreTxoReconcileReport) { + report.retries += 1 + report.stoppedOnOpenRound = true + report.completed = false + } - // Pass A — heal: every engine coin the store lacks. - var cursor: CoreEngineUtxo? + /// Report the page just written applied — after its guard was released: + /// the call resolves the manager handle. Returns whether the run may + /// read another page, or `nil` when the run must stop: cancelled (the + /// page is left unreported, so the next run reads it again; its step is + /// idempotent) or the call failed. + private nonisolated static func reportPageApplied( + _ report: inout CoreTxoReconcileReport, + isCancelled: @Sendable () -> Bool, + _ applied: () throws -> Bool + ) -> Bool? { + if isCancelled() { + report.completed = false + return nil + } + do { + return try applied() + } catch { + report.transportFailures += 1 + report.completed = false + return nil + } + } + + /// Heal pass: the engine's coins from where the run starts (the engine + /// keeps that place), a page at a time, each page applied in one store + /// step and then reported applied. Returns whether the run may go on to + /// the classify pass: the inventory's end was reached with budget left. + /// + /// Each page is read under an engine guard, held until its step is done: + /// a fork that truncated the engine between the read and the write would + /// leave a healed row unspent in the store for a coin the engine no + /// longer owns — the classify pass answers `unknown` for it and leaves + /// it, and the next launch restores it as spendable. A step deferred for + /// an open round releases the guard and stops the run without reporting + /// the page applied; the wallet's next run reads it again. + private nonisolated static func healMissingTxos( + _ walletId: Data, + _ limit: Int, + _ engine: any CoreTxoReconcileEngine, + _ handler: PlatformWalletPersistenceHandler, + _ isCancelled: @Sendable () -> Bool, + _ report: inout CoreTxoReconcileReport + ) -> Bool { while true { if isCancelled() { report.completed = false - return report + return false } - let page: (rows: [CoreEngineUtxo], hasMore: Bool) + let page: (rows: [CoreTxoHealRow], hasMore: Bool) + let step: CoreTxoReconcileStep? do { - page = try engine.utxoPage(after: cursor, limit: pageSize) - } catch { - report.transportFailures += 1 - report.completed = false - return report + defer { engine.releaseVerdicts() } + let read: CoreTxoReconcileRead<(rows: [CoreTxoHealRow], hasMore: Bool)> + do { + read = try engine.healPage(limit: limit) + } catch { + report.transportFailures += 1 + report.completed = false + return false + } + guard case .page(let value) = read else { + stopOnBudget(&report) + return false + } + page = value + if page.rows.isEmpty { + step = nil + } else if isCancelled() { + report.completed = false + return false + } else { + step = handler.reconcileHealMissingTxos( + walletId: walletId, rows: page.rows, isCancelled: isCancelled) + } } - 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 } + if let step { + let counts: CoreTxoHealCounts + switch step { + case .done(let value): + counts = value + case .stopped: + report.completed = false + return false + case .failed: + report.storeFailures += 1 + report.completed = false + return false + case .retryLater: + // The guard is already released. Not applied: the + // wallet's next run reads the same page. + stopOnOpenRound(&report) + return false + } 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.skippedMissingAddress += counts.skippedMissingAddress report.skippedInvalid += counts.skippedInvalid } - guard page.hasMore, let last = page.rows.last else { break } - cursor = last + report.engineRows += page.rows.count + // The step committed (or there was nothing to write) and the + // guard is released: report the page applied. + guard let mayContinue = reportPageApplied(&report, isCancelled: isCancelled, { + try engine.healPageApplied() + }) else { return false } + // At the inventory's end the engine moved the wallet's next run + // on to the classify pass; without budget left, this run stops + // before it. + guard mayContinue else { + stopOnBudget(&report) + return false + } + if !page.hasMore { return true } } + } - // 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 + /// Classify pass: the unspent store rows from `startOffset` (0, or the + /// offset an earlier run's last applied page left), a page at a time. + /// Each page is read on the persistence queue together with the store's + /// round generation, classified off it under an engine guard — an empty + /// page too, since each is one page of the run's budget — and applied + /// back on it, only if no round committed in between and none is open. + /// Otherwise nothing is written, the guard is released, and the page is + /// read and classified again (another page of the budget) for as long + /// as the engine allows (`retryStalePage`); then the run stops + /// incomplete. A page read that finds a round open stops the run + /// (`stopOnOpenRound`). Only the attempt that applies is reported + /// applied, with the store cursor past it, or as the last page when the + /// store has no more. + /// + /// The guard is what makes a verdict safe to write: the store's fences + /// see its persistence rounds, not the engine, and a fork can move the + /// engine with no round at all. It is held for one page — from the + /// classification to the end of that page's step — and released on + /// every path out of it, before the page is reported applied and before + /// the next page is read. + private nonisolated static func classifyStoreRows( + _ walletId: Data, + _ limit: Int, + _ startOffset: Int, + _ engine: any CoreTxoReconcileEngine, + _ handler: PlatformWalletPersistenceHandler, + _ isCancelled: @Sendable () -> Bool, + _ report: inout CoreTxoReconcileReport + ) { + var offset = startOffset + var staleInARow = 0 while true { if isCancelled() { report.completed = false - return report + return } - guard let page = withRoundRetry(&report, isCancelled: isCancelled, { - handler.reconcileUnspentTxoPage(walletId: walletId, offset: offset, limit: pageSize) - }) else { return report } - report.storeRows += page.rows.count + guard let page = storeStep(&report, handler.reconcileUnspentTxoPage( + walletId: walletId, offset: offset, limit: limit, isCancelled: isCancelled) + ) else { return } var flippedThisPage = 0 - if !page.rows.isEmpty { - let classes: [CoreOutpointClass] + do { + // The page counts as walked on every way out of its step but + // the two that hand it to a later read: no budget left to + // classify it, and a stale apply that reads it again. Counted + // once, as the step ends — before the page is reported + // applied. + var handedBack = false + defer { + if !handedBack { + report.storeRows += page.rows.count + report.unresolvedAccount += page.unresolvedAccount + } + } + defer { engine.releaseVerdicts() } + let read: CoreTxoReconcileRead<[CoreOutpointClass]> do { - classes = try engine.classify(page.rows.map(\.query)) + read = try engine.classifyHeld(page.rows.map(\.query)) } catch { report.transportFailures += 1 report.completed = false - return report + return + } + guard case .page(let classes) = read else { + // Not classified: this run walked none of the page, and + // the next run reads it. + handedBack = true + stopOnBudget(&report) + return } guard classes.count == page.rows.count else { + // No verdicts, no classification: stop rather than guess. report.transportFailures += 1 report.completed = false - return report + return } - guard let counts = withRoundRetry(&report, isCancelled: isCancelled, { - handler.reconcileApplyEngineClasses( + if !page.rows.isEmpty { + if isCancelled() { + report.completed = false + return + } + let counts: CoreTxoFlipCounts + switch handler.reconcileApplyEngineClasses( walletId: walletId, rows: page.rows, classes: classes, - expectedGeneration: page.generation - ) - }) else { return report } - if counts.staleGeneration { - staleRetries += 1 - report.staleRetries += 1 - guard staleRetries <= coreTxoReconcileMaxStaleRetries else { + expectedGeneration: page.generation, + isCancelled: isCancelled + ) { + case .done(let value): + counts = value + case .stopped: + // Stopped after the check above, before the apply + // reached the persistence queue: nothing written. + report.completed = false + return + case .failed: + report.storeFailures += 1 report.completed = false - return report + return + case .retryLater: + // The apply answers an open round as a stale page; + // a deferral is treated the same: nothing was + // written, and whether the page is read again is + // the engine's call (`retryStalePage`). + var stale = CoreTxoFlipCounts() + stale.staleGeneration = true + counts = stale + } + if counts.staleGeneration { + staleInARow += 1 + report.staleRetries += 1 + guard engine.retryStalePage(staleInARow: staleInARow) else { + report.completed = false + return + } + // Same offset, not reported applied: nothing was + // written, so the page is read again and classified + // against the store as it is now. + handedBack = true + continue } - // 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 + 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 } - staleRetries = 0 - report.flipped += counts.flipped - report.flippedDuffs = report.flippedDuffs.addingReportingOverflow(counts.flippedDuffs).0 - report.unspent += counts.unspent - report.unknown += counts.unknown - report.notOwned += counts.notOwned - flippedThisPage = counts.flipped } - guard page.hasMore else { break } + staleInARow = 0 + let isLast = !page.hasMore // Flipped rows left the `isSpent == false` predicate, so the // next page starts that many rows earlier — at the same offset // when the whole page flipped, which still makes progress: the // rows now at that offset are ones this walk has not seen. - offset += page.fetched - flippedThisPage + let next = offset + page.fetched - flippedThisPage + // The apply committed (or there was nothing to write) and the + // guard is released: report the page applied — the store's last + // page ends the cycle. + guard let mayContinue = reportPageApplied(&report, isCancelled: isCancelled, { + try engine.classifyPageApplied(nextAfter: isLast ? nil : coreTxoStoreCursor(offset: next)) + }) else { return } + if isLast { return } + guard mayContinue else { + stopOnBudget(&report) + return + } + offset = next } - 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( + /// The value of one store step taken with no engine guard held, or + /// `nil` when the run must stop: the step was stopped, it failed, or a + /// Rust round was open (`stopOnOpenRound`) — `report` says which. + private nonisolated static func storeStep( _ report: inout CoreTxoReconcileReport, - isCancelled: @Sendable () -> Bool, - _ step: () -> CoreTxoReconcileStep + _ 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) - } + switch step { + case .done(let value): + return value + case .stopped: + report.completed = false + return nil + case .failed: + report.storeFailures += 1 + report.completed = false + return nil + case .retryLater: + stopOnOpenRound(&report) + return nil } } - /// 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 + /// Automatic trigger, fed by the manager's 1 Hz progress poll with the + /// wallets the engine's schedule reports due + /// (`platform_wallet_manager_txo_reconcile_due_wallets`, which owns the + /// steady-state edge and the cadence). Launches one run per loaded + /// wallet — never inline, so the tick stays cheap — and no second one + /// while this manager's previous launch for the wallet has not returned: + /// a report consumes nothing until a run claims the wallet, so a run + /// still queued behind another wallet's is reported again on every + /// tick. A wallet the engine holds but this manager has not loaded yet + /// is reported again on the next tick. A run that throws is logged and + /// retried when the schedule next reports it due. Called on the main + /// actor from `applyManagerSnapshot`. + func launchDueCoreTxoReconciles(_ due: [Data]) { + // A manager configured without persistence has no store to repair: + // every run would skip before claiming the wallet, so the schedule + // would report it due again on every tick. + guard !shutdownRequested, handle != NULL_HANDLE, persistence != nil else { return } + for walletId in due where wallets[walletId] != nil && !coreTxoReconcileLaunched.contains(walletId) { + coreTxoReconcileLaunched.insert(walletId) Task { [weak self] in guard let self else { return } + defer { self.coreTxoReconcileLaunched.remove(walletId) } do { _ = try await self.reconcileCoreTxoStore(for: walletId) } catch { @@ -378,30 +676,45 @@ extension PlatformWalletManager { return .skipped(reason) } + /// The summary's level: `info` for a run that wrote nothing and either + /// completed, stopped on its budget, or stopped on an open persistence + /// round — expected states, the rest of the cycle is the next run's — + /// and `warning` for any write or any other stop. + nonisolated static func summarySeverity(_ report: CoreTxoReconcileReport) -> SDKLogSeverity { + let expectedStop = report.completed || report.stoppedOnBudget || report.stoppedOnOpenRound + return report.mutations == 0 && expectedStop ? .info : .warning + } + + /// The one summary event per run: counts, `completed`, how the run + /// started and stopped, and the wallet's reference — no amount, not even + /// a total. 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, + severity: summarySeverity(report), fields: [ "already_present_count": .integer(Int64(report.alreadyPresent)), "completed": .boolean(report.completed), "engine_row_count": .integer(Int64(report.engineRows)), "flipped_count": .integer(Int64(report.flipped)), - "flipped_value_duffs": .unsignedInteger(report.flippedDuffs), "healed_spent_count": .integer(Int64(report.healedSpent)), "inserted_count": .integer(Int64(report.inserted)), - "inserted_value_duffs": .unsignedInteger(report.insertedDuffs), "not_owned_count": .integer(Int64(report.notOwned)), + "resumed_from_checkpoint": .boolean(report.resumedFromCheckpoint), "retry_count": .integer(Int64(report.retries)), "stale_retry_count": .integer(Int64(report.staleRetries)), "skipped_immature_count": .integer(Int64(report.skippedImmature)), "skipped_invalid_count": .integer(Int64(report.skippedInvalid)), + "skipped_missing_address_count": .integer(Int64(report.skippedMissingAddress)), "skipped_unresolved_account_count": .integer(Int64(report.skippedUnresolvedAccount)), "store_failure_count": .integer(Int64(report.storeFailures)), + "stopped_on_budget": .boolean(report.stoppedOnBudget), + "stopped_on_open_round": .boolean(report.stoppedOnOpenRound), "store_row_count": .integer(Int64(report.storeRows)), "transport_failure_count": .integer(Int64(report.transportFailures)), "unknown_count": .integer(Int64(report.unknown)), + "unresolved_account_count": .integer(Int64(report.unresolvedAccount)), "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 f1d499682d7..5c48e449f94 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2437,7 +2437,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// same txid — a later batch of this round sweeping or chaining onto /// it — returns the same object instead of re-fetching. The plain /// pending-changes fetch with no active round keeps the old behaviour - /// for unbracketed callers. + /// for unbracketed callers. The store read goes through `modelFetcher` + /// (verbatim `ModelContext.fetch` in production), so a test can fault + /// it; the reconcile heal relies on that to prove a failed read stops + /// the step instead of minting a stub over the real row. private func fetchTransactionRow( txid: Data, prefetching: [PartialKeyPath] @@ -2453,7 +2456,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { descriptor.relationshipKeyPathsForPrefetching = prefetching } 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?.transactionsByTxid[txid] = row return row @@ -2495,12 +2498,32 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// optimisation — the sweep phase re-points and tombstones these rows /// through this helper, and a second store-only fetch of the same key /// would refresh those mutations away (see `roundIndex`). + /// + /// A failed fetch reads as "no saved rows" — the additive hot path's + /// contract: the claim stays in the store and drains on a later upsert. + /// Inside a round the empty saved set is still registered, merged with + /// the round's staged rows, exactly as before the checked core existed. private func pendingInputRows(outpoint: Data) -> [PersistentPendingInput] { + do { + return try pendingInputRowsChecked(outpoint: outpoint) + } catch { + guard roundIndex != nil else { return [] } + registerPendingInputRows([], outpoint: outpoint) + return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } + } + } + + /// Throwing core of `pendingInputRows`: an empty result is a successful + /// miss, a read that fails throws (and registers nothing). Taken through + /// `modelFetcher` so a test can fault it. The reconcile heal uses this + /// form: a missed claim there would write a coin unspent that a pending + /// spend already covers, and report it repaired. + private func pendingInputRowsChecked(outpoint: Data) throws -> [PersistentPendingInput] { guard roundIndex != nil else { let descriptor = FetchDescriptor( predicate: #Predicate { $0.outpoint == outpoint } ) - return ((try? backgroundContext.fetch(descriptor)) ?? []).filter { !$0.isDeleted } + return try modelFetcher.fetch(descriptor, in: backgroundContext).filter { !$0.isDeleted } } if roundIndex!.pendingInputsFetched.contains(outpoint) { return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } @@ -2509,7 +2532,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { predicate: #Predicate { $0.outpoint == outpoint } ) descriptor.includePendingChanges = false - let saved = (try? backgroundContext.fetch(descriptor)) ?? [] + let saved = try modelFetcher.fetch(descriptor, in: backgroundContext) registerPendingInputRows(saved, outpoint: outpoint) return (roundIndex!.pendingInputsByOutpoint[outpoint] ?? []).filter { !$0.isDeleted } } @@ -2543,6 +2566,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Resolve a `PersistentCoreAddress` by its unique `address`. private func coreAddressRow(address: String) -> PersistentCoreAddress? { + try? coreAddressRowChecked(address: address) + } + + /// Throwing core of `coreAddressRow`: `nil` is a successful miss, a + /// read that fails throws. The heal pass skips a coin whose address + /// has no row, so for it a failed read must stop the step instead of + /// reading as that skip. + private func coreAddressRowChecked(address: String) throws -> PersistentCoreAddress? { if let known = roundIndex?.coreAddressesByAddress[address] { return known.isDeleted ? nil : known } @@ -2551,7 +2582,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) descriptor.fetchLimit = 1 if roundIndex != nil { descriptor.includePendingChanges = false } - guard let row = (try? modelFetcher.fetch(descriptor, in: backgroundContext))?.first, + guard let row = try modelFetcher.fetch(descriptor, in: backgroundContext).first, !row.isDeleted else { return nil } roundIndex?.coreAddressesByAddress[address] = row return row @@ -2996,13 +3027,42 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// 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. + /// this layer regardless of which side arrives first. The drain body + /// (`applyPendingInputs`) is shared by `upsertUtxo` and the store + /// reconcile's heal path (`reconcileHealMissingTxos`), so both writers + /// honour the same tombstone precedence and spender-adoption rules; the + /// heal feeds it through the throwing reads. private func drainPendingInputs(into record: PersistentTxo, resolvedWalletId: Data) { - let pendingRows = pendingInputRows(outpoint: record.outpoint) + applyPendingInputs( + pendingInputRows(outpoint: record.outpoint), + into: record, + resolvedWalletId: resolvedWalletId, + resolveSpender: resolvePendingSpender + ) + } + + /// The drain itself, over rows the caller already read. `upsertUtxo` + /// reads them through the forgiving `pendingInputRows` and resolves + /// spenders through `resolvePendingSpender` (a failed read is a miss); + /// the reconcile heal reads both through their throwing forms, so a + /// failed read stops the step instead of writing the coin unspent and + /// deleting the claim that covered it. Nothing is written until every + /// spender this drain adopts has resolved or thrown — a throw leaves + /// the record as the caller built it, and the heal rolls back. + private func applyPendingInputs( + _ pendingRows: [PersistentPendingInput], + into record: PersistentTxo, + resolvedWalletId: Data, + resolveSpender: (PersistentPendingInput) throws -> PersistentTransaction? + ) rethrows { if !pendingRows.isEmpty { + // Resolve every ordinary row's spender before touching the + // record, so a throwing resolver leaves it as the caller built + // it. Oldest first — the order the adoption pass below walks. + let ordinary = pendingRows + .sorted(by: { $0.createdAt < $1.createdAt }) + .filter { !$0.isSweptTombstone } + let spenders = try ordinary.map { try resolveSpender($0) } // A tombstone is not an observation — it is a sweep's settled // verdict that its winner consumed this coin — so it outranks // every ordinary row regardless of age, and a drained tombstone @@ -3037,9 +3097,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // ordinary row moments before the sweep, and that row — not // the tombstone — carries the link and the right vin index. var adoptedAny = false - for pending in pendingRows.sorted(by: { $0.createdAt < $1.createdAt }) - where !pending.isSweptTombstone { - guard let spending = resolvePendingSpender(pending) else { continue } + for (pending, resolved) in zip(ordinary, spenders) { + guard let spending = resolved else { continue } adoptSpendObservation(txo: record, spender: spending, inputIndex: pending.inputIndex) if record.spendingTransaction?.txid == spending.txid { adoptedAny = true @@ -3076,6 +3135,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return fetchTransactionRow(txid: pending.spendingTxid) } + /// Throwing form of `resolvePendingSpender`, for the reconcile heal: a + /// spender that cannot be read must stop the step, not read as "not + /// arrived yet" and let the drain delete the claim off an unspent coin. + private func resolvePendingSpenderChecked( + _ pending: PersistentPendingInput + ) throws -> PersistentTransaction? { + if let spending = pending.spendingTransaction { + roundIndex?.transactionsByTxid[spending.txid] = spending + return spending + } + return try fetchTransactionRow(txid: pending.spendingTxid, prefetching: []) + } + /// The one rule every spend-linkage writer follows — the record pass /// (`resolveInputOutpoint`), the `utxos_spent` emit (`markUtxoSpent`) /// and the pending-row drain (`upsertUtxo`) all route through @@ -11466,12 +11538,18 @@ private struct UtxoCreditVerdictTally { /// 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`). +/// half-applied step (see `PlatformWalletManager.runCoreTxoReconcile`). 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. + /// A Rust changeset round is open; nothing was read or written — saving + /// mid-round would commit the round's staged rows early. The run does + /// not wait for it: a deferred page read or heal step stops the run with + /// that page not reported applied, and the run is finished as deferred, + /// so the wallet's next run reads the page again. The classify apply, + /// which runs under an engine guard, answers a stale page instead. case retryLater + /// The run was stopped (`isCancelled`) by the time the step reached the + /// queue; nothing was read or written. + case stopped /// The step's writes failed to save and were rolled back. case failed case done(T) @@ -11492,6 +11570,9 @@ struct CoreTxoStoreUnspentPage: Sendable { let rows: [CoreTxoStoreUnspentRow] let fetched: Int let hasMore: Bool + /// This wallet's rows set aside because they name no account (or carry + /// no well-formed txid): nothing the engine could be asked about. + let unresolvedAccount: Int /// `committedRoundGeneration` as read together with the rows. let generation: UInt64 } @@ -11505,6 +11586,8 @@ struct CoreTxoHealCounts: Sendable { var alreadyPresent = 0 var skippedImmature = 0 var skippedUnresolvedAccount = 0 + /// Coins whose address has no row under the resolved account. + var skippedMissingAddress = 0 var skippedInvalid = 0 } @@ -11517,54 +11600,68 @@ 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. + /// A round committed between the read and the apply, or one is open at + /// the apply: nothing written, the page is classified again. var staleGeneration = false } extension PlatformWalletPersistenceHandler { - /// Heal pass: insert every engine coin in `rows` that the store lacks, - /// validated and gated — never touch a row that exists. + /// Heal pass: insert every engine coin in `rows` that the store lacks + /// and the engine's verdict calls eligible — never touch a row that + /// exists. /// /// A row is inserted exactly as `upsertUtxo` would insert it (stub /// parent transaction when the record is absent, account relationship, /// wallet denorm, address link, pending-input drain) so both writers - /// honour the same rules. Gates, in order: a malformed row (txid not - /// 32 bytes, empty script or address) is skipped; a coin the engine - /// does not call confirmed, or below `minConfirmations` at `tipHeight`, - /// is skipped (the inventory - /// carries the engine's own flags, but a fresh coin can still reorg or, - /// for coinbase, be immature — it ages into a later run); a coin whose - /// owning account has no store row is skipped and counted rather than - /// filed unowned, because the restore loader routes by account and an - /// unowned row would be dropped at the next launch, recreating the loss. - /// Inserted rows are `isConfirmed == true` — the gate guarantees it. - /// A store read that fails is not a skip: the step fails and the run - /// stops, like the unspent-page read in the classify pass. + /// honour the same rules. In order: a txid that is not 32 bytes, or the + /// engine's `invalid` verdict (no script or no address), is skipped as + /// invalid; the engine's `immature` verdict (not confirmed, or not deep + /// enough at the run's tip — a fresh coin can still reorg or, for + /// coinbase, be immature) is skipped and ages into a later run; an + /// outpoint the store holds is already present; 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; a coin + /// whose address has no store row, or whose address row is not filed + /// under that same account, is skipped and counted as a missing + /// address, because the restore loader rebuilds each account's address + /// pool from its own address rows and the coin would come back with no + /// derivation path, unspendable — a later run heals it once the address + /// is saved under the account. Inserted rows are `isConfirmed == true` + /// — an eligible coin is engine-confirmed. 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. `isCancelled` is checked on the queue before anything is read: + /// wallet deletion moves the run's epoch before its cascade runs here. func reconcileHealMissingTxos( walletId: Data, - rows: [CoreEngineUtxo], - tipHeight: UInt32, - minConfirmations: UInt32 + rows: [CoreTxoHealRow], + isCancelled: () -> Bool ) -> CoreTxoReconcileStep { onQueue { + guard !isCancelled() else { return .stopped } guard !inChangeset else { return .retryLater } var counts = CoreTxoHealCounts() - for row in rows { - guard row.txid.count == 32, !row.scriptPubKey.isEmpty, !row.address.isEmpty else { + // Outpoints written, logged once the step saved. + var healedOutpoints: [Data] = [] + var healedSpentOutpoints: [Data] = [] + for healRow in rows { + let row = healRow.utxo + guard row.txid.count == 32 else { counts.skippedInvalid += 1 continue } - // The engine's own confirmation flag first, then the depth - // this store requires before it materialises a coin it never - // saw arrive. Which accounts may be healed at all is the - // engine's call: `wallet_utxos_page` omits a contact's - // watch-only chain. - guard row.isConfirmed, row.height > 0, tipHeight >= row.height, - tipHeight - row.height + 1 >= minConfirmations - else { + // Which coins may be healed is the engine's call + // (`txo_heal_verdict`); a contact's watch-only coin is never + // offered at all. + switch healRow.verdict { + case .eligible: + break + case .immature: counts.skippedImmature += 1 continue + case .invalid: + counts.skippedInvalid += 1 + continue } let outpoint = row.outpoint let present: PersistentTxo? @@ -11587,8 +11684,41 @@ extension PlatformWalletPersistenceHandler { counts.skippedUnresolvedAccount += 1 continue } + // The restore loader rebuilds each account's address pool + // from its own address rows only, so a coin whose address + // is not saved under this account would come back with no + // derivation path and fail every send that selects it. Wait + // until the address row exists under the account. Compared + // by `persistentModelID`: object identity is not stable + // across fetches within a context. + let addressRow: PersistentCoreAddress? + do { + addressRow = try coreAddressRowChecked(address: row.address) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } + guard let coreAddr = addressRow, + coreAddr.account?.persistentModelID == account.persistentModelID + else { + counts.skippedMissingAddress += 1 + continue + } + // Both reads below are throwing: a failed parent read taken + // as a miss would insert a stub under the parent's unique + // txid and the save could overwrite its bytes and context; + // a failed pending-input read taken as "no claims" would + // write a covered coin unspent and report it repaired. + // Read before anything is staged for this row. + let existingTx: PersistentTransaction? + let pendingRows: [PersistentPendingInput] + do { + existingTx = try fetchTransactionRow(txid: row.txid, prefetching: []) + pendingRows = try pendingInputRowsChecked(outpoint: outpoint) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } let parentTx: PersistentTransaction - if let existing = fetchTransactionRow(txid: row.txid) { + if let existing = existingTx { parentTx = existing } else { // Stub row, exactly as `upsertUtxo` does: empty bytes read @@ -11613,10 +11743,17 @@ extension PlatformWalletPersistenceHandler { record.isInstantLocked = row.isInstantLocked record.isLocked = row.isLocked backgroundContext.insert(record) - if let coreAddr = coreAddressRow(address: row.address) { - record.coreAddress = coreAddr + record.coreAddress = coreAddr + do { + try applyPendingInputs( + pendingRows, + into: record, + resolvedWalletId: walletId, + resolveSpender: resolvePendingSpenderChecked + ) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) } - 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 @@ -11625,56 +11762,57 @@ extension PlatformWalletPersistenceHandler { // 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), - ] - ) + healedSpentOutpoints.append(outpoint) continue } counts.inserted += 1 counts.insertedDuffs = counts.insertedDuffs.addingReportingOverflow(row.amount).0 - SDKLogger.event( - "persistence_txo_reconcile_item", - category: .persistence, - fields: [ - "action": .publicText("healed"), - "outpoint_reference": .reference(outpoint), - "wallet_reference": .reference(walletId), - ] - ) + healedOutpoints.append(outpoint) } guard counts.inserted + counts.healedSpent == 0 || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) else { return .failed } + // Logged once the step saved, so a rolled-back step never claims + // a row it did not write. + healedOutpoints.forEach { logReconcileItem("healed", outpoint: $0, walletId: walletId) } + healedSpentOutpoints.forEach { logReconcileItem("healed_spent", outpoint: $0, walletId: walletId) } 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`. + /// it by. Rows without an account or a well-formed txid are counted in + /// `unresolvedAccount` and not asked about: the engine could not check + /// a claim the row does not make. The read is scoped to rows stamped + /// with this wallet's id plus legacy rows stamped with none, so other + /// wallets' rows never spend this run's page budget; a legacy row that + /// resolves (through its account) to another wallet is read past + /// (`fetched` counts it). The walk is an offset walk over that set, + /// ordered by creation, because the outpoint key is not comparable in a + /// SwiftData predicate; rows flipped by the apply half are this + /// wallet's and leave the predicate, and nothing else here moves a row + /// out of the set, so the caller advances by `fetched - flipped`. func reconcileUnspentTxoPage( walletId: Data, offset: Int, - limit: Int + limit: Int, + isCancelled: () -> Bool ) -> CoreTxoReconcileStep { onQueue { + guard !isCancelled() else { return .stopped } guard !inChangeset else { return .retryLater } + // The empty id is captured, like `walletId`, rather than built + // inside the predicate. It marks a row written before the + // column existed; the post-filter below resolves it through its + // account. + let empty = Data() var descriptor = FetchDescriptor( - predicate: #Predicate { $0.isSpent == false }, + predicate: #Predicate { + $0.isSpent == false && ($0.walletId == walletId || $0.walletId == empty) + }, sortBy: [SortDescriptor(\.createdAt)] ) descriptor.fetchOffset = offset @@ -11694,13 +11832,17 @@ extension PlatformWalletPersistenceHandler { return .failed } var rows: [CoreTxoStoreUnspentRow] = [] + var unresolvedAccount = 0 for txo in fetched { - guard Self.resolvedWalletId(of: txo) == walletId, - let account = txo.account, - let typeTag = UInt8(exactly: account.accountType) - else { continue } + guard Self.resolvedWalletId(of: txo) == walletId else { continue } let txid = txo.txid - guard txid.count == 32 else { continue } + guard let account = txo.account, + let typeTag = UInt8(exactly: account.accountType), + txid.count == 32 + else { + unresolvedAccount += 1 + continue + } let key = CoreAccountKey( typeTag: typeTag, standardTag: account.standardTag, @@ -11725,6 +11867,7 @@ extension PlatformWalletPersistenceHandler { rows: rows, fetched: fetched.count, hasMore: fetched.count == limit, + unresolvedAccount: unresolvedAccount, generation: committedRoundGeneration )) } @@ -11741,21 +11884,35 @@ extension PlatformWalletPersistenceHandler { /// 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. + /// nothing is written and the caller classifies the page again. A round + /// still open counts too: it may already have staged that re-credit, and + /// the caller holds the engine's guard across this step, so it must not + /// wait for the round to close — the guard is released and the page + /// read again instead. + /// + /// `isCancelled` is checked here, on the queue, before anything is + /// written, not only before the step was queued: a stopped run's guard + /// may already be gone — shutdown moves the epoch, waits a bounded time + /// for the run, and destroy then releases every guard the manager + /// still holds — and a verdict written without its guard can retire a + /// coin a fork just restored. A stopped run writes no verdict. func reconcileApplyEngineClasses( walletId: Data, rows: [CoreTxoStoreUnspentRow], classes: [CoreOutpointClass], - expectedGeneration: UInt64 + expectedGeneration: UInt64, + isCancelled: () -> Bool ) -> CoreTxoReconcileStep { onQueue { - guard !inChangeset else { return .retryLater } - guard committedRoundGeneration == expectedGeneration else { + guard !isCancelled() else { return .stopped } + guard !inChangeset, committedRoundGeneration == expectedGeneration else { var stale = CoreTxoFlipCounts() stale.staleGeneration = true return .done(stale) } var counts = CoreTxoFlipCounts() + // Outpoints flipped, logged once the step saved. + var flippedOutpoints: [Data] = [] for (row, verdict) in zip(rows, classes) { switch verdict { case .unspent: @@ -11780,20 +11937,13 @@ extension PlatformWalletPersistenceHandler { removePendingInputs(for: row.outpoint) counts.flipped += 1 counts.flippedDuffs = counts.flippedDuffs.addingReportingOverflow(txo.amount).0 - SDKLogger.event( - "persistence_txo_reconcile_item", - category: .persistence, - fields: [ - "action": .publicText("flipped_spent"), - "outpoint_reference": .reference(row.outpoint), - "wallet_reference": .reference(walletId), - ] - ) + flippedOutpoints.append(row.outpoint) } } guard counts.flipped == 0 || reconcileSave(operation: "txo_reconcile_flip", walletId: walletId) else { return .failed } + flippedOutpoints.forEach { logReconcileItem("flipped_spent", outpoint: $0, walletId: walletId) } return .done(counts) } } @@ -11827,6 +11977,20 @@ extension PlatformWalletPersistenceHandler { } } + /// One event per healed, healed-spent or flipped row: the action and + /// keyed references only — never the outpoint, address or amount. + private func logReconcileItem(_ action: String, outpoint: Data, walletId: Data) { + SDKLogger.event( + "persistence_txo_reconcile_item", + category: .persistence, + fields: [ + "action": .publicText(action), + "outpoint_reference": .keyedReference(outpoint), + "wallet_reference": .reference(walletId), + ] + ) + } + /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletTypes.swift index 1ac5082e454..2c8e6d651d6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletTypes.swift @@ -68,6 +68,26 @@ func identifierFromFFIArray(_ array: IdentifierArray, at index: Int) -> Identifi return Data(bytes: row, count: 32) } +/// Read the `IdentifierArray` `fetch` fills, then free it with +/// `platform_wallet_identifier_array_free`. Shared by every FFI call that +/// returns that shape, so each caller supplies only the call itself. +func readIdentifierArray( + _ fetch: (inout IdentifierArray) -> PlatformWalletFFIResult +) throws -> [Identifier] { + var array = IdentifierArray() + try fetch(&array).check() + defer { platform_wallet_identifier_array_free(&array) } + guard array.items != nil, array.count > 0 else { + return [] + } + var ids: [Identifier] = [] + ids.reserveCapacity(Int(array.count)) + for i in 0.. Identifier { var buf = [UInt8](repeating: 0, count: 32) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift index d74ec5b810d..ffdb75b8ad8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift @@ -105,6 +105,9 @@ final class CoreTxoReconcilePrivacyTests: XCTestCase { } XCTAssertTrue(applied) _ = handler.endChangeset(walletId: walletId, success: true) + // The healed coin's address, filed under its BIP44 account: the heal + // pass inserts a coin only once its address is saved there. + try fileCoreTxoReconcileAddress(in: container, walletId: walletId, address: fixtureAddress) // The reconcile: one heal, one flip, one row the engine cannot classify. let healed = CoreEngineUtxo( @@ -115,7 +118,7 @@ final class CoreTxoReconcilePrivacyTests: XCTestCase { 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, + walletId: walletId, engine: engine, handler: handler, isCancelled: { false } ) XCTAssertEqual(report.inserted, 1) @@ -132,6 +135,15 @@ final class CoreTxoReconcilePrivacyTests: XCTestCase { log.contains("amount_duffs="), "the value of an individual coin never leaves the store through these events" ) + XCTAssertTrue(log.contains("event=persistence_txo_reconcile_summary")) + XCTAssertFalse(log.contains("value_duffs="), "nor does a total of them in the run's summary") + let reconcileLines = log.split(separator: "\n").filter { $0.contains("txo_reconcile") } + for amount in ["19549", "100001", "119550"] { + XCTAssertFalse( + reconcileLines.contains { $0.contains("=\(amount)") }, + "no reconcile field may carry a coin's amount or a sum of them (\(amount))" + ) + } XCTAssertTrue(log.contains("action=\"flipped_spent\"")) let forbidden: [(String, String)] = [ diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift index 72fc7a9ad5e..f686442ad01 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -3,13 +3,77 @@ 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. +/// The native side of a reconcile run's bracket, faked: the start gate's +/// answer, and a record of every begin, every finish (with how the run +/// ended, and whether an engine guard was still held when it was called) +/// and the tip each engine was made at. +final class FakeReconcileNative: @unchecked Sendable { + private let lock = NSLock() + let engine: any CoreTxoReconcileEngine + private let holding: @Sendable () -> Bool + private var _gate: CoreTxoReconcileGate + private var _begins: [Data] = [] + private var _finishes: [(walletId: Data, outcome: CoreTxoReconcileFinish, guardHeld: Bool)] = [] + private var _enginesMade = 0 + /// Runs inside each begin, before it answers. + var onBegin: (@Sendable () -> Void)? + + init( + engine: any CoreTxoReconcileEngine, + holding: @escaping @Sendable () -> Bool, + gate: CoreTxoReconcileGate = .run(start: .heal(resumed: false)) + ) { + self.engine = engine + self.holding = holding + _gate = gate + } + + convenience init( + engine: FakeCoreTxoEngine, + gate: CoreTxoReconcileGate = .run(start: .heal(resumed: false)) + ) { + self.init(engine: engine, holding: { engine.isHoldingVerdicts }, gate: gate) + } + + var begins: [Data] { lock.withLock { _begins } } + var finishes: [(walletId: Data, outcome: CoreTxoReconcileFinish, guardHeld: Bool)] { + lock.withLock { _finishes } + } + var enginesMade: Int { lock.withLock { _enginesMade } } + + func calls() -> PlatformWalletNativeTxoReconcileCalls { + PlatformWalletNativeTxoReconcileCalls( + begin: { [self] _, walletId in + onBegin?() + return lock.withLock { + _begins.append(walletId) + return _gate + } + }, + finish: { [self] _, walletId, outcome in + let held = holding() + lock.withLock { _finishes.append((walletId, outcome, held)) } + }, + makeEngine: { [self] _, _ in + lock.withLock { _enginesMade += 1 } + return engine + } + ) + } +} + +/// The run's bracket and its stops: a granted begin is always finished, +/// after the last engine guard is released, whatever ends the run — a +/// failure, the epoch, a cancelled caller; a skip claims nothing and is +/// never finished; the run stops between pages when cancelled, stops and +/// finishes as deferred when a guard-free store step finds a Rust +/// persistence round open, and the manager refuses it once shutdown has +/// begun. @MainActor final class CoreTxoReconcileShutdownTests: XCTestCase { private let walletId = Data(repeating: 0x51, count: 32) + /// Every engine coin and store row here pays this one address. + private let fixtureAddress = "yShutdownFixtureAddr" private let fixtureScript = Data([0x76, 0xa9, 0x14] + [UInt8](repeating: 0x5b, count: 20) + [0x88, 0xac]) private var bip44: CoreAccountKey { @@ -35,24 +99,90 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { return (handler, container) } + /// Unspent, confirmed store rows of the wallet under its BIP44 account. + private func seedUnspentTxos(in container: ModelContainer, _ bytes: [UInt8]) throws { + let context = ModelContext(container) + let account = try XCTUnwrap(context.fetch(FetchDescriptor()).first) + for byte in bytes { + let tx = PersistentTransaction( + txid: txid(byte), transactionData: Data(repeating: 0x04, count: 10), + context: 3, blockHeight: 2_391_743, netAmount: 19_549 + ) + context.insert(tx) + let row = PersistentTxo( + transaction: tx, vout: 0, amount: 19_549, address: fixtureAddress, + scriptPubKey: fixtureScript, height: 2_391_743 + ) + row.account = account + row.walletId = walletId + row.isConfirmed = true + context.insert(row) + } + try context.save() + } + 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, + address: fixtureAddress, scriptPubKey: fixtureScript, height: 2_391_743, isConfirmed: true, isInstantLocked: false, isCoinbase: false, isLocked: false ) } + /// Files the fixture address under the wallet's BIP44 account, the + /// account of every `engineUtxo`: the heal pass inserts a coin only once + /// its address is saved under the coin's own account. + private func fileAddress(in container: ModelContainer) throws { + try fileCoreTxoReconcileAddress(in: container, walletId: walletId, address: fixtureAddress) + } + private func txoCount(_ container: ModelContainer) throws -> Int { try ModelContext(container).fetchCount(FetchDescriptor()) } + private nonisolated static func okTeardownCalls() -> PlatformWalletNativeTeardownCalls { + let ok: PlatformWalletNativeTeardownCalls.Call = { _ in + PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil) + } + return PlatformWalletNativeTeardownCalls( + spvStop: ok, platformAddressSyncStop: ok, shieldedSyncStop: ok, + dashPaySyncStop: ok, dpnsSyncStop: ok, destroy: ok + ) + } + + /// A manager with a fake handle, `handler`, the given reconcile calls, + /// and the fixture wallet loaded through a create table whose native + /// handle is `NULL_HANDLE` (never a live registry entry). + private func makeManager( + handle: Handle, + handler: PlatformWalletPersistenceHandler, + native: FakeReconcileNative + ) async throws -> PlatformWalletManager { + let manager = PlatformWalletManager.makeForTesting( + handle: handle, calls: Self.okTeardownCalls(), persistenceHandler: handler) + let walletId = self.walletId + manager.nativeCreateCalls = PlatformWalletNativeCreateCalls(createFromMnemonic: { _, _ in + ( + PlatformWalletFFIResult(code: PLATFORM_WALLET_FFI_RESULT_CODE_SUCCESS, message: nil), + NULL_HANDLE, + walletId + ) + }) + manager.nativeTxoReconcileCalls = native.calls() + let wallet = try await manager.createWallet(mnemonic: "m", network: .testnet) + XCTAssertEqual(wallet.walletId, walletId) + return manager + } + + // MARK: The walk's own stops + func testACancelledRunDoesNothingAndSaysSo() throws { let (handler, container) = try makeHandler() + try fileAddress(in: container) let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x11)]) let report = PlatformWalletManager.runCoreTxoReconcile( - walletId: walletId, tipHeight: 2_535_898, engine: engine, handler: handler, + walletId: walletId, engine: engine, handler: handler, isCancelled: { true } ) @@ -64,65 +194,419 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { func testARunCancelledMidWayStopsAtThePageBoundaryAndKeepsWhatLanded() throws { let (handler, container) = try makeHandler() + try fileAddress(in: container) let engine = FakeCoreTxoEngine(inventory: (0..<6).map { engineUtxo(0x20 + UInt8($0)) }) let checks = Counter() let report = PlatformWalletManager.runCoreTxoReconcile( - walletId: walletId, tipHeight: 2_535_898, pageSize: 2, engine: engine, handler: handler, - // The run checks once before each engine read and once before - // each store step: the first two admit page one and its heal, - // the third (before page two) reports the epoch bumped. - isCancelled: { checks.next() >= 3 } + walletId: walletId, pageSize: 2, engine: engine, handler: handler, + // The run checks once before each engine read, twice per store + // step — before queueing it and again on the persistence queue — + // and once before it reports the page applied: the first three + // admit page one and its heal, the fourth (before the report) + // reports the epoch bumped. + isCancelled: { checks.next() >= 4 } ) XCTAssertFalse(report.completed) + XCTAssertFalse(report.stoppedOnBudget) XCTAssertEqual(engine.pageCalls, 1) XCTAssertEqual(report.inserted, 2, "the page that landed stays") + XCTAssertEqual(engine.healApplied, 0, "a stopped run reports nothing more: the next run reads the page again") 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 { + /// A cancellation that lands while a page's verdicts are held stops the + /// run before the apply, and still releases the guard. + func testACancellationUnderAHeldGuardReleasesItAndWritesNothing() throws { + let (handler, container) = try makeHandler() + try seedUnspentTxos(in: container, [0x61]) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x61), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let cancelled = SyncGenerationCounter() + engine.onClassify = { cancelled.bump() } + + let report = PlatformWalletManager.runCoreTxoReconcile( + walletId: walletId, engine: engine, handler: handler, + isCancelled: { cancelled.current() != 0 } + ) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(engine.releases, 2, "the heal page's guard, and the classified page's") + XCTAssertFalse(engine.isHoldingVerdicts) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first) + XCTAssertFalse(row.isSpent) + } + + /// A Rust round open on the persistence queue defers the run's first + /// guard-free store step: the run stops there without waiting, the page + /// not reported applied, and is finished as deferred, so the engine keeps + /// the wallet due. Once the round has closed, the next run reads the + /// same page and completes. + func testAStepDeferredBehindAnOpenRoundStopsTheRunAndFinishesItDeferred() throws { let (handler, container) = try makeHandler() + try fileAddress(in: container) let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x31)]) + let native = FakeReconcileNative(engine: engine) 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") + let deferred = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: { false }) _ = 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) + guard case .reconciled(let report) = deferred else { + return XCTFail("expected a run, got \(String(describing: deferred))") + } + XCTAssertFalse(report.completed) + XCTAssertTrue(report.stoppedOnOpenRound) + XCTAssertFalse(report.stoppedOnBudget) + XCTAssertEqual(report.retries, 1) + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(engine.pageCalls, 1, "the run does not read the page again itself") + XCTAssertEqual(engine.healApplied, 0, "the deferred page is not reported applied") + XCTAssertNil(engine.checkpoint, "nothing moved: the next run starts at the same page") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertEqual(native.finishes.map { $0.outcome }, [.deferred]) + XCTAssertEqual(native.finishes.first?.guardHeld, false) + XCTAssertEqual(try txoCount(container), 0) + + // The next poll's run, with the round closed: the same page, applied. + let next = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: { false }) + + guard case .reconciled(let rerun) = next else { + return XCTFail("expected a run, got \(String(describing: next))") + } + XCTAssertTrue(rerun.completed) + XCTAssertFalse(rerun.stoppedOnOpenRound) + XCTAssertEqual(rerun.retries, 0) + XCTAssertEqual(rerun.inserted, 1) + XCTAssertEqual(engine.healApplied, 1) + XCTAssertEqual(native.finishes.map { $0.outcome }, [.deferred, .ran]) 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) + /// Teardown can release a page's engine guard while that page's apply + /// is still waiting for the persistence queue: shutdown moves the epoch, + /// waits a bounded time for the run, then destroys the manager, which + /// drops every guard it still holds. The apply checks the stop again + /// once it is on the queue, so that page writes no spend verdict — and + /// the run still frees its token and finishes its claim. + func testARunStoppedBeforeItsApplyReachesThePersistenceQueueWritesNoVerdict() throws { + let (handler, container) = try makeHandler() + try seedUnspentTxos(in: container, [0x64]) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x64), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let native = FakeReconcileNative(engine: engine) + let stopsSeenOnQueue = Counter() + // The stop lands after the run's own check that follows the + // classification, and before its apply runs: the run reads as + // stopped only on the persistence queue, once a page was classified. + let isCancelled: @Sendable () -> Bool = { + guard engine.classifyCalls > 0, Self.onPersistenceQueue() else { return false } + _ = stopsSeenOnQueue.next() + return true } - let manager = PlatformWalletManager.makeForTesting( - handle: 42, - calls: PlatformWalletNativeTeardownCalls( - spvStop: ok, platformAddressSyncStop: ok, shieldedSyncStop: ok, - dashPaySyncStop: ok, dpnsSyncStop: ok, destroy: ok - ) - ) + + let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: isCancelled) + + guard case .reconciled(let report) = outcome else { + return XCTFail("expected a run, got \(String(describing: outcome))") + } + XCTAssertGreaterThan(stopsSeenOnQueue.next(), 1, "the apply asked on the persistence queue") + XCTAssertFalse(report.completed) + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(report.storeFailures, 0, "a stopped page is not a failed one") + XCTAssertEqual(report.staleRetries, 0) + XCTAssertEqual(engine.classifyCalls, 1) + XCTAssertTrue(engine.classifyApplied.isEmpty, "a stopped page is never reported applied") + XCTAssertEqual(engine.releases, 2, "the classified page's token is still freed, as is the heal page's") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertEqual(native.finishes.count, 1, "the claim is still finished") + XCTAssertEqual(native.finishes.first?.outcome, .ran) + XCTAssertEqual(native.finishes.first?.guardHeld, false) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first) + XCTAssertFalse(row.isSpent, "no spend verdict is written once the run is stopped") + } + + // MARK: The begin / finish bracket + + func testASkippedBeginClaimsNothingAndIsNeverFinished() throws { + let (handler, _) = try makeHandler() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x12)]) + let native = FakeReconcileNative( + engine: engine, gate: .skip(code: UInt8(TXO_RECONCILE_SKIP_ALREADY_RUNNING))) + + let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: { false }) + + XCTAssertEqual(outcome, .skipped(.alreadyRunning)) + XCTAssertEqual(native.begins, [walletId]) + XCTAssertTrue(native.finishes.isEmpty, "a skip holds no claim to release") + XCTAssertEqual(native.enginesMade, 0) + XCTAssertEqual(engine.pageCalls, 0) + } + + func testAnUnknownGateCodeThrowsWithoutAClaimToRelease() throws { + let (handler, _) = try makeHandler() + let engine = FakeCoreTxoEngine() + let native = FakeReconcileNative(engine: engine, gate: .skip(code: 0xee)) + + XCTAssertThrowsError(try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: { false })) + XCTAssertTrue(native.finishes.isEmpty) + XCTAssertEqual(engine.pageCalls, 0) + } + + /// A granted run makes one engine and is finished once, as a run that + /// went ahead — even when it stopped on a failed classification — and + /// only after its last guard is released. + func testAGrantedRunIsFinishedAfterItsLastGuardIsReleased() throws { + let (handler, container) = try makeHandler() + try seedUnspentTxos(in: container, [0x62, 0x63]) + try fileAddress(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x13)]) + let native = FakeReconcileNative(engine: engine, gate: .run(start: .heal(resumed: false))) + let classifies = Counter() + // The second page's classification fails, after the first page's + // guard was released. + engine.onClassify = { if classifies.next() == 2 { engine.failClassify = true } } + + let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, pageSize: 1, + isCancelled: { false }) + + guard case .reconciled(let report) = outcome else { + return XCTFail("expected a run, got \(String(describing: outcome))") + } + XCTAssertFalse(report.completed) + XCTAssertEqual(report.transportFailures, 1) + XCTAssertEqual(report.inserted, 1) + XCTAssertEqual(native.enginesMade, 1) + XCTAssertEqual(native.finishes.count, 1) + XCTAssertEqual(native.finishes.first?.outcome, .ran) + XCTAssertEqual(native.finishes.first?.guardHeld, false, "the guard goes before the finish resolves the manager") + XCTAssertEqual(engine.events, [ + "heal", "release", "healApplied", + "classify", "release", "classifyApplied", + "classify", + ]) + XCTAssertEqual(engine.classifyApplied.count, 1, "the failed page is not reported applied") + } + + /// The begin's start is where the walk begins: a run granted at the + /// classify pass reads no heal page and starts after the store cursor + /// the begin handed back, and reports that it resumed. + func testTheRunStartsWhereTheBeginSays() throws { + let (handler, container) = try makeHandler() + try seedUnspentTxos(in: container, [0x65, 0x66, 0x67]) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x18)]) + let after = PlatformWalletManager.coreTxoStoreCursor(offset: 1) + let native = FakeReconcileNative( + engine: engine, gate: .run(start: .classify(after: after))) + + let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, pageSize: 4, + isCancelled: { false }) + + guard case .reconciled(let report) = outcome else { + return XCTFail("expected a run, got \(String(describing: outcome))") + } + XCTAssertTrue(report.completed) + XCTAssertTrue(report.resumedFromCheckpoint) + XCTAssertEqual(engine.pageCalls, 0, "no heal page") + XCTAssertEqual(report.storeRows, 2, "the first row is behind the start cursor") + XCTAssertEqual(engine.classifyApplied, [nil]) + XCTAssertEqual(native.finishes.first?.outcome, .ran) + } + + /// A run staled between a granted begin and its walk walks nothing and + /// still releases the claim, as a run that did not go ahead. + func testARunStaledAfterItsBeginIsFinishedAsNotRun() throws { + let (handler, container) = try makeHandler() + try fileAddress(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x14)]) + let native = FakeReconcileNative(engine: engine) + let epoch = SyncGenerationCounter() + let generation = epoch.current() + native.onBegin = { epoch.bump() } + + let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: { epoch.current() != generation }) + + XCTAssertNil(outcome) + XCTAssertEqual(native.finishes.count, 1) + XCTAssertEqual(native.finishes.first?.outcome, .notRun) + XCTAssertEqual(engine.pageCalls, 0) + XCTAssertEqual(try txoCount(container), 0) + } + + /// A run staled before it began claims nothing at all. + func testARunStaledBeforeItsBeginNeverClaims() throws { + let (handler, _) = try makeHandler() + let native = FakeReconcileNative(engine: FakeCoreTxoEngine()) + + let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( + handle: 42, walletId: walletId, calls: native.calls(), handler: handler, + isCancelled: { true }) + + XCTAssertNil(outcome) + XCTAssertTrue(native.begins.isEmpty) + XCTAssertTrue(native.finishes.isEmpty) + } + + /// A finish that fails cannot strand a guard: the release runs first + /// and on its own. + func testAFailedFinishStillFollowsTheGuardRelease() throws { + let engine = FakeCoreTxoEngine() + _ = try engine.classifyHeld([CoreOutpointOwnershipQuery( + account: bip44, txid: txid(0x15), vout: 0, scriptPubKey: fixtureScript)]) + XCTAssertTrue(engine.isHoldingVerdicts) + var heldAtFinish: Bool? + + PlatformWalletManager.endCoreTxoReconcileRun(walletId: walletId, engine: engine) { + heldAtFinish = engine.isHoldingVerdicts + throw FakeCoreTxoEngine.Failure() + } + + XCTAssertEqual(heldAtFinish, false) + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertEqual(engine.releases, 1) + } + + // MARK: Through the manager + + /// Cancelling the caller of a run already in flight stops the walk at + /// its next boundary; the run's claim is still released, and the + /// caller still gets the run's report. + func testACancelledCallerStillFinishesTheClaim() async throws { + let (handler, container) = try makeHandler() + try fileAddress(in: container) + let entered = expectation(description: "first engine page entered") + let gate = DispatchSemaphore(value: 0) + defer { gate.signal() } + let base = FakeCoreTxoEngine(inventory: [engineUtxo(0x16)]) + let engine = PausedPageEngine(base: base, entered: entered, gate: gate) + let native = FakeReconcileNative(engine: engine, holding: { base.isHoldingVerdicts }) + let manager = try await makeManager(handle: 44, handler: handler, native: native) + let walletId = self.walletId + + let run = Task { try await manager.reconcileCoreTxoStore(for: walletId) } + await fulfillment(of: [entered], timeout: 5) + run.cancel() + gate.signal() + let outcome = try await run.value + + guard case .reconciled(let report) = outcome else { + return XCTFail("expected a run, got \(outcome)") + } + XCTAssertFalse(report.completed) + XCTAssertEqual(report.mutations, 0, "the page read before the cancellation was not written") + XCTAssertEqual(try txoCount(container), 0) + XCTAssertEqual(native.begins, [walletId]) + XCTAssertEqual(native.finishes.count, 1, "a granted begin is finished despite the cancellation") + XCTAssertEqual(native.finishes.first?.outcome, .ran) + _ = await manager.shutdown() + PlatformWalletManager.destroyQueue.sync {} + } + + /// The poll's due list launches one run per loaded wallet, never a + /// second one behind a launch that has not returned, and nothing for a + /// wallet this manager has not loaded. + func testThePollLaunchesOneRunPerDueLoadedWallet() async throws { + let (handler, _) = try makeHandler() + let beginGate = DispatchSemaphore(value: 0) + defer { beginGate.signal() } + let begun = expectation(description: "begin entered") + let native = FakeReconcileNative( + engine: FakeCoreTxoEngine(), gate: .skip(code: UInt8(TXO_RECONCILE_SKIP_NOT_STEADY_STATE))) + let entries = Counter() + native.onBegin = { + if entries.next() == 1 { + begun.fulfill() + XCTAssertEqual(beginGate.wait(timeout: .now() + 5), .success) + } + } + let manager = try await makeManager(handle: 45, handler: handler, native: native) + let walletId = self.walletId + let baseline = PlatformWalletPollBaseline( + spvProgress: .empty, spvIsRunning: false, spvPeers: [], + platformAddressSyncIsSyncing: false, shieldedSyncIsSyncing: false, + dashPaySyncIsSyncing: false, spvTipBlockTime: nil, pendingAccountBuilds: [:]) + var snapshot = PlatformWalletPollSnapshot() + snapshot.txoReconcileDueWallets = [walletId, Data(repeating: 0x99, count: 32)] + + manager.applyManagerSnapshot(snapshot, baseline: baseline) + await fulfillment(of: [begun], timeout: 5) + // A second tick while the first launch is still in its begin. + manager.applyManagerSnapshot(snapshot, baseline: baseline) + XCTAssertEqual(manager.coreTxoReconcileLaunched, [walletId]) + beginGate.signal() + try await waitUntil { manager.coreTxoReconcileLaunched.isEmpty } + XCTAssertEqual(native.begins, [walletId], "one launch, for the loaded wallet only") + XCTAssertTrue(native.finishes.isEmpty, "the gate refused: nothing to finish") + + // Once it returned, the next report launches again. + manager.applyManagerSnapshot(snapshot, baseline: baseline) + try await waitUntil { native.begins.count == 2 } + XCTAssertEqual(native.begins, [walletId, walletId]) + _ = await manager.shutdown() + PlatformWalletManager.destroyQueue.sync {} + } + + /// A second request for a wallet whose first has not returned is + /// refused `alreadyRunning` by the manager: runs share the serial + /// reconcile queue, so it would otherwise wait there and repeat the + /// whole run, and the engine's own check would never see the overlap. + /// Once the first returns, the wallet may be asked for again. + func testASecondRequestWhileTheFirstIsInFlightIsRefused() async throws { + let (handler, _) = try makeHandler() + let beginGate = DispatchSemaphore(value: 0) + defer { beginGate.signal() } + let begun = expectation(description: "first begin entered") + let native = FakeReconcileNative( + engine: FakeCoreTxoEngine(), gate: .skip(code: UInt8(TXO_RECONCILE_SKIP_NOT_STEADY_STATE))) + let entries = Counter() + native.onBegin = { + if entries.next() == 1 { + begun.fulfill() + XCTAssertEqual(beginGate.wait(timeout: .now() + 5), .success) + } + } + let manager = try await makeManager(handle: 47, handler: handler, native: native) + let walletId = self.walletId + + let first = Task { try await manager.reconcileCoreTxoStore(for: walletId) } + await fulfillment(of: [begun], timeout: 5) + let second = try await manager.reconcileCoreTxoStore(for: walletId) + XCTAssertEqual(second, .skipped(.alreadyRunning)) + // The first request is still parked inside its begin, before the + // fake records it, so no begin is recorded yet. + XCTAssertTrue(native.begins.isEmpty, "the refused request never reached native begin") + + beginGate.signal() + let firstOutcome = try await first.value + XCTAssertEqual(firstOutcome, .skipped(.notSteadyState)) + XCTAssertEqual(native.begins.count, 1) + + // The first returned: its in-flight mark is gone. + let third = try await manager.reconcileCoreTxoStore(for: walletId) + XCTAssertEqual(third, .skipped(.notSteadyState)) + XCTAssertEqual(native.begins, [walletId, walletId]) + _ = await manager.shutdown() + PlatformWalletManager.destroyQueue.sync {} + } + + func testTheManagerRefusesAReconcileOnceShutdownHasBegun() async throws { + let manager = PlatformWalletManager.makeForTesting(handle: 42, calls: Self.okTeardownCalls()) let epochBefore = manager.coreTxoReconcileEpoch.current() _ = await manager.shutdown() PlatformWalletManager.destroyQueue.sync {} @@ -134,6 +618,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { func testShouldCancelParkedReconcileBeforeBlockingShieldedStop() async throws { let (handler, container) = try makeHandler() + try fileAddress(in: container) let pageEntered = expectation(description: "Reconcile entered first page") let stopEntered = expectation(description: "Shielded stop entered") let finished = expectation(description: "Reconcile stopped while shielded stop remained blocked") @@ -161,7 +646,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { let box = ReportBox() manager.coreTxoReconcileQueue.async { box.set(PlatformWalletManager.runCoreTxoReconcile( - walletId: walletId, tipHeight: 2_535_898, pageSize: 1, + walletId: walletId, pageSize: 1, engine: engine, handler: handler, isCancelled: { epoch.current() != generation })) finished.fulfill() @@ -181,10 +666,60 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { _ = await shutdown.value } - private final class PausedPageEngine: CoreTxoEngineInventory, @unchecked Sendable { + // MARK: The C API adapter + + /// Against a handle the registry never issues, every call fails cleanly + /// and nothing is held; the stale-page answer is Rust's limit. + func testTheFFIAdapterHoldsNothingOnAFailedCall() throws { + let engine = FFICoreTxoReconcileEngine(handle: NULL_HANDLE, walletId: walletId) + XCTAssertThrowsError(try engine.healPage(limit: 4)) + XCTAssertThrowsError(try engine.classifyHeld([CoreOutpointOwnershipQuery( + account: bip44, txid: txid(0x17), vout: 0, scriptPubKey: fixtureScript)])) + engine.releaseVerdicts() + XCTAssertThrowsError(try engine.classifyHeld([]), "an empty page is a page too: it is asked about") + XCTAssertThrowsError(try engine.healPageApplied()) + XCTAssertThrowsError(try engine.classifyPageApplied(nextAfter: nil)) + XCTAssertThrowsError(try engine.classifyPageApplied( + nextAfter: PlatformWalletManager.coreTxoStoreCursor(offset: 3))) + XCTAssertTrue(engine.retryStalePage(staleInARow: 1)) + XCTAssertEqual( + engine.retryStalePage(staleInARow: 6), + platform_wallet_txo_reconcile_retry_stale_page(6) + ) + XCTAssertThrowsError(try CoreTxoReconcileFFI.begin(NULL_HANDLE, walletId: walletId)) + XCTAssertThrowsError(try CoreTxoReconcileFFI.finish(NULL_HANDLE, walletId: walletId, outcome: .notRun)) + XCTAssertThrowsError(try CoreTxoReconcileFFI.dueWallets(NULL_HANDLE)) + } + + // MARK: Helpers + + /// Whether the caller runs on the persistence handler's serial queue + /// (also inside a `sync` onto it from another thread). + private nonisolated static func onPersistenceQueue() -> Bool { + String(cString: __dispatch_queue_get_label(nil)) == "org.dash.platform-wallet.persistence" + } + + private func waitUntil( + timeout: Duration = .seconds(5), + _ condition: () -> Bool + ) async throws { + let deadline = ContinuousClock.now + timeout + while !condition() { + if ContinuousClock.now > deadline { + XCTFail("condition not met within \(timeout)") + return + } + try await Task.sleep(for: .milliseconds(10)) + } + } + + /// Pauses the run's first heal page until `gate` is signalled. + private final class PausedPageEngine: CoreTxoReconcileEngine, @unchecked Sendable { let base: FakeCoreTxoEngine let entered: XCTestExpectation let gate: DispatchSemaphore + private let lock = NSLock() + private var paused = false init(base: FakeCoreTxoEngine, entered: XCTestExpectation, gate: DispatchSemaphore) { self.base = base @@ -192,16 +727,36 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { self.gate = gate } - func utxoPage(after: CoreEngineUtxo?, limit: Int) throws -> (rows: [CoreEngineUtxo], hasMore: Bool) { - if after == nil { + func healPage(limit: Int) throws -> CoreTxoReconcileRead<(rows: [CoreTxoHealRow], hasMore: Bool)> { + let first = lock.withLock { () -> Bool in + defer { paused = true } + return !paused + } + if first { entered.fulfill() XCTAssertEqual(gate.wait(timeout: .now() + 10), .success) } - return try base.utxoPage(after: after, limit: limit) + return try base.healPage(limit: limit) + } + + func classifyHeld(_ queries: [CoreOutpointOwnershipQuery]) throws -> CoreTxoReconcileRead<[CoreOutpointClass]> { + try base.classifyHeld(queries) + } + + func healPageApplied() throws -> Bool { + try base.healPageApplied() + } + + func classifyPageApplied(nextAfter: Data?) throws -> Bool { + try base.classifyPageApplied(nextAfter: nextAfter) + } + + func releaseVerdicts() { + base.releaseVerdicts() } - func classify(_ queries: [CoreOutpointOwnershipQuery]) throws -> [CoreOutpointClass] { - try base.classify(queries) + func retryStalePage(staleInARow: Int) -> Bool { + base.retryStalePage(staleInARow: staleInARow) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 9eee2e260b6..5aba6a20242 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -3,25 +3,85 @@ 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 { +/// The engine side of a reconcile run, faked: a fixed inventory served in +/// pages, each row with the heal verdict the test gives it (eligible unless +/// told otherwise — the verdict is Rust's, so the fake never derives one +/// from a row's fields), a spend verdict per outpoint, and the engine guard +/// each heal page and classification hands out, tracked so a test can see +/// when it is held and released. The stale-page answer is Rust's own +/// (`platform_wallet_txo_reconcile_retry_stale_page`) unless a test +/// replaces it. +/// +/// It keeps the run's place and page budget as `platform-wallet`'s +/// reconciler does: each held read takes one page of the budget (and reads +/// nothing once it is spent), a heal page starts after the last one +/// reported applied, and the applied reports move the wallet's checkpoint, +/// from which `beginRun()` tells the next run where to start. +final class FakeCoreTxoEngine: CoreTxoReconcileEngine, @unchecked Sendable { + /// Where the wallet's next run picks up; no checkpoint is a new cycle. + enum Checkpoint: Equatable { + /// The heal pass, at this inventory index. + case heal(Int) + /// The classify pass, after this store cursor. + case classify(Data) + } + + private enum Pending { + /// A heal page; the index after it, or `nil` for the last page. + case heal(Int?) + case classify + } + private let lock = NSLock() private var _inventory: [CoreEngineUtxo] + private var _healVerdicts: [Data: CoreTxoHealVerdict] private var _verdicts: [Data: CoreOutpointClass] private var _failPages = false private var _failClassify = false - private(set) var pageCalls = 0 - private(set) var classifyCalls = 0 - private(set) var classified: [CoreOutpointOwnershipQuery] = [] - /// Runs before each `classify`, outside the engine's lock — a test's + private var _held = false + private var _pageCalls = 0 + private var _classifyCalls = 0 + private var _classifiedWhileHeld = 0 + private var _pagedWhileHeld = 0 + private var _releases = 0 + private var _classified: [CoreOutpointOwnershipQuery] = [] + private var _staleAsks: [Int] = [] + private var _events: [String] = [] + private let budget: Int + private var _pagesLeft: Int + private var _budgetSpentReads = 0 + private var _healAfter = 0 + private var _pending: Pending? + private var _checkpoint: Checkpoint? + private var _healApplied = 0 + private var _classifyApplied: [Data?] = [] + private var _appliedWhileHeld = 0 + /// Runs before each classification, outside the fake'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)? + /// Runs after each release of a held guard, outside the fake's lock. + var onRelease: (@Sendable () -> Void)? + /// Runs at each applied report, before it is recorded, outside the + /// fake's lock. + var onApplied: (@Sendable () -> Void)? + /// The engine's stale-page answer; Rust's own unless replaced. + var retryStale: @Sendable (Int) -> Bool = { staleInARow in + platform_wallet_txo_reconcile_retry_stale_page(UInt32(staleInARow)) + } - init(inventory: [CoreEngineUtxo] = [], verdicts: [Data: CoreOutpointClass] = [:]) { + /// `budget` is the pages one run may read; Rust's is 16. + init( + inventory: [CoreEngineUtxo] = [], + healVerdicts: [Data: CoreTxoHealVerdict] = [:], + verdicts: [Data: CoreOutpointClass] = [:], + budget: Int = 16 + ) { _inventory = inventory + _healVerdicts = healVerdicts _verdicts = verdicts + self.budget = budget + _pagesLeft = budget } var failPages: Bool { @@ -34,19 +94,82 @@ final class FakeCoreTxoEngine: CoreTxoEngineInventory, @unchecked Sendable { set { lock.withLock { _failClassify = newValue } } } + var pageCalls: Int { lock.withLock { _pageCalls } } + var classifyCalls: Int { lock.withLock { _classifyCalls } } + /// Classifications asked for while a previous guard was still held. + var classifiedWhileHeld: Int { lock.withLock { _classifiedWhileHeld } } + /// Heal pages asked for while a previous guard was still held. + var pagedWhileHeld: Int { lock.withLock { _pagedWhileHeld } } + /// Releases that let a held guard go. + var releases: Int { lock.withLock { _releases } } + var isHoldingVerdicts: Bool { lock.withLock { _held } } + var classified: [CoreOutpointOwnershipQuery] { lock.withLock { _classified } } + /// Each `staleInARow` the run asked the engine about. + var staleAsks: [Int] { lock.withLock { _staleAsks } } + /// `heal`, `classify`, `release`, `healApplied` and `classifyApplied`, + /// in order. + var events: [String] { lock.withLock { _events } } + /// Reads answered "budget spent". + var budgetSpentReads: Int { lock.withLock { _budgetSpentReads } } + /// Heal pages reported applied. + var healApplied: Int { lock.withLock { _healApplied } } + /// Each classify page reported applied: the store cursor past it, or + /// `nil` for the store's last page. + var classifyApplied: [Data?] { lock.withLock { _classifyApplied } } + /// Applied reports made while a guard was still held. + var appliedWhileHeld: Int { lock.withLock { _appliedWhileHeld } } + var checkpoint: Checkpoint? { lock.withLock { _checkpoint } } + struct Failure: Error {} - func utxoPage(after: CoreEngineUtxo?, limit: Int) throws -> (rows: [CoreEngineUtxo], hasMore: Bool) { + /// What the begin of the wallet's next run grants: a fresh budget, and + /// the start the checkpoint names. + func beginRun() -> CoreTxoReconcileStart { + lock.withLock { + _pagesLeft = budget + _pending = nil + switch _checkpoint { + case nil: + _healAfter = 0 + return .heal(resumed: false) + case .heal(let index): + _healAfter = index + return .heal(resumed: true) + case .classify(let after): + _healAfter = 0 + return .classify(after: after) + } + } + } + + /// One page of the budget, or `false` once it is spent. Under the lock. + private func takePage() -> Bool { + guard _pagesLeft > 0 else { + _budgetSpentReads += 1 + return false + } + _pagesLeft -= 1 + return true + } + + func healPage(limit: Int) throws -> CoreTxoReconcileRead<(rows: [CoreTxoHealRow], hasMore: Bool)> { try lock.withLock { - pageCalls += 1 + _pageCalls += 1 + _events.append("heal") + if _held { _pagedWhileHeld += 1 } if _failPages { throw Failure() } - var start = 0 - if let after, let index = _inventory.firstIndex(where: { $0.outpoint == after.outpoint }) { - start = index + 1 - } + guard takePage() else { return .budgetSpent } + _held = true + let start = min(_healAfter, _inventory.count) let end = min(start + limit, _inventory.count) let rows = start < end ? Array(_inventory[start.. [CoreOutpointClass] { + func classifyHeld(_ queries: [CoreOutpointOwnershipQuery]) throws -> CoreTxoReconcileRead<[CoreOutpointClass]> { onClassify?() return try lock.withLock { - classifyCalls += 1 + _classifyCalls += 1 + _events.append("classify") + if _held { _classifiedWhileHeld += 1 } if _failClassify { throw Failure() } - classified.append(contentsOf: queries) - return queries.map { _verdicts[$0.outpoint] ?? .unknown } + guard takePage() else { return .budgetSpent } + _held = true + _pending = .classify + _classified.append(contentsOf: queries) + return .page(queries.map { _verdicts[$0.outpoint] ?? .unknown }) + } + } + + func releaseVerdicts() { + let released = lock.withLock { () -> Bool in + guard _held else { return false } + _held = false + _releases += 1 + _events.append("release") + return true + } + if released { onRelease?() } + } + + func retryStalePage(staleInARow: Int) -> Bool { + lock.withLock { _staleAsks.append(staleInARow) } + return retryStale(staleInARow) + } + + func healPageApplied() throws -> Bool { + onApplied?() + return lock.withLock { + _events.append("healApplied") + if _held { _appliedWhileHeld += 1 } + if case .heal(let next) = _pending { + _healApplied += 1 + if let next { + _healAfter = next + _checkpoint = .heal(next) + } else { + _checkpoint = .classify(Data()) + } + } + _pending = nil + return _pagesLeft > 0 } } + + func classifyPageApplied(nextAfter: Data?) throws -> Bool { + onApplied?() + return lock.withLock { + _events.append("classifyApplied") + if _held { _appliedWhileHeld += 1 } + if case .classify = _pending { + _classifyApplied.append(nextAfter) + _checkpoint = nextAfter.map { .classify($0) } + } + _pending = nil + return _pagesLeft > 0 + } + } +} + +/// Files the address row of `address` under `walletId`'s account of +/// `accountType`, or under no account when `accountType` is `nil` — +/// inserting the row if the store lacks it, re-filing it otherwise. The +/// heal pass inserts a coin only once its address is saved under the +/// coin's own account, so every reconcile fixture that expects a heal +/// files the coin's address first. The wallet and account rows must +/// already be saved. +func fileCoreTxoReconcileAddress( + in container: ModelContainer, + walletId: Data, + address: String, + underAccountType accountType: UInt32? = 0 +) throws { + let context = ModelContext(container) + var account: PersistentAccount? + if let accountType { + let accounts = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.wallet.walletId == walletId && $0.accountType == accountType } + )) + account = try XCTUnwrap(accounts.first) + } + let existing = try context.fetch(FetchDescriptor( + predicate: #Predicate { $0.address == address } + )).first + let row = existing ?? PersistentCoreAddress( + address: address, poolTypeTag: 0, addressIndex: 0, derivationPath: "m/44'/1'/0'/0/0" + ) + if existing == nil { context.insert(row) } + row.account = account + try context.save() } -/// Coverage for the post-scan store reconcile +/// Coverage for the store side of the 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. +/// calling thread, store steps on the persistence queue. The policy itself +/// (cadence, start gate, heal verdicts, spend verdicts, stale-retry limit) +/// is `platform-wallet`'s and tested there; these tests check that the +/// store follows each answer and keeps its own fences. /// /// 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. +/// inserted only on the engine's eligible verdict and an owning account the +/// store resolves; absence from either side never changes a row; nothing +/// is deleted, nothing is un-marked; each page's engine guard is released +/// before the next page is read; 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 coinJoinAddress = "yReconcileCoinJoinAddr" private let fixtureScript = Data([0x76, 0xa9, 0x14] + [UInt8](repeating: 0x5a, count: 20) + [0x88, 0xac]) private var bip44: CoreAccountKey { @@ -152,6 +366,22 @@ final class CoreTxoReconcileTests: XCTestCase { return wallet } + /// `fileCoreTxoReconcileAddress`, for the fixture wallet and address + /// unless given. + private func fileAddress( + in container: ModelContainer, + walletId: Data? = nil, + address: String? = nil, + underAccountType accountType: UInt32? = 0 + ) throws { + try fileCoreTxoReconcileAddress( + in: container, + walletId: walletId ?? self.walletId, + address: address ?? fixtureAddress, + underAccountType: accountType + ) + } + /// An unspent, confirmed TXO row of `walletId` under its BIP44 account. private func seedUnspentTxo( in container: ModelContainer, @@ -160,7 +390,8 @@ final class CoreTxoReconcileTests: XCTestCase { vout: UInt32 = 0, amount: UInt64 = 19_549, isSpent: Bool = false, - pendingSpender: Data? = nil + pendingSpender: Data? = nil, + legacyEmptyWalletId: Bool = false ) throws { let walletId = walletId ?? self.walletId let context = ModelContext(container) @@ -185,7 +416,9 @@ final class CoreTxoReconcileTests: XCTestCase { height: 2_391_743 ) txo.account = account - txo.walletId = walletId + // A legacy row predates the column: its wallet is only its + // account's. + txo.walletId = legacyEmptyWalletId ? Data() : walletId txo.isConfirmed = true txo.isSpent = isSpent context.insert(txo) @@ -241,17 +474,21 @@ final class CoreTxoReconcileTests: XCTestCase { try ModelContext(container).fetchCount(FetchDescriptor()) } + /// One run, started where the fake engine's begin says (the wallet's + /// checkpoint), with a fresh budget — or at `start` when given. private func run( _ handler: PlatformWalletPersistenceHandler, engine: FakeCoreTxoEngine, walletId: Data? = nil, pageSize: Int = 2, + start: CoreTxoReconcileStart? = nil, isCancelled: @Sendable @escaping () -> Bool = { false } ) -> CoreTxoReconcileReport { - PlatformWalletManager.runCoreTxoReconcile( + let granted = engine.beginRun() + return PlatformWalletManager.runCoreTxoReconcile( walletId: walletId ?? self.walletId, - tipHeight: tipHeight, pageSize: pageSize, + start: start ?? granted, engine: engine, handler: handler, isCancelled: isCancelled @@ -358,6 +595,8 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(engine.classified.count, 1) XCTAssertEqual(engine.classified.first?.account, bip44) XCTAssertEqual(engine.classified.first?.scriptPubKey, fixtureScript) + XCTAssertEqual(engine.releases, 2, "the heal page's and the classified page's guards are released") + XCTAssertFalse(engine.isHoldingVerdicts) } /// The engine is asked off the persistence queue and the verdict is @@ -392,10 +631,123 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(report.flipped, 0) XCTAssertEqual(report.unspent, 1) XCTAssertEqual(report.storeRows, 1) + XCTAssertEqual(engine.staleAsks, [1], "whether to retry the stale page is the engine's call") + XCTAssertEqual(engine.events, [ + "heal", "release", "healApplied", + "classify", "release", "classify", "release", "classifyApplied", + ]) + XCTAssertEqual(engine.classifyApplied, [nil], "only the attempt that applied is reported, as the last page") + XCTAssertEqual( + engine.classifiedWhileHeld, 0, + "the stale page's guard goes before the page is asked about again" + ) let coin = try XCTUnwrap(txo(container, txid: txid(0x72))) XCTAssertFalse(coin.isSpent, "a coin the engine re-credited in the gap stays unspent") } + /// A round still open when the verdicts come back may already have + /// staged a re-credit, and the run holds the engine's guard across the + /// apply, so it must not wait there for the round to close: the page is + /// stale, the guard goes, and the page is read again — here after the + /// round closed, so the read is not deferred. + func testARoundOpenAtTheApplyIsAStalePageNotAWaitUnderTheGuard() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x86)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x86), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let walletId = self.walletId + let classifies = Counter() + let releases = Counter() + engine.onClassify = { + guard classifies.next() == 1 else { return } + handler.beginChangeset(walletId: walletId) + } + engine.onRelease = { + // The first release after the first classification; the heal + // page's release comes before it. + guard engine.classifyCalls == 1, releases.next() == 1 else { return } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.staleRetries, 1) + XCTAssertEqual(report.retries, 0, "no store step was deferred: the apply answered a stale page") + XCTAssertEqual(report.flipped, 1, "the page read after the round closed is applied") + XCTAssertEqual(engine.events, [ + "heal", "release", "healApplied", + "classify", "release", "classify", "release", "classifyApplied", + ]) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0x86))).isSpent) + } + + /// The stale-retry limit is the engine's: when it says stop, the run + /// stops incomplete with nothing written, and the guard is released. + func testTheRunStopsWhenTheEngineDeclinesAnotherStaleRetry() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x87)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x87), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let walletId = self.walletId + engine.onClassify = { + handler.beginChangeset(walletId: walletId) + _ = handler.endChangeset(walletId: walletId, success: true) + } + engine.retryStale = { _ in false } + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.staleRetries, 1) + XCTAssertEqual(engine.staleAsks, [1]) + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(engine.classifyCalls, 1) + XCTAssertTrue(engine.classifyApplied.isEmpty, "a stale page is never reported applied") + XCTAssertFalse(report.stoppedOnBudget, "the engine's stale limit is not the page budget") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x87))).isSpent) + } + + /// The run asks the engine as many times as the engine allows and no + /// more: Rust's own limit, through the C API. + func testTheRunFollowsTheEnginesStaleRetryLimit() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x88)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x88), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let walletId = self.walletId + engine.onClassify = { + handler.beginChangeset(walletId: walletId) + _ = handler.endChangeset(walletId: walletId, success: true) + } + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + let asks = engine.staleAsks + XCTAssertEqual(asks, Array(1...asks.count), "counted in a row, one ask per stale page") + XCTAssertTrue(platform_wallet_txo_reconcile_retry_stale_page(UInt32(asks.count - 1))) + XCTAssertFalse( + platform_wallet_txo_reconcile_retry_stale_page(UInt32(asks.count)), + "the run stopped at the first stale page the engine declined" + ) + XCTAssertEqual(engine.classifyCalls, asks.count) + XCTAssertEqual(engine.classifiedWhileHeld, 0) + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x88))).isSpent) + } + + private final class Recorder: @unchecked Sendable { + private let lock = NSLock() + private var _values: [Int] = [] + func append(_ value: Int) { lock.withLock { _values.append(value) } } + var values: [Int] { lock.withLock { _values } } + } + private final class Counter: @unchecked Sendable { private let lock = NSLock() private var value = 0 @@ -430,8 +782,10 @@ final class CoreTxoReconcileTests: XCTestCase { func testAnEngineCoinMissingFromTheStoreIsInsertedWhenValid() throws { let (handler, container) = try makeHandler() try seedWallet(in: container, withCoinJoinAccount: true) + try fileAddress(in: container) + try fileAddress(in: container, address: coinJoinAddress, underAccountType: 1) let valid = engineUtxo(txid: txid(0x74)) - let coinJoinValid = engineUtxo(account: coinJoin, txid: txid(0x75), amount: 100_001) + let coinJoinValid = engineUtxo(account: coinJoin, txid: txid(0x75), amount: 100_001, address: coinJoinAddress) let engine = FakeCoreTxoEngine(inventory: [valid, coinJoinValid]) let report = run(handler, engine: engine) @@ -449,40 +803,69 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(coin.height, 2_391_743) XCTAssertEqual(coin.walletId, walletId) XCTAssertEqual(coin.account?.accountType, 0) + XCTAssertEqual(coin.coreAddress?.address, fixtureAddress, "linked to its address row") 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(mixed.coreAddress?.address, coinJoinAddress) XCTAssertEqual(try restoredUtxoCount(handler), 2) } - func testTheHealPassRefusesImmatureUnconfirmedUnresolvedAndMalformedCoins() throws { + /// Which coins may be healed is the engine's verdict, not the store's: + /// a deep, confirmed coin the engine calls immature or invalid is left + /// out, and a shallow one it calls eligible goes in. The store only adds + /// what needs the store — the account the coin is filed under. + func testTheHealPassFollowsTheEnginesVerdicts() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) // BIP44 only: no CoinJoin account row - let immature = engineUtxo(txid: txid(0x76), height: tipHeight - 50) - let atGate = engineUtxo(txid: txid(0x77), height: tipHeight - 99) // exactly 100 confirmations - // Deep enough, but the engine itself does not call it confirmed. - let engineUnconfirmed = engineUtxo(txid: txid(0x78), isConfirmed: false) + try fileAddress(in: container) + let eligible = engineUtxo(txid: txid(0x76), height: 2_535_890, isConfirmed: false) + let immature = engineUtxo(txid: txid(0x77)) + let invalid = engineUtxo(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 malformed = engineUtxo(txid: Data(repeating: 0x7a, count: 31)) let engine = FakeCoreTxoEngine( - inventory: [immature, atGate, engineUnconfirmed, unresolved, noScript, noAddress, unconfirmed] + inventory: [eligible, immature, invalid, unresolved, malformed], + healVerdicts: [immature.outpoint: .immature, invalid.outpoint: .invalid] ) let report = run(handler, engine: engine) XCTAssertTrue(report.completed) - XCTAssertEqual(report.engineRows, 7) + XCTAssertEqual(report.engineRows, 5) XCTAssertEqual(report.inserted, 1) - XCTAssertEqual(report.skippedImmature, 3) + XCTAssertEqual(report.skippedImmature, 1) + XCTAssertEqual(report.skippedInvalid, 2, "the engine's invalid verdict, and a txid that does not decode") 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] { + XCTAssertEqual(report.skippedMissingAddress, 0) + let healed = try XCTUnwrap(txo(container, txid: txid(0x76))) + XCTAssertTrue(healed.isConfirmed, "an eligible coin is engine-confirmed") + for byte: UInt8 in [0x77, 0x78, 0x79] { XCTAssertNil(try txo(container, txid: txid(byte)), "coin \(byte) must not be healed") } + XCTAssertEqual(try txoCount(container), 1) + } + + func testTheEnginesCodesMapOntoTheSwiftVerdicts() { + XCTAssertEqual(CoreTxoHealVerdict(code: UInt8(TXO_HEAL_VERDICT_ELIGIBLE)), .eligible) + XCTAssertEqual(CoreTxoHealVerdict(code: UInt8(TXO_HEAL_VERDICT_IMMATURE)), .immature) + XCTAssertEqual(CoreTxoHealVerdict(code: UInt8(TXO_HEAL_VERDICT_INVALID)), .invalid) + XCTAssertEqual(CoreTxoHealVerdict(code: 0xff), .invalid, "a code this build does not know vouches for nothing") + + let gate: [(UInt8, CoreTxoReconcileSkipReason)] = [ + (UInt8(TXO_RECONCILE_SKIP_WALLET_UNKNOWN), .walletUnknown), + (UInt8(TXO_RECONCILE_SKIP_ALREADY_RUNNING), .alreadyRunning), + (UInt8(TXO_RECONCILE_SKIP_NOT_STEADY_STATE), .notSteadyState), + (UInt8(TXO_RECONCILE_SKIP_TIP_UNAVAILABLE), .tipUnavailable), + (UInt8(TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED), .syncFaultDetected), + (UInt8(TXO_RECONCILE_SKIP_WALLET_BEHIND_TIP), .walletBehindTip), + ] + for (code, reason) in gate { + XCTAssertEqual(CoreTxoReconcileSkipReason(gateCode: code), reason) + } + XCTAssertNil(CoreTxoReconcileSkipReason(gateCode: UInt8(TXO_RECONCILE_GATE_RUN))) + XCTAssertNil(CoreTxoReconcileSkipReason(gateCode: 0xff)) } /// A coin the engine holds and the store lacks, whose outpoint a @@ -493,6 +876,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testAHealTheDrainWritesSpentIsReportedAsAMutation() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) + try fileAddress(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. @@ -547,10 +931,209 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(report.storeFailures, 1) XCTAssertEqual(report.skippedUnresolvedAccount, 0, "a failed read is not a missing account") XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(engine.healApplied, 0, "a failed page is never reported applied") + XCTAssertEqual(engine.checkpoint, nil) XCTAssertNil(try txo(container, txid: txid(0x83))) XCTAssertTrue(injector.observedReads.contains("PersistentAccount")) } + /// A coin whose address has no store row is not healed: the restore + /// loader rebuilds each account's address pool from its own address + /// rows, so the coin would come back with no derivation path and fail + /// every send that selects it. It is counted, and a later run heals it + /// once the address is saved under the coin's account. + func testReconcileSkipsHealWhenAddressRowMissing() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x86))]) + + let first = run(handler, engine: engine) + + XCTAssertTrue(first.completed) + XCTAssertEqual(first.engineRows, 1) + XCTAssertEqual(first.skippedMissingAddress, 1) + XCTAssertEqual(first.skippedUnresolvedAccount, 0) + XCTAssertEqual(first.inserted, 0) + XCTAssertEqual(first.mutations, 0) + XCTAssertNil(try txo(container, txid: txid(0x86))) + XCTAssertEqual(try txoCount(container), 0) + + try fileAddress(in: container) + let second = run(handler, engine: engine) + + XCTAssertTrue(second.completed) + XCTAssertEqual(second.skippedMissingAddress, 0) + XCTAssertEqual(second.inserted, 1) + let coin = try XCTUnwrap(txo(container, txid: txid(0x86))) + XCTAssertEqual(coin.account?.accountType, 0) + XCTAssertEqual(coin.coreAddress?.address, fixtureAddress) + XCTAssertEqual(coin.coreAddress?.account?.accountType, 0, "the address row the restore pool is built from") + } + + /// The address row must be filed under the coin's own account: a row + /// under no account, or under another account, is not in the pool the + /// restore loader rebuilds for the coin's account. + func testReconcileSkipsHealWhenAddressRowUnderAnotherAccount() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container, withCoinJoinAccount: true) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x87))]) + + try fileAddress(in: container, underAccountType: nil) + let unfiled = run(handler, engine: engine) + XCTAssertTrue(unfiled.completed) + XCTAssertEqual(unfiled.skippedMissingAddress, 1, "an address row under no account") + XCTAssertEqual(unfiled.inserted, 0) + XCTAssertNil(try txo(container, txid: txid(0x87))) + + try fileAddress(in: container, underAccountType: 1) + let elsewhere = run(handler, engine: engine) + XCTAssertTrue(elsewhere.completed) + XCTAssertEqual(elsewhere.skippedMissingAddress, 1, "an address row under the CoinJoin account") + XCTAssertEqual(elsewhere.inserted, 0) + XCTAssertNil(try txo(container, txid: txid(0x87))) + + try fileAddress(in: container, underAccountType: 0) + let healed = run(handler, engine: engine) + XCTAssertTrue(healed.completed) + XCTAssertEqual(healed.skippedMissingAddress, 0) + XCTAssertEqual(healed.inserted, 1) + let coin = try XCTUnwrap(txo(container, txid: txid(0x87))) + XCTAssertEqual(coin.account?.accountType, 0) + XCTAssertEqual(coin.coreAddress?.address, fixtureAddress) + XCTAssertEqual(try txoCount(container), 1) + } + + /// The address lookup cannot tell "no row" from "could not read", so a + /// failed read must not be counted as a missing address: the step fails + /// and the run stops before any insert. + func testTheRunStopsWhenTheAddressLookupCannotRead() throws { + let injector = FetchFaultInjector(faulting: PersistentCoreAddress.self) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + try fileAddress(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x88))]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.skippedMissingAddress, 0, "a failed read is not a missing address") + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(engine.healApplied, 0, "a failed page is never reported applied") + XCTAssertNil(try txo(container, txid: txid(0x88))) + XCTAssertTrue(injector.observedReads.contains("PersistentCoreAddress")) + } + + /// The heal looks up the coin's parent transaction before filing it. A + /// read that fails there must not read as "no record": the heal would + /// insert an empty stub under the parent's unique txid, and the save + /// could overwrite the real row's bytes and confirmation context. The + /// step fails and the run stops before any insert; the parent row keeps + /// its bytes. + func testTheRunStopsWhenTheParentTransactionLookupCannotRead() throws { + let injector = FetchFaultInjector(faulting: PersistentTransaction.self) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + try fileAddress(in: container) + let parentTxid = txid(0xc1) + let parentBytes = Data(repeating: 0x06, count: 10) + let context = ModelContext(container) + context.insert(PersistentTransaction( + txid: parentTxid, + transactionData: parentBytes, + context: 3, + blockHeight: 2_391_743, + netAmount: 19_549 + )) + try context.save() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: parentTxid)]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(report.healedSpent, 0) + XCTAssertEqual(engine.healApplied, 0, "a failed page is never reported applied") + XCTAssertNil(try txo(container, txid: parentTxid)) + let parents = try ModelContext(container).fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == parentTxid } + )) + XCTAssertEqual(parents.count, 1) + XCTAssertEqual(parents.first?.transactionData, parentBytes, "the parent keeps its bytes") + XCTAssertTrue(injector.observedReads.contains("PersistentTransaction")) + } + + /// The heal drains any pending spend claim on the coin it files. A read + /// of those claims that fails must not read as "no claims": the coin + /// would be written unspent although a pending spend covers it, and be + /// reported repaired. The step fails, nothing is inserted and the claim + /// stays for a later run. + func testTheRunStopsWhenThePendingInputLookupCannotRead() throws { + let injector = FetchFaultInjector(faulting: PersistentPendingInput.self) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + try fileAddress(in: container) + let context = ModelContext(container) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: txid(0xc2), vout: 0), + inputIndex: 0, + spendingTxid: txid(0xc3), + spendingTransaction: nil, + walletId: walletId + )) + try context.save() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0xc2))]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(report.healedSpent, 0) + XCTAssertEqual(engine.healApplied, 0, "a failed page is never reported applied") + XCTAssertNil(try txo(container, txid: txid(0xc2))) + XCTAssertEqual(try pendingCount(container), 1, "the claim is not consumed") + XCTAssertTrue(injector.observedReads.contains("PersistentPendingInput")) + } + + /// A pending claim whose spender is not linked is resolved by txid. A + /// read that fails there must not read as "spender not arrived yet": the + /// drain would delete the claim and leave the coin unspent. The parent + /// read is served (a miss); the spender read faults. The step fails, + /// nothing is inserted and the claim stays. + func testTheRunStopsWhenThePendingSpenderLookupCannotRead() throws { + let injector = FetchFaultInjector(faulting: PersistentTransaction.self, afterServing: 1) + let (handler, container) = try makeHandler(modelFetcher: injector) + try seedWallet(in: container) + try fileAddress(in: container) + let context = ModelContext(container) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: txid(0xc4), vout: 0), + inputIndex: 0, + spendingTxid: txid(0xc5), + spendingTransaction: nil, + walletId: walletId + )) + try context.save() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0xc4))]) + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertEqual(report.storeFailures, 1) + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(report.healedSpent, 0) + XCTAssertEqual(engine.healApplied, 0, "a failed page is never reported applied") + XCTAssertNil(try txo(container, txid: txid(0xc4))) + XCTAssertEqual(try pendingCount(container), 1, "the claim is not consumed") + XCTAssertEqual(injector.readCount(of: PersistentTransaction.self), 2, "parent served, spender faulted") + let stubTxid = txid(0xc4) + XCTAssertEqual(try ModelContext(container).fetchCount(FetchDescriptor( + predicate: #Predicate { $0.txid == stubTxid } + )), 0, "the parent stub was rolled back") + } + /// 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 @@ -590,48 +1173,13 @@ final class CoreTxoReconcileTests: XCTestCase { 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") + XCTAssertFalse(engine.isHoldingVerdicts, "a failed apply still releases the guard") + XCTAssertTrue(engine.classifyApplied.isEmpty, "a failed page is never reported applied") let coin = try XCTUnwrap(txo(container, txid: txid(0x85))) XCTAssertFalse(coin.isSpent) } - // MARK: 4. Nothing runs before the scan is complete - - func testTheSteadyStateGateRefusesAnUnfinishedScan() { - func progress(_ state: PlatformSpvSyncState, filters: (UInt32, UInt32)?) -> PlatformSpvSyncProgress { - PlatformSpvSyncProgress( - overallState: state, - overallPercentage: 0, - headers: PlatformSpvSubProgress(state: state, currentHeight: 2_535_898, targetHeight: 2_535_898, percentage: 1), - filterHeaders: nil, - filters: filters.map { - PlatformSpvSubProgress(state: state, currentHeight: $0.0, targetHeight: $0.1, percentage: 0) - }, - masternodes: nil - ) - } - XCTAssertFalse(PlatformWalletManager.isSteadySyncState(progress(.syncing, filters: (199_999, 2_535_898)))) - XCTAssertFalse(PlatformWalletManager.isSteadySyncState(progress(.waitingForConnections, filters: nil))) - XCTAssertFalse(PlatformWalletManager.isSteadySyncState(progress(.error, filters: (2_535_898, 2_535_898)))) - XCTAssertFalse( - PlatformWalletManager.isSteadySyncState(progress(.waitForEvents, filters: (2_535_800, 2_535_898))), - "waiting for events with the filter phase behind its target is a scan still running" - ) - XCTAssertFalse( - PlatformWalletManager.isSteadySyncState(progress(.waitForEvents, filters: nil)), - "no filter phase at all proves nothing" - ) - XCTAssertTrue(PlatformWalletManager.isSteadySyncState(progress(.synced, filters: (2_535_898, 2_535_898)))) - XCTAssertTrue( - PlatformWalletManager.isSteadySyncState(progress(.waitForEvents, filters: (2_535_898, 2_535_898))), - "dash-spv's fully synced steady state" - ) - XCTAssertEqual(PlatformWalletManager.scanTipHeight(progress(.synced, filters: (2_535_898, 2_535_898))), 2_535_898) - XCTAssertEqual( - PlatformWalletManager.scanTipHeight(progress(.synced, filters: nil)), - 2_535_898, - "falls back to the header tip" - ) - } + // MARK: 4. The manager's own refusals func testTheManagerRefusesToReconcileAnUnknownOrUnconfiguredWallet() async throws { let unconfigured = PlatformWalletManager() @@ -662,6 +1210,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testASecondRunChangesNothing() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) + try fileAddress(in: container) try seedUnspentTxo(in: container, txid: txid(0x81)) let flipped = PersistentTxo.makeOutpoint(txid: txid(0x81), vout: 0) let healed = engineUtxo(txid: txid(0x82)) @@ -778,6 +1327,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testTheRunStopsAtTheFirstFailedEngineReadAndKeepsWhatLanded() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) + try fileAddress(in: container) try seedUnspentTxo(in: container, txid: txid(0xd1)) let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0xd2))]) engine.failClassify = true @@ -786,6 +1336,7 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertFalse(report.completed) XCTAssertEqual(report.transportFailures, 1) + XCTAssertFalse(engine.isHoldingVerdicts, "a failed classification holds nothing") 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) @@ -794,6 +1345,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testTheHealPassWalksEveryPageOfTheInventory() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) + try fileAddress(in: container) let inventory = (0..<5).map { engineUtxo(txid: txid(0xe0 + UInt8($0))) } let engine = FakeCoreTxoEngine(inventory: inventory) @@ -805,6 +1357,73 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(try txoCount(container), 5) } + /// Each heal page is read under an engine guard that is held until the + /// page's rows are written, so no fork can drop a coin the page offers + /// before it lands; the guard goes before the next page is read. + func testEachHealPageIsWrittenUnderItsGuardAndReleasedBeforeTheNext() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try fileAddress(in: container) + let inventory = (0..<5).map { engineUtxo(txid: txid(0x90 + UInt8($0))) } + let engine = FakeCoreTxoEngine(inventory: inventory) + let rowsAtRelease = Recorder() + engine.onRelease = { + guard engine.classifyCalls == 0 else { return } + rowsAtRelease.append((try? ModelContext(container).fetchCount(FetchDescriptor())) ?? -1) + } + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.inserted, 5) + XCTAssertEqual(rowsAtRelease.values, [2, 4, 5], "each page was written before its guard went") + XCTAssertEqual(Array(engine.events.prefix(9)), [ + "heal", "release", "healApplied", + "heal", "release", "healApplied", + "heal", "release", "healApplied", + ]) + XCTAssertEqual(engine.pagedWhileHeld, 0) + XCTAssertFalse(engine.isHoldingVerdicts) + } + + /// A heal step deferred for an open round must not wait for it under the + /// guard (the round's writer may need the engine's lock), and the run + /// does not wait for it at all: the guard goes, the run stops with the + /// page not reported applied, and the wallet's next run reads it again. + func testADeferredHealStepReleasesItsGuardAndStopsTheRun() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try fileAddress(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x9a))]) + let walletId = self.walletId + handler.beginChangeset(walletId: walletId) + let report = run(handler, engine: engine) + _ = handler.endChangeset(walletId: walletId, success: true) + + XCTAssertFalse(report.completed) + XCTAssertTrue(report.stoppedOnOpenRound) + XCTAssertFalse(report.stoppedOnBudget) + XCTAssertEqual(report.retries, 1) + XCTAssertEqual(report.engineRows, 0, "a deferred page is not walked") + XCTAssertEqual(report.inserted, 0) + XCTAssertEqual(engine.pageCalls, 1, "the run does not read the page again itself") + XCTAssertEqual(engine.healApplied, 0) + XCTAssertEqual(engine.events, ["heal", "release"], "the guard goes; nothing is reported applied") + XCTAssertNil(engine.checkpoint, "nothing moved: the next run starts at the same page") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertNil(try txo(container, txid: txid(0x9a))) + + // The round closed: the wallet's next run reads the same page and + // applies it. + let next = run(handler, engine: engine) + + XCTAssertTrue(next.completed) + XCTAssertFalse(next.stoppedOnOpenRound) + XCTAssertEqual(next.inserted, 1) + XCTAssertEqual(engine.healApplied, 1) + XCTAssertNotNil(try txo(container, txid: txid(0x9a))) + } + func testTheClassifyPassWalksEveryUnspentRowAcrossFlips() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) @@ -821,5 +1440,390 @@ final class CoreTxoReconcileTests: XCTestCase { 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) + XCTAssertEqual(engine.classifyCalls, 3) + XCTAssertEqual(engine.releases, 4, "one guard per page, the heal page's included, each released") + XCTAssertEqual(engine.classifiedWhileHeld, 0) + } + + /// An unspent row of the wallet with no account carries no claim the + /// engine could check: it is counted, never asked about, never touched. + func testAnUnspentRowWithNoAccountIsCountedNotAsked() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x89)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x89), vout: 0) + do { + let context = ModelContext(container) + let row = try XCTUnwrap(context.fetch( + FetchDescriptor(predicate: #Predicate { $0.outpoint == outpoint }) + ).first) + row.account = nil + try context.save() + } + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.unresolvedAccount, 1) + XCTAssertEqual(report.storeRows, 0) + XCTAssertEqual(engine.classifyCalls, 1, "the page is still read as one page, with nothing to ask") + XCTAssertTrue(engine.classified.isEmpty) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x89))).isSpent) + } + + // MARK: Page budget and resume + + private func cursor(_ offset: Int) -> Data { + PlatformWalletManager.coreTxoStoreCursor(offset: offset) + } + + /// A run that has read its budget is told so by its next read: it stops + /// there, holding nothing. Here every classification comes back stale, + /// so no classify page is reported applied and the budget runs out on a + /// read; the next run classifies the page again. + func testARunStopsWhenAReadSaysTheBudgetIsSpent() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x41)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x41), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited], budget: 3) + let walletId = self.walletId + engine.onClassify = { + handler.beginChangeset(walletId: walletId) + _ = handler.endChangeset(walletId: walletId, success: true) + } + engine.retryStale = { _ in true } + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertTrue(report.stoppedOnBudget) + XCTAssertFalse(report.resumedFromCheckpoint) + XCTAssertEqual(engine.budgetSpentReads, 1) + XCTAssertEqual(engine.classifyCalls, 3, "two stale pages, then a read the budget refused") + XCTAssertEqual(report.staleRetries, 2) + XCTAssertEqual(report.storeRows, 0, "a page the run never applied is not counted") + XCTAssertEqual(report.flipped, 0) + XCTAssertEqual(report.transportFailures, 0, "a spent budget is not a failure") + XCTAssertTrue(engine.classifyApplied.isEmpty, "no stale page is reported applied") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertEqual(engine.checkpoint, .classify(Data()), "the next run classifies from the first row") + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x41))).isSpent) + } + + /// The classify pass's page read is a guard-free store step: a round + /// open when it runs stops the run before the page is classified, with + /// nothing reported applied past the heal pass, and the wallet's next + /// run starts at the classify pass's first page. + func testADeferredStorePageReadStopsTheRunBeforeItIsClassified() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedUnspentTxo(in: container, txid: txid(0x42)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x42), vout: 0) + let engine = FakeCoreTxoEngine(verdicts: [outpoint: .knownUncredited]) + let walletId = self.walletId + // The heal page is empty, so it takes no store step and is reported + // applied; the first store step is the classify pass's page read. + handler.beginChangeset(walletId: walletId) + let report = run(handler, engine: engine) + _ = handler.endChangeset(walletId: walletId, success: true) + + XCTAssertFalse(report.completed) + XCTAssertTrue(report.stoppedOnOpenRound) + XCTAssertFalse(report.stoppedOnBudget) + XCTAssertEqual(report.retries, 1) + XCTAssertEqual(report.storeRows, 0) + XCTAssertEqual(engine.classifyCalls, 0, "the deferred page is never classified") + XCTAssertTrue(engine.classifyApplied.isEmpty, "the deferred page is not reported applied") + XCTAssertEqual(engine.events, ["heal", "release", "healApplied"]) + XCTAssertEqual(engine.checkpoint, .classify(Data()), "the next run classifies from the first row") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x42))).isSpent) + + // The round closed: the next run resumes at the classify pass. + let next = run(handler, engine: engine) + + XCTAssertTrue(next.completed) + XCTAssertTrue(next.resumedFromCheckpoint) + XCTAssertFalse(next.stoppedOnOpenRound) + XCTAssertEqual(next.flipped, 1) + XCTAssertEqual(engine.classifyApplied, [nil]) + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0x42))).isSpent) + } + + /// The applied report says whether the run may read another page; when + /// it says no before the pass's end, the run stops there, on the budget. + func testARunStopsWhenAnAppliedReportLeavesNoBudgetMidPass() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try fileAddress(in: container) + let inventory = (0..<5).map { engineUtxo(txid: txid(0x50 + UInt8($0))) } + let engine = FakeCoreTxoEngine(inventory: inventory, budget: 2) + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertFalse(report.completed) + XCTAssertTrue(report.stoppedOnBudget) + XCTAssertEqual(engine.budgetSpentReads, 0, "the applied report said stop; no read was refused") + XCTAssertEqual(engine.pageCalls, 2) + XCTAssertEqual(engine.healApplied, 2) + XCTAssertEqual(engine.classifyCalls, 0) + XCTAssertEqual(report.engineRows, 4) + XCTAssertEqual(report.inserted, 4) + XCTAssertEqual(engine.checkpoint, .heal(4)) + XCTAssertEqual(try txoCount(container), 4, "what landed stays") + } + + /// A heal pass that reaches the inventory's end with no budget left stops + /// before the classify pass; the next run starts there. + func testAHealPassThatEndsWithNoBudgetLeftStopsBeforeTheClassifyPass() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try fileAddress(in: container) + try seedUnspentTxo(in: container, txid: txid(0x58)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x58), vout: 0) + let engine = FakeCoreTxoEngine( + inventory: [engineUtxo(txid: txid(0x59))], + verdicts: [outpoint: .knownUncredited], + budget: 1 + ) + + let first = run(handler, engine: engine, pageSize: 4) + + XCTAssertFalse(first.completed) + XCTAssertTrue(first.stoppedOnBudget) + XCTAssertEqual(first.inserted, 1) + XCTAssertEqual(engine.classifyCalls, 0) + XCTAssertEqual(engine.checkpoint, .classify(Data())) + + let second = run(handler, engine: engine, pageSize: 4) + + XCTAssertTrue(second.completed, "the store's last page ends the cycle, budget or not") + XCTAssertFalse(second.stoppedOnBudget) + XCTAssertTrue(second.resumedFromCheckpoint) + XCTAssertEqual(engine.pageCalls, 1, "the resumed run reads no heal page") + XCTAssertEqual(second.engineRows, 0) + XCTAssertEqual(second.storeRows, 2) + XCTAssertEqual(second.flipped, 1) + XCTAssertEqual(engine.classifyApplied, [nil], "reported as the store's last page") + XCTAssertNil(engine.checkpoint, "the cycle ended") + } + + /// A wallet larger than one run's budget is covered over several runs, + /// each starting where the last applied page left the wallet, and the + /// cycle ends on the store's last page. + func testACycleLargerThanTheBudgetIsCoveredOverSeveralRuns() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + for i in 0..<5 { + try seedUnspentTxo(in: container, txid: txid(0x60 + UInt8(i))) + } + let inventory = (0..<5).map { engineUtxo(txid: txid(0x60 + UInt8($0))) } + let engine = FakeCoreTxoEngine(inventory: inventory, budget: 2) + + let first = run(handler, engine: engine, pageSize: 2) + XCTAssertTrue(first.stoppedOnBudget) + XCTAssertFalse(first.completed) + XCTAssertFalse(first.resumedFromCheckpoint) + XCTAssertEqual(first.engineRows, 4) + XCTAssertEqual(first.alreadyPresent, 4) + XCTAssertEqual(engine.checkpoint, .heal(4)) + + let second = run(handler, engine: engine, pageSize: 2) + XCTAssertTrue(second.stoppedOnBudget) + XCTAssertTrue(second.resumedFromCheckpoint) + XCTAssertEqual(second.engineRows, 1, "the heal pass picked up at the fifth coin") + XCTAssertEqual(second.storeRows, 2) + XCTAssertEqual(engine.checkpoint, .classify(cursor(2))) + + let third = run(handler, engine: engine, pageSize: 2) + XCTAssertTrue(third.completed) + XCTAssertFalse(third.stoppedOnBudget) + XCTAssertTrue(third.resumedFromCheckpoint) + XCTAssertEqual(third.engineRows, 0) + XCTAssertEqual(third.storeRows, 3, "the classify pass picked up at the third row") + XCTAssertEqual(engine.pageCalls, 3, "no heal page was read twice") + XCTAssertEqual(engine.classifyApplied, [cursor(2), cursor(4), nil]) + XCTAssertNil(engine.checkpoint) + + let fourth = run(handler, engine: engine, pageSize: 2) + XCTAssertFalse(fourth.resumedFromCheckpoint, "a new cycle") + XCTAssertEqual(fourth.mutations, 0) + } + + /// The classify pass reads every store page as a page of the run, an + /// empty one too: here the first page holds only legacy rows (no + /// wallet id stamped) that resolve to another wallet — the read cannot + /// exclude them, only the post-filter can — and the second, read + /// because the first was full, holds none. + func testAnEmptyStorePageIsStillClassifiedAndReported() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedWallet(in: container, walletId: otherWalletId) + try seedUnspentTxo(in: container, walletId: otherWalletId, txid: txid(0x70), legacyEmptyWalletId: true) + try seedUnspentTxo(in: container, walletId: otherWalletId, txid: txid(0x71), legacyEmptyWalletId: true) + let engine = FakeCoreTxoEngine() + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.storeRows, 0) + XCTAssertEqual(engine.classifyCalls, 2) + XCTAssertTrue(engine.classified.isEmpty, "nothing was asked about") + XCTAssertEqual(engine.classifyApplied, [cursor(2), nil]) + XCTAssertEqual(Array(engine.events.suffix(6)), [ + "classify", "release", "classifyApplied", + "classify", "release", "classifyApplied", + ]) + } + + /// Another wallet's unspent rows ahead of this wallet's in the store's + /// order cost this run nothing: the read is scoped to the wallet (and + /// legacy rows with no id), so a budget that could not page past three + /// full pages of the other wallet's rows still reaches and classifies + /// this wallet's rows in one run, and its legacy row too. + func testOtherWalletsRowsAheadDoNotSpendTheRunsBudget() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try seedWallet(in: container, walletId: otherWalletId) + for i in 0..<6 { + try seedUnspentTxo(in: container, walletId: otherWalletId, txid: txid(0x60 + UInt8(i))) + } + try seedUnspentTxo(in: container, txid: txid(0x68)) + try seedUnspentTxo(in: container, txid: txid(0x69), legacyEmptyWalletId: true) + let mine = PersistentTxo.makeOutpoint(txid: txid(0x68), vout: 0) + let mineLegacy = PersistentTxo.makeOutpoint(txid: txid(0x69), vout: 0) + // One heal page (the empty inventory) plus two classify pages — the + // full page of this wallet's rows and the empty one after it. + // Walking the other wallet's three pages first would need six. + let engine = FakeCoreTxoEngine(verdicts: [mine: .knownUncredited], budget: 3) + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertTrue(report.completed) + XCTAssertFalse(report.stoppedOnBudget) + XCTAssertEqual(report.storeRows, 2) + XCTAssertEqual(report.flipped, 1) + XCTAssertEqual(Set(engine.classified.map(\.outpoint)), [mine, mineLegacy]) + XCTAssertEqual(engine.classifyApplied, [cursor(1), nil], "the flipped row left the set") + XCTAssertTrue(try XCTUnwrap(txo(container, txid: txid(0x68))).isSpent) + for i in 0..<6 { + XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x60 + UInt8(i)))).isSpent) + } + } + + /// A run that starts at the classify pass reads no heal page and walks + /// the store from the offset its start cursor names; a cursor that does + /// not decode starts at the first row. + func testTheClassifyPassResumesAtTheStartCursorsOffset() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + for i in 0..<4 { + try seedUnspentTxo(in: container, txid: txid(0x7a + UInt8(i))) + } + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x7a))]) + + let report = run(handler, engine: engine, pageSize: 2, start: .classify(after: cursor(2))) + + XCTAssertTrue(report.completed) + XCTAssertTrue(report.resumedFromCheckpoint) + XCTAssertEqual(engine.pageCalls, 0) + XCTAssertEqual(report.storeRows, 2, "rows before the offset are not read again") + XCTAssertEqual(engine.classified.count, 2) + XCTAssertEqual(engine.classifyApplied, [cursor(4), nil]) + + let malformed = FakeCoreTxoEngine() + let fromStart = run(handler, engine: malformed, pageSize: 2, start: .classify(after: Data([1, 2, 3]))) + XCTAssertTrue(fromStart.completed) + XCTAssertEqual(fromStart.storeRows, 4) + } + + func testTheStoreCursorIsTheOffsetAsEightLittleEndianBytes() { + XCTAssertEqual(cursor(0x0102), Data([0x02, 0x01, 0, 0, 0, 0, 0, 0])) + for offset in [0, 1, 512, 1 << 40] { + XCTAssertEqual(PlatformWalletManager.coreTxoStoreOffset(cursor: cursor(offset)), offset) + } + XCTAssertLessThanOrEqual(cursor(Int.max).count, CoreTxoReconcileFFI.storeCursorMax) + XCTAssertEqual(PlatformWalletManager.coreTxoStoreOffset(cursor: Data()), 0, "empty: the first row") + XCTAssertEqual(PlatformWalletManager.coreTxoStoreOffset(cursor: Data(count: 7)), 0) + XCTAssertEqual(PlatformWalletManager.coreTxoStoreOffset(cursor: Data(repeating: 1, count: 9)), 0) + XCTAssertEqual( + PlatformWalletManager.coreTxoStoreOffset(cursor: Data(repeating: 0xff, count: 8)), 0, + "an offset past Int.max is malformed" + ) + } + + /// Each page is reported applied only once its store step has committed + /// and its guard is released — the report resolves the manager handle, + /// which must never be waited for under a guard. + func testEachPageIsReportedAppliedAfterItsStepAndItsRelease() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + try fileAddress(in: container) + try seedUnspentTxo(in: container, txid: txid(0x80)) + let outpoint = PersistentTxo.makeOutpoint(txid: txid(0x80), vout: 0) + let inventory = (1...3).map { engineUtxo(txid: txid(0x80 + UInt8($0))) } + let engine = FakeCoreTxoEngine(inventory: inventory, verdicts: [outpoint: .knownUncredited]) + let seen = StoreSnapshots() + engine.onApplied = { + let context = ModelContext(container) + let rows = (try? context.fetchCount(FetchDescriptor())) ?? -1 + let spent = (try? context.fetchCount(FetchDescriptor( + predicate: #Predicate { $0.isSpent == true }))) ?? -1 + seen.append("\(rows)/\(spent)") + } + + let report = run(handler, engine: engine, pageSize: 2) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.inserted, 3) + XCTAssertEqual(report.flipped, 1) + XCTAssertEqual( + seen.values, ["3/0", "4/0", "4/1", "4/1", "4/1"], + "each heal page's rows and the flip were committed before the page was reported" + ) + XCTAssertEqual(engine.appliedWhileHeld, 0) + let events = engine.events + for (index, event) in events.enumerated() where event.hasSuffix("Applied") { + XCTAssertEqual(events[index - 1], "release", "the guard goes first, at event \(index)") + } + } + + /// A run that stopped on its budget or on an open persistence round + /// having written nothing is an expected state, logged like a clean run; + /// a write, or any other stop, is a warning. + func testABudgetStopWithNothingWrittenIsNotAWarning() { + var clean = CoreTxoReconcileReport() + XCTAssertEqual(PlatformWalletManager.summarySeverity(clean), .info) + clean.resumedFromCheckpoint = true + XCTAssertEqual(PlatformWalletManager.summarySeverity(clean), .info) + + var budget = CoreTxoReconcileReport() + budget.completed = false + budget.stoppedOnBudget = true + XCTAssertEqual(PlatformWalletManager.summarySeverity(budget), .info) + budget.inserted = 1 + XCTAssertEqual(PlatformWalletManager.summarySeverity(budget), .warning) + + var openRound = CoreTxoReconcileReport() + openRound.completed = false + openRound.stoppedOnOpenRound = true + openRound.retries = 1 + XCTAssertEqual(PlatformWalletManager.summarySeverity(openRound), .info) + openRound.flipped = 1 + XCTAssertEqual(PlatformWalletManager.summarySeverity(openRound), .warning) + + var failed = CoreTxoReconcileReport() + failed.completed = false + failed.transportFailures = 1 + XCTAssertEqual(PlatformWalletManager.summarySeverity(failed), .warning) + } + + private final class StoreSnapshots: @unchecked Sendable { + private let lock = NSLock() + private var _values: [String] = [] + func append(_ value: String) { lock.withLock { _values.append(value) } } + var values: [String] { lock.withLock { _values } } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletProgressPollTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletProgressPollTests.swift index 32ecbceb4f5..eacbb815341 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletProgressPollTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PlatformWalletProgressPollTests.swift @@ -72,6 +72,10 @@ final class PlatformWalletProgressPollTests: XCTestCase { firstPendingCountGate?.wait() } return 3 + }, + txoReconcileDueWallets: { [self] handle in + record("txo_reconcile_due_wallets", handle) + return [] } ) } @@ -236,7 +240,8 @@ final class PlatformWalletProgressPollTests: XCTestCase { pendingAccountBuildCount: { handle in if handle == 20 { throw PlatformWalletError.invalidHandle("gone") } return 3 - } + }, + txoReconcileDueWallets: { _ in [walletA] } ) let snapshot = PlatformWalletManager.performManagerPoll(5, calls: calls) @@ -251,6 +256,7 @@ final class PlatformWalletProgressPollTests: XCTestCase { XCTAssertEqual(snapshot.shieldedSyncIsSyncing, true) XCTAssertEqual(snapshot.platformAddressSyncIsSyncing, false) XCTAssertEqual(snapshot.spvPeers, []) + XCTAssertEqual(snapshot.txoReconcileDueWallets, [walletA], "the reconcile schedule is read every tick") // `Date??`: compare the outer level explicitly — `XCTAssertNil` would // coerce the nested optional through `Any?` and see a value. XCTAssertTrue( @@ -384,7 +390,8 @@ final class PlatformWalletProgressPollTests: XCTestCase { isPlatformAddressSyncing: pollCalls.isPlatformAddressSyncing, isShieldedSyncing: pollCalls.isShieldedSyncing, isDashPaySyncing: pollCalls.isDashPaySyncing, - pendingAccountBuildCount: pollCalls.pendingAccountBuildCount) + pendingAccountBuildCount: pollCalls.pendingAccountBuildCount, + txoReconcileDueWallets: pollCalls.txoReconcileDueWallets) let shieldedGeneration = manager.shieldedSyncGeneration.current() let balanceGeneration = manager.shieldedLocalBalanceGeneration.current() manager.startProgressPolling() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerTests.swift index a80bbbcf138..e24ace19180 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/SDKLoggerTests.swift @@ -130,6 +130,22 @@ final class SDKLoggerTests: XCTestCase { XCTAssertTrue(line.contains("error_message=\"invalid mnemonic: \"")) } + func testAKeyedReferenceLinksLinesButCannotBeRecomputedFromTheValue() { + let outpoint = Data(repeating: 0x7a, count: 36) + let keyed = SDKLogFormatter.keyedReference(outpoint) + + XCTAssertEqual(keyed, SDKLogFormatter.keyedReference(outpoint), "stable within one process") + XCTAssertEqual(keyed.count, 12) + XCTAssertNotEqual(keyed, SDKLogFormatter.reference(outpoint), "not the public digest") + let line = SDKLogFormatter.format( + event: "keyed_reference_test", + category: .persistence, + severity: .info, + fields: ["outpoint_reference": .keyedReference(outpoint)] + ) + XCTAssertTrue(line.contains("outpoint_reference=\(SDKLogFormatter.reference(keyed))")) + } + func testReferenceIsDeterministicAndRawValueNeverReachesLine() throws { let fixture = "wallet-identity-fixture-123456789" let first = SDKLogFormatter.reference(fixture)