From d686a3bc69ff01448107b3e79c9d8295dec06dcf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:33:00 +0000 Subject: [PATCH 1/5] refactor(platform-wallet)!: hoist load history replay into shared load The SQLite persister replayed its stored transaction records through the wallet checker inside `load()`, so only SQLite rebuilt the in-memory spend guards (`spent_outpoints`, `observed_spent_outpoints`, IS-lock upgrades) that stop a redelivered funding transaction from resurrecting a spent output. Any other persister got no guard for confirmed spends. Move the replay (`replay_order`, lock matching, the persisted-UTXO-set retain filter) into `platform_wallet::manager::history_replay` and run it from `load_from_persistor` for every persister. Persisters now hand their stored history over as `ClientWalletStartState::recorded_history` (`RecordedHistory` / `StoredTransaction`); SQLite supplies its records and locks instead of replaying them itself. The replay runs at the async boundary and awaits the checker, so the first-poll `poll_ready` shim and its suspension rollback are gone. Records an account already holds (a persister's own raw restores) are skipped, and an unconfirmed outgoing send present in the history is only re-dispatched, not accounted twice. BREAKING CHANGE: `ClientWalletStartState` gains the public field `recorded_history`; struct-literal constructors must set it (`Default::default()` keeps the old behaviour). `SqlitePersister::load` no longer returns a replayed projection: callers that bypass `load_from_persistor` must run `replay_recorded_history` themselves. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 1 + .../src/sqlite/persister.rs | 20 +- .../src/sqlite/rehydrate.rs | 746 +------------- .../tests/sqlite_spent_rehydration.rs | 50 +- .../changeset/client_wallet_start_state.rs | 13 + .../rs-platform-wallet/src/changeset/mod.rs | 2 + .../src/changeset/recorded_history.rs | 68 ++ .../src/manager/history_replay.rs | 926 ++++++++++++++++++ .../rs-platform-wallet/src/manager/load.rs | 159 ++- .../rs-platform-wallet/src/manager/mod.rs | 1 + .../rs-platform-wallet/src/manager/startup.rs | 1 + 11 files changed, 1217 insertions(+), 770 deletions(-) create mode 100644 packages/rs-platform-wallet/src/changeset/recorded_history.rs create mode 100644 packages/rs-platform-wallet/src/manager/history_replay.rs diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index d95a1f31db8..61114998d8a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5856,6 +5856,7 @@ fn build_wallet_start_state( identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, + recorded_history: Default::default(), }; let platform_address_state = if per_account.is_empty() diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 546bc42a13c..b201f3343a3 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -21,7 +21,6 @@ use crate::sqlite::error::{AutoBackupOperation, WalletStorageError}; use crate::sqlite::load_ctx::{LoadCtx, LoadDegradation, LoadSite}; use crate::sqlite::rehydrate::{ apply_persisted_core_state, build_wallet, restore_provider_platform_node_pool, - restore_recorded_transactions, }; use crate::sqlite::reports::{CommitReport, DeleteWalletReport}; use crate::sqlite::schema; @@ -1850,13 +1849,17 @@ fn load_one_wallet( )) })?; } - let mut wallet = wallet; - restore_recorded_transactions( - &mut wallet_info, - &mut wallet, - core_state.records, - &core_state.instant_locks_for_non_final_records, - ); + // The stored records are replayed by the shared load + // (`platform_wallet::manager::history_replay`), which rebuilds the spend + // guards on top of the projection restored above. + let recorded_history = platform_wallet::changeset::RecordedHistory { + transactions: core_state + .records + .into_iter() + .map(platform_wallet::changeset::StoredTransaction::from) + .collect(), + instant_locks: core_state.instant_locks_for_non_final_records, + }; Ok(platform_wallet::changeset::ClientWalletStartState { wallet, wallet_info, @@ -1867,6 +1870,7 @@ fn load_one_wallet( // replay inert here, which is the behaviour this path had before the // field existed. unconfirmed_outgoing_txs: Vec::new(), + recorded_history, }) } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 56b800c521b..51e6d51cf46 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -4,17 +4,10 @@ //! the manager consumes the carried snapshot directly, so no wrong-seed check //! runs here; that gate lives in the resolver-backed signing entrypoints. -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; - -use dashcore::ephemerealdata::instant_lock::InstantLock; -use dashcore::{OutPoint, Txid}; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType}; use key_wallet::managed_account::address_pool::{AddressPoolType, PublicKeyType}; -use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::managed_account::ManagedCoreFundsAccount; -use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Network; @@ -266,9 +259,10 @@ pub(crate) fn restore_provider_platform_node_pool( /// Coinbase-maturity nuance re-warms on sync. `is_instantlocked` is NOT /// among them: it is rebuilt from `core_instant_locks` above, for every /// UTXO a replayed lock covers. -/// - **Transaction records**: the SQLite loader replays recorded history -/// through the wallet checker after this projection, in dependency order -/// (in-set parents first, otherwise chain order). +/// - **Transaction records**: the SQLite loader hands its stored records to +/// the shared load as a `RecordedHistory`, which replays them through the +/// wallet checker after this projection, in dependency order (in-set +/// parents first, otherwise chain order). /// /// # Errors /// @@ -423,217 +417,6 @@ pub fn apply_persisted_core_state( Ok(()) } -/// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. -/// -/// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a -/// redelivered funding transaction would re-credit an output they reserve. -/// `instant_locks` are the persisted InstantSend locks: a lock that arrived -/// after its transaction was stored never rewrote the stored record, so the -/// replay upgrades that record's mempool context itself. -pub(crate) fn restore_recorded_transactions( - wallet_info: &mut ManagedWalletInfo, - wallet: &mut Wallet, - records: Vec, - instant_locks: &BTreeMap, -) { - // Where the load projection parked each unspent outpoint; its keys are - // the outputs persistence still considers unspent. - let placed: HashMap = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .flat_map(|account| { - let owner = funds_account_type(account); - account.utxos.keys().map(move |outpoint| (*outpoint, owner)) - }) - .collect(); - if records.is_empty() { - return; - } - // TODO(bound-load-history-replay): every stored record is replayed on each - // load; bounding it to records above the last chain lock needs care so - // finality and spend guards for older records are not lost. - // TODO(expire-unconfirmed-spend-reservations): mempool records are replayed - // on every load with no expiry, so a forged or never-mined spend of a wallet - // outpoint keeps its reservation across load and rescan; only a conflicting - // IS-locked or confirmed spend releases it. Sibling of - // TODO(release-repair-spends-after-reorg) in `core_history`. - let replay = replay_order(records); - - // Kept only to undo a replay the checker suspended part-way through. - let (info_before, wallet_before) = (wallet_info.clone(), wallet.clone()); - let completed = poll_ready(async { - for record in replay { - // The lock set already holds this txid (load marked the restored - // UTXOs), so a later lock event is deduplicated: the InstantSend - // context, and the conflict sweep it runs, must come from here. - let lock = instant_locks - .get(&record.txid) - .filter(|lock| lock_matches_record(lock, &record)); - let context = match (record.context, lock) { - (TransactionContext::Mempool, Some(lock)) => { - TransactionContext::InstantSend(lock.clone()) - } - (context, _) => context, - }; - wallet_info - .check_core_transaction(&record.transaction, context, wallet, true, false) - .await; - } - }) - .is_some(); - if !completed { - // Degrade to the pre-replay projection: persisted spends stay - // excluded, only redelivery guards are missing until the next sync. - tracing::error!( - wallet_id = %hex::encode(wallet_info.wallet_id), - "transaction checker suspended during load replay; restored spend guards skipped" - ); - *wallet_info = info_before; - *wallet = wallet_before; - return; - } - - let spent: HashSet<_> = wallet_info - .observed_spent_outpoints() - .keys() - .copied() - .collect(); - // Replay credits an output to the account whose pool derives it. When - // that differs from the load-time fallback, the fallback copy is a - // duplicate: drop it so each outpoint lives in exactly one account. - let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info - .accounts - .all_funding_accounts() - .into_iter() - .flat_map(|account| { - let owner = funds_account_type(account); - let placed = &placed; - account.utxos.keys().filter_map(move |outpoint| { - placed - .get(outpoint) - .filter(|parked| **parked != owner) - .map(|parked| (*outpoint, *parked)) - }) - }) - .collect(); - for account in wallet_info.accounts.all_funding_accounts_mut() { - let owner = funds_account_type(account); - account.utxos.retain(|outpoint, _| { - placed.contains_key(outpoint) - && !spent.contains(outpoint) - && !misplaced.contains(&(*outpoint, owner)) - }); - } - // Finalize replayed records before a sync checkpoint can prune their spend guards. - if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { - wallet_info.apply_chain_lock(chain_lock); - } - wallet_info.update_balance(); -} - -/// Whether `lock` really locks `record`, so upgrading its context is safe. -/// -/// The lock map is keyed by the stored `txid` column and a record's `txid` is -/// stored beside its transaction, so neither is proof on its own. A mismatch -/// keeps the record's stored context: the lock's conflict sweep must not drop -/// history on the strength of a lock that belongs to another transaction. -fn lock_matches_record(lock: &InstantLock, record: &TransactionRecord) -> bool { - let transaction_txid = record.transaction.txid(); - let matches = lock.txid == record.txid && transaction_txid == record.txid; - if !matches { - tracing::warn!( - record_txid = %record.txid, - transaction_txid = %transaction_txid, - lock_txid = %lock.txid, - "persisted InstantSend lock does not match its transaction record; replaying without it" - ); - } - matches -} - -/// Poll `future` once, returning its output only if it completed without suspending. -/// -/// The wallet checker is `async` only by trait shape: it never awaits, so it -/// completes on the first poll and load needs no async runtime. A test pins -/// that; an upstream change that adds a real await fails it. -fn poll_ready(future: F) -> Option { - let mut future = std::pin::pin!(future); - let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); - match future.as_mut().poll(&mut cx) { - std::task::Poll::Ready(output) => Some(output), - std::task::Poll::Pending => None, - } -} - -/// Order records as the chain would deliver them: every in-set parent ahead of -/// its children, otherwise confirmed by block position, then unconfirmed. -/// -/// Dependencies span both partitions: a parent's stored record can still say -/// mempool after it confirmed (a height-only confirmation never rewrites an -/// existing record), while its child's record is already confirmed. -fn replay_order(records: Vec) -> Vec { - let mut records = records; - records.sort_by_key(|record| { - let block = record.block_info(); - ( - block.is_none(), - block.map(|block| (block.height(), block.position())), - record.txid, - ) - }); - let index: HashMap = records - .iter() - .enumerate() - .map(|(position, record)| (record.txid, position)) - .collect(); - let mut children: Vec> = vec![Vec::new(); records.len()]; - let mut waiting_on: Vec = vec![0; records.len()]; - for (child, record) in records.iter().enumerate() { - let parents: BTreeSet = record - .transaction - .input - .iter() - .filter_map(|input| index.get(&input.previous_output.txid).copied()) - .filter(|parent| *parent != child) - .collect(); - waiting_on[child] = parents.len(); - for parent in parents { - children[parent].push(child); - } - } - // Sorted positions, so the smallest ready one is always next in chain order. - let mut ready: BTreeSet = (0..records.len()) - .filter(|position| waiting_on[*position] == 0) - .collect(); - let mut emitted = vec![false; records.len()]; - let mut order = Vec::with_capacity(records.len()); - while let Some(position) = ready.pop_first() { - emitted[position] = true; - order.push(position); - for &child in &children[position] { - waiting_on[child] -= 1; - if waiting_on[child] == 0 { - ready.insert(child); - } - } - } - // Unreachable for real transactions (txids cannot form a cycle); keep the - // rest in chain order rather than drop a reservation. - order.extend((0..records.len()).filter(|position| !emitted[*position])); - let mut slots: Vec> = records.into_iter().map(Some).collect(); - order - .into_iter() - .filter_map(|position| slots[position].take()) - .collect() -} - -/// Account identity of a funds account, stable across replay mutations. -fn funds_account_type(account: &ManagedCoreFundsAccount) -> AccountType { - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - account.managed_account_type().to_account_type() -} - /// Resolve an owning account to its position among `account_keys`, or fall /// back to the first funds account. A `None` owner (no attribution available) /// falls back silently; an owner not present in `account_keys` (store drift) @@ -3375,525 +3158,4 @@ mod tests { "the restored UTXO must carry instant-locked status, not wait for the next sync" ); } - - /// A fresh random wallet and its first BIP44 receive address. - fn wallet_with_receive_address() -> (Wallet, ManagedWalletInfo, dashcore::Address) { - let wallet = - Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); - let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); - let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; - let address = info - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap() - .next_receive_address(Some(&xpub), true) - .unwrap(); - (wallet, info, address) - } - - /// Load replays history without an async runtime by polling the checker - /// once. If upstream ever makes it suspend, this fails in CI instead of - /// load silently skipping the restored spend guards in production. - #[test] - fn should_complete_transaction_checker_on_first_poll() { - use dashcore::hashes::Hash; - use dashcore::{Transaction, TxIn, TxOut}; - use key_wallet::transaction_checking::BlockInfo; - - let (mut wallet, mut info, address) = wallet_with_receive_address(); - let funding = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(Txid::from_byte_array([9; 32]), 0), - ..Default::default() - }], - output: vec![TxOut { - value: 1_000, - script_pubkey: address.script_pubkey(), - }], - special_transaction_payload: None, - }; - let spend = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(funding.txid(), 0), - ..Default::default() - }], - output: Vec::new(), - special_transaction_payload: None, - }; - let block = - TransactionContext::InBlock(BlockInfo::new(1, dashcore::BlockHash::all_zeros(), 1)); - for (tx, context) in [(&funding, block), (&spend, TransactionContext::Mempool)] { - let result = - poll_ready(info.check_core_transaction(tx, context, &mut wallet, true, false)); - assert!( - result.is_some_and(|r| r.is_relevant), - "the checker must complete on its first poll" - ); - } - } - - /// Same-block funding and spend, with and without in-block positions: an - /// output the load projection still parks as unspent must end up excluded, - /// and recorded as observed spent, whichever of the two is stored first. - #[tokio::test] - async fn should_exclude_spent_output_for_either_same_height_replay_order() { - use dashcore::hashes::Hash; - use dashcore::{BlockHash, Transaction, TxIn, TxOut}; - use key_wallet::transaction_checking::BlockInfo; - use key_wallet::Utxo; - - for (spend_first, positioned) in - [(false, false), (true, false), (false, true), (true, true)] - { - let case = format!("spend_first={spend_first} positioned={positioned}"); - let (mut wallet, mut info, address) = wallet_with_receive_address(); - let funding = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(Txid::from_byte_array([15; 32]), 0), - ..Default::default() - }], - output: [100_000, 20_000] - .map(|value| TxOut { - value, - script_pubkey: address.script_pubkey(), - }) - .to_vec(), - special_transaction_payload: None, - }; - let (spent, available) = ( - OutPoint::new(funding.txid(), 0), - OutPoint::new(funding.txid(), 1), - ); - let spending = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: spent, - ..Default::default() - }], - output: vec![TxOut { - value: 99_000, - script_pubkey: dashcore::ScriptBuf::new(), - }], - special_transaction_payload: None, - }; - let context = |position: u32| { - let block = BlockInfo::new(100, BlockHash::from_byte_array([7; 32]), 100); - TransactionContext::InBlock(if positioned { - block.with_position(position) - } else { - block - }) - }; - let mut records = info - .check_core_transaction(&funding, context(1), &mut wallet, true, true) - .await - .new_records; - records.extend( - info.check_core_transaction(&spending, context(2), &mut wallet, true, true) - .await - .new_records, - ); - assert_eq!(records.len(), 2); - assert!(records.iter().all(|r| r - .block_info() - .is_some_and(|b| b.position().is_some() == positioned))); - if spend_first { - records.reverse(); - } - - // A stale projection that still parks the spent output as unspent. - let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); - let account = restored - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap(); - for outpoint in [spent, available] { - account.utxos.insert( - outpoint, - Utxo { - outpoint, - txout: funding.output[outpoint.vout as usize].clone(), - address: address.clone(), - height: 100, - is_coinbase: false, - is_confirmed: true, - is_instantlocked: false, - is_locked: false, - is_trusted: false, - }, - ); - } - restore_recorded_transactions(&mut restored, &mut wallet, records, &Default::default()); - - let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; - assert!(!coins.contains_key(&spent), "{case}"); - assert!(coins.contains_key(&available), "{case}"); - assert!( - restored.observed_spent_outpoints().contains_key(&spent), - "{case}" - ); - assert_eq!(restored.balance.total(), 20_000, "{case}"); - } - } - - /// A lock that arrived after its transaction was stored lives only in - /// `core_instant_locks`; the stored record still says mempool. Replay must - /// restore the InstantSend context and run its conflict sweep, since the - /// already-marked lock deduplicates any later lock event. - #[tokio::test] - async fn should_replay_mempool_record_with_persisted_lock_as_instant_send() { - use dashcore::hashes::Hash; - use dashcore::{BlockHash, Transaction, TxIn, TxOut}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::transaction_checking::BlockInfo; - - let (mut wallet, mut info, address) = wallet_with_receive_address(); - let funding = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(Txid::from_byte_array([21; 32]), 0), - ..Default::default() - }], - output: vec![TxOut { - value: 100_000, - script_pubkey: address.script_pubkey(), - }], - special_transaction_payload: None, - }; - let spend = |value| Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(funding.txid(), 0), - ..Default::default() - }], - output: vec![TxOut { - value, - script_pubkey: dashcore::ScriptBuf::new(), - }], - special_transaction_payload: None, - }; - let block = TransactionContext::InBlock(BlockInfo::new( - 100, - BlockHash::from_byte_array([8; 32]), - 100, - )); - let mut records = info - .check_core_transaction(&funding, block, &mut wallet, true, true) - .await - .new_records; - // Both double spends as stored: unconfirmed, recorded independently. - let (mut winner, mut loser) = (spend(99_000), spend(98_000)); - for tx in [&winner, &loser] { - let mut scratch = info.clone(); - records.extend( - scratch - .check_core_transaction( - tx, - TransactionContext::Mempool, - &mut wallet, - true, - true, - ) - .await - .new_records, - ); - } - assert_eq!(records.len(), 3); - // Unconfirmed siblings replay by txid: lock the later one so the - // loser is already recorded when the winner's sweep runs. - if winner.txid() < loser.txid() { - std::mem::swap(&mut winner, &mut loser); - } - let lock = InstantLock { - inputs: vec![OutPoint::new(funding.txid(), 0)], - txid: winner.txid(), - ..Default::default() - }; - let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); - - let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); - - // Alone, the locked spend comes back InstantSend. - let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); - let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; - restore_recorded_transactions(&mut alone, &mut wallet, pair, &locks); - assert!( - alone.accounts.standard_bip44_accounts[&0] - .transactions() - .get(&winner.txid()) - .is_some_and(|record| matches!(record.context, TransactionContext::InstantSend(_))), - "the locked record must come back InstantSend, not mempool" - ); - - // Replayed after a conflicting spend, its lock sweeps that spend. - let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); - restore_recorded_transactions(&mut contested, &mut wallet, records.clone(), &locks); - assert!( - !contested.accounts.standard_bip44_accounts[&0] - .transactions() - .contains_key(&loser.txid()), - "the lock's conflict sweep must drop the competing spend" - ); - } - - /// A persisted lock is trusted only when it names the record it is keyed - /// under and that record's transaction really has that txid; otherwise the - /// record replays in its stored mempool context and no sweep runs. - #[tokio::test] - async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { - use dashcore::hashes::Hash; - use dashcore::{BlockHash, Transaction, TxIn, TxOut}; - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - use key_wallet::transaction_checking::BlockInfo; - - let (mut wallet, mut info, address) = wallet_with_receive_address(); - let funding = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), - ..Default::default() - }], - output: vec![TxOut { - value: 100_000, - script_pubkey: address.script_pubkey(), - }], - special_transaction_payload: None, - }; - let spend = Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(funding.txid(), 0), - ..Default::default() - }], - output: vec![TxOut { - value: 99_000, - script_pubkey: dashcore::ScriptBuf::new(), - }], - special_transaction_payload: None, - }; - let block = TransactionContext::InBlock(BlockInfo::new( - 100, - BlockHash::from_byte_array([8; 32]), - 100, - )); - let mut records = info - .check_core_transaction(&funding, block, &mut wallet, true, true) - .await - .new_records; - records.extend( - info.check_core_transaction( - &spend, - TransactionContext::Mempool, - &mut wallet, - true, - true, - ) - .await - .new_records, - ); - assert_eq!(records.len(), 2); - let foreign = Txid::from_byte_array([24; 32]); - let lock_for = |txid| InstantLock { - inputs: vec![OutPoint::new(funding.txid(), 0)], - txid, - ..Default::default() - }; - let mut forged_record = records.clone(); - forged_record - .iter_mut() - .find(|record| record.txid == spend.txid()) - .unwrap() - .txid = foreign; - let cases = [ - // The lock row is keyed under the record but locks another txid. - ( - "lock txid", - records.clone(), - spend.txid(), - lock_for(foreign), - ), - // Record and lock agree, but the record's transaction is another one. - ("record txid", forged_record, foreign, lock_for(foreign)), - ]; - for (case, records, key, lock) in cases { - let locks: BTreeMap = [(key, lock)].into_iter().collect(); - let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); - restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); - let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); - assert!( - !transactions - .values() - .any(|record| matches!(record.context, TransactionContext::InstantSend(_))), - "{case}: a mismatched lock must not upgrade any record" - ); - assert!( - transactions.contains_key(&spend.txid()), - "{case}: the spend must still replay in its stored context" - ); - } - } - - /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. - fn replay_record( - parents: &[Txid], - value: u64, - context: TransactionContext, - ) -> TransactionRecord { - use dashcore::{Transaction, TxIn, TxOut}; - use key_wallet::account::StandardAccountType; - use key_wallet::managed_account::transaction_record::TransactionDirection; - use key_wallet::transaction_checking::TransactionType; - - let transaction = Transaction { - version: 1, - lock_time: 0, - input: parents - .iter() - .map(|parent| TxIn { - previous_output: OutPoint::new(*parent, 0), - ..Default::default() - }) - .collect(), - output: vec![TxOut { - value, - script_pubkey: dashcore::ScriptBuf::new(), - }], - special_transaction_payload: None, - }; - TransactionRecord::new( - transaction, - AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - context, - TransactionType::Standard, - TransactionDirection::Outgoing, - Vec::new(), - Vec::new(), - 0, - ) - } - - fn in_block(height: u32, position: Option) -> TransactionContext { - use dashcore::hashes::Hash; - use key_wallet::transaction_checking::BlockInfo; - - let block = BlockInfo::new(height, dashcore::BlockHash::all_zeros(), height); - TransactionContext::InBlock(position.map_or(block, |p| block.with_position(p))) - } - - fn replayed_txids(records: Vec) -> Vec { - replay_order(records).into_iter().map(|r| r.txid).collect() - } - - /// An unconfirmed child whose txid sorts ahead of its parent's still - /// replays after it, so its input reserves the parent's output. - #[test] - fn should_replay_unconfirmed_parent_before_its_child() { - use dashcore::hashes::Hash; - - let parent = replay_record( - &[Txid::from_byte_array([1; 32])], - 1_000, - TransactionContext::Mempool, - ); - let child = (0..) - .map(|value| replay_record(&[parent.txid], value, TransactionContext::Mempool)) - .find(|child| child.txid < parent.txid) - .unwrap(); - let expected = vec![parent.txid, child.txid]; - - assert_eq!(replayed_txids(vec![child, parent]), expected); - } - - /// A parent whose stored record is still mempool replays ahead of a child - /// already recorded as confirmed. - #[test] - fn should_replay_mempool_parent_before_its_confirmed_child() { - use dashcore::hashes::Hash; - - let parent = replay_record( - &[Txid::from_byte_array([2; 32])], - 1_000, - TransactionContext::Mempool, - ); - let child = replay_record(&[parent.txid], 900, in_block(50, Some(3))); - let unrelated = replay_record(&[Txid::from_byte_array([3; 32])], 700, in_block(40, None)); - let expected = vec![unrelated.txid, parent.txid, child.txid]; - - assert_eq!(replayed_txids(vec![child, unrelated, parent]), expected); - } - - /// Independent records follow chain order: height, then in-block - /// position, then unconfirmed. - #[test] - fn should_order_independent_records_by_height_then_block_position() { - use dashcore::hashes::Hash; - - let funding = |marker| [Txid::from_byte_array([marker; 32])]; - let pending = replay_record(&funding(4), 1, TransactionContext::Mempool); - let late_second = replay_record(&funding(5), 2, in_block(10, Some(2))); - let late_first = replay_record(&funding(6), 3, in_block(10, Some(1))); - let early = replay_record(&funding(7), 4, in_block(9, Some(5))); - let expected = vec![early.txid, late_first.txid, late_second.txid, pending.txid]; - - assert_eq!( - replayed_txids(vec![pending, late_second, late_first, early]), - expected - ); - } - - /// Within one block, in-block position decides: a spend follows the - /// funding transaction it spends, and unrelated transactions keep their - /// place around the pair. - #[test] - fn should_keep_block_position_order_for_same_block_spends() { - use dashcore::hashes::Hash; - - let parent = replay_record(&[Txid::from_byte_array([9; 32])], 10, in_block(20, Some(1))); - let child = replay_record(&[parent.txid], 9, in_block(20, Some(2))); - let before = replay_record(&[Txid::from_byte_array([10; 32])], 8, in_block(20, Some(0))); - let after = replay_record(&[Txid::from_byte_array([11; 32])], 7, in_block(20, Some(3))); - let expected = vec![before.txid, parent.txid, child.txid, after.txid]; - - assert_eq!(replayed_txids(vec![after, child, before, parent]), expected); - } - - /// Records that name each other as parents (impossible for real txids) - /// still all replay, after everything that is ready. - #[test] - fn should_keep_every_record_of_a_dependency_cycle() { - use dashcore::hashes::Hash; - - let (a, b) = ( - Txid::from_byte_array([0xAA; 32]), - Txid::from_byte_array([0xBB; 32]), - ); - let mut first = replay_record(&[b], 1, TransactionContext::Mempool); - first.txid = a; - let mut second = replay_record(&[a], 2, TransactionContext::Mempool); - second.txid = b; - let ready = replay_record( - &[Txid::from_byte_array([8; 32])], - 3, - TransactionContext::Mempool, - ); - let expected = vec![ready.txid, a, b]; - - assert_eq!(replayed_txids(vec![second, first, ready]), expected); - } } diff --git a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs index e296482379c..c2342a04042 100644 --- a/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/sqlite_spent_rehydration.rs @@ -24,8 +24,22 @@ use platform_wallet::changeset::{ AccountRegistrationEntry, CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, WalletMetadataEntry, }; +use platform_wallet::manager::history_replay::replay_recorded_history; use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +/// Load one wallet and run the shared history replay on it, as +/// `load_from_persistor` does. +async fn load_replayed( + persister: &SqlitePersister, + wallet_id: &[u8; 32], +) -> (Wallet, ManagedWalletInfo) { + let mut state = persister.load().unwrap(); + let restored = state.wallets.remove(wallet_id).unwrap(); + let (mut wallet, mut info) = (restored.wallet, restored.wallet_info); + replay_recorded_history(&mut info, &mut wallet, restored.recorded_history).await; + (wallet, info) +} + fn block(height: u32) -> TransactionContext { TransactionContext::InBlock(BlockInfo::new( height, @@ -155,10 +169,14 @@ impl Fixture { } } - fn load(&self) -> (Wallet, ManagedWalletInfo) { + async fn load(&self) -> (Wallet, ManagedWalletInfo) { + load_replayed(&self.persister, &self.wallet_id).await + } + + /// The persister's projection alone, before the shared replay. + fn load_projection(&self) -> ManagedWalletInfo { let mut state = self.persister.load().unwrap(); - let restored = state.wallets.remove(&self.wallet_id).unwrap(); - (restored.wallet, restored.wallet_info) + state.wallets.remove(&self.wallet_id).unwrap().wallet_info } fn assert_spent_excluded(&self, info: &ManagedWalletInfo) { @@ -212,7 +230,7 @@ impl Fixture { }, ) .unwrap(); - let (_, reloaded) = self.load(); + let (_, reloaded) = self.load().await; self.assert_spent_excluded(&reloaded); } } @@ -220,7 +238,7 @@ impl Fixture { #[tokio::test] async fn should_reject_spent_output_after_reload_and_funding_redelivery() { let fixture = Fixture::new(block(200)).await; - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; fixture.assert_spent_excluded(&info); fixture.redeliver(&mut wallet, &mut info).await; } @@ -228,7 +246,7 @@ async fn should_reject_spent_output_after_reload_and_funding_redelivery() { #[tokio::test] async fn should_keep_spent_output_excluded_after_finality_pruning() { let fixture = Fixture::new(block(200)).await; - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; info.apply_chain_lock(ChainLock { block_height: 300, block_hash: BlockHash::from_byte_array([30; 32]), @@ -247,7 +265,7 @@ async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { .lock_conn_for_test() .execute("UPDATE core_utxos SET spent = 0", []) .unwrap(); - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; fixture.assert_spent_excluded(&info); fixture.redeliver(&mut wallet, &mut info).await; } @@ -255,7 +273,7 @@ async fn should_reconcile_stale_unspent_projection_against_confirmed_history() { #[tokio::test] async fn should_not_release_inputs_reserved_by_unconfirmed_spend() { let fixture = Fixture::new(TransactionContext::Mempool).await; - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; fixture.assert_spent_excluded(&info); assert!(!info.observed_spent_outpoints().contains_key(&fixture.spent)); fixture.redeliver(&mut wallet, &mut info).await; @@ -266,7 +284,7 @@ async fn should_not_release_inputs_reserved_by_unconfirmed_spend() { async fn should_keep_unconfirmed_funding_reserved_when_it_confirms_after_reload() { let fixture = Fixture::with_funding(TransactionContext::Mempool, TransactionContext::Mempool).await; - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; assert!(!info.accounts.standard_bip44_accounts[&0] .utxos .contains_key(&fixture.spent)); @@ -277,7 +295,7 @@ async fn should_keep_unconfirmed_funding_reserved_when_it_confirms_after_reload( #[tokio::test] async fn should_handle_funding_and_spend_in_the_same_block() { let fixture = Fixture::new(block(100)).await; - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; fixture.assert_spent_excluded(&info); fixture.redeliver(&mut wallet, &mut info).await; } @@ -288,7 +306,7 @@ fn should_restore_without_an_async_runtime() { .build() .unwrap() .block_on(Fixture::new(block(200))); - let (_, info) = fixture.load(); + let info = fixture.load_projection(); fixture.assert_spent_excluded(&info); } @@ -296,7 +314,7 @@ fn should_restore_without_an_async_runtime() { async fn should_restore_on_managers_blocking_pool() { let fixture = Fixture::new(block(200)).await; tokio::task::spawn_blocking(move || { - let (_, info) = fixture.load(); + let info = fixture.load_projection(); fixture.assert_spent_excluded(&info); }) .await @@ -325,7 +343,7 @@ async fn should_restore_persisted_finality_before_advancing_sync_checkpoint() { }, ) .unwrap(); - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; assert!(info.accounts.standard_bip44_accounts[&0] .keys() .transaction_is_finalized(&fixture.funding.txid())); @@ -456,8 +474,7 @@ async fn should_keep_replayed_output_only_in_its_owning_account() { .unwrap(); } - let mut state = persister.load().unwrap(); - let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + let (_, info) = load_replayed(&persister, &wallet.wallet_id).await; let holders = |outpoint: &OutPoint| { info.accounts .all_funding_accounts() @@ -586,8 +603,7 @@ async fn should_drop_replay_credit_for_contact_only_script() { .unwrap(); } - let mut state = persister.load().unwrap(); - let info = state.wallets.remove(&wallet.wallet_id).unwrap().wallet_info; + let (_, info) = load_replayed(&persister, &wallet.wallet_id).await; assert!( info.accounts .all_funding_accounts() diff --git a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs index 6eab3d88780..40ab85d04d0 100644 --- a/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_wallet_start_state.rs @@ -8,6 +8,7 @@ use std::collections::BTreeMap; use crate::changeset::identity_manager_start_state::IdentityManagerStartState; +use crate::changeset::recorded_history::RecordedHistory; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use dashcore::{OutPoint, Transaction}; use key_wallet::wallet::ManagedWalletInfo; @@ -59,5 +60,17 @@ pub struct ClientWalletStartState { /// input came back as spendable and the balance re-counted it, /// permanently. Replaying the record at load restores exactly the /// state the live process held. + /// + /// A send that is also in [`recorded_history`](Self::recorded_history) + /// is accounted by the history replay alone; this list then only + /// drives the load-time re-dispatch. pub unconfirmed_outgoing_txs: Vec, + /// The wallet's stored transaction history, replayed at load through the + /// wallet checker so confirmed and unconfirmed spends are guarded again — + /// see [`RecordedHistory`]. Empty when the persister supplies none. + /// + /// Like `unconfirmed_outgoing_txs`, applied at the async boundary in + /// [`load_from_persistor`](crate::manager::load), not while the snapshot + /// is built. + pub recorded_history: RecordedHistory, } diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 4125b8df568..d10be2e403e 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -20,6 +20,7 @@ pub mod persistence_capabilities; pub mod platform_address_sync_start_state; #[cfg(any(feature = "bls", feature = "eddsa"))] pub mod provider_key_account; +pub mod recorded_history; #[cfg(feature = "serde")] pub mod serde_adapters; #[cfg(feature = "shielded")] @@ -51,6 +52,7 @@ pub use identity_scan_state::IdentityScanStateEntry; pub use merge::Merge; pub use persistence_capabilities::{PersistenceCapabilities, PERSISTENCE_CAPABILITIES_VERSION}; pub use platform_address_sync_start_state::PlatformAddressSyncStartState; +pub use recorded_history::{RecordedHistory, StoredTransaction}; #[cfg(feature = "shielded")] pub use shielded_changeset::ShieldedChangeSet; #[cfg(not(feature = "shielded"))] diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs new file mode 100644 index 00000000000..8fdd0a4e9f6 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -0,0 +1,68 @@ +//! Stored transaction history a persister hands back at load. +//! +//! The replay that consumes it lives in +//! [`history_replay`](crate::manager::history_replay); this module only +//! carries the persister-agnostic input shape. + +use std::collections::BTreeMap; + +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::{Transaction, Txid}; +use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; +use key_wallet::transaction_checking::TransactionContext; + +/// Every stored transaction of one wallet, replayed at load to rebuild spend guards. +/// +/// Persisted UTXO rows say which outputs are unspent, but not which outpoints +/// the wallet has seen spent. Without that in-memory state a redelivered +/// funding transaction (rescan, gap-limit rediscovery) re-credits an output +/// that a confirmed spend already consumed. Replaying the stored history +/// through the wallet checker rebuilds it; the persisted UTXO set stays +/// authoritative for which outputs are credited. +/// +/// Empty means "no history supplied": load keeps the projection as restored. +#[derive(Debug, Clone, Default)] +pub struct RecordedHistory { + /// Stored transactions, in any order; the replay orders them. + pub transactions: Vec, + /// Persisted InstantSend locks keyed by the txid they lock. A lock that + /// arrived after its transaction was stored never rewrote the stored + /// context, so the replay upgrades a matching mempool record itself. + pub instant_locks: BTreeMap, +} + +impl RecordedHistory { + /// Whether the persister supplied no history at all. + pub fn is_empty(&self) -> bool { + self.transactions.is_empty() + } +} + +/// One stored transaction as its persister keeps it. +#[derive(Debug, Clone)] +pub struct StoredTransaction { + /// The txid the store keys this row under. Kept apart from + /// `transaction` because the two are not proof of each other: a lock is + /// only trusted when both agree with it. + pub txid: Txid, + /// The consensus transaction body. + pub transaction: Transaction, + /// The stored confirmation context. + pub context: TransactionContext, + /// The wallet-level net amount the store holds, when it keeps one. + pub stored_net_amount: Option, + /// The wallet-level direction the store holds, when it keeps one. + pub stored_direction: Option, +} + +impl From for StoredTransaction { + fn from(record: TransactionRecord) -> Self { + Self { + txid: record.txid, + stored_net_amount: Some(record.net_amount), + stored_direction: Some(record.direction), + transaction: record.transaction, + context: record.context, + } + } +} diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs new file mode 100644 index 00000000000..ef1cf2e5e0b --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -0,0 +1,926 @@ +//! Load-time replay of stored transaction history. +//! +//! Every persister hands back its stored records as a [`RecordedHistory`]; +//! [`replay_recorded_history`] runs them through the wallet checker so the +//! in-memory spend state (`spent_outpoints`, `observed_spent_outpoints`, +//! InstantSend upgrades) matches what a live process held. Without it a +//! redelivered funding transaction re-credits an output a confirmed spend +//! already consumed. + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use dashcore::ephemerealdata::instant_lock::InstantLock; +use dashcore::{OutPoint, Txid}; +use key_wallet::account::AccountType; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::ManagedCoreFundsAccount; +use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; + +use crate::changeset::{RecordedHistory, StoredTransaction}; + +/// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. +/// +/// The persisted UTXO set already restored onto `wallet_info` stays +/// authoritative for credits: an output the replay credits but persistence +/// did not hold as unspent is dropped again, so the replay only ever removes. +/// +/// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a +/// redelivered funding transaction would re-credit an output they reserve. +/// A persisted lock upgrades its mempool record to InstantSend, because a lock +/// that arrived after its transaction was stored never rewrote the record. +/// +/// Records whose txid an account already holds are skipped: a persister that +/// restored them itself (asset-lock funding, provider special transactions) +/// owns them, and the checker treats a known mempool transaction as a no-op. +/// +/// Returns how many records were replayed. +pub async fn replay_recorded_history( + wallet_info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + history: RecordedHistory, +) -> usize { + let RecordedHistory { + transactions, + instant_locks, + } = history; + if transactions.is_empty() { + return 0; + } + // Where the load projection parked each unspent outpoint; its keys are + // the outputs persistence still considers unspent. + let placed: HashMap = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + account.utxos.keys().map(move |outpoint| (*outpoint, owner)) + }) + .collect(); + let held: HashSet = transactions + .iter() + .map(|stored| stored.txid) + .filter(|txid| { + wallet_info + .accounts + .all_accounts() + .into_iter() + .any(|account| account.has_transaction(txid)) + }) + .collect(); + if !held.is_empty() { + tracing::debug!( + wallet_id = %hex::encode(wallet_info.wallet_id), + skipped = held.len(), + "load replay: skipped records the persister already restored" + ); + } + // TODO(bound-load-history-replay): every stored record is replayed on each + // load; bounding it to records above the last chain lock needs care so + // finality and spend guards for older records are not lost. + // TODO(expire-unconfirmed-spend-reservations): mempool records are replayed + // on every load with no expiry, so a forged or never-mined spend of a wallet + // outpoint keeps its reservation across load and rescan; only a conflicting + // IS-locked or confirmed spend releases it. Sibling of + // TODO(release-repair-spends-after-reorg) in the SQLite `core_history`. + let replay = replay_order(transactions); + + let mut replayed = 0usize; + for stored in replay { + if held.contains(&stored.txid) { + continue; + } + // The lock set already holds this txid (load marked the restored + // UTXOs), so a later lock event is deduplicated: the InstantSend + // context, and the conflict sweep it runs, must come from here. + let lock = instant_locks + .get(&stored.txid) + .filter(|lock| lock_matches_record(lock, &stored)); + let context = match (stored.context, lock) { + (TransactionContext::Mempool, Some(lock)) => { + TransactionContext::InstantSend(lock.clone()) + } + (context, _) => context, + }; + wallet_info + .check_core_transaction(&stored.transaction, context, wallet, true, false) + .await; + replayed += 1; + } + + let spent: HashSet<_> = wallet_info + .observed_spent_outpoints() + .keys() + .copied() + .collect(); + // Replay credits an output to the account whose pool derives it. When + // that differs from the load-time fallback, the fallback copy is a + // duplicate: drop it so each outpoint lives in exactly one account. + let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| { + let owner = funds_account_type(account); + let placed = &placed; + account.utxos.keys().filter_map(move |outpoint| { + placed + .get(outpoint) + .filter(|parked| **parked != owner) + .map(|parked| (*outpoint, *parked)) + }) + }) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + account.utxos.retain(|outpoint, _| { + placed.contains_key(outpoint) + && !spent.contains(outpoint) + && !misplaced.contains(&(*outpoint, owner)) + }); + } + // Finalize replayed records before a sync checkpoint can prune their spend guards. + if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { + wallet_info.apply_chain_lock(chain_lock); + } + wallet_info.update_balance(); + replayed +} + +/// Whether `lock` really locks `stored`, so upgrading its context is safe. +/// +/// The lock map is keyed by the stored txid and a record's txid is stored +/// beside its transaction, so neither is proof on its own. A mismatch keeps +/// the stored context: the lock's conflict sweep must not drop history on the +/// strength of a lock that belongs to another transaction. +fn lock_matches_record(lock: &InstantLock, stored: &StoredTransaction) -> bool { + let transaction_txid = stored.transaction.txid(); + let matches = lock.txid == stored.txid && transaction_txid == stored.txid; + if !matches { + tracing::warn!( + record_txid = %stored.txid, + transaction_txid = %transaction_txid, + lock_txid = %lock.txid, + "persisted InstantSend lock does not match its transaction record; replaying without it" + ); + } + matches +} + +/// Order records as the chain would deliver them: every in-set parent ahead of +/// its children, otherwise confirmed by block position, then unconfirmed. +/// +/// Dependencies span both partitions: a parent's stored record can still say +/// mempool after it confirmed (a height-only confirmation never rewrites an +/// existing record), while its child's record is already confirmed. +fn replay_order(records: Vec) -> Vec { + let mut records = records; + records.sort_by_key(|record| { + let block = record.context.block_info(); + ( + block.is_none(), + block.map(|block| (block.height(), block.position())), + record.txid, + ) + }); + let index: HashMap = records + .iter() + .enumerate() + .map(|(position, record)| (record.txid, position)) + .collect(); + let mut children: Vec> = vec![Vec::new(); records.len()]; + let mut waiting_on: Vec = vec![0; records.len()]; + for (child, record) in records.iter().enumerate() { + let parents: BTreeSet = record + .transaction + .input + .iter() + .filter_map(|input| index.get(&input.previous_output.txid).copied()) + .filter(|parent| *parent != child) + .collect(); + waiting_on[child] = parents.len(); + for parent in parents { + children[parent].push(child); + } + } + // Sorted positions, so the smallest ready one is always next in chain order. + let mut ready: BTreeSet = (0..records.len()) + .filter(|position| waiting_on[*position] == 0) + .collect(); + let mut emitted = vec![false; records.len()]; + let mut order = Vec::with_capacity(records.len()); + while let Some(position) = ready.pop_first() { + emitted[position] = true; + order.push(position); + for &child in &children[position] { + waiting_on[child] -= 1; + if waiting_on[child] == 0 { + ready.insert(child); + } + } + } + // Unreachable for real transactions (txids cannot form a cycle); keep the + // rest in chain order rather than drop a reservation. + order.extend((0..records.len()).filter(|position| !emitted[*position])); + let mut slots: Vec> = records.into_iter().map(Some).collect(); + order + .into_iter() + .filter_map(|position| slots[position].take()) + .collect() +} + +/// Account identity of a funds account, stable across replay mutations. +fn funds_account_type(account: &ManagedCoreFundsAccount) -> AccountType { + account.managed_account_type().to_account_type() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + use key_wallet::managed_account::transaction_record::TransactionRecord; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + + /// Stored history built from checker-produced records. + fn history( + records: Vec, + instant_locks: BTreeMap, + ) -> RecordedHistory { + RecordedHistory { + transactions: records.into_iter().map(StoredTransaction::from).collect(), + instant_locks, + } + } + + /// A fresh random wallet and its first BIP44 receive address. + fn wallet_with_receive_address() -> (Wallet, ManagedWalletInfo, dashcore::Address) { + let wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + (wallet, info, address) + } + + /// A confirmed funding with two outputs and a confirmed spend of output 0, + /// as checker-produced records, plus the persisted projection that holds + /// only the unspent output 1. + async fn confirmed_spend_fixture() -> ( + Wallet, + ManagedWalletInfo, + dashcore::Transaction, + Vec, + OutPoint, + ) { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([31; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let spent = OutPoint::new(funding.txid(), 0); + let available = OutPoint::new(funding.txid(), 1); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, block(101), &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + available, + Utxo { + outpoint: available, + txout: funding.output[1].clone(), + address, + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + restored.update_balance(); + (wallet, restored, funding, records, spent) + } + + /// The bug the replay exists for: a funding transaction redelivered after + /// load (rescan, gap-limit rediscovery) must not resurrect an output a + /// confirmed spend already consumed. The control half pins that the same + /// redelivery does resurrect it when no history is replayed. + #[tokio::test] + async fn should_not_resurrect_confirmed_spent_output_on_funding_redelivery() { + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let redelivery = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([100; 32]), + 100, + )); + + let mut replayed = restored.clone(); + let count = replay_recorded_history( + &mut replayed, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await; + assert_eq!(count, 2); + replayed + .check_core_transaction(&funding, redelivery.clone(), &mut wallet, true, true) + .await; + assert!( + !replayed.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "a redelivered funding must not re-credit a confirmed-spent output" + ); + assert_eq!(replayed.balance.total(), 20_000); + + let mut unguarded = restored; + unguarded + .check_core_transaction(&funding, redelivery, &mut wallet, true, true) + .await; + assert!( + unguarded.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "control: without the replay the redelivery resurrects the output" + ); + } + + /// A record the persister already restored itself (asset-lock funding, + /// provider special transactions) stays the persister's: the replay must + /// not count it, since the checker treats a known mempool transaction as + /// a no-op and would mask that as applied. + #[tokio::test] + async fn should_skip_records_an_account_already_holds() { + let (mut wallet, mut restored, _, records, _) = confirmed_spend_fixture().await; + let held = records[1].clone(); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(held.txid, held); + + let count = replay_recorded_history( + &mut restored, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await; + assert_eq!(count, 1, "only the record no account held is replayed"); + } + + /// An empty history leaves the restored projection untouched. + #[tokio::test] + async fn should_leave_projection_untouched_without_history() { + let (mut wallet, restored, _, _, _) = confirmed_spend_fixture().await; + let mut info = restored.clone(); + let count = + replay_recorded_history(&mut info, &mut wallet, RecordedHistory::default()).await; + assert_eq!(count, 0); + assert_eq!(info.balance.total(), restored.balance.total()); + assert_eq!( + info.accounts.standard_bip44_accounts[&0].utxos.len(), + restored.accounts.standard_bip44_accounts[&0].utxos.len() + ); + } + + /// Same-block funding and spend, with and without in-block positions: an + /// output the load projection still parks as unspent must end up excluded, + /// and recorded as observed spent, whichever of the two is stored first. + #[tokio::test] + async fn should_exclude_spent_output_for_either_same_height_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + for (spend_first, positioned) in + [(false, false), (true, false), (false, true), (true, true)] + { + let case = format!("spend_first={spend_first} positioned={positioned}"); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([15; 32]), 0), + ..Default::default() + }], + output: [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + special_transaction_payload: None, + }; + let (spent, available) = ( + OutPoint::new(funding.txid(), 0), + OutPoint::new(funding.txid(), 1), + ); + let spending = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let context = |position: u32| { + let block = BlockInfo::new(100, BlockHash::from_byte_array([7; 32]), 100); + TransactionContext::InBlock(if positioned { + block.with_position(position) + } else { + block + }) + }; + let mut records = info + .check_core_transaction(&funding, context(1), &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction(&spending, context(2), &mut wallet, true, true) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + assert!(records.iter().all(|r| r + .block_info() + .is_some_and(|b| b.position().is_some() == positioned))); + if spend_first { + records.reverse(); + } + + // A stale projection that still parks the spent output as unspent. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for outpoint in [spent, available] { + account.utxos.insert( + outpoint, + Utxo { + outpoint, + txout: funding.output[outpoint.vout as usize].clone(), + address: address.clone(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }, + ); + } + replay_recorded_history( + &mut restored, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await; + + let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; + assert!(!coins.contains_key(&spent), "{case}"); + assert!(coins.contains_key(&available), "{case}"); + assert!( + restored.observed_spent_outpoints().contains_key(&spent), + "{case}" + ); + assert_eq!(restored.balance.total(), 20_000, "{case}"); + } + } + + /// A lock that arrived after its transaction was stored lives only in + /// `core_instant_locks`; the stored record still says mempool. Replay must + /// restore the InstantSend context and run its conflict sweep, since the + /// already-marked lock deduplicates any later lock event. + #[tokio::test] + async fn should_replay_mempool_record_with_persisted_lock_as_instant_send() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([21; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = |value| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + // Both double spends as stored: unconfirmed, recorded independently. + let (mut winner, mut loser) = (spend(99_000), spend(98_000)); + for tx in [&winner, &loser] { + let mut scratch = info.clone(); + records.extend( + scratch + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + assert_eq!(records.len(), 3); + // Unconfirmed siblings replay by txid: lock the later one so the + // loser is already recorded when the winner's sweep runs. + if winner.txid() < loser.txid() { + std::mem::swap(&mut winner, &mut loser); + } + let lock = InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid: winner.txid(), + ..Default::default() + }; + let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); + + let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); + + // Alone, the locked spend comes back InstantSend. + let mut alone = ManagedWalletInfo::from_wallet(&wallet, 0); + let pair = vec![record_of(funding.txid()), record_of(winner.txid())]; + replay_recorded_history(&mut alone, &mut wallet, history(pair, locks.clone())).await; + assert!( + alone.accounts.standard_bip44_accounts[&0] + .transactions() + .get(&winner.txid()) + .is_some_and(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "the locked record must come back InstantSend, not mempool" + ); + + // Replayed after a conflicting spend, its lock sweeps that spend. + let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); + replay_recorded_history( + &mut contested, + &mut wallet, + history(records.clone(), locks.clone()), + ) + .await; + assert!( + !contested.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid()), + "the lock's conflict sweep must drop the competing spend" + ); + } + + /// A persisted lock is trusted only when it names the record it is keyed + /// under and that record's transaction really has that txid; otherwise the + /// record replays in its stored mempool context and no sweep runs. + #[tokio::test] + async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([23; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let spend = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([8; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + records.extend( + info.check_core_transaction( + &spend, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 2); + let foreign = Txid::from_byte_array([24; 32]); + let lock_for = |txid| InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid, + ..Default::default() + }; + let mut forged_record = records.clone(); + forged_record + .iter_mut() + .find(|record| record.txid == spend.txid()) + .unwrap() + .txid = foreign; + let cases = [ + // The lock row is keyed under the record but locks another txid. + ( + "lock txid", + records.clone(), + spend.txid(), + lock_for(foreign), + ), + // Record and lock agree, but the record's transaction is another one. + ("record txid", forged_record, foreign, lock_for(foreign)), + ]; + for (case, records, key, lock) in cases { + let locks: BTreeMap = [(key, lock)].into_iter().collect(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + replay_recorded_history(&mut restored, &mut wallet, history(records, locks.clone())) + .await; + let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions + .values() + .any(|record| matches!(record.context, TransactionContext::InstantSend(_))), + "{case}: a mismatched lock must not upgrade any record" + ); + assert!( + transactions.contains_key(&spend.txid()), + "{case}: the spend must still replay in its stored context" + ); + } + } + + /// A record spending `parents` (output 0 of each); `value` keeps txids distinct. + fn replay_record( + parents: &[Txid], + value: u64, + context: TransactionContext, + ) -> TransactionRecord { + use dashcore::{Transaction, TxIn, TxOut}; + use key_wallet::account::StandardAccountType; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionType; + + let transaction = Transaction { + version: 1, + lock_time: 0, + input: parents + .iter() + .map(|parent| TxIn { + previous_output: OutPoint::new(*parent, 0), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + TransactionRecord::new( + transaction, + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) + } + + fn in_block(height: u32, position: Option) -> TransactionContext { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::BlockInfo; + + let block = BlockInfo::new(height, dashcore::BlockHash::all_zeros(), height); + TransactionContext::InBlock(position.map_or(block, |p| block.with_position(p))) + } + + fn replayed_txids(records: Vec) -> Vec { + replay_order(records.into_iter().map(Into::into).collect()) + .into_iter() + .map(|r| r.txid) + .collect() + } + + /// An unconfirmed child whose txid sorts ahead of its parent's still + /// replays after it, so its input reserves the parent's output. + #[test] + fn should_replay_unconfirmed_parent_before_its_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([1; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = (0..) + .map(|value| replay_record(&[parent.txid], value, TransactionContext::Mempool)) + .find(|child| child.txid < parent.txid) + .unwrap(); + let expected = vec![parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, parent]), expected); + } + + /// A parent whose stored record is still mempool replays ahead of a child + /// already recorded as confirmed. + #[test] + fn should_replay_mempool_parent_before_its_confirmed_child() { + use dashcore::hashes::Hash; + + let parent = replay_record( + &[Txid::from_byte_array([2; 32])], + 1_000, + TransactionContext::Mempool, + ); + let child = replay_record(&[parent.txid], 900, in_block(50, Some(3))); + let unrelated = replay_record(&[Txid::from_byte_array([3; 32])], 700, in_block(40, None)); + let expected = vec![unrelated.txid, parent.txid, child.txid]; + + assert_eq!(replayed_txids(vec![child, unrelated, parent]), expected); + } + + /// Independent records follow chain order: height, then in-block + /// position, then unconfirmed. + #[test] + fn should_order_independent_records_by_height_then_block_position() { + use dashcore::hashes::Hash; + + let funding = |marker| [Txid::from_byte_array([marker; 32])]; + let pending = replay_record(&funding(4), 1, TransactionContext::Mempool); + let late_second = replay_record(&funding(5), 2, in_block(10, Some(2))); + let late_first = replay_record(&funding(6), 3, in_block(10, Some(1))); + let early = replay_record(&funding(7), 4, in_block(9, Some(5))); + let expected = vec![early.txid, late_first.txid, late_second.txid, pending.txid]; + + assert_eq!( + replayed_txids(vec![pending, late_second, late_first, early]), + expected + ); + } + + /// Within one block, in-block position decides: a spend follows the + /// funding transaction it spends, and unrelated transactions keep their + /// place around the pair. + #[test] + fn should_keep_block_position_order_for_same_block_spends() { + use dashcore::hashes::Hash; + + let parent = replay_record(&[Txid::from_byte_array([9; 32])], 10, in_block(20, Some(1))); + let child = replay_record(&[parent.txid], 9, in_block(20, Some(2))); + let before = replay_record(&[Txid::from_byte_array([10; 32])], 8, in_block(20, Some(0))); + let after = replay_record(&[Txid::from_byte_array([11; 32])], 7, in_block(20, Some(3))); + let expected = vec![before.txid, parent.txid, child.txid, after.txid]; + + assert_eq!(replayed_txids(vec![after, child, before, parent]), expected); + } + + /// Records that name each other as parents (impossible for real txids) + /// still all replay, after everything that is ready. + #[test] + fn should_keep_every_record_of_a_dependency_cycle() { + use dashcore::hashes::Hash; + + let (a, b) = ( + Txid::from_byte_array([0xAA; 32]), + Txid::from_byte_array([0xBB; 32]), + ); + let mut first = replay_record(&[b], 1, TransactionContext::Mempool); + first.txid = a; + let mut second = replay_record(&[a], 2, TransactionContext::Mempool); + second.txid = b; + let ready = replay_record( + &[Txid::from_byte_array([8; 32])], + 3, + TransactionContext::Mempool, + ); + let expected = vec![ready.txid, a, b]; + + assert_eq!(replayed_txids(vec![second, first, ready]), expected); + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1bbd0330172..d3db89aa713 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,10 +1,11 @@ //! Hydrate a [`PlatformWalletManager`] from its persister. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; use crate::error::PlatformWalletError; +use crate::manager::history_replay::replay_recorded_history; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -13,6 +14,7 @@ use crate::wallet::PlatformWallet; use std::time::Duration; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; +use dashcore::Txid; use key_wallet::transaction_checking::transaction_context::TransactionContext; use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; @@ -129,8 +131,30 @@ impl PlatformWalletManager

{ identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, + recorded_history, } = wallet_state; + // Replay the stored history first, in chain order, so every spend + // the wallet ever saw guards its outpoint again. The persisted + // UTXO set stays authoritative for credits; see + // `replay_recorded_history`. + let history_txids: HashSet = recorded_history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + if !recorded_history.is_empty() { + let offered = recorded_history.transactions.len(); + let replayed = + replay_recorded_history(&mut wallet_info, &mut wallet, recorded_history).await; + tracing::info!( + wallet_id = %hex::encode(expected_wallet_id), + offered, + replayed, + "load: replayed stored transaction history" + ); + } + // Replay the sends the host still holds as unconfirmed, before // anything reads the restored balance. // @@ -159,7 +183,12 @@ impl PlatformWalletManager

{ // below, and the UI reads that. if !unconfirmed_outgoing_txs.is_empty() { let mut replayed = 0usize; - for tx in &unconfirmed_outgoing_txs { + // A send the history replay already applied is only + // re-dispatched below, never accounted twice. + for tx in unconfirmed_outgoing_txs + .iter() + .filter(|tx| !history_txids.contains(&tx.txid())) + { let result = wallet_info .check_core_transaction( tx, @@ -674,12 +703,14 @@ mod idempotent_load_tests { use crate::wallet::core::WalletGeneration; use key_wallet::test_utils::TestWalletContext; + use key_wallet::transaction_checking::transaction_context::TransactionContext; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; use crate::changeset::{ ClientStartState, ClientWalletStartState, IdentityManagerStartState, PersistenceError, - PlatformWalletChangeSet, PlatformWalletPersistence, + PlatformWalletChangeSet, PlatformWalletPersistence, RecordedHistory, StoredTransaction, }; use crate::events::PlatformEventHandler; use crate::test_support::NoopTestEventHandler; @@ -718,6 +749,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: self.pending.clone(), + recorded_history: Default::default(), }, ); Ok(ClientStartState { @@ -784,6 +816,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), }, ); Ok(ClientStartState { @@ -823,6 +856,7 @@ mod idempotent_load_tests { identity_manager: IdentityManagerStartState::default(), unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), }; let mut wallets = BTreeMap::new(); wallets.insert(self.wallet.compute_wallet_id(), entry()); @@ -1130,6 +1164,125 @@ mod idempotent_load_tests { ); } + /// Persister that hands back a projection holding only the persisted + /// UTXO set (no in-memory transactions) plus the stored history — the + /// shape every persister produces once history replay is shared. + struct RecordedHistoryPersister { + wallet: Wallet, + managed: ManagedWalletInfo, + history: RecordedHistory, + } + + impl PlatformWalletPersistence for RecordedHistoryPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + let mut wallets = BTreeMap::new(); + wallets.insert( + self.wallet.compute_wallet_id(), + ClientWalletStartState { + wallet: self.wallet.clone(), + wallet_info: self.managed.clone(), + identity_manager: IdentityManagerStartState::default(), + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + recorded_history: self.history.clone(), + }, + ); + Ok(ClientStartState { + wallets, + ..Default::default() + }) + } + } + + /// `load_from_persistor` replays the stored history the persister hands + /// back, whatever the persister: a stored spend of the only coin must + /// leave nothing spendable even though the persisted UTXO set still + /// lists that coin (an unconfirmed spend never flips `isSpent`). + #[tokio::test] + async fn load_replays_the_recorded_history_of_any_persister() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let funded_outpoint = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let spend = spend_to(funded_outpoint, 74_000); + + // The persisted projection: the coin as an unspent row, no history. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + let coin = + ctx.managed_wallet.accounts.standard_bip44_accounts[&0].utxos[&funded_outpoint].clone(); + projection + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .utxos + .insert(funded_outpoint, coin); + projection.update_balance(); + assert_eq!(projection.balance.total(), 100_000); + + let stored = |transaction: dashcore::Transaction| StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + }; + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + transactions: vec![stored(spend), stored(funding)], + instant_locks: BTreeMap::new(), + }, + }); + + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wallet = manager + .get_wallet(&wallet_id) + .await + .expect("the loaded wallet must be registered"); + let balance = wallet.balance(); + let total = balance.confirmed() + balance.unconfirmed(); + assert_eq!( + total, 0, + "the replayed stored spend consumes the only coin; {} duffs left \ + means the history was not replayed", + total + ); + } + + fn make_history_manager( + persister: RecordedHistoryPersister, + ) -> Arc> { + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(persister), + event_handler, + )) + } + fn make_pending_manager( persister: PendingSendPersister, ) -> Arc> { diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 51a57c32451..e534692e0f1 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -3,6 +3,7 @@ pub mod accessors; pub mod dashpay_sync; pub mod dpns_sync; +pub mod history_replay; pub mod identity_sync; mod load; mod persistence_load; diff --git a/packages/rs-platform-wallet/src/manager/startup.rs b/packages/rs-platform-wallet/src/manager/startup.rs index 04c07d50832..fc08c239707 100644 --- a/packages/rs-platform-wallet/src/manager/startup.rs +++ b/packages/rs-platform-wallet/src/manager/startup.rs @@ -1842,6 +1842,7 @@ mod tests { identity_manager: crate::changeset::IdentityManagerStartState::default(), unused_asset_locks: std::collections::BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), }, ); Ok(crate::changeset::ClientStartState { From bfc9b9f5d3d127a1c2836acebc1bec882cd33305 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:01 +0000 Subject: [PATCH 2/5] feat(platform-wallet): add CORE_HISTORY_RESTORE persistence capability Bit 13 attests that a persister's load hands back the wallet's complete stored transaction history, so the shared load replay rebuilds the spend guards for confirmed spends too. The SQLite persister attests it; the FFI mirrors the bit value as a C constant (host wiring follows). Diagnostic only: `load_from_persistor` warns once per load when a persister restores wallets without it, and no operation is gated on it. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 8 ++++++++ .../src/sqlite/persister.rs | 4 +++- .../src/changeset/persistence_capabilities.rs | 17 +++++++++++++++-- .../rs-platform-wallet/src/manager/load.rs | 19 ++++++++++++++++++- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 61114998d8a..26c5205be00 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -154,6 +154,10 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_ASSET_LOCKS: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << 10; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; +/// Load hands back the stored transaction history through +/// `WalletRestoreEntryFFI::recorded_transactions`. Honoured only with the +/// wallet-list load callback pair wired. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_HISTORY_RESTORE: u64 = 1 << 13; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -8764,6 +8768,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS, PersistenceCapabilities::DASHPAY_PAYMENTS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_HISTORY_RESTORE, + PersistenceCapabilities::CORE_HISTORY_RESTORE.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index b201f3343a3..12c380af355 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1321,7 +1321,9 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) - .union(PersistenceCapabilities::DASHPAY_PAYMENTS); + .union(PersistenceCapabilities::DASHPAY_PAYMENTS) + // `load` hands every stored record back as `recorded_history`. + .union(PersistenceCapabilities::CORE_HISTORY_RESTORE); #[cfg(feature = "shielded")] { capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS) diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index d930daf1ec9..ec89324be04 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -105,6 +105,14 @@ impl PersistenceCapabilities { /// declaration only when `on_persist_dashpay_payments_fn` is actually /// wired. pub const DASHPAY_PAYMENTS: Self = Self(1 << 12); + /// Load hands back the wallet's complete stored transaction history + /// ([`ClientWalletStartState::recorded_history`](super::ClientWalletStartState::recorded_history)), + /// so the shared load replay rebuilds the in-memory spend guards for + /// confirmed AND unconfirmed spends. Without it a funding transaction + /// redelivered after restart (rescan, gap-limit rediscovery) re-credits an + /// output a confirmed spend already consumed until the spend is observed + /// again. Diagnostic only: load warns when it is absent and gates nothing. + pub const CORE_HISTORY_RESTORE: Self = Self(1 << 13); /// Index of the highest bit declared above. It lives here, beside the /// constants, so adding a bit and bumping this is one edit in one place @@ -112,7 +120,7 @@ impl PersistenceCapabilities { /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 12; + const HIGHEST_DECLARED_BIT: u32 = 13; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -205,6 +213,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::DASHPAY_PAYMENTS, "dashpay_payments", ), + ( + PersistenceCapabilities::CORE_HISTORY_RESTORE, + "core_history_restore", + ), ]; KNOWN @@ -222,7 +234,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::DASHPAY_PAYMENTS.bits() + PersistenceCapabilities::CORE_HISTORY_RESTORE.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -250,6 +262,7 @@ mod tests { assert_eq!(PersistenceCapabilities::TRACKED_MASTERNODES.bits(), 0x400); assert_eq!(PersistenceCapabilities::CORE_SWEEP_REMOVAL.bits(), 0x800); assert_eq!(PersistenceCapabilities::DASHPAY_PAYMENTS.bits(), 0x1000); + assert_eq!(PersistenceCapabilities::CORE_HISTORY_RESTORE.bits(), 0x2000); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), 0x281 diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index d3db89aa713..c4fe8fefbfc 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -3,7 +3,9 @@ use std::collections::{BTreeMap, HashSet}; use std::sync::Arc; -use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; +use crate::changeset::{ + ClientStartState, ClientWalletStartState, PersistenceCapabilities, PlatformWalletPersistence, +}; use crate::error::PlatformWalletError; use crate::manager::history_replay::replay_recorded_history; use crate::wallet::core::WalletGeneration; @@ -90,6 +92,21 @@ impl PlatformWalletManager

{ shielded: _, } = start_state; + // Without stored history the load replay has nothing to rebuild + // confirmed-spend guards from; say so rather than degrade silently. + if !wallets.is_empty() + && !self + .persister + .persistence_capabilities() + .contains(PersistenceCapabilities::CORE_HISTORY_RESTORE) + { + tracing::warn!( + wallets = wallets.len(), + "load: persister does not restore transaction history; confirmed-spend \ + guards are not rebuilt until the spends are observed again" + ); + } + // Tracked (wallet-independent) masternodes ride the same startup // hydration; a failure logs and starts empty rather than failing // wallet restore. From ba6f6b18cbaa299f46ab4ebc147bea989f1da0b0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:42:44 +0000 Subject: [PATCH 3/5] feat(platform-wallet-ffi)!: restore recorded transaction history on load The FFI load path handed Rust only the host's unspent UTXO rows plus its unconfirmed outgoing sends, so confirmed spends were never replayed: after restart a redelivered funding transaction (rescan, gap-limit rediscovery) re-credited an output a confirmed spend had already consumed. Hosts can now hand back every stored transaction through `WalletRestoreEntryFFI::recorded_transactions` (`RecordedTransactionRestoreFFI`: txid, bytes, context, block fields, stored net amount and direction). `build_wallet_start_state` decodes them into `ClientWalletStartState::recorded_history` for the shared load replay. A row whose bytes do not hash to its txid, that does not decode, or that carries an unknown context is dropped with a counted warning; InstantSend rows replay as mempool because the host keeps no lock bytes. The FFI persister attests `CORE_HISTORY_RESTORE` when the host declares it and wires the wallet-list load pair. Android does not supply history yet (null/0, TODO in the JNI bridge). BREAKING CHANGE: `WalletRestoreEntryFFI` gains two trailing fields and has no size field, so the host and the library must be rebuilt together (the same lockstep contract as `unconfirmed_outgoing_tx_records`). Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 418 +++++++++++++++++- .../src/wallet_restore_types.rs | 48 ++ .../rs-unified-sdk-jni/src/persistence.rs | 7 + 3 files changed, 468 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 26c5205be00..320608b2bcc 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -34,7 +34,8 @@ use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, + ProviderKeyExtendedPubKey, RecordedHistory, StoredTransaction, + PERSISTENCE_CAPABILITIES_VERSION, }; use platform_wallet::wallet::platform_wallet::WalletId; #[cfg(feature = "shielded")] @@ -72,8 +73,9 @@ use crate::wallet_registration_persistence::AccountAddressPoolFFI; use crate::wallet_restore_types::{ AccountSpecFFI, AccountTypeTagFFI, ContactProfileRestoreEntryFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, LoadWalletListFreeFn, PaymentRestoreEntryFFI, - ProviderSpecialTxRestoreEntryFFI, StandardAccountTypeTagFFI, UnconfirmedOutgoingTxRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, WalletRestoreEntryFFI, + ProviderSpecialTxRestoreEntryFFI, RecordedTransactionRestoreFFI, StandardAccountTypeTagFFI, + UnconfirmedOutgoingTxRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoRestoreEntryFFI, + WalletRestoreEntryFFI, }; use dpp::address_funds::PlatformAddress; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -94,6 +96,10 @@ use std::ffi::CStr; /// `Mempool` vs no-evidence); see each match's comment. pub(crate) const TX_CONTEXT_RAW_IN_BLOCK: u32 = 2; pub(crate) const TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK: u32 = 3; +/// Unconfirmed context values (see above), used by decoders that must tell +/// an unconfirmed row apart from an unknown value. +pub(crate) const TX_CONTEXT_RAW_MEMPOOL: u32 = 0; +pub(crate) const TX_CONTEXT_RAW_INSTANT_SEND: u32 = 1; /// Byte budget for decoding a host-supplied account extended public key. /// @@ -1549,7 +1555,11 @@ impl FFIPersister { capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES); } if wallet_restore { - capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); + // History rides the same wallet-list load; a host that wires it + // must still declare the bit to attest it fills the rows. + capabilities = capabilities + .union(PersistenceCapabilities::WALLET_RESTORE) + .union(PersistenceCapabilities::CORE_HISTORY_RESTORE); } if self.callbacks.on_persist_asset_locks_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); @@ -5194,6 +5204,109 @@ fn order_unconfirmed_outgoing( ordered } +/// Decode the stored transaction history the host handed back for replay. +/// +/// Fail-closed per record, never for the load: a record that does not decode, +/// does not hash to its row's txid, or carries an unknown context is dropped +/// with a counted warning. The replay applies each transaction through the +/// ordinary state-update path, so bytes that do not belong to their row would +/// move accounting for unrelated inputs and outputs. +/// +/// InstantSend rows replay as mempool: the host keeps no lock bytes, so the +/// spend is still reserved but the lock's upgrade and conflict sweep wait for +/// the lock to be observed again. +fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistory { + use dashcore::consensus::Decodable; + use dashcore::hashes::Hash; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; + + let recs: &[RecordedTransactionRestoreFFI] = + if entry.recorded_transactions.is_null() || entry.recorded_transactions_count == 0 { + &[] + } else { + unsafe { + slice::from_raw_parts( + entry.recorded_transactions, + entry.recorded_transactions_count, + ) + } + }; + let mut transactions = Vec::with_capacity(recs.len()); + let mut dropped_decode = 0usize; + let mut dropped_identity = 0usize; + let mut dropped_context = 0usize; + for rec in recs { + let bytes = unsafe { slice_from_raw(rec.tx_bytes, rec.tx_bytes_len) }; + if bytes.is_empty() { + dropped_decode += 1; + continue; + } + let transaction = match dashcore::blockdata::transaction::Transaction::consensus_decode( + &mut &bytes[..], + ) { + Ok(tx) if *tx.txid().as_byte_array() == rec.txid => tx, + Ok(_) => { + dropped_identity += 1; + continue; + } + Err(_) => { + dropped_decode += 1; + continue; + } + }; + let context = match rec.context { + TX_CONTEXT_RAW_MEMPOOL | TX_CONTEXT_RAW_INSTANT_SEND => TransactionContext::Mempool, + ctx @ (TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK) => { + let mut info = BlockInfo::new( + rec.block_height, + dashcore::BlockHash::from_byte_array(rec.block_hash), + rec.block_timestamp, + ); + if rec.has_block_position { + info = info.with_position(rec.block_position); + } + if ctx == TX_CONTEXT_RAW_IN_BLOCK { + TransactionContext::InBlock(info) + } else { + TransactionContext::InChainLockedBlock(info) + } + } + _ => { + dropped_context += 1; + continue; + } + }; + let stored_direction = match rec.direction { + 0 => Some(TransactionDirection::Incoming), + 1 => Some(TransactionDirection::Outgoing), + 2 => Some(TransactionDirection::Internal), + 3 => Some(TransactionDirection::CoinJoin), + _ => None, + }; + transactions.push(StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: Some(rec.net_amount), + stored_direction, + }); + } + if dropped_decode > 0 || dropped_identity > 0 || dropped_context > 0 { + tracing::warn!( + wallet_id = %hex::encode(entry.wallet_id), + dropped_decode, + dropped_identity, + dropped_context, + "load: recorded transaction history rows were dropped" + ); + } + RecordedHistory { + transactions, + instant_locks: BTreeMap::new(), + } +} + /// Map a provider-account rebuild failure to a load error naming the /// curve-specific constructor or `AccountCollection` insert that failed. fn provider_rebuild_error( @@ -5854,13 +5967,17 @@ fn build_wallet_start_state( // it. let unconfirmed_outgoing_txs = decode_unconfirmed_outgoing(entry); + // Decode only, like the sends above: the shared load replays the history + // (`platform_wallet::manager::history_replay`) at the async boundary. + let recorded_history = decode_recorded_transactions(entry); + let wallet_state = ClientWalletStartState { wallet, wallet_info, identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, - recorded_history: Default::default(), + recorded_history, }; let platform_address_state = if per_account.is_empty() @@ -8526,6 +8643,7 @@ mod tests { .union(PersistenceCapabilities::PROVIDER_TRANSACTIONS) .union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE) .union(PersistenceCapabilities::WALLET_RESTORE) + .union(PersistenceCapabilities::CORE_HISTORY_RESTORE) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); cb.on_changeset_begin_fn = Some(noop_begin); @@ -10930,4 +11048,294 @@ mod tests { "every emitted marked-used address must carry used == true" ); } + + /// Owned bytes plus the FFI row that borrows them; the row is valid only + /// while `bytes` lives. + struct RecordedRow { + bytes: Vec, + context: u32, + height: u32, + position: Option, + txid: [u8; 32], + } + + impl RecordedRow { + fn new(tx: &Transaction, context: u32, height: u32, position: Option) -> Self { + use dashcore::hashes::Hash; + Self { + bytes: serialize(tx), + context, + height, + position, + txid: *tx.txid().as_byte_array(), + } + } + + fn ffi(&mut self) -> RecordedTransactionRestoreFFI { + RecordedTransactionRestoreFFI { + txid: self.txid, + tx_bytes: self.bytes.as_mut_ptr(), + tx_bytes_len: self.bytes.len(), + context: self.context, + block_height: self.height, + block_hash: [self.height as u8; 32], + block_timestamp: self.height, + block_position: self.position.unwrap_or(0), + has_block_position: self.position.is_some(), + net_amount: -7, + direction: 1, + } + } + } + + /// A seeded wallet's BIP44 account spec (with its encoded xpub) and first + /// receive address. + fn bip44_wallet_fixture() -> (Wallet, Vec, dashcore::Address) { + let wallet = Wallet::from_seed_bytes( + [0x51; 64], + Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .expect("seeded wallet"); + let xpub = wallet + .get_bip44_account(0) + .expect("a Default-created wallet has BIP44 account 0") + .account_xpub; + let xpub_bytes = + bincode::encode_to_vec(xpub, config::standard()).expect("encode account xpub"); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let address = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .next_receive_address(Some(&xpub), true) + .expect("receive address"); + (wallet, xpub_bytes, address) + } + + fn pay(previous_output: dashcore::OutPoint, outputs: Vec) -> Transaction { + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: outputs, + special_transaction_payload: None, + } + } + + /// The FFI restore path end to end: the host hands back only the unspent + /// change coin plus the stored history (funding and a confirmed spend), + /// `build_wallet_start_state` decodes it, the shared load replay runs, and + /// the funding transaction is redelivered (rescan). The confirmed-spent + /// output must not come back. Without history — the pre-change host + /// shape — the same redelivery resurrects it. + #[test] + fn should_not_resurrect_confirmed_spent_output_after_ffi_restore() { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::{ + BlockInfo, TransactionContext, WalletTransactionChecker, + }; + use platform_wallet::manager::history_replay::replay_recorded_history; + + let (wallet, xpub_bytes, address) = bip44_wallet_fixture(); + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let spec = build_account_spec_ffi(&bip44, &xpub_bytes); + let funding = pay( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([0x61; 32]), 0), + [100_000, 20_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + ); + let spent = dashcore::OutPoint::new(funding.txid(), 0); + let spending = pay( + spent, + vec![TxOut { + value: 99_000, + script_pubkey: ScriptBuf::new(), + }], + ); + let script = address.script_pubkey().to_bytes(); + let change = UtxoRestoreEntryFFI { + type_tag: AccountTypeTagFFI::Standard as u8, + standard_tag: StandardAccountTypeTagFFI::Bip44 as u8, + account_index: 0, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + prev_txid: *funding.txid().as_byte_array(), + vout: 1, + value_duffs: 20_000, + script_pubkey: script.as_ptr(), + script_pubkey_len: script.len(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + }; + // Stored out of chain order on purpose: the replay orders them. + let mut rows = [ + RecordedRow::new(&spending, TX_CONTEXT_RAW_IN_BLOCK, 101, Some(0)), + RecordedRow::new(&funding, TX_CONTEXT_RAW_IN_BLOCK, 100, Some(0)), + ]; + let history: Vec<_> = rows.iter_mut().map(RecordedRow::ffi).collect(); + + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime"); + let redeliver = |recorded: &[RecordedTransactionRestoreFFI]| { + let entry = WalletRestoreEntryFFI { + wallet_id: wallet.wallet_id, + accounts: &spec, + accounts_count: 1, + utxos: &change, + utxos_count: 1, + recorded_transactions: recorded.as_ptr(), + recorded_transactions_count: recorded.len(), + ..Default::default() + }; + let (state, _) = build_wallet_start_state(&entry).expect("wallet must restore"); + let (mut wallet, mut info) = (state.wallet, state.wallet_info); + runtime.block_on(async { + replay_recorded_history(&mut info, &mut wallet, state.recorded_history).await; + info.check_core_transaction( + &funding, + TransactionContext::InBlock(BlockInfo::new( + 100, + dashcore::BlockHash::from_byte_array([100; 32]), + 100, + )), + &mut wallet, + true, + true, + ) + .await; + }); + info + }; + + let guarded = redeliver(&history); + assert!( + guarded + .accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&spent)), + "a redelivered funding must not re-credit a confirmed-spent output" + ); + assert_eq!(guarded.balance.total(), 20_000); + + let unguarded = redeliver(&[]); + assert!( + unguarded + .accounts + .all_funding_accounts() + .into_iter() + .any(|account| account.utxos.contains_key(&spent)), + "control: without history the redelivery resurrects the output" + ); + } + + /// Each stored row decodes into its own context, block position and + /// stored accounting; rows that do not hash to their txid or carry an + /// unknown context drop out instead of failing the load. + #[test] + fn should_decode_recorded_transactions_and_drop_bad_rows() { + use dashcore::hashes::Hash; + use key_wallet::managed_account::transaction_record::TransactionDirection; + use key_wallet::transaction_checking::TransactionContext; + + let tx = |marker: u8| { + pay( + dashcore::OutPoint::new(dashcore::Txid::from_byte_array([marker; 32]), 0), + vec![TxOut { + value: u64::from(marker), + script_pubkey: ScriptBuf::new(), + }], + ) + }; + let mut rows = [ + RecordedRow::new(&tx(1), TX_CONTEXT_RAW_MEMPOOL, 0, None), + RecordedRow::new(&tx(2), TX_CONTEXT_RAW_INSTANT_SEND, 0, None), + RecordedRow::new(&tx(3), TX_CONTEXT_RAW_IN_BLOCK, 50, Some(4)), + RecordedRow::new(&tx(4), TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK, 60, None), + RecordedRow::new(&tx(5), 9, 0, None), + RecordedRow::new(&tx(6), TX_CONTEXT_RAW_MEMPOOL, 0, None), + ]; + // Row 6 claims another row's txid: its bytes do not belong to it. + rows[5].txid = rows[0].txid; + let mut ffi: Vec<_> = rows.iter_mut().map(RecordedRow::ffi).collect(); + ffi[3].direction = 42; + let entry = WalletRestoreEntryFFI { + recorded_transactions: ffi.as_ptr(), + recorded_transactions_count: ffi.len(), + ..Default::default() + }; + + let history = decode_recorded_transactions(&entry); + + let txids: Vec<_> = history.transactions.iter().map(|t| t.txid).collect(); + assert_eq!( + txids, + vec![tx(1).txid(), tx(2).txid(), tx(3).txid(), tx(4).txid()] + ); + let contexts: Vec<_> = history.transactions.iter().map(|t| &t.context).collect(); + assert!(matches!(contexts[0], TransactionContext::Mempool)); + assert!( + matches!(contexts[1], TransactionContext::Mempool), + "InstantSend rows carry no lock and replay as mempool" + ); + assert!(matches!( + contexts[2], + TransactionContext::InBlock(info) if info.height() == 50 && info.position() == Some(4) + )); + assert!(matches!( + contexts[3], + TransactionContext::InChainLockedBlock(info) if info.height() == 60 && info.position().is_none() + )); + assert_eq!(history.transactions[0].stored_net_amount, Some(-7)); + assert_eq!( + history.transactions[0].stored_direction, + Some(TransactionDirection::Outgoing) + ); + assert_eq!(history.transactions[3].stored_direction, None); + assert!(history.instant_locks.is_empty()); + } + + #[test] + fn should_decode_no_history_from_a_null_array() { + let entry = WalletRestoreEntryFFI::default(); + assert!(decode_recorded_transactions(&entry).is_empty()); + } + + /// History restore rides the wallet-list load: a host declaring the bit + /// attests it only with the load callback pair wired. + #[test] + fn core_history_restore_requires_the_wallet_list_load_callbacks() { + let declared = PersistenceCapabilities::CORE_HISTORY_RESTORE; + assert!( + !declared_persister(PersistenceCallbacks::default(), declared) + .persistence_capabilities() + .contains(declared) + ); + let cb = PersistenceCallbacks { + on_load_wallet_list_fn: Some(noop_load_wallets), + on_load_wallet_list_free_fn: Some(noop_free_wallets), + ..Default::default() + }; + assert!(declared_persister(cb, declared) + .persistence_capabilities() + .contains(declared)); + } } diff --git a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs index 22a71ade25b..351d12fcdb4 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -606,6 +606,42 @@ pub struct UnconfirmedOutgoingTxRecordFFI { pub first_seen: u64, } +/// One stored transaction of the wallet, handed back at load so the shared +/// history replay can rebuild the spend guards of confirmed AND unconfirmed +/// spends. +/// +/// Without it only the persisted unspent UTXO set comes back, so a funding +/// transaction redelivered after restart (rescan, gap-limit rediscovery) +/// re-credits an output a confirmed spend already consumed. +#[repr(C)] +pub struct RecordedTransactionRestoreFFI { + /// Wire-order txid of the stored row. The load path decodes `tx_bytes` + /// and drops the record unless it hashes to this: the replay applies the + /// transaction through the ordinary state-update path, so foreign bytes + /// would move accounting for unrelated inputs and outputs. + pub txid: [u8; 32], + /// Consensus-encoded transaction body. Swift-owned for the callback + /// window; freed by `LoadWalletListFreeFn`. + pub tx_bytes: *mut u8, + pub tx_bytes_len: usize, + /// Stored context: `0` mempool, `1` InstantSend, `2` in a block, `3` in a + /// chain-locked block. `1` replays as mempool (no lock bytes are carried); + /// any other value drops the record. + pub context: u32, + /// Block fields, read only for contexts `2` and `3`. + pub block_height: u32, + pub block_hash: [u8; 32], + pub block_timestamp: u32, + /// In-block position, meaningful only when `has_block_position`. + pub block_position: u32, + pub has_block_position: bool, + /// The wallet-level net amount the host stores for this row. + pub net_amount: i64, + /// The stored direction: `0` incoming, `1` outgoing, `2` internal, + /// `3` CoinJoin. Any other value reads as "unknown". + pub direction: u32, +} + /// Per-wallet entry returned by `on_load_wallet_list_fn`. /// /// `accounts` points to a contiguous array of length `accounts_count`. @@ -718,6 +754,16 @@ pub struct WalletRestoreEntryFFI { /// leaves every existing field where it was. pub unconfirmed_outgoing_tx_records: *const UnconfirmedOutgoingTxRecordFFI, pub unconfirmed_outgoing_tx_records_count: usize, + /// Every stored transaction of this wallet, in any order — see + /// [`RecordedTransactionRestoreFFI`]. `null` / `0` means "no history + /// supplied" and keeps the pre-history load behaviour. Each entry's + /// `tx_bytes` buffer is Swift-owned and freed by `LoadWalletListFreeFn`. + /// + /// Appended at the end for the same reason as the field above; the + /// struct carries no size field, so host and library must still be + /// built together. + pub recorded_transactions: *const RecordedTransactionRestoreFFI, + pub recorded_transactions_count: usize, } /// Every field named explicitly so that adding a field to this ABI struct @@ -758,6 +804,8 @@ impl Default for WalletRestoreEntryFFI { last_applied_chain_lock_bytes_len: 0, unconfirmed_outgoing_tx_records: std::ptr::null(), unconfirmed_outgoing_tx_records_count: 0, + recorded_transactions: std::ptr::null(), + recorded_transactions_count: 0, } } } diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index fea73af90dc..f9a8568141c 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -2599,6 +2599,13 @@ fn build_wallet_restore_entry( // inert here, exactly as it was before the field existed. unconfirmed_outgoing_tx_records: ptr::null(), unconfirmed_outgoing_tx_records_count: 0, + // TODO(android-core-history-restore): the Kotlin host does not hand + // back its stored transaction history yet, so confirmed-spend guards + // are not rebuilt on Android load (the persister does not attest + // CORE_HISTORY_RESTORE and load warns). Null/0 keeps the + // pre-history behaviour. + recorded_transactions: ptr::null(), + recorded_transactions_count: 0, core_address_pools: ptr::null(), core_address_pools_count: 0, last_applied_chain_lock_bytes: ptr::null(), From c5787b7bdbd8ceb6eccb0ce2381f4c6a38ab5595 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:44:20 +0000 Subject: [PATCH 4/5] feat(swift-sdk): supply recorded transaction history on wallet load `loadWalletList` now hands every wallet-owned `PersistentTransaction` back to Rust as `WalletRestoreEntryFFI.recorded_transactions` (txid, bytes, context, block fields, stored net amount and direction), so the shared load replay rebuilds the spend guards of confirmed spends and a funding transaction redelivered after restart cannot resurrect a spent output. The handler declares `CORE_HISTORY_RESTORE`. A failed history fetch rejects the snapshot (`errored`), like the unspent TXO fetch: an empty history would claim there is nothing to guard. Rows without a 32-byte txid, without bytes, or confirmed without a 32-byte block hash are skipped with one logged count. Not compiled locally (no Swift toolchain on the authoring host). Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletManager.swift | 7 ++ .../PlatformWalletPersistenceHandler.swift | 110 ++++++++++++++++++ .../InvitationPersistenceTests.swift | 4 + 3 files changed, 121 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index f1ffb020911..a9f48b7233a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -107,6 +107,13 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// Rust only honours the declaration when the payments callback is /// actually wired. public static let dashpayPayments: UInt64 = 1 << 12 + /// Wallet load hands back every stored transaction + /// (`WalletRestoreEntryFFI.recorded_transactions`), so Rust's shared load + /// replay rebuilds the spend guards of confirmed spends and a redelivered + /// funding transaction cannot resurrect a spent output. Mirrors + /// `PersistenceCapabilities::CORE_HISTORY_RESTORE`; Rust only honours the + /// declaration when the wallet-list load callbacks are wired. + public static let coreHistoryRestore: UInt64 = 1 << 13 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 0de8973282d..155bc04fcd5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3182,6 +3182,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.coreHistoryRestore ) } @@ -7129,6 +7130,35 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // Every stored transaction of each restorable wallet, replayed + // Rust-side at load so the spend guards of confirmed spends are + // rebuilt: without them a funding transaction redelivered after the + // restart (rescan, gap-limit rediscovery) re-credits an output a + // confirmed spend already consumed. Same fail-closed contract as the + // unspent fetch above: an unreadable table rejects the snapshot + // instead of claiming "no history". + var historyBuckets: [Data: [PersistentTransaction]] = [:] + do { + let transactions = try modelFetcher.fetch( + FetchDescriptor(), in: backgroundContext + ) + for w in restorable { + historyBuckets[w.walletId] = transactions.filter { + !$0.isDeleted + && Self.walletOwnsTransaction(walletId: w.walletId, transaction: $0) + } + } + } catch { + SDKLogger.event( + "persistence_wallet_load_failed", + category: .persistence, + severity: .error, + fields: ["phase": .publicText("transaction_history_fetch")], + error: error + ) + return (nil, 0, true) + } + // Allocate `entriesPtr` and the `LoadAllocation` here — past // the fallible SwiftData fetch above — so an early-error path // doesn't leak the entries buffer (LoadAllocation only gets @@ -7425,6 +7455,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { entry.unconfirmed_outgoing_tx_records = unconfirmedBuf.map { UnsafePointer($0) } entry.unconfirmed_outgoing_tx_records_count = UInt(unconfirmedCount) + // The wallet's stored history; see `historyBuckets` above. + let (historyBuf, historyCount) = buildRecordedTransactionBuffer( + rows: historyBuckets[w.walletId] ?? [], + allocation: allocation + ) + entry.recorded_transactions = historyBuf.map { UnsafePointer($0) } + entry.recorded_transactions_count = UInt(historyCount) + // Provider special transactions (ProRegTx / ProUpServTx / // ProUpRegTx / ProUpRevTx) re-staged onto the provider-key // accounts so #876 retention keeps them and the masternode @@ -8047,6 +8085,70 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (buf, entries.count) } + /// Marshal a wallet's stored transactions for the Rust load replay. + /// + /// Rows without a 32-byte txid or transaction bytes are skipped, as are + /// confirmed rows without a 32-byte block hash (a zero hash is not + /// fabricated). Rust re-checks that each body hashes to its txid. + private func buildRecordedTransactionBuffer( + rows: [PersistentTransaction], + allocation: LoadAllocation + ) -> (UnsafeMutablePointer?, Int) { + guard !rows.isEmpty else { return (nil, 0) } + var entries: [RecordedTransactionRestoreFFI] = [] + entries.reserveCapacity(rows.count) + var skipped = 0 + for row in rows { + let txBytes = row.transactionData + guard row.txid.count == 32, !txBytes.isEmpty else { + skipped += 1 + continue + } + let confirmed = row.context >= TransactionContextType.inBlock.rawValue + if confirmed, row.blockHash?.count != 32 { + skipped += 1 + continue + } + let txBuf = UnsafeMutablePointer.allocate(capacity: txBytes.count) + txBytes.copyBytes(to: txBuf, count: txBytes.count) + allocation.scalarBuffers.append((txBuf, txBytes.count)) + var entry = RecordedTransactionRestoreFFI() + withUnsafeMutableBytes(of: &entry.txid) { raw in + raw.copyBytes(from: row.txid) + } + entry.tx_bytes = txBuf + entry.tx_bytes_len = UInt(txBytes.count) + entry.context = row.context + if confirmed, let hash = row.blockHash { + entry.block_height = row.blockHeight + withUnsafeMutableBytes(of: &entry.block_hash) { raw in + raw.copyBytes(from: hash) + } + entry.block_timestamp = row.blockTimestamp + entry.block_position = row.blockPosition + entry.has_block_position = row.hasBlockPosition + } + entry.net_amount = row.netAmount + entry.direction = row.direction + entries.append(entry) + } + if skipped > 0 { + SDKLogger.event( + "persistence_transaction_history_rows_skipped", + category: .persistence, + severity: .warning, + fields: ["skipped_count": .integer(Int64(skipped))] + ) + } + guard !entries.isEmpty else { return (nil, 0) } + let buf = UnsafeMutablePointer.allocate( + capacity: entries.count + ) + buf.initialize(from: entries, count: entries.count) + allocation.recordedTransactionArrays.append((buf, entries.count)) + return (buf, entries.count) + } + private func buildUnresolvedAssetLockTxRecordBuffer( walletId: Data, allocation: LoadAllocation @@ -9226,6 +9328,10 @@ private final class LoadAllocation { /// each entry points at are staged on `scalarBuffers`, like the /// asset-lock records above. var unconfirmedOutgoingTxRecordArrays: [(UnsafeMutablePointer, Int)] = [] + /// `RecordedTransactionRestoreFFI` arrays per wallet — the stored history + /// replayed at load. The `tx_bytes` each entry points at are staged on + /// `scalarBuffers`. + var recordedTransactionArrays: [(UnsafeMutablePointer, Int)] = [] /// Per-wallet `ProviderSpecialTxRestoreEntryFFI` arrays — provider /// special txs re-staged so #876 retention keeps them resident after a /// restart. The `tx_bytes` buffer each row references lives in @@ -9310,6 +9416,10 @@ private final class LoadAllocation { ptr.deinitialize(count: count) ptr.deallocate() } + for (ptr, count) in recordedTransactionArrays { + ptr.deinitialize(count: count) + ptr.deallocate() + } for (ptr, count) in providerSpecialTxRecordArrays { ptr.deinitialize(count: count) ptr.deallocate() diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 4fd3c52c85c..8c344e6529a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -67,6 +67,10 @@ final class InvitationPersistenceTests: XCTestCase { // `PersistentDashpayPayment` rows, so the sweep's Failed flip // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments + // Stored transaction history: `loadWalletList` hands every + // wallet-owned `PersistentTransaction` back as + // `recorded_transactions` for the load replay. + | PlatformWalletPersistenceCapabilities.coreHistoryRestore XCTAssertEqual( capabilities.version, From e298ed07e8ea2df25d95d5f1c287ff29841effcf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:21:13 +0000 Subject: [PATCH 5/5] fix(wallet): rebuild spend guards for restored transaction records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replay raw restored history records through the checker while retaining proof lookup records and user metadata. Sweep fallback records against final transactions, prefetch Swift history relationships, and reject trailing transaction bytes at both restore boundaries. Co-Authored-By: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../rs-platform-wallet-ffi/src/persistence.rs | 85 +++++-- .../src/manager/history_replay.rs | 216 +++++++++++++++--- .../PlatformWalletPersistenceHandler.swift | 6 +- 3 files changed, 251 insertions(+), 56 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 320608b2bcc..4607abcaa81 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5112,7 +5112,7 @@ impl Drop for LoadGuard { fn decode_unconfirmed_outgoing( entry: &WalletRestoreEntryFFI, ) -> Vec { - use dashcore::consensus::Decodable; + use dashcore::consensus::deserialize; use dashcore::hashes::Hash; let recs: &[UnconfirmedOutgoingTxRecordFFI] = if entry.unconfirmed_outgoing_tx_records.is_null() || entry.unconfirmed_outgoing_tx_records_count == 0 @@ -5136,7 +5136,7 @@ fn decode_unconfirmed_outgoing( continue; } let bytes = unsafe { slice::from_raw_parts(rec.tx_bytes, rec.tx_bytes_len) }; - match dashcore::blockdata::transaction::Transaction::consensus_decode(&mut &bytes[..]) { + match deserialize::(bytes) { // The bytes must be the row they were selected from. The // replay runs through the ordinary state-update path, so a // stale or partially-written `transactionData` would apply a @@ -5216,7 +5216,7 @@ fn order_unconfirmed_outgoing( /// spend is still reserved but the lock's upgrade and conflict sweep wait for /// the lock to be observed again. fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistory { - use dashcore::consensus::Decodable; + use dashcore::consensus::deserialize; use dashcore::hashes::Hash; use key_wallet::managed_account::transaction_record::TransactionDirection; use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; @@ -5242,9 +5242,7 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor dropped_decode += 1; continue; } - let transaction = match dashcore::blockdata::transaction::Transaction::consensus_decode( - &mut &bytes[..], - ) { + let transaction = match deserialize::(bytes) { Ok(tx) if *tx.txid().as_byte_array() == rec.txid => tx, Ok(_) => { dropped_identity += 1; @@ -11194,13 +11192,30 @@ mod tests { let runtime = tokio::runtime::Builder::new_current_thread() .build() .expect("runtime"); - let redeliver = |recorded: &[RecordedTransactionRestoreFFI]| { + let mut raw_bytes = [serialize(&funding), serialize(&spending)]; + let unresolved: Vec<_> = raw_bytes + .iter_mut() + .enumerate() + .map(|(i, bytes)| UnresolvedAssetLockTxRecordFFI { + account_index: 0, + tx_bytes: bytes.as_mut_ptr(), + tx_bytes_len: bytes.len(), + context_raw: TX_CONTEXT_RAW_IN_BLOCK, + block_height: 100 + i as u32, + block_hash: [100 + i as u8; 32], + block_timestamp: 100 + i as u64, + first_seen: 100 + i as u64, + }) + .collect(); + let redeliver = |recorded: &[RecordedTransactionRestoreFFI], overlap: bool| { let entry = WalletRestoreEntryFFI { wallet_id: wallet.wallet_id, accounts: &spec, accounts_count: 1, utxos: &change, utxos_count: 1, + unresolved_asset_lock_tx_records: unresolved.as_ptr(), + unresolved_asset_lock_tx_records_count: if overlap { unresolved.len() } else { 0 }, recorded_transactions: recorded.as_ptr(), recorded_transactions_count: recorded.len(), ..Default::default() @@ -11225,18 +11240,19 @@ mod tests { info }; - let guarded = redeliver(&history); - assert!( - guarded - .accounts - .all_funding_accounts() - .into_iter() - .all(|account| !account.utxos.contains_key(&spent)), - "a redelivered funding must not re-credit a confirmed-spent output" - ); - assert_eq!(guarded.balance.total(), 20_000); - - let unguarded = redeliver(&[]); + for overlap in [false, true] { + let guarded = redeliver(&history, overlap); + assert!( + guarded + .accounts + .all_funding_accounts() + .into_iter() + .all(|account| !account.utxos.contains_key(&spent)), + "a redelivered funding must not re-credit a confirmed-spent output" + ); + assert_eq!(guarded.balance.total(), 20_000); + } + let unguarded = redeliver(&[], false); assert!( unguarded .accounts @@ -11313,6 +11329,37 @@ mod tests { assert!(history.instant_locks.is_empty()); } + #[test] + fn should_reject_non_exact_transaction_bytes_on_restore() { + use dashcore::hashes::Hash; + let tx = synthetic_minimal_tx(); + let exact = serialize(&tx); + for bytes in [ + [exact.clone(), vec![0]].concat(), + [exact.clone(), exact.clone()].concat(), + exact[..exact.len() - 1].to_vec(), + ] { + let mut row = RecordedRow::new(&tx, TX_CONTEXT_RAW_MEMPOOL, 0, None); + row.bytes = bytes.clone(); + let recorded = row.ffi(); + let outgoing = UnconfirmedOutgoingTxRecordFFI { + txid: *tx.txid().as_byte_array(), + tx_bytes: bytes.as_ptr().cast_mut(), + tx_bytes_len: bytes.len(), + first_seen: 0, + }; + let entry = WalletRestoreEntryFFI { + recorded_transactions: &recorded, + recorded_transactions_count: 1, + unconfirmed_outgoing_tx_records: &outgoing, + unconfirmed_outgoing_tx_records_count: 1, + ..Default::default() + }; + assert!(decode_recorded_transactions(&entry).is_empty()); + assert!(decode_unconfirmed_outgoing(&entry).is_empty()); + } + } + #[test] fn should_decode_no_history_from_a_null_array() { let entry = WalletRestoreEntryFFI::default(); diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index ef1cf2e5e0b..399789956f7 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -13,6 +13,7 @@ use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::{OutPoint, Txid}; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -32,9 +33,9 @@ use crate::changeset::{RecordedHistory, StoredTransaction}; /// A persisted lock upgrades its mempool record to InstantSend, because a lock /// that arrived after its transaction was stored never rewrote the record. /// -/// Records whose txid an account already holds are skipped: a persister that -/// restored them itself (asset-lock funding, provider special transactions) -/// owns them, and the checker treats a known mempool transaction as a no-op. +/// Raw restored records are replayed too: their presence alone does not +/// establish spend guards. Their labels, fees and proof-lookup records survive +/// replay unless a final conflicting transaction sweeps them. /// /// Returns how many records were replayed. pub async fn replay_recorded_history( @@ -60,23 +61,19 @@ pub async fn replay_recorded_history( account.utxos.keys().map(move |outpoint| (*outpoint, owner)) }) .collect(); - let held: HashSet = transactions - .iter() - .map(|stored| stored.txid) - .filter(|txid| { - wallet_info - .accounts - .all_accounts() - .into_iter() - .any(|account| account.has_transaction(txid)) - }) - .collect(); - if !held.is_empty() { - tracing::debug!( - wallet_id = %hex::encode(wallet_info.wallet_id), - skipped = held.len(), - "load replay: skipped records the persister already restored" - ); + let replay_txids: HashSet = transactions.iter().map(|stored| stored.txid).collect(); + let mut held: HashMap> = HashMap::new(); + // Detach raw records so the checker rebuilds reservations even for known mempool txids. + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + account.transactions_mut().retain(|txid, record| { + if replay_txids.contains(txid) { + held.entry(owner).or_default().push(record.clone()); + false + } else { + true + } + }); } // TODO(bound-load-history-replay): every stored record is replayed on each // load; bounding it to records above the last chain lock needs care so @@ -89,10 +86,9 @@ pub async fn replay_recorded_history( let replay = replay_order(transactions); let mut replayed = 0usize; + let mut swept = HashSet::new(); + let mut final_transactions = Vec::new(); for stored in replay { - if held.contains(&stored.txid) { - continue; - } // The lock set already holds this txid (load marked the restored // UTXOs), so a later lock event is deduplicated: the InstantSend // context, and the conflict sweep it runs, must come from here. @@ -105,9 +101,13 @@ pub async fn replay_recorded_history( } (context, _) => context, }; - wallet_info - .check_core_transaction(&stored.transaction, context, wallet, true, false) + let result = wallet_info + .check_core_transaction(&stored.transaction, context.clone(), wallet, true, false) .await; + swept.extend(result.swept_transactions); + if !held.is_empty() && !matches!(context, TransactionContext::Mempool) { + final_transactions.push((stored.transaction, context)); + } replayed += 1; } @@ -142,6 +142,29 @@ pub async fn replay_recorded_history( && !misplaced.contains(&(*outpoint, owner)) }); } + let mut restored_fallback = false; + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + for original in held.remove(&owner).into_iter().flatten() { + if swept.contains(&original.txid) { + continue; + } + if let Some(replayed) = account.transactions_mut().get_mut(&original.txid) { + replayed.label = original.label; + replayed.fee = original.fee.or(replayed.fee); + } else { + // Proof lookup can require a record with no currently attributable inputs or outputs. + account.transactions_mut().insert(original.txid, original); + restored_fallback = true; + } + } + } + if restored_fallback { + // Unattributable raw records were absent from the checker's conflict sweeps. + for (transaction, context) in final_transactions { + wallet_info.sweep_conflicts(&transaction, &context); + } + } // Finalize replayed records before a sync checkpoint can prune their spend guards. if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { wallet_info.apply_chain_lock(chain_lock); @@ -410,29 +433,136 @@ mod tests { ); } - /// A record the persister already restored itself (asset-lock funding, - /// provider special transactions) stays the persister's: the replay must - /// not count it, since the checker treats a known mempool transaction as - /// a no-op and would mask that as applied. #[tokio::test] - async fn should_skip_records_an_account_already_holds() { + async fn should_rebuild_guards_for_raw_restored_records() { + for mempool in [false, true] { + let (mut wallet, mut restored, funding, mut records, spent) = + confirmed_spend_fixture().await; + if mempool { + records[1].context = TransactionContext::Mempool; + } + let mut held = records[1].clone(); + held.label = "retained label".into(); + held.fee = Some(1_000); + let txid = held.txid; + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(txid, held); + + let count = replay_recorded_history( + &mut restored, + &mut wallet, + history(records.clone(), BTreeMap::new()), + ) + .await; + restored + .check_core_transaction( + &funding, + records[0].context.clone(), + &mut wallet, + true, + true, + ) + .await; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.utxos.contains_key(&spent), "mempool={mempool}"); + assert_eq!(restored.balance.total(), 20_000); + assert_eq!(count, 2); + let held = &account.transactions()[&txid]; + assert_eq!(held.label, "retained label"); + assert_eq!(held.fee, Some(1_000)); + } + } + + #[tokio::test] + async fn should_retain_raw_record_without_matching_utxos() { let (mut wallet, mut restored, _, records, _) = confirmed_spend_fixture().await; - let held = records[1].clone(); + let mut held = records[1].clone(); + held.context = TransactionContext::Mempool; + held.label = "proof lookup".into(); + let txid = held.txid; restored .accounts .standard_bip44_accounts .get_mut(&0) .unwrap() .transactions_mut() - .insert(held.txid, held); - - let count = replay_recorded_history( + .insert(txid, held.clone()); + replay_recorded_history( &mut restored, &mut wallet, - history(records, BTreeMap::new()), + history(vec![held], BTreeMap::new()), ) .await; - assert_eq!(count, 1, "only the record no account held is replayed"); + assert_eq!( + restored.accounts.standard_bip44_accounts[&0].transactions()[&txid].label, + "proof lookup" + ); + } + + #[tokio::test] + async fn should_drop_raw_conflict_without_matching_utxos() { + let (mut wallet, restored, _, records, _) = confirmed_spend_fixture().await; + for instant in [false, true] { + let mut info = restored.clone(); + let mut loser = records[1].clone(); + loser.context = TransactionContext::Mempool; + let mut child = loser.clone(); + child.transaction.input[0].previous_output = OutPoint::new(loser.txid, 0); + child.txid = child.transaction.txid(); + let mut winner = loser.clone(); + winner.transaction.output[0].value -= 1; + winner.txid = winner.transaction.txid(); + winner.context = if instant { + TransactionContext::InstantSend(InstantLock { + txid: winner.txid, + inputs: winner + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }) + } else { + records[1].context.clone() + }; + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(loser.txid, loser.clone()); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(child.txid, child.clone()); + replay_recorded_history( + &mut info, + &mut wallet, + history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), + ) + .await; + assert!( + !info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser.txid), + "instant={instant}" + ); + assert!( + !info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&child.txid), + "instant={instant}" + ); + assert_eq!(info.balance.total(), 20_000); + } } /// An empty history leaves the restored projection untouched. @@ -656,6 +786,20 @@ mod tests { // Replayed after a conflicting spend, its lock sweeps that spend. let mut contested = ManagedWalletInfo::from_wallet(&wallet, 0); + contested + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(loser.txid(), record_of(loser.txid())); + contested + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(winner.txid(), record_of(winner.txid())); replay_recorded_history( &mut contested, &mut wallet, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 155bc04fcd5..7297f14ff08 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -7139,8 +7139,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // instead of claiming "no history". var historyBuckets: [Data: [PersistentTransaction]] = [:] do { + var historyDescriptor = FetchDescriptor() + historyDescriptor.relationshipKeyPathsForPrefetching = [ + \.involvedAccounts, \.inputs, \.outputs, \.pendingInputs, + ] let transactions = try modelFetcher.fetch( - FetchDescriptor(), in: backgroundContext + historyDescriptor, in: backgroundContext ) for w in restorable { historyBuckets[w.walletId] = transactions.filter {