From e43aab1fe5c886b36707aeeb34a03fbde982c205 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 10:17:11 -0800 Subject: [PATCH 01/19] feat(platform-wallet): own the TXO-store reconcile policy in the wallet Moves the post-sync store/engine reconcile decisions that #4638 put in the Swift host into rs-platform-wallet (`manager/txo_reconcile.rs`), so every host runs the same policy and keeps only its own store reads and writes. `TxoReconciler` decides: - when a wallet is due: on reaching steady state, then every 30 minutes, one run per wallet at a time; a wallet the host skips stays due, and a removed wallet's cadence is forgotten; - the gates: a latched sync fault, and the wallet watermark more than 6 blocks behind the scan tip; - which engine coins the heal pass may insert: well-formed, engine- confirmed, at least 100 confirmations deep against the run's own tip; - the classify verdict for the store's unspent rows: a row is marked spent only when its spender is ChainLocked (`finalized_spends`), and spends the ChainLock drops from the engine's history are kept for that; - the page budget (16 pages per run) and the resume point. The engine read guard is held from a page's read until the host reports that page applied (`heal_page_applied` / `classify_page_applied`), so no block lands between the verdict and the store write. A run whose page went stale is retried up to 5 times. Shared changes: `PlatformWalletInfo::finalized_spends`, captured in `apply_chain_lock`; the SPV run session is read after the steady-state read. Picked from #5259 with its Kotlin and JNI hunks dropped. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/core_bridge.rs | 8 + .../src/manager/accessors.rs | 272 ++- .../rs-platform-wallet/src/manager/load.rs | 1 + .../rs-platform-wallet/src/manager/mod.rs | 11 + .../src/manager/txo_reconcile.rs | 2088 +++++++++++++++++ .../src/manager/wallet_lifecycle.rs | 5 + .../rs-platform-wallet/src/spv/runtime.rs | 14 +- .../rs-platform-wallet/src/test_support.rs | 5 + .../rs-platform-wallet/src/wallet/apply.rs | 1 + .../wallet/asset_lock/sync/reconstruction.rs | 1 + .../src/wallet/asset_lock/sync/recovery.rs | 1 + .../identity/network/contact_requests.rs | 1 + .../src/wallet/platform_wallet.rs | 12 + .../src/wallet/platform_wallet_traits.rs | 187 +- 14 files changed, 2566 insertions(+), 41 deletions(-) create mode 100644 packages/rs-platform-wallet/src/manager/txo_reconcile.rs 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..9576ea47010 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,8 +393,15 @@ 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, @@ -416,24 +426,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 +461,93 @@ 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 + .core_wallet + .metadata + .last_applied_chain_lock + .as_ref() + .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 +1937,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 +1960,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 +1984,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 +2001,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 +2027,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 +2039,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 +2115,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 +2194,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 +2250,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..f9eb0af1fc4 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), diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 63de4e4d49b..d31f46c5a47 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(), } } @@ -1170,6 +1177,10 @@ impl PlatformWalletManager

{ /// /// [`WorkerStatus`]: dash_async::WorkerStatus pub async fn shutdown(&self) -> ShutdownReport { + // A reconcile's engine guard is held across one host write and + // released by the host; one it failed to release must not hold the + // writers below off. + self.release_txo_reconcile_holds(); // SPV first: it is the event source feeding everything below. Its // `stop` bounds the wait for teardown but never aborts it, so a // timeout means SPV work is still live. Its outcome lands in the 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..3947fabb188 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -0,0 +1,2088 @@ +//! 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; +//! - **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`]). +//! +//! 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_manager::WalletManager; +use tokio::sync::{OwnedRwLockReadGuard, RwLock}; + +use crate::changeset::PlatformWalletPersistence; +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. With +/// [`crate::manager::accessors::WALLET_UTXO_PAGE_MAX`]-sized pages a wallet +/// of a few thousand coins still finishes a whole cycle in one run; a +/// larger one is covered over several, one budget per +/// [`TXO_RECONCILE_CADENCE`]. +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 { + /// The scan tip the run's gate measured. + pub tip: u32, + /// 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 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 + } +} + +/// 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 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.core_wallet.metadata.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. In memory: a fresh process + /// starts a new cycle, which the budget still bounds. + 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, 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 — all of `wallets` on the rising edge into steady state, + /// and afterwards each wallet whose last run is at least + /// [`TXO_RECONCILE_CADENCE`] old or that never ran — never one already + /// in flight. Reporting a wallet consumes nothing: its cadence restarts + /// only when a run claims it ([`Self::try_begin`]), so a wallet the host + /// cannot run yet is reported again on the next poll. + 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(); + } + let rising = self.steady_session != Some(spv_session); + self.steady_session = Some(spv_session); + wallets + .into_iter() + .filter(|wallet_id| !self.in_flight.contains_key(wallet_id)) + .filter(|wallet_id| { + rising + || 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. + 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. `ran_at` is when a run + /// that went ahead finished, which restarts the wallet's cadence from + /// then; `None` for one that did not. 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, ran_at: Option) { + let Some(claim) = self.in_flight.remove(wallet_id) else { + return; + }; + if let Some(slot) = &claim.held { + release_slot(slot); + } + if let Some(at) = ran_at.filter(|_| !claim.forgotten) { + self.last_run.insert(*wallet_id, at); + } + } + + /// 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 { tip, start } + } +} + +impl Drop for ProvisionalClaim<'_> { + fn drop(&mut self) { + if !self.armed { + return; + } + 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, None); + } + } +} + +/// 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. Returns that scan + /// tip, which the claim keeps: the run's heal verdicts are measured + /// against it ([`Self::heal_page_held`]), so the host only reports it. + /// Also 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. + 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 claim = ProvisionalClaim::take(&self.schedule, *wallet_id, Instant::now()) + .ok_or(TxoReconcileSkip::AlreadyRunning)?; + let tip = gate.await?; + 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`] and the manager's + /// shutdown release it too. 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`]. + /// + /// `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 = rows + .last() + .filter(|_| has_more) + .map(|(row, _)| (row.account_type, row.outpoint)); + ((rows, has_more), 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; + Some(Some(match next { + Some(after) => Checkpoint::Heal(after), + None => Checkpoint::Classify(Vec::new()), + })) + } + _ => None, + }) + } + + /// 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 => None, + Some(after) => Some(Some(Checkpoint::Classify(after.to_vec()))), + None => Some(None), + }, + _ => None, + }) + } + + /// The body of the two `*_page_applied` calls: `advance` turns the + /// claim's pending page into the wallet's new checkpoint — `Some(None)` + /// ends the cycle, `None` means the page was not of that kind and + /// nothing moves. + fn page_applied( + &self, + wallet_id: &WalletId, + advance: impl FnOnce(&mut ReconcileClaim) -> Option>, + ) -> 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.filter(|_| !forgotten) { + Some(Some(checkpoint)) => { + schedule.checkpoints.insert(*wallet_id, checkpoint); + } + Some(None) => { + schedule.checkpoints.remove(wallet_id); + } + None => {} + } + 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. `ran` is whether the run went ahead (even if it + /// stopped early): that restarts the wallet's cadence from now. + pub fn finish(&self, wallet_id: &WalletId, ran: bool) { + lock_schedule(&self.schedule).finish(wallet_id, ran.then(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.core_wallet.metadata.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); + } + + /// Release every engine guard a reconcile run still holds, so a host + /// that failed to release one cannot hold teardown's writers off. + pub(super) fn release_txo_reconcile_holds(&self) { + let schedule = lock_schedule(&self.txo_reconcile_schedule); + for slot in schedule + .in_flight + .values() + .filter_map(|claim| claim.held.as_ref()) + { + release_slot(slot); + } + } +} + +#[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 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, Some(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, Some(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, None); + 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_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, None); + 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, Some(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, Some(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, true); + 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) + ); + assert_eq!( + reconciler + .claim_across(&A, async { Ok(TIP) }) + .await + .map(|run| run.tip), + Ok(TIP), + "the refusal released its claim" + ); + assert_eq!( + reconciler + .claim_across(&A, async { Ok(TIP) }) + .await + .map(|run| run.tip), + Err(TxoReconcileSkip::AlreadyRunning), + "the granted claim outlives the begin that took it" + ); + reconciler.finish(&A, true); + assert_eq!( + reconciler + .claim_across(&A, async { Ok(TIP) }) + .await + .map(|run| run.tip), + Ok(TIP) + ); + } + + /// 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, true); + + assert!(reconciler.claim_without_gate(&A, TIP).is_some()); + drop(page(reconciler.heal_page_held(&A, 10).await)); + reconciler.finish(&A, true); + } + + /// A wallet whose engine holds three coins. + async fn wallet_with_three_coins() -> (WalletManager, WalletId) { + let mut ctx = TestWalletContext::new_random(); + let script = ctx.receive_address.script_pubkey(); + for seed in 51u8..54 { + 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, true); + + 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, true); + + 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, true); + + 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, true); + + assert_eq!( + start(&reconciler), + TxoReconcileStart::Heal { resumed: false } + ); + reconciler.finish(&wallet_id, true); + } + + /// 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, true); + } + + /// 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, true); + } + + /// 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, true); + } + + /// 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, None); + 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, None); + let stale = ProvisionalClaim::take(&schedule, A, Instant::now()).expect("claimed"); + lock_schedule(&schedule).finish(&A, None); + 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, true); + + 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, true); + } + + /// 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, true); + timeout(PROCEEDS, fork) + .await + .expect("the fork proceeds once the run finished") + .expect("fork task"); + drop(held); + } + + /// 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, true); + 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..19587e37c6f 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(), @@ -1052,6 +1053,10 @@ impl PlatformWalletManager

{ } } + // A same-id wallet added later is reconciled at once rather than + // waiting out the removed one's cadence. + self.forget_txo_reconcile(wallet_id); + // Still under the generation's teardown gate: any deferred state naming // this generation is dropped in the same critical section as the removal // itself, so no payment operation can observe the wallet as live and then 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..b1420af924e 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,33 @@ 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>(records: impl IntoIterator) -> Vec { + 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) + }) + }) + .collect() +} + // --------------------------------------------------------------------------- // WalletTransactionChecker — delegate to `self.core` // --------------------------------------------------------------------------- @@ -309,9 +356,20 @@ impl WalletTransactionChecker for PlatformWalletInfo { update_balance: bool, ) -> TransactionCheckResult { // TODO: some logic must here - restore - self.core_wallet + let chain_locked = context.is_chain_locked(); + 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. + if DROPS_FINALIZED_RECORDS && chain_locked && update_state && result.is_relevant { + self.finalized_spends.extend(owned_spends( + result.new_records.iter().chain(&result.updated_records), + )); + } + result } } @@ -456,6 +514,129 @@ 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 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)); + 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. From 63a03988c2142ecab263a32b031f74dfaa42c1dc Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 10:17:12 -0800 Subject: [PATCH 02/19] feat(platform-wallet-ffi): expose the TXO reconcile policy over the C ABI `txo_reconcile.rs` wraps `TxoReconciler` for hosts: due wallets, begin and finish, the heal page and classify calls with their held verdicts, the applied acknowledgements that release the engine guard, and the stale-page retry rule. Held verdicts are released without the handle registry, and every export catches panics so none crosses the C ABI; the panic helper moves into `error.rs` and replaces the copies in `shielded_send.rs` and `manager_diagnostics.rs`. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/error.rs | 50 + packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../rs-platform-wallet-ffi/src/manager.rs | 48 + .../src/manager_diagnostics.rs | 298 +++-- .../src/shielded_send.rs | 50 - .../src/shielded_wallet_drain.rs | 6 +- .../src/txo_reconcile.rs | 1100 +++++++++++++++++ 7 files changed, 1383 insertions(+), 171 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/txo_reconcile.rs 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..3fc74215693 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1584,6 +1584,10 @@ mod tests { 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, + }; let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); let callbacks = persistence_callbacks(); let event_cbs = event_callbacks(); @@ -1637,6 +1641,50 @@ mod tests { [0, 0, 0], "every slot is answered, Unknown where nothing can be said" ); + // The reconcile's classifier shares the marshalling; it answers only + // a wallet that holds a run claim, and holds nothing otherwise. + let mut out = [0xFFu8; 3]; + let mut held: *mut TxoReconcileHeldVerdictsFFI = std::ptr::null_mut(); + 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::ErrorWalletOperation + ); + assert_eq!(out, [0, 0, 0]); + assert!(held.is_null()); + + // With a run claim, 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, false); 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..e8b77b858b6 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -17,21 +17,23 @@ use std::ffi::CString; use std::os::raw::c_char; use platform_wallet::manager::accessors::{ - AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, AccountMetadataSnapshot, - AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, - CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, + classify_outpoints, AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, + AccountMetadataSnapshot, AccountTransactionSnapshot, AccountUtxoSnapshot, + AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, + IdentityWalletStateSnapshot, OutpointClass, OutpointOwnershipQuery, PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, - TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, WalletUtxoCursor, 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, WalletUtxoCursorFFI, WalletUtxoEntryFFI, }; -use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::error::{catch_panic_to_code, PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; use crate::wallet_restore_types::AccountSpecFFI; @@ -982,6 +984,78 @@ fn account_spec_from_raw_tags( } } +/// The resume point a cursor names: its raw account tags validated into an +/// `AccountType`, and its outpoint. The error is the tag validator's. +pub(crate) fn wallet_utxo_cursor_from_ffi( + cursor: &WalletUtxoCursorFFI, +) -> Result { + let spec = account_spec_from_raw_tags( + cursor.type_tag, + cursor.standard_tag, + cursor.index, + cursor.registration_index, + cursor.key_class, + cursor.user_identity_id, + cursor.friend_identity_id, + ); + let account_type = account_type_from_spec_ref(&spec)?; + Ok((account_type, dashcore::OutPoint::from(&cursor.outpoint))) +} + +/// 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 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: 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, + } +} + +/// Release the allocations one [`wallet_utxo_entry_ffi`] row owns (not the +/// row itself). +/// +/// # Safety +/// `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); + } + 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, + )); + } +} + /// 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, @@ -1006,98 +1080,56 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page( 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]); + catch_read_panic("platform_wallet_wallet_utxos_page", || { + check_ptr!(wallet_id); + check_ptr!(out_rows); + check_ptr!(out_count); + check_ptr!(out_has_more); + *out_rows = std::ptr::null(); + *out_count = 0; + *out_has_more = false; + let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); - let after: Option = if cursor.is_null() { - None - } else { - let c = &*cursor; - let spec = account_spec_from_raw_tags( - c.type_tag, - c.standard_tag, - c.index, - c.registration_index, - c.key_class, - c.user_identity_id, - c.friend_identity_id, - ); - let account_type = match account_type_from_spec_ref(&spec) { - Ok(at) => at, - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("cursor: {e}"), - ); + let after: Option = if cursor.is_null() { + None + } else { + match wallet_utxo_cursor_from_ffi(&*cursor) { + Ok(after) => Some(after), + Err(e) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("cursor: {e}"), + ); + } } }; - Some((account_type, dashcore::OutPoint::from(&c.outpoint))) - }; - let Some(wallet_manager) = - PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) - else { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidHandle, - "Manager handle invalid".to_string(), - ); - }; - let (rows, has_more) = { - let wm = wallet_manager.blocking_read(); - wallet_utxos_page(&wm, &wid, after.as_ref(), limit) - }; - *out_has_more = has_more; - if rows.is_empty() { - return PlatformWalletFFIResult::ok(); - } + let Some(wallet_manager) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) + else { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + "Manager handle invalid".to_string(), + ); + }; + let (rows, has_more) = { + let wm = wallet_manager.blocking_read(); + wallet_utxos_page(&wm, &wid, after.as_ref(), limit) + }; + *out_has_more = has_more; + if rows.is_empty() { + return PlatformWalletFFIResult::ok(); + } - let entries: Vec = rows - .into_iter() - .map(|row| { - let tags = account_type_to_tags(&row.account_type); - let script_len = row.script_pubkey.len(); - let script_ptr = if script_len == 0 { - std::ptr::null_mut() - } else { - Box::into_raw(row.script_pubkey.into_boxed_slice()) as *mut u8 - }; - let address = CString::new(row.address) - .unwrap_or_else(|_| CString::new("").expect("empty string has no NUL")) - .into_raw(); - WalletUtxoEntryFFI { - type_tag: tags.type_tag, - standard_tag: tags.standard_tag, - index: tags.index, - registration_index: tags.registration_index, - key_class: tags.key_class, - user_identity_id: tags.user_identity_id, - friend_identity_id: tags.friend_identity_id, - outpoint: crate::core_wallet_types::OutPointFFI::from(&row.outpoint), - value_duffs: row.value_duffs, - address, - script_pubkey: script_ptr, - script_pubkey_len: script_len, - height: row.height, - is_confirmed: row.is_confirmed, - is_instantlocked: row.is_instantlocked, - is_coinbase: row.is_coinbase, - is_locked: row.is_locked, - } - }) - .collect(); - let count = entries.len(); - *out_rows = Box::into_raw(entries.into_boxed_slice()) as *const _; - *out_count = count; - PlatformWalletFFIResult::ok() + let entries: Vec = + rows.into_iter().map(wallet_utxo_entry_ffi).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`. @@ -1112,17 +1144,8 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( if rows.is_null() || count == 0 { return; } - let slice = std::slice::from_raw_parts(rows, count); - for entry in slice { - if !entry.address.is_null() { - let _ = CString::from_raw(entry.address); - } - if !entry.script_pubkey.is_null() && entry.script_pubkey_len > 0 { - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut( - entry.script_pubkey, - entry.script_pubkey_len, - )); - } + for entry in std::slice::from_raw_parts(rows, count) { + release_wallet_utxo_entry(entry); } let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); } @@ -1144,12 +1167,61 @@ pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( pub unsafe extern "C" fn platform_wallet_classify_outpoints( manager_handle: Handle, wallet_id: *const u8, - queries: *const crate::core_wallet_types::OutpointOwnershipQueryFFI, + queries: *const OutpointOwnershipQueryFFI, count: usize, out_classes: *mut u8, ) -> PlatformWalletFFIResult { - use platform_wallet::manager::accessors::{classify_outpoints, OutpointOwnershipQuery}; + catch_read_panic("platform_wallet_classify_outpoints", || { + classify_outpoint_rows(wallet_id, queries, count, out_classes, |wid, owned| { + let wallet_manager = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.wallet_manager_arc()) + .ok_or_else(invalid_manager_handle)?; + let wm = wallet_manager.blocking_read(); + Ok(classify_outpoints(&wm, &wid, &owned)) + }) + }) +} +/// Run a read-only diagnostics export's body with a panic turned into an +/// `ErrorWalletOperation` result instead of unwinding into the C caller. +fn catch_read_panic( + operation: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + catch_panic_to_code( + operation, + PlatformWalletFFIResultCode::ErrorWalletOperation, + "The call only reads the engine; it can be retried.", + body, + ) +} + +/// 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 `platform_wallet_classify_outpoints` and +/// `platform_wallet_txo_reconcile_classify_outpoints` share: 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`. An `Err` from `classify` is returned as is. +/// +/// # Safety +/// As `platform_wallet_classify_outpoints`. +pub(crate) unsafe fn classify_outpoint_rows( + wallet_id: *const u8, + queries: *const OutpointOwnershipQueryFFI, + count: usize, + out_classes: *mut u8, + classify: impl FnOnce( + [u8; 32], + Vec, + ) -> Result, PlatformWalletFFIResult>, +) -> PlatformWalletFFIResult { check_ptr!(wallet_id); if count == 0 { return PlatformWalletFFIResult::ok(); @@ -1165,7 +1237,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 +1270,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..7b94646503f --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -0,0 +1,1100 @@ +//! 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, 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; +use crate::unwrap_option_or_return; + +/// 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, + ) +} + +/// `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; + +/// `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 option = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); + let reconciler = unwrap_option_or_return!(option); + 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`, `*out_tip_height` is the scan +/// tip the run is measured against (the claim keeps it; the host only +/// reports it), and the claim holds until +/// `platform_wallet_manager_txo_reconcile_finish`. A skip holds no claim. +/// Either way the wallet's cadence restarts now, unless another run +/// already held it. +/// +/// 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_tip_height: *mut u32, + 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_tip_height); + check_ptr!(out_start); + check_ptr!(out_store_after); + check_ptr!(out_store_after_len); + *out_gate = TXO_RECONCILE_SKIP_WALLET_UNKNOWN; + *out_tip_height = 0; + *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 option = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); + let reconciler = unwrap_option_or_return!(option); + match block_on_worker(async move { reconciler.begin(&wid).await }) { + Ok(run) => { + *out_gate = TXO_RECONCILE_GATE_RUN; + *out_tip_height = run.tip; + *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. `ran` is whether +/// the run went ahead (even if it stopped early), which restarts the +/// wallet's cadence. 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, + ran: bool, +) -> 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 option = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.txo_reconciler().finish(&wid, ran)); + unwrap_option_or_return!(option); + PlatformWalletFFIResult::ok() + }) +} + +/// One page of the wallet's UTXO inventory — the same walk and bounds as +/// `platform_wallet_wallet_utxos_page` — 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 run's own tip (the one +/// `platform_wallet_manager_txo_reconcile_begin` returned), or to the +/// wallet's processed height under this page's guard when that is lower; +/// the caller passes no height. 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(); + let result = catch_reconcile_panic("platform_wallet_txo_reconcile_heal_page", || { + 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() + }); + // A failure holds nothing, even one that struck after the guard was + // handed out. + if result.code != PlatformWalletFFIResultCode::Success { + platform_wallet_txo_reconcile_held_verdicts_free(*out_held); + *out_held = std::ptr::null_mut(); + } + result +} + +/// 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 — `platform_wallet_classify_outpoints`'s +/// answers and marshalling — 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 and manager destroy release a guard the host +/// failed to, as a backstop. 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 +/// As `platform_wallet_classify_outpoints`: `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; + let result = catch_reconcile_panic("platform_wallet_txo_reconcile_classify_outpoints", || { + 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) + }); + // A failure holds nothing, even one that struck after the guard was + // handed out. + if result.code != PlatformWalletFFIResultCode::Success { + platform_wallet_txo_reconcile_held_verdicts_free(*out_held); + *out_held = std::ptr::null_mut(); + } + result +} + +/// 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 option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.txo_reconciler().heal_page_applied(&wid) + }); + *out_may_continue = unwrap_option_or_return!(option); + 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 not kept), 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]); + let after: Option> = if is_last { + None + } else if next_after_len == 0 { + Some(Vec::new()) + } else { + check_ptr!(next_after); + Some(std::slice::from_raw_parts(next_after, next_after_len).to_vec()) + }; + let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.txo_reconciler() + .classify_page_applied(&wid, after.as_deref()) + }); + *out_may_continue = unwrap_option_or_return!(option); + 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, 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 tip = 7u32; + 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 tip, + &mut start, + store_after.as_mut_ptr(), + &mut store_after_len, + ) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(gate, TXO_RECONCILE_SKIP_WALLET_UNKNOWN); + assert_eq!(tip, 0); + 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_ne!(result.code, PlatformWalletFFIResultCode::Success); + 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_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!may_continue); + + let mut ids = IdentifierArray::empty(); + let result = + unsafe { platform_wallet_manager_txo_reconcile_due_wallets(Handle::MAX, &mut ids) }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + 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, true); + + 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, true); + 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, false); + 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, false); + 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" + ); + } +} From 51bc0bb5f31cb5471c6ea9e83e68d1df69eb5672 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 10:17:12 -0800 Subject: [PATCH 03/19] refactor(swift-sdk): ask the Rust wallet for every TXO reconcile decision The Swift reconcile keeps only its SwiftData reads and writes; scheduling, gates, heal eligibility, classify verdicts, the page budget and resume now come from rs-platform-wallet over the C ABI. Each heal and classify page is written while the engine is held, then acknowledged so Rust releases it. No spend verdict is written once a run is stopped. Logging: keyed log references for public identifiers, and `SDKLogValue` keeps its cases for downstream exhaustive switches. Co-Authored-By: Claude Opus 5.5 --- .../Core/Services/SDKLogger.swift | 25 + .../CoreTxoReconcileTypes.swift | 496 +++++++++-- .../PlatformWalletManager.swift | 57 +- .../PlatformWalletManagerTxoReconcile.swift | 702 ++++++++++----- .../PlatformWalletPersistenceHandler.swift | 175 ++-- .../CoreTxoReconcilePrivacyTests.swift | 11 +- .../CoreTxoReconcileShutdownTests.swift | 540 +++++++++++- .../CoreTxoReconcileTests.swift | 830 ++++++++++++++++-- .../PlatformWalletProgressPollTests.swift | 11 +- .../SwiftDashSDKTests/SDKLoggerTests.swift | 16 + 10 files changed, 2386 insertions(+), 477 deletions(-) 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..dd6a3a8015c 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. 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,13 @@ 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 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 +272,44 @@ 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. 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, too many deferrals, 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 + /// 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 +319,47 @@ 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` and a wallet this manager has not +/// loaded 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 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 +367,195 @@ 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, measured against `tipHeight` (the native claim + /// keeps it; the host only reports it), at `start`; the wallet is + /// claimed until `platform_wallet_manager_txo_reconcile_finish`. + case run(tipHeight: UInt32, start: CoreTxoReconcileStart) + /// The gate's `TXO_RECONCILE_SKIP_*` code. Nothing is claimed. + case skip(code: UInt8) +} + +/// 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; the flag is whether the run went ahead. + let finish: @Sendable (Handle, Data, Bool) 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] { + var array = IdentifierArray() + try platform_wallet_manager_txo_reconcile_due_wallets(handle, &array).check() + defer { platform_wallet_identifier_array_free(&array) } + guard array.items != nil, array.count > 0 else { return [] } + return (0.. CoreTxoReconcileGate { + guard walletId.count == 32 else { + return .skip(code: UInt8(TXO_RECONCILE_SKIP_WALLET_UNKNOWN)) + } + var gate = UInt8(TXO_RECONCILE_SKIP_WALLET_UNKNOWN) + var tipHeight: UInt32 = 0 + 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, + &tipHeight, + &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, ran: false) + throw PlatformWalletError.walletOperation("unknown TXO reconcile start code \(startCode)") + } + return .run(tipHeight: tipHeight, start: start) + } + + static func finish(_ handle: Handle, walletId: Data, ran: Bool) 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), + ran + ) + } + 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 +615,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/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 63b20689d2c..016b127553e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -329,6 +329,8 @@ struct PlatformWalletPollSnapshot: Sendable { var shieldedSyncIsSyncing: Bool? var dashPaySyncIsSyncing: Bool? var spvTipBlockTime: Date?? + /// The wallets the TXO-store reconcile schedule reports due this tick. + var txoReconcileDueWallets: [Data]? } /// One wallet's needs-unlock count, tagged with the handle it was read @@ -395,6 +397,9 @@ struct PlatformWalletNativePollCalls: @unchecked Sendable { /// Takes the WALLET handle (`ManagedPlatformWallet.handle`), not the /// manager's. let pendingAccountBuildCount: Read + /// 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,14 @@ 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. - 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 + /// 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 = [] + /// 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 +981,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 +1009,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 +1128,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 +1147,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 +2740,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 +3172,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 +3244,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 +3272,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..bc97dc775c5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -13,11 +13,10 @@ 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`). @@ -25,48 +24,82 @@ import Foundation // 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. 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`). + /// How many times a store step is deferred behind an open Rust + /// persistence round before the run gives up (each deferral waits + /// `coreTxoReconcileRetryDelay`). The store's own fence: saving + /// mid-round would commit the round's staged rows early. Never waited + /// on while the run holds an engine guard. 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 and a wallet this manager has not loaded, 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`). /// - /// 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,146 +108,304 @@ extension PlatformWalletManager { guard walletId.count == 32, wallets[walletId] != nil else { return logSkip(.walletUnknown, walletId: walletId) } - guard !coreTxoReconcileInFlight.contains(walletId) else { - return logSkip(.alreadyRunning, walletId: walletId) - } - guard spvIsRunning else { return logSkip(.spvNotRunning, walletId: walletId) } - let progress = spvProgress - guard Self.isSteadySyncState(progress) else { return logSkip(.notSteadyState, walletId: walletId) } - guard let tipHeight = Self.scanTipHeight(progress) else { - return logSkip(.tipUnavailable, walletId: walletId) - } - if try syncFaultDetected() { return logSkip(.syncFaultDetected, walletId: walletId) } - - coreTxoReconcileInFlight.insert(walletId) - defer { coreTxoReconcileInFlight.remove(walletId) } let epoch = coreTxoReconcileEpoch let generation = epoch.current() let managerHandle = handle + let 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`. 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 ran = false + defer { + endCoreTxoReconcileRun(walletId: walletId, engine: engine, quiet: isCancelled()) { + try calls.finish(handle, walletId, ran) + } } - return nil + guard !isCancelled() else { return nil } + let report = runCoreTxoReconcile( + walletId: walletId, + pageSize: pageSize, + start: start, + engine: engine, + handler: handler, + isCancelled: isCancelled + ) + ran = true + 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. 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 + } - // Pass A — heal: every engine coin the store lacks. - var cursor: CoreEngineUtxo? + /// 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 + } + + /// 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 before it waits, then reads the + /// same page again under a new one (another page of the budget), without + /// reporting the first read applied. + private nonisolated static func healMissingTxos( + _ walletId: Data, + _ limit: Int, + _ engine: any CoreTxoReconcileEngine, + _ handler: PlatformWalletPersistenceHandler, + _ isCancelled: @Sendable () -> Bool, + _ report: inout CoreTxoReconcileReport + ) -> Bool { + var deferrals = 0 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: wait for the round + // guard-free, then read the page again. Not applied, so + // the engine serves the same page. + deferrals += 1 + report.retries += 1 + if deferrals > coreTxoReconcileMaxRetries { + report.completed = false + return false + } + Thread.sleep(forTimeInterval: coreTxoReconcileRetryDelay) + continue + } + deferrals = 0 report.inserted += counts.inserted report.insertedDuffs = report.insertedDuffs.addingReportingOverflow(counts.insertedDuffs).0 report.alreadyPresent += counts.alreadyPresent @@ -223,81 +414,169 @@ extension PlatformWalletManager { report.skippedUnresolvedAccount += counts.skippedUnresolvedAccount 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 || page.rows.isEmpty { 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. 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 } + handler.reconcileUnspentTxoPage( + walletId: walletId, offset: offset, limit: limit, isCancelled: isCancelled) + }) else { return } report.storeRows += page.rows.count + report.unresolvedAccount += page.unresolvedAccount var flippedThisPage = 0 - if !page.rows.isEmpty { - let classes: [CoreOutpointClass] + do { + 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. + report.storeRows -= page.rows.count + report.unresolvedAccount -= page.unresolvedAccount + 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 report + return + case .failed: + report.storeFailures += 1 + report.completed = false + return + case .retryLater: + // The apply answers an open round as a stale page; + // were it ever to defer instead, the run must still + // not wait for a round while it holds the guard. + var stale = CoreTxoFlipCounts() + stale.staleGeneration = true + counts = stale } - // 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 + 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. + report.storeRows -= page.rows.count + report.unresolvedAccount -= page.unresolvedAccount + 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. + /// closed within the retry budget — `report` says which. Only for steps + /// taken with no engine guard held. private nonisolated static func withRoundRetry( _ report: inout CoreTxoReconcileReport, isCancelled: @Sendable () -> Bool, @@ -312,6 +591,9 @@ extension PlatformWalletManager { switch step() { case .done(let value): return value + case .stopped: + report.completed = false + return nil case .failed: report.storeFailures += 1 report.completed = false @@ -328,28 +610,28 @@ extension PlatformWalletManager { } } - /// 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 +660,42 @@ extension PlatformWalletManager { return .skipped(reason) } + /// The summary's level: `info` for a run that wrote nothing and either + /// completed or stopped on its budget — an expected state, 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 { + report.mutations == 0 && (report.completed || report.stoppedOnBudget) ? .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_unresolved_account_count": .integer(Int64(report.skippedUnresolvedAccount)), "store_failure_count": .integer(Int64(report.storeFailures)), + "stopped_on_budget": .boolean(report.stoppedOnBudget), "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..687770fef2d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -11466,12 +11466,17 @@ 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. + /// staged rows early. The run never waits for it while holding an + /// engine guard: the classify apply answers a stale page instead, and a + /// deferred heal step releases its page's guard before it waits. 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 +11497,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 } @@ -11506,6 +11514,9 @@ struct CoreTxoHealCounts: Sendable { var skippedImmature = 0 var skippedUnresolvedAccount = 0 var skippedInvalid = 0 + /// Outpoints written, logged once the step saved. + var healedOutpoints: [Data] = [] + var healedSpentOutpoints: [Data] = [] } /// Counts from one classify-apply step. @@ -11517,54 +11528,62 @@ 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 + /// Outpoints flipped, logged once the step saved. + var flippedOutpoints: [Data] = [] } 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. + /// 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 { + 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? @@ -11625,43 +11644,31 @@ 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), - ] - ) + counts.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), - ] - ) + counts.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. + counts.healedOutpoints.forEach { logReconcileItem("healed", outpoint: $0, walletId: walletId) } + counts.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 + /// 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. 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 @@ -11669,9 +11676,11 @@ extension PlatformWalletPersistenceHandler { func reconcileUnspentTxoPage( walletId: Data, offset: Int, - limit: Int + limit: Int, + isCancelled: () -> Bool ) -> CoreTxoReconcileStep { onQueue { + guard !isCancelled() else { return .stopped } guard !inChangeset else { return .retryLater } var descriptor = FetchDescriptor( predicate: #Predicate { $0.isSpent == false }, @@ -11694,13 +11703,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 +11738,7 @@ extension PlatformWalletPersistenceHandler { rows: rows, fetched: fetched.count, hasMore: fetched.count == limit, + unresolvedAccount: unresolvedAccount, generation: committedRoundGeneration )) } @@ -11741,16 +11755,28 @@ 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) @@ -11780,20 +11806,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), - ] - ) + counts.flippedOutpoints.append(row.outpoint) } } guard counts.flipped == 0 || reconcileSave(operation: "txo_reconcile_flip", walletId: walletId) else { return .failed } + counts.flippedOutpoints.forEach { logReconcileItem("flipped_spent", outpoint: $0, walletId: walletId) } return .done(counts) } } @@ -11827,6 +11846,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/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift index d74ec5b810d..aa35a0e856f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift @@ -115,7 +115,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 +132,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..6b44f85ad78 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -3,10 +3,69 @@ 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 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, ran: Bool, 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(tipHeight: 2_535_898, start: .heal(resumed: false)) + ) { + self.engine = engine + self.holding = holding + _gate = gate + } + + convenience init( + engine: FakeCoreTxoEngine, + gate: CoreTxoReconcileGate = .run(tipHeight: 2_535_898, start: .heal(resumed: false)) + ) { + self.init(engine: engine, holding: { engine.isHoldingVerdicts }, gate: gate) + } + + var begins: [Data] { lock.withLock { _begins } } + var finishes: [(walletId: Data, ran: Bool, 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, ran in + let held = holding() + lock.withLock { _finishes.append((walletId, ran, 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, defers a +/// guard-free store step while a Rust persistence round is open, and the +/// manager refuses it once shutdown has begun. @MainActor final class CoreTxoReconcileShutdownTests: XCTestCase { private let walletId = Data(repeating: 0x51, count: 32) @@ -35,6 +94,28 @@ 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: "yShutdownFixtureAddr", + 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, @@ -47,12 +128,48 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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() 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 } ) @@ -68,24 +185,54 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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. + /// 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 every guard-free + /// store step: the run retries until the round closes, then completes + /// normally. func testAStepDeferredBehindAnOpenRoundCompletesOnceTheRoundCloses() throws { let (handler, container) = try makeHandler() - let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x31)]) + // Each deferred heal step reads its page again, a page of the + // budget each time: room for however many the wait below allows. + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x31)], budget: 1_000) handler.beginChangeset(walletId: walletId) let finished = expectation(description: "reconcile finished") @@ -93,7 +240,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { let walletId = self.walletId DispatchQueue.global(qos: .utility).async { let report = PlatformWalletManager.runCoreTxoReconcile( - walletId: walletId, tipHeight: 2_535_898, engine: engine, handler: handler, + walletId: walletId, engine: engine, handler: handler, isCancelled: { false } ) box.set(report) @@ -112,17 +259,278 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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?.ran, true) + 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]) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x13)]) + let native = FakeReconcileNative(engine: engine, gate: .run(tipHeight: 2_400_001, 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?.ran, true) + 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(tipHeight: 2_400_002, 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?.ran, true) + } + + /// 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() + 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?.ran, false) + 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() + 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?.ran, true) + _ = 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 {} + } + + func testTheManagerRefusesAReconcileOnceShutdownHasBegun() async throws { + let manager = PlatformWalletManager.makeForTesting(handle: 42, calls: Self.okTeardownCalls()) let epochBefore = manager.coreTxoReconcileEpoch.current() _ = await manager.shutdown() PlatformWalletManager.destroyQueue.sync {} @@ -161,7 +569,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 +589,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, ran: false)) + 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 +650,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..ad44ff4765f 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,81 @@ 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 } } } -/// 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 fixtureScript = Data([0x76, 0xa9, 0x14] + [UInt8](repeating: 0x5a, count: 20) + [0x88, 0xac]) @@ -241,17 +421,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 +542,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 +578,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 — the read waits + /// for the round, guard-free. + 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, "nothing waited for the round while the guard was held") + 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 @@ -456,33 +755,58 @@ final class CoreTxoReconcileTests: XCTestCase { 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) + 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] { + 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 @@ -547,6 +871,8 @@ 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")) } @@ -590,48 +916,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() @@ -786,6 +1077,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) @@ -805,6 +1097,62 @@ 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) + 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): the guard goes, + /// the run waits, and the same page is read again under a new one. + func testADeferredHealStepReleasesItsGuardBeforeWaiting() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x9a))]) + let walletId = self.walletId + let releases = Counter() + handler.beginChangeset(walletId: walletId) + engine.onRelease = { + guard releases.next() == 1 else { return } + _ = handler.endChangeset(walletId: walletId, success: true) + } + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.retries, 1, "one wait, taken after the guard was released") + XCTAssertEqual(report.engineRows, 1, "the page read again is counted once") + XCTAssertEqual(report.inserted, 1) + XCTAssertEqual(engine.pageCalls, 2) + XCTAssertEqual(engine.healApplied, 1, "the deferred read is not reported applied; the one that landed is") + XCTAssertEqual(Array(engine.events.prefix(5)), ["heal", "release", "heal", "release", "healApplied"]) + XCTAssertEqual(engine.pagedWhileHeld, 0) + XCTAssertNotNil(try txo(container, txid: txid(0x9a))) + } + func testTheClassifyPassWalksEveryUnspentRowAcrossFlips() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) @@ -821,5 +1169,327 @@ 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) + } + + /// A heal page deferred behind an open round is read again, each read a + /// page of the budget, and never reported applied: when the budget runs + /// out the run stops, and the next run starts at the same page. + func testADeferredHealPageIsNeverReportedApplied() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x42))], budget: 2) + let walletId = self.walletId + handler.beginChangeset(walletId: walletId) + defer { _ = handler.endChangeset(walletId: walletId, success: true) } + + let report = run(handler, engine: engine) + + XCTAssertFalse(report.completed) + XCTAssertTrue(report.stoppedOnBudget) + XCTAssertEqual(report.retries, 2) + XCTAssertEqual(engine.pageCalls, 3) + XCTAssertEqual(engine.budgetSpentReads, 1) + XCTAssertEqual(engine.healApplied, 0) + XCTAssertEqual(report.inserted, 0) + XCTAssertNil(engine.checkpoint, "nothing moved: the next run starts a new cycle") + XCTAssertFalse(engine.isHoldingVerdicts) + XCTAssertNil(try txo(container, txid: txid(0x42))) + } + + /// 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) + 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 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 another wallet's rows, + /// 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)) + try seedUnspentTxo(in: container, walletId: otherWalletId, txid: txid(0x71)) + 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", + ]) + } + + /// 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 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 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 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) From 498eddef1acd22d5e89449f646fe3fae17cab23b Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 10:23:21 -0800 Subject: [PATCH 04/19] fix(swift-sdk): heal a coin only when its address is saved under its account A cold restore rebuilds each account's address pool from the address rows filed under it. The heal pass inserted an engine coin whether or not its address had such a row, so after a restart the engine held a coin with no derivation path, and every send that selected it failed. The heal now requires the coin's `PersistentCoreAddress` row to exist and belong to the account the coin is filed under. Otherwise the coin is skipped and counted as `skippedMissingAddress` (run report and summary log); a later run heals it once the address is saved under the account. A failed address read stops the step instead of counting as that skip. This is the same check the Kotlin heal takes in #5767. Co-Authored-By: Claude Opus 5.5 --- .../CoreTxoReconcileTypes.swift | 5 + .../PlatformWalletManagerTxoReconcile.swift | 2 + .../PlatformWalletPersistenceHandler.swift | 50 +++++-- .../CoreTxoReconcileTests.swift | 138 +++++++++++++++++- 4 files changed, 185 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index dd6a3a8015c..9a3bac31e1b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -259,6 +259,11 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { public var skippedImmature = 0 /// Engine rows whose account has no store row to file them under. public var skippedUnresolvedAccount = 0 + /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index bc97dc775c5..6268fbf5c5f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -412,6 +412,7 @@ extension PlatformWalletManager { report.healedSpent += counts.healedSpent report.skippedImmature += counts.skippedImmature report.skippedUnresolvedAccount += counts.skippedUnresolvedAccount + report.skippedMissingAddress += counts.skippedMissingAddress report.skippedInvalid += counts.skippedInvalid } report.engineRows += page.rows.count @@ -689,6 +690,7 @@ extension PlatformWalletManager { "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), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 687770fef2d..be9ae85f219 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2543,6 +2543,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 +2559,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 @@ -11513,6 +11521,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 /// Outpoints written, logged once the step saved. var healedOutpoints: [Data] = [] @@ -11551,11 +11561,16 @@ extension PlatformWalletPersistenceHandler { /// 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. - /// 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: + /// 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, @@ -11606,6 +11621,25 @@ 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 + } let parentTx: PersistentTransaction if let existing = fetchTransactionRow(txid: row.txid) { parentTx = existing @@ -11632,9 +11666,7 @@ 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 drainPendingInputs(into: record, resolvedWalletId: walletId) if record.isSpent { // A pending-input claim or a swept tombstone already diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index ad44ff4765f..9baa6c4cd32 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -262,6 +262,7 @@ final class CoreTxoReconcileTests: XCTestCase { private let walletId = Data(repeating: 0x31, count: 32) private let otherWalletId = Data(repeating: 0x32, count: 32) 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 { @@ -332,6 +333,38 @@ final class CoreTxoReconcileTests: XCTestCase { return wallet } + /// Files the address row of `address` (the fixture address unless + /// given) 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. + private func fileAddress( + in container: ModelContainer, + walletId: Data? = nil, + address: String? = nil, + underAccountType accountType: UInt32? = 0 + ) throws { + let walletId = walletId ?? self.walletId + let address = address ?? fixtureAddress + 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() + } + /// An unspent, confirmed TXO row of `walletId` under its BIP44 account. private func seedUnspentTxo( in container: ModelContainer, @@ -729,8 +762,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) @@ -748,10 +783,12 @@ 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) } @@ -762,6 +799,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testTheHealPassFollowsTheEnginesVerdicts() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) // BIP44 only: no CoinJoin account row + 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)) @@ -780,6 +818,7 @@ final class CoreTxoReconcileTests: XCTestCase { 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.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] { @@ -817,6 +856,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. @@ -877,6 +917,93 @@ final class CoreTxoReconcileTests: XCTestCase { 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 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 @@ -953,6 +1080,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)) @@ -1069,6 +1197,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 @@ -1086,6 +1215,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) @@ -1103,6 +1233,7 @@ final class CoreTxoReconcileTests: XCTestCase { 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() @@ -1131,6 +1262,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testADeferredHealStepReleasesItsGuardBeforeWaiting() 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 let releases = Counter() @@ -1247,6 +1379,7 @@ final class CoreTxoReconcileTests: XCTestCase { func testADeferredHealPageIsNeverReportedApplied() throws { let (handler, container) = try makeHandler() try seedWallet(in: container) + try fileAddress(in: container) let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x42))], budget: 2) let walletId = self.walletId handler.beginChangeset(walletId: walletId) @@ -1271,6 +1404,7 @@ final class CoreTxoReconcileTests: XCTestCase { 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) @@ -1293,6 +1427,7 @@ final class CoreTxoReconcileTests: XCTestCase { 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( @@ -1435,6 +1570,7 @@ final class CoreTxoReconcileTests: XCTestCase { 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))) } From dfd55901f91dea88b357cc46a48a6e01bb8a10dd Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 14:21:20 -0800 Subject: [PATCH 05/19] refactor(platform-wallet): simplify the TXO reconcile surface - `begin` no longer hands the run's tip to the host; the claim keeps it. - An empty heal page always ends the pass (`has_more` is normalized in Rust), so hosts check `has_more` only. - `page_applied` takes a `CheckpointMove` instead of a nested Option. - Reconcile reads the wallet heights through the `WalletInfoInterface` accessors; `owned_spends` yields an iterator instead of a Vec. - FFI: one wrapper frees a held guard on a failed held read; the inventory and classify exports no host calls any more are removed (`platform_wallet_wallet_utxos_page`, `_free`, `platform_wallet_classify_outpoints`, `WalletUtxoCursorFFI`). - Swift: a classify page is counted once, not added and taken back; report-only outpoint lists become locals; `readIdentifierArray` is shared with `ManagedPlatformWallet`. - The page budget's doc describes pages of the size the host asks for. No behavior change except the defensive empty-page rule. Co-Authored-By: Claude Opus 5.5 --- .../src/core_wallet_types.rs | 33 +-- .../rs-platform-wallet-ffi/src/manager.rs | 29 +-- .../src/manager_diagnostics.rs | 189 ++---------------- .../src/txo_reconcile.rs | 153 +++++++------- .../src/manager/accessors.rs | 9 +- .../src/manager/txo_reconcile.rs | 157 ++++++++++----- .../src/wallet/platform_wallet_traits.rs | 9 +- .../CoreTxoReconcileTypes.swift | 20 +- .../ManagedPlatformWallet.swift | 21 -- .../PlatformWalletManagerTxoReconcile.swift | 24 ++- .../PlatformWalletPersistenceHandler.swift | 22 +- .../PlatformWallet/PlatformWalletTypes.swift | 20 ++ .../CoreTxoReconcileShutdownTests.swift | 8 +- .../CoreTxoReconcileTests.swift | 5 +- 14 files changed, 284 insertions(+), 415 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index 2943926d9f8..d11957c1b0d 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,18 @@ 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 +/// `platform_wallet_txo_reconcile_classify_outpoints` answer: the owning account knows /// the funding txid, owns the script, and does not hold the coin — the /// engine skipped it for a spent reason or consumed it. The one class a /// reconciler may act on. pub const OUTPOINT_CLASS_KNOWN_UNCREDITED: u8 = 2; -/// `platform_wallet_classify_outpoints` answer: the owning account's pools +/// `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/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3fc74215693..7bf6fd5ff8e 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1583,7 +1583,6 @@ 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, @@ -1621,28 +1620,8 @@ 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)]; - let mut out = [0xFFu8; 3]; - let result = unsafe { - platform_wallet_classify_outpoints( - handle, - wallet_id.as_ptr(), - queries.as_ptr(), - queries.len(), - out.as_mut_ptr(), - ) - }; - assert_eq!( - result.code, - PlatformWalletFFIResultCode::Success, - "an unmappable tag degrades that query to Unknown, it does not fail the batch" - ); - assert_eq!( - out, - [0, 0, 0], - "every slot is answered, Unknown where nothing can be said" - ); - // The reconcile's classifier shares the marshalling; it answers only - // a wallet that holds a run claim, and holds nothing otherwise. + // 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 { @@ -1663,7 +1642,9 @@ mod tests { assert_eq!(out, [0, 0, 0]); assert!(held.is_null()); - // With a run claim, every slot is answered and the guard comes back. + // 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"); diff --git a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs index e8b77b858b6..12c330a6340 100644 --- a/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs +++ b/packages/rs-platform-wallet-ffi/src/manager_diagnostics.rs @@ -17,12 +17,12 @@ use std::ffi::CString; use std::os::raw::c_char; use platform_wallet::manager::accessors::{ - classify_outpoints, AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, - AccountMetadataSnapshot, AccountTransactionSnapshot, AccountUtxoSnapshot, - AddressBanInfoSnapshot, CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, - IdentityWalletStateSnapshot, OutpointClass, OutpointOwnershipQuery, - PlatformAddressProviderStateSnapshot, PlatformAddressSyncConfigSnapshot, - TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, WalletUtxoCursor, WalletUtxoRow, + AccountAddressInfoSnapshot, AccountAddressPoolSnapshot, AccountMetadataSnapshot, + AccountTransactionSnapshot, AccountUtxoSnapshot, AddressBanInfoSnapshot, + CoreWalletStateSnapshot, IdentitySyncConfigSnapshot, IdentityWalletStateSnapshot, + OutpointClass, OutpointOwnershipQuery, PlatformAddressProviderStateSnapshot, + PlatformAddressSyncConfigSnapshot, TrackedAssetLockSnapshot, WalletIdentityRowSnapshot, + WalletUtxoRow, }; use crate::check_ptr; @@ -31,9 +31,9 @@ use crate::core_wallet_types::{ AccountUtxoEntryFFI, AddressBanInfoFFI, AddressInfoFFI, CoreWalletStateFFI, IdentitySyncConfigFFI, IdentityWalletStateFFI, OutpointOwnershipQueryFFI, PlatformAddressProviderStateFFI, PlatformAddressSyncConfigFFI, TrackedAssetLockEntryFFI, - WalletIdentityRowFFI, WalletUtxoCursorFFI, WalletUtxoEntryFFI, + WalletIdentityRowFFI, WalletUtxoEntryFFI, }; -use crate::error::{catch_panic_to_code, PlatformWalletFFIResult, PlatformWalletFFIResultCode}; +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::{Handle, PLATFORM_WALLET_MANAGER_STORAGE}; use crate::wallet_restore_types::AccountSpecFFI; @@ -984,24 +984,6 @@ fn account_spec_from_raw_tags( } } -/// The resume point a cursor names: its raw account tags validated into an -/// `AccountType`, and its outpoint. The error is the tag validator's. -pub(crate) fn wallet_utxo_cursor_from_ffi( - cursor: &WalletUtxoCursorFFI, -) -> Result { - let spec = account_spec_from_raw_tags( - cursor.type_tag, - cursor.standard_tag, - cursor.index, - cursor.registration_index, - cursor.key_class, - cursor.user_identity_id, - cursor.friend_identity_id, - ); - let account_type = account_type_from_spec_ref(&spec)?; - Ok((account_type, dashcore::OutPoint::from(&cursor.outpoint))) -} - /// 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`]. @@ -1056,146 +1038,6 @@ pub(crate) unsafe fn release_wallet_utxo_entry(entry: &WalletUtxoEntryFFI) { } } -/// 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 platform_wallet::manager::accessors::{wallet_utxos_page, WalletUtxoCursor}; - - catch_read_panic("platform_wallet_wallet_utxos_page", || { - check_ptr!(wallet_id); - check_ptr!(out_rows); - check_ptr!(out_count); - check_ptr!(out_has_more); - *out_rows = std::ptr::null(); - *out_count = 0; - *out_has_more = false; - let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); - - let after: Option = if cursor.is_null() { - None - } else { - match wallet_utxo_cursor_from_ffi(&*cursor) { - Ok(after) => Some(after), - Err(e) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!("cursor: {e}"), - ); - } - } - }; - - let Some(wallet_manager) = - PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.wallet_manager_arc()) - else { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorInvalidHandle, - "Manager handle invalid".to_string(), - ); - }; - let (rows, has_more) = { - let wm = wallet_manager.blocking_read(); - wallet_utxos_page(&wm, &wid, after.as_ref(), limit) - }; - *out_has_more = has_more; - if rows.is_empty() { - return PlatformWalletFFIResult::ok(); - } - - let entries: Vec = - rows.into_iter().map(wallet_utxo_entry_ffi).collect(); - let count = entries.len(); - *out_rows = Box::into_raw(entries.into_boxed_slice()) as *const _; - *out_count = count; - PlatformWalletFFIResult::ok() - }) -} - -/// Release a page returned by `platform_wallet_wallet_utxos_page`. -/// -/// # Safety -/// `rows`/`count` must be exactly what one call returned, released once. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_wallet_utxos_page_free( - rows: *mut crate::core_wallet_types::WalletUtxoEntryFFI, - count: usize, -) { - if rows.is_null() || count == 0 { - return; - } - for entry in std::slice::from_raw_parts(rows, count) { - release_wallet_utxo_entry(entry); - } - let _ = Box::from_raw(std::ptr::slice_from_raw_parts_mut(rows, count)); -} - -/// Classify `count` store rows for `wallet_id` — see the -/// `OUTPOINT_CLASS_*` constants; `out_classes[i]` answers `queries[i]`, so -/// `out_classes` must have room for `count` bytes. A query whose account -/// tag this build cannot map keeps `OUTPOINT_CLASS_UNKNOWN` in its slot -/// and the remaining queries are still classified; the call fails only on -/// a bad handle or pointer. Cost is `count × accounts`, never the size of -/// the inventory. Same lock discipline as -/// `platform_wallet_wallet_utxos_page`. -/// -/// # Safety -/// `wallet_id` points at 32 readable bytes; `queries` points at `count` -/// live entries whose script pointers are valid for the call; -/// `out_classes` is writable for `count` bytes. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_classify_outpoints( - manager_handle: Handle, - wallet_id: *const u8, - queries: *const OutpointOwnershipQueryFFI, - count: usize, - out_classes: *mut u8, -) -> PlatformWalletFFIResult { - catch_read_panic("platform_wallet_classify_outpoints", || { - classify_outpoint_rows(wallet_id, queries, count, out_classes, |wid, owned| { - let wallet_manager = PLATFORM_WALLET_MANAGER_STORAGE - .with_item(manager_handle, |m| m.wallet_manager_arc()) - .ok_or_else(invalid_manager_handle)?; - let wm = wallet_manager.blocking_read(); - Ok(classify_outpoints(&wm, &wid, &owned)) - }) - }) -} - -/// Run a read-only diagnostics export's body with a panic turned into an -/// `ErrorWalletOperation` result instead of unwinding into the C caller. -fn catch_read_panic( - operation: &str, - body: impl FnOnce() -> PlatformWalletFFIResult, -) -> PlatformWalletFFIResult { - catch_panic_to_code( - operation, - PlatformWalletFFIResultCode::ErrorWalletOperation, - "The call only reads the engine; it can be retried.", - body, - ) -} - /// The error for a manager handle that is not live. pub(crate) fn invalid_manager_handle() -> PlatformWalletFFIResult { PlatformWalletFFIResult::err( @@ -1204,14 +1046,17 @@ pub(crate) fn invalid_manager_handle() -> PlatformWalletFFIResult { ) } -/// The marshalling `platform_wallet_classify_outpoints` and -/// `platform_wallet_txo_reconcile_classify_outpoints` share: 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`. An `Err` from `classify` is returned as is. +/// 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 -/// As `platform_wallet_classify_outpoints`. +/// `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. pub(crate) unsafe fn classify_outpoint_rows( wallet_id: *const u8, queries: *const OutpointOwnershipQueryFFI, diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index 7b94646503f..d02e66d6ba3 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -46,6 +46,26 @@ fn catch_reconcile_panic( ) } +/// [`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 @@ -141,12 +161,11 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( /// 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`, `*out_tip_height` is the scan -/// tip the run is measured against (the claim keeps it; the host only -/// reports it), and the claim holds until -/// `platform_wallet_manager_txo_reconcile_finish`. A skip holds no claim. -/// Either way the wallet's cadence restarts now, unless another run -/// already held it. +/// `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. /// /// On `RUN`, `*out_start` (a `TXO_RECONCILE_START_*`) says where the run /// starts, from where the wallet's last run stopped; for @@ -165,7 +184,6 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( manager_handle: Handle, wallet_id: *const u8, out_gate: *mut u8, - out_tip_height: *mut u32, out_start: *mut u8, out_store_after: *mut u8, out_store_after_len: *mut usize, @@ -173,12 +191,10 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( catch_reconcile_panic("platform_wallet_manager_txo_reconcile_begin", || { check_ptr!(wallet_id); check_ptr!(out_gate); - check_ptr!(out_tip_height); check_ptr!(out_start); check_ptr!(out_store_after); check_ptr!(out_store_after_len); *out_gate = TXO_RECONCILE_SKIP_WALLET_UNKNOWN; - *out_tip_height = 0; *out_start = TXO_RECONCILE_START_HEAL; *out_store_after_len = 0; let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); @@ -188,7 +204,6 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( match block_on_worker(async move { reconciler.begin(&wid).await }) { Ok(run) => { *out_gate = TXO_RECONCILE_GATE_RUN; - *out_tip_height = run.tip; *out_start = match run.start { TxoReconcileStart::Heal { resumed: false } => TXO_RECONCILE_START_HEAL, TxoReconcileStart::Heal { resumed: true } => TXO_RECONCILE_START_HEAL_RESUMED, @@ -233,17 +248,20 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_finish( }) } -/// One page of the wallet's UTXO inventory — the same walk and bounds as -/// `platform_wallet_wallet_utxos_page` — each row with its +/// 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 run's own tip (the one -/// `platform_wallet_manager_txo_reconcile_begin` returned), or to the +/// 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. Contact watch-only coins are never -/// offered. The rows are released by +/// 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 @@ -278,7 +296,7 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page( ) -> PlatformWalletFFIResult { check_ptr!(out_held); *out_held = std::ptr::null_mut(); - let result = catch_reconcile_panic("platform_wallet_txo_reconcile_heal_page", || { + catch_held_read_panic("platform_wallet_txo_reconcile_heal_page", out_held, || { check_ptr!(wallet_id); check_ptr!(out_rows); check_ptr!(out_count); @@ -327,14 +345,7 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page( *out_rows = Box::into_raw(entries.into_boxed_slice()) as *const _; *out_count = count; PlatformWalletFFIResult::ok() - }); - // A failure holds nothing, even one that struck after the guard was - // handed out. - if result.code != PlatformWalletFFIResultCode::Success { - platform_wallet_txo_reconcile_held_verdicts_free(*out_held); - *out_held = std::ptr::null_mut(); - } - result + }) } /// Release a page returned by `platform_wallet_txo_reconcile_heal_page`. @@ -368,11 +379,13 @@ pub struct TxoReconcileHeldVerdictsFFI { } /// Classify `count` store rows of `wallet_id` for the spend pass of the -/// reconcile run it holds a claim for — `platform_wallet_classify_outpoints`'s -/// answers and marshalling — 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. +/// 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 @@ -397,7 +410,7 @@ pub struct TxoReconcileHeldVerdictsFFI { /// run claim. /// /// # Safety -/// As `platform_wallet_classify_outpoints`: `wallet_id` points at 32 +/// `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 @@ -416,47 +429,44 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_outpoints( check_ptr!(out_budget_spent); *out_held = std::ptr::null_mut(); *out_budget_spent = false; - let result = catch_reconcile_panic("platform_wallet_txo_reconcile_classify_outpoints", || { - 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]) + 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(), + )), } - 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) - }); - // A failure holds nothing, even one that struck after the guard was - // handed out. - if result.code != PlatformWalletFFIResultCode::Success { - platform_wallet_txo_reconcile_held_verdicts_free(*out_held); - *out_held = std::ptr::null_mut(); - } - result + 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 @@ -619,7 +629,6 @@ mod tests { fn an_invalid_handle_is_an_error_and_leaves_the_out_params_initialized() { let wallet_id = [0u8; 32]; let mut gate = 0xFFu8; - let mut tip = 7u32; let mut start = 0xFFu8; let mut store_after = [0u8; TXO_RECONCILE_STORE_CURSOR_MAX]; let mut store_after_len = 9usize; @@ -628,7 +637,6 @@ mod tests { Handle::MAX, wallet_id.as_ptr(), &mut gate, - &mut tip, &mut start, store_after.as_mut_ptr(), &mut store_after_len, @@ -636,7 +644,6 @@ mod tests { }; assert_ne!(result.code, PlatformWalletFFIResultCode::Success); assert_eq!(gate, TXO_RECONCILE_SKIP_WALLET_UNKNOWN); - assert_eq!(tip, 0); assert_eq!(start, TXO_RECONCILE_START_HEAL); assert_eq!(store_after_len, 0); diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index 9576ea47010..b1389f98fe2 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -408,6 +408,7 @@ pub fn classify_outpoints( 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()]; @@ -478,13 +479,7 @@ pub fn classify_outpoints( if rest.is_empty() { return classes; } - let Some(chain_locked) = info - .core_wallet - .metadata - .last_applied_chain_lock - .as_ref() - .map(|lock| lock.block_height) - else { + 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; }; diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index 3947fabb188..dc9c2ebd4d4 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -96,6 +96,7 @@ 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}; @@ -130,11 +131,12 @@ pub const TXO_RECONCILE_MAX_STALE_RETRIES: u32 = 5; /// 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. With -/// [`crate::manager::accessors::WALLET_UTXO_PAGE_MAX`]-sized pages a wallet -/// of a few thousand coins still finishes a whole cycle in one run; a -/// larger one is covered over several, one budget per -/// [`TXO_RECONCILE_CADENCE`]. +/// 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, one budget per [`TXO_RECONCILE_CADENCE`]. pub const TXO_RECONCILE_PAGE_BUDGET: u32 = 16; /// The longest store cursor a host may hand @@ -159,8 +161,6 @@ pub enum TxoReconcileStart { /// A run's claim, as [`TxoReconciler::begin`] grants it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TxoReconcileRun { - /// The scan tip the run's gate measured. - pub tip: u32, /// Where the run starts. pub start: TxoReconcileStart, } @@ -183,6 +183,17 @@ enum Checkpoint { 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 { @@ -381,6 +392,19 @@ pub fn txo_heal_page( (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 @@ -396,7 +420,7 @@ pub fn txo_heal_reference_height( run_tip: u32, ) -> u32 { wm.get_wallet_info(wallet_id) - .map_or(0, |info| info.core_wallet.metadata.last_processed_height) + .map_or(0, |info| info.last_processed_height()) .min(run_tip) } @@ -657,7 +681,7 @@ impl<'a> ProvisionalClaim<'a> { claim.pages_left = TXO_RECONCILE_PAGE_BUDGET; claim.heal_after = heal_after; } - TxoReconcileRun { tip, start } + TxoReconcileRun { start } } } @@ -708,11 +732,11 @@ impl TxoReconciler { /// 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. Returns that scan - /// tip, which the claim keeps: the run's heal verdicts are measured - /// against it ([`Self::heal_page_held`]), so the host only reports it. - /// Also returns where the run starts ([`TxoReconcileStart`]), from the - /// wallet's checkpoint, and gives the run [`TXO_RECONCILE_PAGE_BUDGET`] + /// [`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. pub async fn begin(&self, wallet_id: &WalletId) -> Result { @@ -804,7 +828,8 @@ impl TxoReconciler { /// 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`]. + /// [`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. @@ -817,11 +842,8 @@ impl TxoReconciler { 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 = rows - .last() - .filter(|_| has_more) - .map(|(row, _)| (row.account_type, row.outpoint)); - ((rows, has_more), PendingPage::Heal(next)) + let next = heal_page_next(&rows, has_more); + ((rows, next.is_some()), PendingPage::Heal(next)) }) .await } @@ -835,12 +857,12 @@ impl TxoReconciler { self.page_applied(wallet_id, |claim| match claim.pending { Some(PendingPage::Heal(next)) => { claim.heal_after = next; - Some(Some(match next { + CheckpointMove::Set(match next { Some(after) => Checkpoint::Heal(after), None => Checkpoint::Classify(Vec::new()), - })) + }) } - _ => None, + _ => CheckpointMove::Ignore, }) } @@ -853,22 +875,22 @@ impl TxoReconciler { 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 => None, - Some(after) => Some(Some(Checkpoint::Classify(after.to_vec()))), - None => Some(None), + Some(after) if after.len() > TXO_RECONCILE_STORE_CURSOR_MAX => { + CheckpointMove::Ignore + } + Some(after) => CheckpointMove::Set(Checkpoint::Classify(after.to_vec())), + None => CheckpointMove::Clear, }, - _ => None, + _ => CheckpointMove::Ignore, }) } /// The body of the two `*_page_applied` calls: `advance` turns the - /// claim's pending page into the wallet's new checkpoint — `Some(None)` - /// ends the cycle, `None` means the page was not of that kind and - /// nothing moves. + /// claim's pending page into a [`CheckpointMove`] for the wallet. fn page_applied( &self, wallet_id: &WalletId, - advance: impl FnOnce(&mut ReconcileClaim) -> Option>, + 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 { @@ -877,14 +899,15 @@ impl TxoReconciler { let moved = advance(claim); claim.pending = None; let (forgotten, may_continue) = (claim.forgotten, claim.pages_left > 0); - match moved.filter(|_| !forgotten) { - Some(Some(checkpoint)) => { + match moved { + _ if forgotten => {} + CheckpointMove::Set(checkpoint) => { schedule.checkpoints.insert(*wallet_id, checkpoint); } - Some(None) => { + CheckpointMove::Clear => { schedule.checkpoints.remove(wallet_id); } - None => {} + CheckpointMove::Ignore => {} } may_continue } @@ -956,7 +979,7 @@ impl TxoReconciler { let synced_height = { let wm = self.wallet_manager.read().await; wm.get_wallet_info(wallet_id) - .map(|info| info.core_wallet.metadata.synced_height) + .map(|info| info.synced_height()) }; txo_reconcile_wallet_gate(synced_height, tip)?; Ok(tip) @@ -1240,6 +1263,35 @@ mod tests { 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 { @@ -1537,30 +1589,29 @@ mod tests { .await, Err(TxoReconcileSkip::WalletBehindTip) ); - assert_eq!( - reconciler - .claim_across(&A, async { Ok(TIP) }) - .await - .map(|run| run.tip), - Ok(TIP), + 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) }) - .await - .map(|run| run.tip), + 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, true); - assert_eq!( - reconciler - .claim_across(&A, async { Ok(TIP) }) - .await - .map(|run| run.tip), - Ok(TIP) - ); + 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 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 b1420af924e..344340a4b83 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -325,7 +325,11 @@ const DROPS_FINALIZED_RECORDS: bool = cfg!(not(feature = "keep-finalized-transac /// 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>(records: impl IntoIterator) -> Vec { +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()) @@ -338,7 +342,6 @@ fn owned_spends<'a>(records: impl IntoIterator) -> .map(|input| input.previous_output) }) }) - .collect() } // --------------------------------------------------------------------------- @@ -631,7 +634,7 @@ mod tests { .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)); + owned_spends(result.new_records.iter().chain(&result.updated_records)).collect(); named.sort(); coins.sort(); assert_eq!(named, coins); diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 9a3bac31e1b..7f8fa77b56b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -197,7 +197,7 @@ 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. The engine keeps its wallet lock held for reading when this + /// 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. @@ -407,10 +407,10 @@ enum CoreTxoReconcileStart: Equatable, Sendable { /// What `platform_wallet_manager_txo_reconcile_begin` answered. enum CoreTxoReconcileGate: Equatable, Sendable { - /// The run may start, measured against `tipHeight` (the native claim - /// keeps it; the host only reports it), at `start`; the wallet is - /// claimed until `platform_wallet_manager_txo_reconcile_finish`. - case run(tipHeight: UInt32, start: CoreTxoReconcileStart) + /// 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) } @@ -448,11 +448,7 @@ enum CoreTxoReconcileFFI { /// (`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] { - var array = IdentifierArray() - try platform_wallet_manager_txo_reconcile_due_wallets(handle, &array).check() - defer { platform_wallet_identifier_array_free(&array) } - guard array.items != nil, array.count > 0 else { return [] } - return (0.. 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.. do { @@ -487,8 +497,7 @@ extension PlatformWalletManager { guard case .page(let classes) = read else { // Not classified: this run walked none of the page, and // the next run reads it. - report.storeRows -= page.rows.count - report.unresolvedAccount -= page.unresolvedAccount + handedBack = true stopOnBudget(&report) return } @@ -540,8 +549,7 @@ extension PlatformWalletManager { // Same offset, not reported applied: nothing was // written, so the page is read again and classified // against the store as it is now. - report.storeRows -= page.rows.count - report.unresolvedAccount -= page.unresolvedAccount + handedBack = true continue } report.flipped += counts.flipped diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index be9ae85f219..38a9f8a3457 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -11524,9 +11524,6 @@ struct CoreTxoHealCounts: Sendable { /// Coins whose address has no row under the resolved account. var skippedMissingAddress = 0 var skippedInvalid = 0 - /// Outpoints written, logged once the step saved. - var healedOutpoints: [Data] = [] - var healedSpentOutpoints: [Data] = [] } /// Counts from one classify-apply step. @@ -11541,8 +11538,6 @@ struct CoreTxoFlipCounts: Sendable { /// 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 - /// Outpoints flipped, logged once the step saved. - var flippedOutpoints: [Data] = [] } extension PlatformWalletPersistenceHandler { @@ -11581,6 +11576,9 @@ extension PlatformWalletPersistenceHandler { guard !isCancelled() else { return .stopped } guard !inChangeset else { return .retryLater } var counts = CoreTxoHealCounts() + // 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 { @@ -11676,12 +11674,12 @@ 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 - counts.healedSpentOutpoints.append(outpoint) + healedSpentOutpoints.append(outpoint) continue } counts.inserted += 1 counts.insertedDuffs = counts.insertedDuffs.addingReportingOverflow(row.amount).0 - counts.healedOutpoints.append(outpoint) + healedOutpoints.append(outpoint) } guard counts.inserted + counts.healedSpent == 0 || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) @@ -11690,8 +11688,8 @@ extension PlatformWalletPersistenceHandler { } // Logged once the step saved, so a rolled-back step never claims // a row it did not write. - counts.healedOutpoints.forEach { logReconcileItem("healed", outpoint: $0, walletId: walletId) } - counts.healedSpentOutpoints.forEach { logReconcileItem("healed_spent", outpoint: $0, walletId: walletId) } + healedOutpoints.forEach { logReconcileItem("healed", outpoint: $0, walletId: walletId) } + healedSpentOutpoints.forEach { logReconcileItem("healed_spent", outpoint: $0, walletId: walletId) } return .done(counts) } } @@ -11814,6 +11812,8 @@ extension PlatformWalletPersistenceHandler { 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: @@ -11838,13 +11838,13 @@ extension PlatformWalletPersistenceHandler { removePendingInputs(for: row.outpoint) counts.flipped += 1 counts.flippedDuffs = counts.flippedDuffs.addingReportingOverflow(txo.amount).0 - counts.flippedOutpoints.append(row.outpoint) + flippedOutpoints.append(row.outpoint) } } guard counts.flipped == 0 || reconcileSave(operation: "txo_reconcile_flip", walletId: walletId) else { return .failed } - counts.flippedOutpoints.forEach { logReconcileItem("flipped_spent", outpoint: $0, walletId: walletId) } + flippedOutpoints.forEach { logReconcileItem("flipped_spent", outpoint: $0, walletId: walletId) } return .done(counts) } } 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/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift index 6b44f85ad78..155dcfcf363 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -21,7 +21,7 @@ final class FakeReconcileNative: @unchecked Sendable { init( engine: any CoreTxoReconcileEngine, holding: @escaping @Sendable () -> Bool, - gate: CoreTxoReconcileGate = .run(tipHeight: 2_535_898, start: .heal(resumed: false)) + gate: CoreTxoReconcileGate = .run(start: .heal(resumed: false)) ) { self.engine = engine self.holding = holding @@ -30,7 +30,7 @@ final class FakeReconcileNative: @unchecked Sendable { convenience init( engine: FakeCoreTxoEngine, - gate: CoreTxoReconcileGate = .run(tipHeight: 2_535_898, start: .heal(resumed: false)) + gate: CoreTxoReconcileGate = .run(start: .heal(resumed: false)) ) { self.init(engine: engine, holding: { engine.isHoldingVerdicts }, gate: gate) } @@ -342,7 +342,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { let (handler, container) = try makeHandler() try seedUnspentTxos(in: container, [0x62, 0x63]) let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x13)]) - let native = FakeReconcileNative(engine: engine, gate: .run(tipHeight: 2_400_001, start: .heal(resumed: false))) + 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. @@ -379,7 +379,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x18)]) let after = PlatformWalletManager.coreTxoStoreCursor(offset: 1) let native = FakeReconcileNative( - engine: engine, gate: .run(tipHeight: 2_400_002, start: .classify(after: after))) + engine: engine, gate: .run(start: .classify(after: after))) let outcome = try PlatformWalletManager.performCoreTxoReconcileRun( handle: 42, walletId: walletId, calls: native.calls(), handler: handler, pageSize: 4, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 9baa6c4cd32..be46aaa4eb2 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -163,8 +163,9 @@ final class FakeCoreTxoEngine: CoreTxoReconcileEngine, @unchecked Sendable { let start = min(_healAfter, _inventory.count) let end = min(start + limit, _inventory.count) let rows = start < end ? Array(_inventory[start.. Date: Thu, 5 Nov 2026 16:56:44 -0800 Subject: [PATCH 06/19] test(swift-sdk): file heal fixtures' addresses under their accounts The heal now inserts a coin only when its address is saved under the coin's account. The shutdown and privacy reconcile tests never saved an address row, so their heals inserted nothing and the insert and `action="healed"` assertions failed. Their fixtures now file the address under the BIP44 account, through a helper shared with CoreTxoReconcileTests. Tests that expect a stopped run to write nothing file it too, so their zero proves the stop rather than the address gate. Co-Authored-By: Claude Opus 5.5 --- .../CoreTxoReconcilePrivacyTests.swift | 3 + .../CoreTxoReconcileShutdownTests.swift | 20 +++++- .../CoreTxoReconcileTests.swift | 62 ++++++++++++------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcilePrivacyTests.swift index aa35a0e856f..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( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift index 155dcfcf363..dc3daebc271 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -69,6 +69,8 @@ final class FakeReconcileNative: @unchecked Sendable { @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 { @@ -105,7 +107,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { ) context.insert(tx) let row = PersistentTxo( - transaction: tx, vout: 0, amount: 19_549, address: "yShutdownFixtureAddr", + transaction: tx, vout: 0, amount: 19_549, address: fixtureAddress, scriptPubKey: fixtureScript, height: 2_391_743 ) row.account = account @@ -119,11 +121,18 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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()) } @@ -166,6 +175,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { func testACancelledRunDoesNothingAndSaysSo() throws { let (handler, container) = try makeHandler() + try fileAddress(in: container) let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x11)]) let report = PlatformWalletManager.runCoreTxoReconcile( @@ -181,6 +191,7 @@ 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() @@ -230,6 +241,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { /// normally. func testAStepDeferredBehindAnOpenRoundCompletesOnceTheRoundCloses() throws { let (handler, container) = try makeHandler() + try fileAddress(in: container) // Each deferred heal step reads its page again, a page of the // budget each time: room for however many the wait below allows. let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x31)], budget: 1_000) @@ -341,6 +353,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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() @@ -400,6 +413,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { /// 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() @@ -457,6 +471,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { /// 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() } @@ -542,6 +557,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") diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index be46aaa4eb2..27e9ea00d90 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -242,6 +242,38 @@ final class FakeCoreTxoEngine: CoreTxoReconcileEngine, @unchecked Sendable { } } +/// 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 store side of the reconcile /// (`PlatformWalletManager.runCoreTxoReconcile`) against a fake engine, /// driven exactly the way the manager drives it — engine reads on the @@ -334,36 +366,20 @@ final class CoreTxoReconcileTests: XCTestCase { return wallet } - /// Files the address row of `address` (the fixture address unless - /// given) 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. + /// `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 { - let walletId = walletId ?? self.walletId - let address = address ?? fixtureAddress - 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" + try fileCoreTxoReconcileAddress( + in: container, + walletId: walletId ?? self.walletId, + address: address ?? fixtureAddress, + underAccountType: accountType ) - if existing == nil { context.insert(row) } - row.account = account - try context.save() } /// An unspent, confirmed TXO row of `walletId` under its BIP44 account. From e8141d64df189d2a7e8c426c205fd2f21458e3f3 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 20:32:12 -0800 Subject: [PATCH 07/19] fix(swift-sdk): stop a heal step when its parent or pending-input read fails The heal looked up a coin's parent transaction with the forgiving fetchTransactionRow(txid:), so a failed read looked like a miss. It then inserted an empty stub with the same unique txid, and saving that stub could overwrite the stored transaction's bytes and confirmation context. The pending input drain had the same shape: a failed claim or spender read looked like "no claim" or "spender not here yet", so the heal could write a covered coin unspent and report the page applied. The heal now reads the parent, the coin's pending inputs and their spenders through throwing lookups, and fails the step on any error, like its other lookups. The context rolls back and the page is never reported applied. A real miss still inserts the stub as before. The throwing transaction lookup now goes through the modelFetcher seam; LiveModelFetcher is ModelContext.fetch, so other callers are unchanged. upsertUtxo and the other drain callers keep their forgiving reads. Three tests fault the parent, pending-input and spender reads in turn. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 115 +++++++++++++++--- .../CoreTxoReconcileTests.swift | 110 +++++++++++++++++ 2 files changed, 211 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 38a9f8a3457..2106ed4d1f3 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 } } @@ -3004,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 @@ -3045,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 @@ -3084,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 @@ -11638,8 +11702,22 @@ extension PlatformWalletPersistenceHandler { 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 @@ -11665,7 +11743,16 @@ extension PlatformWalletPersistenceHandler { record.isLocked = row.isLocked backgroundContext.insert(record) record.coreAddress = coreAddr - drainPendingInputs(into: record, resolvedWalletId: walletId) + do { + try applyPendingInputs( + pendingRows, + into: record, + resolvedWalletId: walletId, + resolveSpender: resolvePendingSpenderChecked + ) + } catch { + return reconcileReadFailed(walletId: walletId, error: error) + } if record.isSpent { // A pending-input claim or a swept tombstone already // covered this outpoint, so the drain wrote the row spent diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 27e9ea00d90..2021ff7f173 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -1021,6 +1021,116 @@ final class CoreTxoReconcileTests: XCTestCase { 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 From 8461945302bec9ff8c527381b4056f8347f93e34 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 5 Nov 2026 21:43:04 -0800 Subject: [PATCH 08/19] fix(platform-wallet): keep reconcile page guards until SPV has stopped shutdown released every engine guard a reconcile run still held before it stopped SPV. A host still writing a heal page could then lose its guard while SPV was live, a fork could remove the offered coin, and the delayed write would save it unspent; the classify pass answers Unknown for a coin the engine no longer owns, so the next launch restored it as spendable. shutdown now stops SPV first and force-releases the held guards only when that stop returns Ok, when nothing can apply a fork any more. A failed or timed-out stop leaves them to the host's own release; the report is already not clean. A host mid-write holds SPV teardown up to its deadline, which surfaces as a timed-out stop, not a hang. Wallet removal never released a guard, and FFI and JNI destroy go through shutdown. Tests: the guard is still held while SPV stops, a failed stop leaves it held, and a real manager with SPV never started shuts down and releases it. Co-Authored-By: Claude Opus 5.5 --- .../src/txo_reconcile.rs | 7 +- .../rs-platform-wallet/src/manager/mod.rs | 24 ++- .../src/manager/txo_reconcile.rs | 163 ++++++++++++++++-- 3 files changed, 172 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index d02e66d6ba3..f9437338fa3 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -393,8 +393,11 @@ pub struct TxoReconcileHeldVerdictsFFI { /// `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 and manager destroy release a guard the host -/// failed to, as a backstop. Call this outside the host's +/// 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 diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index d31f46c5a47..61ce479b798 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -1138,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 @@ -1166,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`], @@ -1177,15 +1184,20 @@ impl PlatformWalletManager

{ /// /// [`WorkerStatus`]: dash_async::WorkerStatus pub async fn shutdown(&self) -> ShutdownReport { - // A reconcile's engine guard is held across one host write and - // released by the host; one it failed to release must not hold the - // writers below off. - self.release_txo_reconcile_holds(); // SPV first: it is the event source feeding everything below. Its // `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 index dc9c2ebd4d4..bd46d730ead 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -101,6 +101,7 @@ 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, @@ -457,7 +458,8 @@ pub struct TxoReconcileSchedule { 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, and the +/// 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>>; @@ -761,8 +763,9 @@ impl TxoReconciler { /// ([`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`] and the manager's - /// shutdown release it too. Only [`OutpointClass::KnownUncredited`] + /// 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. /// @@ -1015,20 +1018,57 @@ impl PlatformWalletManager

{ lock_schedule(&self.txo_reconcile_schedule).forget(wallet_id); } - /// Release every engine guard a reconcile run still holds, so a host - /// that failed to release one cannot hold teardown's writers off. - pub(super) fn release_txo_reconcile_holds(&self) { - let schedule = lock_schedule(&self.txo_reconcile_schedule); - for slot in schedule - .in_flight - .values() - .filter_map(|claim| claim.held.as_ref()) - { - release_slot(slot); - } + /// 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::*; @@ -2042,6 +2082,101 @@ mod tests { 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, true); + } + + /// 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, true); + } + + /// 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, false); + } + /// 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 From 7a9f8da28935a3d0dcb01921dc8375c8ec0bcaf5 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:01:14 -0800 Subject: [PATCH 09/19] fix(platform-wallet): keep only chain-locked records' inputs as finalized spends check_core_transaction gated on the incoming transaction's context and then kept the owned inputs of every new and updated record. When a funding arrives after its spender, the engine's attribute_late_inputs hands the already recorded spender back in updated_records with its own context unchanged, possibly mempool. A ChainLocked funding thus filed a mempool spender's input in finalized_spends for good, and classify_outpoints answered KnownUncredited for a coin whose spend is not final. Each record is now filtered by its own context: only a record in a ChainLocked block is dropped by the engine, so only its inputs are kept. The incoming-context gate is redundant with that and goes. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/platform_wallet_traits.rs | 83 ++++++++++++++++++- 1 file changed, 79 insertions(+), 4 deletions(-) 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 344340a4b83..e8e0e02892f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -359,17 +359,24 @@ impl WalletTransactionChecker for PlatformWalletInfo { update_balance: bool, ) -> TransactionCheckResult { // TODO: some logic must here - restore - let chain_locked = context.is_chain_locked(); let result = self .core_wallet .check_core_transaction(tx, context, wallet, update_state, update_balance) .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. - if DROPS_FINALIZED_RECORDS && chain_locked && update_state && result.is_relevant { + // 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), + result + .new_records + .iter() + .chain(&result.updated_records) + .filter(|record| record.context.is_chain_locked()), )); } result @@ -584,6 +591,74 @@ mod tests { } } + /// 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. From bc0582b79e85d93b52c75f42b7181596d9186111 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:04:46 -0800 Subject: [PATCH 10/19] fix(platform-wallet): keep every wallet due across a new steady state until claimed due_wallets reported all wallets only on the one poll that saw a new steady session (a one-shot `rising` flag), and updated the session for wallets the host did not claim. A recently run wallet the host skipped on that first poll was then not reported again until its 30-minute cadence came round, contrary to "reporting a wallet consumes nothing". A new steady session now clears every wallet's last run instead, and the filter is the cadence alone: each wallet stays due until try_begin stamps it. A run in flight across the edge still stamps its own finish when it went ahead, and a forgotten wallet's run still records nothing. Co-Authored-By: Claude Opus 5.5 --- .../src/manager/txo_reconcile.rs | 78 ++++++++++++++++--- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index bd46d730ead..fdb6f77a2e1 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -539,12 +539,13 @@ 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 — all of `wallets` on the rising edge into steady state, - /// and afterwards each wallet whose last run is at least - /// [`TXO_RECONCILE_CADENCE`] old or that never ran — never one already - /// in flight. Reporting a wallet consumes nothing: its cadence restarts - /// only when a run claims it ([`Self::try_begin`]), so a wallet the host - /// cannot run yet is reported again on the next poll. + /// 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`]), 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, @@ -556,16 +557,21 @@ impl TxoReconcileSchedule { self.steady_session = None; return Vec::new(); } - let rising = self.steady_session != Some(spv_session); - self.steady_session = Some(spv_session); + 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| { - rising - || self.last_run.get(wallet_id).is_none_or(|last| { - now.saturating_duration_since(*last) >= TXO_RECONCILE_CADENCE - }) + self.last_run.get(wallet_id).is_none_or(|last| { + now.saturating_duration_since(*last) >= TXO_RECONCILE_CADENCE + }) }) .collect() } @@ -1446,6 +1452,54 @@ mod tests { .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, Some(t0 + 2 * second)); + schedule.finish(&B, None); + 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 From f6849b44e35174309db7b92ba538e9d50afba4dc Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:14:12 -0800 Subject: [PATCH 11/19] fix(swift-sdk): refuse a second reconcile request for a wallet already in flight Reconcile runs execute one at a time on the serial coreTxoReconcileQueue, so a second request for the same wallet waited behind the first and then ran in full: the engine's own one-run-per-wallet check never saw the two overlap, and begin does not ask whether the wallet is due. The base answered such a request alreadyRunning from coreTxoReconcileInFlight; moving the policy into Rust dropped that set. Bring it back, checked before the queue hop and cleared on return. Co-Authored-By: Claude Opus 5.5 --- .../CoreTxoReconcileTypes.swift | 7 +++- .../PlatformWalletManager.swift | 7 ++++ .../PlatformWalletManagerTxoReconcile.swift | 14 ++++++- .../CoreTxoReconcileShutdownTests.swift | 41 +++++++++++++++++++ 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index 7f8fa77b56b..b0a2aa6d9cc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -325,8 +325,9 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { } /// Why `reconcileCoreTxoStore(for:)` did not run. Every reason but -/// `notConfigured`, `shutdownRequested` and a wallet this manager has not -/// loaded is `platform-wallet`'s start gate answering +/// `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 @@ -334,6 +335,8 @@ public enum CoreTxoReconcileSkipReason: String, Sendable { /// 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 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 016b127553e..c0850fdea1c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -733,6 +733,13 @@ public class PlatformWalletManager: ObservableObject { /// 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 = [] /// Test seam for the native calls that bracket a reconcile run; same /// contract as [`nativeTeardownCalls`]. internal var nativeTxoReconcileCalls = PlatformWalletNativeTxoReconcileCalls.live diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index f74278e4761..de1c65014e5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -76,7 +76,11 @@ extension PlatformWalletManager { /// 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 and a wallet this manager has not loaded, checked here first. + /// 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. /// /// 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 @@ -108,6 +112,14 @@ 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) + } + coreTxoReconcileInFlight.insert(walletId) + defer { coreTxoReconcileInFlight.remove(walletId) } let epoch = coreTxoReconcileEpoch let generation = epoch.current() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift index dc3daebc271..18d7155b567 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -544,6 +544,47 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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)) + XCTAssertEqual(native.begins.count, 1, "the refused request never reached the queue") + + 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() From 1aa83f1139a33c387704cd766ec1bdcd2b540fe8 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:14:13 -0800 Subject: [PATCH 12/19] fix(swift-sdk): read only the wallet's own unspent rows in the classify pass The classify read walked every wallet's unspent rows and dropped other wallets' after the fetch. With the engine's page budget per run, other wallets' rows ahead of this wallet's could spend several runs' budgets before the walk reached it. Scope the read to rows stamped with the wallet's id plus legacy rows stamped with none; the account post-filter still sorts the legacy ones. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 23 +++++--- .../CoreTxoReconcileTests.swift | 52 ++++++++++++++++--- 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 2106ed4d1f3..80a82897699 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -11785,11 +11785,15 @@ extension PlatformWalletPersistenceHandler { /// `offset`, at most `limit`, each with the query the engine classifies /// 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. 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`. + /// 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, @@ -11799,8 +11803,15 @@ extension PlatformWalletPersistenceHandler { 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 diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 2021ff7f173..2b51e804934 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -390,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) @@ -415,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) @@ -1627,14 +1630,16 @@ final class CoreTxoReconcileTests: XCTestCase { } /// The classify pass reads every store page as a page of the run, an - /// empty one too: here the first page holds only another wallet's rows, - /// and the second, read because the first was full, holds none. + /// 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)) - try seedUnspentTxo(in: container, walletId: otherWalletId, txid: txid(0x71)) + 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) @@ -1650,6 +1655,41 @@ final class CoreTxoReconcileTests: XCTestCase { ]) } + /// 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. From 044ba88d35f6dbc11aa971dab0a88c63ab72f7dc Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:17:17 -0800 Subject: [PATCH 13/19] fix(platform-wallet-ffi): report an invalid manager handle as such on every reconcile export due_wallets, begin, finish, heal_page_applied and classify_page_applied resolved the manager through unwrap_option_or_return!, which answers a dead handle with NotFound, while the heal and classify reads answer it with ErrorInvalidHandle. One failure had two codes across the same run. All five now return invalid_manager_handle(), and the invalid-handle test pins ErrorInvalidHandle on each of them, finish included. Co-Authored-By: Claude Opus 5.5 --- .../src/txo_reconcile.rs | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index f9437338fa3..f2137436e8d 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -22,7 +22,6 @@ use crate::manager_diagnostics::{ }; use crate::runtime::block_on_worker; use crate::types::IdentifierArray; -use crate::unwrap_option_or_return; /// What a host should know after a reconcile export panicked: nothing was /// written to its store by this call. @@ -150,9 +149,11 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( *out_wallet_ids = IdentifierArray::empty(); // Looked up under the registry guard, waited on outside it — see // `platform_wallet_manager_sync_progress`. - let option = - PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); - let reconciler = unwrap_option_or_return!(option); + 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() @@ -198,9 +199,11 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( *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 option = - PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| m.txo_reconciler()); - let reconciler = unwrap_option_or_return!(option); + 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; @@ -241,9 +244,11 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_finish( 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 option = PLATFORM_WALLET_MANAGER_STORAGE - .with_item(manager_handle, |m| m.txo_reconciler().finish(&wid, ran)); - unwrap_option_or_return!(option); + let Some(()) = PLATFORM_WALLET_MANAGER_STORAGE + .with_item(manager_handle, |m| m.txo_reconciler().finish(&wid, ran)) + else { + return invalid_manager_handle(); + }; PlatformWalletFFIResult::ok() }) } @@ -509,10 +514,12 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_applied( check_ptr!(out_may_continue); *out_may_continue = false; let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); - let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + let Some(may_continue) = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { m.txo_reconciler().heal_page_applied(&wid) - }); - *out_may_continue = unwrap_option_or_return!(option); + }) else { + return invalid_manager_handle(); + }; + *out_may_continue = may_continue; PlatformWalletFFIResult::ok() }) } @@ -556,11 +563,15 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_page_applied( check_ptr!(next_after); Some(std::slice::from_raw_parts(next_after, next_after_len).to_vec()) }; - let option = PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { - m.txo_reconciler() - .classify_page_applied(&wid, after.as_deref()) - }); - *out_may_continue = unwrap_option_or_return!(option); + let Some(may_continue) = + PLATFORM_WALLET_MANAGER_STORAGE.with_item(manager_handle, |m| { + m.txo_reconciler() + .classify_page_applied(&wid, after.as_deref()) + }) + else { + return invalid_manager_handle(); + }; + *out_may_continue = may_continue; PlatformWalletFFIResult::ok() }, ) @@ -645,7 +656,7 @@ mod tests { &mut store_after_len, ) }; - assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + 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); @@ -658,7 +669,7 @@ mod tests { &mut may_continue, ) }; - assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); assert!(!may_continue); let mut may_continue = true; let result = unsafe { @@ -671,13 +682,18 @@ mod tests { &mut may_continue, ) }; - assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); assert!(!may_continue); + let result = unsafe { + platform_wallet_manager_txo_reconcile_finish(Handle::MAX, wallet_id.as_ptr(), true) + }; + 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_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); assert!(ids.items.is_null()); assert_eq!(ids.count, 0); From c8294d9139f4e9ebe5781074bc58f60abf574011 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:18:14 -0800 Subject: [PATCH 14/19] perf(platform-wallet-ffi): borrow the host's store cursor instead of copying it classify_page_applied copied the host's cursor with to_vec() at whatever length the host passed, before the reconciler dropped anything over TXO_RECONCILE_STORE_CURSOR_MAX bytes. The reconciler takes a &[u8], so the slice is now borrowed for the call; the null check and the zero-length and is_last cases are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/txo_reconcile.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index f2137436e8d..a16469af2d3 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -555,18 +555,21 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_page_applied( check_ptr!(out_may_continue); *out_may_continue = false; let wid: [u8; 32] = std::ptr::read(wallet_id as *const [u8; 32]); - let after: Option> = if is_last { + // Borrowed, not copied: the reconciler drops a cursor longer + // than TXO_RECONCILE_STORE_CURSOR_MAX, so an oversized one costs + // no allocation. + let after: Option<&[u8]> = if is_last { None } else if next_after_len == 0 { - Some(Vec::new()) + Some(&[]) } else { check_ptr!(next_after); - Some(std::slice::from_raw_parts(next_after, next_after_len).to_vec()) + 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.as_deref()) + .classify_page_applied(&wid, after) }) else { return invalid_manager_handle(); From 03c6fd31a0908e9f442608d9fb2b8b9a03891131 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:18:33 -0800 Subject: [PATCH 15/19] docs(platform-wallet-ffi): say KNOWN_UNCREDITED needs a ChainLocked spender The OUTPOINT_CLASS_KNOWN_UNCREDITED doc described the coin as merely absent with its funding known, which is the shape classify_outpoints deliberately does not trust. The class also requires a spender in a block the wallet's ChainLock covers (or one the ChainLock already dropped); a coin whose spender is not final yet is OUTPOINT_CLASS_UNKNOWN. The doc now says so, matching OutpointClass in platform-wallet. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/core_wallet_types.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index d11957c1b0d..5e5c28d9546 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -2362,9 +2362,13 @@ pub const OUTPOINT_CLASS_UNKNOWN: u8 = 0; /// account's live UTXO set. pub const OUTPOINT_CLASS_UNSPENT: u8 = 1; /// `platform_wallet_txo_reconcile_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. +/// 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_txo_reconcile_classify_outpoints` answer: the owning account's pools /// do not monitor the script; the engine could never have credited it. From 728502eed4313cbeef514262c438485c5824459d Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:29:44 -0800 Subject: [PATCH 16/19] fix(platform-wallet): forget a wallet's reconcile schedule while it still holds the id remove_wallet forgot the reconcile schedule after the inner-manager removal had freed the id, even when a same-id generation had been registered in that window: it wiped the successor's checkpoint and marked its in-flight claim forgotten, so the successor's applied pages and finish recorded nothing. The load rollback never forgot the schedule at all. Both now forget it just before remove_from_wallet_manager, under the inner manager's write lock, like every other id-keyed teardown. A run of the removed wallet still in flight is marked forgotten as before; one claimed afterwards finds the wallet gone at its gate. The remove-versus- recreate test now has the recreated wallet hold a run across the removal and checks its applied page is kept. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/manager/load.rs | 3 ++ .../src/manager/wallet_lifecycle.rs | 42 +++++++++++++++++-- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index f9eb0af1fc4..0dbae0ab3f6 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -645,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/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 19587e37c6f..e8105259ea0 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -992,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), @@ -1053,10 +1060,6 @@ impl PlatformWalletManager

{ } } - // A same-id wallet added later is reconciled at once rather than - // waiting out the removed one's cadence. - self.forget_txo_reconcile(wallet_id); - // Still under the generation's teardown gate: any deferred state naming // this generation is dropped in the same critical section as the removal // itself, so no payment operation can observe the wallet as live and then @@ -2502,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); }) })); @@ -2587,5 +2599,27 @@ 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, true); + 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, false); } } From 7d27936c4d18539ec732e501959f0eae3f02de39 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sat, 7 Nov 2026 19:42:12 -0800 Subject: [PATCH 17/19] style(platform-wallet): rustfmt the review fixes cargo fmt over the finalized-spends regression test, the steady-state schedule tests and the invalid-handle lookups in the reconcile FFI. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/txo_reconcile.rs | 7 +++---- .../src/manager/txo_reconcile.rs | 4 +++- .../src/wallet/platform_wallet_traits.rs | 14 ++++---------- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index a16469af2d3..ae17e117c1d 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -566,10 +566,9 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_page_applied( check_ptr!(next_after); 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) + 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(); diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index fdb6f77a2e1..1348ed2b0ff 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -1487,7 +1487,9 @@ mod tests { assert!(schedule.try_begin(A, t0)); assert!(schedule.try_begin(B, t0)); assert!( - schedule.due_wallets(true, 2, [A, B], t0 + second).is_empty(), + 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 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 e8e0e02892f..92b64b01522 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -637,11 +637,9 @@ mod tests { .await; assert!(result.is_relevant); assert!( - result - .updated_records - .iter() - .any(|record| record.txid == spender.txid() - && !record.context.is_chain_locked()), + 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!( @@ -650,11 +648,7 @@ mod tests { ); } assert_eq!( - classify_outpoints( - &wm, - &wallet_id, - &[query(bip44_account_0(), coin, &script)] - ), + classify_outpoints(&wm, &wallet_id, &[query(bip44_account_0(), coin, &script)]), vec![crate::manager::accessors::OutpointClass::Unknown] ); } From 640c75de4b5344c4bd625e308364809c6fcbddae Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 8 Nov 2026 07:18:18 -0800 Subject: [PATCH 18/19] fix(platform-wallet): keep an unfinished or deferred reconcile cycle due A run that spent its 16-page budget mid-cycle, or that stopped because the host's store had a persistence round open, now leaves the wallet due on the next poll instead of restarting the 30-minute cadence. A cycle larger than one budget finishes within seconds of consecutive runs, so a short app session no longer loses it, and the host no longer owns a retry policy. - finish takes an outcome (NotRun / Ran / Deferred); C ABI `outcome: u8` with TXO_RECONCILE_FINISH_* (unknown codes count as Ran). - Ran with the budget spent, its last page applied and a checkpoint left removes the stamp; Deferred always does. - A begin refused with NotSteadyState no longer keeps the claim's stamp, so a new SPV session's run isn't pushed back by 30 minutes. - Swift drops both open-round retry loops and their constants: a deferred store step stops the run unapplied and finishes Deferred. - The FFI never forms a slice for a classify cursor longer than the store cursor maximum. - Fix testASecondRequestWhileTheFirstIsInFlightIsRefused: the fake records a begin only after onBegin returns. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/manager.rs | 3 +- .../src/txo_reconcile.rs | 151 ++++++- .../src/manager/txo_reconcile.rs | 410 +++++++++++++++--- .../src/manager/wallet_lifecycle.rs | 10 +- .../CoreTxoReconcileTypes.swift | 50 ++- .../PlatformWalletManagerTxoReconcile.swift | 155 ++++--- .../PlatformWalletPersistenceHandler.swift | 11 +- .../CoreTxoReconcileShutdownTests.swift | 106 +++-- .../CoreTxoReconcileTests.swift | 109 +++-- 9 files changed, 757 insertions(+), 248 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 7bf6fd5ff8e..2f1c65f59ec 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -1587,6 +1587,7 @@ mod tests { 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(); @@ -1665,7 +1666,7 @@ mod tests { assert_eq!(out, [0, 0, 0]); assert!(!held.is_null()); unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; - reconciler.finish(&wallet_id, false); + 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/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index ae17e117c1d..9082f590e72 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -9,7 +9,8 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use platform_wallet::manager::accessors::OutpointClass; use platform_wallet::manager::txo_reconcile::{ - txo_reconcile_retry_stale_page, TxoReconcileHeldVerdicts, TxoReconcileRead, TxoReconcileStart, + txo_reconcile_retry_stale_page, TxoReconcileFinish, TxoReconcileHeldVerdicts, TxoReconcileRead, + TxoReconcileStart, }; use crate::check_ptr; @@ -86,6 +87,20 @@ pub const TXO_RECONCILE_SKIP_SYNC_FAULT_DETECTED: u8 = 5; /// 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; @@ -166,7 +181,9 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( /// `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. +/// 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. /// /// On `RUN`, `*out_start` (a `TXO_RECONCILE_START_*`) says where the run /// starts, from where the wallet's last run stopped; for @@ -226,9 +243,15 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( } /// Release the claim a `RUN` from -/// `platform_wallet_manager_txo_reconcile_begin` granted. `ran` is whether -/// the run went ahead (even if it stopped early), which restarts the -/// wallet's cadence. Also releases, as a backstop, an engine guard the +/// `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`). @@ -239,13 +262,14 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_begin( pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_finish( manager_handle: Handle, wallet_id: *const u8, - ran: bool, + 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, ran)) + .with_item(manager_handle, |m| m.txo_reconciler().finish(&wid, outcome)) else { return invalid_manager_handle(); }; @@ -253,6 +277,16 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_finish( }) } +/// 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 @@ -530,7 +564,8 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_heal_page_applied( /// 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 not kept), handed back by +/// 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. @@ -557,14 +592,23 @@ pub unsafe extern "C" fn platform_wallet_txo_reconcile_classify_page_applied( 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. + // 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); - Some(std::slice::from_raw_parts(next_after, next_after_len)) + 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| { @@ -626,6 +670,19 @@ mod tests { 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), @@ -688,7 +745,11 @@ mod tests { assert!(!may_continue); let result = unsafe { - platform_wallet_manager_txo_reconcile_finish(Handle::MAX, wallet_id.as_ptr(), true) + platform_wallet_manager_txo_reconcile_finish( + Handle::MAX, + wallet_id.as_ptr(), + TXO_RECONCILE_FINISH_RAN, + ) }; assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorInvalidHandle); @@ -808,7 +869,7 @@ mod tests { }; assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert!(may_continue); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); let run = reconciler .claim_without_gate(&wallet_id, 0) @@ -859,7 +920,67 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert!(spent); assert!(held.is_null() && rows.is_null() && !has_more); - reconciler.finish(&wallet_id, true); + 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); } @@ -918,7 +1039,7 @@ mod tests { unsafe { platform_wallet_txo_reconcile_held_verdicts_free(held) }; assert!(engine.try_write().is_ok()); - reconciler.finish(&wallet_id, false); + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(handle)); } @@ -1095,7 +1216,7 @@ mod tests { assert!(reader.join().expect("registry reader").is_some()); let other = registry_writer.join().expect("registry writer"); - reconciler.finish(&wallet_id, false); + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(other)); drop(PLATFORM_WALLET_MANAGER_STORAGE.remove(handle)); } diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index 1348ed2b0ff..ebf09eb8767 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -9,7 +9,8 @@ //! //! - **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; +//! [`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 @@ -30,7 +31,8 @@ //! 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`]). +//! 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 @@ -137,7 +139,9 @@ pub const TXO_RECONCILE_MAX_STALE_RETRIES: u32 = 5; /// [`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, one budget per [`TXO_RECONCILE_CADENCE`]. +/// 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 @@ -293,6 +297,33 @@ impl TxoReconcileSkip { } } +/// 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. @@ -446,8 +477,12 @@ pub struct TxoReconcileSchedule { steady_session: Option, in_flight: BTreeMap, last_run: BTreeMap, - /// Where each wallet's next run picks up. In memory: a fresh process - /// starts a new cycle, which the budget still bounds. + /// 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, @@ -544,8 +579,9 @@ impl TxoReconcileSchedule { /// 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`]), so a wallet the host cannot run - /// yet is reported again on the next poll, on the edge or not. + /// 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, @@ -579,7 +615,9 @@ impl TxoReconcileSchedule { /// 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. + /// poll — except a refusal for [`TxoReconcileSkip::NotSteadyState`], + /// whose release ([`TxoReconcileFinish::Deferred`]) leaves the wallet + /// due for the steady state to come. pub fn try_begin(&mut self, wallet_id: WalletId, now: Instant) -> bool { if self.in_flight.contains_key(&wallet_id) { return false; @@ -594,20 +632,38 @@ impl TxoReconcileSchedule { true } - /// Release the claim [`Self::try_begin`] took. `ran_at` is when a run - /// that went ahead finished, which restarts the wallet's cadence from - /// then; `None` for one that did not. 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, ran_at: Option) { + /// 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 let Some(at) = ran_at.filter(|_| !claim.forgotten) { - self.last_run.insert(*wallet_id, at); + 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); + } } } @@ -693,22 +749,31 @@ impl<'a> ProvisionalClaim<'a> { } } -impl Drop for ProvisionalClaim<'_> { - fn drop(&mut self) { +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, None); + 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. @@ -747,6 +812,9 @@ impl TxoReconciler { /// 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. pub async fn begin(&self, wallet_id: &WalletId) -> Result { self.claim_across(wallet_id, self.gate(wallet_id)).await } @@ -759,9 +827,16 @@ impl TxoReconciler { wallet_id: &WalletId, gate: impl Future>, ) -> Result { - let claim = ProvisionalClaim::take(&self.schedule, *wallet_id, Instant::now()) + let mut claim = ProvisionalClaim::take(&self.schedule, *wallet_id, Instant::now()) .ok_or(TxoReconcileSkip::AlreadyRunning)?; - let tip = gate.await?; + let tip = gate.await.inspect_err(|skip| { + // Not steady yet: no stamp, so the wallet is due as soon as it + // is (due_wallets reports nothing until then, so no poll loop). + // Every other refusal keeps the claim's stamp when it drops. + if *skip == TxoReconcileSkip::NotSteadyState { + claim.release(TxoReconcileFinish::Deferred); + } + })?; Ok(claim.hand_over(tip)) } @@ -974,10 +1049,13 @@ impl TxoReconciler { } /// Release the claim [`Self::begin`] granted, and the engine guard it - /// may still hold. `ran` is whether the run went ahead (even if it - /// stopped early): that restarts the wallet's cadence from now. - pub fn finish(&self, wallet_id: &WalletId, ran: bool) { - lock_schedule(&self.schedule).finish(wallet_id, ran.then(Instant::now)); + /// 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 { @@ -1355,7 +1433,7 @@ mod tests { 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, Some(at)); + schedule.finish(wallet_id, TxoReconcileFinish::Ran, at); } } @@ -1398,7 +1476,7 @@ mod tests { // 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, Some(a_ran)); + schedule.finish(&A, TxoReconcileFinish::Ran, a_ran); assert!(schedule .due_wallets(true, 1, [A, B], t0 + 2 * TXO_RECONCILE_CADENCE) .is_empty()); @@ -1422,7 +1500,7 @@ mod tests { vec![B], "not even on the rising edge" ); - schedule.finish(&A, None); + schedule.finish(&A, TxoReconcileFinish::NotRun, Instant::now()); assert!(schedule.try_begin(A, t0)); } @@ -1494,8 +1572,8 @@ mod tests { ); // 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, Some(t0 + 2 * second)); - schedule.finish(&B, None); + 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] @@ -1510,7 +1588,7 @@ mod tests { let t0 = Instant::now(); assert_eq!(schedule.due_wallets(true, 1, [A], t0), vec![A]); assert!(schedule.try_begin(A, t0)); - schedule.finish(&A, None); + schedule.finish(&A, TxoReconcileFinish::NotRun, Instant::now()); assert!(schedule .due_wallets(true, 1, [A], t0 + Duration::from_secs(1)) .is_empty()); @@ -1591,14 +1669,14 @@ mod tests { "the old run holds the wallet until it exits" ); assert!(schedule.due_wallets(true, 1, [A], t0 + second).is_empty()); - schedule.finish(&A, Some(t0 + 2 * second)); + 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, Some(t0 + 4 * second)); + schedule.finish(&A, TxoReconcileFinish::Ran, t0 + 4 * second); assert!( schedule .due_wallets(true, 1, [A], t0 + 5 * second) @@ -1651,7 +1729,7 @@ mod tests { reconciler.begin(&A).await, Err(TxoReconcileSkip::AlreadyRunning) ); - reconciler.finish(&A, true); + reconciler.finish(&A, TxoReconcileFinish::Ran); assert_eq!( reconciler.begin(&A).await, Err(TxoReconcileSkip::NotSteadyState) @@ -1702,7 +1780,7 @@ mod tests { "the granted claim outlives the begin that took it" ); assert_eq!(claim_tip(), Some(TIP), "a refused claim leaves the tip"); - reconciler.finish(&A, true); + reconciler.finish(&A, TxoReconcileFinish::Ran); assert!(reconciler .claim_across(&A, async { Ok(TIP + 1) }) .await @@ -1736,18 +1814,23 @@ mod tests { !holds_engine(&reconciler, &A), "a refused read holds nothing" ); - reconciler.finish(&A, true); + 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, true); + 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..54 { + for seed in 51u8..51 + count { assert!( ctx.check_transaction(&funding(script.clone(), seed, 3_000), in_block(100_000)) .await @@ -1789,7 +1872,7 @@ mod tests { 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, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); assert_eq!( start(&reconciler), @@ -1804,7 +1887,7 @@ mod tests { 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, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); assert_eq!( start(&reconciler), @@ -1812,7 +1895,7 @@ mod tests { ); drop(classify(&reconciler, &wallet_id, &[]).await); assert!(reconciler.classify_page_applied(&wallet_id, Some(b"store cursor"))); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); assert_eq!( start(&reconciler), @@ -1822,13 +1905,228 @@ mod tests { ); drop(classify(&reconciler, &wallet_id, &[]).await); assert!(reconciler.classify_page_applied(&wallet_id, None)); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); assert_eq!( start(&reconciler), TxoReconcileStart::Heal { resumed: false } ); - reconciler.finish(&wallet_id, true); + 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 page reported applied moves nothing when it is not the kind of @@ -1866,7 +2164,7 @@ mod tests { None, "a removed wallet's run records nothing" ); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); } /// The ChainLock the fork tests park on the wallet. @@ -1974,7 +2272,7 @@ mod tests { !holds_engine(&reconciler, &A), "dropping the verdicts released the guard" ); - reconciler.finish(&A, true); + reconciler.finish(&A, TxoReconcileFinish::Ran); } /// A heal page is held like a classification: it needs the run's claim, @@ -1999,7 +2297,7 @@ mod tests { .await .expect("the fork proceeds once the page is released") .expect("fork task"); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); } /// A provisional claim that a stray finish released while its gate @@ -2016,14 +2314,14 @@ mod tests { }; let stale = ProvisionalClaim::take(&schedule, A, Instant::now()).expect("claimed"); - lock_schedule(&schedule).finish(&A, None); + 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, None); + lock_schedule(&schedule).finish(&A, TxoReconcileFinish::NotRun, Instant::now()); let stale = ProvisionalClaim::take(&schedule, A, Instant::now()).expect("claimed"); - lock_schedule(&schedule).finish(&A, None); + 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"); @@ -2063,7 +2361,7 @@ mod tests { TxoHealVerdict::Immature, "the run's tip caps the depth, whatever the engine processed" ); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); assert!(reconciler.claim_without_gate(&wallet_id, TIP).is_some()); assert_eq!( @@ -2082,7 +2380,7 @@ mod tests { TxoHealVerdict::Immature, "the fork lowered the height the next page is measured against" ); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); } /// The race the guard closes: verdicts read, then a fork drops a @@ -2130,7 +2428,7 @@ mod tests { let mut fork = spawn_fork(&reconciler); assert!(timeout(BLOCKED, &mut fork).await.is_err()); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); timeout(PROCEEDS, fork) .await .expect("the fork proceeds once the run finished") @@ -2166,7 +2464,7 @@ mod tests { .expect("teardown's writers are not held off") .expect("fork task"); drop(held); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); } /// A failed or timed-out SPV stop leaves SPV work live, so the guard of @@ -2199,7 +2497,7 @@ mod tests { .await .expect("the fork proceeds once the host released the page") .expect("fork task"); - reconciler.finish(&wallet_id, true); + reconciler.finish(&wallet_id, TxoReconcileFinish::Ran); } /// The manager's shutdown, with an SPV client that never started (its @@ -2230,7 +2528,7 @@ mod tests { .expect("an engine writer goes ahead after shutdown"); drop(writer); drop(held); - reconciler.finish(&wallet_id, false); + reconciler.finish(&wallet_id, TxoReconcileFinish::NotRun); } /// The store keeps a verdict after the guard is gone, so a spend counts @@ -2312,7 +2610,7 @@ mod tests { let mut cx = Context::from_waker(Waker::noop()); assert!(waiting.as_mut().poll(&mut cx).is_pending()); - reconciler.finish(&A, true); + reconciler.finish(&A, TxoReconcileFinish::Ran); assert!( reconciler.claim_without_gate(&A, TIP).is_some(), "a new run for the wallet" diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index e8105259ea0..44f29731563 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -2610,7 +2610,10 @@ mod remove_versus_recreate_tests { }; drop(held); reconciler.heal_page_applied(&wallet_id); - reconciler.finish(&wallet_id, true); + reconciler.finish( + &wallet_id, + crate::manager::txo_reconcile::TxoReconcileFinish::Ran, + ); let next = reconciler .claim_without_gate(&wallet_id, 0) .expect("the run finished"); @@ -2620,6 +2623,9 @@ mod remove_versus_recreate_tests { "the removal forgot the recreated wallet's in-flight run, so its applied page was \ not kept" ); - reconciler.finish(&wallet_id, false); + reconciler.finish( + &wallet_id, + crate::manager::txo_reconcile::TxoReconcileFinish::NotRun, + ); } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift index b0a2aa6d9cc..c01f3950850 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreTxoReconcileTypes.swift @@ -289,7 +289,8 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { public var unresolvedAccount = 0 // Run shape. - /// Store 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, or was still open at the apply. @@ -299,7 +300,8 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { /// Store reads or writes that failed; the run stops at the first. public var storeFailures = 0 /// `false` when the run stopped before the end of the classify pass - /// (cancelled, a failed read or write, too many deferrals, a page that + /// (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 @@ -311,6 +313,11 @@ public struct CoreTxoReconcileReport: Equatable, Sendable { /// 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. @@ -418,14 +425,41 @@ enum CoreTxoReconcileGate: Equatable, Sendable { 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; the flag is whether the run went ahead. - let finish: @Sendable (Handle, Data, Bool) throws -> Void + /// 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 @@ -457,7 +491,7 @@ enum CoreTxoReconcileFFI { /// 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) and the call throws. + /// 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)) @@ -482,19 +516,19 @@ enum CoreTxoReconcileFFI { 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, ran: false) + 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, ran: Bool) throws { + 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), - ran + outcome.code ) } try result.check() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift index de1c65014e5..2380bf1954a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerTxoReconcile.swift @@ -19,7 +19,8 @@ import Foundation // 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 @@ -38,8 +39,12 @@ import Foundation // 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. This file keeps only what needs the -// store: paging its rows (by offset, which it hands the engine as its +// 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. @@ -58,13 +63,6 @@ import Foundation extension PlatformWalletManager { /// Rows per engine page / per store page. nonisolated static let coreTxoReconcilePageSize = 512 - /// How many times a store step is deferred behind an open Rust - /// persistence round before the run gives up (each deferral waits - /// `coreTxoReconcileRetryDelay`). The store's own fence: saving - /// mid-round would commit the round's staged rows early. Never waited - /// on while the run holds an engine guard. - nonisolated static let coreTxoReconcileMaxRetries = 200 - nonisolated static let coreTxoReconcileRetryDelay: TimeInterval = 0.05 /// Reconcile the SwiftData TXO store of `walletId` against the engine — /// see the file comment for what it repairs and what it refuses to @@ -85,7 +83,10 @@ extension PlatformWalletManager { /// 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`). + /// (`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. /// /// The whole run — begin, both passes, the release of the last engine /// guard and the finish that releases the wallet's claim — is one @@ -176,9 +177,12 @@ extension PlatformWalletManager { /// 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`. 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. + /// `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( @@ -202,10 +206,10 @@ extension PlatformWalletManager { } let engine = calls.makeEngine(handle, walletId) - var ran = false + var finishOutcome = CoreTxoReconcileFinish.notRun defer { endCoreTxoReconcileRun(walletId: walletId, engine: engine, quiet: isCancelled()) { - try calls.finish(handle, walletId, ran) + try calls.finish(handle, walletId, finishOutcome) } } guard !isCancelled() else { return nil } @@ -217,7 +221,7 @@ extension PlatformWalletManager { handler: handler, isCancelled: isCancelled ) - ran = true + finishOutcome = report.stoppedOnOpenRound ? .deferred : .ran return .reconciled(report) } @@ -259,7 +263,9 @@ extension PlatformWalletManager { /// 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. The run stops on the budget when a read says the budget is + /// 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. @@ -315,6 +321,18 @@ extension PlatformWalletManager { 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 + } + /// 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 @@ -348,9 +366,8 @@ extension PlatformWalletManager { /// 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 before it waits, then reads the - /// same page again under a new one (another page of the budget), without - /// reporting the first read applied. + /// 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, @@ -359,7 +376,6 @@ extension PlatformWalletManager { _ isCancelled: @Sendable () -> Bool, _ report: inout CoreTxoReconcileReport ) -> Bool { - var deferrals = 0 while true { if isCancelled() { report.completed = false @@ -405,19 +421,11 @@ extension PlatformWalletManager { report.completed = false return false case .retryLater: - // The guard is already released: wait for the round - // guard-free, then read the page again. Not applied, so - // the engine serves the same page. - deferrals += 1 - report.retries += 1 - if deferrals > coreTxoReconcileMaxRetries { - report.completed = false - return false - } - Thread.sleep(forTimeInterval: coreTxoReconcileRetryDelay) - continue + // The guard is already released. Not applied: the + // wallet's next run reads the same page. + stopOnOpenRound(&report) + return false } - deferrals = 0 report.inserted += counts.inserted report.insertedDuffs = report.insertedDuffs.addingReportingOverflow(counts.insertedDuffs).0 report.alreadyPresent += counts.alreadyPresent @@ -453,9 +461,10 @@ extension PlatformWalletManager { /// 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. 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. + /// 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 @@ -479,10 +488,9 @@ extension PlatformWalletManager { report.completed = false return } - guard let page = withRoundRetry(&report, isCancelled: isCancelled, { - handler.reconcileUnspentTxoPage( - walletId: walletId, offset: offset, limit: limit, isCancelled: isCancelled) - }) else { return } + guard let page = storeStep(&report, handler.reconcileUnspentTxoPage( + walletId: walletId, offset: offset, limit: limit, isCancelled: isCancelled) + ) else { return } var flippedThisPage = 0 do { // The page counts as walked on every way out of its step but @@ -545,8 +553,9 @@ extension PlatformWalletManager { return case .retryLater: // The apply answers an open round as a stale page; - // were it ever to defer instead, the run must still - // not wait for a round while it holds the guard. + // 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 @@ -594,40 +603,26 @@ extension PlatformWalletManager { } } - /// 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. Only for steps - /// taken with no engine guard held. - 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 .stopped: - report.completed = false - return nil - 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 } } @@ -682,11 +677,12 @@ extension PlatformWalletManager { } /// The summary's level: `info` for a run that wrote nothing and either - /// completed or stopped on its budget — an expected state, the rest of - /// the cycle is the next run's — and `warning` for any write or any - /// other stop. + /// 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 { - report.mutations == 0 && (report.completed || report.stoppedOnBudget) ? .info : .warning + 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 @@ -714,6 +710,7 @@ extension PlatformWalletManager { "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)), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 80a82897699..5c48e449f94 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -11540,11 +11540,12 @@ private struct UtxoCreditVerdictTally { /// closure so a Rust persistence round is never interleaved with a /// 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. The run never waits for it while holding an - /// engine guard: the classify apply answers a stale page instead, and a - /// deferred heal step releases its page's guard before it waits. + /// 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. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift index 18d7155b567..f686442ad01 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileShutdownTests.swift @@ -4,16 +4,16 @@ import DashSDKFFI @testable import SwiftDashSDK /// The native side of a reconcile run's bracket, faked: the start gate's -/// answer, and a record of every begin, every finish (with whether an -/// engine guard was still held when it was called) and the tip each engine -/// was made at. +/// 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, ran: Bool, guardHeld: Bool)] = [] + private var _finishes: [(walletId: Data, outcome: CoreTxoReconcileFinish, guardHeld: Bool)] = [] private var _enginesMade = 0 /// Runs inside each begin, before it answers. var onBegin: (@Sendable () -> Void)? @@ -36,7 +36,9 @@ final class FakeReconcileNative: @unchecked Sendable { } var begins: [Data] { lock.withLock { _begins } } - var finishes: [(walletId: Data, ran: Bool, guardHeld: Bool)] { lock.withLock { _finishes } } + var finishes: [(walletId: Data, outcome: CoreTxoReconcileFinish, guardHeld: Bool)] { + lock.withLock { _finishes } + } var enginesMade: Int { lock.withLock { _enginesMade } } func calls() -> PlatformWalletNativeTxoReconcileCalls { @@ -48,9 +50,9 @@ final class FakeReconcileNative: @unchecked Sendable { return _gate } }, - finish: { [self] _, walletId, ran in + finish: { [self] _, walletId, outcome in let held = holding() - lock.withLock { _finishes.append((walletId, ran, held)) } + lock.withLock { _finishes.append((walletId, outcome, held)) } }, makeEngine: { [self] _, _ in lock.withLock { _enginesMade += 1 } @@ -63,9 +65,10 @@ final class FakeReconcileNative: @unchecked Sendable { /// 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, defers a -/// guard-free store step while a Rust persistence round is open, and the -/// manager refuses it once shutdown has begun. +/// 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) @@ -236,38 +239,53 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { XCTAssertFalse(row.isSpent) } - /// A Rust round open on the persistence queue defers every guard-free - /// store step: the run retries until the round closes, then completes - /// normally. - func testAStepDeferredBehindAnOpenRoundCompletesOnceTheRoundCloses() throws { + /// 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) - // Each deferred heal step reads its page again, a page of the - // budget each time: room for however many the wait below allows. - let engine = FakeCoreTxoEngine(inventory: [engineUtxo(0x31)], budget: 1_000) + 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, 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) } @@ -310,7 +328,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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?.ran, true) + 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") @@ -373,7 +391,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { XCTAssertEqual(report.inserted, 1) XCTAssertEqual(native.enginesMade, 1) XCTAssertEqual(native.finishes.count, 1) - XCTAssertEqual(native.finishes.first?.ran, true) + 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", @@ -406,7 +424,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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?.ran, true) + XCTAssertEqual(native.finishes.first?.outcome, .ran) } /// A run staled between a granted begin and its walk walks nothing and @@ -426,7 +444,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { XCTAssertNil(outcome) XCTAssertEqual(native.finishes.count, 1) - XCTAssertEqual(native.finishes.first?.ran, false) + XCTAssertEqual(native.finishes.first?.outcome, .notRun) XCTAssertEqual(engine.pageCalls, 0) XCTAssertEqual(try txoCount(container), 0) } @@ -495,7 +513,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { 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?.ran, true) + XCTAssertEqual(native.finishes.first?.outcome, .ran) _ = await manager.shutdown() PlatformWalletManager.destroyQueue.sync {} } @@ -570,7 +588,9 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { await fulfillment(of: [begun], timeout: 5) let second = try await manager.reconcileCoreTxoStore(for: walletId) XCTAssertEqual(second, .skipped(.alreadyRunning)) - XCTAssertEqual(native.begins.count, 1, "the refused request never reached the queue") + // 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 @@ -667,7 +687,7 @@ final class CoreTxoReconcileShutdownTests: XCTestCase { platform_wallet_txo_reconcile_retry_stale_page(6) ) XCTAssertThrowsError(try CoreTxoReconcileFFI.begin(NULL_HANDLE, walletId: walletId)) - XCTAssertThrowsError(try CoreTxoReconcileFFI.finish(NULL_HANDLE, walletId: walletId, ran: false)) + XCTAssertThrowsError(try CoreTxoReconcileFFI.finish(NULL_HANDLE, walletId: walletId, outcome: .notRun)) XCTAssertThrowsError(try CoreTxoReconcileFFI.dueWallets(NULL_HANDLE)) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index 2b51e804934..5aba6a20242 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -648,8 +648,8 @@ final class CoreTxoReconcileTests: XCTestCase { /// 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 — the read waits - /// for the round, guard-free. + /// 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) @@ -674,7 +674,7 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertTrue(report.completed) XCTAssertEqual(report.staleRetries, 1) - XCTAssertEqual(report.retries, 0, "nothing waited for the round while the guard was held") + 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", @@ -1387,31 +1387,40 @@ final class CoreTxoReconcileTests: XCTestCase { } /// 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): the guard goes, - /// the run waits, and the same page is read again under a new one. - func testADeferredHealStepReleasesItsGuardBeforeWaiting() throws { + /// 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 - let releases = Counter() handler.beginChangeset(walletId: walletId) - engine.onRelease = { - guard releases.next() == 1 else { return } - _ = handler.endChangeset(walletId: walletId, success: true) - } - let report = run(handler, engine: engine) + _ = handler.endChangeset(walletId: walletId, success: true) - XCTAssertTrue(report.completed) - XCTAssertEqual(report.retries, 1, "one wait, taken after the guard was released") - XCTAssertEqual(report.engineRows, 1, "the page read again is counted once") - XCTAssertEqual(report.inserted, 1) - XCTAssertEqual(engine.pageCalls, 2) - XCTAssertEqual(engine.healApplied, 1, "the deferred read is not reported applied; the one that landed is") - XCTAssertEqual(Array(engine.events.prefix(5)), ["heal", "release", "heal", "release", "healApplied"]) - XCTAssertEqual(engine.pagedWhileHeld, 0) + 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))) } @@ -1503,30 +1512,44 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertFalse(try XCTUnwrap(txo(container, txid: txid(0x41))).isSpent) } - /// A heal page deferred behind an open round is read again, each read a - /// page of the budget, and never reported applied: when the budget runs - /// out the run stops, and the next run starts at the same page. - func testADeferredHealPageIsNeverReportedApplied() throws { + /// 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 fileAddress(in: container) - let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: txid(0x42))], budget: 2) + 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) - defer { _ = handler.endChangeset(walletId: walletId, success: true) } - let report = run(handler, engine: engine) + _ = handler.endChangeset(walletId: walletId, success: true) XCTAssertFalse(report.completed) - XCTAssertTrue(report.stoppedOnBudget) - XCTAssertEqual(report.retries, 2) - XCTAssertEqual(engine.pageCalls, 3) - XCTAssertEqual(engine.budgetSpentReads, 1) - XCTAssertEqual(engine.healApplied, 0) - XCTAssertEqual(report.inserted, 0) - XCTAssertNil(engine.checkpoint, "nothing moved: the next run starts a new cycle") + 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) - XCTAssertNil(try txo(container, txid: txid(0x42))) + 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 @@ -1767,9 +1790,9 @@ final class CoreTxoReconcileTests: XCTestCase { } } - /// A run that stopped on its budget having written nothing is an - /// expected state, logged like a clean run; a write, or any other stop, - /// is a warning. + /// 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) @@ -1783,6 +1806,14 @@ final class CoreTxoReconcileTests: XCTestCase { 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 From 8c639f0de6969b454181bd2c80df27f14d263b31 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Sun, 8 Nov 2026 17:19:23 -0800 Subject: [PATCH 19/19] fix(platform-wallet): a begin refused for an unknown wallet keeps no stamp A begin that arrives after a wallet's removal finds it unknown at the gate. It used to keep the claim's cadence stamp, so the same id re-added in the same steady SPV session waited up to 30 minutes for its first run, breaking the guarantee `forget` documents. WalletUnknown now releases the claim like NotSteadyState; due_wallets lists only loaded wallets, so it cannot loop. Co-Authored-By: Claude Opus 5.5 --- .../src/txo_reconcile.rs | 3 +- .../src/manager/txo_reconcile.rs | 54 ++++++++++++++++--- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs index 9082f590e72..1efff04981a 100644 --- a/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs +++ b/packages/rs-platform-wallet-ffi/src/txo_reconcile.rs @@ -183,7 +183,8 @@ pub unsafe extern "C" fn platform_wallet_manager_txo_reconcile_due_wallets( /// 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. +/// 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 diff --git a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs index ebf09eb8767..47d453b7d5b 100644 --- a/packages/rs-platform-wallet/src/manager/txo_reconcile.rs +++ b/packages/rs-platform-wallet/src/manager/txo_reconcile.rs @@ -615,9 +615,10 @@ impl TxoReconcileSchedule { /// 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`], - /// whose release ([`TxoReconcileFinish::Deferred`]) leaves the wallet - /// due for the steady state to come. + /// 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; @@ -814,7 +815,10 @@ impl TxoReconciler { /// 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. + /// 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 } @@ -830,10 +834,18 @@ impl TxoReconciler { 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: no stamp, so the wallet is due as soon as it - // is (due_wallets reports nothing until then, so no poll loop). - // Every other refusal keeps the claim's stamp when it drops. - if *skip == TxoReconcileSkip::NotSteadyState { + // 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); } })?; @@ -2129,6 +2141,32 @@ mod tests { ); } + /// 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