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 01/84] 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 02/84] 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 03/84] 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 04/84] 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 05/84] 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 { From 4f03a596c10067165f2592076e4c39196c42168d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:14:11 +0000 Subject: [PATCH 06/84] fix(platform-wallet): settle stored conflicts before the load replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared load replay swept conflicts only as each record replayed, then again for InstantSend winners, and for confirmed winners only when a raw fallback record had been restored. Two cases survived that: - A confirmed winner always replays before a mempool sibling. A loser that pays the wallet was then recorded after the winner's sweep, and its persisted change stayed selectable: an inflated balance. - A loser's descendant that replays after the winner swept its parent was rebuilt from the output it pays the wallet, and no later sweep reached it without the parent. Same for a descendant paying another account, which the per-account sweep never follows. Port the planning the SQLite replay already does: settle each stored context first (a persisted chain lock finalizes the blocks it covers, a matching lock makes a mempool record InstantSend), resolve every conflict on a scratch copy that holds the whole history in one account, and keep the losers out of the replay and their outputs out of the projection. One unconditional sweep of the settled survivors then replaces the two conditional ones. Planning covers every held record, so the repeated-scan descendant closure is gone. Also correct three doc comments the base merge made inaccurate: the SQLite persister replays inside its own load transaction and hands back an empty history. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/persistence_capabilities.rs | 16 +- .../src/manager/history_replay.rs | 764 +++++++++++++----- .../rs-platform-wallet/src/manager/load.rs | 2 +- 3 files changed, 580 insertions(+), 202 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index ec89324be04..e9c1ce8ef5d 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -105,13 +105,15 @@ 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. + /// Load restores the in-memory spend guards for confirmed AND unconfirmed + /// spends from the wallet's complete stored transaction history: either it + /// hands the history back + /// ([`ClientWalletStartState::recorded_history`](super::ClientWalletStartState::recorded_history)) + /// for the shared load replay, or it replays the history itself before + /// returning. 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 diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 0ba980bceda..47c00d50962 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -1,21 +1,26 @@ //! Load-time replay of stored transaction history. //! -//! Every persister hands back its stored records as a [`RecordedHistory`]; +//! A 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. +//! +//! A persister that must repair its own rows while it replays does so inside +//! its `load` and hands back an empty history; the SQLite one does. -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::{OutPoint, TxOut, 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::transaction_record::{TransactionDirection, TransactionRecord}; use key_wallet::managed_account::ManagedCoreFundsAccount; -use key_wallet::transaction_checking::{TransactionContext, WalletTransactionChecker}; +use key_wallet::transaction_checking::{ + TransactionContext, TransactionType, 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; @@ -38,6 +43,9 @@ use crate::changeset::{RecordedHistory, StoredTransaction}; /// establish spend guards. Their labels, fees and proof-lookup records survive /// replay unless a final conflicting transaction sweeps them. /// +/// A stored transaction that a settled one beats to an input is not replayed, +/// nor are its unconfirmed descendants, and their persisted outputs are dropped. +/// /// Returns how many records were replayed. pub async fn replay_recorded_history( wallet_info: &mut ManagedWalletInfo, @@ -84,43 +92,52 @@ pub async fn replay_recorded_history( // 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); - stage_recorded_spent_inputs(wallet_info, &replay, &placed, &replay_txids); - - let mut replayed = 0usize; - let mut swept = HashSet::new(); - let mut final_transactions = Vec::new(); - let mut instant_send_winners = Vec::new(); - for stored 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(&stored.txid) - .filter(|lock| lock_matches_record(lock, &stored)); - let context = match (stored.context, lock) { - (TransactionContext::Mempool, Some(lock)) => { - TransactionContext::InstantSend(lock.clone()) + let mut replay = replay_order(transactions); + let chain_locked_height = wallet_info + .metadata + .last_applied_chain_lock + .as_ref() + .map(|lock| lock.block_height); + // The lock set already holds a locked 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. + for stored in &mut replay { + stored.context = match &stored.context { + TransactionContext::InBlock(block) + if chain_locked_height.is_some_and(|height| block.height() <= height) => + { + TransactionContext::InChainLockedBlock(*block) } - (context, _) => context, + TransactionContext::Mempool => instant_locks + .get(&stored.txid) + .filter(|lock| lock_matches_record(lock, stored)) + .map(|lock| TransactionContext::InstantSend(lock.clone())) + .unwrap_or(TransactionContext::Mempool), + context => context.clone(), }; + } + // Losers leave the replay and the projection; `swept` also keeps their + // held raw records from coming back as fallbacks. + let mut swept = plan_replay_sweeps(wallet_info, &replay); + replay.retain(|stored| !swept.contains(&stored.txid)); + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| !swept.contains(&outpoint.txid)); + } + stage_recorded_spent_inputs(wallet_info, &replay, &placed); + + for stored in &replay { let result = wallet_info - .check_core_transaction(&stored.transaction, context.clone(), wallet, true, false) + .check_core_transaction( + &stored.transaction, + stored.context.clone(), + wallet, + true, + false, + ) .await; swept.extend(result.swept_transactions); - if matches!(context, TransactionContext::InstantSend(_)) { - instant_send_winners.push((stored.transaction.clone(), context.clone())); - } - if !held.is_empty() && !matches!(context, TransactionContext::Mempool) { - final_transactions.push((stored.transaction, context)); - } - replayed += 1; - } - // A lock's sweep only reaches conflicts already replayed; siblings replay - // by txid, so settle the ones that came after their winner here. Their - // held raw records must not come back as fallbacks either. - for (transaction, context) in &instant_send_winners { - swept.extend(wallet_info.sweep_conflicts(transaction, context).txids); } let spent: HashSet<_> = wallet_info @@ -154,8 +171,6 @@ pub async fn replay_recorded_history( && !misplaced.contains(&(*outpoint, owner)) }); } - drop_swept_descendants(&mut swept, &held, &instant_locks); - 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() { @@ -168,14 +183,15 @@ pub async fn replay_recorded_history( } 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); + // A settled spend's first sweep cannot reach competitors replayed later, + // nor the raw records restored above. The upstream sweep preserves + // ChainLock/InstantSend precedence. + for stored in &replay { + if !matches!(stored.context, TransactionContext::Mempool) { + wallet_info.sweep_conflicts(&stored.transaction, &stored.context); } } // Finalize replayed records before a sync checkpoint can prune their spend guards. @@ -183,47 +199,85 @@ pub async fn replay_recorded_history( wallet_info.apply_chain_lock(chain_lock); } wallet_info.update_balance(); - replayed + replay.len() } -/// Extend `swept` with every held unconfirmed record that descends from a swept one. +/// Resolve the full history before a defeated lock can sweep another spender. /// -/// A record the checker could not attribute is restored as a raw fallback, -/// and the final conflict sweeps reach its descendants only through a parent -/// that is still recorded. Once a sweep already removed that parent (it was -/// attributable, e.g. through a staged input), the orphaned child would come -/// back for good. Mirrors the checker's own rule: only unconfirmed, unlocked -/// descendants follow a swept parent. -fn drop_swept_descendants( - swept: &mut HashSet, - held: &HashMap>, - instant_locks: &BTreeMap, -) { - let followable: Vec<&TransactionRecord> = held - .values() - .flatten() - .filter(|record| { - matches!(record.context, TransactionContext::Mempool) - && !instant_locks.contains_key(&record.txid) +/// Returns every transaction a settled stored one beats to an input, with its +/// unconfirmed descendants. Planning ahead of the replay is what keeps them +/// out: replayed after its parent was swept, a descendant that pays the wallet +/// would be recorded again, and no later sweep reaches it without the parent. +fn plan_replay_sweeps( + wallet_info: &ManagedWalletInfo, + records: &[StoredTransaction], +) -> HashSet { + let mut conflicts = wallet_info.clone(); + // A scratch account lets the upstream sweep see descendants across account boundaries. + let Some(account) = conflicts + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + else { + return HashSet::new(); + }; + let owner = funds_account_type(account); + // The sweep reads only a record's context and inputs; the rest is filler. + account + .transactions_mut() + .extend(records.iter().map(|stored| { + let record = TransactionRecord::new( + stored.transaction.clone(), + owner, + stored.context.clone(), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + (stored.txid, record) + })); + // Only an input an unconfirmed record also spends can be contested; + // skipping every other settled record keeps the plan linear in the history. + let contested: HashSet = conflicts + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values()) + .filter(|record| !record.is_confirmed()) + .flat_map(|record| record.transaction.input.iter()) + .map(|input| input.previous_output) + .collect(); + let mut winners: Vec<&StoredTransaction> = records + .iter() + .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .filter(|stored| { + stored + .transaction + .input + .iter() + .any(|input| contested.contains(&input.previous_output)) }) .collect(); - loop { - let before = swept.len(); - for record in &followable { - if !swept.contains(&record.txid) - && record - .transaction - .input - .iter() - .any(|input| swept.contains(&input.previous_output.txid)) - { - swept.insert(record.txid); - } - } - if swept.len() == before { - return; + winners.sort_by_key(|stored| { + let priority = match stored.context { + TransactionContext::InChainLockedBlock(_) => 0, + TransactionContext::InstantSend(_) => 1, + _ => 2, + }; + (priority, stored.txid) + }); + let mut removed = HashSet::new(); + for stored in winners { + if removed.contains(&stored.txid) { + continue; } + let sweep = conflicts.sweep_conflicts(&stored.transaction, &stored.context); + removed.extend(sweep.txids); } + removed } /// Park every owned input a record spends whose funding no replayed record credits. @@ -239,8 +293,8 @@ fn stage_recorded_spent_inputs( wallet_info: &mut ManagedWalletInfo, records: &[StoredTransaction], placed: &HashMap, - replay_txids: &HashSet, ) { + let replayed: HashSet = records.iter().map(|record| record.txid).collect(); let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); for record in records { for detail in &record.owned_inputs { @@ -248,7 +302,7 @@ fn stage_recorded_spent_inputs( continue; }; let outpoint = input.previous_output; - if placed.contains_key(&outpoint) || replay_txids.contains(&outpoint.txid) { + if placed.contains_key(&outpoint) || replayed.contains(&outpoint.txid) { continue; } let Some(account) = accounts @@ -802,6 +856,279 @@ mod tests { } } + /// A wallet-paying child of a conflict loser must go with its parent + /// wherever the InstantSend winner falls in the replay order. Between the + /// two, the loser is already swept when the child replays, and the checker + /// would rebuild the child from the output it pays the wallet. + #[tokio::test] + async fn should_drop_attributable_descendant_of_swept_conflict_for_any_replay_order() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let pay = |previous_output: OutPoint, outputs: &[(u64, bool)]| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: outputs + .iter() + .map(|(value, ours)| TxOut { + value: *value, + script_pubkey: if *ours { + address.script_pubkey() + } else { + dashcore::ScriptBuf::new() + }, + }) + .collect(), + special_transaction_payload: None, + }; + let funding = pay( + OutPoint::new(Txid::from_byte_array([51; 32]), 0), + &[(100_000, true)], + ); + let coin = OutPoint::new(funding.txid(), 0); + let loser = pay(coin, &[(59_000, false), (40_000, true)]); + let loser_change = OutPoint::new(loser.txid(), 1); + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let funded = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + + for position in ["first", "between", "last"] { + // Unconfirmed records replay by txid, a child after its parent. + let (winner, child) = (1..64_u64) + .flat_map(|w| (1..64_u64).map(move |c| (w, c))) + .map(|(w, c)| { + ( + pay(coin, &[(68_000 - w, false), (30_000, true)]), + pay(loser_change, &[(39_000 - c, true)]), + ) + }) + .find(|(winner, child)| { + let (winner, loser, child) = (winner.txid(), loser.txid(), child.txid()); + match position { + "first" => winner < loser, + "between" => loser < winner && winner < child, + _ => loser < winner && child < winner, + } + }) + .unwrap(); + let child_output = OutPoint::new(child.txid(), 0); + let winner_change = OutPoint::new(winner.txid(), 1); + + // Every spend as stored: unconfirmed, the winner's lock kept apart. + let mut records = funded.clone(); + let mut losing = info.clone(); + for tx in [&loser, &child] { + records.extend( + losing + .check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + records.extend( + info.clone() + .check_core_transaction( + &winner, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + assert_eq!(records.len(), 4, "{position}"); + // Persisted as unspent: the winner's change and the child's output. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for (outpoint, txout) in [ + (winner_change, &winner.output[1]), + (child_output, &child.output[0]), + ] { + account.utxos.insert( + outpoint, + Utxo::new(outpoint, txout.clone(), address.clone(), 0, false), + ); + } + let lock = InstantLock { + inputs: vec![coin], + txid: winner.txid(), + ..Default::default() + }; + let locks = [(winner.txid(), lock)].into_iter().collect(); + + replay_recorded_history(&mut restored, &mut wallet, history(records, locks)).await; + + let account = &restored.accounts.standard_bip44_accounts[&0]; + for (name, txid) in [("loser", loser.txid()), ("child", child.txid())] { + assert!( + !account.transactions().contains_key(&txid), + "{position}: the {name} must be swept" + ); + } + assert!( + !account.utxos.contains_key(&child_output), + "{position}: the swept child's output must not stay selectable" + ); + assert!( + account.utxos.contains_key(&winner_change), + "{position}: the winner's change stays" + ); + assert_eq!(restored.balance.total(), 30_000, "{position}"); + } + } + + /// A confirmed winner must also take a loser's descendant that pays a + /// different account: the per-account conflict sweep reaches a descendant + /// only through a parent recorded in the same account. + #[tokio::test] + async fn should_remove_cross_account_descendant_of_conflicting_spend() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn, TxOut}; + use key_wallet::transaction_checking::BlockInfo; + + let mut wallet = Wallet::new_random( + Network::Testnet, + WalletAccountCreationOptions::BIP44AccountsOnly([0, 1].into_iter().collect()), + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let addresses: Vec<_> = [0, 1] + .into_iter() + .map(|index| { + let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; + info.accounts + .standard_bip44_accounts + .get_mut(&index) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap() + }) + .collect(); + let pay = |previous_output: OutPoint, value: u64, script_pubkey| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey, + }], + special_transaction_payload: None, + }; + let funding = pay( + OutPoint::new(Txid::from_byte_array([41; 32]), 0), + 100_000, + addresses[0].script_pubkey(), + ); + let coin = OutPoint::new(funding.txid(), 0); + let root = pay(coin, 99_000, addresses[0].script_pubkey()); + let child = pay( + OutPoint::new(root.txid(), 0), + 98_000, + addresses[1].script_pubkey(), + ); + let winner = pay(coin, 97_000, dashcore::ScriptBuf::new()); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([42; 32]), + height, + )) + }; + let mut records = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + let mut winning = info.clone(); + for tx in [&root, &child] { + records.extend( + info.check_core_transaction( + tx, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await + .new_records, + ); + } + records.extend( + winning + .check_core_transaction(&winner, block(101), &mut wallet, true, true) + .await + .new_records, + ); + crate::test_support::fold_wallet_records(&mut records); + assert_eq!(records.len(), 4); + + // Persisted as unspent: only the child's output, in the other account. + let child_output = OutPoint::new(child.txid(), 0); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&1) + .unwrap() + .utxos + .insert( + child_output, + Utxo::new( + child_output, + child.output[0].clone(), + addresses[1].clone(), + 0, + false, + ), + ); + + replay_recorded_history( + &mut restored, + &mut wallet, + history(records, BTreeMap::new()), + ) + .await; + + for (index, account) in &restored.accounts.standard_bip44_accounts { + for (name, txid) in [("root", root.txid()), ("child", child.txid())] { + assert!( + !account.transactions().contains_key(&txid), + "account {index}: the conflicting {name} must be swept" + ); + } + assert!( + !account.utxos.contains_key(&child_output), + "account {index}: a conflicting descendant must not stay selectable" + ); + } + assert_eq!(restored.balance.total(), 0); + } + /// An empty history leaves the restored projection untouched. #[tokio::test] async fn should_leave_projection_untouched_without_history() { @@ -1051,132 +1378,181 @@ mod tests { ); } - /// An InstantSend winner must sweep its conflicting mempool sibling whichever - /// of the two replays first, so the loser's wallet-owned change, still - /// persisted as unspent, does not come back selectable, nor its raw - /// restored record as an unattributable fallback. + /// Settled spends must sweep persisted competing change after all siblings + /// replay, and a raw restored loser must not come back as a fallback. Only a + /// chainlock overrules a competitor that holds its own InstantSend lock. #[tokio::test] async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { + use dashcore::ephemerealdata::chain_lock::ChainLock; 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; - use key_wallet::Utxo; - for (lock_later_txid, raw_restored) in - [(false, false), (true, false), (false, true), (true, true)] - { - let case = format!("lock_later_txid={lock_later_txid} raw_restored={raw_restored}"); - 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 = |change| Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(funding.txid(), 0), - ..Default::default() - }], - output: vec![ - TxOut { - value: 99_000 - change, - script_pubkey: dashcore::ScriptBuf::new(), - }, - TxOut { - value: change, - script_pubkey: address.script_pubkey(), - }, - ], - special_transaction_payload: None, - }; - let block = TransactionContext::InBlock(BlockInfo::new( - 100, - BlockHash::from_byte_array([9; 32]), - 100, - )); - let mut records = info - .check_core_transaction(&funding, block, &mut wallet, true, true) - .await - .new_records; - let (mut winner, mut loser) = (spend(40_000), spend(30_000)); - if (winner.txid() > loser.txid()) != lock_later_txid { - std::mem::swap(&mut winner, &mut loser); - } - // Both siblings as stored: unconfirmed, each credited its change. - let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); - 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, + let settlements = [ + ("instant_send", false), + ("block", false), + ("chainlock", false), + ("block", true), + ("chainlock", true), + ("persisted_chainlock", true), + ("persisted_chainlock_below", true), + ]; + let orders = [(false, false), (true, false), (false, true), (true, true)]; + for (settlement, locked_competitor) in settlements { + for (winner_later_txid, raw_restored) in orders { + let case = format!( + "{settlement}, locked_competitor={locked_competitor}, \ + winner_later_txid={winner_later_txid}, raw_restored={raw_restored}" ); - let change = OutPoint::new(tx.txid(), 1); - restored - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap() - .utxos - .insert( - change, - Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + 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 = |change| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 99_000 - change, + script_pubkey: dashcore::ScriptBuf::new(), + }, + TxOut { + value: change, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, + }; + let block = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([9; 32]), + 100, + )); + let mut records = info + .check_core_transaction(&funding, block, &mut wallet, true, true) + .await + .new_records; + let (mut winner, mut loser) = (spend(40_000), spend(30_000)); + if (winner.txid() > loser.txid()) != winner_later_txid { + std::mem::swap(&mut winner, &mut loser); + } + // Both siblings as stored: unconfirmed, each credited its change. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + 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, "{case}"); - if raw_restored { - let account = restored - .accounts - .standard_bip44_accounts - .get_mut(&0) - .unwrap(); - for record in &records { - account - .transactions_mut() - .insert(record.txid, record.clone()); + let change = OutPoint::new(tx.txid(), 1); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + change, + Utxo::new(change, tx.output[1].clone(), address.clone(), 0, false), + ); + } + assert_eq!(records.len(), 3, "{case}"); + let lock_of = |txid| InstantLock { + inputs: vec![OutPoint::new(funding.txid(), 0)], + txid, + ..Default::default() + }; + let mut locks = BTreeMap::new(); + if settlement == "instant_send" { + locks.insert(winner.txid(), lock_of(winner.txid())); + } else { + let block = BlockInfo::new(101, BlockHash::from_byte_array([10; 32]), 101); + let record = records + .iter_mut() + .find(|record| record.txid == winner.txid()) + .unwrap(); + record.context = if settlement == "chainlock" { + TransactionContext::InChainLockedBlock(block) + } else { + TransactionContext::InBlock(block) + }; + } + if settlement.starts_with("persisted_chainlock") { + restored.metadata.last_applied_chain_lock = Some(ChainLock { + block_height: if settlement == "persisted_chainlock" { + 101 + } else { + 100 + }, + block_hash: BlockHash::from_byte_array([10; 32]), + signature: [0; 96].into(), + }); + } + if locked_competitor { + locks.insert(loser.txid(), lock_of(loser.txid())); + } + if raw_restored { + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for record in &records { + account + .transactions_mut() + .insert(record.txid, record.clone()); + } } - } - let lock = InstantLock { - inputs: vec![OutPoint::new(funding.txid(), 0)], - txid: winner.txid(), - ..Default::default() - }; - let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); - replay_recorded_history(&mut restored, &mut wallet, history(records, locks)).await; + replay_recorded_history(&mut restored, &mut wallet, history(records, locks)).await; - let account = &restored.accounts.standard_bip44_accounts[&0]; - assert!( - !account.transactions().contains_key(&loser.txid()), - "{case}: the lock must sweep the competing spend" - ); - assert!( - !account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), - "{case}: the swept spend's change must not stay selectable" - ); - assert!( - account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), - "{case}: the winner's change stays" - ); - assert_eq!(restored.balance.total(), winner.output[1].value, "{case}"); + let account = &restored.accounts.standard_bip44_accounts[&0]; + let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") + && locked_competitor; + assert_eq!( + account.transactions().contains_key(&loser.txid()), + keep_competitor, + "{case}: only a chainlock can overrule an InstantSend lock" + ); + assert_eq!( + account.utxos.contains_key(&OutPoint::new(loser.txid(), 1)), + keep_competitor, + "{case}: the swept spend's change must not stay selectable" + ); + assert!( + account.utxos.contains_key(&OutPoint::new(winner.txid(), 1)), + "{case}: the winner's change stays" + ); + let expected_balance = winner.output[1].value + + if keep_competitor { + loser.output[1].value + } else { + 0 + }; + assert_eq!(restored.balance.total(), expected_balance, "{case}"); + } } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index d22e5737ae2..32fa9db98ee 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1183,7 +1183,7 @@ 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. + /// shape of a persister that leaves the replay to the shared load. struct RecordedHistoryPersister { wallet: Wallet, managed: ManagedWalletInfo, From d324442b755c83ca0c12c4ef878f5c9fea238957 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:23:24 +0000 Subject: [PATCH 07/84] fix(platform-wallet): keep asset-lock conflict evidence across the load replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The double-spend screen's memory is seeded at load from the restored wallet history. The shared replay runs first and leaves two kinds of confirmed spender out of that history: one buried under the restored chain lock, whose record the replay's finalization evicts under the default `keep-finalized-transactions = OFF`, and one the checker cannot attribute, which never gets a record. A Built or Broadcast asset lock whose input such a transaction spent then fell back into the unbounded proof wait. Read the evidence from the stored records before the replay consumes them (`recorded_input_conflicts`) and start the memory from it; the history-based seeding still runs on top. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/manager/load.rs | 132 +++++++++++++++++- .../src/wallet/asset_lock/sync/recovery.rs | 125 ++++++++++++++++- 2 files changed, 247 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 32fa9db98ee..9f960b561b8 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,13 +1,16 @@ //! Hydrate a [`PlatformWalletManager`] from its persister. use std::collections::{BTreeMap, HashSet}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use crate::changeset::{ ClientStartState, ClientWalletStartState, PersistenceCapabilities, PlatformWalletPersistence, }; use crate::error::PlatformWalletError; use crate::manager::history_replay::replay_recorded_history; +use crate::wallet::asset_lock::sync::recovery::{ + recorded_input_conflicts, seed_observed_input_conflicts, +}; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -151,6 +154,13 @@ impl PlatformWalletManager

{ recorded_history, } = wallet_state; + // Read before the replay consumes the history: its finalization + // evicts a buried spender's record, where the seeding below looks. + let recorded_conflicts = recorded_input_conflicts( + unused_asset_locks.values().flat_map(BTreeMap::values), + &recorded_history, + ); + // 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 @@ -269,7 +279,7 @@ impl PlatformWalletManager

{ core_balance.locked(), ); let platform_info = PlatformWalletInfo { - observed_input_conflicts: Default::default(), + observed_input_conflicts: Mutex::new(recorded_conflicts), core_wallet: wallet_info, generation: Arc::clone(&generation), identity_manager: IdentityManager::from(identity_manager), @@ -280,9 +290,7 @@ impl PlatformWalletManager

{ // freshly restored state: it closes the race where SPV's // chainlock dispatcher promotion-evicts a restored spender // before the first catch-up resume ever reads it. - crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts( - &platform_info, - ); + seed_observed_input_conflicts(&platform_info); if wallet_id != expected_wallet_id { load_error = Some(PlatformWalletError::WalletCreation(format!( @@ -721,6 +729,7 @@ mod idempotent_load_tests { use key_wallet::test_utils::TestWalletContext; use key_wallet::transaction_checking::transaction_context::TransactionContext; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; @@ -731,7 +740,8 @@ mod idempotent_load_tests { }; use crate::events::PlatformEventHandler; use crate::test_support::NoopTestEventHandler; - use crate::wallet::platform_wallet::WalletId; + use crate::wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; + use crate::wallet::platform_wallet::{ObservedInputConflict, WalletId}; use crate::PlatformWalletManager; /// Persister that hands back one wallet plus the outgoing sends the @@ -1188,6 +1198,7 @@ mod idempotent_load_tests { wallet: Wallet, managed: ManagedWalletInfo, history: RecordedHistory, + asset_locks: BTreeMap, } impl PlatformWalletPersistence for RecordedHistoryPersister { @@ -1211,7 +1222,7 @@ mod idempotent_load_tests { wallet: self.wallet.clone(), wallet_info: self.managed.clone(), identity_manager: IdentityManagerStartState::default(), - unused_asset_locks: BTreeMap::new(), + unused_asset_locks: [(0, self.asset_locks.clone())].into_iter().collect(), unconfirmed_outgoing_txs: Vec::new(), recorded_history: self.history.clone(), }, @@ -1268,6 +1279,7 @@ mod idempotent_load_tests { transactions: vec![stored(spend), stored(funding)], instant_locks: BTreeMap::new(), }, + asset_locks: BTreeMap::new(), }); manager @@ -1289,6 +1301,112 @@ mod idempotent_load_tests { ); } + /// A stored confirmed spend of a pending asset lock's input must reach + /// the double-spend screen's memory although the replayed wallet history + /// no longer shows it. With the funding recorded, the restored chain lock + /// buries the spender and finalization evicts its record (unless + /// `keep-finalized-transactions` is on); without it, the spender is not + /// attributable and never gets a record at all. + #[tokio::test] + async fn should_remember_stored_spender_of_pending_asset_lock_input_on_load() { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::BlockInfo; + + for funding_recorded in [true, false] { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let contested = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let asset_lock = spend_to(contested, 74_000); + let spender = spend_to(contested, 73_000); + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let stored = |transaction: dashcore::Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let mut transactions = vec![stored(spender.clone(), in_block(100))]; + if funding_recorded { + transactions.push(stored(funding, in_block(90))); + } + // The persisted projection: the coin is spent, the chain lock is + // past the spender's block. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: 150, + block_hash: dashcore::BlockHash::from_byte_array([150; 32]), + signature: [0; 96].into(), + }); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + transactions, + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + out_point, + transaction: asset_lock, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status: AssetLockStatus::Broadcast, + proof: None, + }, + )] + .into_iter() + .collect(), + }); + + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wm = manager.wallet_manager.read().await; + let info = wm + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered"); + let remembered = info + .observed_input_conflicts + .lock() + .expect("conflict memory") + .get(&contested) + .copied(); + assert_eq!( + remembered, + Some(ObservedInputConflict { + spender: spender.txid(), + height: 100, + }), + "funding_recorded={funding_recorded}: the stored confirmed spender must be \ + remembered after the load replay" + ); + } + } + fn make_history_manager( persister: RecordedHistoryPersister, ) -> Arc> { diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 7e886ea7da6..519f482e4f7 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -5,7 +5,7 @@ //! and re-deriving private keys. use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -19,8 +19,9 @@ use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundin use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::changeset::changeset::AssetLockChangeSet; +use crate::changeset::RecordedHistory; use crate::error::PlatformWalletError; -use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::platform_wallet::{ObservedInputConflict, PlatformWalletInfo}; use super::super::manager::AssetLockManager; use super::super::orchestration::UNCONFIRMED_BROADCAST_PROOF_TIMEOUT; @@ -492,6 +493,63 @@ pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { } } +/// Confirmed spends of pending asset-lock inputs, read from the stored history. +/// +/// The load replay keeps two kinds of spender out of the wallet history that +/// [`seed_observed_input_conflicts`] reads: one buried under the restored +/// chain lock (finalization evicts its record) and one the checker cannot +/// attribute (it never gets a record). Call before the replay. +pub(crate) fn recorded_input_conflicts<'a>( + locks: impl IntoIterator, + history: &RecordedHistory, +) -> BTreeMap { + let pending: Vec<&TrackedAssetLock> = locks + .into_iter() + .filter(|lock| { + matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) + }) + .collect(); + let watched: HashSet = pending + .iter() + .flat_map(|lock| lock.transaction.input.iter()) + .map(|input| input.previous_output) + .collect(); + let mut conflicts = BTreeMap::new(); + if watched.is_empty() { + return conflicts; + } + for stored in &history.transactions { + let Some(height) = stored.context.block_info().map(|block| block.height()) else { + continue; + }; + for input in stored.transaction.input.iter().map(|i| i.previous_output) { + // A lock's own confirmation is not a conflict with itself. + let contested = watched.contains(&input) + && pending.iter().any(|lock| { + lock.transaction.txid() != stored.txid + && lock + .transaction + .input + .iter() + .any(|i| i.previous_output == input) + }); + if contested { + conflicts.insert( + input, + ObservedInputConflict { + spender: stored.txid, + height, + }, + ); + } + } + } + conflicts +} + impl AssetLockManager { /// Wait for the broadcaster's transport to come up before a resume /// broadcasts, and return the finality budget left over. @@ -1933,6 +1991,7 @@ mod tests { use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; use crate::changeset::{ ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + RecordedHistory, StoredTransaction, }; use crate::error::PlatformWalletError; use crate::test_support::{ @@ -1943,7 +2002,7 @@ mod tests { use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::persister::WalletPersister; - use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; + use crate::wallet::platform_wallet::{ObservedInputConflict, PlatformWalletInfo, WalletId}; use crate::AssetLockFundingType; /// Persistence stub that records every stored changeset so the test @@ -3730,6 +3789,66 @@ mod tests { } } + /// The stored-history evidence names only another transaction's confirmed + /// spend of an input a still-pending lock needs. + #[test] + fn should_report_only_foreign_confirmed_spends_of_pending_lock_inputs() { + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let spending = |spends, lock_time| Transaction { + lock_time, + ..transaction_spending(spends) + }; + let lock = |transaction: Transaction, status| TrackedAssetLock { + out_point: OutPoint::new(transaction.txid(), 0), + transaction, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status, + proof: None, + }; + let stored = |transaction: Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + + let (contested, own, unconfirmed, consumed) = + (outpoint(1), outpoint(2), outpoint(3), outpoint(4)); + let confirmed_lock = spending(own, 0); + let locks = [ + lock(spending(contested, 0), AssetLockStatus::Broadcast), + lock(confirmed_lock.clone(), AssetLockStatus::Built), + lock(spending(unconfirmed, 0), AssetLockStatus::Broadcast), + lock(spending(consumed, 0), AssetLockStatus::Consumed), + ]; + let spender = spending(contested, 1); + let history = RecordedHistory { + transactions: vec![ + stored(spender.clone(), chain_locked_at(100)), + stored(confirmed_lock, confirmed_at(101)), + stored(spending(unconfirmed, 1), TransactionContext::Mempool), + stored(spending(consumed, 1), confirmed_at(102)), + ], + instant_locks: BTreeMap::new(), + }; + + assert_eq!( + super::recorded_input_conflicts(&locks, &history), + BTreeMap::from([( + contested, + ObservedInputConflict { + spender: spender.txid(), + height: 100, + }, + )]) + ); + } + /// Promotion is eviction: once a chainlock buries the spender's block, /// `apply_chain_lock` removes its record from history. The screen's /// session memory must carry the conflict across that disappearance From e255b500946771712bddf1ab1887d40465faed54 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:23:24 +0000 Subject: [PATCH 08/84] docs(platform-wallet): explain both context upgrades of the load replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment above the settling loop only covered the InstantSend upgrade; the loop also finalizes blocks the restored chain lock covers. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/manager/history_replay.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 47c00d50962..bb76e94dd2b 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -98,9 +98,9 @@ pub async fn replay_recorded_history( .last_applied_chain_lock .as_ref() .map(|lock| lock.block_height); - // The lock set already holds a locked 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. + // Settle each stored context: a block under the restored chain lock is + // final, and a persisted lock must upgrade its record here, because the + // lock set already holds that txid and deduplicates a later lock event. for stored in &mut replay { stored.context = match &stored.context { TransactionContext::InBlock(block) From b13a1c136faa7c0102e572f70f4f4c54ac27c734 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:34:01 +0000 Subject: [PATCH 09/84] fix(platform-wallet): plan load-replay sweeps only for contested spends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conflict planner picked its winners once: every settled record sharing an input with any unconfirmed record. Each of them then ran a sweep over a scratch account holding the whole stored history, also after an earlier sweep had already removed the only competitor. One unconfirmed record naming the inputs of N confirmed spenders therefore cost N whole-history scans at every load, and the store keeps that row. - The scratch account now holds only the unconfirmed records: losers and their descendants are never anything else. - A winner is swept only while another unconfirmed record still spends one of its inputs; the count is kept current as sweeps remove records. - The pass after the replay applies the same test instead of sweeping for every settled survivor. Finality precedence and cross-account descendants stay with the upstream sweep. The plan reports its sweep count, logged once per load. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 212 ++++++++++++++---- 1 file changed, 170 insertions(+), 42 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index bb76e94dd2b..bd0317b10e3 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -118,7 +118,13 @@ pub async fn replay_recorded_history( } // Losers leave the replay and the projection; `swept` also keeps their // held raw records from coming back as fallbacks. - let mut swept = plan_replay_sweeps(wallet_info, &replay); + let (mut swept, planned_sweeps) = plan_replay_sweeps(wallet_info, &replay); + tracing::debug!( + wallet_id = %hex::encode(wallet_info.wallet_id), + planned_sweeps, + removed = swept.len(), + "load: settled stored transaction conflicts before the history replay" + ); replay.retain(|stored| !swept.contains(&stored.txid)); for account in wallet_info.accounts.all_funding_accounts_mut() { account @@ -188,9 +194,13 @@ pub async fn replay_recorded_history( } // A settled spend's first sweep cannot reach competitors replayed later, // nor the raw records restored above. The upstream sweep preserves - // ChainLock/InstantSend precedence. + // ChainLock/InstantSend precedence; it runs only for a spend something + // unconfirmed still contests, because each sweep scans every live record. + let competitors = unconfirmed_spends(wallet_info); for stored in &replay { - if !matches!(stored.context, TransactionContext::Mempool) { + if !matches!(stored.context, TransactionContext::Mempool) + && is_contested(stored, &competitors) + { wallet_info.sweep_conflicts(&stored.transaction, &stored.context); } } @@ -208,10 +218,11 @@ pub async fn replay_recorded_history( /// unconfirmed descendants. Planning ahead of the replay is what keeps them /// out: replayed after its parent was swept, a descendant that pays the wallet /// would be recorded again, and no later sweep reaches it without the parent. +/// Also returns how many conflict sweeps the plan ran. fn plan_replay_sweeps( wallet_info: &ManagedWalletInfo, records: &[StoredTransaction], -) -> HashSet { +) -> (HashSet, usize) { let mut conflicts = wallet_info.clone(); // A scratch account lets the upstream sweep see descendants across account boundaries. let Some(account) = conflicts @@ -220,46 +231,26 @@ fn plan_replay_sweeps( .into_iter() .next() else { - return HashSet::new(); + return (HashSet::new(), 0); }; let owner = funds_account_type(account); - // The sweep reads only a record's context and inputs; the rest is filler. - account - .transactions_mut() - .extend(records.iter().map(|stored| { - let record = TransactionRecord::new( - stored.transaction.clone(), - owner, - stored.context.clone(), - TransactionType::Standard, - TransactionDirection::Outgoing, - Vec::new(), - Vec::new(), - 0, - ); - (stored.txid, record) - })); - // Only an input an unconfirmed record also spends can be contested; - // skipping every other settled record keeps the plan linear in the history. - let contested: HashSet = conflicts - .accounts - .all_accounts() - .into_iter() - .flat_map(|account| account.transactions().values()) - .filter(|record| !record.is_confirmed()) - .flat_map(|record| record.transaction.input.iter()) - .map(|input| input.previous_output) + // Losers and their descendants are always unconfirmed, and every sweep + // scans the scratch account whole, so settled records stay out of it. + let unconfirmed: HashMap = records + .iter() + .filter(|stored| stored.context.block_info().is_none()) + .map(|stored| (stored.txid, stored)) .collect(); + account.transactions_mut().extend( + unconfirmed + .values() + .map(|stored| (stored.txid, raw_record(stored, owner))), + ); + let mut competitors = unconfirmed_spends(&conflicts); let mut winners: Vec<&StoredTransaction> = records .iter() .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) - .filter(|stored| { - stored - .transaction - .input - .iter() - .any(|input| contested.contains(&input.previous_output)) - }) + .filter(|stored| is_contested(stored, &competitors)) .collect(); winners.sort_by_key(|stored| { let priority = match stored.context { @@ -269,15 +260,71 @@ fn plan_replay_sweeps( }; (priority, stored.txid) }); - let mut removed = HashSet::new(); + let (mut removed, mut sweeps) = (HashSet::new(), 0); for stored in winners { - if removed.contains(&stored.txid) { + // An earlier sweep may have taken this winner or its last competitor. + if removed.contains(&stored.txid) || !is_contested(stored, &competitors) { continue; } + sweeps += 1; let sweep = conflicts.sweep_conflicts(&stored.transaction, &stored.context); - removed.extend(sweep.txids); + for txid in sweep.txids { + for input in unconfirmed + .get(&txid) + .into_iter() + .flat_map(|loser| &loser.transaction.input) + { + if let Some(count) = competitors.get_mut(&input.previous_output) { + *count = count.saturating_sub(1); + } + } + removed.insert(txid); + } } - removed + (removed, sweeps) +} + +/// A history record holding only what the conflict sweep reads: the context +/// and the inputs. The amounts and details are filler. +fn raw_record(stored: &StoredTransaction, owner: AccountType) -> TransactionRecord { + TransactionRecord::new( + stored.transaction.clone(), + owner, + stored.context.clone(), + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ) +} + +/// How many unconfirmed records of `wallet_info` spend each outpoint. +fn unconfirmed_spends(wallet_info: &ManagedWalletInfo) -> HashMap { + let mut spends = HashMap::new(); + for record in wallet_info + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values()) + .filter(|record| !record.is_confirmed()) + { + for input in &record.transaction.input { + *spends.entry(input.previous_output).or_default() += 1; + } + } + spends +} + +/// Whether an unconfirmed record other than `stored` itself still spends one +/// of its inputs: only then can a conflict sweep for it remove anything. +fn is_contested(stored: &StoredTransaction, competitors: &HashMap) -> bool { + let own = usize::from(stored.context.block_info().is_none()); + stored.transaction.input.iter().any(|input| { + competitors + .get(&input.previous_output) + .is_some_and(|count| *count > own) + }) } /// Park every owned input a record spends whose funding no replayed record credits. @@ -1129,6 +1176,87 @@ mod tests { assert_eq!(restored.balance.total(), 0); } + /// Planning must not run one whole-history sweep per settled record that + /// merely shares an input with an unconfirmed one: a sweep is worth + /// running only while a competitor it could remove is still there. + #[test] + fn should_plan_sweeps_only_while_a_competitor_remains() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + const SPENDERS: u32 = 200; + let (_wallet, info, address) = wallet_with_receive_address(); + let outpoint = |index: u32| { + let mut bytes = [7_u8; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + OutPoint::new(Txid::from_byte_array(bytes), 0) + }; + let stored = |spends: Vec, lock_time, context| { + let transaction = Transaction { + version: 1, + lock_time, + input: spends + .into_iter() + .map(|index| TxIn { + previous_output: outpoint(index), + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + } + }; + let confirmed: Vec = (0..SPENDERS) + .map(|index| { + let block = BlockInfo::new(100 + index, BlockHash::from_byte_array([1; 32]), 0); + stored(vec![index], index, TransactionContext::InBlock(block)) + }) + .collect(); + + // One unconfirmed record contests every confirmed spender at once. + let greedy = stored((0..SPENDERS).collect(), 0, TransactionContext::Mempool); + let mut records = confirmed.clone(); + records.push(greedy.clone()); + let (removed, sweeps) = plan_replay_sweeps(&info, &records); + assert_eq!(removed, HashSet::from([greedy.txid])); + assert_eq!(sweeps, 1, "the competitor is gone after the first sweep"); + + // Each confirmed spender has a competitor of its own, next to + // unconfirmed records nothing contests. + let competitors: Vec = (0..SPENDERS) + .map(|index| stored(vec![index], SPENDERS + index, TransactionContext::Mempool)) + .collect(); + let bystanders: Vec = (0..SPENDERS) + .map(|index| { + stored( + vec![SPENDERS + index], + 2 * SPENDERS + index, + TransactionContext::Mempool, + ) + }) + .collect(); + let records: Vec = + [&confirmed[..], &competitors[..], &bystanders[..]].concat(); + let (removed, sweeps) = plan_replay_sweeps(&info, &records); + assert_eq!( + removed, + competitors.iter().map(|stored| stored.txid).collect() + ); + assert_eq!(sweeps, SPENDERS as usize); + } + /// An empty history leaves the restored projection untouched. #[tokio::test] async fn should_leave_projection_untouched_without_history() { From 2a4368fd7d20d1617de41767214cdad09ce670d9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:45:50 +0000 Subject: [PATCH 10/84] fix(platform-wallet): keep unsafe stored amounts away from the wallet checker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared load replay handed every stored record to the wallet checker, which casts each unsigned amount to `i64` before a signed subtraction. A stored record spending a 1-duff coin and paying `1 << 63` duffs to a wallet address overflows it: a panic at every load where overflow is checked, a wrapped net amount where it is not. Nothing authenticates an unconfirmed transaction, so those amounts are peer-supplied. `unsafe_replay_amounts` checks every operand first: restored coins, outputs (asset-lock credit outputs included) and recorded owned inputs, each alone and summed. It takes only the wallet and each transaction's txid, body and input details, so the SQLite replay can adopt it. A record it rejects is left out of the replay; the load never fails. No valid transaction carries such amounts, so a confirmed one is store corruption (logged as an error), and this path has no recovery mode. Such a record still takes part in conflict settlement on its inputs: - a stored settled winner sweeps it during planning; - otherwise the restored coins it spends stay reserved through spend claims naming it, and an unconfirmed one stays on record, unchecked, so a winner delivered after the load sweeps it and releases the coin the winner does not spend itself. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 483 +++++++++++++++++- .../rs-platform-wallet/src/manager/load.rs | 62 +++ 2 files changed, 540 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index bd0317b10e3..f5ef1bfd857 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -13,10 +13,13 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use dashcore::ephemerealdata::instant_lock::InstantLock; -use dashcore::{OutPoint, TxOut, Txid}; +use dashcore::transaction::TransactionPayload; +use dashcore::{OutPoint, Transaction, TxOut, Txid}; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; -use key_wallet::managed_account::transaction_record::{TransactionDirection, TransactionRecord}; +use key_wallet::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, +}; use key_wallet::managed_account::ManagedCoreFundsAccount; use key_wallet::transaction_checking::{ TransactionContext, TransactionType, WalletTransactionChecker, @@ -46,6 +49,14 @@ use crate::changeset::{RecordedHistory, StoredTransaction}; /// A stored transaction that a settled one beats to an input is not replayed, /// nor are its unconfirmed descendants, and their persisted outputs are dropped. /// +/// A record whose amounts would overflow the checker's signed arithmetic +/// ([`unsafe_replay_amounts`]) is left out rather than failing the load. No +/// valid transaction carries such amounts: an unconfirmed one is unverified +/// peer data, a settled one is store corruption, and this path has no recovery +/// mode to fall back on. The restored coins it spends stay reserved, and an +/// unconfirmed one stays on record, unchecked, so that a settled conflict +/// still sweeps it and releases them. +/// /// Returns how many records were replayed. pub async fn replay_recorded_history( wallet_info: &mut ManagedWalletInfo, @@ -116,9 +127,20 @@ pub async fn replay_recorded_history( context => context.clone(), }; } + let operands: Vec> = replay + .iter() + .map(|stored| { + ( + stored.txid, + &stored.transaction, + stored.owned_inputs.as_slice(), + ) + }) + .collect(); + let unsafe_txids = unsafe_replay_amounts(wallet_info, &operands); // Losers leave the replay and the projection; `swept` also keeps their // held raw records from coming back as fallbacks. - let (mut swept, planned_sweeps) = plan_replay_sweeps(wallet_info, &replay); + let (mut swept, planned_sweeps) = plan_replay_sweeps(wallet_info, &replay, &unsafe_txids); tracing::debug!( wallet_id = %hex::encode(wallet_info.wallet_id), planned_sweeps, @@ -126,6 +148,38 @@ pub async fn replay_recorded_history( "load: settled stored transaction conflicts before the history replay" ); replay.retain(|stored| !swept.contains(&stored.txid)); + let (replay, set_aside): (Vec<_>, Vec<_>) = replay + .into_iter() + .partition(|stored| !unsafe_txids.contains(&stored.txid)); + if !set_aside.is_empty() { + let settled = set_aside + .iter() + .filter(|stored| stored.context.block_info().is_some()) + .count(); + // Counts only: the txids of unconfirmed records are peer-supplied. + tracing::warn!( + wallet_id = %hex::encode(wallet_info.wallet_id), + skipped = set_aside.len(), + "stored transaction records with unsafe amounts left out of the shared load replay; the coins they spend stay reserved" + ); + if settled > 0 { + tracing::error!( + wallet_id = %hex::encode(wallet_info.wallet_id), + settled, + "stored confirmed transaction records carry amounts no valid transaction can; the wallet store is corrupt" + ); + } + } + // `Some` claimant: a conflict sweep or an abandon of the record releases the coin. + let mut reserved: Vec<(OutPoint, Option)> = Vec::new(); + for stored in &set_aside { + for input in &stored.transaction.input { + if placed.contains_key(&input.previous_output) { + reserved.push((input.previous_output, Some(stored.txid))); + } + } + } + let reserved_coins: HashSet = reserved.iter().map(|(coin, _)| *coin).collect(); for account in wallet_info.accounts.all_funding_accounts_mut() { account .utxos @@ -174,6 +228,7 @@ pub async fn replay_recorded_history( account.utxos.retain(|outpoint, _| { placed.contains_key(outpoint) && !spent.contains(outpoint) + && !reserved_coins.contains(outpoint) && !misplaced.contains(&(*outpoint, owner)) }); } @@ -192,6 +247,33 @@ pub async fn replay_recorded_history( } } } + // An unconfirmed record left out stays on record, as stored, in every + // account holding a coin it spends or pays: a settled conflict, stored or + // still to come, must find it there to sweep it and release its coins. + for stored in &set_aside { + if stored.context.block_info().is_some() { + continue; + } + let outputs = (0..stored.transaction.output.len() as u32) + .map(|vout| OutPoint::new(stored.txid, vout)); + let owners: HashSet = stored + .transaction + .input + .iter() + .map(|input| input.previous_output) + .chain(outputs) + .filter_map(|outpoint| placed.get(&outpoint).copied()) + .collect(); + for account in wallet_info.accounts.all_funding_accounts_mut() { + let owner = funds_account_type(account); + if owners.contains(&owner) { + account + .transactions_mut() + .entry(stored.txid) + .or_insert_with(|| raw_record(stored, owner)); + } + } + } // A settled spend's first sweep cannot reach competitors replayed later, // nor the raw records restored above. The upstream sweep preserves // ChainLock/InstantSend precedence; it runs only for a spend something @@ -208,10 +290,85 @@ pub async fn replay_recorded_history( if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { wallet_info.apply_chain_lock(chain_lock); } + // A claim guards a coin only once it has left the UTXO set. + wallet_info.restore_spent_outpoints(&reserved); wallet_info.update_balance(); replay.len() } +/// One stored transaction as [`unsafe_replay_amounts`] reads it: its txid, its +/// body and the wallet-owned inputs its store recorded. +pub type ReplayOperands<'a> = (Txid, &'a Transaction, &'a [InputDetail]); + +/// The stored transactions whose amounts the wallet checker cannot take. +/// +/// The checker casts each unsigned operand to `i64` before a signed +/// subtraction, so an amount above `i64::MAX`, alone or summed, overflows it: +/// a panic where overflow is checked, a wrapped net amount where it is not. A +/// fitting net does not make an overflowing operand safe. Every source of an +/// operand is covered: the coins restored onto `wallet_info`, the outputs of +/// the other `records` (asset-lock credit outputs included) and the recorded +/// owned inputs. +pub fn unsafe_replay_amounts( + wallet_info: &ManagedWalletInfo, + records: &[ReplayOperands<'_>], +) -> HashSet { + /// Whether every value and their running sum fit `i64`. + fn fits(values: impl IntoIterator) -> bool { + let mut sum = 0_u64; + values.into_iter().all(|value| { + sum = sum.saturating_add(value); + sum <= i64::MAX as u64 + }) + } + + let mut amounts: HashMap = HashMap::new(); + let mut remember = |outpoint: OutPoint, value: u64| { + let known = amounts.entry(outpoint).or_insert(value); + *known = (*known).max(value); + }; + for account in wallet_info.accounts.all_funding_accounts() { + for (outpoint, coin) in &account.utxos { + remember(*outpoint, coin.txout.value); + } + } + let mut unsafe_txids = HashSet::new(); + for (txid, transaction, owned_inputs) in records { + let credit_outputs = match &transaction.special_transaction_payload { + Some(TransactionPayload::AssetLockPayloadType(payload)) => { + payload.credit_outputs.as_slice() + } + _ => &[], + }; + let outputs = transaction.output.iter().chain(credit_outputs); + if !fits(owned_inputs.iter().map(|detail| detail.value)) + || !fits(outputs.map(|output| output.value)) + { + // A record left out credits nothing, so its outputs are no child's input amount. + unsafe_txids.insert(*txid); + continue; + } + for (vout, output) in transaction.output.iter().enumerate() { + remember(OutPoint::new(*txid, vout as u32), output.value); + } + for detail in *owned_inputs { + if let Some(input) = transaction.input.get(detail.index as usize) { + remember(input.previous_output, detail.value); + } + } + } + for (txid, transaction, _) in records { + let inputs = transaction + .input + .iter() + .filter_map(|input| amounts.get(&input.previous_output).copied()); + if !fits(inputs) { + unsafe_txids.insert(*txid); + } + } + unsafe_txids +} + /// Resolve the full history before a defeated lock can sweep another spender. /// /// Returns every transaction a settled stored one beats to an input, with its @@ -219,9 +376,12 @@ pub async fn replay_recorded_history( /// out: replayed after its parent was swept, a descendant that pays the wallet /// would be recorded again, and no later sweep reaches it without the parent. /// Also returns how many conflict sweeps the plan ran. +/// +/// `unsafe_txids` are planned on their inputs alone, like every other record. fn plan_replay_sweeps( wallet_info: &ManagedWalletInfo, records: &[StoredTransaction], + unsafe_txids: &HashSet, ) -> (HashSet, usize) { let mut conflicts = wallet_info.clone(); // A scratch account lets the upstream sweep see descendants across account boundaries. @@ -250,6 +410,8 @@ fn plan_replay_sweeps( let mut winners: Vec<&StoredTransaction> = records .iter() .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + // A record with unsafe amounts can lose a conflict, never win one. + .filter(|stored| !unsafe_txids.contains(&stored.txid)) .filter(|stored| is_contested(stored, &competitors)) .collect(); winners.sort_by_key(|stored| { @@ -1176,6 +1338,317 @@ mod tests { assert_eq!(restored.balance.total(), 0); } + /// Stored amounts no valid transaction can carry must not reach the + /// checker's signed arithmetic: the record is left out of the replay, and + /// a restored coin it spends stays reserved instead of coming back + /// spendable. Only an unconfirmed record holding such a coin stays on + /// record, for a later conflict to find. + #[tokio::test] + async fn should_keep_unsafe_amounts_from_the_checker_and_reserve_the_coins_they_spend() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::managed_account::transaction_record::InputDetail; + use key_wallet::transaction_checking::BlockInfo; + + const HALF: u64 = 1 << 62; + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + for (case, outputs, staged, settled) in [ + ("single output", vec![2 * HALF], None, false), + ("summed outputs", vec![HALF, HALF], None, false), + ("staged input", vec![], Some(2 * HALF), false), + ("settled record", vec![2 * HALF], None, true), + ] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let pay = |previous_output: OutPoint, values: &[u64]| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: values + .iter() + .map(|value| TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + }; + let funding = pay(OutPoint::new(Txid::from_byte_array([61; 32]), 0), &[1]); + let coin = OutPoint::new(funding.txid(), 0); + let funded = info + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await + .new_records; + // A staged input stands for a funding the store holds no record of. + let spent = if staged.is_some() { + OutPoint::new(Txid::from_byte_array([62; 32]), 0) + } else { + coin + }; + let unsafe_spend = pay(spent, &outputs); + let mut stored: Vec = + funded.into_iter().map(StoredTransaction::from).collect(); + stored.push(StoredTransaction { + txid: unsafe_spend.txid(), + transaction: unsafe_spend.clone(), + context: if settled { + block(101) + } else { + TransactionContext::Mempool + }, + stored_net_amount: None, + stored_direction: None, + owned_inputs: staged + .map(|value| InputDetail { + index: 0, + value, + address: address.clone(), + }) + .into_iter() + .collect(), + }); + // An unconfirmed spend never flips the coin's persisted spent flag. + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + coin, + Utxo::new(coin, funding.output[0].clone(), address.clone(), 100, false), + ); + + let replayed = replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + transactions: stored, + instant_locks: BTreeMap::new(), + }, + ) + .await; + restored + .check_core_transaction(&funding, block(100), &mut wallet, true, true) + .await; + + assert_eq!(replayed, 1, "{case}: only the funding is replayed"); + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert_eq!( + account.transactions().contains_key(&unsafe_spend.txid()), + !settled && staged.is_none(), + "{case}: only an unconfirmed record reserving a restored coin stays on record" + ); + assert_eq!( + account.utxos.contains_key(&coin), + staged.is_some(), + "{case}: a coin the unsafe record spends stays reserved" + ); + assert_eq!( + restored.balance.total(), + u64::from(staged.is_some()), + "{case}" + ); + } + } + + /// An unconfirmed record with unsafe amounts that spends coins A and B, + /// the confirmed winner that beats it on A alone, and B's funding, which + /// the store holds no record of. The projection still holds both coins. + async fn unsafe_loser_fixture() -> ( + Wallet, + ManagedWalletInfo, + Vec, + StoredTransaction, + dashcore::Transaction, + [OutPoint; 2], + ) { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let spend = |spends: &[OutPoint], value: u64, script_pubkey| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey, + }], + special_transaction_payload: None, + }; + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let fund = |byte, value| { + let unknown = OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + spend(&[unknown], value, address.script_pubkey()) + }; + let (funding_a, funding_b) = (fund(71, 10_000), fund(72, 20_000)); + let coins = [funding_a.txid(), funding_b.txid()].map(|txid| OutPoint::new(txid, 0)); + let stored = |transaction: Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let mut history: Vec = info + .check_core_transaction(&funding_a, block(100), &mut wallet, true, true) + .await + .new_records + .into_iter() + .map(StoredTransaction::from) + .collect(); + history.push(stored( + spend(&coins, 1 << 63, address.script_pubkey()), + TransactionContext::Mempool, + )); + let winner = stored( + spend(&coins[..1], 9_000, dashcore::ScriptBuf::new()), + block(101), + ); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for (coin, funding) in coins.iter().zip([&funding_a, &funding_b]) { + account.utxos.insert( + *coin, + Utxo::new(*coin, funding.output[0].clone(), address.clone(), 99, false), + ); + } + (wallet, restored, history, winner, funding_b, coins) + } + + /// A stored confirmed winner settles an unsafe unconfirmed loser like any + /// other: the loser is gone and the coin only the loser spent is free. + #[tokio::test] + async fn should_release_other_input_of_unsafe_loser_beaten_by_stored_winner() { + let (mut wallet, mut restored, mut history, winner, _, [coin_a, coin_b]) = + unsafe_loser_fixture().await; + let loser = history[1].txid; + history.push(winner); + + replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + transactions: history, + instant_locks: BTreeMap::new(), + }, + ) + .await; + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser)); + assert!( + !account.utxos.contains_key(&coin_a), + "the winner spent this coin" + ); + assert!( + account.utxos.contains_key(&coin_b), + "the coin only the swept loser spent must be spendable again" + ); + assert_eq!(restored.balance.total(), 20_000); + } + + /// With no winner stored, the unsafe unconfirmed record keeps both coins + /// reserved and stays on record, so a winner delivered after the load + /// sweeps it and releases the coin it does not spend itself. + #[tokio::test] + async fn should_release_other_input_of_unsafe_loser_beaten_after_load() { + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut restored, history, winner, funding_b, [coin_a, coin_b]) = + unsafe_loser_fixture().await; + let loser = history[1].txid; + let funded_b = TransactionContext::InBlock(BlockInfo::new( + 99, + BlockHash::from_byte_array([99; 32]), + 99, + )); + + replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + transactions: history, + instant_locks: BTreeMap::new(), + }, + ) + .await; + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(account.transactions().contains_key(&loser)); + assert!(!account.utxos.contains_key(&coin_a) && !account.utxos.contains_key(&coin_b)); + assert_eq!(restored.balance.total(), 0); + // Control: while the record stands, its reservation holds. + let mut reserved = restored.clone(); + reserved + .check_core_transaction(&funding_b, funded_b.clone(), &mut wallet, true, true) + .await; + assert!( + !reserved.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coin_b), + "a coin the unsafe record spends must stay reserved" + ); + + let settled = restored + .check_core_transaction( + &winner.transaction, + winner.context.clone(), + &mut wallet, + true, + true, + ) + .await; + assert_eq!(settled.swept_transactions, vec![loser]); + assert_eq!(settled.released_outpoints, vec![coin_b]); + restored + .check_core_transaction(&funding_b, funded_b, &mut wallet, true, true) + .await; + + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser)); + assert!( + !account.utxos.contains_key(&coin_a), + "the winner spent this coin" + ); + assert!( + account.utxos.contains_key(&coin_b), + "the released coin must be credited when its funding is seen" + ); + assert_eq!(restored.balance.total(), 20_000); + } + /// Planning must not run one whole-history sweep per settled record that /// merely shares an input with an unconfirmed one: a sweep is worth /// running only while a competitor it could remove is still there. @@ -1229,7 +1702,7 @@ mod tests { let greedy = stored((0..SPENDERS).collect(), 0, TransactionContext::Mempool); let mut records = confirmed.clone(); records.push(greedy.clone()); - let (removed, sweeps) = plan_replay_sweeps(&info, &records); + let (removed, sweeps) = plan_replay_sweeps(&info, &records, &HashSet::new()); assert_eq!(removed, HashSet::from([greedy.txid])); assert_eq!(sweeps, 1, "the competitor is gone after the first sweep"); @@ -1249,7 +1722,7 @@ mod tests { .collect(); let records: Vec = [&confirmed[..], &competitors[..], &bystanders[..]].concat(); - let (removed, sweeps) = plan_replay_sweeps(&info, &records); + let (removed, sweeps) = plan_replay_sweeps(&info, &records, &HashSet::new()); assert_eq!( removed, competitors.iter().map(|stored| stored.txid).collect() diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 9f960b561b8..694786abeab 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1407,6 +1407,68 @@ mod idempotent_load_tests { } } + /// A stored record whose amounts overflow the checker's signed arithmetic + /// must not stop the wallet from loading: it is left out, and the coin it + /// spends stays reserved. + #[tokio::test] + async fn should_load_wallet_whose_stored_history_holds_unsafe_amounts() { + 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 coin = + ctx.managed_wallet.accounts.standard_bip44_accounts[&0].utxos[&funded_outpoint].clone(); + let mut unsafe_spend = spend_to(funded_outpoint, 1 << 63); + unsafe_spend.output[0].script_pubkey = coin.txout.script_pubkey.clone(); + + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection + .accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .utxos + .insert(funded_outpoint, coin); + projection.update_balance(); + let stored = |transaction: dashcore::Transaction| StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + transactions: vec![stored(unsafe_spend), stored(funding)], + instant_locks: BTreeMap::new(), + }, + asset_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(); + assert_eq!( + balance.confirmed() + balance.unconfirmed(), + 0, + "the coin the unsafe record spends must stay reserved" + ); + } + fn make_history_manager( persister: RecordedHistoryPersister, ) -> Arc> { From d328a0303c0db521adfb2ba9be7c2c6d5e785cee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:51:47 +0000 Subject: [PATCH 11/84] fix(platform-wallet): count a multi-account record as one spender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `unconfirmed_spends` counted account records, and `is_contested` took one occurrence off for the candidate itself. A transaction the checker records in two accounts, such as a transfer between them, therefore looked contested by its own second record. Replayed with an InstantSend context and no competing spender, it triggered a conflict sweep over every live record that could remove nothing. The index now lists the distinct unconfirmed transactions spending each outpoint, and a record is contested only when a transaction other than itself is among them. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 114 ++++++++++++++++-- 1 file changed, 103 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index f5ef1bfd857..e73534ef7d3 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -436,8 +436,8 @@ fn plan_replay_sweeps( .into_iter() .flat_map(|loser| &loser.transaction.input) { - if let Some(count) = competitors.get_mut(&input.previous_output) { - *count = count.saturating_sub(1); + if let Some(spenders) = competitors.get_mut(&input.previous_output) { + spenders.retain(|spender| *spender != txid); } } removed.insert(txid); @@ -461,9 +461,11 @@ fn raw_record(stored: &StoredTransaction, owner: AccountType) -> TransactionReco ) } -/// How many unconfirmed records of `wallet_info` spend each outpoint. -fn unconfirmed_spends(wallet_info: &ManagedWalletInfo) -> HashMap { - let mut spends = HashMap::new(); +/// The unconfirmed transactions of `wallet_info` spending each outpoint. One +/// recorded in several accounts is still one spender. +fn unconfirmed_spends(wallet_info: &ManagedWalletInfo) -> HashMap> { + let mut seen = HashSet::new(); + let mut spends: HashMap> = HashMap::new(); for record in wallet_info .accounts .all_accounts() @@ -471,21 +473,26 @@ fn unconfirmed_spends(wallet_info: &ManagedWalletInfo) -> HashMap) -> bool { - let own = usize::from(stored.context.block_info().is_none()); +/// Whether an unconfirmed transaction other than `stored` itself still spends +/// one of its inputs: only then can a conflict sweep for it remove anything. +fn is_contested(stored: &StoredTransaction, competitors: &HashMap>) -> bool { stored.transaction.input.iter().any(|input| { competitors .get(&input.previous_output) - .is_some_and(|count| *count > own) + .is_some_and(|spenders| spenders.iter().any(|txid| *txid != stored.txid)) }) } @@ -1730,6 +1737,91 @@ mod tests { assert_eq!(sweeps, SPENDERS as usize); } + /// A wallet with BIP44 accounts 0 and 1 and a receive address of each. + fn two_account_wallet() -> (Wallet, ManagedWalletInfo, [dashcore::Address; 2]) { + let wallet = Wallet::new_random( + Network::Testnet, + WalletAccountCreationOptions::BIP44AccountsOnly([0, 1].into_iter().collect()), + ) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let addresses = [0, 1].map(|index| { + let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; + info.accounts + .standard_bip44_accounts + .get_mut(&index) + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap() + }); + (wallet, info, addresses) + } + + /// A transaction recorded in two accounts is one spender of its inputs, + /// not a competitor of itself: with no other spender it is uncontested. + #[tokio::test] + async fn should_not_count_multi_account_record_as_its_own_competitor() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, mut info, addresses) = two_account_wallet(); + let pay = |previous_output: OutPoint, value: u64, to: usize| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: addresses[to].script_pubkey(), + }], + special_transaction_payload: None, + }; + let funding = pay( + OutPoint::new(Txid::from_byte_array([83; 32]), 0), + 100_000, + 0, + ); + let coin = OutPoint::new(funding.txid(), 0); + let transfer = pay(coin, 99_000, 1); + let locked = TransactionContext::InstantSend(InstantLock { + txid: transfer.txid(), + inputs: vec![coin], + ..Default::default() + }); + let block = BlockInfo::new(100, BlockHash::from_byte_array([84; 32]), 100); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + info.check_core_transaction(&transfer, locked.clone(), &mut wallet, true, true) + .await; + for index in [0, 1] { + assert!( + info.accounts.standard_bip44_accounts[&index] + .transactions() + .contains_key(&transfer.txid()), + "the transfer must be on record in account {index}" + ); + } + let stored = StoredTransaction { + txid: transfer.txid(), + transaction: transfer, + context: locked, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + + assert!(!is_contested(&stored, &unconfirmed_spends(&info))); + } + /// An empty history leaves the restored projection untouched. #[tokio::test] async fn should_leave_projection_untouched_without_history() { From 28254bcfc74eaa451b9626cf212120ca0bdb338c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:53:54 +0000 Subject: [PATCH 12/84] fix(platform-wallet): spend a coin parked in a fallback account once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load projection can park a coin in a fallback account while its replayed funding credits it to the account that derives its address. The duplicate was dropped only after the whole replay, so a stored spend of the coin matched it in both accounts: - the fallback account recorded a spend of a coin it never owned, at any amount; - the checker sums the matches of all accounts before its signed subtraction, so two copies of a `1 << 62` coin overflowed it although every amount passed `unsafe_replay_amounts`, which counts an outpoint once. The fallback copy is now dropped right after the checker call that credits the coin elsewhere, so the amounts the checker sees are the ones the amount check bounded. The cleanup after the replay stays for a copy credited outside its own record's check. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 120 +++++++++++++++++- 1 file changed, 117 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index e73534ef7d3..c677e15915b 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -198,6 +198,7 @@ pub async fn replay_recorded_history( ) .await; swept.extend(result.swept_transactions); + drop_fallback_copies(wallet_info, stored, &placed); } let spent: HashSet<_> = wallet_info @@ -205,9 +206,9 @@ pub async fn replay_recorded_history( .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. + // Replay credits an output to the account whose pool derives it. A + // fallback copy `drop_fallback_copies` did not see credited by then is + // dropped here, so each outpoint lives in exactly one account. let misplaced: HashSet<(OutPoint, AccountType)> = wallet_info .accounts .all_funding_accounts() @@ -496,6 +497,36 @@ fn is_contested(stored: &StoredTransaction, competitors: &HashMap, +) { + for vout in 0..stored.transaction.output.len() as u32 { + let outpoint = OutPoint::new(stored.txid, vout); + let Some(parked) = placed.get(&outpoint) else { + continue; + }; + let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); + let credited_elsewhere = accounts.iter().any(|account| { + funds_account_type(account) != *parked && account.utxos.contains_key(&outpoint) + }); + if credited_elsewhere { + for account in &mut accounts { + if funds_account_type(account) == *parked { + account.utxos.remove(&outpoint); + } + } + } + } +} + /// Park every owned input a record spends whose funding no replayed record credits. /// /// Persistence excludes spent outputs from the load projection, so without @@ -1757,6 +1788,89 @@ mod tests { (wallet, info, addresses) } + /// The projection can park a coin in a fallback account while its funding, + /// replayed, credits it to the account that derives it. A stored spend of + /// that coin must still count it once: summed over both copies, the + /// checker's wallet total doubles and a large coin overflows it. + #[tokio::test] + async fn should_spend_coin_parked_in_fallback_account_once() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + for value in [1_u64 << 62, 100_000] { + let (mut wallet, restored, addresses) = two_account_wallet(); + let pay = |previous_output: OutPoint, value: u64| Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: vec![TxOut { + value, + script_pubkey: addresses[1].script_pubkey(), + }], + special_transaction_payload: None, + }; + let funding = pay(OutPoint::new(Txid::from_byte_array([81; 32]), 0), value); + let coin = OutPoint::new(funding.txid(), 0); + let child = pay(coin, 1); + let stored = |transaction: Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let block = BlockInfo::new(100, BlockHash::from_byte_array([82; 32]), 100); + let history = RecordedHistory { + transactions: vec![ + stored(funding.clone(), TransactionContext::InBlock(block)), + stored(child.clone(), TransactionContext::Mempool), + ], + instant_locks: BTreeMap::new(), + }; + let mut restored = restored; + // Parked in account 0, though account 1 derives its address. + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + coin, + Utxo::new( + coin, + funding.output[0].clone(), + addresses[1].clone(), + 100, + false, + ), + ); + + replay_recorded_history(&mut restored, &mut wallet, history).await; + + let [fallback, owner] = + [0, 1].map(|index| &restored.accounts.standard_bip44_accounts[&index]); + assert!( + !fallback.utxos.contains_key(&coin) && !owner.utxos.contains_key(&coin), + "{value}: the stored spend took the coin" + ); + assert!( + !fallback.transactions().contains_key(&child.txid()), + "{value}: the fallback account never owned the coin the spend took" + ); + assert_eq!( + owner.transactions()[&child.txid()].net_amount, + 1 - value as i64, + "{value}: the spend counts the coin once" + ); + } + } + /// A transaction recorded in two accounts is one spender of its inputs, /// not a competitor of itself: with no other spender it is uncontested. #[tokio::test] From 2c7d590b269a49d6cc39e32c4c45314c48ecef35 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:58:06 +0000 Subject: [PATCH 13/84] fix(platform-wallet): hand each planned sweep only the records it can remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the previous change a conflict sweep ran once per contested settled record, but each one still scanned every unconfirmed stored record, rebuilt the descendant index over them and recomputed the scratch wallet's balance. With N confirmed spenders, each contested by its own unconfirmed record, next to N unrelated unconfirmed records, planning examined 60,100 records for N = 200 and grew with the square of N. Such rows can come from unverified peer transactions and stay in the store, so the cost repeated at every load. Planning now indexes the unconfirmed records once, by spent outpoint and by parent transaction. A settled record's sweep runs in a scratch account holding only the unconfirmed records still spending one of its inputs and their unconfirmed descendants; the scratch wallet keeps no other records and no coins. The upstream sweep still decides every conflict, so finality precedence is not reimplemented. A test compares the result with whole-history sweeps on generated histories, and the planner reports how many records its sweeps examined: 200 for the case above. Known limitation: a competitor holding an InstantSend lock survives a plain-block winner, so it and its descendants are examined again for each such winner. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 372 +++++++++++++++--- 1 file changed, 315 insertions(+), 57 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index c677e15915b..409a6b7651e 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -10,7 +10,7 @@ //! A persister that must repair its own rows while it replays does so inside //! its `load` and hands back an empty history; the SQLite one does. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::transaction::TransactionPayload; @@ -140,13 +140,15 @@ pub async fn replay_recorded_history( let unsafe_txids = unsafe_replay_amounts(wallet_info, &operands); // Losers leave the replay and the projection; `swept` also keeps their // held raw records from coming back as fallbacks. - let (mut swept, planned_sweeps) = plan_replay_sweeps(wallet_info, &replay, &unsafe_txids); + let plan = plan_replay_sweeps(wallet_info, &replay, &unsafe_txids); tracing::debug!( wallet_id = %hex::encode(wallet_info.wallet_id), - planned_sweeps, - removed = swept.len(), + sweeps = plan.sweeps, + examined = plan.examined, + removed = plan.removed.len(), "load: settled stored transaction conflicts before the history replay" ); + let mut swept = plan.removed; replay.retain(|stored| !swept.contains(&stored.txid)); let (replay, set_aside): (Vec<_>, Vec<_>) = replay .into_iter() @@ -370,50 +372,74 @@ pub fn unsafe_replay_amounts( unsafe_txids } +/// What [`plan_replay_sweeps`] decided, and what deciding it cost. +#[derive(Debug, Default)] +struct SweepPlan { + /// Every transaction a settled stored one beats to an input, with its + /// unconfirmed descendants. + removed: HashSet, + /// Conflict sweeps run. + sweeps: usize, + /// Records those sweeps had to look through, summed over the sweeps. + examined: usize, +} + /// Resolve the full history before a defeated lock can sweep another spender. /// -/// Returns every transaction a settled stored one beats to an input, with its -/// unconfirmed descendants. Planning ahead of the replay is what keeps them -/// out: replayed after its parent was swept, a descendant that pays the wallet -/// would be recorded again, and no later sweep reaches it without the parent. -/// Also returns how many conflict sweeps the plan ran. +/// Planning ahead of the replay is what keeps the losers out: replayed after +/// its parent was swept, a descendant that pays the wallet would be recorded +/// again, and no later sweep reaches it without the parent. +/// +/// The upstream sweep decides every conflict, so finality precedence stays +/// its own. It scans the account it runs in whole, so each settled record's +/// sweep is handed only what it could remove: the unconfirmed records still +/// spending one of its inputs, and their unconfirmed descendants. A locked +/// competitor a plain block cannot remove is handed again to each such block. /// /// `unsafe_txids` are planned on their inputs alone, like every other record. fn plan_replay_sweeps( wallet_info: &ManagedWalletInfo, records: &[StoredTransaction], unsafe_txids: &HashSet, -) -> (HashSet, usize) { - let mut conflicts = wallet_info.clone(); - // A scratch account lets the upstream sweep see descendants across account boundaries. - let Some(account) = conflicts - .accounts - .all_funding_accounts_mut() - .into_iter() - .next() - else { - return (HashSet::new(), 0); +) -> SweepPlan { + // The scratch wallet lends the sweep its rules and nothing to scan: its + // unconfirmed records move to the pool, the rest and its coins are dropped. + let mut scratch = wallet_info.clone(); + let mut pool: HashMap = HashMap::new(); + for mut account in scratch.accounts.all_accounts_mut() { + for (txid, record) in std::mem::take(account.transactions_mut()) { + if !record.is_confirmed() { + pool.entry(txid).or_insert(record); + } + } + } + for account in scratch.accounts.all_funding_accounts_mut() { + account.utxos.clear(); + } + // One account for every sweep lets it follow descendants across accounts. + let Some(owner) = sweep_account(&mut scratch).map(|account| funds_account_type(account)) else { + return SweepPlan::default(); }; - let owner = funds_account_type(account); - // Losers and their descendants are always unconfirmed, and every sweep - // scans the scratch account whole, so settled records stay out of it. - let unconfirmed: HashMap = records - .iter() - .filter(|stored| stored.context.block_info().is_none()) - .map(|stored| (stored.txid, stored)) - .collect(); - account.transactions_mut().extend( - unconfirmed - .values() - .map(|stored| (stored.txid, raw_record(stored, owner))), - ); - let mut competitors = unconfirmed_spends(&conflicts); + for stored in records { + if stored.context.block_info().is_none() { + pool.insert(stored.txid, raw_record(stored, owner)); + } + } + // Who spends each outpoint, and who spends an output of each transaction. + let mut spenders: HashMap> = HashMap::new(); + let mut children: HashMap> = HashMap::new(); + for (txid, record) in &pool { + for input in &record.transaction.input { + let spent = input.previous_output; + spenders.entry(spent).or_default().push(*txid); + children.entry(spent.txid).or_default().push(*txid); + } + } let mut winners: Vec<&StoredTransaction> = records .iter() .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) // A record with unsafe amounts can lose a conflict, never win one. .filter(|stored| !unsafe_txids.contains(&stored.txid)) - .filter(|stored| is_contested(stored, &competitors)) .collect(); winners.sort_by_key(|stored| { let priority = match stored.context { @@ -423,28 +449,54 @@ fn plan_replay_sweeps( }; (priority, stored.txid) }); - let (mut removed, mut sweeps) = (HashSet::new(), 0); + let mut plan = SweepPlan::default(); for stored in winners { - // An earlier sweep may have taken this winner or its last competitor. - if removed.contains(&stored.txid) || !is_contested(stored, &competitors) { + if plan.removed.contains(&stored.txid) { continue; } - sweeps += 1; - let sweep = conflicts.sweep_conflicts(&stored.transaction, &stored.context); - for txid in sweep.txids { - for input in unconfirmed - .get(&txid) - .into_iter() - .flat_map(|loser| &loser.transaction.input) - { - if let Some(spenders) = competitors.get_mut(&input.previous_output) { - spenders.retain(|spender| *spender != txid); - } + // A record leaves the pool when handed over, so one already removed + // or already handed is skipped. + let mut handed = BTreeMap::new(); + let mut queue: Vec = stored + .transaction + .input + .iter() + .filter_map(|input| spenders.get(&input.previous_output)) + .flatten() + .copied() + .collect(); + while let Some(txid) = queue.pop() { + if txid == stored.txid { + continue; + } + if let Some(record) = pool.remove(&txid) { + handed.insert(txid, record); + queue.extend(children.get(&txid).into_iter().flatten()); } - removed.insert(txid); + } + let Some(account) = sweep_account(&mut scratch).filter(|_| !handed.is_empty()) else { + continue; + }; + plan.sweeps += 1; + plan.examined += handed.len(); + *account.transactions_mut() = handed; + let sweep = scratch.sweep_conflicts(&stored.transaction, &stored.context); + plan.removed.extend(sweep.txids); + // Whatever the sweep left standing can still lose to a later winner. + if let Some(account) = sweep_account(&mut scratch) { + pool.extend(std::mem::take(account.transactions_mut())); } } - (removed, sweeps) + plan +} + +/// The funds account of a scratch wallet that planned sweeps run in. +fn sweep_account(scratch: &mut ManagedWalletInfo) -> Option<&mut ManagedCoreFundsAccount> { + scratch + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() } /// A history record holding only what the conflict sweep reads: the context @@ -1689,7 +1741,8 @@ mod tests { /// Planning must not run one whole-history sweep per settled record that /// merely shares an input with an unconfirmed one: a sweep is worth - /// running only while a competitor it could remove is still there. + /// running only while a competitor it could remove is still there, and it + /// looks only at the records it could remove. #[test] fn should_plan_sweeps_only_while_a_competitor_remains() { use dashcore::hashes::Hash; @@ -1740,9 +1793,12 @@ mod tests { let greedy = stored((0..SPENDERS).collect(), 0, TransactionContext::Mempool); let mut records = confirmed.clone(); records.push(greedy.clone()); - let (removed, sweeps) = plan_replay_sweeps(&info, &records, &HashSet::new()); - assert_eq!(removed, HashSet::from([greedy.txid])); - assert_eq!(sweeps, 1, "the competitor is gone after the first sweep"); + let plan = plan_replay_sweeps(&info, &records, &HashSet::new()); + assert_eq!(plan.removed, HashSet::from([greedy.txid])); + assert_eq!( + plan.sweeps, 1, + "the competitor is gone after the first sweep" + ); // Each confirmed spender has a competitor of its own, next to // unconfirmed records nothing contests. @@ -1760,12 +1816,214 @@ mod tests { .collect(); let records: Vec = [&confirmed[..], &competitors[..], &bystanders[..]].concat(); - let (removed, sweeps) = plan_replay_sweeps(&info, &records, &HashSet::new()); + let plan = plan_replay_sweeps(&info, &records, &HashSet::new()); assert_eq!( - removed, + plan.removed, competitors.iter().map(|stored| stored.txid).collect() ); - assert_eq!(sweeps, SPENDERS as usize); + assert_eq!(plan.sweeps, SPENDERS as usize); + assert_eq!( + plan.examined, SPENDERS as usize, + "each sweep looks at its own competitor, never at the bystanders" + ); + } + + /// Planning by whole-history sweeps, kept as the reference the narrowed + /// planner must agree with: every unconfirmed stored record sits in the + /// scratch account, and every settled record sweeps all of it. + fn plan_by_whole_history_sweeps( + wallet_info: &ManagedWalletInfo, + records: &[StoredTransaction], + unsafe_txids: &HashSet, + ) -> HashSet { + let mut conflicts = wallet_info.clone(); + let account = conflicts + .accounts + .all_funding_accounts_mut() + .into_iter() + .next() + .unwrap(); + let owner = funds_account_type(account); + account.transactions_mut().extend( + records + .iter() + .filter(|stored| stored.context.block_info().is_none()) + .map(|stored| (stored.txid, raw_record(stored, owner))), + ); + let mut winners: Vec<&StoredTransaction> = records + .iter() + .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .filter(|stored| !unsafe_txids.contains(&stored.txid)) + .collect(); + winners.sort_by_key(|stored| { + let priority = match stored.context { + TransactionContext::InChainLockedBlock(_) => 0, + TransactionContext::InstantSend(_) => 1, + _ => 2, + }; + (priority, stored.txid) + }); + let mut removed = HashSet::new(); + for stored in winners { + if !removed.contains(&stored.txid) { + let sweep = conflicts.sweep_conflicts(&stored.transaction, &stored.context); + removed.extend(sweep.txids); + } + } + removed + } + + /// Handing each sweep only the records it could remove must not change + /// what planning decides. Compared against whole-history sweeps on a + /// fixed InstantSend shape and on generated histories mixing winners, + /// competitors, descendants, bystanders and locked competitors. + #[test] + fn should_plan_the_same_removals_as_whole_history_sweeps() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + + let (_wallet, info, address) = wallet_with_receive_address(); + let coin = |index: u8| OutPoint::new(Txid::from_byte_array([index; 32]), 0); + let block = |height| BlockInfo::new(height, BlockHash::from_byte_array([1; 32]), 0); + let stored = |spends: Vec, lock_time: u32, settlement: u8| { + let transaction = Transaction { + version: 1, + lock_time, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 1_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let txid = transaction.txid(); + let context = match settlement { + 0 => TransactionContext::Mempool, + 1 => TransactionContext::InstantSend(InstantLock { + txid, + inputs: spends, + ..Default::default() + }), + 2 => TransactionContext::InBlock(block(100 + lock_time)), + _ => TransactionContext::InChainLockedBlock(block(100 + lock_time)), + }; + StoredTransaction { + txid, + transaction, + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + } + }; + + // A plain block cannot overrule a lock; a chain lock can, and takes + // the locked competitor's unconfirmed child with it. + let competitor = stored(vec![coin(1)], 1, 1); + let child = stored(vec![OutPoint::new(competitor.txid, 0)], 2, 0); + for (settlement, expected) in [ + (2, HashSet::new()), + (3, HashSet::from([competitor.txid, child.txid])), + ] { + let records = [ + stored(vec![coin(1)], 0, settlement), + competitor.clone(), + child.clone(), + ]; + let plan = plan_replay_sweeps(&info, &records, &HashSet::new()); + assert_eq!(plan.removed, expected, "winner settlement {settlement}"); + assert_eq!( + plan_by_whole_history_sweeps(&info, &records, &HashSet::new()), + expected + ); + } + + // How many generated histories held each shape, so none goes untested. + let (mut swept, mut swept_descendant, mut locked_survivor, mut bystander) = (0, 0, 0, 0); + for seed in 0..500 { + let mut rng = StdRng::seed_from_u64(seed); + let mut records: Vec = Vec::new(); + for index in 0..rng.gen_range(2..=14) { + let spends: BTreeSet = (0..rng.gen_range(1..=2)) + .map(|_| { + if !records.is_empty() && rng.gen_bool(0.35) { + OutPoint::new(records[rng.gen_range(0..records.len())].txid, 0) + } else { + coin(rng.gen_range(0..6)) + } + }) + .collect(); + let settlement = match rng.gen_range(0..10) { + 0..=4 => 0, + 5..=6 => 1, + 7..=8 => 2, + _ => 3, + }; + records.push(stored(spends.into_iter().collect(), index, settlement)); + } + let unsafe_txids: HashSet = records + .iter() + .filter(|_| rng.gen_bool(0.1)) + .map(|stored| stored.txid) + .collect(); + + let expected = plan_by_whole_history_sweeps(&info, &records, &unsafe_txids); + let plan = plan_replay_sweeps(&info, &records, &unsafe_txids); + assert_eq!(plan.removed, expected, "seed {seed}"); + + let shares_input = |a: &StoredTransaction, b: &StoredTransaction| { + a.txid != b.txid + && a.transaction.input.iter().any(|input| { + b.transaction + .input + .iter() + .any(|other| other.previous_output == input.previous_output) + }) + }; + swept += usize::from(!expected.is_empty()); + swept_descendant += usize::from(records.iter().any(|stored| { + expected.contains(&stored.txid) + && stored + .transaction + .input + .iter() + .any(|input| expected.contains(&input.previous_output.txid)) + })); + locked_survivor += usize::from(records.iter().any(|locked| { + matches!(locked.context, TransactionContext::InstantSend(_)) + && !expected.contains(&locked.txid) + && records.iter().any(|winner| { + matches!(winner.context, TransactionContext::InBlock(_)) + && !unsafe_txids.contains(&winner.txid) + && shares_input(winner, locked) + }) + })); + bystander += usize::from(records.iter().any(|stored| { + stored.context.block_info().is_none() + && !expected.contains(&stored.txid) + && !records.iter().any(|other| shares_input(other, stored)) + })); + } + for (shape, histories) in [ + ("a swept competitor", swept), + ("a swept descendant", swept_descendant), + ( + "a locked competitor surviving a plain block", + locked_survivor, + ), + ("an uncontested bystander", bystander), + ] { + assert!(histories >= 25, "only {histories} histories held {shape}"); + } } /// A wallet with BIP44 accounts 0 and 1 and a receive address of each. From 24f56c6e044adc61410c184e178703eae75065ff Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:02:09 +0000 Subject: [PATCH 14/84] fix(platform-wallet): do not re-dispatch sends the history replay retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load re-dispatches every unconfirmed send the host still lists. The host selects them from its own rows, so the list can name a stored transaction the replay just removed as the loser of a settled conflict. The replay returned only a count, so the load sent it again, and a dispatch hands the transaction back to the wallet as a mempool sighting: the loser was on record again right after the load. A stored record left out for unsafe amounts took the same route to the unchecked wallet arithmetic the replay had kept it from. `replay_recorded_history` now returns a `HistoryReplay`: the replayed count and the retired transactions, which are the losers with their descendants, removed during planning, by the checker or by the sweep after the replay, and the records with unsafe amounts. `HistoryReplay::resendable` drops them from the send list before the load accounts or queues it. A surviving stored send and a send the history never held still go out. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 157 ++++++++++++++++-- .../rs-platform-wallet/src/manager/load.rs | 9 +- 2 files changed, 153 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 409a6b7651e..3ba618f9e5f 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -31,6 +31,31 @@ use key_wallet::Utxo; use crate::changeset::{RecordedHistory, StoredTransaction}; +/// What [`replay_recorded_history`] made of the stored history. +#[derive(Debug, Default)] +pub struct HistoryReplay { + /// How many records went through the wallet checker. + pub replayed: usize, + /// Stored transactions the replay removed or left out: the losers of a + /// settled conflict with their descendants, and the records with unsafe + /// amounts. + pub retired: HashSet, +} + +impl HistoryReplay { + /// The unconfirmed sends of `sends` still worth dispatching again. + /// + /// A dispatch hands the transaction back to the wallet as a mempool + /// sighting: a retired loser would be recorded again, and a record with + /// unsafe amounts would reach the checker the replay kept it from. + pub fn resendable(&self, sends: Vec) -> Vec { + sends + .into_iter() + .filter(|send| !self.retired.contains(&send.txid())) + .collect() + } +} + /// Restore spend reservations and finality guards without re-crediting outputs excluded by persistence. /// /// The persisted UTXO set already restored onto `wallet_info` stays @@ -57,18 +82,19 @@ use crate::changeset::{RecordedHistory, StoredTransaction}; /// unconfirmed one stays on record, unchecked, so that a settled conflict /// still sweeps it and releases them. /// -/// Returns how many records were replayed. +/// Returns how many records were replayed and which stored transactions were +/// retired instead. pub async fn replay_recorded_history( wallet_info: &mut ManagedWalletInfo, wallet: &mut Wallet, history: RecordedHistory, -) -> usize { +) -> HistoryReplay { let RecordedHistory { transactions, instant_locks, } = history; if transactions.is_empty() { - return 0; + return HistoryReplay::default(); } // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. @@ -286,7 +312,8 @@ pub async fn replay_recorded_history( if !matches!(stored.context, TransactionContext::Mempool) && is_contested(stored, &competitors) { - wallet_info.sweep_conflicts(&stored.transaction, &stored.context); + let sweep = wallet_info.sweep_conflicts(&stored.transaction, &stored.context); + swept.extend(sweep.txids); } } // Finalize replayed records before a sync checkpoint can prune their spend guards. @@ -296,7 +323,11 @@ pub async fn replay_recorded_history( // A claim guards a coin only once it has left the UTXO set. wallet_info.restore_spent_outpoints(&reserved); wallet_info.update_balance(); - replay.len() + swept.extend(set_aside.iter().map(|stored| stored.txid)); + HistoryReplay { + replayed: replay.len(), + retired: swept, + } } /// One stored transaction as [`unsafe_replay_amounts`] reads it: its txid, its @@ -856,7 +887,8 @@ mod tests { &mut wallet, history(records, BTreeMap::new()), ) - .await; + .await + .replayed; assert_eq!(count, 2); replayed .check_core_transaction(&funding, redelivery.clone(), &mut wallet, true, true) @@ -990,7 +1022,8 @@ mod tests { &mut wallet, history(records.clone(), BTreeMap::new()), ) - .await; + .await + .replayed; restored .check_core_transaction( &funding, @@ -1526,7 +1559,8 @@ mod tests { instant_locks: BTreeMap::new(), }, ) - .await; + .await + .replayed; restored .check_core_transaction(&funding, block(100), &mut wallet, true, true) .await; @@ -1828,6 +1862,108 @@ mod tests { ); } + /// The host lists its unconfirmed sends for re-dispatch from its own rows, + /// so the list can name transactions the replay retired. Those must not + /// go out again; every other send must, stored or not. + #[tokio::test] + async fn should_withhold_retired_sends_from_re_dispatch() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, _, address) = wallet_with_receive_address(); + let coin = |byte: u8| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let spend = |spends: &[OutPoint], value: u64| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + // The winner took coin 1 in a block; the loser wanted coins 1 and 2. + let winner = spend(&[coin(1)], 9_000); + let loser = spend(&[coin(1), coin(2)], 29_000); + let descendant = spend(&[OutPoint::new(loser.txid(), 0)], 28_000); + let survivor = spend(&[coin(3)], 39_000); + let unsafe_send = spend(&[coin(4)], 1 << 63); + let unrecorded = spend(&[coin(5)], 59_000); + let stored = |transaction: &Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let block = BlockInfo::new(101, BlockHash::from_byte_array([101; 32]), 101); + let history = RecordedHistory { + transactions: vec![ + stored(&winner, TransactionContext::InBlock(block)), + stored(&loser, TransactionContext::Mempool), + stored(&descendant, TransactionContext::Mempool), + stored(&survivor, TransactionContext::Mempool), + stored(&unsafe_send, TransactionContext::Mempool), + ], + instant_locks: BTreeMap::new(), + }; + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + // Persisted as unspent: every coin but the one the block spent. + for (byte, value) in [(2, 20_000), (3, 40_000), (4, 50_000), (5, 60_000)] { + let txout = TxOut { + value, + script_pubkey: address.script_pubkey(), + }; + account.utxos.insert( + coin(byte), + Utxo::new(coin(byte), txout, address.clone(), 99, false), + ); + } + + let outcome = replay_recorded_history(&mut restored, &mut wallet, history).await; + + for (retired, why) in [ + (&loser, "the loser of a settled conflict"), + (&descendant, "a descendant of that loser"), + ] { + assert!(outcome.retired.contains(&retired.txid()), "{why}"); + } + assert!( + outcome.retired.contains(&unsafe_send.txid()), + "a record with unsafe amounts must not reach the checker by re-dispatch either" + ); + let sends = vec![ + loser.clone(), + descendant.clone(), + survivor.clone(), + unsafe_send.clone(), + unrecorded.clone(), + ]; + let resendable: Vec = outcome + .resendable(sends) + .iter() + .map(Transaction::txid) + .collect(); + assert_eq!( + resendable, + vec![survivor.txid(), unrecorded.txid()], + "a surviving stored send and one the history never held still go out" + ); + } + /// Planning by whole-history sweeps, kept as the reference the narrowed /// planner must agree with: every unconfirmed stored record sits in the /// scratch account, and every settled record sweeps all of it. @@ -2199,8 +2335,9 @@ mod tests { 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; + let count = replay_recorded_history(&mut info, &mut wallet, RecordedHistory::default()) + .await + .replayed; assert_eq!(count, 0); assert_eq!(info.balance.total(), restored.balance.total()); assert_eq!( diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 694786abeab..3758370915c 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -150,7 +150,7 @@ impl PlatformWalletManager

{ mut wallet_info, identity_manager, unused_asset_locks, - unconfirmed_outgoing_txs, + mut unconfirmed_outgoing_txs, recorded_history, } = wallet_state; @@ -172,14 +172,17 @@ impl PlatformWalletManager

{ .collect(); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); - let replayed = + let outcome = replay_recorded_history(&mut wallet_info, &mut wallet, recorded_history).await; tracing::info!( wallet_id = %hex::encode(expected_wallet_id), offered, - replayed, + replayed = outcome.replayed, + retired = outcome.retired.len(), "load: replayed stored transaction history" ); + // A send the replay retired is not dispatched again below. + unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); } // Replay the sends the host still holds as unconfirmed, before From fc08729bb92aa249ea833ef91457697e5cf7949b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:38:21 +0000 Subject: [PATCH 15/84] fix(platform-wallet): claim each reserved coin for one unsafe record only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay reserves the restored coins a record left out for unsafe amounts spends by restoring a spend claim naming that record. Two such records spending the same coin produced two claims with different claimants, which the wallet merges into a claim that no sweep and no abandon releases. A settled transaction that swept both records left the shared coin guarded for the rest of the run, and a redelivery of its funding was not credited. Each reserved coin is now claimed once, for the record with the smallest txid among those still on record after the sweep that follows the replay, so sweeping or abandoning that record releases it. The wallet has no claim with several claimants, and a claim does not pass to another transaction (dashpay/rust-dashcore#1114). Sweeping only the named record therefore drops the guard while the other record still names the coin. That is accepted and pinned by a test: a record with unsafe amounts can never confirm. The SQLite store restores one claimant per coin by schema and behaves the same way. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 252 +++++++++++++++++- 1 file changed, 239 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 3ba618f9e5f..f69233af5ff 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -80,7 +80,8 @@ impl HistoryReplay { /// peer data, a settled one is store corruption, and this path has no recovery /// mode to fall back on. The restored coins it spends stay reserved, and an /// unconfirmed one stays on record, unchecked, so that a settled conflict -/// still sweeps it and releases them. +/// still sweeps it and releases them. A coin two such records spend is held +/// for one of them only and is released with that one. /// /// Returns how many records were replayed and which stored transactions were /// retired instead. @@ -198,16 +199,14 @@ pub async fn replay_recorded_history( ); } } - // `Some` claimant: a conflict sweep or an abandon of the record releases the coin. - let mut reserved: Vec<(OutPoint, Option)> = Vec::new(); - for stored in &set_aside { - for input in &stored.transaction.input { - if placed.contains_key(&input.previous_output) { - reserved.push((input.previous_output, Some(stored.txid))); - } - } - } - let reserved_coins: HashSet = reserved.iter().map(|(coin, _)| *coin).collect(); + // The restored coins the records left out spend: they leave the UTXO set + // below and are claimed for those records once the replay is over. + let reserved_coins: HashSet = set_aside + .iter() + .flat_map(|stored| &stored.transaction.input) + .map(|input| input.previous_output) + .filter(|outpoint| placed.contains_key(outpoint)) + .collect(); for account in wallet_info.accounts.all_funding_accounts_mut() { account .utxos @@ -320,8 +319,29 @@ pub async fn replay_recorded_history( if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { wallet_info.apply_chain_lock(chain_lock); } - // A claim guards a coin only once it has left the UTXO set. - wallet_info.restore_spent_outpoints(&reserved); + // A claim guards a coin only once it has left the UTXO set, and a sweep + // or an abandon of its claimant releases the coin. One claimant per coin, + // the smallest txid still on record: the wallet turns two different + // claimants into a claim nothing releases. + let mut claims: BTreeMap = BTreeMap::new(); + for stored in set_aside + .iter() + .filter(|stored| !swept.contains(&stored.txid)) + { + for input in &stored.transaction.input { + if reserved_coins.contains(&input.previous_output) { + claims + .entry(input.previous_output) + .and_modify(|claimant| *claimant = stored.txid.min(*claimant)) + .or_insert(stored.txid); + } + } + } + let claims: Vec<(OutPoint, Option)> = claims + .into_iter() + .map(|(coin, claimant)| (coin, Some(claimant))) + .collect(); + wallet_info.restore_spent_outpoints(&claims); wallet_info.update_balance(); swept.extend(set_aside.iter().map(|stored| stored.txid)); HistoryReplay { @@ -2265,6 +2285,212 @@ mod tests { } } + /// Two unconfirmed records with unsafe amounts that both spend one shared + /// coin, each next to a coin of its own, replayed onto a projection + /// holding all three coins. + /// + /// Returns the wallet after the replay, the two records ordered by txid, + /// the shared coin with its funding, and each record's own coin. + async fn overlapping_unsafe_spends_fixture() -> ( + Wallet, + ManagedWalletInfo, + [dashcore::Transaction; 2], + (OutPoint, dashcore::Transaction), + [OutPoint; 2], + ) { + use dashcore::hashes::Hash; + use dashcore::{Transaction, TxIn}; + + let (mut wallet, _, address) = wallet_with_receive_address(); + let spend = |spends: &[OutPoint], value: u64| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let fundings = [(85, 10_000), (86, 20_000), (87, 30_000)].map(|(byte, value)| { + spend( + &[OutPoint::new(Txid::from_byte_array([byte; 32]), 0)], + value, + ) + }); + let [shared, one, other] = [0, 1, 2].map(|index| OutPoint::new(fundings[index].txid(), 0)); + let mut spends = [ + (spend(&[shared, one], 1 << 63), one), + (spend(&[shared, other], 1 << 63), other), + ]; + spends.sort_by_key(|(spend, _)| spend.txid()); + let own = [spends[0].1, spends[1].1]; + let unsafe_spends = spends.map(|(spend, _)| spend); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for (coin, funding) in [shared, one, other].iter().zip(&fundings) { + account.utxos.insert( + *coin, + Utxo::new(*coin, funding.output[0].clone(), address.clone(), 99, false), + ); + } + let history = RecordedHistory { + transactions: unsafe_spends + .iter() + .map(|transaction| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }) + .collect(), + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut restored, &mut wallet, history).await; + assert_eq!( + restored.balance.total(), + 0, + "every coin an unsafe record spends is reserved" + ); + let [funding, _, _] = fundings; + (wallet, restored, unsafe_spends, (shared, funding), own) + } + + /// A confirmed spend of `coins`, delivered to `info` after the load. + async fn deliver_confirmed_spend( + info: &mut ManagedWalletInfo, + wallet: &mut Wallet, + coins: &[OutPoint], + ) -> key_wallet::transaction_checking::TransactionCheckResult { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let winner = Transaction { + version: 1, + lock_time: 0, + input: coins + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value: 1_000, + script_pubkey: dashcore::ScriptBuf::new(), + }], + special_transaction_payload: None, + }; + let block = BlockInfo::new(101, BlockHash::from_byte_array([101; 32]), 101); + info.check_core_transaction( + &winner, + TransactionContext::InBlock(block), + wallet, + true, + true, + ) + .await + } + + /// Whether a redelivery of `funding` credits `coin` to the wallet. + async fn redelivery_credits( + info: &ManagedWalletInfo, + wallet: &mut Wallet, + funding: &dashcore::Transaction, + coin: &OutPoint, + ) -> bool { + use dashcore::hashes::Hash; + use dashcore::BlockHash; + use key_wallet::transaction_checking::BlockInfo; + + let mut info = info.clone(); + let block = BlockInfo::new(99, BlockHash::from_byte_array([99; 32]), 99); + info.check_core_transaction( + funding, + TransactionContext::InBlock(block), + wallet, + true, + true, + ) + .await; + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(coin) + } + + /// Two records left out for unsafe amounts can spend the same restored + /// coin. Once a settled transaction has swept both, nothing spends the + /// coin any more: it must be released and credited when its funding is + /// seen again. + #[tokio::test] + async fn should_release_coin_shared_by_two_unsafe_records_once_both_are_swept() { + let (mut wallet, mut restored, unsafe_spends, (shared, funding), own) = + overlapping_unsafe_spends_fixture().await; + assert!(!redelivery_credits(&restored, &mut wallet, &funding, &shared).await); + + let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &own).await; + + let mut swept = settled.swept_transactions.clone(); + swept.sort_unstable(); + assert_eq!(swept, unsafe_spends.map(|spend| spend.txid())); + assert_eq!( + settled.released_outpoints, + vec![shared], + "the coin only the swept records spent must be released" + ); + assert!( + redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + "the released coin must be credited when its funding is seen" + ); + } + + /// The wallet can name one claimant per reserved coin, so a shared coin + /// is held for the record with the smaller txid. Accepted consequence: + /// sweeping that record alone drops the guard although the other record + /// still names the coin, and sweeping the other one alone drops nothing. + /// Neither is reported as a release while a record on file spends the + /// coin. A record with unsafe amounts can never confirm, so neither + /// costs funds. + #[tokio::test] + async fn should_hold_shared_coin_for_the_unsafe_record_with_the_smaller_txid() { + for (swept, drops_guard) in [(0, true), (1, false)] { + let (mut wallet, mut restored, unsafe_spends, (shared, funding), own) = + overlapping_unsafe_spends_fixture().await; + + let settled = + deliver_confirmed_spend(&mut restored, &mut wallet, &own[swept..=swept]).await; + + assert_eq!( + settled.swept_transactions, + vec![unsafe_spends[swept].txid()] + ); + assert_eq!( + settled.released_outpoints, + vec![], + "record {swept} swept alone: the other record still spends the coin" + ); + assert_eq!( + redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + drops_guard, + "record {swept} swept alone" + ); + } + } + /// A transaction recorded in two accounts is one spender of its inputs, /// not a competitor of itself: with no other spender it is uncontested. #[tokio::test] From 6ecb3e232c244bdba7f5e58f25f6ae2d7345c080 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:41:06 +0000 Subject: [PATCH 16/84] fix(platform-wallet): keep the persisted state of coins the replay rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay rebuilds a coin from its stored funding record, and the wallet carries a coin's flags over only within one account. Two things were lost on a coin persistence still held as unspent: - A coin parked in a fallback account and credited by the replay to the account deriving it came back without its spend lock and InstantSend status, so a coin the store had locked was spendable again. The copy was dropped by the cleanup after the replay; this did not start with the earlier drop in `drop_fallback_copies`. - A coin stored as confirmed became unconfirmed at height 0 when its funding record still said mempool, which a height-only confirmation leaves behind. Its value moved to the unconfirmed balance. The coins are now snapshotted before the replay, and every coin the replay keeps gets its stored state back: the spend lock, the InstantSend status, the trusted mark, and its confirmation with height when the replay left it unconfirmed. Where the replay confirmed the coin, the record's block stands, because the record names the block and the coin holds a bare height. Nothing is raised beyond what was stored. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index f69233af5ff..e84ac68e735 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -61,6 +61,8 @@ impl HistoryReplay { /// 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. +/// A coin it keeps also keeps its stored lock, InstantSend status and +/// confirmation. /// /// Unconfirmed (mempool / InstantSend) spends are replayed too: without them a /// redelivered funding transaction would re-credit an output they reserve. @@ -108,6 +110,13 @@ pub async fn replay_recorded_history( account.utxos.keys().map(move |outpoint| (*outpoint, owner)) }) .collect(); + // The same coins as persistence restored them; see `restore_persisted_coin_state`. + let persisted: HashMap = wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .flat_map(|account| account.utxos.clone()) + .collect(); 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. @@ -259,6 +268,11 @@ pub async fn replay_recorded_history( && !reserved_coins.contains(outpoint) && !misplaced.contains(&(*outpoint, owner)) }); + for (outpoint, coin) in &mut account.utxos { + if let Some(persisted) = persisted.get(outpoint) { + restore_persisted_coin_state(coin, persisted); + } + } } for mut account in wallet_info.accounts.all_accounts_mut() { let owner = account.managed_account_type().to_account_type(); @@ -630,6 +644,28 @@ fn drop_fallback_copies( } } +/// Give a coin the replay kept the state persistence stored on it. +/// +/// The replay rebuilds a coin from its stored funding record, and the record +/// can know less than the coin: it keeps saying mempool after a height-only +/// confirmation, and a coin credited to another account than the one it was +/// parked in starts without its flags. The spend lock, the InstantSend status +/// and the trusted mark are restored as stored. +/// +/// A stored confirmation is restored, with its height, only for a coin the +/// replay left unconfirmed. Where the replay confirmed the coin, the record's +/// block stands: the record names the block, the coin holds a bare height. +/// Nothing is raised beyond what was stored. +fn restore_persisted_coin_state(coin: &mut Utxo, persisted: &Utxo) { + coin.is_locked |= persisted.is_locked; + coin.is_instantlocked |= persisted.is_instantlocked; + coin.is_trusted |= persisted.is_trusted; + if persisted.is_confirmed && !coin.is_confirmed { + coin.is_confirmed = true; + coin.height = persisted.height; + } +} + /// Park every owned input a record spends whose funding no replayed record credits. /// /// Persistence excludes spent outputs from the load projection, so without @@ -2491,6 +2527,156 @@ mod tests { } } + /// A coin the replay moves from its fallback account to the account that + /// derives it keeps what persistence stored on it: the spend lock and the + /// InstantSend status. + #[tokio::test] + async fn should_keep_restored_flags_of_coin_moved_from_fallback_account() { + use dashcore::hashes::Hash; + use dashcore::{Transaction, TxIn}; + + let (mut wallet, mut restored, addresses) = two_account_wallet(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([88; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: addresses[1].script_pubkey(), + }], + special_transaction_payload: None, + }; + let coin = OutPoint::new(funding.txid(), 0); + let mut parked = Utxo::new( + coin, + funding.output[0].clone(), + addresses[1].clone(), + 0, + false, + ); + parked.is_locked = true; + parked.is_instantlocked = true; + // Parked in account 0, though account 1 derives its address. + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert(coin, parked); + let history = RecordedHistory { + transactions: vec![StoredTransaction { + txid: funding.txid(), + transaction: funding, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }; + + replay_recorded_history(&mut restored, &mut wallet, history).await; + + let [fallback, owner] = + [0, 1].map(|index| &restored.accounts.standard_bip44_accounts[&index]); + assert!(!fallback.utxos.contains_key(&coin)); + let moved = &owner.utxos[&coin]; + assert!(moved.is_locked, "a locked coin must stay locked"); + assert!( + moved.is_instantlocked, + "an InstantSend-locked coin must stay so" + ); + } + + /// The replay rebuilds a coin from its stored funding record, which can + /// be older than the coin: a height-only confirmation never rewrites the + /// record. The confirmation persistence stored on the coin must survive, + /// nothing may be promoted beyond it, and where the replay itself + /// confirmed the coin, the record's block stands. + #[tokio::test] + async fn should_restore_only_the_confirmation_persistence_stored_on_a_coin() { + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + // (case, persisted confirmation, stored record, expected confirmation) + for (case, persisted, recorded, expected) in [ + ( + "stale mempool record", + Some(99), + TransactionContext::Mempool, + Some(99), + ), + ("unconfirmed coin", None, TransactionContext::Mempool, None), + ( + "record names another block", + Some(90), + in_block(100), + Some(100), + ), + ] { + let (mut wallet, _, address) = wallet_with_receive_address(); + let funding = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([89; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }; + let coin = OutPoint::new(funding.txid(), 0); + let mut stored_coin = Utxo::new( + coin, + funding.output[0].clone(), + address.clone(), + persisted.unwrap_or(0), + false, + ); + stored_coin.is_confirmed = persisted.is_some(); + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert(coin, stored_coin); + let history = RecordedHistory { + transactions: vec![StoredTransaction { + txid: funding.txid(), + transaction: funding, + context: recorded, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }; + + replay_recorded_history(&mut restored, &mut wallet, history).await; + + let kept = &restored.accounts.standard_bip44_accounts[&0].utxos[&coin]; + assert_eq!(kept.is_confirmed.then_some(kept.height), expected, "{case}"); + assert!(!kept.is_locked && !kept.is_instantlocked, "{case}"); + } + } + /// A transaction recorded in two accounts is one spender of its inputs, /// not a competitor of itself: with no other spender it is uncontested. #[tokio::test] From f96d256fb2cc0e957728024a134fbc533c3f2155 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:42:46 +0000 Subject: [PATCH 17/84] test(platform-wallet): build the replay test wallets from a fixed mnemonic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replay tests built their wallet with `Wallet::new_random`, so every txid derived from its addresses changed between runs. One test searches 63 winners and 63 children for txids that sort before, between and after a loser's and unwraps the result. With a random loser the search has no match in about 4.6% of runs (simulated over 400,000 draws), and the test panicked before it exercised the replay. All three wallets in this test module now come from one fixed mnemonic, so the txids, the replay order and the planner's winner order are the same in every run. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index e84ac68e735..9220ee7bc38 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -801,9 +801,21 @@ mod tests { use std::collections::BTreeMap; use key_wallet::managed_account::transaction_record::TransactionRecord; + use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; + const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; + + /// The test wallet, built from a fixed mnemonic: every txid a test derives + /// from its addresses is the same in each run, so no test depends on how + /// random txids happen to sort. + fn test_wallet(accounts: WalletAccountCreationOptions) -> Wallet { + let mnemonic = Mnemonic::from_phrase(TEST_MNEMONIC).unwrap(); + Wallet::from_mnemonic(mnemonic, Network::Testnet, accounts).unwrap() + } + /// Stored history built from checker-produced records. fn history( records: Vec, @@ -815,10 +827,9 @@ mod tests { } } - /// A fresh random wallet and its first BIP44 receive address. + /// The test 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 wallet = test_wallet(WalletAccountCreationOptions::Default); let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; let address = info @@ -1397,11 +1408,9 @@ mod tests { use dashcore::{BlockHash, Transaction, TxIn, TxOut}; use key_wallet::transaction_checking::BlockInfo; - let mut wallet = Wallet::new_random( - Network::Testnet, - WalletAccountCreationOptions::BIP44AccountsOnly([0, 1].into_iter().collect()), - ) - .unwrap(); + let mut wallet = test_wallet(WalletAccountCreationOptions::BIP44AccountsOnly( + [0, 1].into_iter().collect(), + )); let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); let addresses: Vec<_> = [0, 1] .into_iter() @@ -2220,11 +2229,9 @@ mod tests { /// A wallet with BIP44 accounts 0 and 1 and a receive address of each. fn two_account_wallet() -> (Wallet, ManagedWalletInfo, [dashcore::Address; 2]) { - let wallet = Wallet::new_random( - Network::Testnet, - WalletAccountCreationOptions::BIP44AccountsOnly([0, 1].into_iter().collect()), - ) - .unwrap(); + let wallet = test_wallet(WalletAccountCreationOptions::BIP44AccountsOnly( + [0, 1].into_iter().collect(), + )); let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); let addresses = [0, 1].map(|index| { let xpub = wallet.accounts.standard_bip44_accounts[&index].account_xpub; From 8f2264a6803ca540cc35a4162a5681c4d2a1d032 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:50:20 +0000 Subject: [PATCH 18/84] fix(platform-wallet): guard the inputs of settled spends whose funding was not replayed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored spend whose funding transaction is not in the replayed history marks no coin spent: the coin is not in the load projection, and without recorded owned inputs the replay has nothing to stage. Only the observed spend guarded it. Once a chain lock and the sync height had pruned that, a redelivered funding transaction credited the spent coin again. The FFI/SwiftData persister records no owned inputs, so every such spend was exposed; a test pinned this as a known gap. After the replay, a spend claim is now restored for every input of a settled stored record (in a block or InstantSend-locked) whose funding was not replayed. A claim outlives the pruning. It is safe because a settled spend has consumed the coin on chain. A plain mempool spend can still be evicted and is not claimed. Inputs the wallet does not own are claimed as well; such a claim guards nothing. Cost, measured on 5,000 incoming payments with two foreign inputs each: 10,000 claims, and a replay of about 600 ms before and after in the unoptimized test profile. This closes the double credit. Attribution still needs the host to hand over per-input ownership: such a spend has no sent amount, and no record at all when it pays nothing to the wallet. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../rs-platform-wallet-ffi/src/persistence.rs | 8 +- .../src/changeset/recorded_history.rs | 3 +- .../src/manager/history_replay.rs | 154 ++++++++++++++---- 3 files changed, 133 insertions(+), 32 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 04b53e71153..7ebced50e6d 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5291,9 +5291,11 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor // TODO(ffi-recorded-input-details): `RecordedTransactionRestoreFFI` // carries no per-input ownership, so on the FFI/SwiftData path a // spend whose funding survives only as a height row replays with no - // owned input and its spent mark (the guard against a redelivered - // funding transaction re-crediting the coin) is not rebuilt. Needs - // the host to supply per-input ownership across the ABI. + // owned input. The replay still keeps a redelivered funding from + // re-crediting the coin of a settled spend, but cannot attribute + // it: the sent amount is missing, and a spend paying nothing to the + // wallet gets no record. Needs the host to supply per-input + // ownership across the ABI. owned_inputs: Vec::new(), }); } diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index 710ef81c06d..9bb241372ae 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -60,7 +60,8 @@ pub struct StoredTransaction { /// Load excludes spent outputs, so a spend whose funding survives only as /// a height row replays with no owned input; these let the replay stage /// that input and rebuild its spent mark. Empty when the store keeps no - /// per-input ownership: that spend then rebuilds no guard. + /// per-input ownership: the replay then only keeps a settled spend's coin + /// from being credited again, without attributing the spend. pub owned_inputs: Vec, } diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 9220ee7bc38..01271fef542 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -351,10 +351,11 @@ pub async fn replay_recorded_history( } } } - let claims: Vec<(OutPoint, Option)> = claims + let mut claims: Vec<(OutPoint, Option)> = claims .into_iter() .map(|(coin, claimant)| (coin, Some(claimant))) .collect(); + claims.extend(settled_spend_claims(&replay)); wallet_info.restore_spent_outpoints(&claims); wallet_info.update_balance(); swept.extend(set_aside.iter().map(|stored| stored.txid)); @@ -644,6 +645,34 @@ fn drop_fallback_copies( } } +/// Spend claims for the inputs of every settled record in `replay` whose +/// funding the replay did not see. +/// +/// Such a spend marks no coin spent: the coin is not in the projection and, +/// unless the store recorded the owned inputs, cannot be staged. Only the +/// observed spend guards it, and finality prunes that, so a funding delivered +/// later would credit the coin again. A claim outlives the pruning. +/// +/// The claim is safe because the spend is settled, in a block or under an +/// InstantSend lock: the coin is gone, and no later funding may credit it. A +/// plain mempool spend can still be evicted and is not claimed. Inputs that +/// are not the wallet's are claimed as well; such a claim guards nothing. +fn settled_spend_claims(replay: &[StoredTransaction]) -> Vec<(OutPoint, Option)> { + let replayed: HashSet = replay.iter().map(|stored| stored.txid).collect(); + replay + .iter() + .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .flat_map(|stored| { + stored + .transaction + .input + .iter() + .map(|input| (input.previous_output, Some(stored.txid))) + }) + .filter(|(outpoint, _)| !replayed.contains(&outpoint.txid)) + .collect() +} + /// Give a coin the replay kept the state persistence stored on it. /// /// The replay rebuilds a coin from its stored funding record, and the record @@ -669,12 +698,13 @@ fn restore_persisted_coin_state(coin: &mut Utxo, persisted: &Utxo) { /// Park every owned input a record spends whose funding no replayed record credits. /// /// Persistence excludes spent outputs from the load projection, so without -/// this a spender of a height-only funding row replays with no owned input and -/// rebuilds no spent mark; a redelivered funding transaction would then -/// re-credit the coin once finality prunes the observed spend. The stored -/// [`StoredTransaction::owned_inputs`] are the evidence. Staged coins are -/// never in `placed`, so the retention pass drops whatever replay leaves -/// behind. +/// this a spender of a height-only funding row replays with no owned input: +/// it marks no coin spent, its sent amount is missing, and it gets no record +/// at all when it pays nothing to the wallet. The stored +/// [`StoredTransaction::owned_inputs`] are the evidence. Without them only +/// [`settled_spend_claims`] keeps a redelivered funding from re-crediting the +/// coin. Staged coins are never in `placed`, so the retention pass drops +/// whatever replay leaves behind. fn stage_recorded_spent_inputs( wallet_info: &mut ManagedWalletInfo, records: &[StoredTransaction], @@ -983,10 +1013,8 @@ mod tests { /// A spend whose funding survives only as a height row (no record to /// replay, spent output excluded from the projection) still rebuilds its /// spent mark from the stored owned inputs, and the staged coin does not - /// outlive the replay. Finality then prunes the observed spend, so only - /// the account spent mark guards the redelivery. The control half pins the - /// gap a persister without per-input ownership leaves: the redelivery - /// resurrects the output. + /// outlive the replay. Finality then prunes the observed spend, and the + /// redelivery is still not credited. #[tokio::test] async fn should_rebuild_spent_mark_for_height_only_funding_from_owned_inputs() { use dashcore::bls_sig_utils::BLSSignature; @@ -1042,26 +1070,96 @@ mod tests { "a redelivered height-only funding must not re-credit a spent output" ); assert_eq!(replayed.balance.total(), 20_000); + } - let mut unguarded = restored; - let without_ownership = spender + /// A persister that stores no per-input ownership leaves the replay + /// nothing to stage, so a spend whose funding is not in the history marks + /// no coin spent. A settled spend, in a block or under an InstantSend + /// lock, must still keep its inputs from being credited once finality has + /// pruned the observed spend. A plain mempool spend can still be evicted + /// and guards nothing, and a coin no stored record spends is credited as + /// ever. + #[tokio::test] + async fn should_guard_unreplayed_funding_against_settled_spends_only() { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::ephemerealdata::chain_lock::ChainLock; + use dashcore::hashes::Hash; + use dashcore::{BlockHash, Transaction, TxIn}; + use key_wallet::transaction_checking::BlockInfo; + + let (mut wallet, restored, funding, records, spent) = confirmed_spend_fixture().await; + let spender = records .into_iter() - .map(|stored| StoredTransaction { + .find(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .unwrap(); + let block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let locked = TransactionContext::InstantSend(InstantLock { + txid: spender.txid, + inputs: vec![spent], + ..Default::default() + }); + // A payment the stored history knows nothing about. + let unrelated = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([32; 32]), 0), + ..Default::default() + }], + output: vec![TxOut { + value: 5_000, + script_pubkey: funding.output[0].script_pubkey.clone(), + }], + special_transaction_payload: None, + }; + + for (case, context, guarded) in [ + ("in a block", spender.context.clone(), true), + ("InstantSend-locked", locked, true), + ("mempool", TransactionContext::Mempool, false), + ] { + let mut info = restored.clone(); + let without_ownership = StoredTransaction { + context, owned_inputs: Vec::new(), - ..stored - }) - .collect(); - replay_recorded_history(&mut unguarded, &mut wallet, replay(without_ownership)).await; - finalize(&mut unguarded); - unguarded - .check_core_transaction(&funding, redelivery, &mut wallet, true, true) - .await; - assert!( - unguarded.accounts.standard_bip44_accounts[&0] - .utxos - .contains_key(&spent), - "control: without owned inputs the redelivery resurrects the output" - ); + ..spender.clone() + }; + let history = RecordedHistory { + transactions: vec![without_ownership], + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut info, &mut wallet, history).await; + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + assert!(info.observed_spent_outpoints().is_empty(), "{case}"); + + for (transaction, height) in [(&funding, 100), (&unrelated, 301)] { + info.check_core_transaction(transaction, block(height), &mut wallet, true, true) + .await; + } + + let coins = &info.accounts.standard_bip44_accounts[&0].utxos; + assert_eq!( + coins.contains_key(&spent), + !guarded, + "{case}: only a settled spend keeps its input from being credited" + ); + assert!( + coins.contains_key(&OutPoint::new(unrelated.txid(), 0)), + "{case}: a coin no stored record spends must be credited" + ); + } } #[tokio::test] From 654044b9156309027ad21955286ae1b7a0bc21f8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:54:18 +0000 Subject: [PATCH 19/84] fix(platform-wallet)!: hold a reserved coin for the record that may still confirm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reserved coin shared by several records left out for unsafe amounts was claimed for the one with the smallest txid, on the ground that such a record can never confirm. That holds only for a record unsafe by its own outputs. A record is also left out when only amounts stored beside it do not fit: its recorded owned inputs, or the coins it spends. Its transaction may be valid. Sweeping the named record then dropped the guard while a transaction that may still confirm spent the coin, and a redelivered funding credited it. `unsafe_replay_amounts` now returns the reason for each transaction (`UnsafeAmounts::Outputs` or `UnsafeAmounts::Stored`) instead of a set of txids, and the claimant of a reserved coin follows it: - among records unsafe by their outputs only, the smallest txid, as before; - one record unsafe by stored amounts: that record, whatever its txid; - two or more of those: no claimant. The wallet holds one claimant per coin, and none of them proves the coin free, so the coin stays claimed until the next load, also once both records are swept. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 190 ++++++++++++++---- 1 file changed, 152 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 01271fef542..87b781cb955 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -82,8 +82,8 @@ impl HistoryReplay { /// peer data, a settled one is store corruption, and this path has no recovery /// mode to fall back on. The restored coins it spends stay reserved, and an /// unconfirmed one stays on record, unchecked, so that a settled conflict -/// still sweeps it and releases them. A coin two such records spend is held -/// for one of them only and is released with that one. +/// still sweeps it and releases them. A coin several such records spend is +/// held as [`reservation_claimant`] decides. /// /// Returns how many records were replayed and which stored transactions were /// retired instead. @@ -173,7 +173,8 @@ pub async fn replay_recorded_history( ) }) .collect(); - let unsafe_txids = unsafe_replay_amounts(wallet_info, &operands); + let unsafe_amounts = unsafe_replay_amounts(wallet_info, &operands); + let unsafe_txids: HashSet = unsafe_amounts.keys().copied().collect(); // Losers leave the replay and the projection; `swept` also keeps their // held raw records from coming back as fallbacks. let plan = plan_replay_sweeps(wallet_info, &replay, &unsafe_txids); @@ -333,27 +334,25 @@ pub async fn replay_recorded_history( if let Some(chain_lock) = wallet_info.metadata.last_applied_chain_lock.clone() { wallet_info.apply_chain_lock(chain_lock); } - // A claim guards a coin only once it has left the UTXO set, and a sweep - // or an abandon of its claimant releases the coin. One claimant per coin, - // the smallest txid still on record: the wallet turns two different - // claimants into a claim nothing releases. - let mut claims: BTreeMap = BTreeMap::new(); + // A claim guards a coin only once it has left the UTXO set. Each reserved + // coin is claimed for the records still on record that spend it. + let mut spenders: BTreeMap> = BTreeMap::new(); for stored in set_aside .iter() .filter(|stored| !swept.contains(&stored.txid)) { for input in &stored.transaction.input { if reserved_coins.contains(&input.previous_output) { - claims + spenders .entry(input.previous_output) - .and_modify(|claimant| *claimant = stored.txid.min(*claimant)) - .or_insert(stored.txid); + .or_default() + .insert(stored.txid); } } } - let mut claims: Vec<(OutPoint, Option)> = claims + let mut claims: Vec<(OutPoint, Option)> = spenders .into_iter() - .map(|(coin, claimant)| (coin, Some(claimant))) + .map(|(coin, spenders)| (coin, reservation_claimant(&spenders, &unsafe_amounts))) .collect(); claims.extend(settled_spend_claims(&replay)); wallet_info.restore_spent_outpoints(&claims); @@ -365,10 +364,45 @@ pub async fn replay_recorded_history( } } +/// The claimant a reserved coin is held for, out of the records left out +/// that spend it. +/// +/// The wallet holds one claimant per coin and releases the coin when that +/// one is swept or abandoned; two different claimants make a claim nothing +/// releases. A record unsafe by its own outputs can never confirm, so among +/// such records the smallest txid stands for all. A record unsafe only by +/// stored amounts may still confirm, so the coin is held for it. Two of +/// those leave no claimant whose removal proves the coin free: the coin +/// stays claimed until the next load (`None`). +fn reservation_claimant( + spenders: &BTreeSet, + unsafe_amounts: &HashMap, +) -> Option { + let mut may_confirm = spenders + .iter() + .filter(|txid| unsafe_amounts.get(*txid) == Some(&UnsafeAmounts::Stored)); + match (may_confirm.next(), may_confirm.next()) { + (Some(_), Some(_)) => None, + (Some(only), None) => Some(*only), + _ => spenders.first().copied(), + } +} + /// One stored transaction as [`unsafe_replay_amounts`] reads it: its txid, its /// body and the wallet-owned inputs its store recorded. pub type ReplayOperands<'a> = (Txid, &'a Transaction, &'a [InputDetail]); +/// Which amounts make a stored transaction unsafe to replay. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnsafeAmounts { + /// The transaction's own outputs. No valid transaction carries them, so + /// it can never confirm. + Outputs, + /// Only amounts stored beside the transaction: its recorded owned inputs, + /// or the coins it spends. The transaction itself may be valid. + Stored, +} + /// The stored transactions whose amounts the wallet checker cannot take. /// /// The checker casts each unsigned operand to `i64` before a signed @@ -377,11 +411,11 @@ pub type ReplayOperands<'a> = (Txid, &'a Transaction, &'a [InputDetail]); /// fitting net does not make an overflowing operand safe. Every source of an /// operand is covered: the coins restored onto `wallet_info`, the outputs of /// the other `records` (asset-lock credit outputs included) and the recorded -/// owned inputs. +/// owned inputs. Each rejected transaction comes with the reason. pub fn unsafe_replay_amounts( wallet_info: &ManagedWalletInfo, records: &[ReplayOperands<'_>], -) -> HashSet { +) -> HashMap { /// Whether every value and their running sum fit `i64`. fn fits(values: impl IntoIterator) -> bool { let mut sum = 0_u64; @@ -401,7 +435,7 @@ pub fn unsafe_replay_amounts( remember(*outpoint, coin.txout.value); } } - let mut unsafe_txids = HashSet::new(); + let mut unsafe_amounts = HashMap::new(); for (txid, transaction, owned_inputs) in records { let credit_outputs = match &transaction.special_transaction_payload { Some(TransactionPayload::AssetLockPayloadType(payload)) => { @@ -410,11 +444,16 @@ pub fn unsafe_replay_amounts( _ => &[], }; let outputs = transaction.output.iter().chain(credit_outputs); - if !fits(owned_inputs.iter().map(|detail| detail.value)) - || !fits(outputs.map(|output| output.value)) - { + let reason = if !fits(outputs.map(|output| output.value)) { + Some(UnsafeAmounts::Outputs) + } else if !fits(owned_inputs.iter().map(|detail| detail.value)) { + Some(UnsafeAmounts::Stored) + } else { + None + }; + if let Some(reason) = reason { // A record left out credits nothing, so its outputs are no child's input amount. - unsafe_txids.insert(*txid); + unsafe_amounts.insert(*txid, reason); continue; } for (vout, output) in transaction.output.iter().enumerate() { @@ -432,10 +471,10 @@ pub fn unsafe_replay_amounts( .iter() .filter_map(|input| amounts.get(&input.previous_output).copied()); if !fits(inputs) { - unsafe_txids.insert(*txid); + unsafe_amounts.entry(*txid).or_insert(UnsafeAmounts::Stored); } } - unsafe_txids + unsafe_amounts } /// What [`plan_replay_sweeps`] decided, and what deciding it cost. @@ -2428,16 +2467,22 @@ mod tests { /// Two unconfirmed records with unsafe amounts that both spend one shared /// coin, each next to a coin of its own, replayed onto a projection - /// holding all three coins. + /// holding all three coins. A record is unsafe by its own output, or, + /// where `by_stored_amounts` says so, only by the input amount the store + /// recorded beside an ordinary transaction. /// /// Returns the wallet after the replay, the two records ordered by txid, - /// the shared coin with its funding, and each record's own coin. - async fn overlapping_unsafe_spends_fixture() -> ( + /// the shared coin with its funding, each record's own coin, and which + /// of the ordered records is unsafe by stored amounts only. + async fn overlapping_unsafe_spends_fixture( + by_stored_amounts: [bool; 2], + ) -> ( Wallet, ManagedWalletInfo, [dashcore::Transaction; 2], (OutPoint, dashcore::Transaction), [OutPoint; 2], + [bool; 2], ) { use dashcore::hashes::Hash; use dashcore::{Transaction, TxIn}; @@ -2466,13 +2511,23 @@ mod tests { ) }); let [shared, one, other] = [0, 1, 2].map(|index| OutPoint::new(fundings[index].txid(), 0)); + let value = |stored: bool| if stored { 25_000 } else { 1 << 63 }; let mut spends = [ - (spend(&[shared, one], 1 << 63), one), - (spend(&[shared, other], 1 << 63), other), + ( + spend(&[shared, one], value(by_stored_amounts[0])), + one, + by_stored_amounts[0], + ), + ( + spend(&[shared, other], value(by_stored_amounts[1])), + other, + by_stored_amounts[1], + ), ]; - spends.sort_by_key(|(spend, _)| spend.txid()); + spends.sort_by_key(|(spend, _, _)| spend.txid()); let own = [spends[0].1, spends[1].1]; - let unsafe_spends = spends.map(|(spend, _)| spend); + let stored_only = [spends[0].2, spends[1].2]; + let unsafe_spends = spends.map(|(spend, _, _)| spend); let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); let account = restored @@ -2489,13 +2544,22 @@ mod tests { let history = RecordedHistory { transactions: unsafe_spends .iter() - .map(|transaction| StoredTransaction { + .zip(stored_only) + .map(|(transaction, stored_only)| StoredTransaction { txid: transaction.txid(), transaction: transaction.clone(), context: TransactionContext::Mempool, stored_net_amount: None, stored_direction: None, - owned_inputs: Vec::new(), + owned_inputs: if stored_only { + vec![InputDetail { + index: 0, + value: 1 << 63, + address: address.clone(), + }] + } else { + Vec::new() + }, }) .collect(), instant_locks: BTreeMap::new(), @@ -2507,7 +2571,14 @@ mod tests { "every coin an unsafe record spends is reserved" ); let [funding, _, _] = fundings; - (wallet, restored, unsafe_spends, (shared, funding), own) + ( + wallet, + restored, + unsafe_spends, + (shared, funding), + own, + stored_only, + ) } /// A confirmed spend of `coins`, delivered to `info` after the load. @@ -2579,8 +2650,8 @@ mod tests { /// seen again. #[tokio::test] async fn should_release_coin_shared_by_two_unsafe_records_once_both_are_swept() { - let (mut wallet, mut restored, unsafe_spends, (shared, funding), own) = - overlapping_unsafe_spends_fixture().await; + let (mut wallet, mut restored, unsafe_spends, (shared, funding), own, _) = + overlapping_unsafe_spends_fixture([false, false]).await; assert!(!redelivery_credits(&restored, &mut wallet, &funding, &shared).await); let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &own).await; @@ -2599,8 +2670,9 @@ mod tests { ); } - /// The wallet can name one claimant per reserved coin, so a shared coin - /// is held for the record with the smaller txid. Accepted consequence: + /// The wallet can name one claimant per reserved coin, so a coin shared + /// by records unsafe by their own outputs is held for the one with the + /// smaller txid. Accepted consequence: /// sweeping that record alone drops the guard although the other record /// still names the coin, and sweeping the other one alone drops nothing. /// Neither is reported as a release while a record on file spends the @@ -2609,8 +2681,8 @@ mod tests { #[tokio::test] async fn should_hold_shared_coin_for_the_unsafe_record_with_the_smaller_txid() { for (swept, drops_guard) in [(0, true), (1, false)] { - let (mut wallet, mut restored, unsafe_spends, (shared, funding), own) = - overlapping_unsafe_spends_fixture().await; + let (mut wallet, mut restored, unsafe_spends, (shared, funding), own, _) = + overlapping_unsafe_spends_fixture([false, false]).await; let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &own[swept..=swept]).await; @@ -2782,6 +2854,48 @@ mod tests { } } + /// A record unsafe only by amounts the store recorded beside it holds a + /// valid transaction that may still confirm, so the coin it spends must + /// stay guarded while it stands, whichever other record is swept. Two + /// such records sharing a coin leave no single claimant to name: the + /// coin then stays guarded for the rest of the run, even once both are + /// swept. + #[tokio::test] + async fn should_hold_shared_coin_while_a_record_that_may_confirm_spends_it() { + for swept in [&[0][..], &[1], &[0, 1]] { + let (mut wallet, mut restored, _, (shared, funding), own, _) = + overlapping_unsafe_spends_fixture([true, true]).await; + let coins: Vec = swept.iter().map(|index| own[*index]).collect(); + + let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &coins).await; + + assert_eq!(settled.swept_transactions.len(), swept.len()); + assert_eq!(settled.released_outpoints, vec![], "swept {swept:?}"); + assert!( + !redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + "swept {swept:?}: the coin stays guarded" + ); + } + + // Next to a record that can never confirm, the coin is held for the + // one that may, whichever of the two has the smaller txid. + for by_stored_amounts in [[true, false], [false, true]] { + for swept in [0, 1] { + let (mut wallet, mut restored, _, (shared, funding), own, stored_only) = + overlapping_unsafe_spends_fixture(by_stored_amounts).await; + + deliver_confirmed_spend(&mut restored, &mut wallet, &own[swept..=swept]).await; + + assert_eq!( + redelivery_credits(&restored, &mut wallet, &funding, &shared).await, + stored_only[swept], + "{by_stored_amounts:?}, record {swept} swept: only removing the record \ + that may confirm drops the guard" + ); + } + } + } + /// A transaction recorded in two accounts is one spender of its inputs, /// not a competitor of itself: with no other spender it is uncontested. #[tokio::test] From 2aa149774f9e10742b028aa766be25beff9a81d3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:46:54 +0000 Subject: [PATCH 20/84] fix(platform-wallet): claim only the inputs of spends no fork can drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load replay restored a spend claim for every input of a stored spend in a block whose funding was not replayed. Nothing retires a claim when a fork drops its claimant: the wallet's truncation releases none, and a spend that pays nothing to the wallet has no record for it to report. After a reorg removed such a spend, its claim still kept the funding output from being credited, and the coin stayed unavailable. A claim is now restored only for a spend in a chain-locked block or under an InstantSend lock. A spend in a block above the restored chain lock keeps the observed spend alone, which a fork drops with it. The next load claims it once its block is chain-locked. That leaves a double credit open for a spend that is all of these at once: in a block not yet chain-locked when the wallet loads, paying nothing to the wallet, its funding not replayable, and its funding block delivered again in the same session without its own block. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../rs-platform-wallet-ffi/src/persistence.rs | 8 +- .../src/changeset/recorded_history.rs | 5 +- .../src/manager/history_replay.rs | 127 ++++++++++++++---- 3 files changed, 108 insertions(+), 32 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 7ebced50e6d..85d11cf547b 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5292,10 +5292,10 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor // carries no per-input ownership, so on the FFI/SwiftData path a // spend whose funding survives only as a height row replays with no // owned input. The replay still keeps a redelivered funding from - // re-crediting the coin of a settled spend, but cannot attribute - // it: the sent amount is missing, and a spend paying nothing to the - // wallet gets no record. Needs the host to supply per-input - // ownership across the ABI. + // re-crediting the coin of a chain-locked spend, but cannot + // attribute it: the sent amount is missing, and a spend paying + // nothing to the wallet gets no record. Needs the host to supply + // per-input ownership across the ABI. owned_inputs: Vec::new(), }); } diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index 9bb241372ae..9dc489faf92 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -60,8 +60,9 @@ pub struct StoredTransaction { /// Load excludes spent outputs, so a spend whose funding survives only as /// a height row replays with no owned input; these let the replay stage /// that input and rebuild its spent mark. Empty when the store keeps no - /// per-input ownership: the replay then only keeps a settled spend's coin - /// from being credited again, without attributing the spend. + /// per-input ownership: the replay then only keeps the coin of a + /// chain-locked or InstantSend-locked spend from being credited again, + /// without attributing the spend. pub owned_inputs: Vec, } diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 87b781cb955..6b9cad68313 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -354,7 +354,7 @@ pub async fn replay_recorded_history( .into_iter() .map(|(coin, spenders)| (coin, reservation_claimant(&spenders, &unsafe_amounts))) .collect(); - claims.extend(settled_spend_claims(&replay)); + claims.extend(irreversible_spend_claims(&replay)); wallet_info.restore_spent_outpoints(&claims); wallet_info.update_balance(); swept.extend(set_aside.iter().map(|stored| stored.txid)); @@ -684,7 +684,7 @@ fn drop_fallback_copies( } } -/// Spend claims for the inputs of every settled record in `replay` whose +/// Spend claims for the inputs of every irreversible record in `replay` whose /// funding the replay did not see. /// /// Such a spend marks no coin spent: the coin is not in the projection and, @@ -692,15 +692,23 @@ fn drop_fallback_copies( /// observed spend guards it, and finality prunes that, so a funding delivered /// later would credit the coin again. A claim outlives the pruning. /// -/// The claim is safe because the spend is settled, in a block or under an -/// InstantSend lock: the coin is gone, and no later funding may credit it. A -/// plain mempool spend can still be evicted and is not claimed. Inputs that -/// are not the wallet's are claimed as well; such a claim guards nothing. -fn settled_spend_claims(replay: &[StoredTransaction]) -> Vec<(OutPoint, Option)> { +/// Nothing retires a claim when a fork drops its claimant, so only a spend no +/// fork can drop is claimed: one in a chain-locked block or under an +/// InstantSend lock. A spend in a block above the restored chain lock keeps +/// the observed spend alone, which a fork drops with it; the next load claims +/// it once its block is chain-locked. A plain mempool spend can still be +/// evicted and is not claimed. Inputs that are not the wallet's are claimed as +/// well; such a claim guards nothing. +fn irreversible_spend_claims(replay: &[StoredTransaction]) -> Vec<(OutPoint, Option)> { let replayed: HashSet = replay.iter().map(|stored| stored.txid).collect(); replay .iter() - .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .filter(|stored| { + matches!( + stored.context, + TransactionContext::InChainLockedBlock(_) | TransactionContext::InstantSend(_) + ) + }) .flat_map(|stored| { stored .transaction @@ -741,8 +749,8 @@ fn restore_persisted_coin_state(coin: &mut Utxo, persisted: &Utxo) { /// it marks no coin spent, its sent amount is missing, and it gets no record /// at all when it pays nothing to the wallet. The stored /// [`StoredTransaction::owned_inputs`] are the evidence. Without them only -/// [`settled_spend_claims`] keeps a redelivered funding from re-crediting the -/// coin. Staged coins are never in `placed`, so the retention pass drops +/// [`irreversible_spend_claims`] keeps a redelivered funding from re-crediting +/// the coin. Staged coins are never in `placed`, so the retention pass drops /// whatever replay leaves behind. fn stage_recorded_spent_inputs( wallet_info: &mut ManagedWalletInfo, @@ -1113,13 +1121,21 @@ mod tests { /// A persister that stores no per-input ownership leaves the replay /// nothing to stage, so a spend whose funding is not in the history marks - /// no coin spent. A settled spend, in a block or under an InstantSend - /// lock, must still keep its inputs from being credited once finality has - /// pruned the observed spend. A plain mempool spend can still be evicted - /// and guards nothing, and a coin no stored record spends is credited as - /// ever. + /// no coin spent. A spend no fork can drop, in a chain-locked block or + /// under an InstantSend lock, must still keep its inputs from being + /// credited once finality has pruned the observed spend. A plain mempool + /// spend can still be evicted and guards nothing, and a coin no stored + /// record spends is credited as ever. + /// + /// A spend in a block above the restored chain lock gets no claim either: + /// a fork can still drop it, and nothing retires a claim. That leaves a + /// double credit open for a spend that is all of these at once: in a + /// block not yet chain-locked when the wallet loads, paying nothing to + /// the wallet, its funding not replayable, and its funding block + /// delivered again in the same session without its own block. The next + /// load claims it once its block is chain-locked. #[tokio::test] - async fn should_guard_unreplayed_funding_against_settled_spends_only() { + async fn should_guard_unreplayed_funding_against_irreversible_spends_only() { use dashcore::bls_sig_utils::BLSSignature; use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::hashes::Hash; @@ -1159,12 +1175,27 @@ mod tests { special_transaction_payload: None, }; - for (case, context, guarded) in [ - ("in a block", spender.context.clone(), true), - ("InstantSend-locked", locked, true), - ("mempool", TransactionContext::Mempool, false), + let chain_lock = |block_height| ChainLock { + block_height, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }; + let in_block = spender.context.clone(); + assert_eq!(in_block.block_info().map(|block| block.height()), Some(101)); + + for (case, context, chain_locked, guarded) in [ + ("in a chain-locked block", in_block.clone(), Some(101), true), + ( + "in a block above the chain lock", + in_block, + Some(100), + false, + ), + ("InstantSend-locked", locked, None, true), + ("mempool", TransactionContext::Mempool, None, false), ] { let mut info = restored.clone(); + info.metadata.last_applied_chain_lock = chain_locked.map(chain_lock); let without_ownership = StoredTransaction { context, owned_inputs: Vec::new(), @@ -1175,11 +1206,7 @@ mod tests { instant_locks: BTreeMap::new(), }; replay_recorded_history(&mut info, &mut wallet, history).await; - info.apply_chain_lock(ChainLock { - block_height: 300, - block_hash: BlockHash::from_byte_array([30; 32]), - signature: BLSSignature::from([0; 96]), - }); + info.apply_chain_lock(chain_lock(300)); info.update_synced_height(300); assert!(info.observed_spent_outpoints().is_empty(), "{case}"); @@ -1192,7 +1219,7 @@ mod tests { assert_eq!( coins.contains_key(&spent), !guarded, - "{case}: only a settled spend keeps its input from being credited" + "{case}: only a spend no fork can drop keeps its input from being credited" ); assert!( coins.contains_key(&OutPoint::new(unrelated.txid(), 0)), @@ -1201,6 +1228,54 @@ mod tests { } } + /// A block above the chain lock can be reorged out. When the fork drops a + /// restored spend, the coin it spent is unspent again, and a delivery of + /// its funding must credit it. + #[tokio::test] + async fn should_credit_funding_again_after_a_fork_drops_the_restored_spend() { + 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 spender = records + .into_iter() + .find(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .unwrap(); + assert_eq!( + spender.context.block_info().map(|block| block.height()), + Some(101) + ); + let mut info = restored; + let history = RecordedHistory { + transactions: vec![StoredTransaction { + owned_inputs: Vec::new(), + ..spender + }], + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut info, &mut wallet, history).await; + + info.truncate_above(100).unwrap(); + let block = BlockInfo::new(100, BlockHash::from_byte_array([100; 32]), 100); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + + assert!( + info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "the fork dropped the spend, so the coin is unspent again" + ); + } + #[tokio::test] async fn should_rebuild_guards_for_raw_restored_records() { for mempool in [false, true] { From f55739b3096a46dff05948583e582633e8d63474 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:47:32 +0000 Subject: [PATCH 21/84] test(platform-wallet): pin the guard for an unconfirmed spend of a restored coin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A store may keep the coin of an unconfirmed spend as unspent, as the SwiftData store does for mempool and InstantSend-locked spends. The load projection then holds the coin, the replay spends it, and the account's own spent mark keeps a later delivery of the funding from crediting it, with no owned inputs recorded and no claim. The test covers that through finality and a redelivered funding. The `owned_inputs` documentation now states the other half as a contract for stores: one that drops such a coin from the projection must record the spend's owned inputs. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/changeset/recorded_history.rs | 4 ++ .../src/manager/history_replay.rs | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index 9dc489faf92..c53c7d7b728 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -63,6 +63,10 @@ pub struct StoredTransaction { /// per-input ownership: the replay then only keeps the coin of a /// chain-locked or InstantSend-locked spend from being credited again, /// without attributing the spend. + /// + /// A store that drops the coin of an unconfirmed spend from the load + /// projection must record that spend's owned inputs here: the replay + /// guards an unconfirmed spend only through a coin it can spend. pub owned_inputs: Vec, } diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 6b9cad68313..d6748361ad6 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -1276,6 +1276,75 @@ mod tests { ); } + /// A host that keeps the coin of an unconfirmed spend as unspent, as the + /// SwiftData store does for mempool and InstantSend-locked spends, needs + /// no claim: the replay spends the restored coin and the account's own + /// mark keeps a later funding delivery from crediting it again. + #[tokio::test] + async fn should_guard_restored_coin_of_unconfirmed_spend_without_owned_inputs() { + use dashcore::bls_sig_utils::BLSSignature; + use dashcore::ephemerealdata::chain_lock::ChainLock; + 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 spender = records + .into_iter() + .find(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .unwrap(); + let mut info = restored; + let address = info.accounts.standard_bip44_accounts[&0] + .utxos + .values() + .next() + .unwrap() + .address + .clone(); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + spent, + Utxo::new(spent, funding.output[0].clone(), address, 100, false), + ); + let history = RecordedHistory { + transactions: vec![StoredTransaction { + context: TransactionContext::Mempool, + owned_inputs: Vec::new(), + ..spender + }], + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut info, &mut wallet, history).await; + info.apply_chain_lock(ChainLock { + block_height: 300, + block_hash: BlockHash::from_byte_array([30; 32]), + signature: BLSSignature::from([0; 96]), + }); + info.update_synced_height(300); + + let block = BlockInfo::new(100, BlockHash::from_byte_array([100; 32]), 100); + info.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + + assert!( + !info.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "the unconfirmed spend still holds the coin" + ); + } + #[tokio::test] async fn should_rebuild_guards_for_raw_restored_records() { for mempool in [false, true] { From 35951bfdde88d82f3174ca0367f243477fb3f020 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:49:51 +0000 Subject: [PATCH 22/84] perf(platform-wallet): index pending asset-lock inputs before reading stored conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `recorded_input_conflicts` scanned every pending asset lock, recomputing its txid and walking its inputs, for each stored confirmed input that a pending lock also spends. With many locks pending at once that is quadratic work at load. The pending locks are now indexed once, outpoint to the lock transactions that spend it, with each txid computed once. A stored confirmed spend is a conflict when the index names another owner of the input. The result is unchanged; the test gains two pending locks on one input, where the confirmation of one is a conflict for the other. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/wallet/asset_lock/sync/recovery.rs | 81 +++++++++++-------- 1 file changed, 47 insertions(+), 34 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 519f482e4f7..442b47824f3 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -5,7 +5,7 @@ //! and re-deriving private keys. use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -503,22 +503,21 @@ pub(crate) fn recorded_input_conflicts<'a>( locks: impl IntoIterator, history: &RecordedHistory, ) -> BTreeMap { - let pending: Vec<&TrackedAssetLock> = locks - .into_iter() - .filter(|lock| { - matches!( - lock.status, - AssetLockStatus::Built | AssetLockStatus::Broadcast - ) - }) - .collect(); - let watched: HashSet = pending - .iter() - .flat_map(|lock| lock.transaction.input.iter()) - .map(|input| input.previous_output) - .collect(); + // The pending lock transactions that spend each outpoint. + let mut owners: BTreeMap> = BTreeMap::new(); + for lock in locks.into_iter().filter(|lock| { + matches!( + lock.status, + AssetLockStatus::Built | AssetLockStatus::Broadcast + ) + }) { + let txid = lock.transaction.txid(); + for input in &lock.transaction.input { + owners.entry(input.previous_output).or_default().push(txid); + } + } let mut conflicts = BTreeMap::new(); - if watched.is_empty() { + if owners.is_empty() { return conflicts; } for stored in &history.transactions { @@ -527,15 +526,9 @@ pub(crate) fn recorded_input_conflicts<'a>( }; for input in stored.transaction.input.iter().map(|i| i.previous_output) { // A lock's own confirmation is not a conflict with itself. - let contested = watched.contains(&input) - && pending.iter().any(|lock| { - lock.transaction.txid() != stored.txid - && lock - .transaction - .input - .iter() - .any(|i| i.previous_output == input) - }); + let contested = owners + .get(&input) + .is_some_and(|owners| owners.iter().any(|owner| *owner != stored.txid)); if contested { conflicts.insert( input, @@ -3817,14 +3810,24 @@ mod tests { owned_inputs: Vec::new(), }; - let (contested, own, unconfirmed, consumed) = - (outpoint(1), outpoint(2), outpoint(3), outpoint(4)); + let (contested, own, unconfirmed, consumed, shared) = ( + outpoint(1), + outpoint(2), + outpoint(3), + outpoint(4), + outpoint(5), + ); let confirmed_lock = spending(own, 0); + // Two pending locks on one input: the confirmation of one is a + // conflict for the other. + let confirmed_rival = spending(shared, 0); let locks = [ lock(spending(contested, 0), AssetLockStatus::Broadcast), lock(confirmed_lock.clone(), AssetLockStatus::Built), lock(spending(unconfirmed, 0), AssetLockStatus::Broadcast), lock(spending(consumed, 0), AssetLockStatus::Consumed), + lock(confirmed_rival.clone(), AssetLockStatus::Broadcast), + lock(spending(shared, 2), AssetLockStatus::Built), ]; let spender = spending(contested, 1); let history = RecordedHistory { @@ -3833,19 +3836,29 @@ mod tests { stored(confirmed_lock, confirmed_at(101)), stored(spending(unconfirmed, 1), TransactionContext::Mempool), stored(spending(consumed, 1), confirmed_at(102)), + stored(confirmed_rival.clone(), confirmed_at(103)), ], instant_locks: BTreeMap::new(), }; assert_eq!( super::recorded_input_conflicts(&locks, &history), - BTreeMap::from([( - contested, - ObservedInputConflict { - spender: spender.txid(), - height: 100, - }, - )]) + BTreeMap::from([ + ( + contested, + ObservedInputConflict { + spender: spender.txid(), + height: 100, + }, + ), + ( + shared, + ObservedInputConflict { + spender: confirmed_rival.txid(), + height: 103, + }, + ), + ]) ); } From 96fb93c0354aab5f711fccb23e9a43347212aa01 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:44:32 +0000 Subject: [PATCH 23/84] fix(platform-wallet): treat an overflow the recorded bodies prove as never confirming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spend whose known input amounts overflow the checker was classified as "may still confirm" whenever its own outputs fit, and two such spends sharing a coin left that coin claimed with no claimant until the next load. A peer can produce that shape without touching the store: fake unconfirmed parents paying the wallet `1 << 62` twice, and two forged spends of both parent outputs and of one genuine coin. After a restart the genuine coin stayed unavailable even once a settled transaction had swept both forged spends. `unsafe_replay_amounts` now tells the two sources apart. An outpoint commits to its transaction, so an amount read from a recorded body is certain, and consensus rejects a transaction whose total input value is out of range. An overflow the recorded bodies prove on their own, left-out parents included, is therefore `UnsafeAmounts::Outputs`: the spend can never confirm, the coin is held for the smallest txid and is released when that record is swept. Only an overflow that depends on a restored coin value or a recorded owned-input value stays `UnsafeAmounts::Stored`. The set of records left out is unchanged. A spend whose body-proven inputs exceed the money supply but still fit the checker is replayed like any other unconfirmed record. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 146 ++++++++++++++++-- 1 file changed, 136 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index d6748361ad6..ee55edfdded 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -369,7 +369,7 @@ pub async fn replay_recorded_history( /// /// The wallet holds one claimant per coin and releases the coin when that /// one is swept or abandoned; two different claimants make a claim nothing -/// releases. A record unsafe by its own outputs can never confirm, so among +/// releases. A record unsafe by recorded outputs can never confirm, so among /// such records the smallest txid stands for all. A record unsafe only by /// stored amounts may still confirm, so the coin is held for it. Two of /// those leave no claimant whose removal proves the coin free: the coin @@ -395,11 +395,12 @@ pub type ReplayOperands<'a> = (Txid, &'a Transaction, &'a [InputDetail]); /// Which amounts make a stored transaction unsafe to replay. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UnsafeAmounts { - /// The transaction's own outputs. No valid transaction carries them, so - /// it can never confirm. + /// Outputs of recorded transactions: its own, or the ones it spends. + /// Their bodies commit to these amounts and consensus rejects them, so it + /// can never confirm. Outputs, /// Only amounts stored beside the transaction: its recorded owned inputs, - /// or the coins it spends. The transaction itself may be valid. + /// or the restored coins it spends. The transaction itself may be valid. Stored, } @@ -411,7 +412,10 @@ pub enum UnsafeAmounts { /// fitting net does not make an overflowing operand safe. Every source of an /// operand is covered: the coins restored onto `wallet_info`, the outputs of /// the other `records` (asset-lock credit outputs included) and the recorded -/// owned inputs. Each rejected transaction comes with the reason. +/// owned inputs. Each rejected transaction comes with the reason: an overflow +/// the recorded transaction bodies prove on their own is +/// [`UnsafeAmounts::Outputs`], one that depends on a stored value is +/// [`UnsafeAmounts::Stored`]. pub fn unsafe_replay_amounts( wallet_info: &ManagedWalletInfo, records: &[ReplayOperands<'_>], @@ -435,6 +439,14 @@ pub fn unsafe_replay_amounts( remember(*outpoint, coin.txout.value); } } + // What each recorded body pays, left-out records included: an outpoint + // commits to its transaction, so these amounts hold whatever is stored. + let mut committed: HashMap = HashMap::new(); + for (txid, transaction, _) in records { + for (vout, output) in transaction.output.iter().enumerate() { + committed.insert(OutPoint::new(*txid, vout as u32), output.value); + } + } let mut unsafe_amounts = HashMap::new(); for (txid, transaction, owned_inputs) in records { let credit_outputs = match &transaction.special_transaction_payload { @@ -466,12 +478,21 @@ pub fn unsafe_replay_amounts( } } for (txid, transaction, _) in records { - let inputs = transaction - .input - .iter() - .filter_map(|input| amounts.get(&input.previous_output).copied()); - if !fits(inputs) { + let inputs_fit = |known: &HashMap| { + fits( + transaction + .input + .iter() + .filter_map(|input| known.get(&input.previous_output).copied()), + ) + }; + if inputs_fit(&amounts) { + continue; + } + if inputs_fit(&committed) { unsafe_amounts.entry(*txid).or_insert(UnsafeAmounts::Stored); + } else { + unsafe_amounts.insert(*txid, UnsafeAmounts::Outputs); } } unsafe_amounts @@ -2814,6 +2835,111 @@ mod tests { ); } + /// Amounts a peer can make the wallet record: unconfirmed parents paying + /// it `1 << 62` twice, and two spends of both parent outputs and of one + /// genuine coin. The parents' bodies prove the spends take in more than + /// can exist, so neither spend can ever confirm, and once a settled + /// transaction has swept both, the genuine coin must be released. + #[tokio::test] + async fn should_release_coin_shared_by_spends_of_impossible_recorded_amounts() { + use dashcore::hashes::Hash; + use dashcore::{Transaction, TxIn}; + + for one_parent in [false, true] { + let (mut wallet, _, address) = wallet_with_receive_address(); + let pay = |spends: &[OutPoint], values: &[u64]| Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: values + .iter() + .map(|value| TxOut { + value: *value, + script_pubkey: address.script_pubkey(), + }) + .collect(), + special_transaction_payload: None, + }; + let foreign = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + // One parent with both outputs is itself left out for its own + // outputs; its body still proves what its children take in. + let parents = if one_parent { + vec![pay(&[foreign(81)], &[1 << 62, 1 << 62])] + } else { + vec![ + pay(&[foreign(81)], &[1 << 62]), + pay(&[foreign(82)], &[1 << 62]), + ] + }; + let fakes: Vec = parents + .iter() + .flat_map(|parent| { + (0..parent.output.len() as u32).map(|vout| OutPoint::new(parent.txid(), vout)) + }) + .collect(); + let fundings = [ + pay(&[foreign(85)], &[10_000]), + pay(&[foreign(86)], &[20_000]), + pay(&[foreign(87)], &[30_000]), + ]; + let [genuine, one, other] = + [0, 1, 2].map(|index| OutPoint::new(fundings[index].txid(), 0)); + let forged = [(one, 5_000), (other, 6_000)] + .map(|(own, value)| pay(&[fakes[0], fakes[1], genuine, own], &[value])); + + let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + for funding in parents.iter().chain(&fundings) { + for (vout, txout) in funding.output.iter().enumerate() { + let coin = OutPoint::new(funding.txid(), vout as u32); + account.utxos.insert( + coin, + Utxo::new(coin, txout.clone(), address.clone(), 0, false), + ); + } + } + let history = RecordedHistory { + transactions: parents + .iter() + .chain(&forged) + .map(|transaction| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }) + .collect(), + instant_locks: BTreeMap::new(), + }; + replay_recorded_history(&mut restored, &mut wallet, history).await; + assert!(!redelivery_credits(&restored, &mut wallet, &fundings[0], &genuine).await); + + let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &[one, other]).await; + + let mut swept = settled.swept_transactions.clone(); + swept.sort_unstable(); + let mut expected = forged.map(|spend| spend.txid()); + expected.sort_unstable(); + assert_eq!(swept, expected, "one parent: {one_parent}"); + assert!( + redelivery_credits(&restored, &mut wallet, &fundings[0], &genuine).await, + "one parent: {one_parent}: nothing spends the genuine coin any more" + ); + } + } + /// The wallet can name one claimant per reserved coin, so a coin shared /// by records unsafe by their own outputs is held for the one with the /// smaller txid. Accepted consequence: From c95c984ff497dc34bae6346c6639647ee2da854c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 18:48:41 +0000 Subject: [PATCH 24/84] fix(platform-wallet): claim the inputs of final asset locks on load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host may drop the inputs of an asset lock from the load projection on the lock's status alone: the SwiftData store marks them spent from `InstantSendLocked` on and withholds them at load, whatever context the funding transaction's own row holds. That row often never leaves the mempool context, and an asset-lock funding with no output paying the wallet marks nothing spent when it is replayed. The consumed coin was then credited again when the transaction that created it was delivered again. After the replay, the load now restores a spend claim for every input of a tracked asset lock at `InstantSendLocked`, `ChainLocked`, `Consumed` or `RecoveredFromChain`, the same locks the host withholds inputs for. From `InstantSendLocked` on the funding cannot be replaced, so the inputs are consumed for good. A lock that is only built or broadcast claims nothing. The claim names the funding transaction rather than no claimant. A store that keeps each coin's spender restores the same claim, so the two do not collide into a claim nothing releases, and upstream makes the claim permanent once the funding confirms. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../rs-platform-wallet/src/manager/load.rs | 155 +++++++++++++++++- .../src/wallet/asset_lock/sync/recovery.rs | 33 ++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 3758370915c..5e09d685c43 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -9,7 +9,7 @@ use crate::changeset::{ use crate::error::PlatformWalletError; use crate::manager::history_replay::replay_recorded_history; use crate::wallet::asset_lock::sync::recovery::{ - recorded_input_conflicts, seed_observed_input_conflicts, + final_lock_input_claims, recorded_input_conflicts, seed_observed_input_conflicts, }; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; @@ -247,6 +247,10 @@ impl PlatformWalletManager

{ for (_account_index, account_locks) in unused_asset_locks { tracked_asset_locks.extend(account_locks); } + // A host may drop a final lock's inputs from the projection while + // the funding's own record guards nothing. + wallet_info + .restore_spent_outpoints(&final_lock_input_claims(tracked_asset_locks.values())); // Canonical id recomputed from the wallet's own key material. // Computed up front — before `insert_wallet` consumes `wallet` — @@ -1410,6 +1414,155 @@ mod idempotent_load_tests { } } + /// A host drops the inputs of an asset lock from the projection once the + /// lock is InstantSend-locked or beyond, while the funding's own record + /// can still read as unconfirmed and, paying nothing to the wallet, + /// guards nothing. Such an input is consumed for good and must not be + /// credited when the transaction that created it is delivered again. A + /// lock that is only built or broadcast claims nothing, and a funding + /// with change keeps its change. + #[tokio::test] + async fn should_not_credit_inputs_of_a_final_asset_lock_again() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + for (status, with_change, consumed) in [ + (AssetLockStatus::Built, false, false), + (AssetLockStatus::Broadcast, false, false), + (AssetLockStatus::InstantSendLocked, false, true), + (AssetLockStatus::ChainLocked, false, true), + (AssetLockStatus::Consumed, false, true), + (AssetLockStatus::RecoveredFromChain, false, true), + (AssetLockStatus::InstantSendLocked, true, true), + ] { + let case = format!("{status:?}, change: {with_change}"); + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let coin = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + // An asset-lock funding: the value is burnt into the payload. + let mut asset_lock = dashcore::Transaction { + version: 3, + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 1, + credit_outputs: spend_to(coin, 74_000).output, + }, + )), + ..spend_to(coin, 74_000) + }; + asset_lock.output[0].script_pubkey = dashcore::ScriptBuf::new_op_return(&[]); + if with_change { + asset_lock.output.push(dashcore::TxOut { + value: 20_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }); + } + let change = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 1, + }; + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + if with_change { + projection + .first_bip44_managed_account_mut() + .expect("bip44 account") + .utxos + .insert( + change, + Utxo::new( + change, + asset_lock.output[1].clone(), + ctx.receive_address.clone(), + 0, + false, + ), + ); + } + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + // The funding's row never left the mempool context. + transactions: vec![StoredTransaction { + txid: asset_lock.txid(), + transaction: asset_lock.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + out_point, + transaction: asset_lock.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status, + proof: None, + }, + )] + .into_iter() + .collect(), + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + let holds = |core: &ManagedWalletInfo, outpoint: dashcore::OutPoint| { + core.utxos().iter().any(|utxo| utxo.outpoint == outpoint) + }; + assert_eq!(holds(&core, change), with_change, "{case}"); + + core.check_core_transaction(&funding, in_block(90), &mut wallet, true, true) + .await; + assert_eq!( + holds(&core, coin), + !consumed, + "{case}: only a final lock keeps its input from being credited" + ); + + // The funding's own confirmation changes neither. + core.check_core_transaction(&asset_lock, in_block(95), &mut wallet, true, true) + .await; + assert!(!holds(&core, coin), "{case}: the lock spends its input"); + assert_eq!(holds(&core, change), with_change, "{case}"); + } + } + /// A stored record whose amounts overflow the checker's signed arithmetic /// must not stop the wallet from loading: it is left out, and the coin it /// spends stays reserved. diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 442b47824f3..5afe6720e28 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -493,6 +493,39 @@ pub(crate) fn seed_observed_input_conflicts(info: &PlatformWalletInfo) { } } +/// Spend claims for the inputs of every asset lock whose funding is final. +/// +/// A host may drop those inputs from the load projection on the lock's status +/// alone, while the funding's own record still reads as unconfirmed and, with +/// no output paying the wallet, marks nothing spent. From `InstantSendLocked` +/// on the funding cannot be replaced: its inputs are consumed for good, and a +/// later delivery of the transaction that created one must not credit it. +/// +/// The claim names the funding transaction, as a store that keeps each coin's +/// spender does, so both restore the same claim. It is released only if that +/// transaction is swept or abandoned, and becomes permanent once it confirms. +pub(crate) fn final_lock_input_claims<'a>( + locks: impl IntoIterator, +) -> Vec<(OutPoint, Option)> { + locks + .into_iter() + .filter(|lock| match lock.status { + AssetLockStatus::Built | AssetLockStatus::Broadcast => false, + AssetLockStatus::InstantSendLocked + | AssetLockStatus::ChainLocked + | AssetLockStatus::Consumed + | AssetLockStatus::RecoveredFromChain => true, + }) + .flat_map(|lock| { + let funding = lock.transaction.txid(); + lock.transaction + .input + .iter() + .map(move |input| (input.previous_output, Some(funding))) + }) + .collect() +} + /// Confirmed spends of pending asset-lock inputs, read from the stored history. /// /// The load replay keeps two kinds of spender out of the wallet history that From 712ff9df184cf88af79de392b741b3098eed421c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:18:50 +0000 Subject: [PATCH 25/84] fix(platform-wallet): settle stored conflicts with a tracked asset lock's InstantSend lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load replay settles stored conflicts by each record's context and by the locks the history carries. A store without lock rows hands over neither for an asset-lock funding: its record reads as unconfirmed, while the tracked lock's Instant proof holds the real InstantSend lock. The replay never saw that proof, so it counted a locked funding as unconfirmed: - an unconfirmed competitor for one of its inputs was not swept, and its change stayed in the balance; - a competitor in a plain block retired the locked funding and dropped its change, although only a ChainLock overrules an InstantSend lock. Before the replay, the load now adds to the history's locks the lock of every tracked asset lock that holds an Instant proof, when the lock, the proof's transaction and the tracked funding name one transaction the history holds. A lock the store supplied itself is kept. The replay's existing upgrade and conflict settlement do the rest, and the wallet holds the lock again for the session. The lock's signature is not verified at load, only the identities, as with the locks a store keeps for its own records. A chain proof carries a height and no block, so a funding stored as unconfirmed under a `ChainLocked` lock is not upgraded and still falls to a competitor in a plain block. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../rs-platform-wallet/src/manager/load.rs | 190 +++++++++++++++++- .../src/wallet/asset_lock/sync/recovery.rs | 129 +++++++++++- 2 files changed, 316 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 5e09d685c43..ec86394a6d1 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -9,7 +9,8 @@ use crate::changeset::{ use crate::error::PlatformWalletError; use crate::manager::history_replay::replay_recorded_history; use crate::wallet::asset_lock::sync::recovery::{ - final_lock_input_claims, recorded_input_conflicts, seed_observed_input_conflicts, + add_tracked_instant_locks, final_lock_input_claims, recorded_input_conflicts, + seed_observed_input_conflicts, }; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; @@ -151,7 +152,7 @@ impl PlatformWalletManager

{ identity_manager, unused_asset_locks, mut unconfirmed_outgoing_txs, - recorded_history, + mut recorded_history, } = wallet_state; // Read before the replay consumes the history: its finalization @@ -161,6 +162,11 @@ impl PlatformWalletManager

{ &recorded_history, ); + add_tracked_instant_locks( + &mut recorded_history, + unused_asset_locks.values().flat_map(BTreeMap::values), + ); + // 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 @@ -1563,6 +1569,186 @@ mod idempotent_load_tests { } } + /// A tracked asset lock can hold the InstantSend lock of its funding while + /// the funding's stored record still reads as unconfirmed and the history + /// carries no lock. The load must settle stored conflicts with that + /// lock: an unconfirmed competitor and its change do not survive, and a + /// competitor in a plain block does not retire the locked funding, which + /// only a ChainLock overrules. + /// + /// A lock that names another transaction settles nothing, and the record + /// stays as stored. Neither does a chain proof: it carries a height and + /// no block, so a funding stored as unconfirmed cannot be placed in its + /// chain-locked block and still falls to a competitor in a plain block. + #[tokio::test] + async fn should_settle_stored_conflicts_with_a_tracked_instant_lock() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::hashes::Hash; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::transaction_checking::BlockInfo; + use key_wallet::Utxo; + + enum Proof { + Instant, + InstantOfAnotherTransaction, + Chain, + } + let mut outcomes = Vec::new(); + for (proof, competitor_in_block) in [ + (Proof::Instant, false), + (Proof::Instant, true), + (Proof::InstantOfAnotherTransaction, true), + (Proof::Chain, true), + ] { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let coin = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let change_to_wallet = |value| dashcore::TxOut { + value, + script_pubkey: ctx.receive_address.script_pubkey(), + }; + let mut asset_lock = dashcore::Transaction { + version: 3, + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 1, + credit_outputs: spend_to(coin, 74_000).output, + }, + )), + ..spend_to(coin, 74_000) + }; + asset_lock.output[0].script_pubkey = dashcore::ScriptBuf::new_op_return(&[]); + asset_lock.output.push(change_to_wallet(20_000)); + let mut competitor = spend_to(coin, 60_000); + competitor.output.push(change_to_wallet(30_000)); + let change_of = |transaction: &dashcore::Transaction| dashcore::OutPoint { + txid: transaction.txid(), + vout: 1, + }; + + // The persisted projection: the contested coin is gone, both + // change coins are on file. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + for transaction in [&asset_lock, &competitor] { + projection + .first_bip44_managed_account_mut() + .expect("bip44 account") + .utxos + .insert( + change_of(transaction), + Utxo::new( + change_of(transaction), + transaction.output[1].clone(), + ctx.receive_address.clone(), + 0, + false, + ), + ); + } + let competitor_context = if competitor_in_block { + TransactionContext::InBlock(BlockInfo::new( + 120, + dashcore::BlockHash::from_byte_array([120; 32]), + 120, + )) + } else { + TransactionContext::Mempool + }; + let stored = |transaction: &dashcore::Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let instant = |txid| { + let lock = InstantLock { + txid, + inputs: vec![coin], + ..Default::default() + }; + AssetLockProof::Instant(InstantAssetLockProof::new(lock, asset_lock.clone(), 0)) + }; + let (status, proof) = match proof { + Proof::Instant => ( + AssetLockStatus::InstantSendLocked, + instant(asset_lock.txid()), + ), + Proof::InstantOfAnotherTransaction => ( + AssetLockStatus::InstantSendLocked, + instant(competitor.txid()), + ), + Proof::Chain => ( + AssetLockStatus::ChainLocked, + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 150, + out_point, + }), + ), + }; + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + transactions: vec![ + stored(&asset_lock, TransactionContext::Mempool), + stored(&competitor, competitor_context), + ], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + out_point, + transaction: asset_lock.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status, + proof: Some(proof), + }, + )] + .into_iter() + .collect(), + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + + let wm = manager.wallet_manager.read().await; + let core = &wm + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet; + let holds = |outpoint: dashcore::OutPoint| { + core.utxos().iter().any(|utxo| utxo.outpoint == outpoint) + }; + outcomes.push((holds(change_of(&asset_lock)), holds(change_of(&competitor)))); + } + + // (the funding's change stands, the competitor's change stands) + assert_eq!( + outcomes, + [(true, false), (true, true), (false, true), (false, true)] + ); + } + /// A stored record whose amounts overflow the checker's signed arithmetic /// must not stop the wallet from loading: it is left out, and the coin it /// spends stays reserved. diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 5afe6720e28..3ee20f7191f 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -12,7 +12,7 @@ use std::time::Duration; use dashcore::Address as DashAddress; use dashcore::{OutPoint, Txid}; -use dpp::prelude::CoreBlockHeight; +use dpp::prelude::{AssetLockProof, CoreBlockHeight}; use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -526,6 +526,38 @@ pub(crate) fn final_lock_input_claims<'a>( .collect() } +/// Hand `history` the InstantSend lock each tracked asset lock holds for its +/// funding, so the replay settles that funding's stored conflicts by it. +/// +/// A store may keep the funding's record as unconfirmed and no lock beside it, +/// while the lock's proof carries the real one. A lock is taken only when it, +/// the proof's transaction and the tracked funding name one transaction the +/// history holds, and a lock the store supplied itself stands. Its signature +/// is not verified at load, like the locks a store keeps for its own records. +pub(crate) fn add_tracked_instant_locks<'a>( + history: &mut RecordedHistory, + locks: impl IntoIterator, +) { + for lock in locks { + let Some(AssetLockProof::Instant(proof)) = &lock.proof else { + continue; + }; + let funding = lock.transaction.txid(); + if proof.instant_lock.txid == funding + && proof.transaction.txid() == funding + && history + .transactions + .iter() + .any(|stored| stored.txid == funding) + { + history + .instant_locks + .entry(funding) + .or_insert_with(|| proof.instant_lock.clone()); + } + } +} + /// Confirmed spends of pending asset-lock inputs, read from the stored history. /// /// The load replay keeps two kinds of spender out of the wallet history that @@ -3895,6 +3927,101 @@ mod tests { ); } + /// Only a lock that names the tracked funding, backed by a proof of that + /// same transaction and by a stored record of it, reaches the history. A + /// lock the store supplied itself is kept, and a chain proof holds no + /// lock to hand over. + #[test] + fn should_hand_history_only_matching_tracked_instant_locks() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let instant_lock = |txid, input| InstantLock { + txid, + inputs: vec![input], + ..Default::default() + }; + let instant = |lock: InstantLock, transaction: &Transaction| { + AssetLockProof::Instant(InstantAssetLockProof::new(lock, transaction.clone(), 0)) + }; + let tracked = |funding: &Transaction, proof| TrackedAssetLock { + out_point: OutPoint::new(funding.txid(), 0), + transaction: funding.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status: AssetLockStatus::InstantSendLocked, + proof: Some(proof), + }; + let stored = |transaction: &Transaction| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + + let [matching, foreign_lock, foreign_proof, unrecorded, supplied, chain_proven, other] = + [1, 2, 3, 4, 5, 6, 9].map(|byte| transaction_spending(outpoint(byte))); + let kept = instant_lock(supplied.txid(), outpoint(50)); + let locks = [ + tracked( + &matching, + instant(instant_lock(matching.txid(), outpoint(1)), &matching), + ), + tracked( + &foreign_lock, + instant(instant_lock(other.txid(), outpoint(2)), &foreign_lock), + ), + tracked( + &foreign_proof, + instant(instant_lock(foreign_proof.txid(), outpoint(3)), &other), + ), + tracked( + &unrecorded, + instant(instant_lock(unrecorded.txid(), outpoint(4)), &unrecorded), + ), + tracked( + &supplied, + instant(instant_lock(supplied.txid(), outpoint(5)), &supplied), + ), + tracked( + &chain_proven, + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 150, + out_point: OutPoint::new(chain_proven.txid(), 0), + }), + ), + ]; + let mut history = RecordedHistory { + transactions: [ + &matching, + &foreign_lock, + &foreign_proof, + &supplied, + &chain_proven, + ] + .map(stored) + .to_vec(), + instant_locks: BTreeMap::from([(supplied.txid(), kept.clone())]), + }; + + super::add_tracked_instant_locks(&mut history, &locks); + + assert_eq!( + history.instant_locks, + BTreeMap::from([ + (matching.txid(), instant_lock(matching.txid(), outpoint(1))), + (supplied.txid(), kept), + ]) + ); + } + /// Promotion is eviction: once a chainlock buries the spender's block, /// `apply_chain_lock` removes its record from history. The screen's /// session memory must carry the conflict across that disappearance From 8cf38efb882639d43673de25e3edd316c08441e1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:20:46 +0000 Subject: [PATCH 26/84] perf(platform-wallet): look tracked lock fundings up in the history's txid set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `add_tracked_instant_locks` searched the whole history vector for each tracked lock that holds a matching Instant proof. Retained consumed lock rows count too, so the work grew with locks times history on every load. The load already builds the set of history txids for the replay. It is now built before the hand-over and passed in, so each lock costs one lookup. Behaviour is unchanged. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../rs-platform-wallet/src/manager/load.rs | 10 +++++----- .../src/wallet/asset_lock/sync/recovery.rs | 20 +++++++++++-------- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index ec86394a6d1..0f693ce4b35 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -162,11 +162,6 @@ impl PlatformWalletManager

{ &recorded_history, ); - add_tracked_instant_locks( - &mut recorded_history, - unused_asset_locks.values().flat_map(BTreeMap::values), - ); - // 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 @@ -176,6 +171,11 @@ impl PlatformWalletManager

{ .iter() .map(|stored| stored.txid) .collect(); + add_tracked_instant_locks( + &mut recorded_history, + &history_txids, + unused_asset_locks.values().flat_map(BTreeMap::values), + ); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); let outcome = diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 3ee20f7191f..e9b80b04df0 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -5,7 +5,7 @@ //! and re-deriving private keys. use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -532,10 +532,12 @@ pub(crate) fn final_lock_input_claims<'a>( /// A store may keep the funding's record as unconfirmed and no lock beside it, /// while the lock's proof carries the real one. A lock is taken only when it, /// the proof's transaction and the tracked funding name one transaction the -/// history holds, and a lock the store supplied itself stands. Its signature -/// is not verified at load, like the locks a store keeps for its own records. +/// history holds (`recorded`, its txids), and a lock the store supplied itself +/// stands. Its signature is not verified at load, like the locks a store keeps +/// for its own records. pub(crate) fn add_tracked_instant_locks<'a>( history: &mut RecordedHistory, + recorded: &HashSet, locks: impl IntoIterator, ) { for lock in locks { @@ -545,10 +547,7 @@ pub(crate) fn add_tracked_instant_locks<'a>( let funding = lock.transaction.txid(); if proof.instant_lock.txid == funding && proof.transaction.txid() == funding - && history - .transactions - .iter() - .any(|stored| stored.txid == funding) + && recorded.contains(&funding) { history .instant_locks @@ -4011,7 +4010,12 @@ mod tests { instant_locks: BTreeMap::from([(supplied.txid(), kept.clone())]), }; - super::add_tracked_instant_locks(&mut history, &locks); + let recorded = history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + super::add_tracked_instant_locks(&mut history, &recorded, &locks); assert_eq!( history.instant_locks, From 33f959fdf8fcba3b863e7410b5db0a804f629772 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:23:33 +0000 Subject: [PATCH 27/84] fix(platform-wallet): do not claim the inputs of a tracked funding the replay defeated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After the replay, the load restored a spend claim for every input of a tracked asset lock at a final status. It did so for a funding the replay had just defeated as well. Only a ChainLock overrules an InstantSend lock: when a stored chain-locked spend takes one input of a locked funding, the funding is retired and its other inputs are unspent again. The claim named the retired funding, which has no record left, so no later sweep released it and the coins stayed unavailable on every load. The replay now reports the records a stored conflict winner defeated, apart from the records it only left out for unsafe amounts, and the load skips the claims of a lock whose funding is among them. A funding left out for unsafe amounts keeps its claim: nothing has proven its inputs free. The defeated lock itself stays tracked. Live settlement removes such a lock and persists the removal; the load does not write to the store. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 11 + .../rs-platform-wallet/src/manager/load.rs | 222 +++++++++++++++++- 2 files changed, 230 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index ee55edfdded..a05bdc0fdc0 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -40,6 +40,10 @@ pub struct HistoryReplay { /// settled conflict with their descendants, and the records with unsafe /// amounts. pub retired: HashSet, + /// The retired records a stored conflict winner defeated, with their + /// descendants. A record only left out for unsafe amounts is not among + /// them: nothing has proven the coins it spends free. + pub defeated: HashSet, } impl HistoryReplay { @@ -357,10 +361,12 @@ pub async fn replay_recorded_history( claims.extend(irreversible_spend_claims(&replay)); wallet_info.restore_spent_outpoints(&claims); wallet_info.update_balance(); + let defeated = swept.clone(); swept.extend(set_aside.iter().map(|stored| stored.txid)); HistoryReplay { replayed: replay.len(), retired: swept, + defeated, } } @@ -2312,6 +2318,11 @@ mod tests { outcome.retired.contains(&unsafe_send.txid()), "a record with unsafe amounts must not reach the checker by re-dispatch either" ); + assert_eq!( + outcome.defeated, + HashSet::from([loser.txid(), descendant.txid()]), + "a record left out for unsafe amounts lost no conflict" + ); let sends = vec![ loser.clone(), descendant.clone(), diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 0f693ce4b35..9073f8c16b9 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -176,6 +176,7 @@ impl PlatformWalletManager

{ &history_txids, unused_asset_locks.values().flat_map(BTreeMap::values), ); + let mut defeated = HashSet::new(); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); let outcome = @@ -189,6 +190,7 @@ impl PlatformWalletManager

{ ); // A send the replay retired is not dispatched again below. unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); + defeated = outcome.defeated; } // Replay the sends the host still holds as unconfirmed, before @@ -254,9 +256,13 @@ impl PlatformWalletManager

{ tracked_asset_locks.extend(account_locks); } // A host may drop a final lock's inputs from the projection while - // the funding's own record guards nothing. - wallet_info - .restore_spent_outpoints(&final_lock_input_claims(tracked_asset_locks.values())); + // the funding's own record guards nothing. A funding the replay + // defeated spends nothing any more, whatever its lock's status. + wallet_info.restore_spent_outpoints(&final_lock_input_claims( + tracked_asset_locks + .values() + .filter(|lock| !defeated.contains(&lock.transaction.txid())), + )); // Canonical id recomputed from the wallet's own key material. // Computed up front — before `insert_wallet` consumes `wallet` — @@ -1749,6 +1755,216 @@ mod idempotent_load_tests { ); } + /// Only a ChainLock overrules an InstantSend lock. When a stored + /// chain-locked spend defeats a tracked lock's funding, the funding's + /// other inputs are unspent again: the load must not claim them for a + /// transaction that no longer stands. + #[tokio::test] + async fn should_not_claim_inputs_of_a_tracked_funding_the_replay_defeated() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dashcore::hashes::Hash; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::BlockInfo; + + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let contested = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let mut other_funding = spend_to( + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7; 32]), + vout: 0, + }, + 50_000, + ); + other_funding.output[0].script_pubkey = ctx.receive_address.script_pubkey(); + let other = dashcore::OutPoint { + txid: other_funding.txid(), + vout: 0, + }; + let mut asset_lock = spend_to(contested, 120_000); + asset_lock.input.push(dashcore::TxIn { + previous_output: other, + ..asset_lock.input[0].clone() + }); + let winner = spend_to(contested, 60_000); + let stored = |transaction: &dashcore::Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + // The persisted projection: the winner's block is chain-locked, and + // the host withholds both inputs of the lock. + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: 150, + block_hash: dashcore::BlockHash::from_byte_array([150; 32]), + signature: [0; 96].into(), + }); + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let instant_lock = InstantLock { + txid: asset_lock.txid(), + inputs: vec![contested, other], + ..Default::default() + }; + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: projection, + history: RecordedHistory { + transactions: vec![ + stored(&asset_lock, TransactionContext::Mempool), + stored(&winner, in_block(100)), + ], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + out_point, + transaction: asset_lock.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 120_000, + status: AssetLockStatus::InstantSendLocked, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + asset_lock.clone(), + 0, + ))), + }, + )] + .into_iter() + .collect(), + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + + for (transaction, height) in [(&funding, 90), (&other_funding, 91)] { + core.check_core_transaction(transaction, in_block(height), &mut wallet, true, true) + .await; + } + + let holds = |outpoint| core.utxos().iter().any(|utxo| utxo.outpoint == outpoint); + assert!(!holds(contested), "the winner spent the contested coin"); + assert!( + holds(other), + "the coin only the defeated funding spent is unspent again" + ); + } + + /// A funding left out of the replay for unsafe amounts was not defeated: + /// nothing proves its inputs free, so a final lock still claims them. + #[tokio::test] + async fn should_keep_the_claim_of_a_final_lock_whose_funding_was_only_left_out() { + use dashcore::hashes::Hash; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; + use key_wallet::transaction_checking::BlockInfo; + + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let coin = dashcore::OutPoint { + txid: funding.txid(), + vout: 0, + }; + let asset_lock = spend_to(coin, 1 << 63); + let out_point = dashcore::OutPoint { + txid: asset_lock.txid(), + vout: 0, + }; + let mut wallet = ctx.wallet.clone(); + let manager = make_history_manager(RecordedHistoryPersister { + managed: ManagedWalletInfo::from_wallet(&ctx.wallet, 0), + wallet: ctx.wallet, + history: RecordedHistory { + transactions: vec![StoredTransaction { + txid: asset_lock.txid(), + transaction: asset_lock.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::new(), + }, + asset_locks: [( + out_point, + TrackedAssetLock { + out_point, + transaction: asset_lock, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status: AssetLockStatus::InstantSendLocked, + proof: None, + }, + )] + .into_iter() + .collect(), + }); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let mut core = manager + .wallet_manager + .read() + .await + .get_wallet_info(&wallet_id) + .expect("the loaded wallet must be registered") + .core_wallet + .clone(); + + let block = BlockInfo::new(90, dashcore::BlockHash::from_byte_array([90; 32]), 90); + core.check_core_transaction( + &funding, + TransactionContext::InBlock(block), + &mut wallet, + true, + true, + ) + .await; + + assert!( + !core.utxos().iter().any(|utxo| utxo.outpoint == coin), + "the lock's input stays claimed" + ); + } + /// A stored record whose amounts overflow the checker's signed arithmetic /// must not stop the wallet from loading: it is left out, and the coin it /// spends stays reserved. From 9b7fb1541796f118af753cf2a8d002342d92de51 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:28:09 +0000 Subject: [PATCH 28/84] fix(platform-wallet): keep a chain-final lock's funding out of the history replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tracked asset lock can be chain-final while its funding's stored record still reads as unconfirmed. Its chain proof carries a height and no block, so the record cannot be placed in its block. Replayed as unconfirmed, the funding fell to a competitor stored in a plain block: the replay retired it and dropped its change from the projection. The base load restores the projection as stored and kept that change, so the loss came with the replay. Before the replay, the load now takes out of the history the unconfirmed record of every funding a tracked lock holds as `ChainLocked`, `Consumed` or `RecoveredFromChain`, unless a lock in the history settles it. The replay never sees such a funding: its coins stay as stored, as on the base, and the load still claims its inputs. The funding gets no wallet record from the replay. The rule the load follows for tracked asset locks is now complete. What a tracked lock proves about its funding outranks the funding's stored context, and the replay's verdict outranks the lock's status. A lock with an Instant proof makes its funding replay as InstantSend-locked; a chain-final lock keeps its funding out of the replay; a funding the replay still defeats gets no claim. One table-driven test asserts every combination of lock evidence and competitor. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../rs-platform-wallet/src/manager/load.rs | 209 +++++++++++++----- .../src/wallet/asset_lock/sync/recovery.rs | 113 ++++++++++ 2 files changed, 263 insertions(+), 59 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 9073f8c16b9..82a3bfd2e3e 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -10,7 +10,7 @@ use crate::error::PlatformWalletError; use crate::manager::history_replay::replay_recorded_history; use crate::wallet::asset_lock::sync::recovery::{ add_tracked_instant_locks, final_lock_input_claims, recorded_input_conflicts, - seed_observed_input_conflicts, + seed_observed_input_conflicts, withhold_chain_final_fundings, }; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; @@ -176,6 +176,10 @@ impl PlatformWalletManager

{ &history_txids, unused_asset_locks.values().flat_map(BTreeMap::values), ); + withhold_chain_final_fundings( + &mut recorded_history, + unused_asset_locks.values().flat_map(BTreeMap::values), + ); let mut defeated = HashSet::new(); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); @@ -1575,19 +1579,24 @@ mod idempotent_load_tests { } } - /// A tracked asset lock can hold the InstantSend lock of its funding while - /// the funding's stored record still reads as unconfirmed and the history - /// carries no lock. The load must settle stored conflicts with that - /// lock: an unconfirmed competitor and its change do not survive, and a - /// competitor in a plain block does not retire the locked funding, which - /// only a ChainLock overrules. + /// What a tracked asset lock proves about its funding outranks the + /// funding's stored context, and the replay's verdict outranks the lock's + /// status. Each row restores a funding stored as unconfirmed, with change, + /// against one competitor for its contested input: /// - /// A lock that names another transaction settles nothing, and the record - /// stays as stored. Neither does a chain proof: it carries a height and - /// no block, so a funding stored as unconfirmed cannot be placed in its - /// chain-locked block and still falls to a competitor in a plain block. + /// - an Instant proof makes the funding replay as InstantSend-locked: it + /// sweeps an unconfirmed competitor, stands beside a plain block, and + /// falls only to a chain-locked spend, after which its other input is + /// free again; + /// - a chain-final lock proves a height and no block, so the funding is + /// kept out of the replay: its coins stay as stored whatever the + /// competitor, and its inputs stay claimed; + /// - a chain-final lock whose funding a lock in the history settles + /// replays like any locked funding; + /// - a lock that names another transaction settles nothing, and the + /// record replays as stored. #[tokio::test] - async fn should_settle_stored_conflicts_with_a_tracked_instant_lock() { + async fn should_settle_stored_conflicts_by_what_a_tracked_lock_proves() { use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; use dashcore::blockdata::transaction::special_transaction::TransactionPayload; use dashcore::ephemerealdata::instant_lock::InstantLock; @@ -1595,55 +1604,130 @@ mod idempotent_load_tests { use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::prelude::AssetLockProof; + use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; use key_wallet::transaction_checking::BlockInfo; use key_wallet::Utxo; - enum Proof { + #[derive(Debug, Clone, Copy)] + enum Evidence { Instant, + ChainFinal, + ChainFinalWithInstant, InstantOfAnotherTransaction, - Chain, } - let mut outcomes = Vec::new(); - for (proof, competitor_in_block) in [ - (Proof::Instant, false), - (Proof::Instant, true), - (Proof::InstantOfAnotherTransaction, true), - (Proof::Chain, true), + #[derive(Debug, Clone, Copy)] + enum Competitor { + Unconfirmed, + PlainBlock, + ChainLocked, + } + let in_block = |height| { + TransactionContext::InBlock(BlockInfo::new( + height, + dashcore::BlockHash::from_byte_array([height as u8; 32]), + height, + )) + }; + // (the funding's change stands, the competitor's change stands, the + // funding's other input stays guarded) + for (evidence, competing, expected) in [ + ( + Evidence::Instant, + Competitor::Unconfirmed, + (true, false, true), + ), + ( + Evidence::Instant, + Competitor::PlainBlock, + (true, true, true), + ), + ( + Evidence::Instant, + Competitor::ChainLocked, + (false, true, false), + ), + ( + Evidence::ChainFinal, + Competitor::Unconfirmed, + (true, true, true), + ), + ( + Evidence::ChainFinal, + Competitor::PlainBlock, + (true, true, true), + ), + ( + Evidence::ChainFinal, + Competitor::ChainLocked, + (true, true, true), + ), + ( + Evidence::ChainFinalWithInstant, + Competitor::Unconfirmed, + (true, false, true), + ), + ( + Evidence::InstantOfAnotherTransaction, + Competitor::PlainBlock, + (false, true, false), + ), ] { let (ctx, funding) = TestWalletContext::new_random() .with_mempool_funding(100_000) .await; let wallet_id = ctx.wallet.compute_wallet_id(); - let coin = dashcore::OutPoint { + let contested = dashcore::OutPoint { txid: funding.txid(), vout: 0, }; - let change_to_wallet = |value| dashcore::TxOut { + let to_wallet = |value| dashcore::TxOut { value, script_pubkey: ctx.receive_address.script_pubkey(), }; + let mut other_funding = spend_to( + dashcore::OutPoint { + txid: dashcore::Txid::from_byte_array([7; 32]), + vout: 0, + }, + 50_000, + ); + other_funding.output[0] = to_wallet(50_000); + let other = dashcore::OutPoint { + txid: other_funding.txid(), + vout: 0, + }; let mut asset_lock = dashcore::Transaction { version: 3, special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( AssetLockPayload { version: 1, - credit_outputs: spend_to(coin, 74_000).output, + credit_outputs: spend_to(contested, 120_000).output, }, )), - ..spend_to(coin, 74_000) + ..spend_to(contested, 120_000) }; + asset_lock.input.push(dashcore::TxIn { + previous_output: other, + ..asset_lock.input[0].clone() + }); asset_lock.output[0].script_pubkey = dashcore::ScriptBuf::new_op_return(&[]); - asset_lock.output.push(change_to_wallet(20_000)); - let mut competitor = spend_to(coin, 60_000); - competitor.output.push(change_to_wallet(30_000)); + asset_lock.output.push(to_wallet(20_000)); + let mut competitor = spend_to(contested, 60_000); + competitor.output.push(to_wallet(30_000)); let change_of = |transaction: &dashcore::Transaction| dashcore::OutPoint { txid: transaction.txid(), vout: 1, }; - // The persisted projection: the contested coin is gone, both - // change coins are on file. + // The persisted projection: both inputs of the lock are gone, + // both change coins are on file, the chain lock stands at 110. let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + projection.metadata.last_applied_chain_lock = + Some(dashcore::ephemerealdata::chain_lock::ChainLock { + block_height: 110, + block_hash: dashcore::BlockHash::from_byte_array([110; 32]), + signature: [0; 96].into(), + }); for transaction in [&asset_lock, &competitor] { projection .first_bip44_managed_account_mut() @@ -1660,15 +1744,6 @@ mod idempotent_load_tests { ), ); } - let competitor_context = if competitor_in_block { - TransactionContext::InBlock(BlockInfo::new( - 120, - dashcore::BlockHash::from_byte_array([120; 32]), - 120, - )) - } else { - TransactionContext::Mempool - }; let stored = |transaction: &dashcore::Transaction, context| StoredTransaction { txid: transaction.txid(), transaction: transaction.clone(), @@ -1684,28 +1759,37 @@ mod idempotent_load_tests { let instant = |txid| { let lock = InstantLock { txid, - inputs: vec![coin], + inputs: vec![contested, other], ..Default::default() }; AssetLockProof::Instant(InstantAssetLockProof::new(lock, asset_lock.clone(), 0)) }; - let (status, proof) = match proof { - Proof::Instant => ( + let (status, proof) = match evidence { + Evidence::Instant => ( AssetLockStatus::InstantSendLocked, instant(asset_lock.txid()), ), - Proof::InstantOfAnotherTransaction => ( - AssetLockStatus::InstantSendLocked, - instant(competitor.txid()), - ), - Proof::Chain => ( + Evidence::ChainFinal => ( AssetLockStatus::ChainLocked, AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: 150, + core_chain_locked_height: 105, out_point, }), ), + Evidence::ChainFinalWithInstant => { + (AssetLockStatus::Consumed, instant(asset_lock.txid())) + } + Evidence::InstantOfAnotherTransaction => ( + AssetLockStatus::InstantSendLocked, + instant(competitor.txid()), + ), }; + let competitor_context = match competing { + Competitor::Unconfirmed => TransactionContext::Mempool, + Competitor::PlainBlock => in_block(120), + Competitor::ChainLocked => in_block(100), + }; + let mut wallet = ctx.wallet.clone(); let manager = make_history_manager(RecordedHistoryPersister { wallet: ctx.wallet, managed: projection, @@ -1724,7 +1808,7 @@ mod idempotent_load_tests { account_index: 0, funding_type: AssetLockFundingType::IdentityRegistration, identity_index: 0, - amount: 74_000, + amount: 120_000, status, proof: Some(proof), }, @@ -1737,22 +1821,29 @@ mod idempotent_load_tests { .await .expect("the wallet must load"); - let wm = manager.wallet_manager.read().await; - let core = &wm + let mut core = manager + .wallet_manager + .read() + .await .get_wallet_info(&wallet_id) .expect("the loaded wallet must be registered") - .core_wallet; - let holds = |outpoint: dashcore::OutPoint| { + .core_wallet + .clone(); + let holds = |core: &ManagedWalletInfo, outpoint: dashcore::OutPoint| { core.utxos().iter().any(|utxo| utxo.outpoint == outpoint) }; - outcomes.push((holds(change_of(&asset_lock)), holds(change_of(&competitor)))); + let changes = ( + holds(&core, change_of(&asset_lock)), + holds(&core, change_of(&competitor)), + ); + core.check_core_transaction(&other_funding, in_block(91), &mut wallet, true, true) + .await; + assert_eq!( + (changes.0, changes.1, !holds(&core, other)), + expected, + "{evidence:?} against {competing:?}" + ); } - - // (the funding's change stands, the competitor's change stands) - assert_eq!( - outcomes, - [(true, false), (true, true), (false, true), (false, true)] - ); } /// Only a ChainLock overrules an InstantSend lock. When a stored diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index e9b80b04df0..bea73c89337 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -15,6 +15,7 @@ use dashcore::{OutPoint, Txid}; use dpp::prelude::{AssetLockProof, CoreBlockHeight}; use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::transaction_checking::TransactionContext; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; @@ -557,6 +558,40 @@ pub(crate) fn add_tracked_instant_locks<'a>( } } +/// Take out of `history` the unconfirmed record of every funding a tracked +/// asset lock holds as chain-final and no lock in `history` settles. +/// +/// What a tracked asset lock proves about its funding outranks the funding's +/// stored context, and the replay's verdict outranks the lock's status. A lock +/// with an Instant proof makes its funding replay as InstantSend-locked; a +/// chain-final lock proves a height and no block, so its funding stays out of +/// the replay, with its coins as stored and its inputs under +/// [`final_lock_input_claims`]. A funding the replay still defeats gets no +/// claim. +pub(crate) fn withhold_chain_final_fundings<'a>( + history: &mut RecordedHistory, + locks: impl IntoIterator, +) { + let chain_final: HashSet = locks + .into_iter() + .filter(|lock| match lock.status { + AssetLockStatus::Built + | AssetLockStatus::Broadcast + | AssetLockStatus::InstantSendLocked => false, + AssetLockStatus::ChainLocked + | AssetLockStatus::Consumed + | AssetLockStatus::RecoveredFromChain => true, + }) + .map(|lock| lock.transaction.txid()) + .collect(); + let locked = &history.instant_locks; + history.transactions.retain(|stored| { + !matches!(stored.context, TransactionContext::Mempool) + || locked.contains_key(&stored.txid) + || !chain_final.contains(&stored.txid) + }); +} + /// Confirmed spends of pending asset-lock inputs, read from the stored history. /// /// The load replay keeps two kinds of spender out of the wallet history that @@ -4026,6 +4061,84 @@ mod tests { ); } + /// Only the unconfirmed record of a funding that a tracked lock holds as + /// chain-final leaves the history, and only when no lock in the history + /// settles it. A pending or InstantSend-locked lock, a record already in + /// a block and a record no lock tracks all stay. + #[test] + fn should_withhold_only_unsettled_chain_final_fundings() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let tracked = |funding: &Transaction, status| TrackedAssetLock { + out_point: OutPoint::new(funding.txid(), 0), + transaction: funding.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status, + proof: None, + }; + let stored = |transaction: &Transaction, context| StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let [chain_locked, consumed, recovered, built, broadcast, instant, in_block, settled, untracked] = + [1, 2, 3, 4, 5, 6, 7, 8, 9].map(|byte| transaction_spending(outpoint(byte))); + let locks = [ + tracked(&chain_locked, AssetLockStatus::ChainLocked), + tracked(&consumed, AssetLockStatus::Consumed), + tracked(&recovered, AssetLockStatus::RecoveredFromChain), + tracked(&built, AssetLockStatus::Built), + tracked(&broadcast, AssetLockStatus::Broadcast), + tracked(&instant, AssetLockStatus::InstantSendLocked), + tracked(&in_block, AssetLockStatus::ChainLocked), + tracked(&settled, AssetLockStatus::Consumed), + ]; + let unconfirmed = [ + &chain_locked, + &consumed, + &recovered, + &built, + &broadcast, + &instant, + &settled, + &untracked, + ]; + let mut history = RecordedHistory { + transactions: unconfirmed + .map(|transaction| stored(transaction, TransactionContext::Mempool)) + .into_iter() + .chain([stored(&in_block, confirmed_at(101))]) + .collect(), + instant_locks: BTreeMap::from([( + settled.txid(), + InstantLock { + txid: settled.txid(), + ..Default::default() + }, + )]), + }; + + super::withhold_chain_final_fundings(&mut history, &locks); + + let kept: Vec = history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + assert_eq!( + kept, + [&built, &broadcast, &instant, &settled, &untracked, &in_block] + .map(|transaction| transaction.txid()) + ); + } + /// Promotion is eviction: once a chainlock buries the spender's block, /// `apply_chain_lock` removes its record from history. The screen's /// session memory must carry the conflict across that disappearance From be3a40ea8ed760d1fca21ac33cd87a09b6e4e243 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:20:32 +0000 Subject: [PATCH 29/84] fix(platform-wallet): withhold a chain-final funding unless the replay accepts its history lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withhold_chain_final_fundings` left a chain-final funding in the history whenever the history held any InstantSend lock filed under its txid. The replay accepts a lock only when the lock and the record's body both name the record's txid. A lock that named another transaction therefore kept the funding in the replay, the replay then rejected the lock, and the funding replayed as unconfirmed: a competitor stored in a plain block retired it and dropped its change. The helper now uses the replay's own check, `lock_matches_record`, so a lock keeps a funding in the replay only if the replay will settle the funding with it. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/manager/history_replay.rs | 2 +- .../rs-platform-wallet/src/manager/load.rs | 38 +++++++++++++++---- .../src/wallet/asset_lock/sync/recovery.rs | 35 ++++++++++------- 3 files changed, 53 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index a05bdc0fdc0..8593e74e6ea 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -818,7 +818,7 @@ fn stage_recorded_spent_inputs( /// 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 { +pub(crate) 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 { diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 82a3bfd2e3e..4515e4e7dbd 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1592,7 +1592,9 @@ mod idempotent_load_tests { /// kept out of the replay: its coins stay as stored whatever the /// competitor, and its inputs stay claimed; /// - a chain-final lock whose funding a lock in the history settles - /// replays like any locked funding; + /// replays like any locked funding, while a lock filed under the + /// funding that names another transaction settles nothing and leaves + /// it out of the replay; /// - a lock that names another transaction settles nothing, and the /// record replays as stored. #[tokio::test] @@ -1613,6 +1615,7 @@ mod idempotent_load_tests { Instant, ChainFinal, ChainFinalWithInstant, + ChainFinalWithForeignHistoryLock, InstantOfAnotherTransaction, } #[derive(Debug, Clone, Copy)] @@ -1628,6 +1631,7 @@ mod idempotent_load_tests { height, )) }; + let mut failures = Vec::new(); // (the funding's change stands, the competitor's change stands, the // funding's other input stays guarded) for (evidence, competing, expected) in [ @@ -1666,6 +1670,11 @@ mod idempotent_load_tests { Competitor::Unconfirmed, (true, false, true), ), + ( + Evidence::ChainFinalWithForeignHistoryLock, + Competitor::PlainBlock, + (true, true, true), + ), ( Evidence::InstantOfAnotherTransaction, Competitor::PlainBlock, @@ -1756,6 +1765,17 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; + // A lock filed under the funding that names another transaction. + let history_locks = match evidence { + Evidence::ChainFinalWithForeignHistoryLock => BTreeMap::from([( + asset_lock.txid(), + InstantLock { + txid: competitor.txid(), + ..Default::default() + }, + )]), + _ => BTreeMap::new(), + }; let instant = |txid| { let lock = InstantLock { txid, @@ -1769,7 +1789,7 @@ mod idempotent_load_tests { AssetLockStatus::InstantSendLocked, instant(asset_lock.txid()), ), - Evidence::ChainFinal => ( + Evidence::ChainFinal | Evidence::ChainFinalWithForeignHistoryLock => ( AssetLockStatus::ChainLocked, AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 105, @@ -1798,7 +1818,7 @@ mod idempotent_load_tests { stored(&asset_lock, TransactionContext::Mempool), stored(&competitor, competitor_context), ], - instant_locks: BTreeMap::new(), + instant_locks: history_locks, }, asset_locks: [( out_point, @@ -1838,12 +1858,14 @@ mod idempotent_load_tests { ); core.check_core_transaction(&other_funding, in_block(91), &mut wallet, true, true) .await; - assert_eq!( - (changes.0, changes.1, !holds(&core, other)), - expected, - "{evidence:?} against {competing:?}" - ); + let outcome = (changes.0, changes.1, !holds(&core, other)); + if outcome != expected { + failures.push(format!( + "{evidence:?} against {competing:?}: {outcome:?}, expected {expected:?}" + )); + } } + assert!(failures.is_empty(), "{failures:#?}"); } /// Only a ChainLock overrules an InstantSend lock. When a stored diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index bea73c89337..ace14614a1f 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -22,6 +22,7 @@ use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoIn use crate::changeset::changeset::AssetLockChangeSet; use crate::changeset::RecordedHistory; use crate::error::PlatformWalletError; +use crate::manager::history_replay::lock_matches_record; use crate::wallet::platform_wallet::{ObservedInputConflict, PlatformWalletInfo}; use super::super::manager::AssetLockManager; @@ -559,7 +560,8 @@ pub(crate) fn add_tracked_instant_locks<'a>( } /// Take out of `history` the unconfirmed record of every funding a tracked -/// asset lock holds as chain-final and no lock in `history` settles. +/// asset lock holds as chain-final and no lock in `history` settles; a lock +/// settles it only if the replay accepts it for that record. /// /// What a tracked asset lock proves about its funding outranks the funding's /// stored context, and the replay's verdict outranks the lock's status. A lock @@ -587,8 +589,10 @@ pub(crate) fn withhold_chain_final_fundings<'a>( let locked = &history.instant_locks; history.transactions.retain(|stored| { !matches!(stored.context, TransactionContext::Mempool) - || locked.contains_key(&stored.txid) || !chain_final.contains(&stored.txid) + || locked + .get(&stored.txid) + .is_some_and(|lock| lock_matches_record(lock, stored)) }); } @@ -4063,8 +4067,9 @@ mod tests { /// Only the unconfirmed record of a funding that a tracked lock holds as /// chain-final leaves the history, and only when no lock in the history - /// settles it. A pending or InstantSend-locked lock, a record already in - /// a block and a record no lock tracks all stay. + /// settles it; a lock filed under it that names another transaction + /// settles nothing. A pending or InstantSend-locked lock, a record already + /// in a block and a record no lock tracks all stay. #[test] fn should_withhold_only_unsettled_chain_final_fundings() { use dashcore::ephemerealdata::instant_lock::InstantLock; @@ -4088,8 +4093,8 @@ mod tests { stored_direction: None, owned_inputs: Vec::new(), }; - let [chain_locked, consumed, recovered, built, broadcast, instant, in_block, settled, untracked] = - [1, 2, 3, 4, 5, 6, 7, 8, 9].map(|byte| transaction_spending(outpoint(byte))); + let [chain_locked, consumed, recovered, built, broadcast, instant, in_block, settled, untracked, foreign] = + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(|byte| transaction_spending(outpoint(byte))); let locks = [ tracked(&chain_locked, AssetLockStatus::ChainLocked), tracked(&consumed, AssetLockStatus::Consumed), @@ -4099,6 +4104,7 @@ mod tests { tracked(&instant, AssetLockStatus::InstantSendLocked), tracked(&in_block, AssetLockStatus::ChainLocked), tracked(&settled, AssetLockStatus::Consumed), + tracked(&foreign, AssetLockStatus::ChainLocked), ]; let unconfirmed = [ &chain_locked, @@ -4109,6 +4115,7 @@ mod tests { &instant, &settled, &untracked, + &foreign, ]; let mut history = RecordedHistory { transactions: unconfirmed @@ -4116,13 +4123,15 @@ mod tests { .into_iter() .chain([stored(&in_block, confirmed_at(101))]) .collect(), - instant_locks: BTreeMap::from([( - settled.txid(), - InstantLock { - txid: settled.txid(), - ..Default::default() - }, - )]), + instant_locks: [&settled, &foreign] + .map(|filed_under| { + let lock = InstantLock { + txid: settled.txid(), + ..Default::default() + }; + (filed_under.txid(), lock) + }) + .into(), }; super::withhold_chain_final_fundings(&mut history, &locks); From f5e93324af012b87b8cd065ab5d76739a2134660 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 12 Oct 2026 22:20:32 +0000 Subject: [PATCH 30/84] docs(platform-wallet): state the raw-record contract for chain-final asset-lock fundings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load keeps the funding of a chain-final tracked asset lock out of the history replay, so its coins stay as stored. That covers the history a persister returns, not a raw unconfirmed record the persister attaches to the restored wallet for the same funding: the replay takes every attached unconfirmed record as unconfirmed, and a stored spend of one of its inputs retires it and its coins. No shipped persister attaches such a record. The contract is stated on `RecordedHistory`, where a persister author reads it, and pointed to from the helper. `should_settle_an_attached_unconfirmed_record_the_history_does_not_hold` pins what the replay does with an attached unconfirmed record. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/changeset/recorded_history.rs | 6 ++++ .../src/manager/history_replay.rs | 34 +++++++++++++++++++ .../src/wallet/asset_lock/sync/recovery.rs | 3 ++ 3 files changed, 43 insertions(+) diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index c53c7d7b728..c7ed9029ee7 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -23,6 +23,12 @@ use key_wallet::transaction_checking::TransactionContext; /// authoritative for which outputs are credited. /// /// Empty means "no history supplied": load keeps the projection as restored. +/// +/// A persister that returns history must not also attach to the restored +/// wallet a raw unconfirmed record for the funding of a tracked asset lock at +/// `ChainLocked`, `Consumed` or `RecoveredFromChain`. The replay takes every +/// attached unconfirmed record as unconfirmed, so a stored spend of one of +/// that funding's inputs retires it and its coins. #[derive(Debug, Clone, Default)] pub struct RecordedHistory { /// Stored transactions, in any order; the replay orders them. diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 8593e74e6ea..484ef73fb2e 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -1505,6 +1505,40 @@ mod tests { } } + /// The replay takes an unconfirmed record already attached to the wallet + /// as unconfirmed, whether the history holds it or not: a stored spend in + /// a block retires it. A persister must therefore not attach one for a + /// transaction it knows to be final. + #[tokio::test] + async fn should_settle_an_attached_unconfirmed_record_the_history_does_not_hold() { + let (mut wallet, mut restored, _, records, _) = confirmed_spend_fixture().await; + let mut attached = records[1].clone(); + attached.context = TransactionContext::Mempool; + let mut winner = attached.clone(); + winner.transaction.output[0].value -= 1; + winner.txid = winner.transaction.txid(); + winner.context = records[1].context.clone(); + restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .transactions_mut() + .insert(attached.txid, attached.clone()); + + let outcome = replay_recorded_history( + &mut restored, + &mut wallet, + history(vec![winner], BTreeMap::new()), + ) + .await; + + assert!(!restored.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&attached.txid)); + assert!(outcome.defeated.contains(&attached.txid)); + } + /// A held, unattributable child of a conflict loser must go with its /// parent even when the loser was attributable (its spent input staged /// from the stored owned inputs) and a sweep already removed it before diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index ace14614a1f..b44583d45fb 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -570,6 +570,9 @@ pub(crate) fn add_tracked_instant_locks<'a>( /// the replay, with its coins as stored and its inputs under /// [`final_lock_input_claims`]. A funding the replay still defeats gets no /// claim. +/// +/// Only the history is covered: a raw unconfirmed record a persister attached +/// for such a funding still enters the replay, see [`RecordedHistory`]. pub(crate) fn withhold_chain_final_fundings<'a>( history: &mut RecordedHistory, locks: impl IntoIterator, From 89f9eae341f3c875feddf28bea9b0b7993b03d07 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:40:34 +0000 Subject: [PATCH 31/84] fix(platform-wallet): keep the attached record of a chain-final funding out of the history replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The load withheld the history row of a funding that a tracked asset lock holds as chain-final, but a record of the same funding already attached to the restored wallet stayed where it was. The replay plans every attached unconfirmed record as unconfirmed, so a competitor stored in a plain block still retired the funding, dropped its change and cost it its input claims. A host attaches such a record when two tracked locks share one funding transaction: one credit output is chain-final while a sibling is still at `Broadcast`, and the host hands back the funding's record for the sibling. `withhold_chain_final_fundings` now returns the fundings it keeps out, whether the history held a row for them or not. The load detaches their unconfirmed records from the wallet before the replay and puts them back after it, so the replay sees such a funding in no form and nothing is dropped. A confirmed record stays attached: no stored competitor can take it. The paragraph on `RecordedHistory` that asked persisters not to attach such a record is replaced by a sentence saying what the load does. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../src/changeset/recorded_history.rs | 9 +- .../src/manager/history_replay.rs | 4 +- .../rs-platform-wallet/src/manager/load.rs | 101 +++++++++++-- .../src/wallet/asset_lock/sync/recovery.rs | 141 ++++++++++++++++-- 4 files changed, 220 insertions(+), 35 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index c7ed9029ee7..e0d254e5b63 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -24,11 +24,10 @@ use key_wallet::transaction_checking::TransactionContext; /// /// Empty means "no history supplied": load keeps the projection as restored. /// -/// A persister that returns history must not also attach to the restored -/// wallet a raw unconfirmed record for the funding of a tracked asset lock at -/// `ChainLocked`, `Consumed` or `RecoveredFromChain`. The replay takes every -/// attached unconfirmed record as unconfirmed, so a stored spend of one of -/// that funding's inputs retires it and its coins. +/// The load keeps the funding of a tracked asset lock at `ChainLocked`, +/// `Consumed` or `RecoveredFromChain` out of the replay when nothing here +/// places it in a block or locks it: its row here, and any unconfirmed record +/// already attached to the restored wallet, which it puts back afterwards. #[derive(Debug, Clone, Default)] pub struct RecordedHistory { /// Stored transactions, in any order; the replay orders them. diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 484ef73fb2e..36fea2463fb 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -1507,8 +1507,8 @@ mod tests { /// The replay takes an unconfirmed record already attached to the wallet /// as unconfirmed, whether the history holds it or not: a stored spend in - /// a block retires it. A persister must therefore not attach one for a - /// transaction it knows to be final. + /// a block retires it. The load therefore detaches the attached record of + /// a funding it keeps out of the replay. #[tokio::test] async fn should_settle_an_attached_unconfirmed_record_the_history_does_not_hold() { let (mut wallet, mut restored, _, records, _) = confirmed_spend_fixture().await; diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4515e4e7dbd..63bfa233bd0 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -9,8 +9,9 @@ use crate::changeset::{ use crate::error::PlatformWalletError; use crate::manager::history_replay::replay_recorded_history; use crate::wallet::asset_lock::sync::recovery::{ - add_tracked_instant_locks, final_lock_input_claims, recorded_input_conflicts, - seed_observed_input_conflicts, withhold_chain_final_fundings, + add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, + reattach_records, recorded_input_conflicts, seed_observed_input_conflicts, + withhold_chain_final_fundings, }; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; @@ -176,10 +177,13 @@ impl PlatformWalletManager

{ &history_txids, unused_asset_locks.values().flat_map(BTreeMap::values), ); - withhold_chain_final_fundings( + let withheld = withhold_chain_final_fundings( &mut recorded_history, unused_asset_locks.values().flat_map(BTreeMap::values), ); + // A record a host attached for a withheld funding sits the + // replay out as well, or the replay would plan it as unconfirmed. + let withheld_records = detach_unconfirmed_records(&mut wallet_info, &withheld); let mut defeated = HashSet::new(); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); @@ -196,6 +200,7 @@ impl PlatformWalletManager

{ unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); defeated = outcome.defeated; } + reattach_records(&mut wallet_info, withheld_records); // Replay the sends the host still holds as unconfirmed, before // anything reads the restored balance. @@ -1595,6 +1600,10 @@ mod idempotent_load_tests { /// replays like any locked funding, while a lock filed under the /// funding that names another transaction settles nothing and leaves /// it out of the replay; + /// - a chain-final lock keeps its funding out of the replay in every + /// form: a sibling credit output still at `Broadcast` has the host + /// attach the funding's record to the wallet, and that record sits the + /// replay out too and is back afterwards; /// - a lock that names another transaction settles nothing, and the /// record replays as stored. #[tokio::test] @@ -1606,8 +1615,13 @@ mod idempotent_load_tests { use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::prelude::AssetLockProof; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; - use key_wallet::transaction_checking::BlockInfo; + use key_wallet::transaction_checking::{BlockInfo, TransactionType}; use key_wallet::Utxo; #[derive(Debug, Clone, Copy)] @@ -1616,6 +1630,7 @@ mod idempotent_load_tests { ChainFinal, ChainFinalWithInstant, ChainFinalWithForeignHistoryLock, + ChainFinalWithPendingSibling, InstantOfAnotherTransaction, } #[derive(Debug, Clone, Copy)] @@ -1675,6 +1690,11 @@ mod idempotent_load_tests { Competitor::PlainBlock, (true, true, true), ), + ( + Evidence::ChainFinalWithPendingSibling, + Competitor::PlainBlock, + (true, true, true), + ), ( Evidence::InstantOfAnotherTransaction, Competitor::PlainBlock, @@ -1720,6 +1740,17 @@ mod idempotent_load_tests { ..asset_lock.input[0].clone() }); asset_lock.output[0].script_pubkey = dashcore::ScriptBuf::new_op_return(&[]); + let pending_sibling = matches!(evidence, Evidence::ChainFinalWithPendingSibling); + if pending_sibling { + // One funding transaction, two credit outputs. + if let Some(TransactionPayload::AssetLockPayloadType(payload)) = + &mut asset_lock.special_transaction_payload + { + payload.credit_outputs[0].value = 60_000; + let second = payload.credit_outputs[0].clone(); + payload.credit_outputs.push(second); + } + } asset_lock.output.push(to_wallet(20_000)); let mut competitor = spend_to(contested, 60_000); competitor.output.push(to_wallet(30_000)); @@ -1753,6 +1784,28 @@ mod idempotent_load_tests { ), ); } + if pending_sibling { + // What the host attaches for a lock still at `Broadcast`: + // the funding's record, raw, in its stored context. + let record = TransactionRecord::new( + asset_lock.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Outgoing, + Vec::new(), + Vec::new(), + 0, + ); + projection + .first_bip44_managed_account_mut() + .expect("bip44 account") + .transactions_mut() + .insert(asset_lock.txid(), record); + } let stored = |transaction: &dashcore::Transaction, context| StoredTransaction { txid: transaction.txid(), transaction: transaction.clone(), @@ -1789,7 +1842,9 @@ mod idempotent_load_tests { AssetLockStatus::InstantSendLocked, instant(asset_lock.txid()), ), - Evidence::ChainFinal | Evidence::ChainFinalWithForeignHistoryLock => ( + Evidence::ChainFinal + | Evidence::ChainFinalWithForeignHistoryLock + | Evidence::ChainFinalWithPendingSibling => ( AssetLockStatus::ChainLocked, AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 105, @@ -1820,9 +1875,8 @@ mod idempotent_load_tests { ], instant_locks: history_locks, }, - asset_locks: [( - out_point, - TrackedAssetLock { + asset_locks: { + let lock = TrackedAssetLock { out_point, transaction: asset_lock.clone(), account_index: 0, @@ -1831,10 +1885,23 @@ mod idempotent_load_tests { amount: 120_000, status, proof: Some(proof), - }, - )] - .into_iter() - .collect(), + }; + // The sibling credit output, still waiting for its proof. + let sibling = TrackedAssetLock { + out_point: dashcore::OutPoint { + txid: out_point.txid, + vout: 1, + }, + status: AssetLockStatus::Broadcast, + proof: None, + ..lock.clone() + }; + [lock] + .into_iter() + .chain(pending_sibling.then_some(sibling)) + .map(|lock| (lock.out_point, lock)) + .collect() + }, }); manager .load_from_persistor() @@ -1859,6 +1926,16 @@ mod idempotent_load_tests { core.check_core_transaction(&other_funding, in_block(91), &mut wallet, true, true) .await; let outcome = (changes.0, changes.1, !holds(&core, other)); + let record_kept = core + .first_bip44_managed_account() + .expect("bip44 account") + .transactions() + .contains_key(&asset_lock.txid()); + if pending_sibling && !record_kept { + failures.push(format!( + "{evidence:?} against {competing:?}: the funding's attached record is gone" + )); + } if outcome != expected { failures.push(format!( "{evidence:?} against {competing:?}: {outcome:?}, expected {expected:?}" diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index b44583d45fb..fe72a30a27a 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -13,11 +13,14 @@ use std::time::Duration; use dashcore::Address as DashAddress; use dashcore::{OutPoint, Txid}; use dpp::prelude::{AssetLockProof, CoreBlockHeight}; +use key_wallet::account::AccountType; use key_wallet::bip32::DerivationPath; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::ManagedWalletInfo; use crate::changeset::changeset::AssetLockChangeSet; use crate::changeset::RecordedHistory; @@ -561,7 +564,8 @@ pub(crate) fn add_tracked_instant_locks<'a>( /// Take out of `history` the unconfirmed record of every funding a tracked /// asset lock holds as chain-final and no lock in `history` settles; a lock -/// settles it only if the replay accepts it for that record. +/// settles it only if the replay accepts it for that record. Returns the +/// fundings kept out, whether `history` held a record of them or not. /// /// What a tracked asset lock proves about its funding outranks the funding's /// stored context, and the replay's verdict outranks the lock's status. A lock @@ -570,14 +574,11 @@ pub(crate) fn add_tracked_instant_locks<'a>( /// the replay, with its coins as stored and its inputs under /// [`final_lock_input_claims`]. A funding the replay still defeats gets no /// claim. -/// -/// Only the history is covered: a raw unconfirmed record a persister attached -/// for such a funding still enters the replay, see [`RecordedHistory`]. pub(crate) fn withhold_chain_final_fundings<'a>( history: &mut RecordedHistory, locks: impl IntoIterator, -) { - let chain_final: HashSet = locks +) -> HashSet { + let mut withheld: HashSet = locks .into_iter() .filter(|lock| match lock.status { AssetLockStatus::Built @@ -589,14 +590,64 @@ pub(crate) fn withhold_chain_final_fundings<'a>( }) .map(|lock| lock.transaction.txid()) .collect(); + // A funding the history places in a block, or settles with a lock the + // replay accepts, replays like any other record. let locked = &history.instant_locks; - history.transactions.retain(|stored| { - !matches!(stored.context, TransactionContext::Mempool) - || !chain_final.contains(&stored.txid) - || locked - .get(&stored.txid) - .is_some_and(|lock| lock_matches_record(lock, stored)) - }); + for stored in &history.transactions { + if withheld.contains(&stored.txid) + && (!matches!(stored.context, TransactionContext::Mempool) + || locked + .get(&stored.txid) + .is_some_and(|lock| lock_matches_record(lock, stored))) + { + withheld.remove(&stored.txid); + } + } + history + .transactions + .retain(|stored| !withheld.contains(&stored.txid)); + withheld +} + +/// Detach from `wallet_info` the unconfirmed records of `fundings`, for +/// [`reattach_records`] to put back once the replay is over. +/// +/// Left attached, the record of a withheld funding would enter the replay's +/// conflict plan as unconfirmed and fall like the history row. A confirmed +/// record stays: no stored competitor can take it. +pub(crate) fn detach_unconfirmed_records( + wallet_info: &mut ManagedWalletInfo, + fundings: &HashSet, +) -> Vec<(AccountType, TransactionRecord)> { + let mut detached = Vec::new(); + 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| { + let detach = fundings.contains(txid) && !record.is_confirmed(); + if detach { + detached.push((owner, record.clone())); + } + !detach + }); + } + detached +} + +/// Put back what [`detach_unconfirmed_records`] took, each record in its +/// account. +pub(crate) fn reattach_records( + wallet_info: &mut ManagedWalletInfo, + records: Vec<(AccountType, TransactionRecord)>, +) { + for mut account in wallet_info.accounts.all_accounts_mut() { + let owner = account.managed_account_type().to_account_type(); + for (_, record) in records.iter().filter(|(held, _)| *held == owner) { + account + .transactions_mut() + .entry(record.txid) + .or_insert_with(|| record.clone()); + } + } } /// Confirmed spends of pending asset-lock inputs, read from the stored history. @@ -4073,6 +4124,9 @@ mod tests { /// settles it; a lock filed under it that names another transaction /// settles nothing. A pending or InstantSend-locked lock, a record already /// in a block and a record no lock tracks all stay. + /// + /// The fundings kept out are returned, one the history does not hold + /// included: a record attached to the wallet for it is kept out as well. #[test] fn should_withhold_only_unsettled_chain_final_fundings() { use dashcore::ephemerealdata::instant_lock::InstantLock; @@ -4096,8 +4150,8 @@ mod tests { stored_direction: None, owned_inputs: Vec::new(), }; - let [chain_locked, consumed, recovered, built, broadcast, instant, in_block, settled, untracked, foreign] = - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(|byte| transaction_spending(outpoint(byte))); + let [chain_locked, consumed, recovered, built, broadcast, instant, in_block, settled, untracked, foreign, unrecorded] = + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map(|byte| transaction_spending(outpoint(byte))); let locks = [ tracked(&chain_locked, AssetLockStatus::ChainLocked), tracked(&consumed, AssetLockStatus::Consumed), @@ -4108,6 +4162,7 @@ mod tests { tracked(&in_block, AssetLockStatus::ChainLocked), tracked(&settled, AssetLockStatus::Consumed), tracked(&foreign, AssetLockStatus::ChainLocked), + tracked(&unrecorded, AssetLockStatus::Consumed), ]; let unconfirmed = [ &chain_locked, @@ -4137,8 +4192,14 @@ mod tests { .into(), }; - super::withhold_chain_final_fundings(&mut history, &locks); + let withheld = super::withhold_chain_final_fundings(&mut history, &locks); + assert_eq!( + withheld, + [&chain_locked, &consumed, &recovered, &foreign, &unrecorded] + .map(|transaction| transaction.txid()) + .into() + ); let kept: Vec = history .transactions .iter() @@ -4151,6 +4212,54 @@ mod tests { ); } + /// Only the unconfirmed records of the named fundings leave the wallet, + /// and they come back as they were. A confirmed record of a named funding + /// stays: no stored competitor can take it. + #[tokio::test] + async fn should_detach_unconfirmed_records_of_withheld_fundings_and_put_them_back() { + let (ctx, _) = key_wallet::test_utils::TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let mut info = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); + let outpoint = |byte| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); + let [withheld, confirmed, other] = + [1, 2, 3].map(|byte| transaction_spending(outpoint(byte))); + let records = |info: &ManagedWalletInfo| -> Vec<(Txid, bool)> { + info.accounts.standard_bip44_accounts[&0] + .transactions() + .values() + .map(|record| (record.txid, record.is_confirmed())) + .collect() + }; + for record in [ + record_for(withheld.clone(), TransactionContext::Mempool), + record_for(confirmed.clone(), confirmed_at(101)), + record_for(other.clone(), TransactionContext::Mempool), + ] { + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .expect("bip44 account") + .transactions_mut() + .insert(record.txid, record); + } + let before = records(&info); + + let detached = super::detach_unconfirmed_records( + &mut info, + &[withheld.txid(), confirmed.txid()].into(), + ); + + let attached: Vec = records(&info).into_iter().map(|(txid, _)| txid).collect(); + assert!(!attached.contains(&withheld.txid())); + assert!(attached.contains(&confirmed.txid()) && attached.contains(&other.txid())); + assert_eq!(detached.len(), 1); + + super::reattach_records(&mut info, detached); + + assert_eq!(records(&info), before); + } + /// Promotion is eviction: once a chainlock buries the spender's block, /// `apply_chain_lock` removes its record from history. The screen's /// session memory must carry the conflict across that disappearance From 3e400576e6bf085a7d01b02bd77a4b611ee6a285 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:56:27 +0000 Subject: [PATCH 32/84] fix(swift-sdk): reconcile SwiftData transaction accounting per wallet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stored Core transaction row kept the amount and direction of its last writer, computed without the wallet's own spent inputs: a self-spend with change showed as a receipt, and an asset lock as a zero transfer. The SwiftData host now recomputes amount and direction from its own TXO rows at load and at the end of each persistence round, keeps contact-only outputs out of the sum, and answers per wallet for a transaction that several local wallets share. Schema V4 records an amount that cannot be determined, and the example app's transaction views show wallet-relative amounts and direction. These changes were developed on the branch of #5150 and are carried over unchanged. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- packages/swift-sdk/SCHEMA_RELEASES.md | 36 +- .../KeyWallet/TransactionDecoder.swift | 3 + .../Persistence/DashModelContainer.swift | 38 +- .../Models/PersistentTransaction.swift | 164 ++++- .../PlatformWalletPersistenceHandler.swift | 162 +++- .../Core/Views/TransactionDetailView.swift | 55 +- .../Views/TransactionDirectionStyle.swift | 25 + .../Core/Views/TransactionListView.swift | 59 +- .../Views/StorageModelListViews.swift | 8 +- .../AssetLockSpendVisibilityTests.swift | 9 +- .../DashLegacySchemaMigrationTests.swift | 19 +- .../DashModelMigrationTests.swift | 16 +- .../DashReleasedSchemaTests.swift | 31 + .../PersistentTransactionDisplayTests.swift | 7 +- .../TransactionAccountingTests.swift | 695 ++++++++++++++++++ 15 files changed, 1184 insertions(+), 143 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 2183bfbe76d..2e568c859da 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -5,7 +5,8 @@ distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the model checksum match the observed App Store 9.0.2 database. Active models are -V3. Other intermediate development shapes remain unsupported. +V4. The released V3 graph is preserved by `DashSchemaSnapshotV3`. Other +intermediate development shapes remain unsupported. The old V2 fixture was generated from September 8 sources containing 13 properties added on August 28, after the August 27 App Store release. The @@ -15,25 +16,28 @@ separate from archive-captured releases. This identifies a matching model source, not the confirmed build commit of Apple's binary. V2 is reserved: automated release capture must not register another shape under that number. -The main migration plan is historical V2 → V3. Accepted V1 has a separate -V1 → V3 plan: V1 already contains the 13 properties missing from historical -V2, so a V1 → V2 → V3 chain could discard values. Routing uses model metadata +The main migration plan is historical V2 → V3 → V4. Accepted V1 has a separate +V1 → V4 plan: V1 already contains the 13 properties missing from historical +V2, so a chain through V2 could discard values. Routing uses model metadata and runs after recovery; a version label alone never selects an unknown beta schema. The former live V2 is accepted only when its complete graph matches -current V3 exactly, entity hashes and checksum alike. That alias therefore -lasts only until the next live-graph change: after it, every store still -labelled `2.0.0` with the former live shape becomes `unsupported-v2` and fails -closed. Check internal devices still carrying that label before the next -shape change and migrate or deliberately reset them then, rather than -discovering them afterwards as failed opens. +frozen V3 exactly, entity hashes and checksum alike. The alias remains supported +when the live graph changes; other stores labelled `2.0.0` fail closed. + +V4 adds the optional `PersistentTransaction.netAmountUnavailable` marker through +a lightweight migration. Existing rows receive `nil`, preserving their stored +accounting. When deleting one participant of a shared transaction, the remaining +wallet's amount and direction are saved before the ownership links disappear. +If its amount was unresolved, the marker keeps it unavailable across restart; +an authoritative wallet record or complete accounting reconciliation clears it. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, -`accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +`accepted-v1-to-v4`, `historical-v2-to-v4`, `previous-live-v2-v3-shape`, +`published-v3-to-v4`, `unsupported-v2`, `labelled-current-v4`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a -label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the +label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` or `4.0.0` label takes the default plan without any probe, and a probe failure on the undecidable labels refuses the open with the probe's own error while leaving the store untouched. @@ -137,9 +141,9 @@ The run failed before upload, so this provenance establishes a tested source layout rather than proof of publication. Regression tests exercise the public factory, data/default preservation, writes, reopen, and failure recovery. -Keep the bridge for installations that skip the V3 app release. When advancing -to V4, bind `DashSchemaV3` to its released snapshot and retain the legacy-to-V3 -step before the normal V3-to-current plan. The bridge must never automatically +Keep the bridge for installations that skip the V3 app release. `DashSchemaV3` +stays bound to its released snapshot, retaining the legacy-to-V3 step before +the normal V3-to-current plan. The bridge must never automatically follow the latest live model graph. The release observer's one-time `bootstrap` only records its observation baseline; it neither runs this migration nor proves V1's App Store provenance. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift index 380deeeeff7..7224d3dc282 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift @@ -39,6 +39,9 @@ public struct DecodedTransaction: Sendable, Equatable { public let valueDuffs: UInt64 /// Raw scriptPubKey bytes. public let scriptPubkey: Data + + /// `true` for an `OP_RETURN` (0x6a) data-carrier / burn output. + var isOpReturn: Bool { scriptPubkey.first == 0x6a } } /// Transaction id in consensus (internal) byte order — reverse for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ea878415c36..22fbdb63ffc 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -100,7 +100,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV3.self) + Schema(versionedSchema: DashSchemaV4.self) } /// Create a persistent model container for storing data. @@ -172,7 +172,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +185,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("3.0.0")]) + fields: ["target_version": .publicText("4.0.0")]) return container } catch { logMigrationFailure(error) @@ -201,7 +201,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("3.0.0")]) + "target_version": .publicText("4.0.0")]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +218,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("3.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +236,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("3.0.0")]) + "route": .publicText(route), "target_version": .publicText("4.0.0")]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -247,7 +247,7 @@ public enum DashModelContainer { // probe stays fatal here: the open fails with the probe's own error // instead of an inapplicable plan, and the store is untouched. if versions == ["1.0.0"], try matches(DashSchemaV1.self) { - logRoute("accepted-v1-to-v3") + logRoute("accepted-v1-to-v4") return DashAcceptedV1MigrationPlan.self } if versions == ["2.0.0"] { @@ -261,12 +261,14 @@ public enum DashModelContainer { throw DashLegacyStoreSQLite.Failure.unsupported( "The database identifies itself as schema 2.0.0 but its model does not match the supported historical or current schema. The original database has not been replaced. Contact support; do not delete the app.") } - logRoute(historical ? "historical-v2-to-v3" : "previous-live-v2-current-shape") + logRoute(historical ? "historical-v2-to-v4" : "previous-live-v2-v3-shape") } else if versions == ["3.0.0"] { + logRoute("published-v3-to-v4") + } else if versions == ["4.0.0"] { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("labelled-current-v3") + logRoute("labelled-current-v4") } else { logRoute("ordinary-current-plan") } @@ -287,12 +289,13 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] } public static var stages: [MigrationStage] { [ - .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self) + .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) ] } } @@ -300,9 +303,9 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV4.self] } static var stages: [MigrationStage] { - [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self)] + [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV4.self)] } } @@ -372,9 +375,14 @@ public enum DashSchemaV2: VersionedSchema { } } -/// Current working schema. A later shape change must preserve this graph as -/// the fixed legacy-bridge target before introducing another live version. +/// Frozen released graph and fixed target of the legacy-store bridge. public enum DashSchemaV3: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(3, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } +} + +/// Live schema adds an optional accounting-availability marker; existing rows keep nil. +public enum DashSchemaV4: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index f0ecd0fce34..a27fb766d11 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -84,8 +84,11 @@ public final class PersistentTransaction { /// replaces it with the real discriminant on touch. Accessors /// treat the sentinel as unknown (no branch fires). public var transactionTypeKind: UInt8 = 0xFF - /// Net amount in duffs (signed: positive=received, negative=sent). + /// Net Core amount in duffs across locally owned TXOs (positive=received, negative=sent). public var netAmount: Int64 + /// Set when removing a wallet left no authoritative amount for the survivor. + /// `nil` preserves the accounting of rows migrated from V3. + public var netAmountUnavailable: Bool? = nil /// Fee in duffs (nil if unknown). public var fee: UInt64? /// User-assigned label. @@ -228,12 +231,150 @@ public final class PersistentTransaction { } } + /// Core value movement for one wallet; `nil` when it cannot be derived yet. + /// + /// The stored scalar is the last recording wallet's (Rust `net_amount`, + /// or the reconciliation over its own TXOs), so it answers only when + /// `walletId` is the sole participant. Pending inputs do not veto that + /// answer: `upsertTransaction` writes one for every input whose prevout + /// has no local TXO — every foreign input of an incoming payment — and + /// never prunes them, so they cannot tell a late input of ours apart. + public func netAmount(for walletId: Data) -> Int64? { + func owned(_ rows: [PersistentTxo]) -> [PersistentTxo] { + var seen = Set() + return rows.filter { + PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) + && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + && seen.insert($0.outpoint).inserted + } + } + let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } + if participatingWalletIds == [walletId], !hasUnownedTxos { + return netAmountUnavailable == true ? nil : netAmount + } + // Computed from TXOs alone: a pending input this wallet recorded may be + // one of its own still-unlinked coins, so the sum is only provisional. + // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two + // local wallets leaves an unresolvable pending input per wallet, so both + // show "Amount unavailable". Store Rust's per-wallet net amount instead + // (tracked in #5226). + guard !pendingInputs.contains(where: { $0.walletId == walletId }) else { return nil } + let walletInputs = owned(inputs) + let walletOutputs = owned(outputs) + guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } + return Self.reconciledAccounting( + inputs: walletInputs, ownedOutputAmounts: walletOutputs.map(\.amount), + allOutputsOwned: false, previousDirection: direction, isAssetLock: isAssetLock + )?.netAmount + } + + /// Direction relative to one wallet for transactions shared by multiple local wallets. + public func direction(for walletId: Data) -> UInt32 { + guard participatingWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, + typedKind != .coinJoin else { return direction } + let spendsOurs = inputs.contains { + PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) + && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + } + if isAssetLock { + let ownedOutputs = outputs.filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + if spendsOurs { + return ownedOutputs.contains { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) != walletId + } ? CoreDirectionCode.outgoing : direction + } + return ownedOutputs.contains { + PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId + } ? CoreDirectionCode.incoming : direction + } + return spendsOurs ? CoreDirectionCode.outgoing : CoreDirectionCode.incoming + } + + /// Retain the sole surviving wallet's accounting before ownership links disappear. + func preserveAccounting(removingWallet walletId: Data) { + let participants = participatingWalletIds + guard participants.count == 2, participants.contains(walletId), + let survivor = participants.first(where: { $0 != walletId }) else { return } + let amount = netAmount(for: survivor) + let survivorDirection = direction(for: survivor) + if let amount { netAmount = amount } + netAmountUnavailable = amount == nil + direction = survivorDirection + } + + /// Format the wallet's Core value movement in DASH. + public func formattedAmount(for walletId: Data) -> String { + guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } + return Self.format(duffs: amount) + } + + /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. + public func displayNetAmount(for walletId: Data?) -> Int64? { + walletId.map { netAmount(for: $0) } ?? (netAmountUnavailable == true ? nil : netAmount) + } + + /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. + public func displayDirectionCode(for walletId: Data?) -> UInt32 { + walletId.map { direction(for: $0) } ?? direction + } + + /// Formatted amount for `walletId`, or the stored scalar's when no wallet scope is given. + public func displayFormattedAmount(for walletId: Data?) -> String { + walletId.map { formattedAmount(for: $0) } ?? formattedAmount + } + + /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. + static func format(duffs: Int64) -> String { + String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) + } + + /// Local wallets owning one of this transaction's TXOs or having recorded it + /// (`involvedAccounts`), whose last writer's accounting is the stored scalar. + private var participatingWalletIds: Set { + let owning = (inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) + .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) } + let recording = involvedAccounts.compactMap { account -> Data? in + let wallet: PersistentWallet? = account.wallet + return wallet?.walletId + } + return Set(owning + recording) + } + + static func reconciledAccounting( + inputs: [PersistentTxo], ownedOutputAmounts: [UInt64], + allOutputsOwned: Bool, previousDirection: UInt32, isAssetLock: Bool + ) -> (netAmount: Int64, direction: UInt32)? { + func total(_ amounts: [UInt64]) -> Int64? { + var sum: Int64 = 0 + for amount in amounts { + guard let value = Int64(exactly: amount) else { return nil } + let addition = sum.addingReportingOverflow(value) + guard !addition.overflow else { return nil } + sum = addition.partialValue + } + return sum + } + guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { + return nil + } + // Same rule as Rust (`platform_wallet::changeset::wallet_direction`): + // internal only when nothing leaves the wallet and something stays in + // it, or an asset lock burns into Platform. Both sides test one table. + let direction: UInt32 + if previousDirection == CoreDirectionCode.coinJoin { direction = CoreDirectionCode.coinJoin } + else if inputs.isEmpty { direction = CoreDirectionCode.incoming } + else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { + direction = CoreDirectionCode.internalTransfer + } else { direction = CoreDirectionCode.outgoing } + return (received - spent, direction) + } + public var directionName: String { switch direction { - case 0: return "Incoming" - case 1: return "Outgoing" - case 2: return "Internal" - case 3: return "CoinJoin" + case CoreDirectionCode.incoming: return "Incoming" + case CoreDirectionCode.outgoing: return "Outgoing" + case CoreDirectionCode.internalTransfer: return "Internal" + case CoreDirectionCode.coinJoin: return "CoinJoin" default: return "Unknown" } } @@ -337,12 +478,19 @@ public final class PersistentTransaction { } public var formattedAmount: String { - let dash = Double(abs(netAmount)) / 100_000_000.0 - let sign = netAmount >= 0 ? "+" : "-" - return String(format: "%@%.8f DASH", sign, dash) + netAmountUnavailable == true ? "Amount unavailable" : Self.format(duffs: netAmount) } } +/// Wire values of `PersistentTransaction.direction`, matching `directionName` +/// and the FFI's `TransactionDirection` discriminants. +public enum CoreDirectionCode { + public static let incoming: UInt32 = 0 + public static let outgoing: UInt32 = 1 + public static let internalTransfer: UInt32 = 2 + public static let coinJoin: UInt32 = 3 +} + /// Typed mirror of Rust's /// `key_wallet::transaction_checking::transaction_router::TransactionType`, /// pinned to the `u8` discriminants emitted by diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 0693c9e7366..f48f1614e63 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -105,6 +105,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return wallet?.walletId } + /// Contact watch-only TXOs do not belong to the wallet tracking their addresses. + static func isWalletOwnedTxo(_ txo: PersistentTxo) -> Bool { + resolvedWalletId(of: txo) != nil + && txo.account?.accountType != dashpayExternalAccountTypeTag + && txo.coreAddress?.account?.accountType != dashpayExternalAccountTypeTag + } + + /// Network of the account's wallet, read through the same Optional cast + /// as `resolvedWalletId(of:)`. + static func walletNetwork(of account: PersistentAccount?) -> Network? { + let wallet: PersistentWallet? = account?.wallet + return wallet?.network + } + static func walletOwnsTransaction( walletId: Data, transaction: PersistentTransaction @@ -246,6 +260,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] } private var roundIndex: ChangesetRoundIndex? + private var accountingDirty: [Data: PersistentTransaction] = [:] /// Number of persistence rounds committed by this handler, read and /// compared on `serialQueue`. The store reconcile classifies rows off /// this queue and applies the verdicts on it; a round committed in @@ -411,6 +426,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { error: error ) backgroundContext.rollback() + // Rows staged by the failed save are gone; never reconcile them. + accountingDirty.removeAll() } } @@ -2538,7 +2555,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.hasBlockPosition = tx.has_block_position let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes - record.direction = tx.direction + // Empty asset-lock recovery records cannot replace a funded debit or an unavailable amount. + let preserveLockAccounting = tx.transaction_type_kind == TransactionTypeKind.assetLock.rawValue + && tx.net_amount == 0 && !tx.has_fee + && (record.netAmount < 0 || record.netAmountUnavailable == true) + if !preserveLockAccounting { record.direction = tx.direction } if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) } @@ -2560,8 +2581,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.providerVotingKeyHash = tx.has_provider_voting_key_hash ? withUnsafeBytes(of: tx.provider_voting_key_hash) { Data($0) } : nil - record.netAmount = tx.net_amount - record.fee = tx.has_fee ? tx.fee : nil + if !preserveLockAccounting { + record.netAmount = tx.net_amount + record.netAmountUnavailable = false + record.fee = tx.has_fee ? tx.fee : nil + } + accountingDirty[record.txid] = record if let labelPtr = tx.label { record.label = String(cString: labelPtr) } @@ -2905,6 +2930,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// (`reconcileHealMissingTxos`), so both writers honour the same /// tombstone precedence and spender-adoption rules. private func drainPendingInputs(into record: PersistentTxo, resolvedWalletId: Data) { + if let parent = record.transaction { accountingDirty[parent.txid] = parent } + if let spender = record.spendingTransaction { accountingDirty[spender.txid] = spender } let pendingRows = pendingInputRows(outpoint: record.outpoint) if !pendingRows.isEmpty { // A tombstone is not an observation — it is a sweep's settled @@ -3037,7 +3064,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spender: PersistentTransaction, inputIndex: UInt32? ) { + accountingDirty[spender.txid] = spender let currentSpender = txo.spendingTransaction + if let currentSpender { accountingDirty[currentSpender.txid] = currentSpender } let verdict = Self.reconcileSpendObservation( currentSpenderTxid: currentSpender?.txid, currentSpenderContext: currentSpender?.context, @@ -3280,6 +3309,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() + // Out-of-round writers (heal paths, deferred backfills) stage + // entries too; they are not this round's to reconcile and may + // point at rows a failed save rolled back. Load-time accounting + // repairs whatever they touched. + self.accountingDirty.removeAll() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -3366,6 +3400,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() self.roundIndex = nil + self.accountingDirty.removeAll() self.roundAdvancedFinalityBoundary = false self.inChangeset = false self.drainDeferredBackfills() @@ -3408,6 +3443,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if roundAdvancedFinalityBoundary { collectFinalizedSweptTombstones(walletId: walletId) } + // Display-only accounting: a failure here must not fail the + // round, and is reported under its own event, not save_failed. + // Recomputed values that did land are consistent on their own. + // TODO(test-round-accounting-failure): cover this catch with a + // FetchFaultInjector test; pinning which read faults needs a Swift run. + do { + try reconcileTransactionAccounting(Array(accountingDirty.values)) + } catch { + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("round"), "wallet_reference": .reference(walletId)], + error: error + ) + } do { try backgroundContext.save() committedRoundGeneration &+= 1 @@ -6503,6 +6552,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if let walletRow = walletRow { + // Shared scalars must be scoped while the departing wallet's TXOs still exist. + for transaction in try backgroundContext.fetch(FetchDescriptor()) { + transaction.preserveAccounting(removingWallet: walletId) + } // Wallet → identities is `.nullify`; this delete // path cascades them explicitly. let identitiesToDelete = Array(walletRow.identities) @@ -6924,6 +6977,73 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return txids } + /// Repair only fully resolved spends; missing prevouts are not evidence of external ownership. + func reconcileTransactionAccounting( + _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil, + addresses: [String: PersistentCoreAddress]? = nil + ) throws { + for transaction in transactions where !transaction.isDeleted { + guard let transactionNetwork = network + ?? Self.walletNetwork(of: transaction.involvedAccounts.first) + ?? Self.walletNetwork(of: transaction.inputs.first?.account) + ?? Self.walletNetwork(of: transaction.outputs.first?.account), + let decoded = try? TransactionDecoder.decode( + transaction.transactionData, network: transactionNetwork + ), !decoded.inputs.isEmpty else { continue } + var inputs: [PersistentTxo] = [] + var complete = true + for input in decoded.inputs { + let key = PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) + let row: PersistentTxo? + if let txos { row = txos[key] } + else { row = try fetchTxoRowChecked(outpoint: key) } + guard let row, !row.isDeleted, Self.isWalletOwnedTxo(row) else { + complete = false + break + } + inputs.append(row) + } + guard complete else { continue } + var amounts: [UInt64] = [] + var allOutputsOwned = true + let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted && Self.isWalletOwnedTxo($0) }.map(\.vout)) + // An address-matched output with no linked TXO carries no wallet of + // its own, so it counts only for the spending wallets: another local + // wallet's credit must not hide inside the sender's scalar. + let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) + for (index, output) in decoded.outputs.enumerated() { + // OP_RETURN burns (including asset locks) are not spendable Core outputs. + if output.isOpReturn { continue } + var belongs = ownedVouts.contains(UInt32(index)) + if !belongs, let address = output.address { + let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) + let owner: PersistentCoreAddress? + if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } + else if let addresses { owner = addresses[address] } + else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } + if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { + let ownerWallet: PersistentWallet? = account.wallet + if let ownerWallet, spendingWallets.contains(ownerWallet.walletId) { + belongs = true + } + } + } + if belongs { amounts.append(output.valueDuffs) } + else { allOutputsOwned = false } + } + if let accounting = PersistentTransaction.reconciledAccounting( + inputs: inputs, ownedOutputAmounts: amounts, allOutputsOwned: allOutputsOwned, + previousDirection: transaction.typedKind == .coinJoin + ? CoreDirectionCode.coinJoin : transaction.direction, + isAssetLock: transaction.isAssetLock + ) { + transaction.netAmount = accounting.netAmount + transaction.netAmountUnavailable = false + transaction.direction = accounting.direction + } + } + } + /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { SDKLogger.event( @@ -6969,6 +7089,42 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } + // Mid-round the context holds another round's staged writes: saving + // would commit half of it and rolling back would silently drop it. + // That round reconciles its own dirty rows; the next load repairs the rest. + // TODO(persist-accounting-backfill-marker): this pass re-reads all + // history on every launch; a persisted completion marker needs a + // SwiftData shape change (a new live schema version) or a side + // channel, which is a product decision. + if !inChangeset { + do { + let walletIds = Set(wallets.map(\.walletId)) + let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + .filter { row in + walletIds.contains { Self.walletOwnsTransaction(walletId: $0, transaction: row) } + } + let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + // One read instead of one per unlinked output. + let addresses = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + try reconcileTransactionAccounting( + transactions, + // Never trap on a duplicate outpoint; a live row wins over a deleted one. + txos: Dictionary(txos.map { ($0.outpoint, $0) }, uniquingKeysWith: { kept, other in + kept.isDeleted ? other : kept + }), + addresses: Dictionary(addresses.map { ($0.address, $0) }, uniquingKeysWith: { first, _ in first }) + ) + try backgroundContext.save() + } catch { + // Display-only accounting must never block restoring wallets: + // drop this pass's edits and restore on the stored values. + backgroundContext.rollback() + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("load")], error: error + ) + } + } let restorable = wallets.filter { wallet in wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index c222a532b70..ba2092b1f68 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -3,36 +3,29 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction - /// Override amount for asset-lock txs. The wallet's `netAmount` - /// shows ~0 for these (credit output is structurally self-owned), - /// so the list view passes the linked - /// `PersistentAssetLock.amountDuffs`. `nil` for non-asset-lock - /// rows OR consumed asset locks whose tracking row was cleaned - /// up after successful identity registration. + var walletId: Data? = nil + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @State private var showCopiedAlert = false - /// Amount label rendered prominently at the top of the sheet. - /// Same precedence rule as the row: asset-lock duffs when we - /// have them, else an explicit "amount unknown" label for the - /// historical-asset-lock case (rather than the misleading - /// `+0.00000000 DASH` from `transaction.formattedAmount`). - /// `nil` for a payload-only provider special tx — a ProRegTx - /// observed via the owner/voting keys moves no wallet balance, - /// and `+0.00000000 DASH` reads as a broken zero-value receive. + /// Show the lock's funding amount, or the wallet's Core value movement for ordinary transactions. private var displayAmount: String? { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return nil } - return transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } private var typeDescription: String { @@ -42,11 +35,13 @@ struct TransactionDetailView: View { || transaction.isProviderSpecial { return transaction.displayDirection } - switch transaction.netAmount { - case let amount where amount > 0: + switch direction { + case CoreDirectionCode.incoming: return "Received" - case let amount where amount < 0: + case CoreDirectionCode.outgoing: return "Sent" + case CoreDirectionCode.coinJoin: + return "CoinJoin" default: return "Self-Transfer" } @@ -56,14 +51,7 @@ struct TransactionDetailView: View { if transaction.isAssetLock { return "lock.fill" } if transaction.isAssetUnlock { return "lock.open.fill" } if transaction.isProviderSpecial { return "server.rack" } - switch transaction.netAmount { - case let amount where amount > 0: - return "arrow.down.circle.fill" - case let amount where amount < 0: - return "arrow.up.circle.fill" - default: - return "arrow.triangle.2.circlepath" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -73,14 +61,7 @@ struct TransactionDetailView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.netAmount { - case let amount where amount > 0: - return .green - case let amount where amount < 0: - return .red - default: - return .blue - } + return TransactionDirectionStyle.color(for: direction) } private var isConfirmed: Bool { @@ -208,7 +189,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, transaction.netAmount < 0 { + if let fee = formattedFee, let amount = netAmount, amount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift new file mode 100644 index 00000000000..589aa6bb7f0 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift @@ -0,0 +1,25 @@ +import SwiftUI +import SwiftDashSDK + +/// Direction icon and colour shared by the transaction list and detail views. +enum TransactionDirectionStyle { + static func icon(for direction: UInt32) -> String { + switch direction { + case CoreDirectionCode.incoming: return "arrow.down.circle.fill" + case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" + case CoreDirectionCode.internalTransfer: return "arrow.triangle.2.circlepath" + case CoreDirectionCode.coinJoin: return "shuffle.circle.fill" + default: return "questionmark.circle" + } + } + + /// Internal transfers share the outgoing colour: they still pay a fee. + static func color(for direction: UInt32) -> Color { + switch direction { + case CoreDirectionCode.incoming: return .green + case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red + case CoreDirectionCode.coinJoin: return .blue + default: return .secondary + } + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index f185f78c20f..5d84aaca7e4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -29,10 +29,9 @@ struct TransactionListView: View { @Query private var walletAccounts: [PersistentAccount] @Query private var transactionObservation: [PersistentTransaction] /// Per-wallet asset-lock rows. Used to look up the *locked* amount - /// for each asset-lock tx — `PersistentTransaction.netAmount` is - /// the wallet's input-vs-output diff, which sees the credit - /// output as "to-self" and reports ~0 for asset locks. The - /// `amountDuffs` on the asset-lock row is the actual L1 burn. + /// for each asset-lock tx: `amountDuffs` on the asset-lock row is the + /// payload funding amount, while `PersistentTransaction.netAmount` is + /// the Core debit, which includes the fee. @Query private var assetLocks: [PersistentAssetLock] /// This wallet's owning identities. The DashPay payment / contact /// join below must be scoped to these — two identities in one store @@ -169,6 +168,7 @@ struct TransactionListView: View { .sheet(item: $selectedTransaction) { transaction in TransactionDetailView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmountByTxid[transaction.txidHex] ) } @@ -202,6 +202,7 @@ struct TransactionListView: View { } label: { TransactionRowView( transaction: transaction, + walletId: walletId, assetLockAmountDuffs: assetLockAmounts[transaction.txidHex], dashpayPayment: payment, dashpayCounterpartyName: payment.map { @@ -219,12 +220,12 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction - /// Override amount displayed for asset-lock rows. The wallet's - /// `netAmount` shows ~0 for these (credit output is structurally - /// self-owned), so the list view passes the linked - /// `PersistentAssetLock.amountDuffs` — the actual L1 DASH burned - /// to mint platform credits. `nil` for non-asset-lock rows or - /// when no matching row was found. + var walletId: Data? = nil + /// `nil` while this wallet's amount is unresolved — the same state the + /// amount label shows as "Amount unavailable", so fee and amount agree. + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } + /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in /// `TransactionListView`. When set, the row shows the contact context @@ -253,14 +254,7 @@ struct TransactionRowView: View { // `Internal` — the wallet just sees its own owner/voting/payout // keys in the payload — so the self-transfer arrows would lie. if transaction.isProviderSpecial { return "server.rack" } - // direction: 0=incoming, 1=outgoing, 2=internal, 3=coinJoin - switch transaction.direction { - case 0: return "arrow.down.circle.fill" - case 1: return "arrow.up.circle.fill" - case 2: return "arrow.triangle.2.circlepath" - case 3: return "shuffle.circle.fill" - default: return "questionmark.circle" - } + return TransactionDirectionStyle.icon(for: direction) } private var typeColor: Color { @@ -278,12 +272,7 @@ struct TransactionRowView: View { if transaction.isProviderSpecial { return .orange } - switch transaction.direction { - case 0: return .green - case 1, 2: return .red - case 3: return .blue - default: return .secondary - } + return TransactionDirectionStyle.color(for: direction) } /// Primary label: the contact context for a DashPay payment, else the @@ -400,7 +389,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, transaction.netAmount < 0 { + if let fee = transaction.fee, let amount = netAmount, amount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) @@ -417,35 +406,23 @@ struct TransactionRowView: View { return String(format: "%.8f DASH", dash) } - /// Amount label for the row. For asset-lock txs we substitute - /// the linked `PersistentAssetLock.amountDuffs` (the L1 DASH - /// actually burned to mint platform credits); the wallet's - /// `netAmount` is ~0 for these because the credit output is a - /// self-owned address. Rendered as a negative (DASH leaving L1). - /// - /// If we know the row is an asset lock but the linked - /// `PersistentAssetLock` is missing (e.g. a historical record - /// from before the `Consumed`-status retention change shipped), - /// we render "Asset Lock (amount unknown)" instead of falling - /// through to `transaction.formattedAmount` — that would say - /// `+0.00000000 DASH`, which is misleading for a row the user - /// can see was a funding tx. + /// Keep asset-lock funding amounts distinct from the Core debit, which includes fees. private var displayAmount: String { if transaction.isAssetLock { if let duffs = assetLockAmountDuffs { let dash = Double(duffs) / 100_000_000.0 return String(format: "-%.8f DASH", dash) } - return "Asset Lock (amount unknown)" + return "Asset Lock (amount unavailable)" } // A payload-only provider special tx moves no wallet balance; // `+0.00000000 DASH` reads as a broken zero-value receive, so // put the tx kind in the amount slot instead. A provider tx // that DOES move value (e.g. this wallet funded the collateral) // falls through and shows the real signed amount. - if transaction.isProviderSpecial && transaction.netAmount == 0 { + if transaction.isProviderSpecial && netAmount == 0 { return transaction.providerSpecialName ?? transaction.transactionType } - return transaction.formattedAmount + return transaction.displayFormattedAmount(for: walletId) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index b4627318cf0..ec38b7dae0f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1162,10 +1162,10 @@ struct TransactionStorageListView: View { // Direction. switch directionFilter { case .all: break - case .incoming where record.direction != 0: return false - case .outgoing where record.direction != 1: return false - case .internalTx where record.direction != 2: return false - case .coinjoin where record.direction != 3: return false + case .incoming where record.direction != CoreDirectionCode.incoming: return false + case .outgoing where record.direction != CoreDirectionCode.outgoing: return false + case .internalTx where record.direction != CoreDirectionCode.internalTransfer: return false + case .coinjoin where record.direction != CoreDirectionCode.coinJoin: return false default: break } // Type. Treat the legacy `"Standard"` placeholder (the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index e54ed241b93..c4a793f0ceb 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -368,8 +368,13 @@ final class AssetLockSpendVisibilityTests: XCTestCase { XCTAssertEqual( injector.observedReads, - ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], - "the wallet and unspent-TXO reads must have been served — only the lock read failed" + [ + // Wallet list, then the load-time accounting reconcile. + "PersistentWallet", "PersistentTransaction", "PersistentTxo", "PersistentCoreAddress", + // Unspent-TXO restore, then the faulted lock read. + "PersistentTxo", "PersistentAssetLock", + ], + "every read before the lock read must have been served — only the lock read failed" ) XCTAssertTrue( errored, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 982d9ab0afd..26a2fd54943 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,15 +11,18 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV4: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV3.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV5: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, BridgeFutureV4.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .custom(fromVersion: DashSchemaV3.self, toVersion: BridgeFutureV4.self, + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -770,7 +773,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV4.self) + let schema = Schema(versionedSchema: BridgeFutureV5.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( @@ -887,7 +890,9 @@ final class DashLegacySchemaMigrationTests: XCTestCase { let container = try ModelContainer(for: schema, configurations: [ ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) ]) - try verifyRows(container.mainContext) + let wallet = try XCTUnwrap(container.mainContext.fetch( + FetchDescriptor()).first) + XCTAssertEqual(wallet.name, "historical audit wallet") } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["2.0.0"]) let container = try open(url, hooks: .init(visit: { _, _ in diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 4beee9638fa..0f27acf4063 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -75,11 +75,11 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let log = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(log.contains("event=store_open_started")) - XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) + XCTAssertTrue(log.contains("route=\"historical-v2-to-v4\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"3.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v3\"")) + XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"3.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV3RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["3.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV3.self) + let v2Schema = DashModelContainer.schema let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index d283b5a20c0..7e268061af8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -99,6 +99,37 @@ final class DashReleasedSchemaTests: XCTestCase { } } + @MainActor + func testShouldMigrateV3AccountingAvailabilityAndPersistAnUnavailableAmount() throws { + let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { + $0.version.versionIdentifier == Schema.Version(3, 0, 0) + }) + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("accounting.store") + try FileManager.default.copyItem(at: source(fixture), to: url) + let txid = Data(repeating: 0x32, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertNil(row.netAmountUnavailable, "V3 rows retain their stored accounting by default") + XCTAssertEqual(row.displayNetAmount(for: nil), row.netAmount) + row.netAmountUnavailable = true + try container.mainContext.save() + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) + .first { $0.txid == txid }) + XCTAssertEqual(row.netAmountUnavailable, true) + XCTAssertNil(row.displayNetAmount(for: nil)) + XCTAssertEqual(row.formattedAmount, "Amount unavailable") + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) + } + } + @MainActor func testRuntimePlanHasNoDuplicateModelChecksums() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift index 02b0bc78db8..f3f6d5deb40 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PersistentTransactionDisplayTests.swift @@ -12,7 +12,10 @@ final class PersistentTransactionDisplayTests: XCTestCase { /// Direction 2 = internal — the raw classification every special /// tx gets (asset locks, provider txs), which the display helpers /// exist to override. - private func makeTransaction(kind: UInt8, direction: UInt32 = 2) -> PersistentTransaction { + private func makeTransaction( + kind: UInt8, + direction: UInt32 = CoreDirectionCode.internalTransfer + ) -> PersistentTransaction { let tx = PersistentTransaction( txid: Data(repeating: 0xAB, count: 32), transactionData: Data(), @@ -53,7 +56,7 @@ final class PersistentTransactionDisplayTests: XCTestCase { XCTAssertEqual(makeTransaction(kind: 7).displayDirection, "Asset Unlock") // …and non-special kinds fall through to the raw direction. XCTAssertEqual(makeTransaction(kind: 0).displayDirection, "Internal") - XCTAssertEqual(makeTransaction(kind: 0, direction: 0).displayDirection, "Incoming") + XCTAssertEqual(makeTransaction(kind: 0, direction: CoreDirectionCode.incoming).displayDirection, "Incoming") XCTAssertEqual(makeTransaction(kind: 0xFF).displayDirection, "Internal") } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift new file mode 100644 index 00000000000..6fc27f08526 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -0,0 +1,695 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +@MainActor +final class TransactionAccountingTests: XCTestCase { + private func input(_ value: UInt64, wallet: UInt8 = 1) -> PersistentTxo { + let parent = PersistentTransaction(txid: Data(repeating: wallet, count: 32), transactionData: Data()) + let txo = PersistentTxo(transaction: parent, vout: 0, amount: value, address: "", height: 1) + txo.walletId = Data(repeating: wallet, count: 32) + return txo + } + + func testShouldRepairSpentInputsWithoutChangingBalanceOrConsumption() { + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let spent = input(100) + spent.isSpent = true + let result = PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [40], allOutputsOwned: false, previousDirection: 0, isAssetLock: false + ) + XCTAssertEqual(result?.netAmount, -60) + XCTAssertEqual(result?.direction, 1) + XCTAssertTrue(spent.isSpent) + XCTAssertEqual(tx.netAmount, 40) // Computing accounting has no storage side effects. + } + + func testShouldKeepInternalAndCoinJoinSemanticsWithNegativeNet() { + let spent = input(100) + XCTAssertEqual(PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: true, previousDirection: 0, isAssetLock: false + )?.direction, 2) + let lock = PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: true, previousDirection: 2, isAssetLock: true + ) + XCTAssertEqual(lock?.netAmount, -100) + XCTAssertEqual(lock?.direction, 2) + XCTAssertEqual(PersistentTransaction.reconciledAccounting( + inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: false, previousDirection: 3, isAssetLock: false + )?.direction, 3) + } + + /// Same case table as the Rust repair's + /// `should_classify_repaired_direction_like_the_swift_sdk`. + func testShouldClassifyDirectionLikeTheRustRepair() { + let spent = input(100) + // (spends ours, owned output amounts, all outputs owned, asset lock, previous direction, expected) + let (incoming, outgoing, internalTransfer, coinJoin) = ( + CoreDirectionCode.incoming, CoreDirectionCode.outgoing, + CoreDirectionCode.internalTransfer, CoreDirectionCode.coinJoin + ) + let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ + (true, [99], true, false, incoming, internalTransfer), + (true, [40], false, false, incoming, outgoing), + (true, [], true, false, incoming, outgoing), + (true, [], true, true, incoming, internalTransfer), + (true, [40], true, true, incoming, internalTransfer), + (true, [], false, true, incoming, outgoing), + (false, [40], true, false, incoming, incoming), + (true, [99], true, false, coinJoin, coinJoin), + ] + for (index, (spendsOurs, owned, allOwned, isLock, previous, expected)) in cases.enumerated() { + let result = PersistentTransaction.reconciledAccounting( + inputs: spendsOurs ? [spent] : [], ownedOutputAmounts: owned, + allOutputsOwned: allOwned, previousDirection: previous, isAssetLock: isLock + ) + XCTAssertEqual(result?.direction, expected, "case \(index)") + } + } + + func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { + XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") + XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") + XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) + } + + func testShouldRejectOverflowInsteadOfWrappingHistory() { + XCTAssertNil(PersistentTransaction.reconciledAccounting( + inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false + )) + } + + func testShouldScopeNetToWalletAndDeduplicateOutpoints() { + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) + let first = input(100) + let other = input(200, wallet: 2) + tx.inputs = [first, first, other] + XCTAssertEqual(tx.netAmount(for: first.walletId), -100) + XCTAssertEqual(tx.netAmount(for: other.walletId), -200) + } + /// `burn` makes the single output an OP_RETURN, as an asset lock's is. + private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40, burn: Bool = false) -> Data { + var bytes = Data([2, 0, 0, 0, UInt8(inputs.count)]) + for txid in inputs { + bytes.append(txid) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255]) + } + bytes.append(1) + withUnsafeBytes(of: outputValue.littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: burn ? [1, 0x6a] : [0]) + bytes.append(contentsOf: [0, 0, 0, 0]) + return bytes + } + + func testShouldBackfillExistingHistoryOnLoadAndRemainIdempotent() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction(txid: walletId, transactionData: Data()) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) + coin.walletId = walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(funding) + context.insert(spender) + context.insert(coin) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + XCTAssertFalse(handler.loadWalletList().errored) + let fresh = ModelContext(container) + let repaired = try XCTUnwrap(fresh.fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(repaired.netAmount, -100) + XCTAssertEqual(repaired.direction, 1) + XCTAssertTrue(try XCTUnwrap(fresh.fetch(FetchDescriptor()).first).isSpent) + } + + func testShouldNotCommitOrDropOpenRoundWhenLoadRunsMidChangeset() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let funding = PersistentTransaction(txid: walletId, transactionData: Data()) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) + coin.walletId = walletId + coin.isSpent = true + coin.spendingTransaction = spender + context.insert(funding) + context.insert(spender) + context.insert(coin) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let identityId = Data(repeating: 9, count: 32) + let fetchIdentity = { + try ModelContext(container).fetch(FetchDescriptor()).first { $0.identityId == identityId } + } + + handler.beginChangeset(walletId: walletId) + handler.persistIdentities( + walletId: walletId, + upserts: [.init( + identityId: identityId, balance: 100, revision: 1, identityIndex: 0, label: nil, + status: 0, walletId: walletId, dpnsNames: [], dashpayProfile: nil, contactProfiles: [] + )], + removed: [] + ) + XCTAssertFalse(handler.loadWalletList().errored) + XCTAssertNil(try fetchIdentity(), "load must not commit half of an open round") + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + XCTAssertNotNil(try fetchIdentity(), "load must not roll back an open round") + + // The skipped full pass runs on the next load outside any round. + XCTAssertFalse(handler.loadWalletList().errored) + let repaired = try ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == spender.txid } + XCTAssertEqual(repaired?.netAmount, -100) + } + + func testShouldRestoreWalletsWhenLoadTimeAccountingFails() throws { + let container = try DashModelContainer.createInMemory() + container.mainContext.insert(PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet)) + try container.mainContext.save() + let injector = FetchFaultInjector(faulting: PersistentCoreAddress.self) + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet, modelFetcher: injector + ) + XCTAssertFalse(handler.loadWalletList().errored, "display accounting must not block restore") + XCTAssertTrue(injector.observedReads.contains("PersistentCoreAddress")) + } + + func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let coin = input(100) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), + transactionData: serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)]), + direction: CoreDirectionCode.outgoing, netAmount: -200 + ) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.first { $0.txid == spender.txid }?.netAmount, -200) + } + + private func persist( + _ handler: PlatformWalletPersistenceHandler, walletId: Data, txid: Data, + bytes: Data? = nil, net: Int64 = 0, kind: UInt8 = 0, + inputTxids: [Data] = [], outputs: [(Data, UInt64)] = [] + ) { + let name = strdup("Standard { index: 0 }") + let address = strdup("") + defer { free(name); free(address) } + var record = TransactionRecordFFI() + withUnsafeMutableBytes(of: &record.txid) { $0.copyBytes(from: txid) } + record.context = 2 + record.net_amount = net + record.transaction_type_kind = kind + var inputs = inputTxids.map { txid -> OutPointFFI in + var result = OutPointFFI() + withUnsafeMutableBytes(of: &result.txid) { $0.copyBytes(from: txid) } + return result + } + var txos = outputs.map { txid, amount -> UtxoEntryFFI in + var result = UtxoEntryFFI() + withUnsafeMutableBytes(of: &result.outpoint.txid) { $0.copyBytes(from: txid) } + result.amount = amount + result.address = address + return result + } + var raw = Array(bytes ?? Data()) + handler.beginChangeset(walletId: walletId) + raw.withUnsafeMutableBufferPointer { rawPtr in + inputs.withUnsafeMutableBufferPointer { inputPtr in + txos.withUnsafeMutableBufferPointer { txoPtr in + record.tx_data = rawPtr.baseAddress + record.tx_data_len = UInt(rawPtr.count) + record.input_outpoints = inputPtr.baseAddress + record.input_outpoints_count = UInt(inputPtr.count) + withUnsafeMutablePointer(to: &record) { recordPtr in + var account = AccountChangeSetFFI() + account.account_type_name = name + account.transactions = recordPtr + account.transactions_count = bytes == nil ? 0 : 1 + account.utxos_added = txoPtr.baseAddress + account.utxos_added_count = UInt(txoPtr.count) + withUnsafeMutablePointer(to: &account) { accountPtr in + var changeset = WalletChangeSetFFI() + changeset.accounts = accountPtr + changeset.accounts_count = 1 + withUnsafePointer(to: &changeset) { ptr in + XCTAssertTrue(handler.persistWalletChangeset(walletId: walletId, changeset: ptr)) + } + } + } + } + } + } + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + func testShouldRepairLateInputAndLateOutputInSeparateAtomicRounds() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let spenderId = Data(repeating: 3, count: 32) + persist(handler, walletId: walletId, txid: spenderId, + bytes: serializedSpend(inputs: [walletId]), net: 40, inputTxids: [walletId]) + persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) + persist(handler, walletId: walletId, txid: spenderId, outputs: [(spenderId, 40)]) + let context = ModelContext(container) + let row = try XCTUnwrap(context.fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -60) + XCTAssertEqual(row.direction, 2) + XCTAssertEqual(row.inputs.count, 1) + XCTAssertEqual(row.outputs.count, 1) + XCTAssertTrue(try XCTUnwrap(row.inputs.first).isSpent) + } + + func testShouldPreserveFundedAssetLockAccountingDuringSyntheticReplayWithMissingPrevout() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0, burn: true) + let spender = PersistentTransaction( + txid: spenderId, transactionData: bytes, + direction: CoreDirectionCode.internalTransfer, netAmount: -200 + ) + spender.transactionTypeKind = 6 + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6, inputTxids: [walletId]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200) + XCTAssertEqual(row.direction, 2) + XCTAssertEqual(row.context, 2) + } + + func testShouldPreserveAssetLockDebitWhenNoInputIsLinkedYet() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let spenderId = Data(repeating: 3, count: 32) + let bytes = serializedSpend(inputs: [Data(repeating: 2, count: 32)], outputValue: 0, burn: true) + let lock = PersistentTransaction( + txid: spenderId, transactionData: bytes, + direction: CoreDirectionCode.internalTransfer, netAmount: -200 + ) + lock.transactionTypeKind = 6 + lock.fee = 7 + context.insert(lock) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -200, "a synthetic zero update must not erase the debit") + XCTAssertEqual(row.fee, 7) + XCTAssertEqual(row.direction, 2) + } + + func testShouldRepairNoChangeAssetLockToFullCoreDebit() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let spenderId = Data(repeating: 3, count: 32) + persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) + persist(handler, walletId: walletId, txid: spenderId, + bytes: serializedSpend(inputs: [walletId], outputValue: 0, burn: true), kind: 6, inputTxids: [walletId]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) + XCTAssertEqual(row.netAmount, -100) + XCTAssertEqual(row.direction, 2) + XCTAssertTrue(row.isAssetLock) + } + + func testShouldReportSoleWalletsStoredAmountDespitePendingInputs() { + let walletId = Data(repeating: 1, count: 32) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let change = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + change.walletId = walletId + tx.outputs = [change] + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletId + )] + XCTAssertEqual(tx.netAmount(for: walletId), 40, "the sole wallet's stored amount is Rust's net_amount") + } + + func testShouldNotComputeSharedAmountWhileOwnInputsArePending() { + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) + let toA = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) + toA.walletId = walletA + let toB = PersistentTxo(transaction: tx, vout: 1, amount: 60, address: "", height: 1) + toB.walletId = walletB + tx.outputs = [toA, toB] + tx.pendingInputs = [PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletA + )] + XCTAssertNil(tx.netAmount(for: walletA), "an unlinked input A recorded may be A's own coin") + XCTAssertEqual(tx.netAmount(for: walletB), 60) + } + + func testShouldKeepIncomingAmountWhenInputsAreForeign() throws { + let container = try DashModelContainer.createInMemory() + let walletId = Data(repeating: 1, count: 32) + container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let paymentId = Data(repeating: 3, count: 32) + let foreign = Data(repeating: 2, count: 32) + persist(handler, walletId: walletId, txid: paymentId, + bytes: serializedSpend(inputs: [foreign]), net: 40, + inputTxids: [foreign], outputs: [(paymentId, 40)]) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == paymentId }) + XCTAssertEqual(row.netAmount, 40) + XCTAssertEqual(row.netAmount(for: walletId), 40, "a foreign input is not an unresolved input of ours") + } + + private func localTransfer(in context: ModelContext, assetLock: Bool = false) throws -> PersistentTransaction { + let walletA = PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet) + let walletB = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let accountA = PersistentAccount(wallet: walletA, accountType: 0, accountIndex: 0, accountTypeName: "Standard") + let accountB = PersistentAccount(wallet: walletB, accountType: 0, accountIndex: 0, accountTypeName: "Standard") + context.insert(walletA) + context.insert(walletB) + context.insert(accountA) + context.insert(accountB) + let tx = PersistentTransaction( + txid: Data(repeating: 3, count: 32), + transactionData: serializedSpend(inputs: [walletA.walletId], outputValue: 90), + direction: CoreDirectionCode.internalTransfer, netAmount: -10 + ) + tx.transactionTypeKind = assetLock ? TransactionTypeKind.assetLock.rawValue : TransactionTypeKind.standard.rawValue + let coin = input(100) + coin.account = accountA + coin.isSpent = true + coin.spendingTransaction = tx + let credit = PersistentTxo(transaction: tx, vout: 0, amount: 90, address: "", height: 1) + credit.walletId = walletB.walletId + credit.account = accountB + context.insert(tx) + context.insert(coin) + context.insert(credit) + tx.involvedAccounts = [accountA, accountB] + try context.save() + return tx + } + + func testShouldKeepSurvivingWalletAccountingAfterDeletingEitherTransferWallet() throws { + for deletedWallet in [UInt8(1), UInt8(2)] { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let survivor = Data(repeating: deletedWallet == 1 ? 2 : 1, count: 32) + let amount: Int64 = deletedWallet == 1 ? 90 : -100 + let direction = deletedWallet == 1 ? CoreDirectionCode.incoming : CoreDirectionCode.outgoing + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + _ = try localTransfer(in: container.mainContext) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + try handler.deleteWalletData(walletId: Data(repeating: deletedWallet, count: 32)) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertEqual(row.netAmount(for: survivor), amount) + XCTAssertEqual(row.direction(for: survivor), direction) + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertEqual(row.netAmount(for: survivor), amount) + XCTAssertEqual(row.direction(for: survivor), direction) + } + } + } + + func testShouldKeepUnavailableAmountAfterDeletingAnotherParticipant() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + let survivor = Data(repeating: 2, count: 32) + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let foreign = Data(repeating: 9, count: 32) + tx.transactionData = serializedSpend(inputs: [Data(repeating: 1, count: 32), foreign], outputValue: 90) + tx.netAmount = 0 + let pending = PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: foreign, vout: 0), inputIndex: 1, + spendingTxid: tx.txid, spendingTransaction: tx, walletId: survivor + ) + container.mainContext.insert(pending) + try container.mainContext.save() + XCTAssertNil(tx.netAmount(for: survivor)) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + try handler.deleteWalletData(walletId: Data(repeating: 1, count: 32)) + // An empty asset-lock recovery update must not invent accounting. + persist(handler, walletId: survivor, txid: tx.txid, bytes: tx.transactionData, kind: 6) + } + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == Data(repeating: 3, count: 32) }) + XCTAssertNil(row.netAmount(for: survivor)) + XCTAssertEqual(row.formattedAmount(for: survivor), "Amount unavailable") + // A new authoritative wallet record can make the amount available. + persist(handler, walletId: survivor, txid: row.txid, bytes: row.transactionData, net: 90, kind: 6) + let updated = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == row.txid }) + XCTAssertEqual(updated.netAmount(for: survivor), 90) + } + } + + func testShouldScopeSharedAssetLockDirectionBeforeAndAfterReconciliation() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let walletA = Data(repeating: 1, count: 32) + let walletB = Data(repeating: 2, count: 32) + // A's Rust record is outgoing, while reconciliation sees both local wallets. + tx.direction = CoreDirectionCode.outgoing + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) + try container.mainContext.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.direction, CoreDirectionCode.internalTransfer) + XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(row.direction(for: walletB), CoreDirectionCode.incoming) + } + + func testShouldKeepOwnAssetLockConversionInternalWithAnotherKeysOnlyParticipant() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext, assetLock: true) + let walletA = Data(repeating: 1, count: 32) + let change = try XCTUnwrap(tx.outputs.first) + change.walletId = walletA + change.account = tx.inputs.first?.account + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) + XCTAssertEqual(tx.direction(for: Data(repeating: 2, count: 32)), CoreDirectionCode.internalTransfer) + // A no-change conversion remains internal too. + container.mainContext.delete(change) + tx.transactionData = serializedSpend(inputs: [walletA], outputValue: 90, burn: true) + try container.mainContext.save() + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.internalTransfer) + } + + func testShouldScopeDirectionAndAmountToEachWalletOfALocalTransfer() { + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + let (amount, fee): (UInt64, UInt64) = (90, 10) + let coin = input(amount + fee, wallet: 1) + // Whichever wallet recorded last owns the stored scalars; here the sender. + let tx = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: Data(), + direction: CoreDirectionCode.outgoing, netAmount: -Int64(amount + fee) + ) + let credit = PersistentTxo(transaction: tx, vout: 0, amount: amount, address: "", height: 1) + credit.walletId = walletB + tx.inputs = [coin] + tx.outputs = [credit] + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) + XCTAssertEqual(tx.netAmount(for: walletA), -Int64(amount + fee)) + XCTAssertEqual(tx.netAmount(for: walletB), Int64(amount)) + XCTAssertEqual(tx.formattedAmount(for: walletB), "+0.00000090 DASH") + } + + func testShouldReportAmountUnavailableOnlyWhenNoWalletAccountingApplies() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) + func makeAccount(of walletId: Data) -> PersistentAccount { + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let account = PersistentAccount( + wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + context.insert(wallet) + context.insert(account) + return account + } + let (accountA, accountB) = (makeAccount(of: walletA), makeAccount(of: walletB)) + // Payload-only (no linked TXO), e.g. a provider tx matched by key. + let payloadOnly = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) + let shared = PersistentTransaction(txid: Data(repeating: 4, count: 32), transactionData: Data(), netAmount: 40) + let unrecorded = PersistentTransaction(txid: Data(repeating: 5, count: 32), transactionData: Data(), netAmount: 40) + context.insert(payloadOnly) + context.insert(shared) + context.insert(unrecorded) + payloadOnly.involvedAccounts = [accountA] + shared.involvedAccounts = [accountA, accountB] + try context.save() + + XCTAssertEqual(payloadOnly.netAmount(for: walletA), 0, "the sole recorder's stored amount stands") + XCTAssertEqual(payloadOnly.formattedAmount(for: walletA), "+0.00000000 DASH") + XCTAssertNil(shared.netAmount(for: walletA), "the stored scalar may be the other recorder's") + XCTAssertNil(shared.netAmount(for: walletB)) + XCTAssertEqual(shared.formattedAmount(for: walletA), "Amount unavailable") + XCTAssertNil(unrecorded.netAmount(for: walletA), "a wallet with no stake has no amount") + } + + func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let senderId = Data(repeating: 1, count: 32) + let receiver = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) + let receiverAccount = PersistentAccount( + wallet: receiver, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" + ) + // P2PKH to pubkey hash 0x05 x 20 on testnet: B's address with no TXO row yet. + let receiverAddress = PersistentCoreAddress( + address: "yLmzEvw3frCPS4cyRmFFeKbt64fUPzMwFh", poolTypeTag: 0, addressIndex: 0, derivationPath: "" + ) + receiverAddress.account = receiverAccount + context.insert(PersistentWallet(walletId: senderId, network: .testnet)) + context.insert(receiver) + context.insert(receiverAccount) + context.insert(receiverAddress) + var bytes = Data([2, 0, 0, 0, 1]) + bytes.append(senderId) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) + withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: [25, 0x76, 0xa9, 0x14] + [UInt8](repeating: 5, count: 20) + [0x88, 0xac]) + bytes.append(contentsOf: [0, 0, 0, 0]) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: CoreDirectionCode.outgoing, netAmount: -100 + ) + let coin = input(100) + coin.spendingTransaction = spender + context.insert(coin) + context.insert(spender) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100, "the receiving wallet's credit is not the sender's") + XCTAssertEqual(row.netAmount(for: senderId), -100) + } + + func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let coin = input(100) + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.internalTransfer, netAmount: -60 + ) + coin.spendingTransaction = spender + let contactOutput = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) + contactOutput.walletId = walletId + contactOutput.account = contactAccount + context.insert(coin) + context.insert(spender) + context.insert(contactOutput) + try context.save() + XCTAssertEqual(spender.netAmount(for: walletId), -100) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, -100) + XCTAssertEqual(row.direction, 1) + XCTAssertEqual(row.netAmount(for: walletId), -100) + } + + func testShouldNotTreatPersistedContactInputAsOurFunding() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let coin = input(100) + coin.account = contactAccount + let spender = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + coin.spendingTransaction = spender + let received = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) + received.walletId = walletId + context.insert(coin) + context.insert(spender) + context.insert(received) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) + XCTAssertEqual(row.netAmount, 40) + XCTAssertEqual(row.direction, 0) + XCTAssertEqual(row.netAmount(for: walletId), 40) + } + +} From 4413d3246211c11a36a89122567558ff8ad66007 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:53:54 +0000 Subject: [PATCH 33/84] fix(swift-sdk): report a received amount despite a contact's watch-only output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `netAmount(for:)` treated a DashPay contact's watch-only TXO as a reason to distrust the sole participant's stored amount, fell through to the TXO-computed path, and there a pending foreign input made it answer `nil` ("Amount unavailable") although the wallet's own output gave the amount. The stored amount of such a row is still not trusted on its own: rows with a contact TXO predate the Rust filter that keeps a contact's record from defining the transaction row. The computed amount now stands despite a pending input when the wallet is the sole participant, every unowned TXO is a contact's, and the stored amount agrees with it. A stored amount the wallet's outputs do not confirm keeps answering `nil`, since the pending input may then be an unlinked coin of the wallet. `isContactTxo` names the contact predicate `isWalletOwnedTxo` already applied; its result is unchanged. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../Models/PersistentTransaction.swift | 27 +++++++++----- .../PlatformWalletPersistenceHandler.swift | 10 +++-- .../TransactionAccountingTests.swift | 37 +++++++++++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index a27fb766d11..cc84271d68f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -248,24 +248,31 @@ public final class PersistentTransaction { && seen.insert($0.outpoint).inserted } } - let hasUnownedTxos = (inputs + outputs).contains { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } - if participatingWalletIds == [walletId], !hasUnownedTxos { + let unownedTxos = (inputs + outputs).filter { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } + let isSoleParticipant = participatingWalletIds == [walletId] + if isSoleParticipant, unownedTxos.isEmpty { return netAmountUnavailable == true ? nil : netAmount } - // Computed from TXOs alone: a pending input this wallet recorded may be - // one of its own still-unlinked coins, so the sum is only provisional. - // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two - // local wallets leaves an unresolvable pending input per wallet, so both - // show "Amount unavailable". Store Rust's per-wallet net amount instead - // (tracked in #5226). - guard !pendingInputs.contains(where: { $0.walletId == walletId }) else { return nil } let walletInputs = owned(inputs) let walletOutputs = owned(outputs) guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } - return Self.reconciledAccounting( + let computed = Self.reconciledAccounting( inputs: walletInputs, ownedOutputAmounts: walletOutputs.map(\.amount), allOutputsOwned: false, previousDirection: direction, isAssetLock: isAssetLock )?.netAmount + // Computed from TXOs alone: a pending input this wallet recorded may be + // one of its own still-unlinked coins, so the sum is only provisional. + // It stands when only a contact's watch-only TXOs kept the sole + // participant off its stored amount and that amount agrees with it. + // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two + // local wallets leaves an unresolvable pending input per wallet, so both + // show "Amount unavailable". Store Rust's per-wallet net amount instead + // (tracked in #5226). + if pendingInputs.contains(where: { $0.walletId == walletId }) { + guard isSoleParticipant, netAmountUnavailable != true, computed == netAmount, + unownedTxos.allSatisfy(PlatformWalletPersistenceHandler.isContactTxo) else { return nil } + } + return computed } /// Direction relative to one wallet for transactions shared by multiple local wallets. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index f48f1614e63..efddb7e5f59 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -107,9 +107,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Contact watch-only TXOs do not belong to the wallet tracking their addresses. static func isWalletOwnedTxo(_ txo: PersistentTxo) -> Bool { - resolvedWalletId(of: txo) != nil - && txo.account?.accountType != dashpayExternalAccountTypeTag - && txo.coreAddress?.account?.accountType != dashpayExternalAccountTypeTag + resolvedWalletId(of: txo) != nil && !isContactTxo(txo) + } + + /// A TXO on a contact's watch-only chain, by its account or its address's. + static func isContactTxo(_ txo: PersistentTxo) -> Bool { + txo.account?.accountType == dashpayExternalAccountTypeTag + || txo.coreAddress?.account?.accountType == dashpayExternalAccountTypeTag } /// Network of the account's wallet, read through the same Optional cast diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 6fc27f08526..358235b6f7e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -692,4 +692,41 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(row.netAmount(for: walletId), 40) } + func testShouldReportReceivedAmountDespiteContactOutputWhenStoredAmountAgrees() throws { + let container = try DashModelContainer.createInMemory() + let context = container.mainContext + let walletId = Data(repeating: 1, count: 32) + let wallet = PersistentWallet(walletId: walletId, network: .testnet) + let contactAccount = PersistentAccount( + wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, + accountIndex: 0, accountTypeName: "DashPay External Account" + ) + context.insert(wallet) + context.insert(contactAccount) + let payment = PersistentTransaction( + txid: Data(repeating: 3, count: 32), transactionData: Data(), + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + let received = PersistentTxo(transaction: payment, vout: 0, amount: 40, address: "", height: 1) + received.walletId = walletId + let contactOutput = PersistentTxo(transaction: payment, vout: 1, amount: 60, address: "", height: 1) + contactOutput.walletId = walletId + contactOutput.account = contactAccount + context.insert(payment) + context.insert(received) + context.insert(contactOutput) + // The foreign input: every prevout with no local TXO is recorded as pending. + context.insert(PersistentPendingInput( + outpoint: Data(repeating: 9, count: 36), inputIndex: 0, + spendingTxid: payment.txid, spendingTransaction: payment, walletId: walletId + )) + try context.save() + XCTAssertEqual( + payment.netAmount(for: walletId), 40, "a contact's output does not make the received amount unknown" + ) + // A stored amount the wallet's own outputs do not confirm may hide an unlinked coin of ours. + payment.netAmount = -60 + XCTAssertNil(payment.netAmount(for: walletId)) + } + } From 7a282c927319075458cf02167471e568747a7f9b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:54:20 +0000 Subject: [PATCH 34/84] fix(swift-sdk): scope linked outputs to the spending wallets in reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `reconcileTransactionAccounting` counted every locally owned linked output as received, whichever wallet owned it, while the address fallback below it counted an unlinked output only for the spending wallets. When local wallet A spent 100 and local wallet B received 90 in one transaction, B's credit entered the stored scalar: -10 and "internal" instead of A's -100 and "outgoing". Linked outputs are now scoped by their wallet the same way. The stored scalar of a transfer between two local wallets is therefore the sender's; `testShouldScopeSharedAssetLockDirectionBeforeAndAfterReconciliation` pinned the old "internal" and is updated. Per-wallet answers (`netAmount(for:)`, `direction(for:)`) are unchanged: with two participants they never read the stored scalar. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../PlatformWalletPersistenceHandler.swift | 10 +++++++--- .../TransactionAccountingTests.swift | 17 +++++++++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index efddb7e5f59..ed99dee0327 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -7010,11 +7010,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { guard complete else { continue } var amounts: [UInt64] = [] var allOutputsOwned = true - let ownedVouts = Set(transaction.outputs.filter { !$0.isDeleted && Self.isWalletOwnedTxo($0) }.map(\.vout)) - // An address-matched output with no linked TXO carries no wallet of - // its own, so it counts only for the spending wallets: another local + // An output counts only for the spending wallets — a linked TXO by + // its wallet, an address-matched one by its address's: another local // wallet's credit must not hide inside the sender's scalar. let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) + let ownedVouts = Set(transaction.outputs.filter { output -> Bool in + guard !output.isDeleted, Self.isWalletOwnedTxo(output), + let owner = Self.resolvedWalletId(of: output) else { return false } + return spendingWallets.contains(owner) + }.map(\.vout)) for (index, output) in decoded.outputs.enumerated() { // OP_RETURN burns (including asset locks) are not spendable Core outputs. if output.isOpReturn { continue } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 358235b6f7e..4686f2abc39 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -501,7 +501,7 @@ final class TransactionAccountingTests: XCTestCase { let tx = try localTransfer(in: container.mainContext, assetLock: true) let walletA = Data(repeating: 1, count: 32) let walletB = Data(repeating: 2, count: 32) - // A's Rust record is outgoing, while reconciliation sees both local wallets. + // A's Rust record is outgoing, and reconciliation keeps the stored scalar the sender's. tx.direction = CoreDirectionCode.outgoing XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) @@ -510,7 +510,7 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertFalse(handler.loadWalletList().errored) let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) .first { $0.txid == tx.txid }) - XCTAssertEqual(row.direction, CoreDirectionCode.internalTransfer) + XCTAssertEqual(row.direction, CoreDirectionCode.outgoing) XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.outgoing) XCTAssertEqual(row.direction(for: walletB), CoreDirectionCode.incoming) } @@ -627,6 +627,19 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(row.netAmount(for: senderId), -100) } + func testShouldNotCountAnotherLocalWalletsLinkedOutputForTheSender() throws { + let container = try DashModelContainer.createInMemory() + let tx = try localTransfer(in: container.mainContext) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertFalse(handler.loadWalletList().errored) + let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) + .first { $0.txid == tx.txid }) + XCTAssertEqual(row.netAmount, -100, "the receiving wallet's linked credit is not the sender's") + XCTAssertEqual(row.direction, CoreDirectionCode.outgoing) + XCTAssertEqual(row.netAmount(for: Data(repeating: 1, count: 32)), -100) + XCTAssertEqual(row.netAmount(for: Data(repeating: 2, count: 32)), 90) + } + func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { let container = try DashModelContainer.createInMemory() let context = container.mainContext From 70a1b30c3adf44d093300c7d3cf18ccd85dca8ce Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:55:21 +0000 Subject: [PATCH 35/84] fix(swift-sdk): reconcile accounting when a TXO heal completes a spend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `drainPendingInputs` marks the healed coin's parent and its adopted spender in `accountingDirty`, but only `endChangeset` consumed that dictionary. `reconcileHealMissingTxos` runs outside a round and commits through `reconcileSave`, so a healed prevout repaired the TXO graph and left the spender's `netAmount` and `direction` stale until the next load-time pass. The heal step now reconciles the transactions it marked before its save, so the accounting commits with the coin and a failed save rolls both back. The step clears `accountingDirty` when it starts, because entries an earlier out-of-round writer left may point at rolled-back rows, and again once it has consumed them. Accounting is display-only: a failure is logged as `persistence_transaction_accounting_failed` with `phase=txo_reconcile_heal` and does not fail the heal. That catch has no fault-injection test yet (`TODO(test-heal-accounting-failure)`). 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PSi8cX229kW2fmLxKW6ir4 --- .../PlatformWalletPersistenceHandler.swift | 26 +++++++++-- .../CoreTxoReconcileTests.swift | 43 +++++++++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index ed99dee0327..2f50466e4a2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3313,10 +3313,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() - // Out-of-round writers (heal paths, deferred backfills) stage - // entries too; they are not this round's to reconcile and may - // point at rows a failed save rolled back. Load-time accounting - // repairs whatever they touched. + // Out-of-round writers stage entries too (deferred backfills, a + // heal step that stopped before reconciling its own); they are + // not this round's to reconcile and may point at rows a failed + // save rolled back. Load-time accounting repairs whatever is left. self.accountingDirty.removeAll() SDKLogger.event( "persistence_changeset_started", @@ -11523,6 +11523,9 @@ extension PlatformWalletPersistenceHandler { ) -> CoreTxoReconcileStep { onQueue { guard !inChangeset else { return .retryLater } + // This step reconciles only what it stages: entries left by an + // earlier out-of-round writer may point at rolled-back rows. + accountingDirty.removeAll() var counts = CoreTxoHealCounts() for row in rows { guard row.txid.count == 32, !row.scriptPubKey.isEmpty, !row.address.isEmpty else { @@ -11622,6 +11625,21 @@ extension PlatformWalletPersistenceHandler { ] ) } + // A healed prevout can complete a spend, so the accounting it + // resolves commits in the same save. Display-only: a failure is + // reported under its own event and the heal still lands. + // TODO(test-heal-accounting-failure): cover this catch with a + // FetchFaultInjector test; pinning which read faults needs a Swift run. + do { + try reconcileTransactionAccounting(Array(accountingDirty.values)) + } catch { + SDKLogger.event( + "persistence_transaction_accounting_failed", category: .persistence, severity: .error, + fields: ["phase": .publicText("txo_reconcile_heal"), "wallet_reference": .reference(walletId)], + error: error + ) + } + accountingDirty.removeAll() guard counts.inserted + counts.healedSpent == 0 || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) else { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index eac6a57af07..d8688d49aba 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -467,6 +467,49 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(try restoredUtxoCount(handler), 0) } + /// A healed coin can be the prevout a stored spender was missing. The + /// heal's own save then repairs that spender's amount and direction — + /// they do not stay stale until the next launch's load-time pass. + func testAHealedPrevoutRepairsItsSpendersAccounting() throws { + let (handler, container) = try makeHandler() + try seedWallet(in: container) + let coinTxid = txid(0x86) + let spenderTxid = txid(0x87) + // One input spending the missing coin, one output the wallet does not own. + var bytes = Data([2, 0, 0, 0, 1]) + bytes.append(coinTxid) + bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) + withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } + bytes.append(contentsOf: [0, 0, 0, 0, 0]) + let context = ModelContext(container) + // Stored as an incoming payment: what history showed while the coin was missing. + let spender = PersistentTransaction( + txid: spenderTxid, transactionData: bytes, context: 3, blockHeight: 2_391_800, + direction: CoreDirectionCode.incoming, netAmount: 40 + ) + context.insert(spender) + context.insert(PersistentPendingInput( + outpoint: PersistentTxo.makeOutpoint(txid: coinTxid, vout: 0), + inputIndex: 0, + spendingTxid: spenderTxid, + spendingTransaction: spender, + walletId: walletId + )) + try context.save() + let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: coinTxid)]) + + let report = run(handler, engine: engine) + + XCTAssertTrue(report.completed) + XCTAssertEqual(report.healedSpent, 1) + // Read without a load in between: the load-time pass would repair it too. + let row = try XCTUnwrap( + ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderTxid } + ) + XCTAssertEqual(row.netAmount, -19_549, "the healed coin is the spender's only input") + XCTAssertEqual(row.direction, CoreDirectionCode.outgoing) + } + /// A store read that fails inside the heal pass is a failure, not a /// skip: the account lookup cannot tell "no such account" from "could /// not read", so it must not answer the former when the latter From 33c253b67066bf103bd6ced267c9ad96b6cad27e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:19:35 +0000 Subject: [PATCH 36/84] fix(platform-wallet): reserve absent inputs of unsafe stored spends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep recorded owned inputs reserved when their funding is absent from the restored UTXO projection. Retain the unconfirmed spender in its owning account so later conflict settlement can release the other inputs. Add a regression covering absent funding, redelivery protection and release after a confirmed competitor sweeps the stored spender. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: OpenAI Codex --- .../src/manager/history_replay.rs | 108 ++++++++++++++++-- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 36fea2463fb..0c2d8165f59 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -213,13 +213,22 @@ pub async fn replay_recorded_history( ); } } - // The restored coins the records left out spend: they leave the UTXO set - // below and are claimed for those records once the replay is over. + // Reserve restored coins and recorded owned inputs absent from the + // projection: unsafe stored amounts do not prove the transaction invalid. let reserved_coins: HashSet = set_aside .iter() .flat_map(|stored| &stored.transaction.input) .map(|input| input.previous_output) .filter(|outpoint| placed.contains_key(outpoint)) + .chain(set_aside.iter().flat_map(|stored| { + stored.owned_inputs.iter().filter_map(|detail| { + stored + .transaction + .input + .get(detail.index as usize) + .map(|input| input.previous_output) + }) + })) .collect(); for account in wallet_info.accounts.all_funding_accounts_mut() { account @@ -295,8 +304,8 @@ pub async fn replay_recorded_history( } } // An unconfirmed record left out stays on record, as stored, in every - // account holding a coin it spends or pays: a settled conflict, stored or - // still to come, must find it there to sweep it and release its coins. + // account owning a recorded input or holding a coin it spends or pays: + // a settled conflict must find it there to sweep it and release its coins. for stored in &set_aside { if stored.context.block_info().is_some() { continue; @@ -313,7 +322,15 @@ pub async fn replay_recorded_history( .collect(); for account in wallet_info.accounts.all_funding_accounts_mut() { let owner = funds_account_type(account); - if owners.contains(&owner) { + let owns_recorded_input = stored.owned_inputs.iter().any(|detail| { + stored + .transaction + .input + .get(detail.index as usize) + .is_some() + && account.contains_address(&detail.address) + }); + if owners.contains(&owner) || owns_recorded_input { account .transactions_mut() .entry(stored.txid) @@ -1976,8 +1993,8 @@ mod tests { let account = &restored.accounts.standard_bip44_accounts[&0]; assert_eq!( account.transactions().contains_key(&unsafe_spend.txid()), - !settled && staged.is_none(), - "{case}: only an unconfirmed record reserving a restored coin stays on record" + !settled, + "{case}: an unconfirmed record with owned inputs stays on record" ); assert_eq!( account.utxos.contains_key(&coin), @@ -2180,6 +2197,83 @@ mod tests { assert_eq!(restored.balance.total(), 20_000); } + /// Unsafe stored metadata must not erase ownership or spend reservations + /// when the store has already removed the funding coins from its projection. + #[tokio::test] + async fn should_reserve_absent_inputs_of_unsafe_stored_record_until_swept() { + let (mut wallet, mut restored, mut records, winner, funding_b, [coin_a, coin_b]) = + unsafe_loser_fixture().await; + let account = restored + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap(); + let address = account.utxos[&coin_b].address.clone(); + account.utxos.clear(); + let mut loser = records.pop().unwrap(); + loser.transaction.output[0].value = 25_000; + loser.txid = loser.transaction.txid(); + loser.owned_inputs = vec![InputDetail { + index: 1, + value: 1 << 63, + address, + }]; + let loser_txid = loser.txid; + let outcome = replay_recorded_history( + &mut restored, + &mut wallet, + RecordedHistory { + transactions: vec![loser], + instant_locks: BTreeMap::new(), + }, + ) + .await; + assert_eq!(outcome.replayed, 0); + assert!( + restored.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&loser_txid), + "the owned-input record must remain available for a later conflict sweep" + ); + let mut reserved = restored.clone(); + reserved + .check_core_transaction( + &funding_b, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + assert!( + !reserved.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&coin_b), + "funding redelivery must not make an unsafe stored spender's input available" + ); + let settled = restored + .check_core_transaction(&winner.transaction, winner.context, &mut wallet, true, true) + .await; + assert_eq!(settled.swept_transactions, vec![loser_txid]); + assert_eq!(settled.released_outpoints, vec![coin_b]); + restored + .check_core_transaction( + &funding_b, + TransactionContext::Mempool, + &mut wallet, + true, + true, + ) + .await; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert!(!account.transactions().contains_key(&loser_txid)); + assert!(!account.utxos.contains_key(&coin_a)); + assert!( + account.utxos.contains_key(&coin_b), + "the input unused by the winner becomes available after its claimant is swept" + ); + } + /// Planning must not run one whole-history sweep per settled record that /// merely shares an input with an unconfirmed one: a sweep is worth /// running only while a competitor it could remove is still there, and it From 018f00fdb3bc3acf70abb07dd338e61dde94238f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:16:46 +0000 Subject: [PATCH 37/84] fix(swift-sdk): preserve base V4 schema before accounting migration Freeze the complete base V4 graph and migrate accounting availability through V5. Keep historical routes and the legacy bridge intact. Co-Authored-By: OpenAI Codex --- packages/swift-sdk/SCHEMA_RELEASES.md | 14 +- .../Persistence/DashModelContainer.swift | 30 +- ...shSchemaSnapshotV4+PersistentAccount.swift | 78 ++++ ...SchemaSnapshotV4+PersistentAssetLock.swift | 106 +++++ ...hemaSnapshotV4+PersistentCoreAddress.swift | 68 +++ ...hSchemaSnapshotV4+PersistentDPNSName.swift | 132 ++++++ ...otV4+PersistentDashpayContactProfile.swift | 97 +++++ ...otV4+PersistentDashpayContactRequest.swift | 118 +++++ ...hotV4+PersistentDashpayIgnoredSender.swift | 67 +++ ...aSnapshotV4+PersistentDashpayPayment.swift | 99 +++++ ...aSnapshotV4+PersistentDashpayProfile.swift | 70 +++ ...emaSnapshotV4+PersistentDataContract.swift | 286 +++++++++++++ ...hSchemaSnapshotV4+PersistentDocument.swift | 181 ++++++++ ...emaSnapshotV4+PersistentDocumentType.swift | 154 +++++++ ...hSchemaSnapshotV4+PersistentIdentity.swift | 304 +++++++++++++ ...V4+PersistentIdentityBalanceMetadata.swift | 32 ++ ...DashSchemaSnapshotV4+PersistentIndex.swift | 113 +++++ ...chemaSnapshotV4+PersistentInvitation.swift | 73 ++++ ...shSchemaSnapshotV4+PersistentKeyword.swift | 40 ++ ...chemaSnapshotV4+PersistentMasternode.swift | 185 ++++++++ ...emaSnapshotV4+PersistentPendingInput.swift | 46 ++ ...SnapshotV4+PersistentPlatformAddress.swift | 78 ++++ ...PersistentPlatformAddressesSyncState.swift | 41 ++ ...hSchemaSnapshotV4+PersistentProperty.swift | 55 +++ ...SchemaSnapshotV4+PersistentPublicKey.swift | 218 ++++++++++ ...napshotV4+PersistentShieldedActivity.swift | 89 ++++ ...emaSnapshotV4+PersistentShieldedNote.swift | 66 +++ ...hotV4+PersistentShieldedOutgoingNote.swift | 49 +++ ...apshotV4+PersistentShieldedSyncState.swift | 34 ++ ...pshotV4+PersistentShieldedViewingKey.swift | 34 ++ ...DashSchemaSnapshotV4+PersistentToken.swift | 403 ++++++++++++++++++ ...emaSnapshotV4+PersistentTokenBalance.swift | 198 +++++++++ ...apshotV4+PersistentTokenHistoryEvent.swift | 112 +++++ ...apshotV4+PersistentTrackedMasternode.swift | 42 ++ ...hemaSnapshotV4+PersistentTransaction.swift | 167 ++++++++ .../DashSchemaSnapshotV4+PersistentTxo.swift | 99 +++++ ...ashSchemaSnapshotV4+PersistentWallet.swift | 95 +++++ ...otV4+PersistentWalletManagerMetadata.swift | 34 ++ .../DashSchemaSnapshotV4+Schema.swift | 47 ++ .../DashSchemaSnapshotV4+TokenTypes.swift | 165 +++++++ .../DashLegacySchemaMigrationTests.swift | 13 +- .../DashModelMigrationTests.swift | 12 +- .../DashReleasedSchemaTests.swift | 20 +- .../swift-sdk/scripts/freeze_schema_models.py | 16 +- .../scripts/test_freeze_schema_models.py | 2 +- 45 files changed, 4345 insertions(+), 37 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAccount.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAssetLock.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentCoreAddress.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDPNSName.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactProfile.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactRequest.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayIgnoredSender.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayPayment.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayProfile.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDataContract.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocument.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocumentType.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentity.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentityBalanceMetadata.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIndex.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentInvitation.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentKeyword.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPendingInput.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddress.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddressesSyncState.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentProperty.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPublicKey.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedActivity.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedOutgoingNote.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedSyncState.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedViewingKey.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentToken.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenBalance.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenHistoryEvent.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTrackedMasternode.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTransaction.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTxo.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWallet.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWalletManagerMetadata.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+Schema.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+TokenTypes.swift diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 5d3fd2e4dc8..6a8aef72f51 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -5,7 +5,7 @@ distribution. TestFlight uploads capture provenance and a synthetic SQLite fixture, but do not by themselves register a released schema. The accepted frozen V1 remains unchanged. Historical V2 is now reconstructed from `52e8d4ec68f0c772313fa1bbef223fb1eabbf1cc`; all 35 entity hashes and the model checksum match the observed App Store 9.0.2 database. Active models are -V4. The released V3 graph is preserved by `DashSchemaSnapshotV3`. Other +V5. The merged V4 graph is preserved by `DashSchemaSnapshotV4`. The released V3 graph is preserved by `DashSchemaSnapshotV3`. Other intermediate development shapes remain unsupported. The old V2 fixture was generated from September 8 sources containing 13 @@ -16,15 +16,15 @@ separate from archive-captured releases. This identifies a matching model source, not the confirmed build commit of Apple's binary. V2 is reserved: automated release capture must not register another shape under that number. -The main migration plan is historical V2 → V3 → V4. Accepted V1 has a separate -V1 → V4 plan: V1 already contains the 13 properties missing from historical +The main migration plan is historical V2 → V3 → V4 → V5. Accepted V1 has a separate +V1 → V3 → V4 → V5 plan: V1 already contains the 13 properties missing from historical V2, so a chain through V2 could discard values. Routing uses model metadata and runs after recovery; a version label alone never selects an unknown beta schema. The former live V2 is accepted only when its complete graph matches frozen V3 exactly, entity hashes and checksum alike. The alias remains supported when the live graph changes; other stores labelled `2.0.0` fail closed. -V4 adds the optional `PersistentTransaction.netAmountUnavailable` marker through +V5 adds the optional `PersistentTransaction.netAmountUnavailable` marker through a lightweight migration. Existing rows receive `nil`, preserving their stored accounting. When deleting one participant of a shared transaction, the remaining wallet's amount and direction are saved before the ownership links disappear. @@ -33,11 +33,11 @@ an authoritative wallet record or complete accounting reconciliation clears it. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, -`accepted-v1-to-v4`, `historical-v2-to-v4`, `previous-live-v2-v3-shape`, -`published-v3-to-v4`, `unsupported-v2`, `labelled-current-v4`, `ordinary-current-plan` or, from the +`accepted-v1-to-v5`, `historical-v2-to-v5`, `previous-live-v2-v3-shape`, +`published-v3-to-v5`, `published-v4-to-v5`, `unsupported-v2`, `labelled-current-v5`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a -label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` or `4.0.0` label takes the +label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0`, `4.0.0` or `5.0.0` label takes the default plan without any probe, and a probe failure on the undecidable labels refuses the open with the probe's own error while leaving the store untouched. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 58be6921bd4..3614c6633cd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -100,7 +100,7 @@ public enum DashModelContainer { /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV5.self) } /// Create a persistent model container for storing data. @@ -247,7 +247,7 @@ public enum DashModelContainer { // probe stays fatal here: the open fails with the probe's own error // instead of an inapplicable plan, and the store is untouched. if versions == ["1.0.0"], try matches(DashSchemaV1.self) { - logRoute("accepted-v1-to-v4") + logRoute("accepted-v1-to-v5") return DashAcceptedV1MigrationPlan.self } if versions == ["2.0.0"] { @@ -261,14 +261,16 @@ public enum DashModelContainer { throw DashLegacyStoreSQLite.Failure.unsupported( "The database identifies itself as schema 2.0.0 but its model does not match the supported historical or current schema. The original database has not been replaced. Contact support; do not delete the app.") } - logRoute(historical ? "historical-v2-to-v4" : "previous-live-v2-v3-shape") + logRoute(historical ? "historical-v2-to-v5" : "previous-live-v2-v3-shape") } else if versions == ["3.0.0"] { - logRoute("published-v3-to-v4") + logRoute("published-v3-to-v5") } else if versions == ["4.0.0"] { + logRoute("published-v4-to-v5") + } else if versions == ["5.0.0"] { // Same plan either way. Opening a current store must neither wait // on nor fail with a schema probe that could only refine this line; // `source_checksum` above already identifies the exact graph. - logRoute("labelled-current-v4") + logRoute("labelled-current-v5") } else { logRoute("ordinary-current-plan") } @@ -289,13 +291,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -303,10 +306,11 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self] } + static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self)] } } @@ -382,8 +386,14 @@ public enum DashSchemaV3: VersionedSchema { public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } } -/// Current schema: optional drain operation IDs and transaction accounting availability. +/// Frozen base schema with optional drain operation IDs. public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV4.models } +} + +/// Current schema: optional transaction accounting availability. +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAccount.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAccount.swift new file mode 100644 index 00000000000..986e433ce60 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAccount.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentAccount` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentAccount { + #Unique([ + \.wallet, + \.accountType, + \.accountIndex, + \.standardTag, + \.registrationIndex, + \.keyClass, + \.userIdentityId, + \.friendIdentityId, + ]) + + var accountType: UInt32 + var accountIndex: UInt32 + var accountTypeName: String + var balanceConfirmed: UInt64 + var balanceUnconfirmed: UInt64 + var externalHighestUsed: Int32 + var internalHighestUsed: Int32 + var standardTag: UInt8 + var registrationIndex: UInt32 + var keyClass: UInt32 + var userIdentityId: Data + var friendIdentityId: Data + @Attribute(.unique) var accountExtendedPubKeyBytes: Data? + var createdAt: Date + var lastUpdated: Date + + var wallet: PersistentWallet + + @Relationship(deleteRule: .cascade, inverse: \PersistentCoreAddress.account) + var coreAddresses: [PersistentCoreAddress] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPlatformAddress.account) + var platformAddresses: [PersistentPlatformAddress] + + var involvedTransactions: [PersistentTransaction] = [] + + init( + wallet: PersistentWallet, + accountType: UInt32, + accountIndex: UInt32, + accountTypeName: String + ) { + self.wallet = wallet + self.accountType = accountType + self.accountIndex = accountIndex + self.accountTypeName = accountTypeName + self.balanceConfirmed = 0 + self.balanceUnconfirmed = 0 + self.externalHighestUsed = -1 + self.internalHighestUsed = -1 + self.standardTag = 0 + self.registrationIndex = 0 + self.keyClass = 0 + self.userIdentityId = Data() + self.friendIdentityId = Data() + self.accountExtendedPubKeyBytes = nil + self.createdAt = Date() + self.lastUpdated = Date() + self.coreAddresses = [] + self.platformAddresses = [] + self.involvedTransactions = [] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAssetLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAssetLock.swift new file mode 100644 index 00000000000..d5a66f33295 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentAssetLock.swift @@ -0,0 +1,106 @@ +import Foundation +import SwiftData + +// `PersistentAssetLock` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentAssetLock { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var walletId: Data + + var transactionBytes: Data + + var fundingTypeRaw: Int + + var identityIndexRaw: Int32 + + var accountIndexRaw: Int32 = 0 + + var amountDuffs: Int64 + + var statusRaw: Int + + var proofBytes: Data? + + var recipientPlatformAddressHash: Data? + + var recipientPlatformAddressType: UInt8? + + var recipientIsExternal: Bool? + + var operationId: Data? + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + walletId: Data, + transactionBytes: Data, + fundingTypeRaw: Int, + identityIndexRaw: Int32, + accountIndexRaw: Int32 = 0, + amountDuffs: Int64, + statusRaw: Int, + proofBytes: Data? = nil, + operationId: Data? = nil + ) { + self.outPointHex = outPointHex + self.walletId = walletId + self.transactionBytes = transactionBytes + self.fundingTypeRaw = fundingTypeRaw + self.identityIndexRaw = identityIndexRaw + self.accountIndexRaw = accountIndexRaw + self.amountDuffs = amountDuffs + self.statusRaw = statusRaw + self.proofBytes = proofBytes + self.operationId = operationId + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentAssetLock { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static func predicate( + walletId: Data, + identityIndex: UInt32 + ) -> Predicate { + let identityIndexRaw = Int32(bitPattern: identityIndex) + return #Predicate { entry in + entry.walletId == walletId && entry.identityIndexRaw == identityIndexRaw + } + } +} + +extension DashSchemaSnapshotV4.PersistentAssetLock { + static func encodeOutPoint(rawBytes: Data) -> String { + precondition(rawBytes.count == 36, "outpoint must be 36 bytes") + let txid = rawBytes.prefix(32) + let voutBytes = rawBytes.suffix(4) + let vout = voutBytes.withUnsafeBytes { raw -> UInt32 in + var value: UInt32 = 0 + withUnsafeMutableBytes(of: &value) { dst in + dst.copyBytes(from: raw.prefix(MemoryLayout.size)) + } + return UInt32(littleEndian: value) + } + let txidHex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(txidHex):\(vout)" + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentCoreAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentCoreAddress.swift new file mode 100644 index 00000000000..3deef36953b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentCoreAddress.swift @@ -0,0 +1,68 @@ +import Foundation +import SwiftData + +// `PersistentCoreAddress` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentCoreAddress { + @Attribute(.unique) var address: String + var publicKey: Data + var keyType: UInt8 = 0 + var poolTypeTag: UInt8 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt32 + var balance: UInt64 + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.coreAddress) + var txos: [PersistentTxo] = [] + + init( + address: String, + publicKey: Data = Data(), + keyType: UInt8 = 0, + poolTypeTag: UInt8, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0 + ) { + self.address = address + self.publicKey = publicKey + self.keyType = keyType + self.poolTypeTag = poolTypeTag + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.balance = balance + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentCoreAddress { + var poolTypeName: String { + switch poolTypeTag { + case 0: return "External" + case 1: return "Internal" + case 2: return "Additional" + case 3: return "Additional (Hardened)" + default: return "Unknown(\(poolTypeTag))" + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDPNSName.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDPNSName.swift new file mode 100644 index 00000000000..8e3deed2f65 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDPNSName.swift @@ -0,0 +1,132 @@ +import Foundation +import SwiftData + +// `PersistentDPNSName` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDPNSName { + #Unique([\.networkRaw, \.normalizedParentDomainName, \.normalizedLabel]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var label: String + + var normalizedLabel: String + + var parentDomainName: String + + var normalizedParentDomainName: String + + var acquiredAt: UInt64 + + var isOwned: Bool = true + + var documentIdBase58: String? + + var priceCredits: Int64? + + var saleStatusRaw: Int16 = 0 + + var counterpartyIdBase58: String? + + var documentCreatedAtMs: UInt64? + + var documentUpdatedAtMs: UInt64? + + var documentTransferredAtMs: UInt64? + + var marketplaceUpdatedAt: UInt64 = 0 + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + label: String, + parentDomainName: String = "dash", + acquiredAt: UInt64 = 0, + isOwned: Bool = true + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.label = label + self.normalizedLabel = Self.normalize(label) + self.parentDomainName = parentDomainName + self.normalizedParentDomainName = Self.normalize(parentDomainName) + self.acquiredAt = acquiredAt + self.isOwned = isOwned + self.documentIdBase58 = nil + self.priceCredits = nil + self.saleStatusRaw = 0 + self.counterpartyIdBase58 = nil + self.documentCreatedAtMs = nil + self.documentUpdatedAtMs = nil + self.documentTransferredAtMs = nil + self.marketplaceUpdatedAt = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDPNSName { + var saleStatus: DpnsNameSaleStatus? { + guard documentIdBase58 != nil else { return nil } + switch saleStatusRaw { + case 0: + return .owned + case 1: + guard let to = counterpartyId else { return nil } + return .sold(to: to) + case 2: + guard let to = counterpartyId else { return nil } + return .transferred(to: to) + default: + return nil + } + } + + var counterpartyId: Data? { + counterpartyIdBase58.flatMap { Data.identifier(fromBase58: $0) } + } + + var listedPriceCredits: UInt64? { + guard documentIdBase58 != nil, let priceCredits else { return nil } + return UInt64(bitPattern: priceCredits) + } +} + +extension DashSchemaSnapshotV4.PersistentDPNSName { + static func normalize(_ input: String) -> String { + String(input.map { c -> Character in + switch c { + case "o", "O": return "0" + case "i", "I": return "1" + case "l", "L": return "1" + default: return Character(c.lowercased()) + } + }) + } +} + +extension DashSchemaSnapshotV4.PersistentDPNSName { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { name in + name.identity.identityId == target && name.isOwned == true + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactProfile.swift new file mode 100644 index 00000000000..37df99d2187 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactProfile.swift @@ -0,0 +1,97 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactProfile` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDashpayContactProfile { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var checkedAtMs: UInt64 + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + checkedAtMs: UInt64, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.checkedAtMs = checkedAtMs + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDashpayContactProfile { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + contactIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let contact = contactIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.contactIdentityId == contact + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactRequest.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactRequest.swift new file mode 100644 index 00000000000..83d17bfafad --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayContactRequest.swift @@ -0,0 +1,118 @@ +import Foundation +import SwiftData + +// `PersistentDashpayContactRequest` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDashpayContactRequest { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.contactIdentityId, \.isOutgoing + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var contactIdentityId: Data + + var isOutgoing: Bool + + var senderKeyIndex: UInt32 + + var recipientKeyIndex: UInt32 + + var accountReference: UInt32 + + var encryptedPublicKey: Data + + var encryptedAccountLabel: Data? + + var autoAcceptProof: Data? + + var coreHeightCreatedAt: UInt32 + + var createdAtMillis: UInt64 + + var paymentChannelBroken: Bool = false + + var contactAlias: String? + + var contactNote: String? + + var contactHidden: Bool = false + + var contactAccountLabel: String? + + var contactAcceptedAccounts: [UInt32] = [] + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + contactIdentityId: Data, + isOutgoing: Bool, + senderKeyIndex: UInt32, + recipientKeyIndex: UInt32, + accountReference: UInt32, + encryptedPublicKey: Data, + encryptedAccountLabel: Data? = nil, + autoAcceptProof: Data? = nil, + coreHeightCreatedAt: UInt32, + createdAtMillis: UInt64, + paymentChannelBroken: Bool = false + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.contactIdentityId = contactIdentityId + self.isOutgoing = isOutgoing + self.senderKeyIndex = senderKeyIndex + self.recipientKeyIndex = recipientKeyIndex + self.accountReference = accountReference + self.encryptedPublicKey = encryptedPublicKey + self.encryptedAccountLabel = encryptedAccountLabel + self.autoAcceptProof = autoAcceptProof + self.coreHeightCreatedAt = coreHeightCreatedAt + self.createdAtMillis = createdAtMillis + self.paymentChannelBroken = paymentChannelBroken + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDashpayContactRequest { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + isOutgoing: Bool + ) -> Predicate { + let target = ownerIdentityId + let direction = isOutgoing + return #Predicate { row in + row.ownerIdentityId == target && row.isOutgoing == direction + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayIgnoredSender.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayIgnoredSender.swift new file mode 100644 index 00000000000..fd8e91d8177 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayIgnoredSender.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +// `PersistentDashpayIgnoredSender` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDashpayIgnoredSender { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.ignoredSenderId + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var ignoredSenderId: Data + + var owner: PersistentIdentity + + var ignoredAt: Date + + init( + owner: PersistentIdentity, + ignoredSenderId: Data + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.ignoredSenderId = ignoredSenderId + self.ignoredAt = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDashpayIgnoredSender { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + ignoredSenderId: Data + ) -> Predicate { + let target = ownerIdentityId + let sender = ignoredSenderId + return #Predicate { row in + row.ownerIdentityId == target + && row.ignoredSenderId == sender + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayPayment.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayPayment.swift new file mode 100644 index 00000000000..6783a127296 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayPayment.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentDashpayPayment` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDashpayPayment { + #Unique([ + \.networkRaw, \.ownerIdentityId, \.txid + ]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var ownerIdentityId: Data + + var counterpartyIdentityId: Data + + var amountDuffs: UInt64 + + var directionRaw: UInt8 + + var direction: DashPayPaymentDirection { + get { DashPayPaymentDirection(rawValue: directionRaw) ?? .sent } + set { directionRaw = newValue.rawValue } + } + + var statusRaw: UInt8 + + var status: DashPayPaymentStatus { + get { DashPayPaymentStatus(rawValue: statusRaw) ?? .pending } + set { statusRaw = newValue.rawValue } + } + + var txid: String + + var memo: String? + + var owner: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + owner: PersistentIdentity, + counterpartyIdentityId: Data, + amountDuffs: UInt64, + direction: DashPayPaymentDirection, + status: DashPayPaymentStatus, + txid: String, + memo: String? = nil + ) { + self.owner = owner + self.networkRaw = owner.networkRaw + self.ownerIdentityId = owner.identityId + self.counterpartyIdentityId = counterpartyIdentityId + self.amountDuffs = amountDuffs + self.directionRaw = direction.rawValue + self.statusRaw = status.rawValue + self.txid = txid + self.memo = memo + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDashpayPayment { + static func predicate( + ownerIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + return #Predicate { row in + row.ownerIdentityId == target + } + } + + static func predicate( + ownerIdentityId: Data, + counterpartyIdentityId: Data + ) -> Predicate { + let target = ownerIdentityId + let counterparty = counterpartyIdentityId + return #Predicate { row in + row.ownerIdentityId == target + && row.counterpartyIdentityId == counterparty + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayProfile.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayProfile.swift new file mode 100644 index 00000000000..a5f835fe3af --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDashpayProfile.swift @@ -0,0 +1,70 @@ +import Foundation +import SwiftData + +// `PersistentDashpayProfile` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDashpayProfile { + #Unique([\.networkRaw, \.identity]) + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var displayName: String? + + var publicMessage: String? + + var bio: String? + + var avatarUrl: String? + + var avatarHash: Data? + + var avatarFingerprint: Data? + + var identity: PersistentIdentity + + var createdAt: Date + var lastUpdated: Date + + init( + identity: PersistentIdentity, + displayName: String? = nil, + publicMessage: String? = nil, + bio: String? = nil, + avatarUrl: String? = nil, + avatarHash: Data? = nil, + avatarFingerprint: Data? = nil + ) { + self.identity = identity + self.networkRaw = identity.networkRaw + self.displayName = displayName + self.publicMessage = publicMessage + self.bio = bio + self.avatarUrl = avatarUrl + self.avatarHash = avatarHash + self.avatarFingerprint = avatarFingerprint + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDashpayProfile { + static func predicate(identityId: Data) -> Predicate { + let target = identityId + return #Predicate { profile in + profile.identity.identityId == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDataContract.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDataContract.swift new file mode 100644 index 00000000000..2aec109f10e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDataContract.swift @@ -0,0 +1,286 @@ +import Foundation +import SwiftData + +// `PersistentDataContract` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDataContract { + #Index([\.networkRaw]) + + @Attribute(.unique) var id: Data + var name: String + var serializedContract: Data + var createdAt: Date + var lastAccessedAt: Date + + var binarySerialization: Data? + + var version: Int? + var ownerId: Data? + + @Relationship(deleteRule: .cascade, inverse: \PersistentKeyword.dataContract) + var keywordRelations: [PersistentKeyword] + var contractDescription: String? + + var schemaData: Data + var documentTypesData: Data + + var groupsData: Data? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var lastUpdated: Date + var lastSyncedAt: Date? + + var canBeDeleted: Bool + var readonly: Bool + var keepsHistory: Bool + var schemaDefs: Int? + + var documentsKeepHistoryContractDefault: Bool + var documentsMutableContractDefault: Bool + var documentsCanBeDeletedContractDefault: Bool + + @Relationship(deleteRule: .cascade, inverse: \PersistentToken.dataContract) + var tokens: [PersistentToken]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocumentType.dataContract) + var documentTypes: [PersistentDocumentType]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.dataContract) + var documents: [PersistentDocument] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.ownedDataContracts) + var ownerIdentity: PersistentIdentity? + + var hasTokens: Bool + var tokensData: Data? + + var idBase58: String { + id.toBase58String() + } + + var ownerIdBase58: String? { + ownerId?.toBase58String() + } + + var parsedContract: [String: Any]? { + try? JSONSerialization.jsonObject(with: serializedContract, options: []) as? [String: Any] + } + + var binarySerializationHex: String? { + binarySerialization?.toHexString() + } + + var keywords: [String] { + keywordRelations.map { $0.keyword } + } + + var schema: [String: Any] { + get { + guard let json = try? JSONSerialization.jsonObject(with: schemaData), + let dict = json as? [String: Any] else { + return [:] + } + return dict + } + set { + schemaData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var documentTypesList: [String] { + get { + guard let json = try? JSONSerialization.jsonObject(with: documentTypesData), + let array = json as? [String] else { + return [] + } + return array + } + set { + documentTypesData = (try? JSONSerialization.data(withJSONObject: newValue)) ?? Data() + lastUpdated = Date() + } + } + + var tokenConfigurations: [String: Any]? { + get { + guard let data = tokensData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + tokensData = try? JSONSerialization.data(withJSONObject: newValue) + hasTokens = true + } else { + tokensData = nil + hasTokens = false + } + lastUpdated = Date() + } + } + + var groups: [String: Any]? { + get { + guard let data = groupsData, + let json = try? JSONSerialization.jsonObject(with: data), + let dict = json as? [String: Any] else { + return nil + } + return dict + } + set { + if let newValue = newValue { + groupsData = try? JSONSerialization.data(withJSONObject: newValue) + } else { + groupsData = nil + } + lastUpdated = Date() + } + } + + init( + id: Data, + name: String, + serializedContract: Data, + version: Int? = 1, + ownerId: Data? = nil, + schema: [String: Any] = [:], + documentTypesList: [String] = [], + keywords: [String] = [], + description: String? = nil, + hasTokens: Bool = false, + network: Network + ) { + self.id = id + self.name = name + self.serializedContract = serializedContract + self.createdAt = Date() + self.lastAccessedAt = Date() + self.version = version + self.ownerId = ownerId + + self.schemaData = (try? JSONSerialization.data(withJSONObject: schema)) ?? Data() + self.documentTypesData = (try? JSONSerialization.data(withJSONObject: documentTypesList)) ?? Data() + + self.keywordRelations = keywords.map { PersistentKeyword(keyword: $0, contractId: id.toBase58String()) } + self.contractDescription = description + + self.hasTokens = hasTokens + self.tokensData = nil + + self.groupsData = nil + + self.documents = [] + + self.ownerIdentity = nil + + self.networkRaw = network.rawValue + self.lastUpdated = Date() + self.lastSyncedAt = nil + + self.canBeDeleted = false + self.readonly = false + self.keepsHistory = false + self.documentsKeepHistoryContractDefault = false + self.documentsMutableContractDefault = true + self.documentsCanBeDeletedContractDefault = true + } + + func updateLastAccessed() { + self.lastAccessedAt = Date() + } + + func updateVersion(_ newVersion: Int) { + self.version = newVersion + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func addDocument(_ document: PersistentDocument) { + documents.append(document) + lastUpdated = Date() + } + + func removeDocument(withId documentId: String) { + if let docIdData = Data.identifier(fromBase58: documentId) { + documents.removeAll { $0.id == docIdData } + } + lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDataContract { + static func predicate(contractId: String) -> Predicate { + guard let idData = Data.identifier(fromBase58: contractId) else { + return #Predicate { _ in false } + } + return #Predicate { contract in + contract.id == idData + } + } + + static func predicate(ownerId: Data) -> Predicate { + #Predicate { contract in + contract.ownerId == ownerId + } + } + + static func predicate(name: String) -> Predicate { + #Predicate { contract in + contract.name.localizedStandardContains(name) + } + } + + static var contractsWithTokensPredicate: Predicate { + #Predicate { contract in + contract.hasTokens == true + } + } + + static func predicate(keyword: String) -> Predicate { + #Predicate { contract in + contract.keywordRelations.contains { $0.keyword == keyword } + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { contract in + contract.lastSyncedAt == nil || contract.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.networkRaw == target + } + } + + static func contractsWithTokensPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { contract in + contract.hasTokens == true && contract.networkRaw == target + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocument.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocument.swift new file mode 100644 index 00000000000..adddc393f53 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocument.swift @@ -0,0 +1,181 @@ +import Foundation +import SwiftData + +// `PersistentDocument` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDocument { + #Index([\.networkRaw]) + + @Attribute(.unique) var documentId: String + + var documentType: String + var revision: Int32 + var data: Data + + var contractId: String + var ownerId: String + + var contractIdData: Data + var ownerIdData: Data + + var createdAt: Date + var updatedAt: Date + var transferredAt: Date? + + var createdAtBlockHeight: Int64? + var updatedAtBlockHeight: Int64? + var transferredAtBlockHeight: Int64? + + var createdAtCoreBlockHeight: Int64? + var updatedAtCoreBlockHeight: Int64? + var transferredAtCoreBlockHeight: Int64? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var isDeleted: Bool = false + + var localCreatedAt: Date + var localUpdatedAt: Date + + var documentType_relation: PersistentDocumentType? + var dataContract: PersistentDataContract? + + var ownerIdentity: PersistentIdentity? + + var id: Data { + Data.identifier(fromBase58: documentId) ?? Data() + } + + var idBase58: String { + documentId + } + + var ownerIdBase58: String { + ownerId + } + + var contractIdBase58: String { + contractId + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var displayTitle: String { + guard let props = properties else { return "Document" } + + if let title = props["title"] as? String { return title } + if let name = props["name"] as? String { return name } + if let label = props["label"] as? String { return label } + if let normalizedLabel = props["normalizedLabel"] as? String { return normalizedLabel } + + return documentType + } + + var summary: String { + var parts: [String] = [] + + parts.append("Type: \(documentType)") + parts.append("Rev: \(revision)") + + let formatter = DateFormatter() + formatter.calendar = Calendar(identifier: .gregorian) + formatter.dateStyle = .short + parts.append("Created: \(formatter.string(from: createdAt))") + + return parts.joined(separator: " • ") + } + + init( + documentId: String, + documentType: String, + revision: Int32, + data: Data, + contractId: String, + ownerId: String, + network: Network + ) { + self.documentId = documentId + self.documentType = documentType + self.revision = revision + self.data = data + self.contractId = contractId + self.ownerId = ownerId + self.contractIdData = Data.identifier(fromBase58: contractId) ?? Data() + self.ownerIdData = Data.identifier(fromBase58: ownerId) ?? Data() + self.networkRaw = network.rawValue + self.createdAt = Date() + self.updatedAt = Date() + self.localCreatedAt = Date() + self.localUpdatedAt = Date() + } + + func updateProperties(_ newData: Data) { + self.data = newData + self.updatedAt = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = Int32(newRevision) + self.updatedAt = Date() + } + + func markAsDeleted() { + self.isDeleted = true + self.updatedAt = Date() + } + + static func predicate(documentId: String) -> Predicate { + #Predicate { doc in + doc.documentId == documentId && doc.isDeleted == false + } + } + + static func predicate(contractId: String, network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { doc in + doc.contractId == contractId && doc.networkRaw == target && doc.isDeleted == false + } + } + + static func predicate(ownerId: Data) -> Predicate { + let ownerIdString = ownerId.toBase58String() + return #Predicate { doc in + doc.ownerId == ownerIdString && doc.isDeleted == false + } + } + + func linkToLocalIdentityIfNeeded(in modelContext: ModelContext) { + guard ownerIdentity == nil else { return } + + let ownerIdToMatch = self.ownerIdData + let identityPredicate = #Predicate { identity in + identity.identityId == ownerIdToMatch && identity.isLocal == true + } + + let descriptor = FetchDescriptor(predicate: identityPredicate) + + do { + if let localIdentity = try modelContext.fetch(descriptor).first { + self.ownerIdentity = localIdentity + self.localUpdatedAt = Date() + } + } catch { + print("Failed to link document to local identity: \(error)") + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocumentType.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocumentType.swift new file mode 100644 index 00000000000..8e590b57b92 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentDocumentType.swift @@ -0,0 +1,154 @@ +import Foundation +import SwiftData + +// `PersistentDocumentType` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentDocumentType { + @Attribute(.unique) var id: Data + var contractId: Data + var name: String + + var schemaJSON: Data + var propertiesJSON: Data + + var documentsKeepHistory: Bool + var documentsMutable: Bool + var documentsCanBeDeleted: Bool + var documentsTransferable: Bool + + var indexOnly: Bool = false + + var requiredFieldsJSON: Data? + + var securityLevel: Int + + var tradeMode: Int + var creationRestrictionMode: Int + + var requiresIdentityEncryptionBoundedKey: Bool + var requiresIdentityDecryptionBoundedKey: Bool + + var createdAt: Date + var lastAccessedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.documentType_relation) + var documents: [PersistentDocument]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentIndex.documentType) + var indices: [PersistentIndex]? + + @Relationship(deleteRule: .cascade, inverse: \PersistentProperty.documentType) + var propertiesList: [PersistentProperty]? + + init(contractId: Data, name: String, schemaJSON: Data, propertiesJSON: Data) { + var idData = contractId + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.name = name + self.schemaJSON = schemaJSON + self.propertiesJSON = propertiesJSON + self.documentsKeepHistory = false + self.documentsMutable = true + self.documentsCanBeDeleted = true + self.documentsTransferable = false + self.securityLevel = 0 + self.tradeMode = 0 + self.creationRestrictionMode = 0 + self.requiresIdentityEncryptionBoundedKey = false + self.requiresIdentityDecryptionBoundedKey = false + self.createdAt = Date() + self.lastAccessedAt = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentDocumentType { + var contractIdBase58: String { + contractId.toBase58String() + } + + var schema: [String: Any]? { + try? JSONSerialization.jsonObject(with: schemaJSON, options: []) as? [String: Any] + } + + var properties: [String: Any]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String: Any] + } + + var persistentProperties: [DashSchemaSnapshotV4.PersistentProperty]? { + return propertiesList + } + + var requiredFields: [String]? { + guard let data = requiredFieldsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String] + } + + var immutability: DocumentTypeImmutability { + DocumentTypeImmutability(documentTypeSchema: schema) + } + + var immutableProperties: [String] { + immutability.immutableProperties + } + + var immutableAllowSetting: [String] { + immutability.immutableAllowSetting + } + + var typedArrays: [DocumentTypedArray] { + DocumentTypedArray.all(inDocumentTypeSchema: schema) + } + + func typedArray(named name: String) -> DocumentTypedArray? { + DocumentTypedArray.named(name, inDocumentTypeSchema: schema) + } + + var declaresPropertyConstraints: Bool { + schema?["propertyConstraints"] != nil + } + + func propertyConstraints(using sdk: SDK) throws -> [DocumentPropertyConstraint] { + try sdk.documentPropertyConstraints( + serializedContract: storedContractSerialization(), + documentType: name + ) + } + + func propertyConstraintViolation( + propertiesJSON: String, + ownerId: Identifier, + using sdk: SDK + ) throws -> PropertyConstraintViolation? { + try sdk.checkDocumentPropertyConstraints( + serializedContract: storedContractSerialization(), + documentType: name, + propertiesJSON: propertiesJSON, + ownerId: ownerId + ) + } + + private func storedContractSerialization() throws -> Data { + guard let serialization = dataContract?.binarySerialization, !serialization.isEmpty else { + throw SDKError.invalidState( + "The data contract \(contractIdBase58) has no stored serialization; download it again" + ) + } + return serialization + } + + var documentCount: Int { + documents?.count ?? 0 + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentity.swift new file mode 100644 index 00000000000..d25bcb5997a --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentity.swift @@ -0,0 +1,304 @@ +import Foundation +import SwiftData + +// `PersistentIdentity` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentIdentity { + #Index([\.networkRaw]) + + @Attribute(.unique) var identityId: Data + var balance: Int64 + var revision: Int64 + var isLocal: Bool + var alias: String? + var dpnsName: String? + var mainDpnsName: String? + var identityType: String + + var votingPrivateKeyIdentifier: String? + var ownerPrivateKeyIdentifier: String? + var payoutPrivateKeyIdentifier: String? + + @Relationship(deleteRule: .cascade) var publicKeys: [PersistentPublicKey] + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + var wallet: PersistentWallet? + var identityIndex: UInt32 = 0 + + @Relationship(deleteRule: .cascade, inverse: \PersistentDocument.ownerIdentity) var documents: [PersistentDocument] + @Relationship(deleteRule: .nullify) var tokenBalances: [PersistentTokenBalance] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDPNSName.identity) + var dpnsNames: [PersistentDPNSName] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayProfile.identity) + var dashpayProfile: PersistentDashpayProfile? + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactRequest.owner) + var contactRequests: [PersistentDashpayContactRequest] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayPayment.owner) + var dashpayPayments: [PersistentDashpayPayment] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayIgnoredSender.owner) + var dashpayIgnoredSenders: [PersistentDashpayIgnoredSender] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentDashpayContactProfile.owner) + var contactProfiles: [PersistentDashpayContactProfile] = [] + + var ownedDataContracts: [PersistentDataContract] + + init( + identityId: Data, + balance: Int64 = 0, + revision: Int64 = 0, + isLocal: Bool = true, + alias: String? = nil, + dpnsName: String? = nil, + mainDpnsName: String? = nil, + identityType: IdentityType = .user, + votingPrivateKeyIdentifier: String? = nil, + ownerPrivateKeyIdentifier: String? = nil, + payoutPrivateKeyIdentifier: String? = nil, + network: Network, + identityIndex: UInt32 = 0 + ) { + self.identityId = identityId + self.balance = balance + self.revision = revision + self.isLocal = isLocal + self.alias = alias + self.dpnsName = dpnsName + self.mainDpnsName = mainDpnsName + self.identityType = identityType.rawValue + self.votingPrivateKeyIdentifier = votingPrivateKeyIdentifier + self.ownerPrivateKeyIdentifier = ownerPrivateKeyIdentifier + self.payoutPrivateKeyIdentifier = payoutPrivateKeyIdentifier + self.networkRaw = network.rawValue + self.identityIndex = identityIndex + self.publicKeys = [] + self.documents = [] + self.tokenBalances = [] + self.dpnsNames = [] + self.dashpayProfile = nil + self.contactRequests = [] + self.dashpayPayments = [] + self.dashpayIgnoredSenders = [] + self.contactProfiles = [] + self.ownedDataContracts = [] + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + } + + var identityIdString: String { + identityId.toHexString() + } + + var identityIdBase58: String { + identityId.toBase58String() + } + + var formattedBalance: String { + let dashAmount = Double(balance) / 100_000_000_000 + return String(format: "%.8f DASH", dashAmount) + } + + var identityPublicKeys: [IdentityPublicKey] { + publicKeys.compactMap { $0.toIdentityPublicKey() } + } + + var displayName: String { + if let alias = alias, !alias.isEmpty { + return alias + } + if let mainDpnsName = ownedMainDpnsName { + return mainDpnsName + } + if let dpnsName = dpnsName, !dpnsName.isEmpty { + return dpnsName + } + return String(identityIdString.prefix(12)) + "..." + } + + var ownedMainDpnsName: String? { + guard let mainDpnsName, !mainDpnsName.isEmpty else { return nil } + let normalized = PersistentDPNSName.normalize(mainDpnsName) + let names = dpnsNames + guard names.isEmpty else { + return names.contains { $0.isOwned && $0.normalizedLabel == normalized } ? mainDpnsName : nil + } + guard let context = modelContext else { return mainDpnsName } + let networkRaw = networkRaw + let parent = PersistentDPNSName.normalize("dash") + var descriptor = FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw + && $0.normalizedParentDomainName == parent + && $0.normalizedLabel == normalized + } + ) + descriptor.fetchLimit = 1 + let row: PersistentDPNSName? + do { + row = try context.fetch(descriptor).first + } catch { + return nil + } + if let row, row.identity.identityId != identityId { + return nil + } + return mainDpnsName + } + + var identityTypeEnum: IdentityType { + IdentityType(rawValue: identityType) ?? .user + } + + func updateBalance(_ newBalance: Int64) { + self.balance = newBalance + self.lastUpdated = Date() + } + + func updateRevision(_ newRevision: Int64) { + self.revision = newRevision + self.lastUpdated = Date() + } + + func markAsSynced() { + self.lastSyncedAt = Date() + } + + func updateDPNSName(_ name: String?) { + self.dpnsName = name + self.lastUpdated = Date() + } + + func addPublicKey(_ key: PersistentPublicKey) { + publicKeys.append(key) + lastUpdated = Date() + } + + func removePublicKey(withId keyId: Int32) { + publicKeys.removeAll { $0.keyId == keyId } + lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentIdentity { + static func predicate(identityId: Data) -> Predicate { + #Predicate { identity in + identity.identityId == identityId + } + } + + static var walletOwnedIdentitiesPredicate: Predicate { + #Predicate { identity in + identity.wallet != nil + } + } + + static func predicate(type: IdentityType) -> Predicate { + let typeString = type.rawValue + return #Predicate { identity in + identity.identityType == typeString + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { identity in + identity.lastSyncedAt == nil || identity.lastSyncedAt! < date + } + } + + static func predicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.networkRaw == target + } + } + + static func walletOwnedIdentitiesPredicate(network: Network) -> Predicate { + let target = network.rawValue + return #Predicate { identity in + identity.wallet != nil && identity.networkRaw == target + } + } + + static func fetch( + in context: ModelContext, + identityId: Data + ) -> DashSchemaSnapshotV4.PersistentIdentity? { + let target = identityId + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.identityId == target } + ) + return try? context.fetch(descriptor).first + } +} + +extension DashSchemaSnapshotV4.PersistentIdentity { + @discardableResult + static func updateBalance( + in context: ModelContext, + identityId: Data, + balance: UInt64 + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.balance = Int64(bitPattern: balance) + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateDpnsName( + in context: ModelContext, + identityId: Data, + dpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.dpnsName = dpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func updateMainDpnsName( + in context: ModelContext, + identityId: Data, + mainDpnsName: String? + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + row.mainDpnsName = mainDpnsName + row.lastUpdated = Date() + return true + } + + @discardableResult + static func remove( + in context: ModelContext, + identityId: Data + ) -> Bool { + guard let row = fetch(in: context, identityId: identityId) else { return false } + context.delete(row) + return true + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentityBalanceMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentityBalanceMetadata.swift new file mode 100644 index 00000000000..4a1de59943b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIdentityBalanceMetadata.swift @@ -0,0 +1,32 @@ +import Foundation +import SwiftData + +// `PersistentIdentityBalanceMetadata` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentIdentityBalanceMetadata { + #Unique([\.networkRaw, \.walletId, \.identityId]) + var networkRaw: UInt32 + var walletId: Data + var identityId: Data + var platformHeight: Int64 + var coreHeight: UInt32 + var timestampMillis: Int64 + + init(networkRaw: UInt32, walletId: Data, identityId: Data, + platformHeight: UInt64, coreHeight: UInt32, timestampMillis: UInt64) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.platformHeight = Int64(bitPattern: platformHeight) + self.coreHeight = coreHeight + self.timestampMillis = Int64(bitPattern: timestampMillis) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIndex.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIndex.swift new file mode 100644 index 00000000000..ea10dbfbcb4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentIndex.swift @@ -0,0 +1,113 @@ +import Foundation +import SwiftData + +// `PersistentIndex` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentIndex { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var unique: Bool + var nullSearchable: Bool + var contested: Bool + + var countable: String? + var rangeCountable: Bool = false + var summable: String? + var rangeSummable: Bool = false + var averageable: String? + var rangeAverageable: Bool = false + + var rankedCountable: Bool = false + var rankedSummable: Bool = false + var rankedAverageable: Bool = false + + var terminal: String? + + var preallocated: Bool = false + + var timeRangeJSON: Data? + + var propertiesJSON: Data + + var contestedDetailsJSON: Data? + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, properties: [String]) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.unique = false + self.nullSearchable = false + self.contested = false + + if let jsonData = try? JSONSerialization.data(withJSONObject: properties, options: []) { + self.propertiesJSON = jsonData + } else { + self.propertiesJSON = Data() + } + + self.createdAt = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentIndex { + var properties: [String]? { + try? JSONSerialization.jsonObject(with: propertiesJSON, options: []) as? [String] + } + + var contestedDetails: [String: Any]? { + guard let data = contestedDetailsJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var timeRange: [String: Any]? { + guard let data = timeRangeJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + var authoredDefinition: [String: Any]? { + guard let indices = documentType?.schema?["indices"] as? [[String: Any]] else { + return nil + } + return indices.first { $0["name"] as? String == name } + } + + var authoredKeywords: AuthoredIndexKeywords { + AuthoredIndexKeywords(authoredDefinition: authoredDefinition) + } + + var summableOffCountIndex: String? { + authoredKeywords.summableOffCountIndex + } + + var rankedCountableAt: [String] { + authoredKeywords.rankedCountableAt + } + + var rankedSummableAt: [String] { + authoredKeywords.rankedSummableAt + } + + var rankedAverageableAt: [String] { + authoredKeywords.rankedAverageableAt + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentInvitation.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentInvitation.swift new file mode 100644 index 00000000000..fa8fb65d042 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentInvitation.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftData + +// `PersistentInvitation` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentInvitation { + #Index([\.walletId]) + + @Attribute(.unique) var outPointHex: String + + var rawOutPoint: Data + + var walletId: Data + + var fundingIndexRaw: Int + + var amountDuffs: Int64 + + var expiryUnix: Int + + var createdAtSecs: Int + + var hasInviter: Bool + + var statusRaw: Int + + var reclaimInFlight: Bool = false + + var createdAt: Date + var updatedAt: Date + + init( + outPointHex: String, + rawOutPoint: Data, + walletId: Data, + fundingIndexRaw: Int, + amountDuffs: Int64, + expiryUnix: Int, + createdAtSecs: Int, + hasInviter: Bool, + statusRaw: Int, + reclaimInFlight: Bool = false + ) { + self.outPointHex = outPointHex + self.rawOutPoint = rawOutPoint + self.walletId = walletId + self.fundingIndexRaw = fundingIndexRaw + self.amountDuffs = amountDuffs + self.expiryUnix = expiryUnix + self.createdAtSecs = createdAtSecs + self.hasInviter = hasInviter + self.statusRaw = statusRaw + self.reclaimInFlight = reclaimInFlight + self.createdAt = Date() + self.updatedAt = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentInvitation { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentKeyword.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentKeyword.swift new file mode 100644 index 00000000000..ba5e5e39e71 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentKeyword.swift @@ -0,0 +1,40 @@ +import Foundation +import SwiftData + +// `PersistentKeyword` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentKeyword { + @Attribute(.unique) var id: String + var keyword: String + var contractId: String + + var dataContract: PersistentDataContract? + + init(keyword: String, contractId: String) { + self.id = "\(contractId)_\(keyword)" + self.keyword = keyword + self.contractId = contractId + } + } +} + +extension DashSchemaSnapshotV4.PersistentKeyword { + static func predicate(keyword: String) -> Predicate { + #Predicate { item in + item.keyword.localizedStandardContains(keyword) + } + } + + static func predicate(contractId: String) -> Predicate { + #Predicate { item in + item.contractId == contractId + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentMasternode.swift new file mode 100644 index 00000000000..8399203bde7 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentMasternode.swift @@ -0,0 +1,185 @@ +import Foundation +import SwiftData + +// `PersistentMasternode` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentMasternode { + #Unique([\.walletId, \.proTxHash]) + + var walletId: Data + var proTxHash: Data + var registrationTxid: Data + + var serviceAddress: String? + var isEvonode: Bool + + var ownerKeyHash: Data? + var votingKeyHash: Data? + var ownerAddress: String? + var votingAddress: String? + var operatorPublicKey: Data? + var platformNodeId: Data? + var payoutAddress: String? + var operatorPseudoAddress: String? + var platformNodeAddress: String? + + var ownerInWallet: Bool = false + var ownerAccountType: UInt8 = 0 + var ownerKeyIndex: UInt32 = 0 + var votingInWallet: Bool = false + var votingAccountType: UInt8 = 0 + var votingKeyIndex: UInt32 = 0 + var operatorInWallet: Bool = false + var operatorAccountType: UInt8 = 0 + var operatorKeyIndex: UInt32 = 0 + var platformInWallet: Bool = false + var platformAccountType: UInt8 = 0 + var platformKeyIndex: UInt32 = 0 + + var collateralTxid: Data? + var collateralVout: UInt32 + + var revoked: Bool + var revocationReason: UInt16 + var statusRaw: UInt8 = 3 + + var registrationHeight: UInt32 + var hasRegistration: Bool + var txCount: UInt32 + + var orderIndex: UInt32 + var typeIndex: UInt32 = 0 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + proTxHash: Data, + registrationTxid: Data, + serviceAddress: String? = nil, + isEvonode: Bool = false, + ownerKeyHash: Data? = nil, + votingKeyHash: Data? = nil, + ownerAddress: String? = nil, + votingAddress: String? = nil, + operatorPublicKey: Data? = nil, + platformNodeId: Data? = nil, + payoutAddress: String? = nil, + collateralTxid: Data? = nil, + collateralVout: UInt32 = 0, + revoked: Bool = false, + revocationReason: UInt16 = 0, + statusRaw: UInt8 = 3, + registrationHeight: UInt32 = 0, + hasRegistration: Bool = false, + txCount: UInt32 = 0, + orderIndex: UInt32 = 0, + typeIndex: UInt32 = 0 + ) { + self.walletId = walletId + self.proTxHash = proTxHash + self.registrationTxid = registrationTxid + self.serviceAddress = serviceAddress + self.isEvonode = isEvonode + self.ownerKeyHash = ownerKeyHash + self.votingKeyHash = votingKeyHash + self.ownerAddress = ownerAddress + self.votingAddress = votingAddress + self.operatorPublicKey = operatorPublicKey + self.platformNodeId = platformNodeId + self.payoutAddress = payoutAddress + self.collateralTxid = collateralTxid + self.collateralVout = collateralVout + self.revoked = revoked + self.revocationReason = revocationReason + self.statusRaw = statusRaw + self.registrationHeight = registrationHeight + self.hasRegistration = hasRegistration + self.txCount = txCount + self.orderIndex = orderIndex + self.typeIndex = typeIndex + self.createdAt = Date() + self.lastUpdated = Date() + } + + var proTxHashHex: String { + proTxHash.reversed().map { String(format: "%02x", $0) }.joined() + } + + var proTxHashShort: String { + let hex = proTxHashHex + guard hex.count >= 12 else { return hex } + return "\(String(hex.prefix(6)))…\(String(hex.suffix(6)))" + } + + var ownerKeyHashHex: String? { + ownerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var votingKeyHashHex: String? { + votingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + static func providerAccountTypeName(_ tag: UInt8) -> String { + switch tag { + case 8: return "ProviderVotingKeys" + case 9: return "ProviderOwnerKeys" + case 10: return "ProviderOperatorKeys" + case 11: return "ProviderPlatformKeys" + default: return "Unknown(\(tag))" + } + } + + static func keyOwnershipLabel( + inWallet: Bool, + accountType: UInt8, + index: UInt32 + ) -> String { + inWallet + ? "\(providerAccountTypeName(accountType)) #\(index)" + : "not in this wallet" + } + + var operatorPublicKeyHex: String? { + operatorPublicKey.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var platformNodeIdHex: String? { + platformNodeId.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var collateralDisplay: String? { + guard let txid = collateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(collateralVout)" + } + + var displayNumber: Int { + Int(typeIndex) + } + + var typeName: String { + isEvonode ? "Evonode" : "Masternode" + } + + var displayTitle: String { + "\(typeName) \(displayNumber)" + } + + var status: MasternodeStatus { + MasternodeStatus(rawValue: statusRaw) ?? .unknown + } + + var statusName: String { + status.displayName + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPendingInput.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPendingInput.swift new file mode 100644 index 00000000000..43a2f6a2fb9 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPendingInput.swift @@ -0,0 +1,46 @@ +import Foundation +import SwiftData + +// `PersistentPendingInput` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentPendingInput { + #Index([\.outpoint], [\.walletId], [\.walletId, \.isSweptTombstone]) + var outpoint: Data + + var inputIndex: UInt32 + + var spendingTxid: Data + + var spendingTransaction: PersistentTransaction? + + var walletId: Data + + var createdAt: Date + + var isSweptTombstone: Bool = false + + var winnerMinedHeight: UInt32? + + init( + outpoint: Data, + inputIndex: UInt32, + spendingTxid: Data, + spendingTransaction: PersistentTransaction?, + walletId: Data + ) { + self.outpoint = outpoint + self.inputIndex = inputIndex + self.spendingTxid = spendingTxid + self.spendingTransaction = spendingTransaction + self.walletId = walletId + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddress.swift new file mode 100644 index 00000000000..f621099c8d2 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddress.swift @@ -0,0 +1,78 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddress` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentPlatformAddress { + #Index([\.walletId]) + + @Attribute(.unique) var address: String + var addressType: UInt8 + @Attribute(.unique) var addressHash: Data + var publicKey: Data + var accountIndex: UInt32 + var addressIndex: UInt32 + var derivationPath: String + var isUsed: Bool + var balance: UInt64 + var nonce: UInt32 + var firstSeenHeight: UInt32 + var lastSeenHeight: UInt64 + var walletId: Data + var createdAt: Date + var lastUpdated: Date + + var account: PersistentAccount? + + init( + address: String, + addressType: UInt8, + addressHash: Data, + publicKey: Data = Data(), + accountIndex: UInt32, + addressIndex: UInt32, + derivationPath: String, + isUsed: Bool = false, + balance: UInt64 = 0, + nonce: UInt32 = 0, + walletId: Data + ) { + self.address = address + self.addressType = addressType + self.addressHash = addressHash + self.publicKey = publicKey + self.accountIndex = accountIndex + self.addressIndex = addressIndex + self.derivationPath = derivationPath + self.isUsed = isUsed + self.balance = balance + self.nonce = nonce + self.firstSeenHeight = 0 + self.lastSeenHeight = 0 + self.walletId = walletId + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentPlatformAddress { + static func predicate(walletId: Data) -> Predicate { + #Predicate { entry in + entry.walletId == walletId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { entry in + entry.balance > 0 + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddressesSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddressesSyncState.swift new file mode 100644 index 00000000000..7677e0c6a2d --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPlatformAddressesSyncState.swift @@ -0,0 +1,41 @@ +import Foundation +import SwiftData + +// `PersistentPlatformAddressesSyncState` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentPlatformAddressesSyncState { + @Attribute(.unique) var walletId: Data + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + var syncHeight: UInt64 + var syncTimestamp: UInt64 + var lastKnownRecentBlock: UInt64 + var lastUpdated: Date + + init( + walletId: Data, + network: Network, + syncHeight: UInt64, + syncTimestamp: UInt64, + lastKnownRecentBlock: UInt64 + ) { + self.walletId = walletId + self.networkRaw = network.rawValue + self.syncHeight = syncHeight + self.syncTimestamp = syncTimestamp + self.lastKnownRecentBlock = lastKnownRecentBlock + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentProperty.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentProperty.swift new file mode 100644 index 00000000000..2253d76b134 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentProperty.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData + +// `PersistentProperty` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentProperty { + @Attribute(.unique) var id: Data + var contractId: Data + var documentTypeName: String + var name: String + + var type: String + var format: String? + var contentMediaType: String? + var byteArray: Bool + var minItems: Int? + var maxItems: Int? + var pattern: String? + var minLength: Int? + var maxLength: Int? + var minValue: Int? + var maxValue: Int? + var fieldDescription: String? + + var transient: Bool + var isRequired: Bool + + var createdAt: Date + + var documentType: PersistentDocumentType? + + init(contractId: Data, documentTypeName: String, name: String, type: String) { + var idData = contractId + idData.append(documentTypeName.data(using: .utf8) ?? Data()) + idData.append(name.data(using: .utf8) ?? Data()) + self.id = idData + + self.contractId = contractId + self.documentTypeName = documentTypeName + self.name = name + self.type = type + self.byteArray = false + self.transient = false + self.isRequired = false + self.createdAt = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPublicKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPublicKey.swift new file mode 100644 index 00000000000..981a7f07985 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentPublicKey.swift @@ -0,0 +1,218 @@ +import Foundation +import SwiftData + +// `PersistentPublicKey` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentPublicKey { + var keyId: Int32 + var purpose: String + var securityLevel: String + var keyType: String + var readOnly: Bool + var disabledAt: Int64? + + var totalBudget: Int64? + + var expiresAt: Int64? + + var publicKeyData: Data + + var contractBoundsData: Data? + + var contractBoundsDocumentTypeName: String? + + var contractBoundsKind: Int? + + var privateKeyKeychainIdentifier: String? + + var walletId: Data? + + var identityDerivationPath: String? + + var identityId: String + var createdAt: Date + var lastAccessed: Date? + + @Relationship(inverse: \PersistentIdentity.publicKeys) + var identity: PersistentIdentity? + + init( + keyId: Int32, + purpose: KeyPurpose, + securityLevel: SecurityLevel, + keyType: KeyType, + publicKeyData: Data, + readOnly: Bool = false, + disabledAt: Int64? = nil, + contractBounds: [Data]? = nil, + contractBoundsDocumentTypeName: String? = nil, + totalBudget: Int64? = nil, + expiresAt: Int64? = nil, + contractBoundsKind: Int? = nil, + identityId: String + ) { + self.keyId = keyId + self.purpose = String(purpose.rawValue) + self.securityLevel = String(securityLevel.rawValue) + self.keyType = String(keyType.rawValue) + self.publicKeyData = publicKeyData + self.readOnly = readOnly + self.disabledAt = disabledAt + self.totalBudget = totalBudget + self.expiresAt = expiresAt + if let contractBounds = contractBounds { + self.contractBoundsData = try? JSONSerialization.data(withJSONObject: contractBounds.map { $0.base64EncodedString() }) + } else { + self.contractBoundsData = nil + } + self.contractBoundsDocumentTypeName = contractBoundsDocumentTypeName + self.contractBoundsKind = contractBoundsKind + self.identityId = identityId + self.createdAt = Date() + } + + var contractBounds: [Data]? { + get { + guard let data = contractBoundsData, + let json = try? JSONSerialization.jsonObject(with: data), + let strings = json as? [String] else { + return nil + } + return strings.compactMap { Data(base64Encoded: $0) } + } + set { + contractBoundsDocumentTypeName = nil + contractBoundsKind = newValue == nil ? 0 : 1 + if let newValue = newValue { + contractBoundsData = try? JSONSerialization.data(withJSONObject: newValue.map { $0.base64EncodedString() }) + } else { + contractBoundsData = nil + } + } + } + + var effectiveContractBoundsKind: Int { + if let contractBoundsKind = contractBoundsKind { + return contractBoundsKind + } + guard contractBoundsData != nil else { return 0 } + if let name = contractBoundsDocumentTypeName, !name.isEmpty { return 2 } + return 1 + } + + var purposeEnum: KeyPurpose? { + guard let purposeInt = UInt8(purpose) else { return nil } + return KeyPurpose(rawValue: purposeInt) + } + + var securityLevelEnum: SecurityLevel? { + guard let levelInt = UInt8(securityLevel) else { return nil } + return SecurityLevel(rawValue: levelInt) + } + + var keyTypeEnum: KeyType? { + guard let typeInt = UInt8(keyType) else { return nil } + return KeyType(rawValue: typeInt) + } + + var isDisabled: Bool { + disabledAt != nil + } + + var totalBudgetCredits: UInt64? { + get { totalBudget.map { UInt64(bitPattern: $0) } } + set { totalBudget = newValue.map { Int64(bitPattern: $0) } } + } + + var expiresAtMillis: UInt64? { + get { expiresAt.map { UInt64(bitPattern: $0) } } + set { expiresAt = newValue.map { Int64(bitPattern: $0) } } + } + + var hasLimits: Bool { + totalBudget != nil || expiresAt != nil + } + + var hasPrivateKeyIdentifier: Bool { + privateKeyKeychainIdentifier != nil + } + } +} + +extension DashSchemaSnapshotV4.PersistentPublicKey { + func toIdentityPublicKey() -> IdentityPublicKey? { + guard let purpose = purposeEnum, + let securityLevel = securityLevelEnum, + let keyType = keyTypeEnum else { + return nil + } + + let bounds: ContractBounds? + let boundsKind = effectiveContractBoundsKind + if (1...2).contains(boundsKind), let id = contractBounds?.first, id.count == 32 { + if boundsKind == 2, + let docTypeName = contractBoundsDocumentTypeName, !docTypeName.isEmpty { + bounds = .singleContractDocumentType(id: id, documentTypeName: docTypeName) + } else { + bounds = .singleContract(id: id) + } + } else { + bounds = nil + } + + return IdentityPublicKey( + id: KeyID(keyId), + purpose: purpose, + securityLevel: securityLevel, + contractBounds: bounds, + keyType: keyType, + readOnly: readOnly, + data: publicKeyData, + disabledAt: disabledAt.map { TimestampMillis($0) }, + totalBudget: totalBudgetCredits, + expiresAt: expiresAtMillis + ) + } + + static func from(_ publicKey: IdentityPublicKey, identityId: String) -> DashSchemaSnapshotV4.PersistentPublicKey? { + let boundsIds: [Data]? + let docTypeName: String? + let kind: Int + switch publicKey.contractBounds { + case .singleContract(let id): + boundsIds = [id] + docTypeName = nil + kind = 1 + case .singleContractDocumentType(let id, let name): + boundsIds = [id] + docTypeName = name + kind = 2 + case .none: + boundsIds = nil + docTypeName = nil + kind = 0 + } + return DashSchemaSnapshotV4.PersistentPublicKey( + keyId: Int32(publicKey.id), + purpose: publicKey.purpose, + securityLevel: publicKey.securityLevel, + keyType: publicKey.keyType, + publicKeyData: publicKey.data, + readOnly: publicKey.readOnly, + disabledAt: publicKey.disabledAt.map { Int64($0) }, + contractBounds: boundsIds, + contractBoundsDocumentTypeName: docTypeName, + totalBudget: publicKey.totalBudget.map { Int64(bitPattern: $0) }, + expiresAt: publicKey.expiresAt.map { Int64(bitPattern: $0) }, + contractBoundsKind: kind, + identityId: identityId + ) + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedActivity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedActivity.swift new file mode 100644 index 00000000000..ea27a8572b8 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedActivity.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftData + +// `PersistentShieldedActivity` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentShieldedActivity { + #Unique([\.walletId, \.accountIndex, \.entryId]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var entryId: Data + + var kindTag: Int + var direction: Int + var status: Int + + var amount: UInt64 + var fee: UInt64 + var hasFee: Bool + var blockHeight: UInt64 + var hasBlockHeight: Bool + var createdAtMs: UInt64 + + var minNotePosition: UInt64 = 0 + var hasMinNotePosition: Bool = false + + var identityId: Data + var counterparty: Data + var memo: Data + var noteCmxs: Data + var spentNullifiers: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + entryId: Data, + kindTag: Int, + direction: Int, + status: Int, + amount: UInt64, + fee: UInt64, + hasFee: Bool, + blockHeight: UInt64, + hasBlockHeight: Bool, + createdAtMs: UInt64, + minNotePosition: UInt64 = 0, + hasMinNotePosition: Bool = false, + identityId: Data, + counterparty: Data, + memo: Data, + noteCmxs: Data, + spentNullifiers: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.entryId = entryId + self.kindTag = kindTag + self.direction = direction + self.status = status + self.amount = amount + self.fee = fee + self.hasFee = hasFee + self.blockHeight = blockHeight + self.hasBlockHeight = hasBlockHeight + self.createdAtMs = createdAtMs + self.minNotePosition = minNotePosition + self.hasMinNotePosition = hasMinNotePosition + self.identityId = identityId + self.counterparty = counterparty + self.memo = memo + self.noteCmxs = noteCmxs + self.spentNullifiers = spentNullifiers + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedNote.swift new file mode 100644 index 00000000000..beedd9b7268 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedNote.swift @@ -0,0 +1,66 @@ +import Foundation +import SwiftData + +// `PersistentShieldedNote` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentShieldedNote { + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var position: UInt64 + var cmx: Data + @Attribute(.unique) var nullifier: Data + var blockHeight: UInt64 + var isSpent: Bool + var value: UInt64 + var noteData: Data + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + position: UInt64, + cmx: Data, + nullifier: Data, + blockHeight: UInt64, + isSpent: Bool, + value: UInt64, + noteData: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.position = position + self.cmx = cmx + self.nullifier = nullifier + self.blockHeight = blockHeight + self.isSpent = isSpent + self.value = value + self.noteData = noteData + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} + +extension DashSchemaSnapshotV4.PersistentShieldedNote { + static func unspentPredicate(walletId: Data) -> Predicate { + #Predicate { + $0.walletId == walletId && $0.isSpent == false + } + } + + static var unspentPredicate: Predicate { + #Predicate { $0.isSpent == false } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedOutgoingNote.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedOutgoingNote.swift new file mode 100644 index 00000000000..6831b25d1ef --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedOutgoingNote.swift @@ -0,0 +1,49 @@ +import Foundation +import SwiftData + +// `PersistentShieldedOutgoingNote` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentShieldedOutgoingNote { + #Unique([\.walletId, \.accountIndex, \.cmx]) + #Index([\.walletId, \.accountIndex]) + + var walletId: Data + var accountIndex: UInt32 + var cmx: Data + var recipient: Data + var value: UInt64 + var memo: Data + var blockHeight: UInt64 + + var createdAt: Date + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + cmx: Data, + recipient: Data, + value: UInt64, + memo: Data, + blockHeight: UInt64 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.cmx = cmx + self.recipient = recipient + self.value = value + self.memo = memo + self.blockHeight = blockHeight + let now = Date() + self.createdAt = now + self.lastUpdated = now + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedSyncState.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedSyncState.swift new file mode 100644 index 00000000000..8b92ec5c9da --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedSyncState.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedSyncState` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentShieldedSyncState { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var lastSyncedIndex: UInt64 + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + lastSyncedIndex: UInt64 = 0 + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.lastSyncedIndex = lastSyncedIndex + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedViewingKey.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedViewingKey.swift new file mode 100644 index 00000000000..bc2ccb9d82e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentShieldedViewingKey.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentShieldedViewingKey` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentShieldedViewingKey { + #Unique([\.walletId, \.accountIndex]) + #Index([\.walletId]) + + var walletId: Data + var accountIndex: UInt32 + var fvkBytes: Data + + var lastUpdated: Date + + init( + walletId: Data, + accountIndex: UInt32, + fvkBytes: Data + ) { + self.walletId = walletId + self.accountIndex = accountIndex + self.fvkBytes = fvkBytes + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentToken.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentToken.swift new file mode 100644 index 00000000000..cf706af35aa --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentToken.swift @@ -0,0 +1,403 @@ +import Foundation +import SwiftData + +// `PersistentToken` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentToken { + @Attribute(.unique) var id: Data + var contractId: Data + var position: Int + var name: String + + var baseSupply: String + var maxSupply: String? + var decimals: Int + + var localizations: [String: TokenLocalization]? + + var isPaused: Bool + var allowTransferToFrozenBalance: Bool + + var keepsTransferHistory: Bool + var keepsFreezingHistory: Bool + var keepsMintingHistory: Bool + var keepsBurningHistory: Bool + var keepsDirectPricingHistory: Bool + var keepsDirectPurchaseHistory: Bool + + var conventionsChangeRules: ChangeControlRules? + var maxSupplyChangeRules: ChangeControlRules? + var manualMintingRules: ChangeControlRules? + var manualBurningRules: ChangeControlRules? + var freezeRules: ChangeControlRules? + var unfreezeRules: ChangeControlRules? + var destroyFrozenFundsRules: ChangeControlRules? + var emergencyActionRules: ChangeControlRules? + + var perpetualDistribution: TokenPerpetualDistribution? + var preProgrammedDistribution: TokenPreProgrammedDistribution? + var newTokensDestinationIdentity: Data? + var mintingAllowChoosingDestination: Bool + var distributionChangeRules: TokenDistributionChangeRules? + + var tradeMode: TokenTradeMode + var tradeModeChangeRules: ChangeControlRules? + + var mainControlGroupPosition: Int? + var mainControlGroupCanBeModified: String? + + var tokenDescription: String? + + var createdAt: Date + var lastUpdatedAt: Date + + var dataContract: PersistentDataContract? + + @Relationship(deleteRule: .cascade) + var balances: [PersistentTokenBalance]? + + @Relationship(deleteRule: .cascade) + var historyEvents: [PersistentTokenHistoryEvent]? + + init(contractId: Data, position: Int, name: String, baseSupply: String, decimals: Int = 8) { + var idData = contractId + withUnsafeBytes(of: position.bigEndian) { bytes in + idData.append(contentsOf: bytes) + } + self.id = idData + + self.contractId = contractId + self.position = position + self.name = name + self.baseSupply = baseSupply + self.decimals = decimals + + self.isPaused = false + self.allowTransferToFrozenBalance = true + self.keepsTransferHistory = true + self.keepsFreezingHistory = true + self.keepsMintingHistory = true + self.keepsBurningHistory = true + self.keepsDirectPricingHistory = true + self.keepsDirectPurchaseHistory = true + self.mintingAllowChoosingDestination = true + self.tradeMode = TokenTradeMode.notTradeable + + self.createdAt = Date() + self.lastUpdatedAt = Date() + } + } +} + +extension DashSchemaSnapshotV4.PersistentToken { + var displayName: String { + if let desc = tokenDescription, !desc.isEmpty { + return desc + } + return getSingularForm() ?? name + } + + var formattedBaseSupply: String { + Self.formatSupply(baseSupply, decimals: decimals) + } + + static func formatSupply(_ raw: String, decimals: Int) -> String { + guard !raw.isEmpty, raw.allSatisfy({ $0.isASCII && $0.isNumber }) else { + return raw + } + let normalized = String(raw.drop(while: { $0 == "0" })) + let digits = normalized.isEmpty ? "0" : normalized + let scale = max(0, decimals) + let integer: String + var fraction = "" + if scale == 0 { + integer = digits + } else if digits.count <= scale { + integer = "0" + fraction = String(repeating: "0", count: scale - digits.count) + digits + } else { + let split = digits.index(digits.endIndex, offsetBy: -scale) + integer = String(digits[.. 0 && offset.isMultiple(of: 3) { grouped.append(",") } + grouped.append(character) + } + grouped = String(grouped.reversed()) + return fraction.isEmpty ? grouped : "\(grouped).\(fraction)" + } + + var contractIdBase58: String { + contractId.toBase58String() + } + + var canManuallyMint: Bool { + manualMintingRules != nil + } + + var canManuallyBurn: Bool { + manualBurningRules != nil + } + + var canFreeze: Bool { + freezeRules != nil + } + + var canUnfreeze: Bool { + unfreezeRules != nil + } + + var canDestroyFrozenFunds: Bool { + destroyFrozenFundsRules != nil + } + + var hasEmergencyActions: Bool { + emergencyActionRules != nil + } + + var canChangeMaxSupply: Bool { + maxSupplyChangeRules != nil + } + + var canChangeConventions: Bool { + conventionsChangeRules != nil + } + + var oncePerIdentityDistribution: TokenOncePerIdentityDistribution? { + guard let contract = dataContract else { return nil } + return TokenOncePerIdentityDistributionCache.shared.distribution( + contractId: contract.id, + serializedContract: contract.serializedContract, + lastUpdated: contract.lastUpdated, + position: position + ) + } + + var hasDistribution: Bool { + perpetualDistribution != nil + || preProgrammedDistribution != nil + || oncePerIdentityDistribution != nil + } + + var canChangeTradeMode: Bool { + tradeModeChangeRules != nil + } + + var keepsAnyHistory: Bool { + keepsTransferHistory || + keepsFreezingHistory || + keepsMintingHistory || + keepsBurningHistory || + keepsDirectPricingHistory || + keepsDirectPurchaseHistory + } + + var totalSupply: String { + guard let balances = balances, !balances.isEmpty else { return baseSupply } + return Self.sumUnsignedBalances(balances.map(\.unsignedBalance)) + } + + var totalFrozenBalance: String { + guard let balances = balances else { return "0" } + return Self.sumUnsignedBalances( + balances.lazy.filter(\.frozen).map(\.unsignedBalance) + ) + } + + var activeHolders: Int { + balances?.filter { $0.unsignedBalance > 0 }.count ?? 0 + } + + private static func sumUnsignedBalances(_ values: S) -> String + where S.Element == UInt64 { + var digits: [UInt8] = [0] // little-endian decimal digits + + for value in values { + var carry = 0 + let addend = String(value).utf8.reversed().map { Int($0 - 48) } + let width = max(digits.count, addend.count) + if digits.count < width { + digits.append(contentsOf: repeatElement(0, count: width - digits.count)) + } + + for index in 0.. 0 { + digits.append(UInt8(carry % 10)) + carry /= 10 + } + } + + return String(digits.reversed().map { Character(String($0)) }) + } + + var hasMaxSupply: Bool { + maxSupply != nil + } + + var isTradeable: Bool { + tradeMode != .notTradeable + } + + var newTokensDestinationIdentityBase58: String? { + newTokensDestinationIdentity?.toBase58String() + } +} + +extension DashSchemaSnapshotV4.PersistentToken { + func setLocalization(languageCode: String, singularForm: String, pluralForm: String, description: String? = nil) { + if localizations == nil { + localizations = [:] + } + localizations?[languageCode] = DashSchemaSnapshotV4.TokenLocalization( + singularForm: singularForm, + pluralForm: pluralForm, + description: description + ) + lastUpdatedAt = Date() + } + + func getSingularForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.singularForm ?? localizations?["en"]?.singularForm + } + + func getPluralForm(languageCode: String = "en") -> String? { + return localizations?[languageCode]?.pluralForm ?? localizations?["en"]?.pluralForm + } +} + +extension DashSchemaSnapshotV4.PersistentToken { + func getChangeControlRules(for type: ChangeControlRuleType) -> DashSchemaSnapshotV4.ChangeControlRules? { + switch type { + case .conventions: return conventionsChangeRules + case .maxSupply: return maxSupplyChangeRules + case .manualMinting: return manualMintingRules + case .manualBurning: return manualBurningRules + case .freeze: return freezeRules + case .unfreeze: return unfreezeRules + case .destroyFrozenFunds: return destroyFrozenFundsRules + case .emergencyAction: return emergencyActionRules + case .tradeMode: return tradeModeChangeRules + } + } + + func setChangeControlRules(_ rules: DashSchemaSnapshotV4.ChangeControlRules, for type: ChangeControlRuleType) { + switch type { + case .conventions: conventionsChangeRules = rules + case .maxSupply: maxSupplyChangeRules = rules + case .manualMinting: manualMintingRules = rules + case .manualBurning: manualBurningRules = rules + case .freeze: freezeRules = rules + case .unfreeze: unfreezeRules = rules + case .destroyFrozenFunds: destroyFrozenFundsRules = rules + case .emergencyAction: emergencyActionRules = rules + case .tradeMode: tradeModeChangeRules = rules + } + + lastUpdatedAt = Date() + } +} + +extension DashSchemaSnapshotV4.PersistentToken { + static func mintableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualMintingRules != nil + } + } + + static func burnableTokensPredicate() -> Predicate { + #Predicate { token in + token.manualBurningRules != nil + } + } + + static func freezableTokensPredicate() -> Predicate { + #Predicate { token in + token.freezeRules != nil + } + } + + static func columnBackedDistributionTokensPredicate() -> Predicate { + #Predicate { token in + token.perpetualDistribution != nil || token.preProgrammedDistribution != nil + } + } + + @available( + *, + deprecated, + renamed: "columnBackedDistributionTokensPredicate()", + message: """ + This predicate never matched the once-per-identity kind, which is derived from the \ + contract JSON rather than stored on the token row. Fetch without it and filter on \ + `hasDistribution`, or call `columnBackedDistributionTokensPredicate()` when the two \ + column-backed kinds really are all you want. + """ + ) + static func distributionTokensPredicate() -> Predicate { + columnBackedDistributionTokensPredicate() + } + + static func pausedTokensPredicate() -> Predicate { + #Predicate { token in + token.isPaused == true + } + } + + static func tokensByContractPredicate(contractId: Data) -> Predicate { + #Predicate { token in + token.contractId == contractId + } + } + + static func tokensWithControlRulePredicate(rule: ControlRuleType) -> Predicate { + switch rule { + case .manualMinting: + return #Predicate { token in + token.manualMintingRules != nil + } + case .manualBurning: + return #Predicate { token in + token.manualBurningRules != nil + } + case .freeze: + return #Predicate { token in + token.freezeRules != nil + } + case .unfreeze: + return #Predicate { token in + token.unfreezeRules != nil + } + case .destroyFrozenFunds: + return #Predicate { token in + token.destroyFrozenFundsRules != nil + } + case .emergencyAction: + return #Predicate { token in + token.emergencyActionRules != nil + } + case .conventions: + return #Predicate { token in + token.conventionsChangeRules != nil + } + case .maxSupply: + return #Predicate { token in + token.maxSupplyChangeRules != nil + } + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenBalance.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenBalance.swift new file mode 100644 index 00000000000..da61f3ba641 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenBalance.swift @@ -0,0 +1,198 @@ +import Foundation +import SwiftData + +// `PersistentTokenBalance` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentTokenBalance { + #Index([\.networkRaw]) + + var tokenId: String + var identityId: Data + var balance: Int64 + var frozen: Bool + + var createdAt: Date + var lastUpdated: Date + var lastSyncedAt: Date? + + var tokenName: String? + var tokenSymbol: String? + var tokenDecimals: Int32? + + var networkRaw: UInt32 + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + @Relationship(deleteRule: .nullify) var identity: PersistentIdentity? + @Relationship(inverse: \PersistentToken.balances) var token: PersistentToken? + + init( + tokenId: String, + identityId: Data, + balance: Int64 = 0, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.tokenId = tokenId + self.identityId = identityId + self.balance = balance + self.frozen = frozen + self.tokenName = tokenName + self.tokenSymbol = tokenSymbol + self.tokenDecimals = tokenDecimals + self.createdAt = Date() + self.lastUpdated = Date() + self.lastSyncedAt = nil + self.networkRaw = network.rawValue + } + + convenience init( + tokenId: String, + identityId: Data, + unsignedBalance: UInt64, + frozen: Bool = false, + tokenName: String? = nil, + tokenSymbol: String? = nil, + tokenDecimals: Int32? = nil, + network: Network + ) { + self.init( + tokenId: tokenId, + identityId: identityId, + balance: Int64(bitPattern: unsignedBalance), + frozen: frozen, + tokenName: tokenName, + tokenSymbol: tokenSymbol, + tokenDecimals: tokenDecimals, + network: network + ) + } + + var unsignedBalance: UInt64 { + get { UInt64(bitPattern: balance) } + set { balance = Int64(bitPattern: newValue) } + } + + var formattedBalance: String { + let decimals: Int + if let tokenDecimals { + decimals = Int(tokenDecimals) + } else if let tokenDecimals = token?.decimals { + decimals = tokenDecimals + } else { + return "\(unsignedBalance)" + } + + guard decimals > 0 else { return String(unsignedBalance) } + + let digits = String(unsignedBalance) + let scale = decimals + if digits.count <= scale { + return "0." + String(repeating: "0", count: scale - digits.count) + digits + } + let split = digits.index(digits.endIndex, offsetBy: -scale) + return String(digits[.. (tokenId: String, balance: UInt64, frozen: Bool) { + return (tokenId: tokenId, balance: unsignedBalance, frozen: frozen) + } +} + +extension DashSchemaSnapshotV4.PersistentTokenBalance { + static func predicate(tokenId: String, identityId: Data) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId && balance.identityId == identityId + } + } + + static func predicate(identityId: Data) -> Predicate { + #Predicate { balance in + balance.identityId == identityId + } + } + + static func predicate(tokenId: String) -> Predicate { + #Predicate { balance in + balance.tokenId == tokenId + } + } + + static var nonZeroBalancesPredicate: Predicate { + #Predicate { balance in + balance.balance != 0 + } + } + + static var frozenBalancesPredicate: Predicate { + #Predicate { balance in + balance.frozen == true + } + } + + static func needsSyncPredicate(olderThan date: Date) -> Predicate { + #Predicate { balance in + balance.lastSyncedAt == nil || balance.lastSyncedAt! < date + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenHistoryEvent.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenHistoryEvent.swift new file mode 100644 index 00000000000..af354fd6d32 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTokenHistoryEvent.swift @@ -0,0 +1,112 @@ +import Foundation +import SwiftData + +// `PersistentTokenHistoryEvent` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentTokenHistoryEvent { + @Attribute(.unique) var id: UUID + + var eventType: String + var transactionId: Data? + var blockHeight: Int64? + var coreBlockHeight: Int64? + + var fromIdentity: Data? + var toIdentity: Data? + var performedByIdentity: Data + + var amount: String? + var balanceBefore: String? + var balanceAfter: String? + + var additionalDataJSON: Data? + + var eventDescription: String? + + var createdAt: Date + var eventTimestamp: Date + + @Relationship(inverse: \PersistentToken.historyEvents) + var token: PersistentToken? + + init( + eventType: TokenEventType, + performedByIdentity: Data, + eventTimestamp: Date = Date() + ) { + self.id = UUID() + self.eventType = eventType.rawValue + self.performedByIdentity = performedByIdentity + self.eventTimestamp = eventTimestamp + self.createdAt = Date() + } + + var eventTypeEnum: TokenEventType { + TokenEventType(rawValue: eventType) ?? .unknown + } + + var fromIdentityBase58: String? { + fromIdentity?.toBase58String() + } + + var toIdentityBase58: String? { + toIdentity?.toBase58String() + } + + var performedByIdentityBase58: String { + performedByIdentity.toBase58String() + } + + var displayTitle: String { + switch eventTypeEnum { + case .mint: + return "Minted \(formattedAmount)" + case .burn: + return "Burned \(formattedAmount)" + case .transfer: + return "Transfer \(formattedAmount)" + case .freeze: + return "Frozen \(formattedAmount)" + case .unfreeze: + return "Unfrozen \(formattedAmount)" + case .destroyFrozenFunds: + return "Destroyed Frozen Funds \(formattedAmount)" + case .configUpdate: + return "Configuration Updated" + case .emergencyAction: + return "Emergency Action" + case .perpetualDistribution: + return "Perpetual Distribution \(formattedAmount)" + case .preProgrammedRelease: + return "Pre-programmed Release \(formattedAmount)" + case .directPricing: + return "Direct Pricing Updated" + case .directPurchase: + return "Direct Purchase \(formattedAmount)" + case .unknown: + return "Unknown Event" + } + } + + private var formattedAmount: String { + guard let amount = amount else { return "" } + return amount + } + + func setAdditionalData(_ data: [String: Any]) { + additionalDataJSON = try? JSONSerialization.data(withJSONObject: data) + } + + func getAdditionalData() -> [String: Any]? { + guard let data = additionalDataJSON else { return nil } + return try? JSONSerialization.jsonObject(with: data) as? [String: Any] + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTrackedMasternode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTrackedMasternode.swift new file mode 100644 index 00000000000..a673ac09cfe --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTrackedMasternode.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData + +// `PersistentTrackedMasternode` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentTrackedMasternode { + #Unique([\.networkRaw, \.proTxHash]) + #Index([\.networkRaw]) + + var networkRaw: UInt32 + var proTxHash: Data + var label: String? + var addedAt: UInt64 + var snapshotJSON: String + + var network: Network? { + get { Network(rawValue: networkRaw) } + set { networkRaw = newValue?.rawValue ?? networkRaw } + } + + init( + networkRaw: UInt32, + proTxHash: Data, + label: String?, + addedAt: UInt64, + snapshotJSON: String + ) { + self.networkRaw = networkRaw + self.proTxHash = proTxHash + self.label = label + self.addedAt = addedAt + self.snapshotJSON = snapshotJSON + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTransaction.swift new file mode 100644 index 00000000000..3ec3cf0fae4 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTransaction.swift @@ -0,0 +1,167 @@ +import Foundation +import SwiftData + +// `PersistentTransaction` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentTransaction { + #Index([\.firstSeen]) + + @Attribute(.unique) var txid: Data + var transactionData: Data + var context: UInt32 + var blockHeight: UInt32 + var blockHash: Data? + var blockTimestamp: UInt32 + var blockPosition: UInt32 = 0 + var hasBlockPosition: Bool = false + var direction: UInt32 + var transactionType: String + var transactionTypeKind: UInt8 = 0xFF + var netAmount: Int64 + var fee: UInt64? + var label: String + var firstSeen: UInt64 + + var providerServiceAddress: String? = nil + var providerProTxHash: Data? = nil + var providerCollateralTxid: Data? = nil + var providerCollateralVout: UInt32 = 0 + var providerOwnerKeyHash: Data? = nil + var providerVotingKeyHash: Data? = nil + + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentTxo.transaction) + var outputs: [PersistentTxo] = [] + + @Relationship(inverse: \PersistentTxo.spendingTransaction) + var inputs: [PersistentTxo] = [] + + @Relationship(deleteRule: .cascade, inverse: \PersistentPendingInput.spendingTransaction) + var pendingInputs: [PersistentPendingInput] = [] + + @Relationship(inverse: \PersistentAccount.involvedTransactions) + var involvedAccounts: [PersistentAccount] = [] + + init( + txid: Data, + transactionData: Data, + context: UInt32 = 0, + blockHeight: UInt32 = 0, + direction: UInt32 = 0, + transactionType: String = "Standard", + netAmount: Int64 = 0, + firstSeen: UInt64 = 0 + ) { + self.txid = txid + self.transactionData = transactionData + self.context = context + self.blockHeight = blockHeight + self.blockTimestamp = 0 + self.direction = direction + self.transactionType = transactionType + self.netAmount = netAmount + self.firstSeen = firstSeen + self.label = "" + self.createdAt = Date() + self.lastUpdated = Date() + } + + var txidHex: String { + txid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var contextName: String { + switch context { + case 0: return "Mempool" + case 1: return "InstantSend" + case 2: return "In Block" + case 3: return "Chain Locked" + default: return "Unknown" + } + } + + var directionName: String { + switch direction { + case 0: return "Incoming" + case 1: return "Outgoing" + case 2: return "Internal" + case 3: return "CoinJoin" + default: return "Unknown" + } + } + + var typedKind: TransactionTypeKind? { + TransactionTypeKind(rawValue: transactionTypeKind) + } + + var isAssetLock: Bool { + typedKind == .assetLock + } + + var isAssetUnlock: Bool { + typedKind == .assetUnlock + } + + var isProviderRegistration: Bool { + typedKind == .providerRegistration + } + + var isProviderUpdateService: Bool { + typedKind == .providerUpdateService + } + + var providerProTxHashHex: String? { + providerProTxHash.map { $0.reversed().map { String(format: "%02x", $0) }.joined() } + } + + var providerCollateralDisplay: String? { + guard let txid = providerCollateralTxid else { return nil } + let hex = txid.reversed().map { String(format: "%02x", $0) }.joined() + return "\(hex):\(providerCollateralVout)" + } + + var providerOwnerKeyHashHex: String? { + providerOwnerKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var providerVotingKeyHashHex: String? { + providerVotingKeyHash.map { $0.map { String(format: "%02x", $0) }.joined() } + } + + var isProviderSpecial: Bool { + providerSpecialName != nil + } + + var providerSpecialName: String? { + switch typedKind { + case .providerRegistration: return "Provider Registration" + case .providerUpdateRegistrar: return "Provider Update Registrar" + case .providerUpdateService: return "Provider Update Service" + case .providerUpdateRevocation: return "Provider Update Revocation" + default: return nil + } + } + + var displayDirection: String { + if isAssetLock { return "Asset Lock" } + if isAssetUnlock { return "Asset Unlock" } + if let name = providerSpecialName { return name } + return directionName + } + + var formattedAmount: String { + let dash = Double(abs(netAmount)) / 100_000_000.0 + let sign = netAmount >= 0 ? "+" : "-" + return String(format: "%@%.8f DASH", sign, dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTxo.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTxo.swift new file mode 100644 index 00000000000..b730772443c --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentTxo.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData + +// `PersistentTxo` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentTxo { + #Index([\.walletId]) + + @Attribute(.unique) var outpoint: Data + var vout: UInt32 + var amount: UInt64 + var address: String + var scriptPubKey: Data + var height: UInt32 + var isCoinbase: Bool + var isConfirmed: Bool + var isInstantLocked: Bool + var isLocked: Bool + var isSpent: Bool + var createdAt: Date + var lastUpdated: Date + + var walletId: Data = Data() + + var transaction: PersistentTransaction? + + var spendingTransaction: PersistentTransaction? + + var supersededByTxid: Data? + + var spendingInputIndex: UInt32? = nil + + var account: PersistentAccount? + + var coreAddress: PersistentCoreAddress? + + init( + transaction: PersistentTransaction, + vout: UInt32, + amount: UInt64, + address: String, + scriptPubKey: Data = Data(), + height: UInt32 = 0 + ) { + self.outpoint = Self.makeOutpoint(txid: transaction.txid, vout: vout) + self.vout = vout + self.amount = amount + self.address = address + self.scriptPubKey = scriptPubKey + self.height = height + self.isCoinbase = false + self.isConfirmed = false + self.isInstantLocked = false + self.isLocked = false + self.isSpent = false + self.createdAt = Date() + self.lastUpdated = Date() + self.transaction = transaction + } + + static func makeOutpoint(txid: Data, vout: UInt32) -> Data { + var data = Data(capacity: 36) + data.append(txid) + var v = vout.littleEndian + withUnsafeBytes(of: &v) { data.append(contentsOf: $0) } + return data + } + + var txid: Data { + if let transaction { + return transaction.txid + } + return outpoint.count >= 32 ? Data(outpoint.prefix(32)) : Data() + } + + var txidHex: String { + let rawTxid = txid + guard rawTxid.count == 32 else { return "" } + return rawTxid.reversed().map { String(format: "%02x", $0) }.joined() + } + + var outpointHex: String { + let hex = txidHex + return hex.isEmpty ? "" : "\(hex):\(vout)" + } + + var formattedAmount: String { + let dash = Double(amount) / 100_000_000.0 + return String(format: "%.8f DASH", dash) + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWallet.swift new file mode 100644 index 00000000000..d085cd9cf01 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWallet.swift @@ -0,0 +1,95 @@ +import Foundation +import SwiftData + +// `PersistentWallet` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentWallet { + #Index([\.networkRaw], [\.walletGroupId]) + #Unique([\.walletId]) + + var walletId: Data + var walletGroupId: Data = Data() + var networkRaw: UInt32? + + var network: Network? { + get { + guard let raw = networkRaw else { return nil } + return Network(rawValue: raw) ?? .testnet + } + set { networkRaw = newValue?.rawValue } + } + var name: String? + var walletDescription: String? + var birthHeight: UInt32 + var syncedHeight: UInt32 + var lastSynced: UInt64 + var lastAppliedChainLockBytes: Data? + var lastAppliedChainLockHeight: UInt32? + var isImported: Bool = false + var seedBindingVerifiedMarker: String? + var createdAt: Date + var lastUpdated: Date + + @Relationship(deleteRule: .cascade, inverse: \PersistentAccount.wallet) + var accounts: [PersistentAccount] + + @Relationship(deleteRule: .nullify, inverse: \PersistentIdentity.wallet) + var identities: [PersistentIdentity] + + init( + walletId: Data, + walletGroupId: Data = Data(), + network: Network? = nil, + name: String? = nil, + walletDescription: String? = nil, + birthHeight: UInt32 = 0, + syncedHeight: UInt32 = 0, + isImported: Bool = false + ) { + self.walletId = walletId + self.walletGroupId = walletGroupId + self.networkRaw = network?.rawValue + self.name = name + self.walletDescription = walletDescription + self.birthHeight = birthHeight + self.syncedHeight = syncedHeight + self.lastSynced = 0 + self.isImported = isImported + self.createdAt = Date() + self.lastUpdated = Date() + self.accounts = [] + self.identities = [] + } + } +} + +extension DashSchemaSnapshotV4.PersistentWallet { + var label: String { + if let name = name, !name.isEmpty { + return name + } + let hex = walletId.prefix(4) + .map { String(format: "%02x", $0) } + .joined() + return hex.isEmpty ? "Wallet" : "Wallet \(hex)…" + } +} + +extension DashSchemaSnapshotV4.PersistentWallet { + static func predicate(walletId: Data) -> Predicate { + #Predicate { $0.walletId == walletId } + } + + static func predicate( + walletGroupId: Data + ) -> Predicate { + #Predicate { $0.walletGroupId == walletGroupId } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWalletManagerMetadata.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWalletManagerMetadata.swift new file mode 100644 index 00000000000..1a08dd6ab62 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+PersistentWalletManagerMetadata.swift @@ -0,0 +1,34 @@ +import Foundation +import SwiftData + +// `PersistentWalletManagerMetadata` exactly as schema DashSchemaSnapshotV4 registered it, generated by +// scripts/freeze_schema_models.py from the live model at commit 292b5101f3. +// Do not edit: every stored property, its optionality and default, and +// every @Attribute / @Relationship / #Unique here is an input to that +// version's checksum, and every #Index to the store's SQLite indexes; +// changing any of them re-breaks the stores this copy exists to keep +// openable. See the live model for what each column means. +extension DashSchemaSnapshotV4 { + @Model + final class PersistentWalletManagerMetadata { + @Attribute(.unique) var networkRaw: UInt32 + var combinedSyncHeight: UInt32 + var combinedSyncBlockHash: Data? + var walletCount: Int + var createdAt: Date + var lastUpdated: Date + + var network: Network { + get { Network(rawValue: networkRaw) ?? .testnet } + set { networkRaw = newValue.rawValue } + } + + init(network: Network) { + self.networkRaw = network.rawValue + self.combinedSyncHeight = 0 + self.walletCount = 0 + self.createdAt = Date() + self.lastUpdated = Date() + } + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+Schema.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+Schema.swift new file mode 100644 index 00000000000..1362c23bf5e --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+Schema.swift @@ -0,0 +1,47 @@ +import Foundation +import SwiftData + +// Generated release snapshot; never add alongside its live version in the migration plan. +enum DashSchemaSnapshotV4: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + static var models: [any PersistentModel.Type] { + [ + PersistentIdentity.self, + PersistentDPNSName.self, + PersistentDashpayProfile.self, + PersistentDashpayContactProfile.self, + PersistentDashpayContactRequest.self, + PersistentDashpayPayment.self, + PersistentDashpayIgnoredSender.self, + PersistentDocument.self, + PersistentDataContract.self, + PersistentPublicKey.self, + PersistentTokenBalance.self, + PersistentKeyword.self, + PersistentToken.self, + PersistentDocumentType.self, + PersistentIndex.self, + PersistentProperty.self, + PersistentTokenHistoryEvent.self, + PersistentPlatformAddress.self, + PersistentPlatformAddressesSyncState.self, + PersistentWallet.self, + PersistentAccount.self, + PersistentCoreAddress.self, + PersistentTransaction.self, + PersistentTxo.self, + PersistentPendingInput.self, + PersistentWalletManagerMetadata.self, + PersistentShieldedNote.self, + PersistentShieldedOutgoingNote.self, + PersistentShieldedSyncState.self, + PersistentShieldedActivity.self, + PersistentShieldedViewingKey.self, + PersistentAssetLock.self, + PersistentInvitation.self, + PersistentMasternode.self, + PersistentTrackedMasternode.self, + PersistentIdentityBalanceMetadata.self + ] + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+TokenTypes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+TokenTypes.swift new file mode 100644 index 00000000000..568f869cf26 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/FrozenSchemas/DashSchemaSnapshotV4+TokenTypes.swift @@ -0,0 +1,165 @@ +import Foundation +import SwiftData + +// Inline value types exactly as schema DashSchemaSnapshotV4 stored them, generated +// by scripts/freeze_schema_models.py from TokenTypes.swift +// at commit 292b5101f3. SwiftData expands a stored Codable struct into composite +// attributes of the owning entity, so these shapes are inputs to that +// version's checksum just like the model's own properties. Do not edit. +extension DashSchemaSnapshotV4 { + struct ChangeControlRules: Codable, Equatable, Sendable { + var authorizedToMakeChange: String + var adminActionTakers: String + var changingAuthorizedActionTakersToNoOneAllowed: Bool + var changingAdminActionTakersToNoOneAllowed: Bool + var selfChangingAdminActionTakersAllowed: Bool + + init( + authorizedToMakeChange: String = AuthorizedActionTakers.noOne.rawValue, + adminActionTakers: String = AuthorizedActionTakers.noOne.rawValue, + changingAuthorizedActionTakersToNoOneAllowed: Bool = false, + changingAdminActionTakersToNoOneAllowed: Bool = false, + selfChangingAdminActionTakersAllowed: Bool = false + ) { + self.authorizedToMakeChange = authorizedToMakeChange + self.adminActionTakers = adminActionTakers + self.changingAuthorizedActionTakersToNoOneAllowed = changingAuthorizedActionTakersToNoOneAllowed + self.changingAdminActionTakersToNoOneAllowed = changingAdminActionTakersToNoOneAllowed + self.selfChangingAdminActionTakersAllowed = selfChangingAdminActionTakersAllowed + } + + static func mostRestrictive() -> ChangeControlRules { + return ChangeControlRules() + } + + static func contractOwnerControlled() -> ChangeControlRules { + return ChangeControlRules( + authorizedToMakeChange: AuthorizedActionTakers.contractOwner.rawValue, + adminActionTakers: AuthorizedActionTakers.noOne.rawValue, + selfChangingAdminActionTakersAllowed: true + ) + } + } + + enum AuthorizedActionTakers: String, CaseIterable, Codable, Sendable { + case noOne = "NoOne" + case contractOwner = "ContractOwner" + case mainGroup = "MainGroup" + + enum WireType { + static let noOne = "noOne" + static let contractOwner = "contractOwner" + static let identity = "identity" + static let mainGroup = "mainGroup" + static let group = "group" + } + + static func identity(_ id: Data) -> String { + return "Identity:\(id.toBase58String())" + } + + static func identity(_ base58Id: String) -> String { + return "Identity:\(base58Id)" + } + + static func group(_ position: Int) -> String { + return "Group:\(position)" + } + } + + struct TokenPerpetualDistribution: Codable, Equatable, Sendable { + var distributionType: String + var distributionRecipient: String + var enabled: Bool + var lastDistributionTime: Date? + var nextDistributionTime: Date? + + init(distributionRecipient: String = "AllEqualShare", enabled: Bool = true) { + self.distributionType = "{}" + self.distributionRecipient = distributionRecipient + self.enabled = enabled + } + } + + struct TokenPreProgrammedDistribution: Codable, Equatable, Sendable { + var distributionSchedule: [DistributionEvent] + var currentEventIndex: Int + var totalDistributed: String + var remainingToDistribute: String + var isActive: Bool + var isPaused: Bool + var isCompleted: Bool + + init() { + self.distributionSchedule = [] + self.currentEventIndex = 0 + self.totalDistributed = "0" + self.remainingToDistribute = "0" + self.isActive = true + self.isPaused = false + self.isCompleted = false + } + } + + struct DistributionEvent: Codable, Equatable, Sendable { + var id: UUID + var triggerType: String + var triggerTime: Date? + var triggerBlock: Int64? + var triggerCondition: String? + var amount: String + var recipient: String + var description: String? + + init(triggerTime: Date, amount: String, recipient: String = "AllHolders", description: String? = nil) { + self.id = UUID() + self.triggerType = "Time" + self.triggerTime = triggerTime + self.amount = amount + self.recipient = recipient + self.description = description + } + } + + struct TokenDistributionChangeRules: Codable, Equatable, Sendable { + var perpetualDistributionRules: ChangeControlRules? + var newTokensDestinationIdentityRules: ChangeControlRules? + var mintingAllowChoosingDestinationRules: ChangeControlRules? + var changeDirectPurchasePricingRules: ChangeControlRules? + + init( + perpetualDistributionRules: ChangeControlRules? = nil, + newTokensDestinationIdentityRules: ChangeControlRules? = nil, + mintingAllowChoosingDestinationRules: ChangeControlRules? = nil, + changeDirectPurchasePricingRules: ChangeControlRules? = nil + ) { + self.perpetualDistributionRules = perpetualDistributionRules + self.newTokensDestinationIdentityRules = newTokensDestinationIdentityRules + self.mintingAllowChoosingDestinationRules = mintingAllowChoosingDestinationRules + self.changeDirectPurchasePricingRules = changeDirectPurchasePricingRules + } + } + + enum TokenTradeMode: String, CaseIterable, Codable, Sendable { + case notTradeable = "NotTradeable" + + var displayName: String { + switch self { + case .notTradeable: + return "Not Tradeable" + } + } + } + + struct TokenLocalization: Codable, Equatable, Sendable { + let singularForm: String + let pluralForm: String + let description: String? + + init(singularForm: String, pluralForm: String, description: String? = nil) { + self.singularForm = singularForm + self.pluralForm = pluralForm + self.description = description + } + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index 26a2fd54943..8a494a0a09c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,18 +11,19 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV5: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV6: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(6, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV5.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { static var schemas: [any VersionedSchema.Type] { - [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self, BridgeFutureV6.self] } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), - .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self), + .custom(fromVersion: DashSchemaV5.self, toVersion: BridgeFutureV6.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -773,7 +774,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV5.self) + let schema = Schema(versionedSchema: BridgeFutureV6.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 8e5344b165f..0cb85079011 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -75,11 +75,11 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let log = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(log.contains("event=store_open_started")) - XCTAssertTrue(log.contains("route=\"historical-v2-to-v4\"")) + XCTAssertTrue(log.contains("route=\"historical-v2-to-v5\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) + XCTAssertTrue(log.contains("target_version=\"5.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v5\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"5.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV5RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["5.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index 7e268061af8..6b5482a4b67 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -99,8 +99,15 @@ final class DashReleasedSchemaTests: XCTestCase { } } + private enum BaseV4Plan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { [DashSchemaV3.self, DashSchemaV4.self] } + static var stages: [MigrationStage] { + [.lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] + } + } + @MainActor - func testShouldMigrateV3AccountingAvailabilityAndPersistAnUnavailableAmount() throws { + func testShouldMigrateV4AccountingAvailabilityAndPersistAnUnavailableAmount() throws { let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { $0.version.versionIdentifier == Schema.Version(3, 0, 0) }) @@ -109,12 +116,19 @@ final class DashReleasedSchemaTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let url = directory.appendingPathComponent("accounting.store") try FileManager.default.copyItem(at: source(fixture), to: url) + try autoreleasepool { + let schema = Schema(versionedSchema: DashSchemaV4.self) + let container = try ModelContainer(for: schema, migrationPlan: BaseV4Plan.self, + configurations: [ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none)]) + try container.mainContext.save() + } + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) let txid = Data(repeating: 0x32, count: 32) try autoreleasepool { let container = try DashModelContainer.create(url: url) let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) .first { $0.txid == txid }) - XCTAssertNil(row.netAmountUnavailable, "V3 rows retain their stored accounting by default") + XCTAssertNil(row.netAmountUnavailable, "V4 rows retain their stored accounting by default") XCTAssertEqual(row.displayNetAmount(for: nil), row.netAmount) row.netAmountUnavailable = true try container.mainContext.save() @@ -126,7 +140,7 @@ final class DashReleasedSchemaTests: XCTestCase { XCTAssertEqual(row.netAmountUnavailable, true) XCTAssertNil(row.displayNetAmount(for: nil)) XCTAssertEqual(row.formattedAmount, "Amount unavailable") - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["5.0.0"]) } } diff --git a/packages/swift-sdk/scripts/freeze_schema_models.py b/packages/swift-sdk/scripts/freeze_schema_models.py index ef4cf8b08d2..d5c59b05b57 100755 --- a/packages/swift-sdk/scripts/freeze_schema_models.py +++ b/packages/swift-sdk/scripts/freeze_schema_models.py @@ -333,6 +333,8 @@ def emit(path, text): f"{OUT_DIR}/{freeze.schema}+{model}.swift", render_model(freeze, sha, source, model, sibling), ) + # Preserve the merged V4 model graph independently of release capture. + files.update(render_source_snapshot(root, "4.0.0", "292b5101f33b7b7a68f7ffd97df1520b2ff9ef67")) return files @@ -662,13 +664,19 @@ def render_snapshot(root, version, entry, *, inventory=None): commit = entry["platform_sha"] if not re.fullmatch(r"[0-9a-f]{40}", commit): raise SystemExit("release source must be a full Git SHA") - namespace = "DashSchemaSnapshotV" + version.split(".")[0] - if entry["namespace"] != namespace: + inventory = read_inventory(root, commit) if inventory is None else validate_inventory(inventory) + if set(inventory["models"]) != set(entry["schema"]["entity_hashes"]): + raise SystemExit("historical inventory differs from captured model membership") + if entry["namespace"] != "DashSchemaSnapshotV" + version.split(".")[0]: raise SystemExit("unexpected snapshot namespace") + return render_source_snapshot(root, version, commit, inventory=inventory) + + +def render_source_snapshot(root, version, commit, *, inventory=None): + """Freeze a source graph without asserting App Store release provenance.""" + namespace = "DashSchemaSnapshotV" + version.split(".")[0] inventory = read_inventory(root, commit) if inventory is None else validate_inventory(inventory) models = inventory["models"] - if set(models) != set(entry["schema"]["entity_hashes"]): - raise SystemExit("historical inventory differs from captured model membership") sources = inventory_sources(root, inventory, commit) validate_storage_graph(inventory, sources) names = set(models) | {name for group in inventory["value_types"] for name in group["names"]} diff --git a/packages/swift-sdk/scripts/test_freeze_schema_models.py b/packages/swift-sdk/scripts/test_freeze_schema_models.py index 128f501bd42..4d2ef3be8c6 100644 --- a/packages/swift-sdk/scripts/test_freeze_schema_models.py +++ b/packages/swift-sdk/scripts/test_freeze_schema_models.py @@ -51,7 +51,7 @@ def setUp(self): shutil.copyfile(os.path.join(ROOT, gen.TEST_REGISTRY_FILE), target) def test_should_find_the_committed_files_are_the_generators_output(self): - self.assertEqual(len(gen.render_baseline(ROOT)), 71) + self.assertEqual(len(gen.render_baseline(ROOT)), 109) self.assertIn(gen.TEST_REGISTRY_FILE, self.files) self.assertEqual(gen.check_problems(ROOT, self.files), []) From a22e3246a4b45387b6871aa26f9059ac70173d16 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:17:54 +0000 Subject: [PATCH 38/84] style(swift-sdk): align migration stage indentation Co-Authored-By: OpenAI Codex --- .../Sources/SwiftDashSDK/Persistence/DashModelContainer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 3614c6633cd..c45fadd05b8 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -310,7 +310,7 @@ enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), - .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self)] + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self)] } } From accca0d9eb6e42989c40dc260eae6d14ce5a5ed8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:45:15 +0000 Subject: [PATCH 39/84] feat(platform-wallet): emit per-wallet transaction accounting to persistence hosts Hosts kept one transaction row per txid and re-derived each wallet's net amount and direction from their own TXO links. That accounting belongs to rs-platform-wallet, so it now hands it over ready to store: - `changeset::record_accounting` turns a record into a `WalletAccounting` (txid, net, direction, fee), or `None` for a detail-less record such as an asset lock rebuilt from its bytes at load and re-emitted on chain-lock promotion, whose net of 0 is only a placeholder. - `changeset::history_accounting` computes the same rows for a host's stored history from the restored wallet's own scripts (contact watch-only chains excluded), so rows stored before hosts kept per-wallet accounting are backfilled with the live rule. - `PlatformWalletPersistence` gains `load_core_history_for_accounting` and `store_transaction_accounting` (no-op defaults); `load_from_persistor` runs the backfill per loaded wallet and never fails the load on it. - FFI: `TransactionAccountingFFI`, `CoreHistoryTransactionFFI` and three size-gated `PersistenceCallbacksExtension` slots. The persist slot fires after each round's changeset callback with the round's rows; the frozen `TransactionRecordFFI` and `WalletChangeSetFFI` layouts are unchanged. `PLATFORM_WALLET_TRANSACTION_DIRECTION_*` export the direction values. Co-Authored-By: Claude Opus 5.5 --- .../src/core_wallet_types.rs | 75 ++- .../rs-platform-wallet-ffi/src/manager.rs | 19 +- .../rs-platform-wallet-ffi/src/persistence.rs | 512 +++++++++++++++++- .../src/sqlite/schema/core_history.rs | 6 +- .../rs-platform-wallet/src/changeset/mod.rs | 5 +- .../src/changeset/traits.rs | 34 ++ .../src/changeset/wallet_accounting.rs | 410 +++++++++++++- .../rs-platform-wallet/src/manager/load.rs | 164 +++++- 8 files changed, 1178 insertions(+), 47 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index dd56522df75..be4896c895a 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -364,6 +364,40 @@ pub struct AccountChangeSetFFI { pub has_internal_highest_used: bool, } +/// One transaction's Core accounting from ONE wallet's point of view, as +/// `platform_wallet::changeset::WalletAccounting` computes it. The host +/// persists it keyed by `(txid, wallet_id)` (the wallet is the callback's +/// `wallet_id`) and displays it; it never derives or corrects it. +/// +/// [`TransactionRecordFFI`]'s own `net_amount` / `direction` / `fee` stay +/// for hosts that predate this row. A record with no accounting evidence +/// yields NO row here, so the host keeps what it stored. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransactionAccountingFFI { + pub txid: [u8; 32], + /// `Σ owned outputs − Σ owned inputs` in duffs (positive = received). + pub net_amount: i64, + /// 0=incoming, 1=outgoing, 2=internal, 3=coinJoin. + pub direction: u32, + /// Meaningful only when `has_fee`. + pub fee: u64, + pub has_fee: bool, +} + +/// One stored Core transaction the host hands back for the load-time +/// accounting backfill (see `on_load_core_history_for_accounting_fn`). +/// Host-owned until the matching free callback. +#[repr(C)] +pub struct CoreHistoryTransactionFFI { + /// Consensus-encoded transaction body. + pub tx_bytes: *const u8, + pub tx_bytes_len: usize, + /// The host holds no `TransactionAccountingFFI` row for this + /// transaction and wallet yet. + pub needs_accounting: bool, +} + // --------------------------------------------------------------------------- // Top-level wallet changeset // --------------------------------------------------------------------------- @@ -1525,6 +1559,40 @@ pub(crate) fn masternode_entry_v2_ffi( } } +/// `direction` wire values of [`TransactionRecordFFI`] and +/// [`TransactionAccountingFFI`], exported so hosts name them from the +/// generated header instead of re-declaring them. +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING: u32 = 0; +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING: u32 = 1; +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL: u32 = 2; +pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN: u32 = 3; + +fn direction_to_u32( + direction: key_wallet::managed_account::transaction_record::TransactionDirection, +) -> u32 { + use key_wallet::managed_account::transaction_record::TransactionDirection; + match direction { + TransactionDirection::Incoming => PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING, + TransactionDirection::Outgoing => PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING, + TransactionDirection::Internal => PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL, + TransactionDirection::CoinJoin => PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN, + } +} + +impl From<&platform_wallet::changeset::WalletAccounting> for TransactionAccountingFFI { + fn from(row: &platform_wallet::changeset::WalletAccounting) -> Self { + let mut txid = [0u8; 32]; + txid.copy_from_slice(row.txid.as_ref()); + Self { + txid, + net_amount: row.net_amount, + direction: direction_to_u32(row.direction), + fee: row.fee.unwrap_or(0), + has_fee: row.fee.is_some(), + } + } +} + fn tx_record_to_ffi( tr: &key_wallet::managed_account::transaction_record::TransactionRecord, ) -> TransactionRecordFFI { @@ -1571,12 +1639,7 @@ fn tx_record_to_ffi( } }; - let dir_val = match tr.direction { - key_wallet::managed_account::transaction_record::TransactionDirection::Incoming => 0u32, - key_wallet::managed_account::transaction_record::TransactionDirection::Outgoing => 1, - key_wallet::managed_account::transaction_record::TransactionDirection::Internal => 2, - key_wallet::managed_account::transaction_record::TransactionDirection::CoinJoin => 3, - }; + let dir_val = direction_to_u32(tr.direction); let type_str = CString::new(format!("{:?}", tr.transaction_type)) .unwrap_or_else(|_| CString::new("Unknown").unwrap()); diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 4d1adad6356..e5767267301 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,9 +8,10 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, - LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, - PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + FFIPersister, FreeCoreHistoryForAccountingFn, FreeTrackedMasternodesFn, + LoadAssetLockOperationIdFn, LoadCoreHistoryForAccountingFn, LoadIdentityBalanceBlockTimeFn, + LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, + PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistTransactionAccountingFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, @@ -290,6 +291,18 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), + persist_transaction_accounting: slot!( + on_persist_transaction_accounting_fn, + PersistTransactionAccountingFn + ), + load_core_history_for_accounting: slot!( + on_load_core_history_for_accounting_fn, + LoadCoreHistoryForAccountingFn + ), + load_core_history_for_accounting_free: slot!( + on_load_core_history_for_accounting_free_fn, + FreeCoreHistoryForAccountingFn + ), } } diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 09e06b1fe14..1846eaa2b23 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -30,11 +30,13 @@ use crate::types::{FFINetwork, Network}; use platform_wallet::changeset::provider_key_account::{ rebuild_provider_key_account, ProviderAccountRebuildError, }; +use platform_wallet::changeset::record_accounting; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, + ProviderKeyExtendedPubKey, StoredCoreTransaction, WalletAccounting, + PERSISTENCE_CAPABILITIES_VERSION, }; use platform_wallet::wallet::platform_wallet::WalletId; #[cfg(feature = "shielded")] @@ -56,8 +58,9 @@ use crate::contact_persistence::{ use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ build_dashpay_backfill_covered_for_callback, build_sweep_batches_for_callback, - build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, - DashPayBackfillCoveredContactFFI, SweepBatchFFI, UtxoCreditVerdictFFI, WalletChangeSetFFI, + build_utxo_credit_verdicts_for_callback, free_wallet_changeset_ffi, CoreHistoryTransactionFFI, + DashPayBackfillCoveredContactFFI, SweepBatchFFI, TransactionAccountingFFI, + UtxoCreditVerdictFFI, WalletChangeSetFFI, }; use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; use crate::dpns_name_state_persistence::{ @@ -319,6 +322,40 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( out_operation_id: *mut u8, ) -> i32; +/// Carries one wallet's accounting rows (see [`TransactionAccountingFFI`]). +/// The host upserts each keyed by `(txid, wallet_id)`. Fired inside a round +/// right after `on_persist_wallet_changeset_fn`, so the transaction rows the +/// accounting belongs to already exist, and only when the round has a +/// record with accounting evidence. Also fired OUTSIDE any round, once per +/// wallet at load, with the backfill computed from +/// `on_load_core_history_for_accounting_fn`; the host then commits on its +/// own. A non-zero return fails the round like any other per-kind callback. +pub type PersistTransactionAccountingFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, +) -> i32; + +/// Return the wallet's stored Core transactions for the accounting backfill: +/// an EMPTY array when every one already has an accounting row for this +/// wallet, otherwise ALL of them with `needs_accounting` set on the ones +/// missing it (the others resolve the flagged ones' inputs). The host keeps +/// the array valid until [`FreeCoreHistoryForAccountingFn`]. +pub type LoadCoreHistoryForAccountingFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_rows: *mut *const CoreHistoryTransactionFFI, + out_count: *mut usize, +) -> i32; + +/// Release an array returned by [`LoadCoreHistoryForAccountingFn`]. +pub type FreeCoreHistoryForAccountingFn = unsafe extern "C" fn( + context: *mut c_void, + rows: *const CoreHistoryTransactionFFI, + count: usize, +); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -485,6 +522,35 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, + /// Per-wallet transaction accounting (see + /// [`PersistTransactionAccountingFn`]). Additive like the slots above: a + /// host without it keeps reading `TransactionRecordFFI`'s own values. + pub on_persist_transaction_accounting_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, + ) -> i32, + >, + /// Stored history for the load-time accounting backfill (see + /// [`LoadCoreHistoryForAccountingFn`]). Read only together with the free + /// slot below and the persist slot above. + pub on_load_core_history_for_accounting_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_rows: *mut *const CoreHistoryTransactionFFI, + out_count: *mut usize, + ) -> i32, + >, + pub on_load_core_history_for_accounting_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + rows: *const CoreHistoryTransactionFFI, + count: usize, + ), + >, } impl Default for PersistenceCallbacksExtension { @@ -505,6 +571,9 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_dashpay_backfill_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, + on_persist_transaction_accounting_fn: None, + on_load_core_history_for_accounting_fn: None, + on_load_core_history_for_accounting_free_fn: None, } } } @@ -526,6 +595,9 @@ pub struct PersistenceExtensionCallbacks { pub wallet_dashpay_backfill: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, + pub persist_transaction_accounting: Option, + pub load_core_history_for_accounting: Option, + pub load_core_history_for_accounting_free: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1467,6 +1539,14 @@ pub struct FFIPersister { tracked_masternodes_callbacks: PersistenceExtensionCallbacks, persist_identity_balance_block_time_callback: Option, load_identity_balance_block_time_callback: Option, + /// Per-wallet accounting rows, fired after each round's changeset and by + /// the load-time backfill. `None` keeps a host on `TransactionRecordFFI`'s + /// values, as before the slot existed. + persist_transaction_accounting_callback: Option, + /// Stored-history read for the backfill; used only when the free slot is + /// wired too. + load_core_history_for_accounting_callback: Option, + load_core_history_for_accounting_free_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1592,6 +1672,10 @@ impl FFIPersister { persist_identity_balance_block_time_callback: extensions .persist_identity_balance_block_time, load_identity_balance_block_time_callback: extensions.load_identity_balance_block_time, + persist_transaction_accounting_callback: extensions.persist_transaction_accounting, + load_core_history_for_accounting_callback: extensions.load_core_history_for_accounting, + load_core_history_for_accounting_free_callback: extensions + .load_core_history_for_accounting_free, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), } @@ -1825,6 +1909,87 @@ impl PlatformWalletPersistence for FFIPersister { Ok(()) } + fn load_core_history_for_accounting( + &self, + wallet_id: WalletId, + ) -> Result, PersistenceError> { + // Rows nobody could persist are not worth reading. + let (Some(load), Some(free), Some(_)) = ( + self.load_core_history_for_accounting_callback, + self.load_core_history_for_accounting_free_callback, + self.persist_transaction_accounting_callback, + ) else { + return Ok(Vec::new()); + }; + let mut rows_ptr: *const CoreHistoryTransactionFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + &mut rows_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_core_history_for_accounting_fn returned error code {rc}"), + )); + } + let mut out = Vec::with_capacity(count); + if !rows_ptr.is_null() && count > 0 { + let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; + for row in rows { + let bytes = unsafe { slice_from_raw(row.tx_bytes, row.tx_bytes_len) }; + match dashcore::consensus::encode::deserialize::(bytes) { + Ok(transaction) => out.push(StoredCoreTransaction { + transaction, + needs_accounting: row.needs_accounting, + }), + // One undecodable row only loses its own accounting (or a + // prevout another row would have resolved). + Err(e) => tracing::warn!( + error = %e, + "load: skipping an undecodable stored transaction in the accounting backfill" + ), + } + } + } + unsafe { free(self.callbacks.context, rows_ptr, count) }; + Ok(out) + } + + fn store_transaction_accounting( + &self, + wallet_id: WalletId, + rows: &[WalletAccounting], + ) -> Result<(), PersistenceError> { + let Some(cb) = self.persist_transaction_accounting_callback else { + return Ok(()); + }; + if rows.is_empty() { + return Ok(()); + } + let rows: Vec = + rows.iter().map(TransactionAccountingFFI::from).collect(); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + rows.as_ptr(), + rows.len(), + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_persist_transaction_accounting_fn returned error code {rc}"), + )); + } + Ok(()) + } + fn load_tracked_masternodes( &self, network: dashcore::Network, @@ -2269,6 +2434,37 @@ impl PlatformWalletPersistence for FFIPersister { } } + // Per-wallet accounting rides its own size-negotiated slot: the + // frozen `TransactionRecordFFI` stride cannot say "this record + // carries no accounting", and this row can, by being absent. + // Fired after the changeset callback so the host already holds + // the transaction rows these belong to. + if let Some(cb) = self.persist_transaction_accounting_callback { + let rows: Vec = core_cs + .records + .iter() + .filter_map(record_accounting) + .map(|row| TransactionAccountingFFI::from(&row)) + .collect(); + if !rows.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + rows.as_ptr(), + rows.len(), + ) + }; + if result != 0 { + eprintln!( + "Transaction accounting persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + // The numeric chainlock height rides its own size-negotiated // extension slot for the same layout reason the sweeps below do: // the bincode blob on the changeset struct is opaque to a @@ -9757,6 +9953,286 @@ mod tests { drop(persister); } + /// Per-wallet accounting crosses right after the round's changeset, one + /// row per record that carries details. A detail-less record (an asset + /// lock's funding record rebuilt at load and re-emitted when a chain + /// lock promotes it) crosses as NO row, so the host keeps what it stored + /// instead of overwriting it with the placeholder 0. A host without the + /// slot still succeeds. + #[test] + fn store_delivers_transaction_accounting_after_the_changeset_skipping_detailless_records() { + use dashcore::hashes::Hash as _; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + OutputDetail, OutputRole, TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::TransactionType; + use platform_wallet::changeset::CoreChangeSet; + use platform_wallet::test_support::core_fixtures::{ + block, foreign_address, outside, pay, spend_of, + }; + + #[derive(Default)] + struct Sink { + events: std::sync::Mutex>, + } + unsafe extern "C" fn record_changeset( + ctx: *mut c_void, + _wallet_id: *const u8, + _changeset: *const WalletChangeSetFFI, + ) -> i32 { + let sink = &*(ctx as *const Sink); + sink.events.lock().unwrap().push("changeset".into()); + 0 + } + unsafe extern "C" fn record_accounting_rows( + ctx: *mut c_void, + wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, + ) -> i32 { + let sink = &*(ctx as *const Sink); + let wallet = *wallet_id; + let mut events = sink.events.lock().unwrap(); + for row in slice::from_raw_parts(rows, rows_count) { + events.push(format!( + "accounting wallet={wallet:02x} txid={} net={} direction={} has_fee={}", + hex::encode(row.txid), + row.net_amount, + row.direction, + row.has_fee + )); + } + 0 + } + let bip44 = AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }; + let received_tx = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let received = TransactionRecord::new( + received_tx.clone(), + bip44, + block(10), + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + vec![OutputDetail { + index: 0, + role: OutputRole::Received, + address: Some(foreign_address(2)), + value: 5_000, + }], + 5_000, + ); + let restored_lock = TransactionRecord::new( + spend_of(&[outside(3)], vec![pay(9_000, &foreign_address(4))]), + bip44, + block(11), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + let round = || PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![received.clone(), restored_lock.clone()], + ..Default::default() + }), + ..Default::default() + }; + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_transaction_accounting: Some(record_accounting_rows), + ..Default::default() + }, + ); + persister + .store([0xAA; 32], round()) + .expect("accounting round must succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec![ + "changeset".to_string(), + format!( + "accounting wallet=aa txid={} net=5000 direction=0 has_fee=false", + hex::encode(received_tx.txid().as_byte_array()) + ), + ], + ); + drop(persister); + + let sink = Sink::default(); + let callbacks = PersistenceCallbacks { + context: &sink as *const Sink as *mut c_void, + on_persist_wallet_changeset_fn: Some(record_changeset), + ..PersistenceCallbacks::default() + }; + let persister = FFIPersister::new_with_persistence_capabilities( + callbacks, + PersistenceCapabilities::NONE, + ); + persister + .store([0xAA; 32], round()) + .expect("slotless-host round must still succeed"); + assert_eq!( + sink.events.lock().unwrap().clone(), + vec!["changeset".to_string()] + ); + } + + /// The load-time backfill reads the host's stored history through the + /// load/free pair, decoding each body and keeping its flag (an + /// undecodable body is skipped, not fatal), and hands the computed rows + /// back through the persist slot. Without the persist slot nothing is + /// read: rows nobody can store are not worth computing. + #[test] + fn should_read_stored_history_and_persist_backfilled_accounting_through_the_extension() { + use key_wallet::managed_account::transaction_record::TransactionDirection; + use platform_wallet::test_support::core_fixtures::{ + foreign_address, outside, pay, spend_of, + }; + + struct Host { + bodies: Vec>, + rows: Vec, + loads: std::sync::Mutex, + frees: std::sync::Mutex, + persisted: std::sync::Mutex>, + } + unsafe extern "C" fn load( + ctx: *mut c_void, + _wallet_id: *const u8, + out_rows: *mut *const CoreHistoryTransactionFFI, + out_count: *mut usize, + ) -> i32 { + let host = &*(ctx as *const Host); + *host.loads.lock().unwrap() += 1; + *out_rows = host.rows.as_ptr(); + *out_count = host.rows.len(); + 0 + } + unsafe extern "C" fn free( + ctx: *mut c_void, + _rows: *const CoreHistoryTransactionFFI, + _count: usize, + ) { + let host = &*(ctx as *const Host); + *host.frees.lock().unwrap() += 1; + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _wallet_id: *const u8, + rows: *const TransactionAccountingFFI, + rows_count: usize, + ) -> i32 { + let host = &*(ctx as *const Host); + host.persisted + .lock() + .unwrap() + .extend_from_slice(slice::from_raw_parts(rows, rows_count)); + 0 + } + + let first = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let second = spend_of(&[outside(3)], vec![pay(7_000, &foreign_address(4))]); + let mut host = Host { + bodies: vec![ + dashcore::consensus::encode::serialize(&first), + vec![0xFF], + dashcore::consensus::encode::serialize(&second), + ], + rows: Vec::new(), + loads: std::sync::Mutex::new(0), + frees: std::sync::Mutex::new(0), + persisted: std::sync::Mutex::new(Vec::new()), + }; + host.rows = host + .bodies + .iter() + .zip([true, true, false]) + .map(|(body, needs_accounting)| CoreHistoryTransactionFFI { + tx_bytes: body.as_ptr(), + tx_bytes_len: body.len(), + needs_accounting, + }) + .collect(); + let context = &host as *const Host as *mut c_void; + let extensions = PersistenceExtensionCallbacks { + persist_transaction_accounting: Some(persist), + load_core_history_for_accounting: Some(load), + load_core_history_for_accounting_free: Some(free), + ..Default::default() + }; + + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context, + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + extensions, + ); + let history = persister + .load_core_history_for_accounting([1; 32]) + .expect("history read must succeed"); + assert_eq!( + history + .iter() + .map(|stored| (stored.transaction.txid(), stored.needs_accounting)) + .collect::>(), + vec![(first.txid(), true), (second.txid(), false)] + ); + assert_eq!(*host.frees.lock().unwrap(), 1, "the host array is returned"); + + let row = WalletAccounting { + txid: first.txid(), + net_amount: -5_000, + direction: TransactionDirection::Outgoing, + fee: Some(250), + }; + persister + .store_transaction_accounting([1; 32], &[row]) + .expect("accounting persist must succeed"); + assert_eq!( + host.persisted.lock().unwrap().clone(), + vec![TransactionAccountingFFI::from(&row)] + ); + let persisted = host.persisted.lock().unwrap()[0]; + assert_eq!( + (persisted.direction, persisted.fee, persisted.has_fee), + (1, 250, true) + ); + drop(persister); + + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context, + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_transaction_accounting: None, + ..extensions + }, + ); + assert!(persister + .load_core_history_for_accounting([1; 32]) + .expect("a host that cannot store rows is not read") + .is_empty()); + assert_eq!(*host.loads.lock().unwrap(), 1); + } + #[test] fn should_roundtrip_drain_operation_ids_including_consumed_tombstones() { use dashcore::hashes::Hash; @@ -10256,6 +10732,36 @@ mod tests { PersistenceCallbacksExtension, on_load_asset_lock_operation_id_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_transaction_accounting_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_transaction_accounting_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 82544eb0b0b..d1223ca4334 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -523,10 +523,10 @@ mod tests { ); } - /// Shared with the Swift SDK's `TransactionAccountingTests` direction - /// table; pins the rule repair takes from `platform_wallet`. + /// Direction case table; pins the rule repair takes from `platform_wallet`, + /// the same one the live projection and the host-history backfill use. #[test] - fn should_classify_repaired_direction_like_the_swift_sdk() { + fn should_classify_repaired_direction_like_the_live_projection() { use TransactionDirection::{CoinJoin, Incoming, Internal, Outgoing}; use TransactionType::{AssetLock, Standard}; // (type, spends ours, has owned output, has external output, expected) diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 90550f78c8f..cb9fef3d027 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -69,4 +69,7 @@ pub use traits::{ persists_outbound_account_markers, ListedCoreTxid, PersistenceError, PersistenceErrorKind, PlatformWalletPersistence, }; -pub use wallet_accounting::{is_owned, wallet_accounting, wallet_direction}; +pub use wallet_accounting::{ + history_accounting, is_owned, record_accounting, wallet_accounting, wallet_direction, + StoredCoreTransaction, WalletAccounting, +}; diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 11e5d2c58a7..9960e0d52dd 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -8,6 +8,7 @@ use std::error::Error as StdError; use crate::changeset::changeset::{DpnsNameStateEntry, PlatformWalletChangeSet}; use crate::changeset::client_start_state::ClientStartState; use crate::changeset::persistence_capabilities::PersistenceCapabilities; +use crate::changeset::wallet_accounting::{StoredCoreTransaction, WalletAccounting}; use crate::wallet::platform_wallet::WalletId; use dashcore::Txid; use dpp::prelude::Identifier; @@ -528,6 +529,39 @@ pub trait PlatformWalletPersistence: Send + Sync { Ok(None) } + /// The wallet's persisted Core transaction history, each entry flagged + /// when the backend holds no [`WalletAccounting`] for it yet. + /// + /// Read once per wallet at load: when any entry is flagged, the manager + /// computes the missing rows with + /// [`history_accounting`](crate::changeset::history_accounting) and + /// hands them to [`Self::store_transaction_accounting`]. A backend + /// returns an EMPTY vector when nothing is flagged, so a backfilled + /// store costs one cheap read per launch. When something is flagged it + /// must return the whole history, flagged or not, because a flagged + /// spend resolves its owned inputs from the outputs of the others. + /// + /// The default (no history) suits backends that never stored + /// accounting-less rows, such as the SQLite store, whose own migration + /// repairs history. + fn load_core_history_for_accounting( + &self, + _wallet_id: WalletId, + ) -> Result, PersistenceError> { + Ok(Vec::new()) + } + + /// Persist per-wallet accounting computed outside a changeset round (the + /// load-time backfill, see [`Self::load_core_history_for_accounting`]). + /// Upsert keyed by `(txid, wallet_id)`. The default drops them. + fn store_transaction_accounting( + &self, + _wallet_id: WalletId, + _rows: &[WalletAccounting], + ) -> Result<(), PersistenceError> { + Ok(()) + } + /// Look up the persisted DPNS marketplace row for one /// `(wallet_id, wallet_identity_id, normalized_label)` triple. /// diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 48ae44a914a..9feb6a67283 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -1,11 +1,16 @@ //! Wallet-level accounting of a Core transaction record: the ownership //! predicate ([`is_owned`]), the direction rule ([`wallet_direction`]) and //! the net formula ([`wallet_accounting`]). The live projection -//! (`fold_same_txid_records`) and the SQLite repair -//! (`platform-wallet-storage`'s `core_history`) both call them, so a row +//! (`fold_same_txid_records`), the per-wallet rows handed to hosts +//! ([`record_accounting`]), the host-history backfill +//! ([`history_accounting`]) and the SQLite repair +//! (`platform-wallet-storage`'s `core_history`) all call them, so a row //! cannot change accounting when storage repairs it; they differ only on //! an `i64` overflow (live saturates, repair errors). //! +//! Hosts (the Swift and Kotlin SDKs) persist [`WalletAccounting`] keyed by +//! `(txid, wallet id)` and display it; they never derive it themselves. +//! //! Upstream `key-wallet` classifies each matched account on its own, and //! its account-local views disagree for an asset lock: the funding //! account reads a lock with no change as `Outgoing` (the OP_RETURN burn @@ -14,12 +19,38 @@ //! moves Core duffs into its own Platform credits, so only the fee //! leaves the wallet. -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; +use dashcore::{OutPoint, ScriptBuf, Transaction, Txid}; +use key_wallet::managed_account::managed_account_ref::ManagedAccountRef; use key_wallet::managed_account::transaction_record::{ OutputRole, TransactionDirection, TransactionRecord, }; -use key_wallet::transaction_checking::transaction_router::TransactionType; +use key_wallet::transaction_checking::transaction_router::{TransactionRouter, TransactionType}; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + +/// One transaction's Core accounting from one wallet's point of view: the +/// value a host persists keyed by `(txid, wallet id)`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WalletAccounting { + pub txid: Txid, + /// `Σ owned outputs − Σ owned inputs` in duffs, saturated to `i64`. + pub net_amount: i64, + pub direction: TransactionDirection, + /// The transaction fee, when the wallet funded every input. + pub fee: Option, +} + +/// One persisted Core transaction a host hands back for +/// [`history_accounting`]. +#[derive(Debug, Clone)] +pub struct StoredCoreTransaction { + pub transaction: Transaction, + /// The host holds no [`WalletAccounting`] for this transaction and + /// wallet yet. Transactions without the flag still resolve the + /// prevouts of the flagged ones. + pub needs_accounting: bool, +} /// Classify a transaction from the wallet's point of view. /// @@ -30,11 +61,8 @@ use key_wallet::transaction_checking::transaction_router::TransactionType; /// /// An asset lock is `Internal` whenever nothing leaves the wallet, even /// with no change output: its OP_RETURN value becomes the wallet's own -/// Platform credits. The Swift SDK's -/// `PersistentTransaction.reconciledAccounting` applies the same rule; -/// keep them in step. The case table -/// shared with Swift lives in `platform-wallet-storage` -/// (`should_classify_repaired_direction_like_the_swift_sdk`). +/// Platform credits. The case table lives in `platform-wallet-storage` +/// (`should_classify_repaired_direction_like_the_live_projection`). pub fn wallet_direction( transaction_type: TransactionType, spends_ours: bool, @@ -63,12 +91,141 @@ pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { return; } let (net, direction) = wallet_accounting(record); - // Unreachable for real amounts (the whole supply fits i64 many times - // over); saturate rather than panic on a corrupt record. - record.net_amount = i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }); + record.net_amount = saturating_net(net); record.direction = direction; } +/// The wallet-level accounting `record` carries, or `None` when it carries +/// none. +/// +/// A record with no input or output details is no evidence of what the +/// transaction did to the wallet: a keys-account marker, or a record the +/// load path rebuilt from persisted bytes alone (an asset lock's funding +/// transaction, a spender of its input) and re-emits when a chain lock +/// promotes it. Such a record's `net_amount` is a placeholder, so the host +/// keeps whatever accounting it already stored. +pub fn record_accounting(record: &TransactionRecord) -> Option { + if record.input_details.is_empty() && record.output_details.is_empty() { + return None; + } + let (net, direction) = wallet_accounting(record); + Some(WalletAccounting { + txid: record.txid, + net_amount: saturating_net(net), + direction, + fee: record.fee, + }) +} + +/// Accounting for each flagged transaction of a host's stored history, from +/// the wallet's own view of which scripts it owns. +/// +/// Rows a host stored before it kept per-wallet accounting carry no +/// trustworthy amount, and the restored wallet holds no records to re-emit. +/// So the host hands back the raw history and this recomputes what the live +/// projection would have emitted: +/// - an output is owned when its script belongs to one of the wallet's funds +/// accounts other than a contact's watch-only chain +/// ([`AccountType::is_contact_owned`](key_wallet::account::AccountType::is_contact_owned)); +/// - an input is owned when its prevout is an owned output of the supplied +/// history or one of the wallet's restored UTXOs; +/// - type, net and direction come from [`TransactionRouter::classify_transaction`] +/// and [`wallet_accounting`]'s rule. +/// +/// A flagged transaction that touches no owned script yields no row, like a +/// detail-less record in [`record_accounting`]. An owned input whose funding +/// transaction is missing from the history and already spent reads as +/// foreign, exactly as it would to the live projection. +pub fn history_accounting( + wallet: &ManagedWalletInfo, + history: &[StoredCoreTransaction], +) -> Vec { + let mut owned_scripts: BTreeSet = BTreeSet::new(); + let mut prevouts: BTreeMap = BTreeMap::new(); + for account in wallet.accounts.all_accounts() { + let ManagedAccountRef::Funds(funds) = account else { + continue; + }; + if account + .managed_account_type() + .to_account_type() + .is_contact_owned() + { + continue; + } + owned_scripts.extend(account.all_script_pubkeys()); + for (outpoint, utxo) in &funds.utxos { + prevouts.insert(*outpoint, utxo.txout.value); + } + } + for stored in history { + let txid = stored.transaction.txid(); + for (vout, output) in stored.transaction.output.iter().enumerate() { + if owned_scripts.contains(&output.script_pubkey) { + prevouts.insert( + OutPoint { + txid, + vout: vout as u32, + }, + output.value, + ); + } + } + } + + let mut rows = Vec::new(); + for stored in history.iter().filter(|stored| stored.needs_accounting) { + let transaction = &stored.transaction; + let spent: Vec = transaction + .input + .iter() + .filter_map(|input| prevouts.get(&input.previous_output).copied()) + .collect(); + let owned: Vec<(usize, u64)> = transaction + .output + .iter() + .enumerate() + .filter(|(_, output)| owned_scripts.contains(&output.script_pubkey)) + .map(|(index, output)| (index, output.value)) + .collect(); + if spent.is_empty() && owned.is_empty() { + continue; + } + let (net, direction) = accounting( + transaction, + TransactionRouter::classify_transaction(transaction), + &spent, + &owned, + &BTreeSet::new(), + ); + // Only a transaction funded entirely by the wallet has a fee the + // wallet can know. + let fee = (spent.len() == transaction.input.len()) + .then(|| { + let funded = spent.iter().try_fold(0u64, |sum, v| sum.checked_add(*v))?; + let paid = transaction + .output + .iter() + .try_fold(0u64, |sum, o| sum.checked_add(o.value))?; + funded.checked_sub(paid) + }) + .flatten(); + rows.push(WalletAccounting { + txid: transaction.txid(), + net_amount: saturating_net(net), + direction, + fee, + }); + } + rows +} + +/// Unreachable for real amounts (the whole supply fits i64 many times over); +/// saturate rather than panic on a corrupt record. +fn saturating_net(net: i128) -> i64 { + i64::try_from(net).unwrap_or(if net < 0 { i64::MIN } else { i64::MAX }) +} + /// Wallet-level net amount and direction of `record`, from its details. /// /// The net is `Σ owned outputs − Σ owned inputs`: the change in the @@ -77,41 +234,234 @@ pub(crate) fn apply_wallet_accounting(record: &mut TransactionRecord) { /// nets `−(credit + fee)`. The net is returned as `i128` so each caller /// picks its own policy for a value outside `i64`. pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirection) { - let owned: i128 = record + let spent: Vec = record.input_details.iter().map(|d| d.value).collect(); + let owned: Vec<(usize, u64)> = record .output_details .iter() .filter(|d| is_owned(d.role)) - .map(|d| i128::from(d.value)) - .sum(); - let spent: i128 = record - .input_details - .iter() - .map(|d| i128::from(d.value)) - .sum(); - let has_ours = record.output_details.iter().any(|d| is_owned(d.role)); - // Indexed once so the per-output check below stays linear. - let accounted: BTreeSet = record + .map(|d| (d.index as usize, d.value)) + .collect(); + let unspendable: BTreeSet = record .output_details .iter() - .filter(|d| is_owned(d.role) || d.role == OutputRole::Unspendable) + .filter(|d| d.role == OutputRole::Unspendable) .map(|d| d.index as usize) .collect(); - let has_external = record - .transaction + accounting( + &record.transaction, + record.transaction_type, + &spent, + &owned, + &unspendable, + ) +} + +/// The net formula and direction rule over a transaction's owned inputs +/// (`spent` values) and owned outputs (`(index, value)`); `unspendable` +/// outputs leave nothing the wallet could lose, like an OP_RETURN. +fn accounting( + transaction: &Transaction, + transaction_type: TransactionType, + spent: &[u64], + owned: &[(usize, u64)], + unspendable: &BTreeSet, +) -> (i128, TransactionDirection) { + let received: i128 = owned.iter().map(|(_, value)| i128::from(*value)).sum(); + let paid: i128 = spent.iter().map(|value| i128::from(*value)).sum(); + // Indexed once so the per-output check below stays linear. + let accounted: BTreeSet = owned + .iter() + .map(|(index, _)| *index) + .chain(unspendable.iter().copied()) + .collect(); + let has_external = transaction .output .iter() .enumerate() .any(|(index, output)| !output.script_pubkey.is_op_return() && !accounted.contains(&index)); let direction = wallet_direction( - record.transaction_type, - !record.input_details.is_empty(), - has_ours, + transaction_type, + !spent.is_empty(), + !owned.is_empty(), has_external, ); - (owned - spent, direction) + (received - paid, direction) } /// Whether an output with this role belongs to the wallet. pub fn is_owned(role: OutputRole) -> bool { matches!(role, OutputRole::Received | OutputRole::Change) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::changeset::fold_same_txid_records; + use crate::test_support::core_fixtures::{ + block, foreign_address, outside, pay, records_of, spend_of, wallet_with_receive_address, + }; + use key_wallet::account::{Account, AccountType}; + use key_wallet::managed_account::managed_core_funds_account::ManagedCoreFundsAccount; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Wallet; + + const FUNDING: u64 = 100_000; + const PAID: u64 = 30_000; + const CHANGE: u64 = 69_000; + const FEE: u64 = FUNDING - PAID - CHANGE; + + fn stored(transaction: &Transaction, needs_accounting: bool) -> StoredCoreTransaction { + StoredCoreTransaction { + transaction: transaction.clone(), + needs_accounting, + } + } + + /// A payment from outside into the wallet, then a spend of it that pays + /// a foreign address and returns change: what the live projection emits + /// for each, and the history the host stored. + async fn receive_then_spend() -> ( + ManagedWalletInfo, + Vec, + Transaction, + Transaction, + ) { + let (mut wallet, mut info, receive) = wallet_with_receive_address(); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let change = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_change_address(Some(&xpub), true) + .unwrap(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + let spend = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(PAID, &foreign_address(9)), pay(CHANGE, &change)], + ); + let mut live = Vec::new(); + for (tx, height) in [(&funding, 100), (&spend, 101)] { + let mut records = records_of(&mut info, &mut wallet, tx, block(height)).await; + fold_same_txid_records(&mut records); + live.extend(records.iter().filter_map(record_accounting)); + } + (info, live, funding, spend) + } + + #[tokio::test] + async fn should_backfill_stored_history_with_the_live_projections_accounting() { + let (info, live, funding, spend) = receive_then_spend().await; + assert_eq!( + live.iter().map(|row| row.net_amount).collect::>(), + vec![FUNDING as i64, CHANGE as i64 - FUNDING as i64], + "live projection: receive, then a spend netting −(paid + fee)" + ); + + let backfilled = history_accounting(&info, &[stored(&funding, true), stored(&spend, true)]); + + assert_eq!(backfilled.len(), 2); + for (row, expected) in backfilled.iter().zip(&live) { + assert_eq!(row.txid, expected.txid); + assert_eq!(row.net_amount, expected.net_amount); + assert_eq!(row.direction, expected.direction); + } + assert_eq!(backfilled[1].direction, TransactionDirection::Outgoing); + assert_eq!( + backfilled[1].fee, + Some(FEE), + "the wallet funded every input" + ); + assert_eq!(backfilled[0].fee, None, "a foreign input hides the fee"); + } + + /// The other stored transactions resolve a flagged spend's inputs, but + /// only flagged ones get a row. + #[tokio::test] + async fn should_resolve_inputs_from_unflagged_history_and_answer_only_flagged_rows() { + let (info, live, funding, spend) = receive_then_spend().await; + + let backfilled = + history_accounting(&info, &[stored(&funding, false), stored(&spend, true)]); + + assert_eq!(backfilled.len(), 1); + assert_eq!(backfilled[0].txid, spend.txid()); + assert_eq!(backfilled[0].net_amount, live[1].net_amount); + } + + #[tokio::test] + async fn should_yield_no_row_for_a_transaction_touching_no_owned_script() { + let (info, _, _, _) = receive_then_spend().await; + let unrelated = spend_of(&[outside(3)], vec![pay(5_000, &foreign_address(4))]); + + assert!(history_accounting(&info, &[stored(&unrelated, true)]).is_empty()); + } + + /// An output on a contact's watch-only chain is the contact's money, + /// not the wallet's: paying it is outgoing, the same rule the live + /// projection applies by dropping the contact account's slice. + #[tokio::test] + async fn should_not_count_a_contacts_watch_only_output_as_owned() { + let (_, mut info, receive) = wallet_with_receive_address(); + let contact_wallet = Wallet::new_random( + dashcore::Network::Testnet, + WalletAccountCreationOptions::Default, + ) + .unwrap(); + let contact_xpub = contact_wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let account = Account::new( + None, + AccountType::DashpayExternalAccount { + index: 0, + user_identity_id: [1; 32], + friend_identity_id: [2; 32], + }, + contact_xpub, + dashcore::Network::Testnet, + ) + .unwrap(); + let contact_account = ManagedCoreFundsAccount::from_account(&account); + let contact_address = ManagedAccountRef::Funds(&contact_account) + .all_addresses() + .into_iter() + .next() + .expect("the contact chain derives addresses"); + info.accounts + .insert_funds_bearing_account(contact_account) + .unwrap(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + let to_contact = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(FUNDING - FEE, &contact_address)], + ); + + let rows = history_accounting(&info, &[stored(&funding, false), stored(&to_contact, true)]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].net_amount, -(FUNDING as i64)); + assert_eq!(rows[0].direction, TransactionDirection::Outgoing); + } + + /// A record rebuilt at load from bytes alone (an asset lock's funding + /// transaction) is re-emitted on chain-lock promotion with a placeholder + /// net of 0: it must reach the host as "no accounting", not as 0. + #[test] + fn should_report_no_accounting_for_a_record_without_details() { + let tx = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let record = TransactionRecord::new( + tx, + AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }, + block(10), + TransactionType::AssetLock, + TransactionDirection::Internal, + Vec::new(), + Vec::new(), + 0, + ); + + assert_eq!(record_accounting(&record), None); + } +} diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1b612536087..e32c25762fa 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; use std::sync::Arc; -use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; +use crate::changeset::{ + history_accounting, ClientStartState, ClientWalletStartState, PlatformWalletPersistence, +}; use crate::error::PlatformWalletError; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; @@ -656,6 +658,12 @@ impl PlatformWalletManager

{ return Err(err); } + // Past the rollback point. Accounting is display-only, so a failure + // here is logged and never fails the load. + for (wallet_id, _) in &inserted_in_wallets { + self.backfill_history_accounting(*wallet_id).await; + } + // Past the rollback point: every registration here is one this load // actually committed, so the transactions now have a wallet to belong // to for as long as it stays registered. @@ -735,6 +743,53 @@ impl PlatformWalletManager

{ Ok(()) } + + /// Hand the persister the per-wallet accounting of every stored + /// transaction it flags as lacking one (see + /// [`PlatformWalletPersistence::load_core_history_for_accounting`]). + async fn backfill_history_accounting(&self, wallet_id: WalletId) { + let persister = Arc::clone(&self.persister); + let history = + match run_blocking_load(move || persister.load_core_history_for_accounting(wallet_id)) + .await + { + Ok(history) => history, + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = ?e, + "load: reading stored history for the accounting backfill failed" + ); + return; + } + }; + if !history.iter().any(|stored| stored.needs_accounting) { + return; + } + let rows = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&wallet_id) else { + return; + }; + history_accounting(&info.core_wallet, &history) + }; + if rows.is_empty() { + return; + } + let persister = Arc::clone(&self.persister); + let count = rows.len(); + if let Err(e) = + run_blocking_load(move || persister.store_transaction_accounting(wallet_id, &rows)) + .await + { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + rows = count, + error = ?e, + "load: persisting backfilled transaction accounting failed" + ); + } + } } /// What one re-dispatch attempt did, so a caller — and a test — can tell the @@ -1312,6 +1367,113 @@ mod idempotent_load_tests { ); } + /// Hands back a stored history for the accounting backfill and records + /// what the manager persists from it. + struct HistoryPersister { + wallet: Wallet, + managed: ManagedWalletInfo, + history: Vec, + stored: std::sync::Mutex)>>, + } + + impl PlatformWalletPersistence for HistoryPersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + SingleWalletPersister { + wallet: self.wallet.clone(), + managed: self.managed.clone(), + } + .load() + } + + fn load_core_history_for_accounting( + &self, + _wallet_id: WalletId, + ) -> Result, PersistenceError> { + Ok(self.history.clone()) + } + + fn store_transaction_accounting( + &self, + wallet_id: WalletId, + rows: &[crate::changeset::WalletAccounting], + ) -> Result<(), PersistenceError> { + self.stored.lock().unwrap().push((wallet_id, rows.to_vec())); + Ok(()) + } + } + + async fn load_with_history( + flagged: bool, + ) -> ( + WalletId, + dashcore::Transaction, + Vec<(WalletId, Vec)>, + ) { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let persister = Arc::new(HistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history: vec![crate::changeset::StoredCoreTransaction { + transaction: funding.clone(), + needs_accounting: flagged, + }], + stored: std::sync::Mutex::new(Vec::new()), + }); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + let manager = PlatformWalletManager::new(sdk, Arc::clone(&persister), event_handler); + manager + .load_from_persistor() + .await + .expect("the wallet must load"); + let stored = persister.stored.lock().unwrap().clone(); + (wallet_id, funding, stored) + } + + /// Rows the host stored before it kept per-wallet accounting are + /// backfilled at load from the restored wallet's own view of its scripts. + #[tokio::test] + async fn load_backfills_accounting_for_stored_history_the_host_flags() { + let (wallet_id, funding, stored) = load_with_history(true).await; + + assert_eq!(stored.len(), 1, "one backfill write for the wallet"); + let (written_for, rows) = &stored[0]; + assert_eq!(written_for, &wallet_id); + assert_eq!( + rows.as_slice(), + &[crate::changeset::WalletAccounting { + txid: funding.txid(), + net_amount: 100_000, + direction: + key_wallet::managed_account::transaction_record::TransactionDirection::Incoming, + fee: None, + }] + ); + } + + /// A host whose history is all accounted writes nothing. + #[tokio::test] + async fn load_skips_the_backfill_when_nothing_is_flagged() { + let (_, _, stored) = load_with_history(false).await; + + assert!(stored.is_empty()); + } + fn make_pending_manager( persister: PendingSendPersister, ) -> Arc> { From c081e8711333c6e570523c838f62f13e9d5f8783 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:45:33 +0000 Subject: [PATCH 40/84] feat(swift-sdk): persist Rust-emitted per-wallet transaction accounting SwiftData now stores the accounting rs-platform-wallet emits, one `PersistentTransactionAccounting` row per (walletId, txid), and shows it. It no longer recomputes or overwrites it. - Live schema V5 (unreleased) carries the new entity in place of the `netAmountUnavailable` marker; V4 -> V5 stays a lightweight migration. - The handler wires the three new extension slots: rows from each round stage with it, and the load-time backfill commits on its own. History is offered to Rust only when some transaction lacks this wallet's row. - `netAmount(for:)`, `direction(for:)` and `fee(for:)` are lookups that fall back to the shared last-writer scalars when Rust has emitted no row. - `upsertTransaction` writes the record's shared values as delivered. The asset-lock "recovery record" veto is gone; Rust now says "no accounting" by sending no row. - The load, round-end and TXO-heal reconcile passes and `accountingDirty` are removed. Deleting a wallet deletes its accounting rows. - `CoreDirectionCode` binds the exported `PLATFORM_WALLET_TRANSACTION_DIRECTION_*` constants instead of re-declaring the values. - Example app: the account-detail transaction rows and sheet are now scoped to their wallet, and fees come from the wallet's row. Not built here: no Swift toolchain on this host. Co-Authored-By: Claude Opus 5.5 --- packages/swift-sdk/SCHEMA_RELEASES.md | 13 +- .../Persistence/DashModelContainer.swift | 3 +- .../Models/PersistentTransaction.swift | 165 ++------- .../PersistentTransactionAccounting.swift | 48 +++ .../PlatformWallet/CoreDirectionCode.swift | 11 + .../PlatformWalletPersistenceHandler.swift | 346 +++++++++--------- .../Core/Views/AccountDetailView.swift | 4 +- .../Core/Views/TransactionDetailView.swift | 9 +- .../Core/Views/TransactionListView.swift | 7 +- packages/swift-sdk/schema-models.json | 3 +- 10 files changed, 293 insertions(+), 316 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 0714ae16766..ee711319892 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -24,12 +24,13 @@ schema. The former live V2 is accepted only when its complete graph matches frozen V3 exactly, entity hashes and checksum alike. The alias remains supported when the live graph changes; other stores labelled `2.0.0` fail closed. -V5 adds the optional `PersistentTransaction.netAmountUnavailable` marker through -a lightweight migration. Existing rows receive `nil`, preserving their stored -accounting. When deleting one participant of a shared transaction, the remaining -wallet's amount and direction are saved before the ownership links disappear. -If its amount was unresolved, the marker keeps it unavailable across restart; -an authoritative wallet record or complete accounting reconciliation clears it. +V5 adds the `PersistentTransactionAccounting` entity through a lightweight +migration: one row per `(walletId, txid)` holding the net amount, direction and +fee `rs-platform-wallet` emitted for that wallet. Migrated stores start with no +rows; at the first load Rust reads the stored history back +(`on_load_core_history_for_accounting_fn`), computes the accounting of every +transaction lacking a row and hands it to `on_persist_transaction_accounting_fn`. +Deleting a wallet deletes its rows; deleting a transaction cascades to them. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index c45fadd05b8..d21ed19427f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -94,7 +94,8 @@ public enum DashModelContainer { PersistentInvitation.self, PersistentMasternode.self, PersistentTrackedMasternode.self, - PersistentIdentityBalanceMetadata.self + PersistentIdentityBalanceMetadata.self, + PersistentTransactionAccounting.self ] } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index cc84271d68f..bfd3811ba9c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -84,12 +84,12 @@ public final class PersistentTransaction { /// replaces it with the real discriminant on touch. Accessors /// treat the sentinel as unknown (no branch fires). public var transactionTypeKind: UInt8 = 0xFF - /// Net Core amount in duffs across locally owned TXOs (positive=received, negative=sent). + /// Net amount in duffs (signed: positive=received, negative=sent) as the + /// last recording wallet's `TransactionRecordFFI` carried it. Shared by + /// every wallet in the transaction; per-wallet values live in + /// [`walletAccounting`]; `netAmount(for:)` prefers the wallet's row. public var netAmount: Int64 - /// Set when removing a wallet left no authoritative amount for the survivor. - /// `nil` preserves the accounting of rows migrated from V3. - public var netAmountUnavailable: Bool? = nil - /// Fee in duffs (nil if unknown). + /// Fee in duffs (nil if unknown); same last-writer caveat as `netAmount`. public var fee: UInt64? /// User-assigned label. public var label: String @@ -183,6 +183,11 @@ public final class PersistentTransaction { @Relationship(inverse: \PersistentAccount.involvedTransactions) public var involvedAccounts: [PersistentAccount] = [] + /// Rust-emitted accounting per wallet, one row per `(wallet, txid)`. + /// Cascade-deletes with the transaction. + @Relationship(deleteRule: .cascade, inverse: \PersistentTransactionAccounting.transaction) + public var walletAccounting: [PersistentTransactionAccounting] = [] + public init( txid: Data, transactionData: Data, @@ -231,93 +236,36 @@ public final class PersistentTransaction { } } - /// Core value movement for one wallet; `nil` when it cannot be derived yet. - /// - /// The stored scalar is the last recording wallet's (Rust `net_amount`, - /// or the reconciliation over its own TXOs), so it answers only when - /// `walletId` is the sole participant. Pending inputs do not veto that - /// answer: `upsertTransaction` writes one for every input whose prevout - /// has no local TXO — every foreign input of an incoming payment — and - /// never prunes them, so they cannot tell a late input of ours apart. - public func netAmount(for walletId: Data) -> Int64? { - func owned(_ rows: [PersistentTxo]) -> [PersistentTxo] { - var seen = Set() - return rows.filter { - PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) - && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId - && seen.insert($0.outpoint).inserted - } - } - let unownedTxos = (inputs + outputs).filter { !PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) } - let isSoleParticipant = participatingWalletIds == [walletId] - if isSoleParticipant, unownedTxos.isEmpty { - return netAmountUnavailable == true ? nil : netAmount - } - let walletInputs = owned(inputs) - let walletOutputs = owned(outputs) - guard !walletInputs.isEmpty || !walletOutputs.isEmpty else { return nil } - let computed = Self.reconciledAccounting( - inputs: walletInputs, ownedOutputAmounts: walletOutputs.map(\.amount), - allOutputsOwned: false, previousDirection: direction, isAssetLock: isAssetLock - )?.netAmount - // Computed from TXOs alone: a pending input this wallet recorded may be - // one of its own still-unlinked coins, so the sum is only provisional. - // It stands when only a contact's watch-only TXOs kept the sole - // participant off its stored amount and that amount agrees with it. - // TODO(wallet-scoped-accounting-from-rust): a foreign payment to two - // local wallets leaves an unresolvable pending input per wallet, so both - // show "Amount unavailable". Store Rust's per-wallet net amount instead - // (tracked in #5226). - if pendingInputs.contains(where: { $0.walletId == walletId }) { - guard isSoleParticipant, netAmountUnavailable != true, computed == netAmount, - unownedTxos.allSatisfy(PlatformWalletPersistenceHandler.isContactTxo) else { return nil } - } - return computed + /// The accounting Rust emitted for `walletId`, if any. + public func accounting(for walletId: Data) -> PersistentTransactionAccounting? { + walletAccounting.first { $0.walletId == walletId } } - /// Direction relative to one wallet for transactions shared by multiple local wallets. + /// Core value movement for one wallet: its Rust-emitted row, else the + /// shared stored amount (a transaction Rust has no accounting for). + public func netAmount(for walletId: Data) -> Int64 { + accounting(for: walletId)?.netAmount ?? netAmount + } + + /// `CoreDirectionCode` for one wallet, with the same fallback as `netAmount(for:)`. public func direction(for walletId: Data) -> UInt32 { - guard participatingWalletIds.count > 1, direction != CoreDirectionCode.coinJoin, - typedKind != .coinJoin else { return direction } - let spendsOurs = inputs.contains { - PlatformWalletPersistenceHandler.isWalletOwnedTxo($0) - && PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId - } - if isAssetLock { - let ownedOutputs = outputs.filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - if spendsOurs { - return ownedOutputs.contains { - PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) != walletId - } ? CoreDirectionCode.outgoing : direction - } - return ownedOutputs.contains { - PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) == walletId - } ? CoreDirectionCode.incoming : direction - } - return spendsOurs ? CoreDirectionCode.outgoing : CoreDirectionCode.incoming + accounting(for: walletId)?.direction ?? direction } - /// Retain the sole surviving wallet's accounting before ownership links disappear. - func preserveAccounting(removingWallet walletId: Data) { - let participants = participatingWalletIds - guard participants.count == 2, participants.contains(walletId), - let survivor = participants.first(where: { $0 != walletId }) else { return } - let amount = netAmount(for: survivor) - let survivorDirection = direction(for: survivor) - if let amount { netAmount = amount } - netAmountUnavailable = amount == nil - direction = survivorDirection + /// Fee for one wallet, with the same fallback as `netAmount(for:)`. + public func fee(for walletId: Data) -> UInt64? { + guard let row = accounting(for: walletId) else { return fee } + return row.fee } /// Format the wallet's Core value movement in DASH. public func formattedAmount(for walletId: Data) -> String { - guard let amount = netAmount(for: walletId) else { return "Amount unavailable" } - return Self.format(duffs: amount) + Self.format(duffs: netAmount(for: walletId)) } /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. - public func displayNetAmount(for walletId: Data?) -> Int64? { - walletId.map { netAmount(for: $0) } ?? (netAmountUnavailable == true ? nil : netAmount) + public func displayNetAmount(for walletId: Data?) -> Int64 { + walletId.map { netAmount(for: $0) } ?? netAmount } /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. @@ -325,6 +273,11 @@ public final class PersistentTransaction { walletId.map { direction(for: $0) } ?? direction } + /// Fee for `walletId`, or the stored fee when no wallet scope is given. + public func displayFee(for walletId: Data?) -> UInt64? { + walletId.map { fee(for: $0) } ?? fee + } + /// Formatted amount for `walletId`, or the stored scalar's when no wallet scope is given. public func displayFormattedAmount(for walletId: Data?) -> String { walletId.map { formattedAmount(for: $0) } ?? formattedAmount @@ -335,47 +288,6 @@ public final class PersistentTransaction { String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) } - /// Local wallets owning one of this transaction's TXOs or having recorded it - /// (`involvedAccounts`), whose last writer's accounting is the stored scalar. - private var participatingWalletIds: Set { - let owning = (inputs + outputs).filter(PlatformWalletPersistenceHandler.isWalletOwnedTxo) - .compactMap { PlatformWalletPersistenceHandler.resolvedWalletId(of: $0) } - let recording = involvedAccounts.compactMap { account -> Data? in - let wallet: PersistentWallet? = account.wallet - return wallet?.walletId - } - return Set(owning + recording) - } - - static func reconciledAccounting( - inputs: [PersistentTxo], ownedOutputAmounts: [UInt64], - allOutputsOwned: Bool, previousDirection: UInt32, isAssetLock: Bool - ) -> (netAmount: Int64, direction: UInt32)? { - func total(_ amounts: [UInt64]) -> Int64? { - var sum: Int64 = 0 - for amount in amounts { - guard let value = Int64(exactly: amount) else { return nil } - let addition = sum.addingReportingOverflow(value) - guard !addition.overflow else { return nil } - sum = addition.partialValue - } - return sum - } - guard let received = total(ownedOutputAmounts), let spent = total(inputs.map(\.amount)) else { - return nil - } - // Same rule as Rust (`platform_wallet::changeset::wallet_direction`): - // internal only when nothing leaves the wallet and something stays in - // it, or an asset lock burns into Platform. Both sides test one table. - let direction: UInt32 - if previousDirection == CoreDirectionCode.coinJoin { direction = CoreDirectionCode.coinJoin } - else if inputs.isEmpty { direction = CoreDirectionCode.incoming } - else if allOutputsOwned && (!ownedOutputAmounts.isEmpty || isAssetLock) { - direction = CoreDirectionCode.internalTransfer - } else { direction = CoreDirectionCode.outgoing } - return (received - spent, direction) - } - public var directionName: String { switch direction { case CoreDirectionCode.incoming: return "Incoming" @@ -485,19 +397,10 @@ public final class PersistentTransaction { } public var formattedAmount: String { - netAmountUnavailable == true ? "Amount unavailable" : Self.format(duffs: netAmount) + Self.format(duffs: netAmount) } } -/// Wire values of `PersistentTransaction.direction`, matching `directionName` -/// and the FFI's `TransactionDirection` discriminants. -public enum CoreDirectionCode { - public static let incoming: UInt32 = 0 - public static let outgoing: UInt32 = 1 - public static let internalTransfer: UInt32 = 2 - public static let coinJoin: UInt32 = 3 -} - /// Typed mirror of Rust's /// `key_wallet::transaction_checking::transaction_router::TransactionType`, /// pinned to the `u8` discriminants emitted by diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift new file mode 100644 index 00000000000..e2da437cbef --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift @@ -0,0 +1,48 @@ +import Foundation +import SwiftData + +/// One transaction's Core accounting for ONE wallet, exactly as +/// `rs-platform-wallet` emitted it (`TransactionAccountingFFI`, through +/// `on_persist_transaction_accounting_fn`). +/// +/// A transaction shared by several local wallets has one row per wallet, +/// so each wallet shows its own side of it. A wallet with no row has no +/// accounting from Rust yet (a payload-only provider transaction, or a +/// stored row the load-time backfill has not reached). Pure storage: +/// nothing on the Swift side derives, corrects or vetoes these values. +@Model +public final class PersistentTransactionAccounting { + #Unique([\.walletId, \.txid]) + #Index([\.walletId], [\.txid]) + + /// The wallet this accounting belongs to (32 bytes). + public var walletId: Data + /// Transaction id, raw wire bytes like `PersistentTransaction.txid`. + public var txid: Data + /// Net Core amount in duffs for this wallet (positive = received). + public var netAmount: Int64 + /// `CoreDirectionCode` wire value for this wallet. + public var direction: UInt32 + /// Fee in duffs; `nil` unless this wallet funded every input. + public var fee: UInt64? + /// Inverse of `PersistentTransaction.walletAccounting`, which cascades. + public var transaction: PersistentTransaction? + public var lastUpdated: Date + + public init( + walletId: Data, + txid: Data, + netAmount: Int64, + direction: UInt32, + fee: UInt64?, + transaction: PersistentTransaction? + ) { + self.walletId = walletId + self.txid = txid + self.netAmount = netAmount + self.direction = direction + self.fee = fee + self.transaction = transaction + self.lastUpdated = Date() + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift new file mode 100644 index 00000000000..27f3c9abf4f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/CoreDirectionCode.swift @@ -0,0 +1,11 @@ +import DashSDKFFI + +/// Swift names for the `direction` wire values of `PersistentTransaction` +/// and `PersistentTransactionAccounting`, bound to the constants the Rust +/// FFI exports (`PLATFORM_WALLET_TRANSACTION_DIRECTION_*`), never re-declared. +public enum CoreDirectionCode { + public static let incoming = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING) + public static let outgoing = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING) + public static let internalTransfer = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL) + public static let coinJoin = UInt32(PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN) +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 16bfee78e95..c0606d0955a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -271,7 +271,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { var coreAddressesByAddress: [String: PersistentCoreAddress] = [:] } private var roundIndex: ChangesetRoundIndex? - private var accountingDirty: [Data: PersistentTransaction] = [:] /// Number of persistence rounds committed by this handler, read and /// compared on `serialQueue`. The store reconcile classifies rows off /// this queue and applies the verdicts on it; a round committed in @@ -437,8 +436,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { error: error ) backgroundContext.rollback() - // Rows staged by the failed save are gone; never reconcile them. - accountingDirty.removeAll() } } @@ -2655,11 +2652,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.hasBlockPosition = tx.has_block_position let blockHashBytes = hashData(tx.block_hash) record.blockHash = blockHashBytes.allSatisfy { $0 == 0 } ? nil : blockHashBytes - // Empty asset-lock recovery records cannot replace a funded debit or an unavailable amount. - let preserveLockAccounting = tx.transaction_type_kind == TransactionTypeKind.assetLock.rawValue - && tx.net_amount == 0 && !tx.has_fee - && (record.netAmount < 0 || record.netAmountUnavailable == true) - if !preserveLockAccounting { record.direction = tx.direction } + record.direction = tx.direction if let typeName = tx.transaction_type { record.transactionType = String(cString: typeName) } @@ -2681,12 +2674,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { record.providerVotingKeyHash = tx.has_provider_voting_key_hash ? withUnsafeBytes(of: tx.provider_voting_key_hash) { Data($0) } : nil - if !preserveLockAccounting { - record.netAmount = tx.net_amount - record.netAmountUnavailable = false - record.fee = tx.has_fee ? tx.fee : nil - } - accountingDirty[record.txid] = record + // The shared last-writer scalars, kept for unscoped display. The + // per-wallet values arrive through `persistTransactionAccounting`. + record.netAmount = tx.net_amount + record.fee = tx.has_fee ? tx.fee : nil if let labelPtr = tx.label { record.label = String(cString: labelPtr) } @@ -3030,8 +3021,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// (`reconcileHealMissingTxos`), so both writers honour the same /// tombstone precedence and spender-adoption rules. private func drainPendingInputs(into record: PersistentTxo, resolvedWalletId: Data) { - if let parent = record.transaction { accountingDirty[parent.txid] = parent } - if let spender = record.spendingTransaction { accountingDirty[spender.txid] = spender } let pendingRows = pendingInputRows(outpoint: record.outpoint) if !pendingRows.isEmpty { // A tombstone is not an observation — it is a sweep's settled @@ -3164,9 +3153,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { spender: PersistentTransaction, inputIndex: UInt32? ) { - accountingDirty[spender.txid] = spender let currentSpender = txo.spendingTransaction - if let currentSpender { accountingDirty[currentSpender.txid] = currentSpender } let verdict = Self.reconcileSpendObservation( currentSpenderTxid: currentSpender?.txid, currentSpenderContext: currentSpender?.context, @@ -3329,6 +3316,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback + // Per-wallet transaction accounting exactly as Rust computes it: + // after each round's changeset, and once at load for stored rows + // that have none yet (Rust reads the history back, computes, and + // hands the rows to the persist slot). + extensionCallbacks.on_persist_transaction_accounting_fn = persistTransactionAccountingCallback + extensionCallbacks.on_load_core_history_for_accounting_fn = loadCoreHistoryForAccountingCallback + extensionCallbacks.on_load_core_history_for_accounting_free_fn = + loadCoreHistoryForAccountingFreeCallback return extensionCallbacks } @@ -3412,11 +3407,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.inChangeset = true self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() - // Out-of-round writers stage entries too (deferred backfills, a - // heal step that stopped before reconciling its own); they are - // not this round's to reconcile and may point at rows a failed - // save rolled back. Load-time accounting repairs whatever is left. - self.accountingDirty.removeAll() SDKLogger.event( "persistence_changeset_started", category: .persistence, @@ -3503,7 +3493,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.roundUtxoCreditVerdicts = [:] self.roundUtxoCreditTally = UtxoCreditVerdictTally() self.roundIndex = nil - self.accountingDirty.removeAll() self.roundAdvancedFinalityBoundary = false self.inChangeset = false self.drainDeferredBackfills() @@ -3546,20 +3535,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if roundAdvancedFinalityBoundary { collectFinalizedSweptTombstones(walletId: walletId) } - // Display-only accounting: a failure here must not fail the - // round, and is reported under its own event, not save_failed. - // Recomputed values that did land are consistent on their own. - // TODO(test-round-accounting-failure): cover this catch with a - // FetchFaultInjector test; pinning which read faults needs a Swift run. - do { - try reconcileTransactionAccounting(Array(accountingDirty.values)) - } catch { - SDKLogger.event( - "persistence_transaction_accounting_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("round"), "wallet_reference": .reference(walletId)], - error: error - ) - } do { try backgroundContext.save() committedRoundGeneration &+= 1 @@ -6708,9 +6683,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } if let walletRow = walletRow { - // Shared scalars must be scoped while the departing wallet's TXOs still exist. - for transaction in try backgroundContext.fetch(FetchDescriptor()) { - transaction.preserveAccounting(removingWallet: walletId) + // The departing wallet's accounting rows go with it; a + // shared transaction keeps the other wallets' rows. + let accountingRows = FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId }) + for row in try backgroundContext.fetch(accountingRows) { + backgroundContext.delete(row) } // Wallet → identities is `.nullify`; this delete // path cascades them explicitly. @@ -7133,77 +7111,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return txids } - /// Repair only fully resolved spends; missing prevouts are not evidence of external ownership. - func reconcileTransactionAccounting( - _ transactions: [PersistentTransaction], txos: [Data: PersistentTxo]? = nil, - addresses: [String: PersistentCoreAddress]? = nil - ) throws { - for transaction in transactions where !transaction.isDeleted { - guard let transactionNetwork = network - ?? Self.walletNetwork(of: transaction.involvedAccounts.first) - ?? Self.walletNetwork(of: transaction.inputs.first?.account) - ?? Self.walletNetwork(of: transaction.outputs.first?.account), - let decoded = try? TransactionDecoder.decode( - transaction.transactionData, network: transactionNetwork - ), !decoded.inputs.isEmpty else { continue } - var inputs: [PersistentTxo] = [] - var complete = true - for input in decoded.inputs { - let key = PersistentTxo.makeOutpoint(txid: input.prevTxid, vout: input.prevVout) - let row: PersistentTxo? - if let txos { row = txos[key] } - else { row = try fetchTxoRowChecked(outpoint: key) } - guard let row, !row.isDeleted, Self.isWalletOwnedTxo(row) else { - complete = false - break - } - inputs.append(row) - } - guard complete else { continue } - var amounts: [UInt64] = [] - var allOutputsOwned = true - // An output counts only for the spending wallets — a linked TXO by - // its wallet, an address-matched one by its address's: another local - // wallet's credit must not hide inside the sender's scalar. - let spendingWallets = Set(inputs.compactMap { Self.resolvedWalletId(of: $0) }) - let ownedVouts = Set(transaction.outputs.filter { output -> Bool in - guard !output.isDeleted, Self.isWalletOwnedTxo(output), - let owner = Self.resolvedWalletId(of: output) else { return false } - return spendingWallets.contains(owner) - }.map(\.vout)) - for (index, output) in decoded.outputs.enumerated() { - // OP_RETURN burns (including asset locks) are not spendable Core outputs. - if output.isOpReturn { continue } - var belongs = ownedVouts.contains(UInt32(index)) - if !belongs, let address = output.address { - let descriptor = FetchDescriptor(predicate: #Predicate { $0.address == address }) - let owner: PersistentCoreAddress? - if let cached = roundIndex?.coreAddressesByAddress[address] { owner = cached } - else if let addresses { owner = addresses[address] } - else { owner = try modelFetcher.fetch(descriptor, in: backgroundContext).first } - if let account = owner?.account, account.accountType != Self.dashpayExternalAccountTypeTag { - let ownerWallet: PersistentWallet? = account.wallet - if let ownerWallet, spendingWallets.contains(ownerWallet.walletId) { - belongs = true - } - } - } - if belongs { amounts.append(output.valueDuffs) } - else { allOutputsOwned = false } - } - if let accounting = PersistentTransaction.reconciledAccounting( - inputs: inputs, ownedOutputAmounts: amounts, allOutputsOwned: allOutputsOwned, - previousDirection: transaction.typedKind == .coinJoin - ? CoreDirectionCode.coinJoin : transaction.direction, - isAssetLock: transaction.isAssetLock - ) { - transaction.netAmount = accounting.netAmount - transaction.netAmountUnavailable = false - transaction.direction = accounting.direction - } - } - } - /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { SDKLogger.event( @@ -7249,42 +7156,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } - // Mid-round the context holds another round's staged writes: saving - // would commit half of it and rolling back would silently drop it. - // That round reconciles its own dirty rows; the next load repairs the rest. - // TODO(persist-accounting-backfill-marker): this pass re-reads all - // history on every launch; a persisted completion marker needs a - // SwiftData shape change (a new live schema version) or a side - // channel, which is a product decision. - if !inChangeset { - do { - let walletIds = Set(wallets.map(\.walletId)) - let transactions = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - .filter { row in - walletIds.contains { Self.walletOwnsTransaction(walletId: $0, transaction: row) } - } - let txos = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - // One read instead of one per unlinked output. - let addresses = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - try reconcileTransactionAccounting( - transactions, - // Never trap on a duplicate outpoint; a live row wins over a deleted one. - txos: Dictionary(txos.map { ($0.outpoint, $0) }, uniquingKeysWith: { kept, other in - kept.isDeleted ? other : kept - }), - addresses: Dictionary(addresses.map { ($0.address, $0) }, uniquingKeysWith: { first, _ in first }) - ) - try backgroundContext.save() - } catch { - // Display-only accounting must never block restoring wallets: - // drop this pass's edits and restore on the stored values. - backgroundContext.rollback() - SDKLogger.event( - "persistence_transaction_accounting_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("load")], error: error - ) - } - } let restorable = wallets.filter { wallet in wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } @@ -9219,6 +9090,108 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Outstanding tracked-masternode load allocations keyed by the /// entries pointer we handed to Rust. + /// Outstanding stored-history loans handed to Rust for the accounting + /// backfill, keyed by the rows pointer. + private var coreHistoryLoadAllocations: [UnsafeRawPointer: CoreHistoryLoadAllocation] = [:] + + /// Upsert one wallet's Rust-emitted accounting rows, keyed by + /// `(walletId, txid)`. Inside a round they stage with it and commit at + /// `endChangeset`; outside one (the load-time backfill) they commit here. + /// Returns `false` to fail the round, like the other per-kind callbacks. + func persistTransactionAccounting( + walletId: Data, + rows: UnsafePointer?, + count: UInt + ) -> Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "transaction_accounting") { + case .failed: return false + case .absent: return true + case .found: break + } + guard count > 0, let rows else { return true } + for i in 0.. (rows: UnsafePointer?, count: Int, errored: Bool) { + onQueue { + let history: [PersistentTransaction] + do { + history = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) + .filter { Self.walletOwnsTransaction(walletId: walletId, transaction: $0) } + } catch { + SDKLogger.event( + "persistence_core_history_load_failed", category: .persistence, severity: .error, + fields: ["wallet_reference": .reference(walletId)], error: error + ) + return (nil, 0, true) + } + let flags = history.map { $0.accounting(for: walletId) == nil } + guard flags.contains(true) else { return (nil, 0, false) } + let allocation = CoreHistoryLoadAllocation() + let buf = UnsafeMutablePointer.allocate(capacity: history.count) + allocation.entries = buf + for (index, transaction) in history.enumerated() { + let body = transaction.transactionData + let bytes = UnsafeMutablePointer.allocate(capacity: max(body.count, 1)) + body.copyBytes(to: bytes, count: body.count) + allocation.buffers.append(bytes) + buf[index] = CoreHistoryTransactionFFI( + tx_bytes: UnsafePointer(bytes), + tx_bytes_len: UInt(body.count), + needs_accounting: flags[index] + ) + } + coreHistoryLoadAllocations[UnsafeRawPointer(buf)] = allocation + return (UnsafePointer(buf), history.count, false) + } + } + + /// Release a loan handed out by `loadCoreHistoryForAccounting`. + func loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer?) { + onQueue { + guard let rows, + let allocation = coreHistoryLoadAllocations.removeValue(forKey: rows) else { return } + allocation.release() + } + } + private var trackedMasternodeLoadAllocations: [UnsafeRawPointer: TrackedMasternodeLoadAllocation] = [:] @@ -9461,6 +9434,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Owned allocations behind one `loadTrackedMasternodes` answer: the /// entries buffer plus every strdup'd label / snapshot string. Trivial C /// structs — deallocate only. +private final class CoreHistoryLoadAllocation { + var entries: UnsafeMutablePointer? + var buffers: [UnsafeMutablePointer] = [] + + func release() { + for buffer in buffers { + buffer.deallocate() + } + buffers.removeAll() + entries?.deallocate() + entries = nil + } +} + private final class TrackedMasternodeLoadAllocation { var entries: UnsafeMutablePointer? var count: Int = 0 @@ -11705,9 +11692,6 @@ extension PlatformWalletPersistenceHandler { ) -> CoreTxoReconcileStep { onQueue { guard !inChangeset else { return .retryLater } - // This step reconciles only what it stages: entries left by an - // earlier out-of-round writer may point at rolled-back rows. - accountingDirty.removeAll() var counts = CoreTxoHealCounts() for row in rows { guard row.txid.count == 32, !row.scriptPubKey.isEmpty, !row.address.isEmpty else { @@ -11807,21 +11791,6 @@ extension PlatformWalletPersistenceHandler { ] ) } - // A healed prevout can complete a spend, so the accounting it - // resolves commits in the same save. Display-only: a failure is - // reported under its own event and the heal still lands. - // TODO(test-heal-accounting-failure): cover this catch with a - // FetchFaultInjector test; pinning which read faults needs a Swift run. - do { - try reconcileTransactionAccounting(Array(accountingDirty.values)) - } catch { - SDKLogger.event( - "persistence_transaction_accounting_failed", category: .persistence, severity: .error, - fields: ["phase": .publicText("txo_reconcile_heal"), "wallet_reference": .reference(walletId)], - error: error - ) - } - accountingDirty.removeAll() guard counts.inserted + counts.healedSpent == 0 || reconcileSave(operation: "txo_reconcile_heal", walletId: walletId) else { @@ -12041,6 +12010,51 @@ extension PlatformWalletPersistenceHandler { } } +/// C shim for the extension's `on_persist_transaction_accounting_fn`. +private func persistTransactionAccountingCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + rows: UnsafePointer?, + count: UInt +) -> Int32 { + guard let context, let walletIdPtr else { return 0 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + return handler.persistTransactionAccounting( + walletId: Data(bytes: walletIdPtr, count: 32), rows: rows, count: count + ) ? 0 : 1 +} + +/// C shim for `on_load_core_history_for_accounting_fn`. Hands Rust a loaned +/// array released by the free shim below. +private func loadCoreHistoryForAccountingCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer?, + outRows: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletIdPtr, let outRows, let outCount else { return 1 } + outRows.pointee = nil + outCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let (rows, count, errored) = handler.loadCoreHistoryForAccounting( + walletId: Data(bytes: walletIdPtr, count: 32) + ) + outRows.pointee = rows + outCount.pointee = UInt(count) + return errored ? 1 : 0 +} + +/// C shim for `on_load_core_history_for_accounting_free_fn`. +private func loadCoreHistoryForAccountingFreeCallback( + context: UnsafeMutableRawPointer?, + rows: UnsafePointer?, + _ count: UInt +) { + guard let context else { return } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + handler.loadCoreHistoryForAccountingFree(rows: rows.map(UnsafeRawPointer.init)) +} + private func persistAssetLockOperationIdCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPoint: UnsafePointer?, operationId: UnsafePointer? diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift index e056190e51b..2b5bbc1fe7c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/AccountDetailView.swift @@ -164,7 +164,7 @@ struct AccountDetailView: View { .navigationTitle(account.accountTypeName) .navigationBarTitleDisplayMode(.large) .sheet(item: $selectedTransaction) { transaction in - TransactionDetailView(transaction: transaction) + TransactionDetailView(transaction: transaction, walletId: wallet.walletId) } .sheet(isPresented: $showingPINPrompt) { PINPromptView( @@ -465,7 +465,7 @@ struct AccountDetailView: View { Button { selectedTransaction = tx } label: { - TransactionRowView(transaction: tx) + TransactionRowView(transaction: tx, walletId: wallet.walletId) } .buttonStyle(.plain) if idx < txs.count - 1 { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index ba2092b1f68..4f2c5753486 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -4,9 +4,8 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction var walletId: Data? = nil - /// `nil` while this wallet's amount is unresolved — the same state the - /// amount label shows as "Amount unavailable", so fee and amount agree. - private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + /// This wallet's Rust-emitted amount (see `PersistentTransaction.netAmount(for:)`). + private var netAmount: Int64 { transaction.displayNetAmount(for: walletId) } private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @@ -78,7 +77,7 @@ struct TransactionDetailView: View { } private var formattedFee: String? { - guard let fee = transaction.fee else { return nil } + guard let fee = transaction.displayFee(for: walletId) else { return nil } let dash = Double(fee) / 100_000_000.0 return String(format: "%.8f DASH", dash) } @@ -189,7 +188,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, let amount = netAmount, amount < 0 { + if let fee = formattedFee, netAmount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index 5d84aaca7e4..e38acb46e4a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -221,9 +221,8 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction var walletId: Data? = nil - /// `nil` while this wallet's amount is unresolved — the same state the - /// amount label shows as "Amount unavailable", so fee and amount agree. - private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + /// This wallet's Rust-emitted amount (see `PersistentTransaction.netAmount(for:)`). + private var netAmount: Int64 { transaction.displayNetAmount(for: walletId) } private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @@ -389,7 +388,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.fee, let amount = netAmount, amount < 0 { + if let fee = transaction.displayFee(for: walletId), netAmount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 439a181f8e3..b712344a688 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -36,7 +36,8 @@ "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", - "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift", + "PersistentTransactionAccounting": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift" }, "value_types": [ { From b59aa587a2ec3ad2f6baf893e754818a6fd7fb05 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:46:30 +0000 Subject: [PATCH 41/84] refactor(swift-sdk): drop the Swift-side transaction accounting derivation With accounting coming from rs-platform-wallet, the Swift copies of its rules have no callers left: - `isWalletOwnedTxo`, `isContactTxo` and `walletNetwork(of:)`: TXO ownership and contact-output detection for the deleted reconcile pass. - `DecodedTransaction.Output.isOpReturn`, added only to feed that pass. Tests follow the new contract. `TransactionAccountingTests` now covers storing, updating and looking up the emitted per-wallet rows, the out-of-round backfill commit, the history offered to Rust, a record re-emitted without accounting, and wallet deletion. The direction case table stays in Rust. The V5 schema tests cover the new entity, and the heal-time and load-time accounting expectations are reverted to the base. Not built here: no Swift toolchain on this host. Co-Authored-By: Claude Opus 5.5 --- .../KeyWallet/TransactionDecoder.swift | 3 - .../PlatformWalletPersistenceHandler.swift | 18 - .../AssetLockSpendVisibilityTests.swift | 9 +- .../CoreTxoReconcileTests.swift | 43 - .../DashModelMigrationTests.swift | 7 + .../DashReleasedSchemaTests.swift | 26 +- .../TransactionAccountingTests.swift | 779 +++--------------- 7 files changed, 164 insertions(+), 721 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift index 7224d3dc282..380deeeeff7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/KeyWallet/TransactionDecoder.swift @@ -39,9 +39,6 @@ public struct DecodedTransaction: Sendable, Equatable { public let valueDuffs: UInt64 /// Raw scriptPubKey bytes. public let scriptPubkey: Data - - /// `true` for an `OP_RETURN` (0x6a) data-carrier / burn output. - var isOpReturn: Bool { scriptPubkey.first == 0x6a } } /// Transaction id in consensus (internal) byte order — reverse for diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index c0606d0955a..6322f76b003 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -112,24 +112,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return wallet?.walletId } - /// Contact watch-only TXOs do not belong to the wallet tracking their addresses. - static func isWalletOwnedTxo(_ txo: PersistentTxo) -> Bool { - resolvedWalletId(of: txo) != nil && !isContactTxo(txo) - } - - /// A TXO on a contact's watch-only chain, by its account or its address's. - static func isContactTxo(_ txo: PersistentTxo) -> Bool { - txo.account?.accountType == dashpayExternalAccountTypeTag - || txo.coreAddress?.account?.accountType == dashpayExternalAccountTypeTag - } - - /// Network of the account's wallet, read through the same Optional cast - /// as `resolvedWalletId(of:)`. - static func walletNetwork(of account: PersistentAccount?) -> Network? { - let wallet: PersistentWallet? = account?.wallet - return wallet?.network - } - static func walletOwnsTransaction( walletId: Data, transaction: PersistentTransaction diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index 8e927d75230..7aaafce9db8 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -368,13 +368,8 @@ final class AssetLockSpendVisibilityTests: XCTestCase { XCTAssertEqual( injector.observedReads, - [ - // Wallet list, then the load-time accounting reconcile. - "PersistentWallet", "PersistentTransaction", "PersistentTxo", "PersistentCoreAddress", - // Unspent-TXO restore, then the faulted lock read. - "PersistentTxo", "PersistentAssetLock", - ], - "every read before the lock read must have been served — only the lock read failed" + ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], + "the wallet and unspent-TXO reads must have been served — only the lock read failed" ) XCTAssertTrue( errored, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift index d8688d49aba..eac6a57af07 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreTxoReconcileTests.swift @@ -467,49 +467,6 @@ final class CoreTxoReconcileTests: XCTestCase { XCTAssertEqual(try restoredUtxoCount(handler), 0) } - /// A healed coin can be the prevout a stored spender was missing. The - /// heal's own save then repairs that spender's amount and direction — - /// they do not stay stale until the next launch's load-time pass. - func testAHealedPrevoutRepairsItsSpendersAccounting() throws { - let (handler, container) = try makeHandler() - try seedWallet(in: container) - let coinTxid = txid(0x86) - let spenderTxid = txid(0x87) - // One input spending the missing coin, one output the wallet does not own. - var bytes = Data([2, 0, 0, 0, 1]) - bytes.append(coinTxid) - bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) - withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } - bytes.append(contentsOf: [0, 0, 0, 0, 0]) - let context = ModelContext(container) - // Stored as an incoming payment: what history showed while the coin was missing. - let spender = PersistentTransaction( - txid: spenderTxid, transactionData: bytes, context: 3, blockHeight: 2_391_800, - direction: CoreDirectionCode.incoming, netAmount: 40 - ) - context.insert(spender) - context.insert(PersistentPendingInput( - outpoint: PersistentTxo.makeOutpoint(txid: coinTxid, vout: 0), - inputIndex: 0, - spendingTxid: spenderTxid, - spendingTransaction: spender, - walletId: walletId - )) - try context.save() - let engine = FakeCoreTxoEngine(inventory: [engineUtxo(txid: coinTxid)]) - - let report = run(handler, engine: engine) - - XCTAssertTrue(report.completed) - XCTAssertEqual(report.healedSpent, 1) - // Read without a load in between: the load-time pass would repair it too. - let row = try XCTUnwrap( - ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderTxid } - ) - XCTAssertEqual(row.netAmount, -19_549, "the healed coin is the spender's only input") - XCTAssertEqual(row.direction, CoreDirectionCode.outgoing) - } - /// A store read that fails inside the heal pass is a failure, not a /// skip: the account lookup cannot tell "no such account" from "could /// not read", so it must not answer the former when the latter diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 0cb85079011..0351eeb6143 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -454,6 +454,13 @@ final class DashModelMigrationTests: XCTestCase { ["PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata"]) } + func testV5AddsPerWalletTransactionAccountingToV4() { + XCTAssertEqual( + Set(Schema(versionedSchema: DashSchemaV5.self).entities.map(\.name)) + .subtracting(Schema(versionedSchema: DashSchemaV4.self).entities.map(\.name)), + ["PersistentTransactionAccounting"]) + } + @MainActor func testV1BalanceGetsNoWatermarkUntilOneIsPersistedInLiveV3() throws { let (directory, url) = try copyFixture(Self.fixtures[0]) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index 6b5482a4b67..ae7e3e0713f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -106,8 +106,11 @@ final class DashReleasedSchemaTests: XCTestCase { } } + /// V4 → V5 adds the per-wallet accounting entity: migrated rows start + /// without one and keep their stored amount, and a row written after the + /// migration survives a reopen and cascades with its transaction. @MainActor - func testShouldMigrateV4AccountingAvailabilityAndPersistAnUnavailableAmount() throws { + func testShouldMigrateV4ToPerWalletAccountingRows() throws { let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { $0.version.versionIdentifier == Schema.Version(3, 0, 0) }) @@ -124,23 +127,32 @@ final class DashReleasedSchemaTests: XCTestCase { } XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["4.0.0"]) let txid = Data(repeating: 0x32, count: 32) + let walletId = Data(repeating: 0x07, count: 32) try autoreleasepool { let container = try DashModelContainer.create(url: url) let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) .first { $0.txid == txid }) - XCTAssertNil(row.netAmountUnavailable, "V4 rows retain their stored accounting by default") - XCTAssertEqual(row.displayNetAmount(for: nil), row.netAmount) - row.netAmountUnavailable = true + XCTAssertTrue(row.walletAccounting.isEmpty, "V4 rows carry no per-wallet accounting yet") + XCTAssertEqual(row.netAmount(for: walletId), row.netAmount) + let accounting = PersistentTransactionAccounting( + walletId: walletId, txid: txid, netAmount: -1_234, + direction: CoreDirectionCode.outgoing, fee: 34, transaction: nil + ) + container.mainContext.insert(accounting) + row.walletAccounting.append(accounting) try container.mainContext.save() } try autoreleasepool { let container = try DashModelContainer.create(url: url) let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) .first { $0.txid == txid }) - XCTAssertEqual(row.netAmountUnavailable, true) - XCTAssertNil(row.displayNetAmount(for: nil)) - XCTAssertEqual(row.formattedAmount, "Amount unavailable") + XCTAssertEqual(row.netAmount(for: walletId), -1_234) + XCTAssertEqual(row.direction(for: walletId), CoreDirectionCode.outgoing) + XCTAssertEqual(row.fee(for: walletId), 34) XCTAssertEqual(try DashLegacySchemaBridge.identity(at: url).versions, ["5.0.0"]) + container.mainContext.delete(row) + try container.mainContext.save() + XCTAssertTrue(try container.mainContext.fetch(FetchDescriptor()).isEmpty) } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 4686f2abc39..246381c1daf 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -3,209 +3,50 @@ import SwiftData import DashSDKFFI @testable import SwiftDashSDK +/// The Swift store persists the per-wallet accounting `rs-platform-wallet` +/// emits and displays it; it never derives it. The rule itself is tested in +/// Rust (`platform_wallet::changeset::wallet_accounting`). @MainActor final class TransactionAccountingTests: XCTestCase { - private func input(_ value: UInt64, wallet: UInt8 = 1) -> PersistentTxo { - let parent = PersistentTransaction(txid: Data(repeating: wallet, count: 32), transactionData: Data()) - let txo = PersistentTxo(transaction: parent, vout: 0, amount: value, address: "", height: 1) - txo.walletId = Data(repeating: wallet, count: 32) - return txo - } - - func testShouldRepairSpentInputsWithoutChangingBalanceOrConsumption() { - let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) - let spent = input(100) - spent.isSpent = true - let result = PersistentTransaction.reconciledAccounting( - inputs: [spent], ownedOutputAmounts: [40], allOutputsOwned: false, previousDirection: 0, isAssetLock: false - ) - XCTAssertEqual(result?.netAmount, -60) - XCTAssertEqual(result?.direction, 1) - XCTAssertTrue(spent.isSpent) - XCTAssertEqual(tx.netAmount, 40) // Computing accounting has no storage side effects. - } - - func testShouldKeepInternalAndCoinJoinSemanticsWithNegativeNet() { - let spent = input(100) - XCTAssertEqual(PersistentTransaction.reconciledAccounting( - inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: true, previousDirection: 0, isAssetLock: false - )?.direction, 2) - let lock = PersistentTransaction.reconciledAccounting( - inputs: [spent], ownedOutputAmounts: [], allOutputsOwned: true, previousDirection: 2, isAssetLock: true - ) - XCTAssertEqual(lock?.netAmount, -100) - XCTAssertEqual(lock?.direction, 2) - XCTAssertEqual(PersistentTransaction.reconciledAccounting( - inputs: [spent], ownedOutputAmounts: [99], allOutputsOwned: false, previousDirection: 3, isAssetLock: false - )?.direction, 3) - } + private let walletA = Data(repeating: 1, count: 32) + private let walletB = Data(repeating: 2, count: 32) + private let txid = Data(repeating: 3, count: 32) - /// Same case table as the Rust repair's - /// `should_classify_repaired_direction_like_the_swift_sdk`. - func testShouldClassifyDirectionLikeTheRustRepair() { - let spent = input(100) - // (spends ours, owned output amounts, all outputs owned, asset lock, previous direction, expected) - let (incoming, outgoing, internalTransfer, coinJoin) = ( - CoreDirectionCode.incoming, CoreDirectionCode.outgoing, - CoreDirectionCode.internalTransfer, CoreDirectionCode.coinJoin - ) - let cases: [(Bool, [UInt64], Bool, Bool, UInt32, UInt32)] = [ - (true, [99], true, false, incoming, internalTransfer), - (true, [40], false, false, incoming, outgoing), - (true, [], true, false, incoming, outgoing), - (true, [], true, true, incoming, internalTransfer), - (true, [40], true, true, incoming, internalTransfer), - (true, [], false, true, incoming, outgoing), - (false, [40], true, false, incoming, incoming), - (true, [99], true, false, coinJoin, coinJoin), - ] - for (index, (spendsOurs, owned, allOwned, isLock, previous, expected)) in cases.enumerated() { - let result = PersistentTransaction.reconciledAccounting( - inputs: spendsOurs ? [spent] : [], ownedOutputAmounts: owned, - allOutputsOwned: allOwned, previousDirection: previous, isAssetLock: isLock - ) - XCTAssertEqual(result?.direction, expected, "case \(index)") - } - } - - func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { - XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") - XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") - XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) - } - - func testShouldRejectOverflowInsteadOfWrappingHistory() { - XCTAssertNil(PersistentTransaction.reconciledAccounting( - inputs: [input(UInt64.max)], ownedOutputAmounts: [], allOutputsOwned: false, previousDirection: 0, isAssetLock: false - )) - } - - func testShouldScopeNetToWalletAndDeduplicateOutpoints() { - let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) - let first = input(100) - let other = input(200, wallet: 2) - tx.inputs = [first, first, other] - XCTAssertEqual(tx.netAmount(for: first.walletId), -100) - XCTAssertEqual(tx.netAmount(for: other.walletId), -200) - } - /// `burn` makes the single output an OP_RETURN, as an asset lock's is. - private func serializedSpend(inputs: [Data], outputValue: UInt64 = 40, burn: Bool = false) -> Data { - var bytes = Data([2, 0, 0, 0, UInt8(inputs.count)]) - for txid in inputs { - bytes.append(txid) - bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255]) - } - bytes.append(1) - withUnsafeBytes(of: outputValue.littleEndian) { bytes.append(contentsOf: $0) } - bytes.append(contentsOf: burn ? [1, 0x6a] : [0]) - bytes.append(contentsOf: [0, 0, 0, 0]) - return bytes - } - - func testShouldBackfillExistingHistoryOnLoadAndRemainIdempotent() throws { + private func makeStore() throws -> (ModelContainer, PlatformWalletPersistenceHandler) { let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let funding = PersistentTransaction(txid: walletId, transactionData: Data()) - let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: CoreDirectionCode.incoming, netAmount: 40 - ) - let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) - coin.walletId = walletId - coin.isSpent = true - coin.spendingTransaction = spender - context.insert(funding) - context.insert(spender) - context.insert(coin) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - XCTAssertFalse(handler.loadWalletList().errored) - let fresh = ModelContext(container) - let repaired = try XCTUnwrap(fresh.fetch(FetchDescriptor()).first { $0.txid == spender.txid }) - XCTAssertEqual(repaired.netAmount, -100) - XCTAssertEqual(repaired.direction, 1) - XCTAssertTrue(try XCTUnwrap(fresh.fetch(FetchDescriptor()).first).isSpent) - } - - func testShouldNotCommitOrDropOpenRoundWhenLoadRunsMidChangeset() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let funding = PersistentTransaction(txid: walletId, transactionData: Data()) - let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: CoreDirectionCode.incoming, netAmount: 40 - ) - let coin = PersistentTxo(transaction: funding, vout: 0, amount: 100, address: "", height: 1) - coin.walletId = walletId - coin.isSpent = true - coin.spendingTransaction = spender - context.insert(funding) - context.insert(spender) - context.insert(coin) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - let identityId = Data(repeating: 9, count: 32) - let fetchIdentity = { - try ModelContext(container).fetch(FetchDescriptor()).first { $0.identityId == identityId } - } - - handler.beginChangeset(walletId: walletId) - handler.persistIdentities( - walletId: walletId, - upserts: [.init( - identityId: identityId, balance: 100, revision: 1, identityIndex: 0, label: nil, - status: 0, walletId: walletId, dpnsNames: [], dashpayProfile: nil, contactProfiles: [] - )], - removed: [] - ) - XCTAssertFalse(handler.loadWalletList().errored) - XCTAssertNil(try fetchIdentity(), "load must not commit half of an open round") - XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) - XCTAssertNotNil(try fetchIdentity(), "load must not roll back an open round") - - // The skipped full pass runs on the next load outside any round. - XCTAssertFalse(handler.loadWalletList().errored) - let repaired = try ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == spender.txid } - XCTAssertEqual(repaired?.netAmount, -100) - } - - func testShouldRestoreWalletsWhenLoadTimeAccountingFails() throws { - let container = try DashModelContainer.createInMemory() - container.mainContext.insert(PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet)) + container.mainContext.insert(PersistentWallet(walletId: walletA, network: .testnet)) + container.mainContext.insert(PersistentWallet(walletId: walletB, network: .testnet)) try container.mainContext.save() - let injector = FetchFaultInjector(faulting: PersistentCoreAddress.self) - let handler = PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet, modelFetcher: injector - ) - XCTAssertFalse(handler.loadWalletList().errored, "display accounting must not block restore") - XCTAssertTrue(injector.observedReads.contains("PersistentCoreAddress")) + return (container, PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)) + } + + private func row(_ txid: Data, net: Int64, direction: UInt32, fee: UInt64? = nil) -> TransactionAccountingFFI { + var row = TransactionAccountingFFI() + withUnsafeMutableBytes(of: &row.txid) { $0.copyBytes(from: txid) } + row.net_amount = net + row.direction = direction + row.fee = fee ?? 0 + row.has_fee = fee != nil + return row + } + + /// Hand the handler `rows` the way Rust's extension slot does: inside a + /// round, or on their own like the load-time backfill. + @discardableResult + private func account( + _ handler: PlatformWalletPersistenceHandler, walletId: Data, + _ rows: [TransactionAccountingFFI], inRound: Bool = true + ) -> Bool { + if inRound { handler.beginChangeset(walletId: walletId) } + let stored = rows.withUnsafeBufferPointer { + handler.persistTransactionAccounting(walletId: walletId, rows: $0.baseAddress, count: UInt($0.count)) + } + if inRound { XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) } + return stored } - func testShouldPreserveAccountingWhenSomePrevoutsAreMissing() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let coin = input(100) - let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), - transactionData: serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)]), - direction: CoreDirectionCode.outgoing, netAmount: -200 - ) - coin.spendingTransaction = spender - context.insert(coin) - context.insert(spender) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let rows = try ModelContext(container).fetch(FetchDescriptor()) - XCTAssertEqual(rows.first { $0.txid == spender.txid }?.netAmount, -200) + private func transaction(_ container: ModelContainer, _ txid: Data) throws -> PersistentTransaction { + try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == txid }) } private func persist( @@ -264,482 +105,134 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) } - func testShouldRepairLateInputAndLateOutputInSeparateAtomicRounds() throws { - let container = try DashModelContainer.createInMemory() - let walletId = Data(repeating: 1, count: 32) - container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) - try container.mainContext.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - let spenderId = Data(repeating: 3, count: 32) - persist(handler, walletId: walletId, txid: spenderId, - bytes: serializedSpend(inputs: [walletId]), net: 40, inputTxids: [walletId]) - persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) - persist(handler, walletId: walletId, txid: spenderId, outputs: [(spenderId, 40)]) - let context = ModelContext(container) - let row = try XCTUnwrap(context.fetch(FetchDescriptor()).first { $0.txid == spenderId }) - XCTAssertEqual(row.netAmount, -60) - XCTAssertEqual(row.direction, 2) - XCTAssertEqual(row.inputs.count, 1) - XCTAssertEqual(row.outputs.count, 1) - XCTAssertTrue(try XCTUnwrap(row.inputs.first).isSpent) - } + func testShouldStoreEachWalletsEmittedAccountingSeparately() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: -100) + persist(handler, walletId: walletB, txid: txid, bytes: Data([1]), net: 90) + account(handler, walletId: walletA, [row(txid, net: -100, direction: CoreDirectionCode.outgoing, fee: 10)]) + account(handler, walletId: walletB, [row(txid, net: 90, direction: CoreDirectionCode.incoming)]) - func testShouldPreserveFundedAssetLockAccountingDuringSyntheticReplayWithMissingPrevout() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let spenderId = Data(repeating: 3, count: 32) - let bytes = serializedSpend(inputs: [walletId, Data(repeating: 2, count: 32)], outputValue: 0, burn: true) - let spender = PersistentTransaction( - txid: spenderId, transactionData: bytes, - direction: CoreDirectionCode.internalTransfer, netAmount: -200 - ) - spender.transactionTypeKind = 6 - let coin = input(100) - coin.spendingTransaction = spender - context.insert(coin) - context.insert(spender) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6, inputTxids: [walletId]) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) - XCTAssertEqual(row.netAmount, -200) - XCTAssertEqual(row.direction, 2) - XCTAssertEqual(row.context, 2) + let stored = try transaction(container, txid) + XCTAssertEqual(stored.walletAccounting.count, 2) + XCTAssertEqual(stored.netAmount(for: walletA), -100) + XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.outgoing) + XCTAssertEqual(stored.fee(for: walletA), 10) + XCTAssertEqual(stored.netAmount(for: walletB), 90) + XCTAssertEqual(stored.direction(for: walletB), CoreDirectionCode.incoming) + XCTAssertNil(stored.fee(for: walletB), "a wallet that funded nothing knows no fee") + XCTAssertEqual(stored.displayFormattedAmount(for: walletB), "+0.00000090 DASH") } - func testShouldPreserveAssetLockDebitWhenNoInputIsLinkedYet() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let spenderId = Data(repeating: 3, count: 32) - let bytes = serializedSpend(inputs: [Data(repeating: 2, count: 32)], outputValue: 0, burn: true) - let lock = PersistentTransaction( - txid: spenderId, transactionData: bytes, - direction: CoreDirectionCode.internalTransfer, netAmount: -200 - ) - lock.transactionTypeKind = 6 - lock.fee = 7 - context.insert(lock) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - persist(handler, walletId: walletId, txid: spenderId, bytes: bytes, kind: 6) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) - XCTAssertEqual(row.netAmount, -200, "a synthetic zero update must not erase the debit") - XCTAssertEqual(row.fee, 7) - XCTAssertEqual(row.direction, 2) - } + func testShouldUpdateTheWalletsRowInsteadOfAddingAnother() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) + account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) + account(handler, walletId: walletA, [row(txid, net: -60, direction: CoreDirectionCode.outgoing)]) - func testShouldRepairNoChangeAssetLockToFullCoreDebit() throws { - let container = try DashModelContainer.createInMemory() - let walletId = Data(repeating: 1, count: 32) - container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) - try container.mainContext.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - let spenderId = Data(repeating: 3, count: 32) - persist(handler, walletId: walletId, txid: walletId, outputs: [(walletId, 100)]) - persist(handler, walletId: walletId, txid: spenderId, - bytes: serializedSpend(inputs: [walletId], outputValue: 0, burn: true), kind: 6, inputTxids: [walletId]) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spenderId }) - XCTAssertEqual(row.netAmount, -100) - XCTAssertEqual(row.direction, 2) - XCTAssertTrue(row.isAssetLock) + let stored = try transaction(container, txid) + XCTAssertEqual(stored.walletAccounting.count, 1) + XCTAssertEqual(stored.netAmount(for: walletA), -60) + XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.outgoing) } - func testShouldReportSoleWalletsStoredAmountDespitePendingInputs() { - let walletId = Data(repeating: 1, count: 32) - let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) - let change = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) - change.walletId = walletId - tx.outputs = [change] - tx.pendingInputs = [PersistentPendingInput( - outpoint: Data(repeating: 9, count: 36), inputIndex: 0, - spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletId - )] - XCTAssertEqual(tx.netAmount(for: walletId), 40, "the sole wallet's stored amount is Rust's net_amount") - } + /// The load-time backfill arrives outside any round and commits itself. + func testShouldCommitAccountingDeliveredOutsideARound() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) - func testShouldNotComputeSharedAmountWhileOwnInputsArePending() { - let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) - let tx = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data(), netAmount: 40) - let toA = PersistentTxo(transaction: tx, vout: 0, amount: 40, address: "", height: 1) - toA.walletId = walletA - let toB = PersistentTxo(transaction: tx, vout: 1, amount: 60, address: "", height: 1) - toB.walletId = walletB - tx.outputs = [toA, toB] - tx.pendingInputs = [PersistentPendingInput( - outpoint: Data(repeating: 9, count: 36), inputIndex: 0, - spendingTxid: tx.txid, spendingTransaction: tx, walletId: walletA - )] - XCTAssertNil(tx.netAmount(for: walletA), "an unlinked input A recorded may be A's own coin") - XCTAssertEqual(tx.netAmount(for: walletB), 60) - } + XCTAssertTrue(account(handler, walletId: walletA, + [row(txid, net: -60, direction: CoreDirectionCode.outgoing)], inRound: false)) - func testShouldKeepIncomingAmountWhenInputsAreForeign() throws { - let container = try DashModelContainer.createInMemory() - let walletId = Data(repeating: 1, count: 32) - container.mainContext.insert(PersistentWallet(walletId: walletId, network: .testnet)) - try container.mainContext.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - let paymentId = Data(repeating: 3, count: 32) - let foreign = Data(repeating: 2, count: 32) - persist(handler, walletId: walletId, txid: paymentId, - bytes: serializedSpend(inputs: [foreign]), net: 40, - inputTxids: [foreign], outputs: [(paymentId, 40)]) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == paymentId }) - XCTAssertEqual(row.netAmount, 40) - XCTAssertEqual(row.netAmount(for: walletId), 40, "a foreign input is not an unresolved input of ours") + XCTAssertEqual(try transaction(container, txid).netAmount(for: walletA), -60) } - private func localTransfer(in context: ModelContext, assetLock: Bool = false) throws -> PersistentTransaction { - let walletA = PersistentWallet(walletId: Data(repeating: 1, count: 32), network: .testnet) - let walletB = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) - let accountA = PersistentAccount(wallet: walletA, accountType: 0, accountIndex: 0, accountTypeName: "Standard") - let accountB = PersistentAccount(wallet: walletB, accountType: 0, accountIndex: 0, accountTypeName: "Standard") - context.insert(walletA) - context.insert(walletB) - context.insert(accountA) - context.insert(accountB) - let tx = PersistentTransaction( - txid: Data(repeating: 3, count: 32), - transactionData: serializedSpend(inputs: [walletA.walletId], outputValue: 90), - direction: CoreDirectionCode.internalTransfer, netAmount: -10 - ) - tx.transactionTypeKind = assetLock ? TransactionTypeKind.assetLock.rawValue : TransactionTypeKind.standard.rawValue - let coin = input(100) - coin.account = accountA - coin.isSpent = true - coin.spendingTransaction = tx - let credit = PersistentTxo(transaction: tx, vout: 0, amount: 90, address: "", height: 1) - credit.walletId = walletB.walletId - credit.account = accountB - context.insert(tx) - context.insert(coin) - context.insert(credit) - tx.involvedAccounts = [accountA, accountB] - try context.save() - return tx - } + func testShouldIgnoreAccountingForATransactionTheStoreDoesNotHold() throws { + let (container, handler) = try makeStore() - func testShouldKeepSurvivingWalletAccountingAfterDeletingEitherTransferWallet() throws { - for deletedWallet in [UInt8(1), UInt8(2)] { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let url = directory.appendingPathComponent("wallet.store") - let survivor = Data(repeating: deletedWallet == 1 ? 2 : 1, count: 32) - let amount: Int64 = deletedWallet == 1 ? 90 : -100 - let direction = deletedWallet == 1 ? CoreDirectionCode.incoming : CoreDirectionCode.outgoing - try autoreleasepool { - let container = try DashModelContainer.create(url: url) - _ = try localTransfer(in: container.mainContext) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - try handler.deleteWalletData(walletId: Data(repeating: deletedWallet, count: 32)) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == Data(repeating: 3, count: 32) }) - XCTAssertEqual(row.netAmount(for: survivor), amount) - XCTAssertEqual(row.direction(for: survivor), direction) - } - try autoreleasepool { - let container = try DashModelContainer.create(url: url) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == Data(repeating: 3, count: 32) }) - XCTAssertEqual(row.netAmount(for: survivor), amount) - XCTAssertEqual(row.direction(for: survivor), direction) - } - } - } + XCTAssertTrue(account(handler, walletId: walletA, [row(txid, net: 5, direction: CoreDirectionCode.incoming)])) - func testShouldKeepUnavailableAmountAfterDeletingAnotherParticipant() throws { - let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: directory) } - let url = directory.appendingPathComponent("wallet.store") - let survivor = Data(repeating: 2, count: 32) - try autoreleasepool { - let container = try DashModelContainer.create(url: url) - let tx = try localTransfer(in: container.mainContext, assetLock: true) - let foreign = Data(repeating: 9, count: 32) - tx.transactionData = serializedSpend(inputs: [Data(repeating: 1, count: 32), foreign], outputValue: 90) - tx.netAmount = 0 - let pending = PersistentPendingInput( - outpoint: PersistentTxo.makeOutpoint(txid: foreign, vout: 0), inputIndex: 1, - spendingTxid: tx.txid, spendingTransaction: tx, walletId: survivor - ) - container.mainContext.insert(pending) - try container.mainContext.save() - XCTAssertNil(tx.netAmount(for: survivor)) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - try handler.deleteWalletData(walletId: Data(repeating: 1, count: 32)) - // An empty asset-lock recovery update must not invent accounting. - persist(handler, walletId: survivor, txid: tx.txid, bytes: tx.transactionData, kind: 6) - } - try autoreleasepool { - let container = try DashModelContainer.create(url: url) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == Data(repeating: 3, count: 32) }) - XCTAssertNil(row.netAmount(for: survivor)) - XCTAssertEqual(row.formattedAmount(for: survivor), "Amount unavailable") - // A new authoritative wallet record can make the amount available. - persist(handler, walletId: survivor, txid: row.txid, bytes: row.transactionData, net: 90, kind: 6) - let updated = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == row.txid }) - XCTAssertEqual(updated.netAmount(for: survivor), 90) - } + XCTAssertTrue(try ModelContext(container).fetch(FetchDescriptor()).isEmpty) } - func testShouldScopeSharedAssetLockDirectionBeforeAndAfterReconciliation() throws { - let container = try DashModelContainer.createInMemory() - let tx = try localTransfer(in: container.mainContext, assetLock: true) - let walletA = Data(repeating: 1, count: 32) - let walletB = Data(repeating: 2, count: 32) - // A's Rust record is outgoing, and reconciliation keeps the stored scalar the sender's. - tx.direction = CoreDirectionCode.outgoing - XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) - XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) - try container.mainContext.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == tx.txid }) - XCTAssertEqual(row.direction, CoreDirectionCode.outgoing) - XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.outgoing) - XCTAssertEqual(row.direction(for: walletB), CoreDirectionCode.incoming) - } + /// A record Rust re-emits without accounting (an asset lock restored from + /// its bytes, promoted by a chain lock) overwrites only the shared + /// scalar; the wallet's row, which Rust did not resend, still answers. + func testShouldKeepTheWalletsRowWhenARecordArrivesWithoutAccounting() throws { + let (container, handler) = try makeStore() + let assetLock = TransactionTypeKind.assetLock.rawValue + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: -100, kind: assetLock) + account(handler, walletId: walletA, [row(txid, net: -100, direction: CoreDirectionCode.internalTransfer)]) + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 0, kind: assetLock) - func testShouldKeepOwnAssetLockConversionInternalWithAnotherKeysOnlyParticipant() throws { - let container = try DashModelContainer.createInMemory() - let tx = try localTransfer(in: container.mainContext, assetLock: true) - let walletA = Data(repeating: 1, count: 32) - let change = try XCTUnwrap(tx.outputs.first) - change.walletId = walletA - change.account = tx.inputs.first?.account - XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) - XCTAssertEqual(tx.direction(for: Data(repeating: 2, count: 32)), CoreDirectionCode.internalTransfer) - // A no-change conversion remains internal too. - container.mainContext.delete(change) - tx.transactionData = serializedSpend(inputs: [walletA], outputValue: 90, burn: true) - try container.mainContext.save() - XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.internalTransfer) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == tx.txid }) - XCTAssertEqual(row.direction(for: walletA), CoreDirectionCode.internalTransfer) + let stored = try transaction(container, txid) + XCTAssertEqual(stored.netAmount, 0, "the shared scalar is written as delivered") + XCTAssertEqual(stored.netAmount(for: walletA), -100) + XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.internalTransfer) } - func testShouldScopeDirectionAndAmountToEachWalletOfALocalTransfer() { - let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) - let (amount, fee): (UInt64, UInt64) = (90, 10) - let coin = input(amount + fee, wallet: 1) - // Whichever wallet recorded last owns the stored scalars; here the sender. + /// Without a row for the wallet, display falls back to the shared scalar. + func testShouldFallBackToTheSharedValuesWithoutAWalletRow() { let tx = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: Data(), - direction: CoreDirectionCode.outgoing, netAmount: -Int64(amount + fee) + txid: txid, transactionData: Data(), direction: CoreDirectionCode.incoming, netAmount: 25 ) - let credit = PersistentTxo(transaction: tx, vout: 0, amount: amount, address: "", height: 1) - credit.walletId = walletB - tx.inputs = [coin] - tx.outputs = [credit] - XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.outgoing) - XCTAssertEqual(tx.direction(for: walletB), CoreDirectionCode.incoming) - XCTAssertEqual(tx.netAmount(for: walletA), -Int64(amount + fee)) - XCTAssertEqual(tx.netAmount(for: walletB), Int64(amount)) - XCTAssertEqual(tx.formattedAmount(for: walletB), "+0.00000090 DASH") + XCTAssertEqual(tx.netAmount(for: walletA), 25) + XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.incoming) + XCTAssertEqual(tx.displayNetAmount(for: nil), 25) } - func testShouldReportAmountUnavailableOnlyWhenNoWalletAccountingApplies() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let (walletA, walletB) = (Data(repeating: 1, count: 32), Data(repeating: 2, count: 32)) - func makeAccount(of walletId: Data) -> PersistentAccount { - let wallet = PersistentWallet(walletId: walletId, network: .testnet) - let account = PersistentAccount( - wallet: wallet, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" - ) - context.insert(wallet) - context.insert(account) - return account - } - let (accountA, accountB) = (makeAccount(of: walletA), makeAccount(of: walletB)) - // Payload-only (no linked TXO), e.g. a provider tx matched by key. - let payloadOnly = PersistentTransaction(txid: Data(repeating: 3, count: 32), transactionData: Data()) - let shared = PersistentTransaction(txid: Data(repeating: 4, count: 32), transactionData: Data(), netAmount: 40) - let unrecorded = PersistentTransaction(txid: Data(repeating: 5, count: 32), transactionData: Data(), netAmount: 40) - context.insert(payloadOnly) - context.insert(shared) - context.insert(unrecorded) - payloadOnly.involvedAccounts = [accountA] - shared.involvedAccounts = [accountA, accountB] - try context.save() + func testShouldOfferNoHistoryWhenEveryTransactionHasTheWalletsRow() throws { + let (_, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) - XCTAssertEqual(payloadOnly.netAmount(for: walletA), 0, "the sole recorder's stored amount stands") - XCTAssertEqual(payloadOnly.formattedAmount(for: walletA), "+0.00000000 DASH") - XCTAssertNil(shared.netAmount(for: walletA), "the stored scalar may be the other recorder's") - XCTAssertNil(shared.netAmount(for: walletB)) - XCTAssertEqual(shared.formattedAmount(for: walletA), "Amount unavailable") - XCTAssertNil(unrecorded.netAmount(for: walletA), "a wallet with no stake has no amount") - } + let history = handler.loadCoreHistoryForAccounting(walletId: walletA) - func testShouldNotCountAnotherLocalWalletsUnlinkedOutputForTheSender() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let senderId = Data(repeating: 1, count: 32) - let receiver = PersistentWallet(walletId: Data(repeating: 2, count: 32), network: .testnet) - let receiverAccount = PersistentAccount( - wallet: receiver, accountType: 0, accountIndex: 0, accountTypeName: "Standard BIP44 Account" - ) - // P2PKH to pubkey hash 0x05 x 20 on testnet: B's address with no TXO row yet. - let receiverAddress = PersistentCoreAddress( - address: "yLmzEvw3frCPS4cyRmFFeKbt64fUPzMwFh", poolTypeTag: 0, addressIndex: 0, derivationPath: "" - ) - receiverAddress.account = receiverAccount - context.insert(PersistentWallet(walletId: senderId, network: .testnet)) - context.insert(receiver) - context.insert(receiverAccount) - context.insert(receiverAddress) - var bytes = Data([2, 0, 0, 0, 1]) - bytes.append(senderId) - bytes.append(contentsOf: [0, 0, 0, 0, 0, 255, 255, 255, 255, 1]) - withUnsafeBytes(of: UInt64(40).littleEndian) { bytes.append(contentsOf: $0) } - bytes.append(contentsOf: [25, 0x76, 0xa9, 0x14] + [UInt8](repeating: 5, count: 20) + [0x88, 0xac]) - bytes.append(contentsOf: [0, 0, 0, 0]) - let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: bytes, direction: CoreDirectionCode.outgoing, netAmount: -100 - ) - let coin = input(100) - coin.spendingTransaction = spender - context.insert(coin) - context.insert(spender) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) - XCTAssertEqual(row.netAmount, -100, "the receiving wallet's credit is not the sender's") - XCTAssertEqual(row.netAmount(for: senderId), -100) + XCTAssertFalse(history.errored) + XCTAssertNil(history.rows) + XCTAssertEqual(history.count, 0) } - func testShouldNotCountAnotherLocalWalletsLinkedOutputForTheSender() throws { - let container = try DashModelContainer.createInMemory() - let tx = try localTransfer(in: container.mainContext) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()) - .first { $0.txid == tx.txid }) - XCTAssertEqual(row.netAmount, -100, "the receiving wallet's linked credit is not the sender's") - XCTAssertEqual(row.direction, CoreDirectionCode.outgoing) - XCTAssertEqual(row.netAmount(for: Data(repeating: 1, count: 32)), -100) - XCTAssertEqual(row.netAmount(for: Data(repeating: 2, count: 32)), 90) - } + /// Rust needs the whole history to resolve a flagged spend's inputs, so + /// every transaction crosses, flagged by whether it lacks a row. + func testShouldOfferTheWholeHistoryFlaggingTransactionsWithoutARow() throws { + let (_, handler) = try makeStore() + let other = Data(repeating: 4, count: 32) + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + persist(handler, walletId: walletA, txid: other, bytes: Data([7, 8, 9]), net: -40) + persist(handler, walletId: walletB, txid: Data(repeating: 5, count: 32), bytes: Data([5]), net: 1) + account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) - func testShouldExcludePersistedContactOutputsFromOwnedAccounting() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - let wallet = PersistentWallet(walletId: walletId, network: .testnet) - let contactAccount = PersistentAccount( - wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, - accountIndex: 0, accountTypeName: "DashPay External Account" - ) - context.insert(wallet) - context.insert(contactAccount) - let coin = input(100) - let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: CoreDirectionCode.internalTransfer, netAmount: -60 - ) - coin.spendingTransaction = spender - let contactOutput = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) - contactOutput.walletId = walletId - contactOutput.account = contactAccount - context.insert(coin) - context.insert(spender) - context.insert(contactOutput) - try context.save() - XCTAssertEqual(spender.netAmount(for: walletId), -100) - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) - XCTAssertEqual(row.netAmount, -100) - XCTAssertEqual(row.direction, 1) - XCTAssertEqual(row.netAmount(for: walletId), -100) - } + let history = handler.loadCoreHistoryForAccounting(walletId: walletA) - func testShouldNotTreatPersistedContactInputAsOurFunding() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - let wallet = PersistentWallet(walletId: walletId, network: .testnet) - let contactAccount = PersistentAccount( - wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, - accountIndex: 0, accountTypeName: "DashPay External Account" - ) - context.insert(wallet) - context.insert(contactAccount) - let coin = input(100) - coin.account = contactAccount - let spender = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: serializedSpend(inputs: [walletId]), - direction: CoreDirectionCode.incoming, netAmount: 40 - ) - coin.spendingTransaction = spender - let received = PersistentTxo(transaction: spender, vout: 0, amount: 40, address: "", height: 1) - received.walletId = walletId - context.insert(coin) - context.insert(spender) - context.insert(received) - try context.save() - let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) - XCTAssertFalse(handler.loadWalletList().errored) - let row = try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == spender.txid }) - XCTAssertEqual(row.netAmount, 40) - XCTAssertEqual(row.direction, 0) - XCTAssertEqual(row.netAmount(for: walletId), 40) + XCTAssertFalse(history.errored) + let rows = try XCTUnwrap(history.rows) + let offered = (0.. (Data, Bool) in + let entry = rows[index] + return (Data(bytes: entry.tx_bytes!, count: Int(entry.tx_bytes_len)), entry.needs_accounting) + } + XCTAssertEqual(offered.count, 2, "only wallet A's transactions") + XCTAssertEqual(offered.first { $0.0 == Data([1, 2]) }?.1, false) + XCTAssertEqual(offered.first { $0.0 == Data([7, 8, 9]) }?.1, true) + handler.loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer(rows)) } - func testShouldReportReceivedAmountDespiteContactOutputWhenStoredAmountAgrees() throws { - let container = try DashModelContainer.createInMemory() - let context = container.mainContext - let walletId = Data(repeating: 1, count: 32) - let wallet = PersistentWallet(walletId: walletId, network: .testnet) - let contactAccount = PersistentAccount( - wallet: wallet, accountType: PlatformWalletPersistenceHandler.dashpayExternalAccountTypeTag, - accountIndex: 0, accountTypeName: "DashPay External Account" - ) - context.insert(wallet) - context.insert(contactAccount) - let payment = PersistentTransaction( - txid: Data(repeating: 3, count: 32), transactionData: Data(), - direction: CoreDirectionCode.incoming, netAmount: 40 - ) - let received = PersistentTxo(transaction: payment, vout: 0, amount: 40, address: "", height: 1) - received.walletId = walletId - let contactOutput = PersistentTxo(transaction: payment, vout: 1, amount: 60, address: "", height: 1) - contactOutput.walletId = walletId - contactOutput.account = contactAccount - context.insert(payment) - context.insert(received) - context.insert(contactOutput) - // The foreign input: every prevout with no local TXO is recorded as pending. - context.insert(PersistentPendingInput( - outpoint: Data(repeating: 9, count: 36), inputIndex: 0, - spendingTxid: payment.txid, spendingTransaction: payment, walletId: walletId - )) - try context.save() - XCTAssertEqual( - payment.netAmount(for: walletId), 40, "a contact's output does not make the received amount unknown" - ) - // A stored amount the wallet's own outputs do not confirm may hide an unlinked coin of ours. - payment.netAmount = -60 - XCTAssertNil(payment.netAmount(for: walletId)) + func testShouldDeleteOnlyTheDepartingWalletsRows() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: -100) + persist(handler, walletId: walletB, txid: txid, bytes: Data([1]), net: 90) + account(handler, walletId: walletA, [row(txid, net: -100, direction: CoreDirectionCode.outgoing)]) + account(handler, walletId: walletB, [row(txid, net: 90, direction: CoreDirectionCode.incoming)]) + + try handler.deleteWalletData(walletId: walletA) + + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.map(\.walletId), [walletB]) + XCTAssertEqual(rows.first?.netAmount, 90) } + func testShouldFormatSignedDuffsWithoutTrappingOnInt64Min() { + XCTAssertEqual(PersistentTransaction.format(duffs: 150_000_000), "+1.50000000 DASH") + XCTAssertEqual(PersistentTransaction.format(duffs: -100), "-0.00000100 DASH") + XCTAssertTrue(PersistentTransaction.format(duffs: .min).hasPrefix("-92233720368.")) + } } From aa086b48d2bf766faba5dbebd80efaee72e7dcbf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:48:30 +0000 Subject: [PATCH 42/84] fix(swift-sdk): log the live schema version as the migration target `makeContainer`, `logMigrationFailure`, the new-store route and `logRoute` still logged `target_version` "4.0.0" after the live schema moved to V5, so `DashModelMigrationTests` (which expect "5.0.0") failed and production logs misnamed every V5 migration. All five sites now use `targetVersion`, which is derived from `LiveSchema`, the same type that builds `schema`. The tests also assert that no "4.0.0" target is logged. Not built here: no Swift toolchain on this host. Co-Authored-By: Claude Opus 5.5 --- .../Persistence/DashModelContainer.swift | 22 ++++++++++++++----- .../DashModelMigrationTests.swift | 2 ++ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index d21ed19427f..de65f8c5466 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -99,9 +99,19 @@ public enum DashModelContainer { ] } + /// The live versioned schema. `schema` and every diagnostic's + /// `target_version` derive from it, so a schema bump is one edit. + typealias LiveSchema = DashSchemaV5 + /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV5.self) + Schema(versionedSchema: LiveSchema.self) + } + + /// `LiveSchema`'s version as logged in `target_version` (`major.minor.patch`). + static var targetVersion: String { + let version = LiveSchema.versionIdentifier + return "\(version.major).\(version.minor).\(version.patch)" } /// Create a persistent model container for storing data. @@ -173,7 +183,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText(targetVersion)]) do { let container: ModelContainer if bridgeLegacyStore { @@ -186,7 +196,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText(targetVersion)]) return container } catch { logMigrationFailure(error) @@ -202,7 +212,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("4.0.0")]) + "target_version": .publicText(targetVersion)]) } /// Select by the complete stored model identity, after journal recovery. @@ -219,7 +229,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText(targetVersion)]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -237,7 +247,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("4.0.0")]) + "route": .publicText(route), "target_version": .publicText(targetVersion)]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 0351eeb6143..0b5d6272d1e 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -79,6 +79,7 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) XCTAssertTrue(log.contains("target_version=\"5.0.0\"")) + XCTAssertFalse(log.contains("target_version=\"4.0.0\""), "every diagnostic names the live schema") XCTAssertTrue(log.contains("route=\"labelled-current-v5\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) @@ -94,6 +95,7 @@ final class DashModelMigrationTests: XCTestCase { SDKLogger.flush() let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) + XCTAssertFalse(updatedLog.contains("target_version=\"4.0.0\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) XCTAssertTrue(failure.contains("target_version=\"5.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) From 98db33e8ed79b53ba0353c8ef44a07b53c5c0f51 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:14:10 +0000 Subject: [PATCH 43/84] fix(platform-wallet): make the accounting backfill converge and match live rows - Compute the fee once in the shared accounting rule (only when the wallet funded every input), so the load-time backfill writes exactly the rows the live projection emits; tests assert whole-row equality for payments, no-change asset locks, CoinJoin and unspendable outputs. - Classify address-less, provably unspendable outputs like key-wallet's `Unspendable` role in the backfill. - Replace the "empty or ALL history" host obligation: the host returns only the wallet's unaccounted transactions (any subset is safe) or reports the wallet backfilled; Rust fetches the funding transactions it needs through `get_core_tx_record`. - `store_history_accounting` persists the rows and a per-wallet completion marker atomically, so later loads skip the read. The FFI persister brackets it in its own serialized begin/end round (new `on_persist_core_history_accounted_fn` slot, appended and size-gated; `on_load_core_history_for_accounting_fn` gains `out_backfilled`). - Use top-of-file imports in the new code. Co-Authored-By: Claude Opus 5.5 --- .../src/core_wallet_types.rs | 21 +- .../rs-platform-wallet-ffi/src/manager.rs | 81 +++- .../rs-platform-wallet-ffi/src/persistence.rs | 388 ++++++++++------ .../rs-platform-wallet/src/changeset/mod.rs | 4 +- .../src/changeset/traits.rs | 47 +- .../src/changeset/wallet_accounting.rs | 424 ++++++++++++------ .../rs-platform-wallet/src/manager/load.rs | 275 +++++++++--- 7 files changed, 882 insertions(+), 358 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index be4896c895a..497fbf0fb90 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -1,5 +1,7 @@ //! C-compatible types for core wallet changeset FFI. +use key_wallet::managed_account::transaction_record::TransactionDirection; +use platform_wallet::changeset::WalletAccounting; use platform_wallet::masternode::{provider_payload_fields, MasternodeRecord}; use std::os::raw::c_char; @@ -385,17 +387,15 @@ pub struct TransactionAccountingFFI { pub has_fee: bool, } -/// One stored Core transaction the host hands back for the load-time -/// accounting backfill (see `on_load_core_history_for_accounting_fn`). -/// Host-owned until the matching free callback. +/// One stored Core transaction without this wallet's accounting row, handed +/// back for the load-time backfill (see +/// `on_load_core_history_for_accounting_fn`). Host-owned until the matching +/// free callback. #[repr(C)] pub struct CoreHistoryTransactionFFI { /// Consensus-encoded transaction body. pub tx_bytes: *const u8, pub tx_bytes_len: usize, - /// The host holds no `TransactionAccountingFFI` row for this - /// transaction and wallet yet. - pub needs_accounting: bool, } // --------------------------------------------------------------------------- @@ -1567,10 +1567,7 @@ pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING: u32 = 1; pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_INTERNAL: u32 = 2; pub const PLATFORM_WALLET_TRANSACTION_DIRECTION_COIN_JOIN: u32 = 3; -fn direction_to_u32( - direction: key_wallet::managed_account::transaction_record::TransactionDirection, -) -> u32 { - use key_wallet::managed_account::transaction_record::TransactionDirection; +fn direction_to_u32(direction: TransactionDirection) -> u32 { match direction { TransactionDirection::Incoming => PLATFORM_WALLET_TRANSACTION_DIRECTION_INCOMING, TransactionDirection::Outgoing => PLATFORM_WALLET_TRANSACTION_DIRECTION_OUTGOING, @@ -1579,8 +1576,8 @@ fn direction_to_u32( } } -impl From<&platform_wallet::changeset::WalletAccounting> for TransactionAccountingFFI { - fn from(row: &platform_wallet::changeset::WalletAccounting) -> Self { +impl From<&WalletAccounting> for TransactionAccountingFFI { + fn from(row: &WalletAccounting) -> Self { let mut txid = [0u8; 32]; txid.copy_from_slice(row.txid.as_ref()); Self { diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index e5767267301..4e1424113cc 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -10,11 +10,12 @@ use crate::handle::*; use crate::persistence::{ FFIPersister, FreeCoreHistoryForAccountingFn, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, LoadCoreHistoryForAccountingFn, LoadIdentityBalanceBlockTimeFn, - LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, - PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistTransactionAccountingFn, - PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, - PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistCoreHistoryAccountedFn, + PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, + PersistTransactionAccountingFn, PersistWalletChangesetChainLockHeightFn, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, + PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -303,6 +304,10 @@ unsafe fn persistence_extension_callbacks( on_load_core_history_for_accounting_free_fn, FreeCoreHistoryForAccountingFn ), + persist_core_history_accounted: slot!( + on_persist_core_history_accounted_fn, + PersistCoreHistoryAccountedFn + ), } } @@ -1425,6 +1430,72 @@ mod tests { assert!(parsed.load_asset_lock_operation_id.is_none()); } + /// Each transaction-accounting slot is read only when the host's + /// `struct_size` covers it, so a host built before any of them simply + /// never has them read. + #[test] + fn should_size_gate_transaction_accounting_slots_independently() { + use crate::core_wallet_types::{CoreHistoryTransactionFFI, TransactionAccountingFFI}; + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const TransactionAccountingFFI, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut bool, + _: *mut *const CoreHistoryTransactionFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const CoreHistoryTransactionFFI, _: usize) {} + unsafe extern "C" fn accounted(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + let mut ext = PersistenceCallbacksExtension { + on_persist_transaction_accounting_fn: Some(persist), + on_load_core_history_for_accounting_fn: Some(load), + on_load_core_history_for_accounting_free_fn: Some(free), + on_persist_core_history_accounted_fn: Some(accounted), + ..Default::default() + }; + let present = |ext: &PersistenceCallbacksExtension| { + let parsed = unsafe { persistence_extension_callbacks(ext) }; + [ + parsed.persist_transaction_accounting.is_some(), + parsed.load_core_history_for_accounting.is_some(), + parsed.load_core_history_for_accounting_free.is_some(), + parsed.persist_core_history_accounted.is_some(), + ] + }; + assert_eq!(present(&ext), [true; 4]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_history_accounted_fn + ); + assert_eq!(present(&ext), [true, true, true, false]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_free_fn + ); + assert_eq!(present(&ext), [true, true, false, false]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_history_for_accounting_fn + ); + assert_eq!(present(&ext), [true, false, false, false]); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_transaction_accounting_fn + ); + assert_eq!(present(&ext), [false; 4]); + } + /// A host whose `struct_size` stops right after the chainlock-height /// slot (built before the credit-verdict slot existed) keeps every /// earlier slot and simply never has the verdict slot read; a host diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 1846eaa2b23..e0c2f585202 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -35,8 +35,7 @@ use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, StoredCoreTransaction, WalletAccounting, - PERSISTENCE_CAPABILITIES_VERSION, + ProviderKeyExtendedPubKey, WalletAccounting, PERSISTENCE_CAPABILITIES_VERSION, }; use platform_wallet::wallet::platform_wallet::WalletId; #[cfg(feature = "shielded")] @@ -323,13 +322,12 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( ) -> i32; /// Carries one wallet's accounting rows (see [`TransactionAccountingFFI`]). -/// The host upserts each keyed by `(txid, wallet_id)`. Fired inside a round -/// right after `on_persist_wallet_changeset_fn`, so the transaction rows the -/// accounting belongs to already exist, and only when the round has a -/// record with accounting evidence. Also fired OUTSIDE any round, once per -/// wallet at load, with the backfill computed from -/// `on_load_core_history_for_accounting_fn`; the host then commits on its -/// own. A non-zero return fails the round like any other per-kind callback. +/// The host upserts each keyed by `(txid, wallet_id)`. Always fired inside a +/// begin/end round: right after `on_persist_wallet_changeset_fn`, so the +/// transaction rows the accounting belongs to already exist, when the round +/// has a record with accounting evidence; and in the load-time backfill's +/// own round, before [`PersistCoreHistoryAccountedFn`]. A non-zero return +/// fails the round like any other per-kind callback. pub type PersistTransactionAccountingFn = unsafe extern "C" fn( context: *mut c_void, wallet_id: *const u8, @@ -337,14 +335,18 @@ pub type PersistTransactionAccountingFn = unsafe extern "C" fn( rows_count: usize, ) -> i32; -/// Return the wallet's stored Core transactions for the accounting backfill: -/// an EMPTY array when every one already has an accounting row for this -/// wallet, otherwise ALL of them with `needs_accounting` set on the ones -/// missing it (the others resolve the flagged ones' inputs). The host keeps -/// the array valid until [`FreeCoreHistoryForAccountingFn`]. +/// The load-time accounting backfill's read. Sets `*out_backfilled = true` +/// (and returns no rows) once [`PersistCoreHistoryAccountedFn`] has been +/// stored for this wallet. Otherwise returns the wallet's stored +/// transactions that have no accounting row for it; any subset is safe, +/// since Rust fetches the funding transactions it needs itself +/// (`on_get_core_tx_record_fn`) and a transaction left out only stays +/// without a row. The host keeps the array valid until +/// [`FreeCoreHistoryForAccountingFn`]. pub type LoadCoreHistoryForAccountingFn = unsafe extern "C" fn( context: *mut c_void, wallet_id: *const u8, + out_backfilled: *mut bool, out_rows: *mut *const CoreHistoryTransactionFFI, out_count: *mut usize, ) -> i32; @@ -356,6 +358,12 @@ pub type FreeCoreHistoryForAccountingFn = unsafe extern "C" fn( count: usize, ); +/// Record that this wallet's history backfill is complete, so the next +/// [`LoadCoreHistoryForAccountingFn`] reports it backfilled. Fired in the +/// backfill's round after its rows, and committed with them. +pub type PersistCoreHistoryAccountedFn = + unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8) -> i32; + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -535,11 +543,12 @@ pub struct PersistenceCallbacksExtension { >, /// Stored history for the load-time accounting backfill (see /// [`LoadCoreHistoryForAccountingFn`]). Read only together with the free - /// slot below and the persist slot above. + /// slot below, the persist slot above and the completion slot. pub on_load_core_history_for_accounting_fn: Option< unsafe extern "C" fn( context: *mut c_void, wallet_id: *const u8, + out_backfilled: *mut bool, out_rows: *mut *const CoreHistoryTransactionFFI, out_count: *mut usize, ) -> i32, @@ -551,6 +560,10 @@ pub struct PersistenceCallbacksExtension { count: usize, ), >, + /// Backfill completion marker (see [`PersistCoreHistoryAccountedFn`]). + /// The backfill runs only when all four accounting slots are present. + pub on_persist_core_history_accounted_fn: + Option i32>, } impl Default for PersistenceCallbacksExtension { @@ -574,6 +587,7 @@ impl Default for PersistenceCallbacksExtension { on_persist_transaction_accounting_fn: None, on_load_core_history_for_accounting_fn: None, on_load_core_history_for_accounting_free_fn: None, + on_persist_core_history_accounted_fn: None, } } } @@ -598,6 +612,7 @@ pub struct PersistenceExtensionCallbacks { pub persist_transaction_accounting: Option, pub load_core_history_for_accounting: Option, pub load_core_history_for_accounting_free: Option, + pub persist_core_history_accounted: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1543,10 +1558,11 @@ pub struct FFIPersister { /// the load-time backfill. `None` keeps a host on `TransactionRecordFFI`'s /// values, as before the slot existed. persist_transaction_accounting_callback: Option, - /// Stored-history read for the backfill; used only when the free slot is - /// wired too. + /// The load-time backfill's read, free and completion slots; the + /// backfill runs only when all of them and the persist slot are wired. load_core_history_for_accounting_callback: Option, load_core_history_for_accounting_free_callback: Option, + persist_core_history_accounted_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1676,6 +1692,7 @@ impl FFIPersister { load_core_history_for_accounting_callback: extensions.load_core_history_for_accounting, load_core_history_for_accounting_free_callback: extensions .load_core_history_for_accounting_free, + persist_core_history_accounted_callback: extensions.persist_core_history_accounted, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), } @@ -1912,21 +1929,25 @@ impl PlatformWalletPersistence for FFIPersister { fn load_core_history_for_accounting( &self, wallet_id: WalletId, - ) -> Result, PersistenceError> { - // Rows nobody could persist are not worth reading. - let (Some(load), Some(free), Some(_)) = ( + ) -> Result>, PersistenceError> { + // A backfill that could not be stored or completed is not worth + // reading. + let (Some(load), Some(free), Some(_), Some(_)) = ( self.load_core_history_for_accounting_callback, self.load_core_history_for_accounting_free_callback, self.persist_transaction_accounting_callback, + self.persist_core_history_accounted_callback, ) else { - return Ok(Vec::new()); + return Ok(None); }; + let mut backfilled = false; let mut rows_ptr: *const CoreHistoryTransactionFFI = std::ptr::null(); let mut count: usize = 0; let rc = unsafe { load( self.callbacks.context, wallet_id.as_ptr(), + &mut backfilled, &mut rows_ptr, &mut count, ) @@ -1943,12 +1964,8 @@ impl PlatformWalletPersistence for FFIPersister { for row in rows { let bytes = unsafe { slice_from_raw(row.tx_bytes, row.tx_bytes_len) }; match dashcore::consensus::encode::deserialize::(bytes) { - Ok(transaction) => out.push(StoredCoreTransaction { - transaction, - needs_accounting: row.needs_accounting, - }), - // One undecodable row only loses its own accounting (or a - // prevout another row would have resolved). + Ok(transaction) => out.push(transaction), + // One undecodable row only goes without accounting. Err(e) => tracing::warn!( error = %e, "load: skipping an undecodable stored transaction in the accounting backfill" @@ -1957,37 +1974,84 @@ impl PlatformWalletPersistence for FFIPersister { } } unsafe { free(self.callbacks.context, rows_ptr, count) }; - Ok(out) + Ok((!backfilled).then_some(out)) } - fn store_transaction_accounting( + /// The backfill's rows and its completion marker, as one round under + /// `round_lock`: they commit together or not at all, and never land in + /// another wallet's open round. + fn store_history_accounting( &self, wallet_id: WalletId, rows: &[WalletAccounting], ) -> Result<(), PersistenceError> { - let Some(cb) = self.persist_transaction_accounting_callback else { + let (Some(persist_rows), Some(accounted)) = ( + self.persist_transaction_accounting_callback, + self.persist_core_history_accounted_callback, + ) else { return Ok(()); }; - if rows.is_empty() { - return Ok(()); + let mut round = self.round_lock.lock(); + round.begin_round()?; + if let Some(cb) = self.callbacks.on_changeset_begin_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + let _ = round.end_round(); + return Err(persist_callback_error( + result, + format!( + "changeset-begin callback returned error code {result}; \ + accounting backfill aborted before any write" + ), + )); + } } - let rows: Vec = - rows.iter().map(TransactionAccountingFFI::from).collect(); - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - rows.as_ptr(), - rows.len(), - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_persist_transaction_accounting_fn returned error code {rc}"), - )); + let mut outcome = RoundOutcome::default(); + if !rows.is_empty() { + let rows: Vec = + rows.iter().map(TransactionAccountingFFI::from).collect(); + let result = unsafe { + persist_rows( + self.callbacks.context, + wallet_id.as_ptr(), + rows.as_ptr(), + rows.len(), + ) + }; + if result != 0 { + outcome.record(result); + } + } + if outcome.is_success() { + let result = unsafe { accounted(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + outcome.record(result); + } + } + if let Some(cb) = self.callbacks.on_changeset_end_fn { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + outcome.is_success(), + ) + }; + if result != 0 { + if outcome.is_success() { + outcome.record(result); + } else { + outcome.record_fatal(); + } + } + } + round.end_round()?; + match outcome.failure_kind() { + Some(kind) => Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(kind), + "the accounting backfill round failed and was rolled back", + )), + None => Ok(()), } - Ok(()) } fn load_tracked_masternodes( @@ -10091,35 +10155,44 @@ mod tests { ); } - /// The load-time backfill reads the host's stored history through the - /// load/free pair, decoding each body and keeping its flag (an - /// undecodable body is skipped, not fatal), and hands the computed rows - /// back through the persist slot. Without the persist slot nothing is - /// read: rows nobody can store are not worth computing. + /// The load-time backfill reads the host's unaccounted transactions + /// through the load/free pair (an undecodable body is skipped, not + /// fatal) until the host reports the wallet backfilled. It then writes + /// the rows and the completion marker as ONE round: begin, rows, marker, + /// end. A failing row write rolls the round back with no marker, so the + /// backfill runs again. Without every slot nothing is read. #[test] - fn should_read_stored_history_and_persist_backfilled_accounting_through_the_extension() { + fn should_backfill_accounting_through_the_extension_in_one_round() { use key_wallet::managed_account::transaction_record::TransactionDirection; use platform_wallet::test_support::core_fixtures::{ foreign_address, outside, pay, spend_of, }; struct Host { - bodies: Vec>, + backfilled: bool, rows: Vec, + fail_rows: bool, loads: std::sync::Mutex, frees: std::sync::Mutex, - persisted: std::sync::Mutex>, + events: std::sync::Mutex>, + } + unsafe fn host<'a>(ctx: *mut c_void) -> &'a Host { + &*(ctx as *const Host) } unsafe extern "C" fn load( ctx: *mut c_void, _wallet_id: *const u8, + out_backfilled: *mut bool, out_rows: *mut *const CoreHistoryTransactionFFI, out_count: *mut usize, ) -> i32 { - let host = &*(ctx as *const Host); + let host = host(ctx); *host.loads.lock().unwrap() += 1; - *out_rows = host.rows.as_ptr(); - *out_count = host.rows.len(); + *out_backfilled = host.backfilled; + if !host.backfilled { + *out_rows = host.rows.as_ptr(); + *out_count = host.rows.len(); + } 0 } unsafe extern "C" fn free( @@ -10127,8 +10200,7 @@ mod tests { _rows: *const CoreHistoryTransactionFFI, _count: usize, ) { - let host = &*(ctx as *const Host); - *host.frees.lock().unwrap() += 1; + *host(ctx).frees.lock().unwrap() += 1; } unsafe extern "C" fn persist( ctx: *mut c_void, @@ -10136,101 +10208,153 @@ mod tests { rows: *const TransactionAccountingFFI, rows_count: usize, ) -> i32 { - let host = &*(ctx as *const Host); - host.persisted + let host = host(ctx); + for row in slice::from_raw_parts(rows, rows_count) { + host.events.lock().unwrap().push(format!( + "row net={} direction={} fee={}", + row.net_amount, row.direction, row.fee + )); + } + i32::from(host.fail_rows) + } + unsafe extern "C" fn accounted(ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + host(ctx).events.lock().unwrap().push("accounted".into()); + 0 + } + unsafe extern "C" fn begin(ctx: *mut c_void, _wallet_id: *const u8) -> i32 { + host(ctx).events.lock().unwrap().push("begin".into()); + 0 + } + unsafe extern "C" fn end(ctx: *mut c_void, _wallet_id: *const u8, success: bool) -> i32 { + host(ctx) + .events .lock() .unwrap() - .extend_from_slice(slice::from_raw_parts(rows, rows_count)); + .push(format!("end success={success}")); 0 } let first = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); let second = spend_of(&[outside(3)], vec![pay(7_000, &foreign_address(4))]); - let mut host = Host { - bodies: vec![ - dashcore::consensus::encode::serialize(&first), - vec![0xFF], - dashcore::consensus::encode::serialize(&second), - ], - rows: Vec::new(), + let bodies = [ + dashcore::consensus::encode::serialize(&first), + vec![0xFF], + dashcore::consensus::encode::serialize(&second), + ]; + let make_host = |backfilled: bool, fail_rows: bool| Host { + backfilled, + rows: bodies + .iter() + .map(|body| CoreHistoryTransactionFFI { + tx_bytes: body.as_ptr(), + tx_bytes_len: body.len(), + }) + .collect(), + fail_rows, loads: std::sync::Mutex::new(0), frees: std::sync::Mutex::new(0), - persisted: std::sync::Mutex::new(Vec::new()), + events: std::sync::Mutex::new(Vec::new()), }; - host.rows = host - .bodies - .iter() - .zip([true, true, false]) - .map(|(body, needs_accounting)| CoreHistoryTransactionFFI { - tx_bytes: body.as_ptr(), - tx_bytes_len: body.len(), - needs_accounting, - }) - .collect(); - let context = &host as *const Host as *mut c_void; let extensions = PersistenceExtensionCallbacks { persist_transaction_accounting: Some(persist), load_core_history_for_accounting: Some(load), load_core_history_for_accounting_free: Some(free), + persist_core_history_accounted: Some(accounted), ..Default::default() }; - - let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( - PersistenceCallbacks { - context, - ..PersistenceCallbacks::default() - }, - PersistenceCapabilities::NONE, - extensions, - ); - let history = persister - .load_core_history_for_accounting([1; 32]) - .expect("history read must succeed"); - assert_eq!( - history - .iter() - .map(|stored| (stored.transaction.txid(), stored.needs_accounting)) - .collect::>(), - vec![(first.txid(), true), (second.txid(), false)] - ); - assert_eq!(*host.frees.lock().unwrap(), 1, "the host array is returned"); - + let persister_for = |host: &Host, extensions: PersistenceExtensionCallbacks| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: host as *const Host as *mut c_void, + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + extensions, + ) + }; let row = WalletAccounting { txid: first.txid(), net_amount: -5_000, direction: TransactionDirection::Outgoing, fee: Some(250), }; - persister - .store_transaction_accounting([1; 32], &[row]) - .expect("accounting persist must succeed"); + + // Not yet backfilled: the decodable bodies come back, the loan is returned. + let host = make_host(false, false); + let persister = persister_for(&host, extensions); + let unaccounted = persister + .load_core_history_for_accounting([1; 32]) + .expect("history read must succeed") + .expect("not backfilled yet"); assert_eq!( - host.persisted.lock().unwrap().clone(), - vec![TransactionAccountingFFI::from(&row)] + unaccounted.iter().map(|tx| tx.txid()).collect::>(), + vec![first.txid(), second.txid()] ); - let persisted = host.persisted.lock().unwrap()[0]; + assert_eq!(*host.frees.lock().unwrap(), 1); + + // Rows and marker commit in one round. + persister + .store_history_accounting([1; 32], &[row]) + .expect("backfill round must succeed"); assert_eq!( - (persisted.direction, persisted.fee, persisted.has_fee), - (1, 250, true) + *host.events.lock().unwrap(), + vec![ + "begin", + "row net=-5000 direction=1 fee=250", + "accounted", + "end success=true" + ] ); drop(persister); - let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( - PersistenceCallbacks { - context, - ..PersistenceCallbacks::default() - }, - PersistenceCapabilities::NONE, - PersistenceExtensionCallbacks { - persist_transaction_accounting: None, - ..extensions - }, + // Nothing to write still records completion. + let host = make_host(false, false); + persister_for(&host, extensions) + .store_history_accounting([1; 32], &[]) + .expect("empty backfill round must succeed"); + assert_eq!( + *host.events.lock().unwrap(), + vec!["begin", "accounted", "end success=true"] ); - assert!(persister - .load_core_history_for_accounting([1; 32]) - .expect("a host that cannot store rows is not read") - .is_empty()); - assert_eq!(*host.loads.lock().unwrap(), 1); + + // A failing row write rolls back, with no marker. + let host = make_host(false, true); + assert!(persister_for(&host, extensions) + .store_history_accounting([1; 32], &[row]) + .is_err()); + assert_eq!( + *host.events.lock().unwrap(), + vec![ + "begin", + "row net=-5000 direction=1 fee=250", + "end success=false" + ] + ); + + // Backfilled: nothing to do. + let host = make_host(true, false); + assert_eq!( + persister_for(&host, extensions) + .load_core_history_for_accounting([1; 32]) + .expect("history read must succeed"), + None + ); + + // Without the completion slot the host is never read. + let host = make_host(false, false); + let without_marker = PersistenceExtensionCallbacks { + persist_core_history_accounted: None, + ..extensions + }; + assert_eq!( + persister_for(&host, without_marker) + .load_core_history_for_accounting([1; 32]) + .expect("a host that cannot complete a backfill is not read"), + None + ); + assert_eq!(*host.loads.lock().unwrap(), 0); } #[test] @@ -10762,6 +10886,16 @@ mod tests { PersistenceCallbacksExtension, on_load_core_history_for_accounting_free_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_history_accounted_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_history_accounted_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index cb9fef3d027..6fcebc19ca3 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -70,6 +70,6 @@ pub use traits::{ PlatformWalletPersistence, }; pub use wallet_accounting::{ - history_accounting, is_owned, record_accounting, wallet_accounting, wallet_direction, - StoredCoreTransaction, WalletAccounting, + funding_txids, history_accounting, is_owned, record_accounting, wallet_accounting, + wallet_direction, WalletAccounting, }; diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 9960e0d52dd..a7867d89510 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -8,9 +8,9 @@ use std::error::Error as StdError; use crate::changeset::changeset::{DpnsNameStateEntry, PlatformWalletChangeSet}; use crate::changeset::client_start_state::ClientStartState; use crate::changeset::persistence_capabilities::PersistenceCapabilities; -use crate::changeset::wallet_accounting::{StoredCoreTransaction, WalletAccounting}; +use crate::changeset::wallet_accounting::WalletAccounting; use crate::wallet::platform_wallet::WalletId; -use dashcore::Txid; +use dashcore::{Transaction, Txid}; use dpp::prelude::Identifier; use key_wallet::managed_account::transaction_record::TransactionRecord; @@ -529,32 +529,33 @@ pub trait PlatformWalletPersistence: Send + Sync { Ok(None) } - /// The wallet's persisted Core transaction history, each entry flagged - /// when the backend holds no [`WalletAccounting`] for it yet. - /// - /// Read once per wallet at load: when any entry is flagged, the manager - /// computes the missing rows with - /// [`history_accounting`](crate::changeset::history_accounting) and - /// hands them to [`Self::store_transaction_accounting`]. A backend - /// returns an EMPTY vector when nothing is flagged, so a backfilled - /// store costs one cheap read per launch. When something is flagged it - /// must return the whole history, flagged or not, because a flagged - /// spend resolves its owned inputs from the outputs of the others. - /// - /// The default (no history) suits backends that never stored - /// accounting-less rows, such as the SQLite store, whose own migration - /// repairs history. + /// The wallet's stored Core transactions that have no + /// [`WalletAccounting`] yet, or `None` once this wallet's history + /// backfill has completed (see [`Self::store_history_accounting`]). + /// + /// Read once per wallet at load. The manager computes the missing rows + /// with [`history_accounting`](crate::changeset::history_accounting), + /// fetching the funding transactions it needs through + /// [`Self::get_core_tx_record`] itself, and hands them back through + /// [`Self::store_history_accounting`]. Any subset is safe: a transaction + /// left out only stays without a row. + /// + /// The default (`None`) suits backends that never stored rows without + /// accounting, such as the SQLite store, whose own migration repairs + /// history. fn load_core_history_for_accounting( &self, _wallet_id: WalletId, - ) -> Result, PersistenceError> { - Ok(Vec::new()) + ) -> Result>, PersistenceError> { + Ok(None) } - /// Persist per-wallet accounting computed outside a changeset round (the - /// load-time backfill, see [`Self::load_core_history_for_accounting`]). - /// Upsert keyed by `(txid, wallet_id)`. The default drops them. - fn store_transaction_accounting( + /// Persist the backfilled rows (upsert keyed by `(txid, wallet_id)`) + /// and record that this wallet's history backfill is complete, as one + /// atomic write; `rows` may be empty. After it, + /// [`Self::load_core_history_for_accounting`] returns `None` for the + /// wallet. The default drops both. + fn store_history_accounting( &self, _wallet_id: WalletId, _rows: &[WalletAccounting], diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 9feb6a67283..7f54b88215b 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -21,8 +21,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use dashcore::{OutPoint, ScriptBuf, Transaction, Txid}; +use dashcore::{Address, OutPoint, ScriptBuf, Transaction, Txid}; use key_wallet::managed_account::managed_account_ref::ManagedAccountRef; +use key_wallet::managed_account::managed_core_funds_account::ManagedCoreFundsAccount; use key_wallet::managed_account::transaction_record::{ OutputRole, TransactionDirection, TransactionRecord, }; @@ -37,21 +38,11 @@ pub struct WalletAccounting { /// `Σ owned outputs − Σ owned inputs` in duffs, saturated to `i64`. pub net_amount: i64, pub direction: TransactionDirection, - /// The transaction fee, when the wallet funded every input. + /// The transaction fee, when the wallet funded every input; otherwise + /// the wallet cannot know it. pub fee: Option, } -/// One persisted Core transaction a host hands back for -/// [`history_accounting`]. -#[derive(Debug, Clone)] -pub struct StoredCoreTransaction { - pub transaction: Transaction, - /// The host holds no [`WalletAccounting`] for this transaction and - /// wallet yet. Transactions without the flag still resolve the - /// prevouts of the flagged ones. - pub needs_accounting: bool, -} - /// Classify a transaction from the wallet's point of view. /// /// - `spends_ours`: at least one input spends a wallet-owned output. @@ -108,59 +99,71 @@ pub fn record_accounting(record: &TransactionRecord) -> Option if record.input_details.is_empty() && record.output_details.is_empty() { return None; } - let (net, direction) = wallet_accounting(record); + let accounting = record_parts(record); Some(WalletAccounting { txid: record.txid, - net_amount: saturating_net(net), - direction, - fee: record.fee, + net_amount: saturating_net(accounting.net), + direction: accounting.direction, + fee: accounting.fee, }) } -/// Accounting for each flagged transaction of a host's stored history, from -/// the wallet's own view of which scripts it owns. -/// -/// Rows a host stored before it kept per-wallet accounting carry no -/// trustworthy amount, and the restored wallet holds no records to re-emit. -/// So the host hands back the raw history and this recomputes what the live -/// projection would have emitted: +/// The txids whose transactions [`history_accounting`] needs, beyond +/// `unaccounted` itself, to resolve the inputs of `unaccounted`: every +/// prevout that is neither one of those transactions' outputs nor one of the +/// wallet's restored UTXOs. Some of them are foreign and will not be found; +/// that is expected. +pub fn funding_txids(wallet: &ManagedWalletInfo, unaccounted: &[Transaction]) -> BTreeSet { + let own: BTreeSet = unaccounted.iter().map(Transaction::txid).collect(); + let utxos: BTreeSet = wallet_funds(wallet) + .flat_map(|funds| funds.utxos.keys().copied()) + .collect(); + unaccounted + .iter() + .flat_map(|transaction| &transaction.input) + .map(|input| input.previous_output) + .filter(|prevout| { + !prevout.is_null() && !own.contains(&prevout.txid) && !utxos.contains(prevout) + }) + .map(|prevout| prevout.txid) + .collect() +} + +/// Accounting for each of `unaccounted`, a host's stored transactions that +/// have no [`WalletAccounting`] yet, from the wallet's own view of which +/// scripts it owns. The live projection's rule, recomputed for rows a host +/// stored before it kept per-wallet accounting: /// - an output is owned when its script belongs to one of the wallet's funds /// accounts other than a contact's watch-only chain /// ([`AccountType::is_contact_owned`](key_wallet::account::AccountType::is_contact_owned)); -/// - an input is owned when its prevout is an owned output of the supplied -/// history or one of the wallet's restored UTXOs; -/// - type, net and direction come from [`TransactionRouter::classify_transaction`] -/// and [`wallet_accounting`]'s rule. +/// an address-less, provably unspendable output is `Unspendable`, as +/// key-wallet classifies it; +/// - an input is owned when its prevout is an owned output of `unaccounted`, +/// of `funding` (the stored transactions [`funding_txids`] named), or one +/// of the wallet's restored UTXOs; +/// - type, net, direction and fee follow [`record_accounting`]. /// -/// A flagged transaction that touches no owned script yields no row, like a -/// detail-less record in [`record_accounting`]. An owned input whose funding -/// transaction is missing from the history and already spent reads as -/// foreign, exactly as it would to the live projection. +/// A transaction that touches no owned script yields no row, like a +/// detail-less record. An owned input whose funding transaction the host no +/// longer stores, and that is no longer a UTXO, cannot be resolved and reads +/// as foreign. Any subset of a host's unaccounted transactions is therefore +/// safe to pass: omitting one only leaves it without a row. pub fn history_accounting( wallet: &ManagedWalletInfo, - history: &[StoredCoreTransaction], + unaccounted: &[Transaction], + funding: &[Transaction], ) -> Vec { let mut owned_scripts: BTreeSet = BTreeSet::new(); let mut prevouts: BTreeMap = BTreeMap::new(); - for account in wallet.accounts.all_accounts() { - let ManagedAccountRef::Funds(funds) = account else { - continue; - }; - if account - .managed_account_type() - .to_account_type() - .is_contact_owned() - { - continue; - } - owned_scripts.extend(account.all_script_pubkeys()); + for funds in wallet_funds(wallet) { + owned_scripts.extend(ManagedAccountRef::Funds(funds).all_script_pubkeys()); for (outpoint, utxo) in &funds.utxos { prevouts.insert(*outpoint, utxo.txout.value); } } - for stored in history { - let txid = stored.transaction.txid(); - for (vout, output) in stored.transaction.output.iter().enumerate() { + for transaction in unaccounted.iter().chain(funding) { + let txid = transaction.txid(); + for (vout, output) in transaction.output.iter().enumerate() { if owned_scripts.contains(&output.script_pubkey) { prevouts.insert( OutPoint { @@ -174,8 +177,7 @@ pub fn history_accounting( } let mut rows = Vec::new(); - for stored in history.iter().filter(|stored| stored.needs_accounting) { - let transaction = &stored.transaction; + for transaction in unaccounted { let spent: Vec = transaction .input .iter() @@ -191,35 +193,52 @@ pub fn history_accounting( if spent.is_empty() && owned.is_empty() { continue; } - let (net, direction) = accounting( + let unspendable: BTreeSet = transaction + .output + .iter() + .enumerate() + .filter(|(_, output)| { + Address::from_script(&output.script_pubkey, wallet.network).is_err() + && output.script_pubkey.is_provably_unspendable() + }) + .map(|(index, _)| index) + .collect(); + let accounting = accounting( transaction, TransactionRouter::classify_transaction(transaction), &spent, &owned, - &BTreeSet::new(), + &unspendable, ); - // Only a transaction funded entirely by the wallet has a fee the - // wallet can know. - let fee = (spent.len() == transaction.input.len()) - .then(|| { - let funded = spent.iter().try_fold(0u64, |sum, v| sum.checked_add(*v))?; - let paid = transaction - .output - .iter() - .try_fold(0u64, |sum, o| sum.checked_add(o.value))?; - funded.checked_sub(paid) - }) - .flatten(); rows.push(WalletAccounting { txid: transaction.txid(), - net_amount: saturating_net(net), - direction, - fee, + net_amount: saturating_net(accounting.net), + direction: accounting.direction, + fee: accounting.fee, }); } rows } +/// The wallet's funds accounts, without contacts' watch-only chains. +fn wallet_funds(wallet: &ManagedWalletInfo) -> impl Iterator + '_ { + wallet + .accounts + .all_accounts() + .into_iter() + .filter_map(|account| match account { + ManagedAccountRef::Funds(funds) + if !account + .managed_account_type() + .to_account_type() + .is_contact_owned() => + { + Some(funds) + } + _ => None, + }) +} + /// Unreachable for real amounts (the whole supply fits i64 many times over); /// saturate rather than panic on a corrupt record. fn saturating_net(net: i128) -> i64 { @@ -234,6 +253,19 @@ fn saturating_net(net: i128) -> i64 { /// nets `−(credit + fee)`. The net is returned as `i128` so each caller /// picks its own policy for a value outside `i64`. pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirection) { + let accounting = record_parts(record); + (accounting.net, accounting.direction) +} + +/// What [`accounting`] computes, before an `i64` policy is applied to `net`. +struct Accounting { + net: i128, + direction: TransactionDirection, + fee: Option, +} + +/// [`accounting`] over a record's details. +fn record_parts(record: &TransactionRecord) -> Accounting { let spent: Vec = record.input_details.iter().map(|d| d.value).collect(); let owned: Vec<(usize, u64)> = record .output_details @@ -256,16 +288,17 @@ pub fn wallet_accounting(record: &TransactionRecord) -> (i128, TransactionDirect ) } -/// The net formula and direction rule over a transaction's owned inputs -/// (`spent` values) and owned outputs (`(index, value)`); `unspendable` -/// outputs leave nothing the wallet could lose, like an OP_RETURN. +/// The net formula, direction rule and fee over a transaction's owned +/// inputs (`spent` values) and owned outputs (`(index, value)`); +/// `unspendable` outputs leave nothing the wallet could lose, like an +/// OP_RETURN. The fee is known only when every input is an owned one. fn accounting( transaction: &Transaction, transaction_type: TransactionType, spent: &[u64], owned: &[(usize, u64)], unspendable: &BTreeSet, -) -> (i128, TransactionDirection) { +) -> Accounting { let received: i128 = owned.iter().map(|(_, value)| i128::from(*value)).sum(); let paid: i128 = spent.iter().map(|value| i128::from(*value)).sum(); // Indexed once so the per-output check below stays linear. @@ -285,7 +318,23 @@ fn accounting( !owned.is_empty(), has_external, ); - (received - paid, direction) + let fee = (!spent.is_empty() && spent.len() == transaction.input.len()) + .then(|| { + let funded = spent + .iter() + .try_fold(0u64, |sum, value| sum.checked_add(*value))?; + let paid_out = transaction + .output + .iter() + .try_fold(0u64, |sum, output| sum.checked_add(output.value))?; + funded.checked_sub(paid_out) + }) + .flatten(); + Accounting { + net: received - paid, + direction, + fee, + } } /// Whether an output with this role belongs to the wallet. @@ -300,8 +349,10 @@ mod tests { use crate::test_support::core_fixtures::{ block, foreign_address, outside, pay, records_of, spend_of, wallet_with_receive_address, }; - use key_wallet::account::{Account, AccountType}; - use key_wallet::managed_account::managed_core_funds_account::ManagedCoreFundsAccount; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::transaction::special_transaction::TransactionPayload; + use dashcore::{Network, TxOut}; + use key_wallet::account::{Account, AccountType, StandardAccountType}; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Wallet; @@ -309,18 +360,15 @@ mod tests { const PAID: u64 = 30_000; const CHANGE: u64 = 69_000; const FEE: u64 = FUNDING - PAID - CHANGE; + /// A CoinJoin denomination (0.001 DASH + fee). + const DENOMINATION: u64 = 100_001; - fn stored(transaction: &Transaction, needs_accounting: bool) -> StoredCoreTransaction { - StoredCoreTransaction { - transaction: transaction.clone(), - needs_accounting, - } - } - - /// A payment from outside into the wallet, then a spend of it that pays - /// a foreign address and returns change: what the live projection emits - /// for each, and the history the host stored. - async fn receive_then_spend() -> ( + /// A payment from outside into the wallet, then `spend` of that coin, + /// both seen by the live projection. Returns the wallet as it stands + /// afterwards, the live rows, and the two transactions. + async fn live( + spend: impl FnOnce(OutPoint, &Address) -> Transaction, + ) -> ( ManagedWalletInfo, Vec, Transaction, @@ -336,65 +384,186 @@ mod tests { .next_change_address(Some(&xpub), true) .unwrap(); let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); - let spend = spend_of( - &[OutPoint::new(funding.txid(), 0)], - vec![pay(PAID, &foreign_address(9)), pay(CHANGE, &change)], - ); - let mut live = Vec::new(); + let spend = spend(OutPoint::new(funding.txid(), 0), &change); + let mut rows = Vec::new(); for (tx, height) in [(&funding, 100), (&spend, 101)] { let mut records = records_of(&mut info, &mut wallet, tx, block(height)).await; fold_same_txid_records(&mut records); - live.extend(records.iter().filter_map(record_accounting)); + rows.extend(records.iter().filter_map(record_accounting)); } - (info, live, funding, spend) + (info, rows, funding, spend) + } + + fn payment_with_change(coin: OutPoint, change: &Address) -> Transaction { + spend_of( + &[coin], + vec![pay(PAID, &foreign_address(9)), pay(CHANGE, change)], + ) + } + + /// The backfill must write exactly what the live projection emitted for + /// the same transactions: amount, direction AND fee. + async fn assert_backfill_matches_live( + spend: impl FnOnce(OutPoint, &Address) -> Transaction, + ) -> Vec { + let (info, live, funding, spend) = live(spend).await; + assert_eq!(live.len(), 2, "the live projection accounts for both"); + let backfilled = history_accounting(&info, &[funding, spend], &[]); + assert_eq!(backfilled, live); + backfilled } #[tokio::test] - async fn should_backfill_stored_history_with_the_live_projections_accounting() { - let (info, live, funding, spend) = receive_then_spend().await; - assert_eq!( - live.iter().map(|row| row.net_amount).collect::>(), - vec![FUNDING as i64, CHANGE as i64 - FUNDING as i64], - "live projection: receive, then a spend netting −(paid + fee)" + async fn should_backfill_a_payment_with_change_exactly_like_the_live_projection() { + let rows = assert_backfill_matches_live(payment_with_change).await; + + assert_eq!(rows[0].net_amount, FUNDING as i64); + assert_eq!(rows[0].fee, None, "a foreign input hides the fee"); + assert_eq!(rows[1].net_amount, CHANGE as i64 - FUNDING as i64); + assert_eq!(rows[1].direction, TransactionDirection::Outgoing); + assert_eq!(rows[1].fee, Some(FEE), "the wallet funded every input"); + } + + /// An asset lock with no change: the OP_RETURN burn becomes the wallet's + /// own Platform credits, so it is `Internal`, and the Core net is the + /// whole coin. + #[tokio::test] + async fn should_backfill_a_no_change_asset_lock_exactly_like_the_live_projection() { + let credit = FUNDING - FEE; + let rows = assert_backfill_matches_live(|coin, _| { + let mut lock = spend_of( + &[coin], + vec![TxOut { + value: credit, + script_pubkey: ScriptBuf::new_op_return(&[]), + }], + ); + lock.version = 3; + lock.special_transaction_payload = + Some(TransactionPayload::AssetLockPayloadType(AssetLockPayload { + version: 1, + credit_outputs: vec![pay(credit, &foreign_address(5))], + })); + lock + }) + .await; + + assert_eq!(rows[1].net_amount, -(FUNDING as i64)); + assert_eq!(rows[1].direction, TransactionDirection::Internal); + assert_eq!(rows[1].fee, Some(FEE)); + } + + /// An address-less, provably unspendable output that is not an OP_RETURN + /// takes nothing out of the wallet either: a self-transfer stays + /// `Internal`, as key-wallet's `Unspendable` role makes it live. + #[tokio::test] + async fn should_backfill_an_unspendable_output_exactly_like_the_live_projection() { + let rows = assert_backfill_matches_live(|coin, change| { + spend_of( + &[coin], + vec![ + pay(CHANGE, change), + TxOut { + value: 0, + // OP_VERIF: an illegal opcode, so provably unspendable. + script_pubkey: ScriptBuf::from_bytes(vec![0x65]), + }, + ], + ) + }) + .await; + + assert_eq!(rows[1].direction, TransactionDirection::Internal); + } + + #[tokio::test] + async fn should_backfill_a_coinjoin_exactly_like_the_live_projection() { + let rows = assert_backfill_matches_live(|coin, change| { + spend_of( + &[coin, outside(1), outside(2)], + vec![ + pay(DENOMINATION, change), + pay(DENOMINATION, &foreign_address(3)), + pay(DENOMINATION, &foreign_address(4)), + ], + ) + }) + .await; + + assert_eq!(rows[1].direction, TransactionDirection::CoinJoin); + assert_eq!(rows[1].fee, None, "other participants funded inputs"); + } + + /// A spend's coin resolves from a funding transaction passed in + /// `funding`; only `unaccounted` transactions get a row. + #[tokio::test] + async fn should_resolve_inputs_from_funding_transactions_and_answer_only_unaccounted() { + let (info, live, funding, spend) = live(payment_with_change).await; + + let backfilled = history_accounting(&info, std::slice::from_ref(&spend), &[funding]); + + assert_eq!(backfilled, vec![live[1]]); + } + + /// A spend whose funding transaction the host no longer has still + /// resolves while its coin is a restored UTXO. + #[tokio::test] + async fn should_resolve_an_input_from_a_restored_utxo_alone() { + let (mut wallet, mut info, receive) = wallet_with_receive_address(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + records_of(&mut info, &mut wallet, &funding, block(100)).await; + let spend = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(FUNDING - FEE, &foreign_address(9))], ); - let backfilled = history_accounting(&info, &[stored(&funding, true), stored(&spend, true)]); + let rows = history_accounting(&info, std::slice::from_ref(&spend), &[]); - assert_eq!(backfilled.len(), 2); - for (row, expected) in backfilled.iter().zip(&live) { - assert_eq!(row.txid, expected.txid); - assert_eq!(row.net_amount, expected.net_amount); - assert_eq!(row.direction, expected.direction); - } - assert_eq!(backfilled[1].direction, TransactionDirection::Outgoing); assert_eq!( - backfilled[1].fee, - Some(FEE), - "the wallet funded every input" + rows, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: -(FUNDING as i64), + direction: TransactionDirection::Outgoing, + fee: Some(FEE), + }] ); - assert_eq!(backfilled[0].fee, None, "a foreign input hides the fee"); } - /// The other stored transactions resolve a flagged spend's inputs, but - /// only flagged ones get a row. + /// The manager fetches only prevouts nothing else resolves: not the + /// unaccounted transactions' own outputs, not restored UTXOs, not a + /// coinbase's null prevout. #[tokio::test] - async fn should_resolve_inputs_from_unflagged_history_and_answer_only_flagged_rows() { - let (info, live, funding, spend) = receive_then_spend().await; + async fn should_name_only_the_funding_transactions_nothing_else_resolves() { + let (mut wallet, mut info, receive) = wallet_with_receive_address(); + let utxo_funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + records_of(&mut info, &mut wallet, &utxo_funding, block(100)).await; + let chained = spend_of(&[outside(8)], vec![pay(FUNDING, &foreign_address(1))]); + let spend = spend_of( + &[ + OutPoint::new(utxo_funding.txid(), 0), + OutPoint::new(chained.txid(), 0), + outside(6), + OutPoint::null(), + ], + vec![pay(1, &foreign_address(2))], + ); - let backfilled = - history_accounting(&info, &[stored(&funding, false), stored(&spend, true)]); + let needed = funding_txids(&info, &[chained.clone(), spend]); - assert_eq!(backfilled.len(), 1); - assert_eq!(backfilled[0].txid, spend.txid()); - assert_eq!(backfilled[0].net_amount, live[1].net_amount); + assert_eq!( + needed, + BTreeSet::from([outside(6).txid, outside(8).txid]), + "`chained` is itself unaccounted and the UTXO is restored" + ); } #[tokio::test] async fn should_yield_no_row_for_a_transaction_touching_no_owned_script() { - let (info, _, _, _) = receive_then_spend().await; + let (info, _, _, _) = live(payment_with_change).await; let unrelated = spend_of(&[outside(3)], vec![pay(5_000, &foreign_address(4))]); - assert!(history_accounting(&info, &[stored(&unrelated, true)]).is_empty()); + assert!(history_accounting(&info, &[unrelated], &[]).is_empty()); } /// An output on a contact's watch-only chain is the contact's money, @@ -403,11 +572,8 @@ mod tests { #[tokio::test] async fn should_not_count_a_contacts_watch_only_output_as_owned() { let (_, mut info, receive) = wallet_with_receive_address(); - let contact_wallet = Wallet::new_random( - dashcore::Network::Testnet, - WalletAccountCreationOptions::Default, - ) - .unwrap(); + let contact_wallet = + Wallet::new_random(Network::Testnet, WalletAccountCreationOptions::Default).unwrap(); let contact_xpub = contact_wallet.accounts.standard_bip44_accounts[&0].account_xpub; let account = Account::new( None, @@ -417,7 +583,7 @@ mod tests { friend_identity_id: [2; 32], }, contact_xpub, - dashcore::Network::Testnet, + Network::Testnet, ) .unwrap(); let contact_account = ManagedCoreFundsAccount::from_account(&account); @@ -435,7 +601,7 @@ mod tests { vec![pay(FUNDING - FEE, &contact_address)], ); - let rows = history_accounting(&info, &[stored(&funding, false), stored(&to_contact, true)]); + let rows = history_accounting(&info, std::slice::from_ref(&to_contact), &[funding]); assert_eq!(rows.len(), 1); assert_eq!(rows[0].net_amount, -(FUNDING as i64)); @@ -452,7 +618,7 @@ mod tests { tx, AccountType::Standard { index: 0, - standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + standard_account_type: StandardAccountType::BIP44Account, }, block(10), TransactionType::AssetLock, diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index e32c25762fa..96f6d277cdf 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -4,7 +4,8 @@ use std::collections::BTreeMap; use std::sync::Arc; use crate::changeset::{ - history_accounting, ClientStartState, ClientWalletStartState, PlatformWalletPersistence, + funding_txids, history_accounting, ClientStartState, ClientWalletStartState, + PlatformWalletPersistence, }; use crate::error::PlatformWalletError; use crate::wallet::core::WalletGeneration; @@ -744,16 +745,19 @@ impl PlatformWalletManager

{ Ok(()) } - /// Hand the persister the per-wallet accounting of every stored - /// transaction it flags as lacking one (see + /// Backfill per-wallet accounting for the stored transactions the + /// persister holds without one (see /// [`PlatformWalletPersistence::load_core_history_for_accounting`]). + /// Nothing is written unless every step succeeds, so a failed backfill + /// simply runs again at the next load. async fn backfill_history_accounting(&self, wallet_id: WalletId) { let persister = Arc::clone(&self.persister); - let history = + let unaccounted = match run_blocking_load(move || persister.load_core_history_for_accounting(wallet_id)) .await { - Ok(history) => history, + Ok(Some(unaccounted)) => unaccounted, + Ok(None) => return, Err(e) => { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -763,24 +767,47 @@ impl PlatformWalletManager

{ return; } }; - if !history.iter().any(|stored| stored.needs_accounting) { - return; - } + let needed = { + let wm = self.wallet_manager.read().await; + let Some(info) = wm.get_wallet_info(&wallet_id) else { + return; + }; + funding_txids(&info.core_wallet, &unaccounted) + }; + // Fetched without the manager lock held: each lookup calls the host. + let persister = Arc::clone(&self.persister); + let funding = match run_blocking_load(move || { + let mut funding = Vec::new(); + for txid in &needed { + if let Some(record) = persister.get_core_tx_record(wallet_id, txid)? { + funding.push(record.transaction); + } + } + Ok(funding) + }) + .await + { + Ok(funding) => funding, + Err(e) => { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = ?e, + "load: reading funding transactions for the accounting backfill failed" + ); + return; + } + }; let rows = { let wm = self.wallet_manager.read().await; let Some(info) = wm.get_wallet_info(&wallet_id) else { return; }; - history_accounting(&info.core_wallet, &history) + history_accounting(&info.core_wallet, &unaccounted, &funding) }; - if rows.is_empty() { - return; - } let persister = Arc::clone(&self.persister); let count = rows.len(); if let Err(e) = - run_blocking_load(move || persister.store_transaction_accounting(wallet_id, &rows)) - .await + run_blocking_load(move || persister.store_history_accounting(wallet_id, &rows)).await { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -911,9 +938,18 @@ mod idempotent_load_tests { use key_wallet::wallet::ManagedWalletInfo; use key_wallet::Wallet; + use std::sync::Mutex; + + use dashcore::{OutPoint, Transaction, Txid}; + use key_wallet::account::{AccountType, StandardAccountType}; + use key_wallet::managed_account::transaction_record::{ + TransactionDirection, TransactionRecord, + }; + use key_wallet::transaction_checking::{TransactionContext, TransactionType}; + use crate::changeset::{ ClientStartState, ClientWalletStartState, IdentityManagerStartState, PersistenceError, - PlatformWalletChangeSet, PlatformWalletPersistence, + PlatformWalletChangeSet, PlatformWalletPersistence, WalletAccounting, }; use crate::events::PlatformEventHandler; use crate::test_support::NoopTestEventHandler; @@ -1367,13 +1403,17 @@ mod idempotent_load_tests { ); } - /// Hands back a stored history for the accounting backfill and records - /// what the manager persists from it. + /// Hands back the stored transactions lacking accounting (`None` once + /// backfilled), serves funding transactions by txid, and records what + /// the manager persists. struct HistoryPersister { wallet: Wallet, managed: ManagedWalletInfo, - history: Vec, - stored: std::sync::Mutex)>>, + unaccounted: Option>, + stored_transactions: BTreeMap, + failing_lookup: bool, + lookups: Mutex>, + stored: Mutex)>>, } impl PlatformWalletPersistence for HistoryPersister { @@ -1397,43 +1437,61 @@ mod idempotent_load_tests { .load() } + fn get_core_tx_record( + &self, + _wallet_id: WalletId, + txid: &Txid, + ) -> Result, PersistenceError> { + self.lookups.lock().unwrap().push(*txid); + if self.failing_lookup { + return Err(PersistenceError::backend("store unreadable")); + } + Ok(self.stored_transactions.get(txid).map(|tx| { + TransactionRecord::new( + tx.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ) + })) + } + fn load_core_history_for_accounting( &self, _wallet_id: WalletId, - ) -> Result, PersistenceError> { - Ok(self.history.clone()) + ) -> Result>, PersistenceError> { + Ok(self.unaccounted.clone()) } - fn store_transaction_accounting( + fn store_history_accounting( &self, wallet_id: WalletId, - rows: &[crate::changeset::WalletAccounting], + rows: &[WalletAccounting], ) -> Result<(), PersistenceError> { self.stored.lock().unwrap().push((wallet_id, rows.to_vec())); Ok(()) } } - async fn load_with_history( - flagged: bool, - ) -> ( - WalletId, - dashcore::Transaction, - Vec<(WalletId, Vec)>, - ) { + /// A wallet funded by `funding` (100_000 duffs, output 0), and a spend of + /// that coin paying 74_000 to a foreign address. + async fn funded_wallet() -> (Wallet, ManagedWalletInfo, Transaction, Transaction) { let (ctx, funding) = TestWalletContext::new_random() .with_mempool_funding(100_000) .await; - let wallet_id = ctx.wallet.compute_wallet_id(); - let persister = Arc::new(HistoryPersister { - wallet: ctx.wallet, - managed: ctx.managed_wallet, - history: vec![crate::changeset::StoredCoreTransaction { - transaction: funding.clone(), - needs_accounting: flagged, - }], - stored: std::sync::Mutex::new(Vec::new()), - }); + let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + (ctx.wallet, ctx.managed_wallet, funding, spend) + } + + async fn backfill(persister: HistoryPersister) -> Arc { + let persister = Arc::new(persister); let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let event_handler: Arc = Arc::new(NoopTestEventHandler); let manager = PlatformWalletManager::new(sdk, Arc::clone(&persister), event_handler); @@ -1441,37 +1499,134 @@ mod idempotent_load_tests { .load_from_persistor() .await .expect("the wallet must load"); - let stored = persister.stored.lock().unwrap().clone(); - (wallet_id, funding, stored) + persister + } + + fn history_persister( + wallet: Wallet, + managed: ManagedWalletInfo, + unaccounted: Option>, + stored: &[&Transaction], + ) -> HistoryPersister { + HistoryPersister { + wallet, + managed, + unaccounted, + stored_transactions: stored.iter().map(|tx| (tx.txid(), (*tx).clone())).collect(), + failing_lookup: false, + lookups: Mutex::new(Vec::new()), + stored: Mutex::new(Vec::new()), + } + } + + fn without_utxos(mut managed: ManagedWalletInfo) -> ManagedWalletInfo { + for account in managed.accounts.standard_bip44_accounts.values_mut() { + account.utxos.clear(); + } + managed } - /// Rows the host stored before it kept per-wallet accounting are - /// backfilled at load from the restored wallet's own view of its scripts. + /// Rows stored before per-wallet accounting are backfilled at load. A + /// spend whose coin is among the unaccounted transactions resolves + /// without a lookup; only the funding transaction's foreign prevout is + /// asked for (and not found). #[tokio::test] - async fn load_backfills_accounting_for_stored_history_the_host_flags() { - let (wallet_id, funding, stored) = load_with_history(true).await; + async fn load_backfills_accounting_for_unaccounted_history() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let wallet_id = wallet.compute_wallet_id(); + let persister = backfill(history_persister( + wallet, + without_utxos(managed), + Some(vec![funding.clone(), spend.clone()]), + &[], + )) + .await; - assert_eq!(stored.len(), 1, "one backfill write for the wallet"); - let (written_for, rows) = &stored[0]; - assert_eq!(written_for, &wallet_id); assert_eq!( - rows.as_slice(), - &[crate::changeset::WalletAccounting { - txid: funding.txid(), - net_amount: 100_000, - direction: - key_wallet::managed_account::transaction_record::TransactionDirection::Incoming, - fee: None, + *persister.stored.lock().unwrap(), + vec![( + wallet_id, + vec![ + WalletAccounting { + txid: funding.txid(), + net_amount: 100_000, + direction: TransactionDirection::Incoming, + fee: None, + }, + WalletAccounting { + txid: spend.txid(), + net_amount: -100_000, + direction: TransactionDirection::Outgoing, + fee: Some(26_000), + }, + ] + )] + ); + assert!( + !persister.lookups.lock().unwrap().contains(&funding.txid()), + "the spend's coin resolves from the unaccounted funding transaction" + ); + } + + /// A spend whose coin is neither among the unaccounted transactions nor + /// a UTXO any more resolves through the funding transaction the manager + /// fetches itself; the host is never asked to send its whole history. + #[tokio::test] + async fn load_fetches_the_funding_transaction_a_backfilled_spend_needs() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let persister = backfill(history_persister( + wallet, + without_utxos(managed), + Some(vec![spend.clone()]), + &[&funding], + )) + .await; + + assert_eq!(*persister.lookups.lock().unwrap(), vec![funding.txid()]); + let stored = persister.stored.lock().unwrap().clone(); + assert_eq!(stored.len(), 1); + assert_eq!( + stored[0].1, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: -100_000, + direction: TransactionDirection::Outgoing, + fee: Some(26_000), }] ); } - /// A host whose history is all accounted writes nothing. + /// A failed funding lookup writes nothing, so the backfill (and the + /// completion it records) runs again at the next load. #[tokio::test] - async fn load_skips_the_backfill_when_nothing_is_flagged() { - let (_, _, stored) = load_with_history(false).await; + async fn load_writes_no_backfill_when_a_funding_lookup_fails() { + let (wallet, managed, _, spend) = funded_wallet().await; + let mut persister = + history_persister(wallet, without_utxos(managed), Some(vec![spend]), &[]); + persister.failing_lookup = true; + let persister = backfill(persister).await; + + assert_eq!(persister.lookups.lock().unwrap().len(), 1); + assert!(persister.stored.lock().unwrap().is_empty()); + } + + /// Nothing unaccounted still completes the backfill, so the host stops + /// being asked; an already backfilled wallet is left alone. + #[tokio::test] + async fn load_completes_an_empty_backfill_and_skips_a_finished_one() { + let (wallet, managed, _, _) = funded_wallet().await; + let wallet_id = wallet.compute_wallet_id(); + let empty = backfill(history_persister( + wallet.clone(), + managed.clone(), + Some(Vec::new()), + &[], + )) + .await; + assert_eq!(*empty.stored.lock().unwrap(), vec![(wallet_id, Vec::new())]); - assert!(stored.is_empty()); + let finished = backfill(history_persister(wallet, managed, None, &[])).await; + assert!(finished.stored.lock().unwrap().is_empty()); } fn make_pending_manager( From 651d1d5476ac9a85917290fabea30eaffb79d213 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:14:30 +0000 Subject: [PATCH 44/84] fix(swift-sdk): mark per-wallet accounting backfills and stop scalar fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `PersistentWallet.transactionAccountingBackfilled` (V5, unreleased) records Rust's per-wallet completion marker; a backfilled wallet's load costs one indexed fetch, and an unfinished one offers only its own transactions lacking a row (wallet relationships and walletId-indexed TXOs / pending inputs, not a whole-store scan). - Accounting rows always arrive inside a round and stage with it; the out-of-round save path is gone. - Wallet-scoped amount, direction and fee are `nil` without the wallet's row and display as "—" instead of another wallet's shared scalar. - Storage Explorer lists `PersistentTransactionAccounting` rows and shows the wallet's backfill marker. - Re-attach detached doc comments and refresh the V5 schema docs. - Tests cover the backfill round commit and rollback, the marker, the wallet-scoped offer, loan release and the C shims end to end. Co-Authored-By: Claude Opus 5.5 --- packages/swift-sdk/SCHEMA_RELEASES.md | 19 +- .../Persistence/DashModelContainer.swift | 3 +- .../Models/PersistentTransaction.swift | 34 ++-- .../Persistence/Models/PersistentWallet.swift | 5 + .../PlatformWalletPersistenceHandler.swift | 171 +++++++++++++----- .../Core/Views/TransactionDetailView.swift | 10 +- .../Views/TransactionDirectionStyle.swift | 7 +- .../Core/Views/TransactionListView.swift | 9 +- .../Views/StorageExplorerView.swift | 8 + .../Views/StorageModelListViews.swift | 47 +++++ .../Views/StorageRecordDetailViews.swift | 38 ++++ .../TransactionAccountingTests.swift | 146 ++++++++++++--- 12 files changed, 386 insertions(+), 111 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index ee711319892..62882b7cf65 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -24,13 +24,18 @@ schema. The former live V2 is accepted only when its complete graph matches frozen V3 exactly, entity hashes and checksum alike. The alias remains supported when the live graph changes; other stores labelled `2.0.0` fail closed. -V5 adds the `PersistentTransactionAccounting` entity through a lightweight -migration: one row per `(walletId, txid)` holding the net amount, direction and -fee `rs-platform-wallet` emitted for that wallet. Migrated stores start with no -rows; at the first load Rust reads the stored history back -(`on_load_core_history_for_accounting_fn`), computes the accounting of every -transaction lacking a row and hands it to `on_persist_transaction_accounting_fn`. -Deleting a wallet deletes its rows; deleting a transaction cascades to them. +V5 adds the `PersistentTransactionAccounting` entity and the +`PersistentWallet.transactionAccountingBackfilled` attribute (default `false`) +through a lightweight migration. Each accounting row, one per +`(walletId, txid)`, holds the net amount, direction and fee `rs-platform-wallet` +emitted for that wallet. Migrated stores start with no rows; at the first load +of each wallet Rust reads back the wallet's transactions that lack a row +(`on_load_core_history_for_accounting_fn`), fetches any funding transaction it +needs through `on_get_core_tx_record_fn`, and writes the rows +(`on_persist_transaction_accounting_fn`) and the marker +(`on_persist_core_history_accounted_fn`) in one round. Later loads see the +marker and skip the read. Deleting a wallet deletes its rows; deleting a +transaction cascades to them. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index de65f8c5466..0395d0115ac 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -403,7 +403,8 @@ public enum DashSchemaV4: VersionedSchema { public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV4.models } } -/// Current schema: optional transaction accounting availability. +/// Current schema: per-wallet transaction accounting (`PersistentTransactionAccounting`) +/// and the wallet's accounting-backfill marker. public enum DashSchemaV5: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index bfd3811ba9c..d097d974b34 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -86,8 +86,8 @@ public final class PersistentTransaction { public var transactionTypeKind: UInt8 = 0xFF /// Net amount in duffs (signed: positive=received, negative=sent) as the /// last recording wallet's `TransactionRecordFFI` carried it. Shared by - /// every wallet in the transaction; per-wallet values live in - /// [`walletAccounting`]; `netAmount(for:)` prefers the wallet's row. + /// every wallet in the transaction, so only unscoped views (the storage + /// explorer) show it; per-wallet values live in [`walletAccounting`]. public var netAmount: Int64 /// Fee in duffs (nil if unknown); same last-writer caveat as `netAmount`. public var fee: UInt64? @@ -241,35 +241,34 @@ public final class PersistentTransaction { walletAccounting.first { $0.walletId == walletId } } - /// Core value movement for one wallet: its Rust-emitted row, else the - /// shared stored amount (a transaction Rust has no accounting for). - public func netAmount(for walletId: Data) -> Int64 { - accounting(for: walletId)?.netAmount ?? netAmount + /// Core value movement for one wallet; `nil` when Rust has emitted no + /// accounting for this wallet and transaction. + public func netAmount(for walletId: Data) -> Int64? { + accounting(for: walletId)?.netAmount } - /// `CoreDirectionCode` for one wallet, with the same fallback as `netAmount(for:)`. - public func direction(for walletId: Data) -> UInt32 { - accounting(for: walletId)?.direction ?? direction + /// `CoreDirectionCode` for one wallet; `nil` like `netAmount(for:)`. + public func direction(for walletId: Data) -> UInt32? { + accounting(for: walletId)?.direction } - /// Fee for one wallet, with the same fallback as `netAmount(for:)`. + /// Fee for one wallet; `nil` without a row, or when it funded only some inputs. public func fee(for walletId: Data) -> UInt64? { - guard let row = accounting(for: walletId) else { return fee } - return row.fee + accounting(for: walletId)?.fee } - /// Format the wallet's Core value movement in DASH. + /// The wallet's Core value movement in DASH, or "—" without accounting. public func formattedAmount(for walletId: Data) -> String { - Self.format(duffs: netAmount(for: walletId)) + netAmount(for: walletId).map(Self.format(duffs:)) ?? Self.noAccounting } /// Net amount for `walletId`, or the stored scalar when no wallet scope is given. - public func displayNetAmount(for walletId: Data?) -> Int64 { + public func displayNetAmount(for walletId: Data?) -> Int64? { walletId.map { netAmount(for: $0) } ?? netAmount } /// `CoreDirectionCode` for `walletId`, or the stored direction when no wallet scope is given. - public func displayDirectionCode(for walletId: Data?) -> UInt32 { + public func displayDirectionCode(for walletId: Data?) -> UInt32? { walletId.map { direction(for: $0) } ?? direction } @@ -283,6 +282,9 @@ public final class PersistentTransaction { walletId.map { formattedAmount(for: $0) } ?? formattedAmount } + /// Shown in place of an amount Rust has not accounted for this wallet. + public static let noAccounting = "—" + /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. static func format(duffs: Int64) -> String { String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift index 52365db3006..2d98dac84be 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentWallet.swift @@ -118,6 +118,11 @@ public final class PersistentWallet { /// column, or never verified) means the full check runs at the next /// unlock. public var seedBindingVerifiedMarker: String? + /// Set once Rust's load-time accounting backfill has evaluated this + /// wallet's stored transactions (`on_persist_core_history_accounted_fn`). + /// Opaque to Swift: it only stops the history from being offered again. + /// `false` for rows predating the column, so they are backfilled once. + public var transactionAccountingBackfilled: Bool = false /// Record timestamps. public var createdAt: Date public var lastUpdated: Date diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 6322f76b003..404897a3f50 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3299,13 +3299,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback // Per-wallet transaction accounting exactly as Rust computes it: - // after each round's changeset, and once at load for stored rows - // that have none yet (Rust reads the history back, computes, and - // hands the rows to the persist slot). + // after each round's changeset, and once per wallet at load for + // stored rows that have none yet (Rust reads them back, computes, + // and writes the rows plus the completion marker in one round). extensionCallbacks.on_persist_transaction_accounting_fn = persistTransactionAccountingCallback extensionCallbacks.on_load_core_history_for_accounting_fn = loadCoreHistoryForAccountingCallback extensionCallbacks.on_load_core_history_for_accounting_free_fn = loadCoreHistoryForAccountingFreeCallback + extensionCallbacks.on_persist_core_history_accounted_fn = persistCoreHistoryAccountedCallback return extensionCallbacks } @@ -9070,16 +9071,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// Outstanding tracked-masternode load allocations keyed by the - /// entries pointer we handed to Rust. - /// Outstanding stored-history loans handed to Rust for the accounting - /// backfill, keyed by the rows pointer. - private var coreHistoryLoadAllocations: [UnsafeRawPointer: CoreHistoryLoadAllocation] = [:] - /// Upsert one wallet's Rust-emitted accounting rows, keyed by - /// `(walletId, txid)`. Inside a round they stage with it and commit at - /// `endChangeset`; outside one (the load-time backfill) they commit here. - /// Returns `false` to fail the round, like the other per-kind callbacks. + /// `(walletId, txid)`. Always fired inside a round (a sync round, or the + /// load-time backfill's own), so they stage with it and commit at + /// `endChangeset`. Returns `false` to fail the round, like the other + /// per-kind callbacks. func persistTransactionAccounting( walletId: Data, rows: UnsafePointer?, @@ -9095,8 +9091,22 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { for i in 0.. Bool { + onQueue { + switch roundWalletLookup(walletId: walletId, callback: "core_history_accounted") { + case .failed: return false + case .absent: return true + case .found(let wallet): + wallet.transactionAccountingBackfilled = true + return true + } } } @@ -9124,47 +9134,87 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } - /// The wallet's stored transactions for Rust's accounting backfill: none - /// when each already has this wallet's row, otherwise all of them, with - /// the ones lacking a row flagged (the others resolve their inputs). - /// Payload-only transactions never get a row, so a wallet holding one - /// is offered its history on every load; Rust answers it with no rows. + /// Rust's accounting-backfill read. `backfilled` once the wallet's + /// marker is set (or the wallet row is gone): one indexed fetch, no + /// history scan. Otherwise the wallet's stored transactions lacking its + /// accounting row, gathered from the wallet's own relationships and + /// `walletId`-indexed rows rather than the whole store. Rust fetches any + /// funding transaction it needs itself, so a transaction missed here + /// only stays without a row. func loadCoreHistoryForAccounting( walletId: Data - ) -> (rows: UnsafePointer?, count: Int, errored: Bool) { + ) -> (backfilled: Bool, rows: UnsafePointer?, count: Int, errored: Bool) { onQueue { - let history: [PersistentTransaction] + let unaccounted: [PersistentTransaction] do { - history = try modelFetcher.fetch(FetchDescriptor(), in: backgroundContext) - .filter { Self.walletOwnsTransaction(walletId: walletId, transaction: $0) } + guard let wallet = try fetchWalletRecord(walletId: walletId), + !wallet.transactionAccountingBackfilled else { + return (true, nil, 0, false) + } + unaccounted = try walletTransactions(wallet: wallet) + .filter { $0.accounting(for: walletId) == nil } } catch { SDKLogger.event( "persistence_core_history_load_failed", category: .persistence, severity: .error, fields: ["wallet_reference": .reference(walletId)], error: error ) - return (nil, 0, true) + return (false, nil, 0, true) } - let flags = history.map { $0.accounting(for: walletId) == nil } - guard flags.contains(true) else { return (nil, 0, false) } + guard !unaccounted.isEmpty else { return (false, nil, 0, false) } let allocation = CoreHistoryLoadAllocation() - let buf = UnsafeMutablePointer.allocate(capacity: history.count) + let buf = UnsafeMutablePointer.allocate(capacity: unaccounted.count) allocation.entries = buf - for (index, transaction) in history.enumerated() { + for (index, transaction) in unaccounted.enumerated() { let body = transaction.transactionData let bytes = UnsafeMutablePointer.allocate(capacity: max(body.count, 1)) body.copyBytes(to: bytes, count: body.count) allocation.buffers.append(bytes) buf[index] = CoreHistoryTransactionFFI( tx_bytes: UnsafePointer(bytes), - tx_bytes_len: UInt(body.count), - needs_accounting: flags[index] + tx_bytes_len: UInt(body.count) ) } coreHistoryLoadAllocations[UnsafeRawPointer(buf)] = allocation - return (UnsafePointer(buf), history.count, false) + return (false, UnsafePointer(buf), unaccounted.count, false) } } + /// Every stored transaction touching `wallet`, deduplicated by txid: + /// its accounts' `involvedTransactions`, plus the creating and spending + /// transactions of its TXOs and pending inputs. TXOs migrated with an + /// empty `walletId` are matched through their account, like + /// `walletOwnsTransaction`. + private func walletTransactions(wallet: PersistentWallet) throws -> [PersistentTransaction] { + let walletId = wallet.walletId + var byTxid: [Data: PersistentTransaction] = [:] + func add(_ transaction: PersistentTransaction?) { + guard let transaction else { return } + byTxid[transaction.txid] = transaction + } + for account in wallet.accounts { + account.involvedTransactions.forEach(add) + } + let emptyWalletId = Data() + let ownTxos: [PersistentTxo] = try modelFetcher.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }), + in: backgroundContext + ) + let legacyTxos: [PersistentTxo] = try modelFetcher.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == emptyWalletId }), + in: backgroundContext + ).filter { Self.resolvedWalletId(of: $0) == walletId } + for txo in ownTxos + legacyTxos { + add(txo.transaction) + add(txo.spendingTransaction) + } + let pendingInputs = try modelFetcher.fetch( + FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }), + in: backgroundContext + ) + pendingInputs.forEach { add($0.spendingTransaction) } + return Array(byTxid.values) + } + /// Release a loan handed out by `loadCoreHistoryForAccounting`. func loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer?) { onQueue { @@ -9174,9 +9224,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// Outstanding tracked-masternode load allocations keyed by the + /// entries pointer we handed to Rust. private var trackedMasternodeLoadAllocations: [UnsafeRawPointer: TrackedMasternodeLoadAllocation] = [:] + /// Outstanding unaccounted-history loans handed to Rust for the + /// accounting backfill, keyed by the rows pointer. + private var coreHistoryLoadAllocations: [UnsafeRawPointer: CoreHistoryLoadAllocation] = [:] + + /// Loans Rust has not returned yet; zero between backfills. + var outstandingCoreHistoryLoanCount: Int { + onQueue { coreHistoryLoadAllocations.count } + } + /// Human-readable name for a persisted account, mirroring the /// top-level `AccountTypeTagFFI` discriminant plus — for tag 0 /// (Standard) — the `StandardAccountTypeTagFFI` sub-discriminant. @@ -9416,20 +9477,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Owned allocations behind one `loadTrackedMasternodes` answer: the /// entries buffer plus every strdup'd label / snapshot string. Trivial C /// structs — deallocate only. -private final class CoreHistoryLoadAllocation { - var entries: UnsafeMutablePointer? - var buffers: [UnsafeMutablePointer] = [] - - func release() { - for buffer in buffers { - buffer.deallocate() - } - buffers.removeAll() - entries?.deallocate() - entries = nil - } -} - private final class TrackedMasternodeLoadAllocation { var entries: UnsafeMutablePointer? var count: Int = 0 @@ -9446,6 +9493,23 @@ private final class TrackedMasternodeLoadAllocation { } } +/// Owned allocations behind one `loadCoreHistoryForAccounting` answer: the +/// entries buffer plus each copied transaction body. Released by +/// `loadCoreHistoryForAccountingFree`. +private final class CoreHistoryLoadAllocation { + var entries: UnsafeMutablePointer? + var buffers: [UnsafeMutablePointer] = [] + + func release() { + for buffer in buffers { + buffer.deallocate() + } + buffers.removeAll() + entries?.deallocate() + entries = nil + } +} + private final class LoadAllocation { var entries: UnsafeMutablePointer? /// Allocated capacity — equal to `restorable.count`. Used for @@ -12011,21 +12075,34 @@ private func persistTransactionAccountingCallback( private func loadCoreHistoryForAccountingCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, + outBackfilled: UnsafeMutablePointer?, outRows: UnsafeMutablePointer?>?, outCount: UnsafeMutablePointer? ) -> Int32 { - guard let context, let walletIdPtr, let outRows, let outCount else { return 1 } + guard let context, let walletIdPtr, let outBackfilled, let outRows, let outCount else { return 1 } + outBackfilled.pointee = false outRows.pointee = nil outCount.pointee = 0 let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() - let (rows, count, errored) = handler.loadCoreHistoryForAccounting( + let (backfilled, rows, count, errored) = handler.loadCoreHistoryForAccounting( walletId: Data(bytes: walletIdPtr, count: 32) ) + outBackfilled.pointee = backfilled outRows.pointee = rows outCount.pointee = UInt(count) return errored ? 1 : 0 } +/// C shim for `on_persist_core_history_accounted_fn`. +private func persistCoreHistoryAccountedCallback( + context: UnsafeMutableRawPointer?, + walletIdPtr: UnsafePointer? +) -> Int32 { + guard let context, let walletIdPtr else { return 1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + return handler.persistCoreHistoryAccounted(walletId: Data(bytes: walletIdPtr, count: 32)) ? 0 : 1 +} + /// C shim for `on_load_core_history_for_accounting_free_fn`. private func loadCoreHistoryForAccountingFreeCallback( context: UnsafeMutableRawPointer?, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift index 4f2c5753486..0875e244d16 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDetailView.swift @@ -4,9 +4,10 @@ import SwiftDashSDK struct TransactionDetailView: View { let transaction: PersistentTransaction var walletId: Data? = nil - /// This wallet's Rust-emitted amount (see `PersistentTransaction.netAmount(for:)`). - private var netAmount: Int64 { transaction.displayNetAmount(for: walletId) } - private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } + /// This wallet's Rust-emitted amount and direction; `nil` when Rust + /// emitted no accounting for it (see `PersistentTransaction.netAmount(for:)`). + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32? { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil @Environment(\.dismiss) private var dismiss @@ -34,6 +35,7 @@ struct TransactionDetailView: View { || transaction.isProviderSpecial { return transaction.displayDirection } + guard let direction else { return "Transaction" } switch direction { case CoreDirectionCode.incoming: return "Received" @@ -188,7 +190,7 @@ struct TransactionDetailView: View { ) } - if let fee = formattedFee, netAmount < 0 { + if let fee = formattedFee, let amount = netAmount, amount < 0 { TransactionDetailRow( label: "Network Fee", value: fee diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift index 589aa6bb7f0..256b7a8ae19 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionDirectionStyle.swift @@ -3,7 +3,9 @@ import SwiftDashSDK /// Direction icon and colour shared by the transaction list and detail views. enum TransactionDirectionStyle { - static func icon(for direction: UInt32) -> String { + /// `nil` (no accounting from Rust for this wallet) reads as unknown. + static func icon(for direction: UInt32?) -> String { + guard let direction else { return "questionmark.circle" } switch direction { case CoreDirectionCode.incoming: return "arrow.down.circle.fill" case CoreDirectionCode.outgoing: return "arrow.up.circle.fill" @@ -14,7 +16,8 @@ enum TransactionDirectionStyle { } /// Internal transfers share the outgoing colour: they still pay a fee. - static func color(for direction: UInt32) -> Color { + static func color(for direction: UInt32?) -> Color { + guard let direction else { return .secondary } switch direction { case CoreDirectionCode.incoming: return .green case CoreDirectionCode.outgoing, CoreDirectionCode.internalTransfer: return .red diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift index e38acb46e4a..3d6eaffa633 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/TransactionListView.swift @@ -221,9 +221,10 @@ struct TransactionListView: View { struct TransactionRowView: View { let transaction: PersistentTransaction var walletId: Data? = nil - /// This wallet's Rust-emitted amount (see `PersistentTransaction.netAmount(for:)`). - private var netAmount: Int64 { transaction.displayNetAmount(for: walletId) } - private var direction: UInt32 { transaction.displayDirectionCode(for: walletId) } + /// This wallet's Rust-emitted amount and direction; `nil` when Rust + /// emitted no accounting for it (see `PersistentTransaction.netAmount(for:)`). + private var netAmount: Int64? { transaction.displayNetAmount(for: walletId) } + private var direction: UInt32? { transaction.displayDirectionCode(for: walletId) } /// Asset-lock payload funding amount, excluding the Core transaction fee. var assetLockAmountDuffs: Int64? = nil /// The DashPay payment this tx belongs to, if any — joined by `txid` in @@ -388,7 +389,7 @@ struct TransactionRowView: View { .font(.headline) .foregroundColor(typeColor) - if let fee = transaction.displayFee(for: walletId), netAmount < 0 { + if let fee = transaction.displayFee(for: walletId), let amount = netAmount, amount < 0 { Text("Fee: \(formatFee(fee))") .font(.caption2) .foregroundColor(.secondary) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index a567917dedf..d2d429ac86e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -145,6 +145,11 @@ struct StorageExplorerView: View { modelRow("Pending Inputs", icon: "hourglass", type: PersistentPendingInput.self) { PendingInputStorageListView(network: network) } + modelRow( + "Transaction Accounting", icon: "plusminus.circle", type: PersistentTransactionAccounting.self + ) { + TransactionAccountingStorageListView(network: network) + } modelRow("Asset Locks", icon: "lock.shield", type: PersistentAssetLock.self) { AssetLockStorageListView(network: network) } @@ -328,6 +333,9 @@ struct StorageExplorerView: View { filteredCount(PersistentPendingInput.self) { walletsOnNetwork.contains($0.walletId) } + filteredCount(PersistentTransactionAccounting.self) { + walletsOnNetwork.contains($0.walletId) + } filteredCount(PersistentShieldedNote.self) { walletsOnNetwork.contains($0.walletId) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index c7a3724fd63..a3433e034fd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -1757,6 +1757,53 @@ struct TxoStorageListView: View { } } +// MARK: - PersistentTransactionAccounting + +/// Every per-wallet accounting row `rs-platform-wallet` emitted, newest +/// first: one per `(walletId, txid)`, so a transaction shared by two local +/// wallets appears twice. +struct TransactionAccountingStorageListView: View { + let network: Network + @Query(sort: [SortDescriptor(\PersistentTransactionAccounting.lastUpdated, order: .reverse)]) + private var records: [PersistentTransactionAccounting] + + @Query private var allWallets: [PersistentWallet] + + private var scopedRecords: [PersistentTransactionAccounting] { + let ids = Set(allWallets.lazy + .filter { $0.networkRaw == network.rawValue } + .map(\.walletId)) + return records.filter { ids.contains($0.walletId) } + } + + var body: some View { + let visible = scopedRecords + List(visible) { record in + NavigationLink(destination: TransactionAccountingStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.txid.reversed().map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1).truncationMode(.middle) + HStack(spacing: 8) { + Text(PersistentTransaction.format(duffs: record.netAmount)) + .font(.caption2) + Spacer() + Text(record.walletId.prefix(4).map { String(format: "%02x", $0) }.joined()) + .font(.system(.caption2, design: .monospaced)) + .foregroundColor(.secondary) + } + } + } + } + .navigationTitle("Transaction Accounting (\(visible.count))") + .overlay { + if visible.isEmpty { + ContentUnavailableView("No Accounting Rows", systemImage: "plusminus.circle") + } + } + } +} + // MARK: - PersistentPendingInput /// Diagnostic list of every `PersistentPendingInput` row — one per diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 7fe6ed1cd28..d6b06c037a4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -1590,6 +1590,10 @@ struct WalletStorageDetailView: View { FieldRow(label: "Birth Height", value: "\(record.birthHeight)") FieldRow(label: "Synced Height", value: "\(record.syncedHeight)") FieldRow(label: "Imported", value: record.isImported ? "Yes" : "No") + FieldRow( + label: "Accounting Backfilled", + value: record.transactionAccountingBackfilled ? "Yes" : "No" + ) } Section("Relationships") { FieldRow(label: "Accounts", value: "\(record.accounts.count)") @@ -2180,6 +2184,40 @@ struct TxoStorageDetailView: View { } } +// MARK: - PersistentTransactionAccounting + +struct TransactionAccountingStorageDetailView: View { + let record: PersistentTransactionAccounting + + var body: some View { + Form { + Section("Accounting") { + FieldRow(label: "Net Amount", value: PersistentTransaction.format(duffs: record.netAmount)) + FieldRow(label: "Direction", value: "\(record.direction)") + FieldRow(label: "Fee", value: record.fee.map { "\($0) duffs" } ?? "—") + } + Section("Keys") { + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "TXID", value: record.txid.reversed().map { String(format: "%02x", $0) }.joined()) + } + Section("Relationships") { + if let transaction = record.transaction { + NavigationLink(destination: TransactionStorageDetailView(record: transaction)) { + FieldRow(label: "Transaction", value: transaction.txidHex) + } + } else { + FieldRow(label: "Transaction", value: "— (unlinked)") + } + } + Section("Timestamps") { + FieldRow(label: "Last Updated", value: dateString(record.lastUpdated)) + } + } + .navigationTitle("Transaction Accounting") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentPendingInput struct PendingInputStorageDetailView: View { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift index 246381c1daf..c2955b0621b 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/TransactionAccountingTests.swift @@ -31,20 +31,40 @@ final class TransactionAccountingTests: XCTestCase { } /// Hand the handler `rows` the way Rust's extension slot does: inside a - /// round, or on their own like the load-time backfill. + /// round, optionally followed by the backfill's completion marker. @discardableResult private func account( _ handler: PlatformWalletPersistenceHandler, walletId: Data, - _ rows: [TransactionAccountingFFI], inRound: Bool = true + _ rows: [TransactionAccountingFFI], markBackfilled: Bool = false, success: Bool = true ) -> Bool { - if inRound { handler.beginChangeset(walletId: walletId) } - let stored = rows.withUnsafeBufferPointer { + handler.beginChangeset(walletId: walletId) + var stored = rows.withUnsafeBufferPointer { handler.persistTransactionAccounting(walletId: walletId, rows: $0.baseAddress, count: UInt($0.count)) } - if inRound { XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) } + if markBackfilled { + stored = handler.persistCoreHistoryAccounted(walletId: walletId) && stored + } + _ = handler.endChangeset(walletId: walletId, success: success) return stored } + /// The bodies `loadCoreHistoryForAccounting` offers, returning the loan. + private func offered( + _ handler: PlatformWalletPersistenceHandler, walletId: Data + ) throws -> (backfilled: Bool, bodies: [Data]) { + let history = handler.loadCoreHistoryForAccounting(walletId: walletId) + XCTAssertFalse(history.errored) + guard let rows = history.rows else { + XCTAssertEqual(history.count, 0) + return (history.backfilled, []) + } + let bodies = (0.. Data in + Data(bytes: rows[index].tx_bytes!, count: Int(rows[index].tx_bytes_len)) + } + handler.loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer(rows)) + return (history.backfilled, bodies) + } + private func transaction(_ container: ModelContainer, _ txid: Data) throws -> PersistentTransaction { try XCTUnwrap(ModelContext(container).fetch(FetchDescriptor()).first { $0.txid == txid }) } @@ -135,15 +155,31 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.outgoing) } - /// The load-time backfill arrives outside any round and commits itself. - func testShouldCommitAccountingDeliveredOutsideARound() throws { + /// The load-time backfill's rows and marker commit with its round. + func testShouldCommitTheBackfillsRowsAndMarkerTogether() throws { let (container, handler) = try makeStore() persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) XCTAssertTrue(account(handler, walletId: walletA, - [row(txid, net: -60, direction: CoreDirectionCode.outgoing)], inRound: false)) + [row(txid, net: -60, direction: CoreDirectionCode.outgoing)], markBackfilled: true)) XCTAssertEqual(try transaction(container, txid).netAmount(for: walletA), -60) + XCTAssertEqual(try offered(handler, walletId: walletA).backfilled, true) + } + + /// A failed backfill round leaves neither rows nor marker, so the next + /// load offers the history again. + func testShouldDropTheBackfillsRowsAndMarkerWhenItsRoundFails() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1]), net: 40) + + account(handler, walletId: walletA, + [row(txid, net: -60, direction: CoreDirectionCode.outgoing)], markBackfilled: true, success: false) + + XCTAssertNil(try transaction(container, txid).netAmount(for: walletA)) + let history = try offered(handler, walletId: walletA) + XCTAssertFalse(history.backfilled) + XCTAssertEqual(history.bodies, [Data([1])]) } func testShouldIgnoreAccountingForATransactionTheStoreDoesNotHold() throws { @@ -170,31 +206,33 @@ final class TransactionAccountingTests: XCTestCase { XCTAssertEqual(stored.direction(for: walletA), CoreDirectionCode.internalTransfer) } - /// Without a row for the wallet, display falls back to the shared scalar. - func testShouldFallBackToTheSharedValuesWithoutAWalletRow() { + /// Without a row for the wallet there is no amount to show for it; the + /// shared scalar answers only unscoped views. + func testShouldShowNoAccountingWithoutAWalletRow() { let tx = PersistentTransaction( txid: txid, transactionData: Data(), direction: CoreDirectionCode.incoming, netAmount: 25 ) - XCTAssertEqual(tx.netAmount(for: walletA), 25) - XCTAssertEqual(tx.direction(for: walletA), CoreDirectionCode.incoming) + XCTAssertNil(tx.netAmount(for: walletA)) + XCTAssertNil(tx.direction(for: walletA)) + XCTAssertEqual(tx.formattedAmount(for: walletA), PersistentTransaction.noAccounting) XCTAssertEqual(tx.displayNetAmount(for: nil), 25) + XCTAssertEqual(tx.displayDirectionCode(for: nil), CoreDirectionCode.incoming) } - func testShouldOfferNoHistoryWhenEveryTransactionHasTheWalletsRow() throws { + func testShouldOfferNothingWhenEveryTransactionHasTheWalletsRow() throws { let (_, handler) = try makeStore() persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) - let history = handler.loadCoreHistoryForAccounting(walletId: walletA) + let history = try offered(handler, walletId: walletA) - XCTAssertFalse(history.errored) - XCTAssertNil(history.rows) - XCTAssertEqual(history.count, 0) + XCTAssertFalse(history.backfilled, "Rust still has to record completion") + XCTAssertEqual(history.bodies, []) } - /// Rust needs the whole history to resolve a flagged spend's inputs, so - /// every transaction crosses, flagged by whether it lacks a row. - func testShouldOfferTheWholeHistoryFlaggingTransactionsWithoutARow() throws { + /// Only this wallet's transactions lacking its row cross; Rust fetches + /// any funding transaction it needs on its own. Every loan is returned. + func testShouldOfferOnlyTheWalletsUnaccountedTransactions() throws { let (_, handler) = try makeStore() let other = Data(repeating: 4, count: 32) persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) @@ -202,18 +240,66 @@ final class TransactionAccountingTests: XCTestCase { persist(handler, walletId: walletB, txid: Data(repeating: 5, count: 32), bytes: Data([5]), net: 1) account(handler, walletId: walletA, [row(txid, net: 40, direction: CoreDirectionCode.incoming)]) - let history = handler.loadCoreHistoryForAccounting(walletId: walletA) + let history = try offered(handler, walletId: walletA) - XCTAssertFalse(history.errored) - let rows = try XCTUnwrap(history.rows) - let offered = (0.. (Data, Bool) in - let entry = rows[index] - return (Data(bytes: entry.tx_bytes!, count: Int(entry.tx_bytes_len)), entry.needs_accounting) + XCTAssertFalse(history.backfilled) + XCTAssertEqual(history.bodies, [Data([7, 8, 9])]) + XCTAssertEqual(handler.outstandingCoreHistoryLoanCount, 0) + } + + /// Once the marker is stored, later loads skip the history entirely. + func testShouldReportABackfilledWalletWithoutOfferingHistory() throws { + let (_, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + account(handler, walletId: walletA, [], markBackfilled: true) + + let history = try offered(handler, walletId: walletA) + + XCTAssertTrue(history.backfilled) + XCTAssertEqual(history.bodies, []) + } + + /// The backfill end to end through the C shims Rust calls: read the + /// unaccounted body, return the loan, write the row and the marker in + /// one round, then report the wallet backfilled. + func testShouldDriveTheBackfillThroughTheExtensionShims() throws { + let (container, handler) = try makeStore() + persist(handler, walletId: walletA, txid: txid, bytes: Data([1, 2]), net: 40) + let callbacks = handler.makePersistenceCallbacksExtension() + let load = try XCTUnwrap(callbacks.on_load_core_history_for_accounting_fn) + let free = try XCTUnwrap(callbacks.on_load_core_history_for_accounting_free_fn) + let persistRows = try XCTUnwrap(callbacks.on_persist_transaction_accounting_fn) + let accounted = try XCTUnwrap(callbacks.on_persist_core_history_accounted_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + let rows = [row(txid, net: -60, direction: CoreDirectionCode.outgoing, fee: 5)] + + try walletA.withUnsafeBytes { wallet in + let walletBytes = try XCTUnwrap(wallet.bindMemory(to: UInt8.self).baseAddress) + var backfilled = true + var entries: UnsafePointer? + var count: UInt = 0 + XCTAssertEqual(load(context, walletBytes, &backfilled, &entries, &count), 0) + XCTAssertFalse(backfilled) + XCTAssertEqual(count, 1) + let entry = try XCTUnwrap(entries)[0] + XCTAssertEqual(Data(bytes: try XCTUnwrap(entry.tx_bytes), count: Int(entry.tx_bytes_len)), Data([1, 2])) + free(context, entries, count) + XCTAssertEqual(handler.outstandingCoreHistoryLoanCount, 0) + + handler.beginChangeset(walletId: walletA) + XCTAssertEqual(rows.withUnsafeBufferPointer { + persistRows(context, walletBytes, $0.baseAddress, UInt($0.count)) + }, 0) + XCTAssertEqual(accounted(context, walletBytes), 0) + XCTAssertTrue(handler.endChangeset(walletId: walletA, success: true)) + + XCTAssertEqual(load(context, walletBytes, &backfilled, &entries, &count), 0) + XCTAssertTrue(backfilled) + XCTAssertEqual(count, 0) + XCTAssertNil(entries) + XCTAssertEqual(load(nil, walletBytes, &backfilled, &entries, &count), 1) } - XCTAssertEqual(offered.count, 2, "only wallet A's transactions") - XCTAssertEqual(offered.first { $0.0 == Data([1, 2]) }?.1, false) - XCTAssertEqual(offered.first { $0.0 == Data([7, 8, 9]) }?.1, true) - handler.loadCoreHistoryForAccountingFree(rows: UnsafeRawPointer(rows)) + XCTAssertEqual(try transaction(container, txid).fee(for: walletA), 5) } func testShouldDeleteOnlyTheDepartingWalletsRows() throws { From 01ceb6cabe415865a3170cf2cef449419faacb50 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:29:35 +0000 Subject: [PATCH 45/84] fix(platform-wallet): leave a wallet spend with an unresolved coin unaccounted until it resolves The history backfill read an input whose funding transaction it could not get as foreign, so a payment with change was written as an `Incoming` receipt of the change and the completion marker made that permanent. - An unresolved input whose scriptSig redeems a P2PKH script the wallet owns (the wallet signed it) now holds back that transaction's row; genuine receipts, whose inputs are always foreign, still get theirs. `history_accounting` returns `HistoryAccounting { rows, unresolved }`. - `store_history_accounting` takes `complete`: a round that left any transaction unaccounted writes its rows but not the marker, so the next load retries the rest. - Funding bodies are read through the new strict `get_core_transaction`: a stored transaction the host cannot hand over fails the round instead of reading as missing. - The backfill runs for every wallet a load saw, including ones the idempotent reload skips, so an unfinished backfill is retried at the next load, not only at the next launch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../rs-platform-wallet-ffi/src/persistence.rs | 36 ++- .../rs-platform-wallet/src/changeset/mod.rs | 2 +- .../src/changeset/traits.rs | 45 +++- .../src/changeset/wallet_accounting.rs | 195 ++++++++++++-- .../rs-platform-wallet/src/manager/load.rs | 247 +++++++++++++++--- 5 files changed, 455 insertions(+), 70 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index e0c2f585202..0309bf18b7a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -360,7 +360,11 @@ pub type FreeCoreHistoryForAccountingFn = unsafe extern "C" fn( /// Record that this wallet's history backfill is complete, so the next /// [`LoadCoreHistoryForAccountingFn`] reports it backfilled. Fired in the -/// backfill's round after its rows, and committed with them. +/// backfill's round after its rows, and committed with them, only when every +/// transaction got its row. A round that had to leave a transaction +/// unaccounted (a wallet-signed input whose funding transaction is not +/// available yet) commits its rows without it, so the next load reads the +/// rest again. pub type PersistCoreHistoryAccountedFn = unsafe extern "C" fn(context: *mut c_void, wallet_id: *const u8) -> i32; @@ -1977,13 +1981,14 @@ impl PlatformWalletPersistence for FFIPersister { Ok((!backfilled).then_some(out)) } - /// The backfill's rows and its completion marker, as one round under - /// `round_lock`: they commit together or not at all, and never land in - /// another wallet's open round. + /// The backfill's rows and, when `complete`, its completion marker, as + /// one round under `round_lock`: they commit together or not at all, and + /// never land in another wallet's open round. fn store_history_accounting( &self, wallet_id: WalletId, rows: &[WalletAccounting], + complete: bool, ) -> Result<(), PersistenceError> { let (Some(persist_rows), Some(accounted)) = ( self.persist_transaction_accounting_callback, @@ -2022,7 +2027,7 @@ impl PlatformWalletPersistence for FFIPersister { outcome.record(result); } } - if outcome.is_success() { + if complete && outcome.is_success() { let result = unsafe { accounted(self.callbacks.context, wallet_id.as_ptr()) }; if result != 0 { outcome.record(result); @@ -10296,7 +10301,7 @@ mod tests { // Rows and marker commit in one round. persister - .store_history_accounting([1; 32], &[row]) + .store_history_accounting([1; 32], &[row], true) .expect("backfill round must succeed"); assert_eq!( *host.events.lock().unwrap(), @@ -10312,17 +10317,32 @@ mod tests { // Nothing to write still records completion. let host = make_host(false, false); persister_for(&host, extensions) - .store_history_accounting([1; 32], &[]) + .store_history_accounting([1; 32], &[], true) .expect("empty backfill round must succeed"); assert_eq!( *host.events.lock().unwrap(), vec!["begin", "accounted", "end success=true"] ); + // An incomplete backfill writes its rows but no marker, so it stays + // open for the next load. + let host = make_host(false, false); + persister_for(&host, extensions) + .store_history_accounting([1; 32], &[row], false) + .expect("incomplete backfill round must succeed"); + assert_eq!( + *host.events.lock().unwrap(), + vec![ + "begin", + "row net=-5000 direction=1 fee=250", + "end success=true" + ] + ); + // A failing row write rolls back, with no marker. let host = make_host(false, true); assert!(persister_for(&host, extensions) - .store_history_accounting([1; 32], &[row]) + .store_history_accounting([1; 32], &[row], true) .is_err()); assert_eq!( *host.events.lock().unwrap(), diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 6fcebc19ca3..1f7a38a3c1a 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -71,5 +71,5 @@ pub use traits::{ }; pub use wallet_accounting::{ funding_txids, history_accounting, is_owned, record_accounting, wallet_accounting, - wallet_direction, WalletAccounting, + wallet_direction, HistoryAccounting, WalletAccounting, }; diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index a7867d89510..24aaffe75c0 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -495,6 +495,28 @@ pub trait PlatformWalletPersistence: Send + Sync { Ok(None) } + /// The body of a stored Core transaction, for the accounting backfill. + /// + /// Stricter than [`Self::get_core_tx_record`], whose policy suits the + /// asset-lock and DashPay callers that retry a miss anyway: here + /// `Ok(None)` must mean only "this backend does not store `txid`". A + /// stored transaction is returned in any context, InstantSend included, + /// and a stored one that cannot be read (a failing backend, an + /// undecodable body) is an `Err`. The backfill then fails its round and + /// runs again at the next load, rather than reading a wallet-owned input + /// as someone else's. + /// + /// The default delegates to [`Self::get_core_tx_record`]. + fn get_core_transaction( + &self, + wallet_id: WalletId, + txid: &Txid, + ) -> Result, PersistenceError> { + Ok(self + .get_core_tx_record(wallet_id, txid)? + .map(|record| record.transaction)) + } + /// Enumerate the persisted Core transaction ids that belong to /// `wallet_id`, each tagged with whether the transaction spends an /// input this wallet funded. @@ -533,13 +555,18 @@ pub trait PlatformWalletPersistence: Send + Sync { /// [`WalletAccounting`] yet, or `None` once this wallet's history /// backfill has completed (see [`Self::store_history_accounting`]). /// - /// Read once per wallet at load. The manager computes the missing rows - /// with [`history_accounting`](crate::changeset::history_accounting), + /// Read at every load of the wallet until the backfill completes. The + /// manager computes the missing rows with + /// [`history_accounting`](crate::changeset::history_accounting), /// fetching the funding transactions it needs through - /// [`Self::get_core_tx_record`] itself, and hands them back through + /// [`Self::get_core_transaction`] itself, and hands them back through /// [`Self::store_history_accounting`]. Any subset is safe: a transaction /// left out only stays without a row. /// + /// A transaction without a row is "not yet accounted", never "accounted + /// as nothing": the host displays it without an amount or direction + /// until a backfill or a live update writes the row. + /// /// The default (`None`) suits backends that never stored rows without /// accounting, such as the SQLite store, whose own migration repairs /// history. @@ -551,14 +578,18 @@ pub trait PlatformWalletPersistence: Send + Sync { } /// Persist the backfilled rows (upsert keyed by `(txid, wallet_id)`) - /// and record that this wallet's history backfill is complete, as one - /// atomic write; `rows` may be empty. After it, - /// [`Self::load_core_history_for_accounting`] returns `None` for the - /// wallet. The default drops both. + /// and, when `complete`, record that this wallet's history backfill is + /// done, as one atomic write; `rows` may be empty. After a complete + /// write, [`Self::load_core_history_for_accounting`] returns `None` for + /// the wallet. An incomplete one (some transaction could not be + /// accounted yet) leaves the backfill open, so the next load reads the + /// transactions still without a row and tries again. The default drops + /// both. fn store_history_accounting( &self, _wallet_id: WalletId, _rows: &[WalletAccounting], + _complete: bool, ) -> Result<(), PersistenceError> { Ok(()) } diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 7f54b88215b..e1b013335ce 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -21,7 +21,9 @@ use std::collections::{BTreeMap, BTreeSet}; -use dashcore::{Address, OutPoint, ScriptBuf, Transaction, Txid}; +use dashcore::blockdata::script::Instruction; +use dashcore::hashes::Hash; +use dashcore::{Address, OutPoint, PubkeyHash, ScriptBuf, Transaction, TxIn, Txid}; use key_wallet::managed_account::managed_account_ref::ManagedAccountRef; use key_wallet::managed_account::managed_core_funds_account::ManagedCoreFundsAccount; use key_wallet::managed_account::transaction_record::{ @@ -144,15 +146,28 @@ pub fn funding_txids(wallet: &ManagedWalletInfo, unaccounted: &[Transaction]) -> /// - type, net, direction and fee follow [`record_accounting`]. /// /// A transaction that touches no owned script yields no row, like a -/// detail-less record. An owned input whose funding transaction the host no -/// longer stores, and that is no longer a UTXO, cannot be resolved and reads -/// as foreign. Any subset of a host's unaccounted transactions is therefore -/// safe to pass: omitting one only leaves it without a row. +/// detail-less record. +/// +/// An input whose prevout none of the above resolves is either someone +/// else's coin (every input of a genuine receipt) or the wallet's own coin +/// whose funding transaction the host could not supply. The two are told +/// apart by the input's redeem: when its scriptSig ends with a public key +/// whose P2PKH script the wallet owns, the wallet signed it. Such a +/// transaction gets no row and is listed in +/// [`HistoryAccounting::unresolved`] instead: reading the input as foreign +/// would turn a payment with change into an `Incoming` receipt of the +/// change. An input redeemed any other way (a script the wallet does not +/// spend from) reads as foreign. +/// +/// No row is never a wrong row: a transaction left without one is not yet +/// accounted, and the host shows it as such until a later backfill (or the +/// live projection) writes it. Any subset of a host's unaccounted +/// transactions is therefore safe to pass. pub fn history_accounting( wallet: &ManagedWalletInfo, unaccounted: &[Transaction], funding: &[Transaction], -) -> Vec { +) -> HistoryAccounting { let mut owned_scripts: BTreeSet = BTreeSet::new(); let mut prevouts: BTreeMap = BTreeMap::new(); for funds in wallet_funds(wallet) { @@ -176,13 +191,24 @@ pub fn history_accounting( } } - let mut rows = Vec::new(); + let mut accounted = HistoryAccounting::default(); for transaction in unaccounted { - let spent: Vec = transaction - .input - .iter() - .filter_map(|input| prevouts.get(&input.previous_output).copied()) - .collect(); + let mut spent: Vec = Vec::new(); + let mut owned_but_unresolved = false; + for input in &transaction.input { + match prevouts.get(&input.previous_output) { + Some(value) => spent.push(*value), + None if !input.previous_output.is_null() => { + owned_but_unresolved |= p2pkh_redeemed_script(input) + .is_some_and(|script| owned_scripts.contains(&script)); + } + None => {} + } + } + if owned_but_unresolved { + accounted.unresolved.push(transaction.txid()); + continue; + } let owned: Vec<(usize, u64)> = transaction .output .iter() @@ -210,14 +236,44 @@ pub fn history_accounting( &owned, &unspendable, ); - rows.push(WalletAccounting { + accounted.rows.push(WalletAccounting { txid: transaction.txid(), net_amount: saturating_net(accounting.net), direction: accounting.direction, fee: accounting.fee, }); } - rows + accounted +} + +/// What [`history_accounting`] made of a host's unaccounted transactions. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct HistoryAccounting { + /// One row per transaction it could account for. + pub rows: Vec, + /// Transactions left without a row because an input spends a coin the + /// wallet signed for but could not resolve. A later backfill retries + /// them. + pub unresolved: Vec, +} + +impl HistoryAccounting { + /// Whether every transaction that touches the wallet got its row, so the + /// backfill may be recorded as complete. + pub fn is_complete(&self) -> bool { + self.unresolved.is_empty() + } +} + +/// The P2PKH script `input` redeems, when its scriptSig ends with a public +/// key push (` `, how a P2PKH output is spent). `None` for any +/// other redeem. +fn p2pkh_redeemed_script(input: &TxIn) -> Option { + let Instruction::PushBytes(pubkey) = input.script_sig.instructions().last()?.ok()? else { + return None; + }; + let pubkey = pubkey.as_bytes(); + matches!(pubkey.len(), 33 | 65).then(|| ScriptBuf::new_p2pkh(&PubkeyHash::hash(pubkey))) } /// The wallet's funds accounts, without contacts' watch-only chains. @@ -353,6 +409,7 @@ mod tests { use dashcore::transaction::special_transaction::TransactionPayload; use dashcore::{Network, TxOut}; use key_wallet::account::{Account, AccountType, StandardAccountType}; + use key_wallet::managed_account::address_pool::PublicKeyType; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Wallet; @@ -408,7 +465,7 @@ mod tests { ) -> Vec { let (info, live, funding, spend) = live(spend).await; assert_eq!(live.len(), 2, "the live projection accounts for both"); - let backfilled = history_accounting(&info, &[funding, spend], &[]); + let backfilled = history_accounting(&info, &[funding, spend], &[]).rows; assert_eq!(backfilled, live); backfilled } @@ -500,7 +557,7 @@ mod tests { async fn should_resolve_inputs_from_funding_transactions_and_answer_only_unaccounted() { let (info, live, funding, spend) = live(payment_with_change).await; - let backfilled = history_accounting(&info, std::slice::from_ref(&spend), &[funding]); + let backfilled = history_accounting(&info, std::slice::from_ref(&spend), &[funding]).rows; assert_eq!(backfilled, vec![live[1]]); } @@ -517,7 +574,7 @@ mod tests { vec![pay(FUNDING - FEE, &foreign_address(9))], ); - let rows = history_accounting(&info, std::slice::from_ref(&spend), &[]); + let rows = history_accounting(&info, std::slice::from_ref(&spend), &[]).rows; assert_eq!( rows, @@ -563,7 +620,10 @@ mod tests { let (info, _, _, _) = live(payment_with_change).await; let unrelated = spend_of(&[outside(3)], vec![pay(5_000, &foreign_address(4))]); - assert!(history_accounting(&info, &[unrelated], &[]).is_empty()); + assert_eq!( + history_accounting(&info, &[unrelated], &[]), + HistoryAccounting::default() + ); } /// An output on a contact's watch-only chain is the contact's money, @@ -601,13 +661,110 @@ mod tests { vec![pay(FUNDING - FEE, &contact_address)], ); - let rows = history_accounting(&info, std::slice::from_ref(&to_contact), &[funding]); + let rows = history_accounting(&info, std::slice::from_ref(&to_contact), &[funding]).rows; assert_eq!(rows.len(), 1); assert_eq!(rows[0].net_amount, -(FUNDING as i64)); assert_eq!(rows[0].direction, TransactionDirection::Outgoing); } + /// `tx` with input `index` redeemed the P2PKH way, ` `. + fn signed_by(mut tx: Transaction, index: usize, pubkey: &[u8]) -> Transaction { + let signature = [0x30; 71]; + tx.input[index].script_sig = dashcore::script::Builder::new() + .push_slice(<&dashcore::script::PushBytes>::try_from(&signature[..]).unwrap()) + .push_slice(<&dashcore::script::PushBytes>::try_from(pubkey).unwrap()) + .into_script(); + tx + } + + /// The compressed public key behind one of the wallet's own addresses. + fn owned_pubkey(info: &ManagedWalletInfo, address: &Address) -> Vec { + let account = &info.accounts.standard_bip44_accounts[&0]; + match ManagedAccountRef::Funds(account) + .get_address_info(address) + .and_then(|info| info.public_key) + { + Some(PublicKeyType::ECDSA(bytes)) => bytes, + other => panic!("the wallet derived {address} from an ECDSA key, got {other:?}"), + } + } + + /// A spend signed by the wallet's own key whose coin the host no longer + /// holds (funding transaction gone, no longer a UTXO) cannot be accounted: + /// reading the input as foreign would turn a payment with change into + /// an `Incoming` receipt of the change. It gets no row and is reported + /// unresolved, and accounts normally once the funding body is passed. + #[test] + fn should_withhold_the_row_of_a_spend_whose_owned_input_cannot_be_resolved() { + let (wallet, mut info, receive) = wallet_with_receive_address(); + let xpub = wallet.accounts.standard_bip44_accounts[&0].account_xpub; + let change = info + .accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .next_change_address(Some(&xpub), true) + .unwrap(); + let funding = spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]); + let spend = signed_by( + payment_with_change(OutPoint::new(funding.txid(), 0), &change), + 0, + &owned_pubkey(&info, &receive), + ); + + let missing = history_accounting(&info, std::slice::from_ref(&spend), &[]); + assert_eq!( + missing, + HistoryAccounting { + rows: Vec::new(), + unresolved: vec![spend.txid()], + } + ); + assert!(!missing.is_complete()); + + let filled = history_accounting(&info, std::slice::from_ref(&spend), &[funding]); + assert_eq!( + filled.rows, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: CHANGE as i64 - FUNDING as i64, + direction: TransactionDirection::Outgoing, + fee: Some(FEE), + }] + ); + assert!(filled.is_complete()); + } + + /// A genuine receipt's inputs are someone else's and never resolve; that + /// must not hold back its row. + #[test] + fn should_still_account_a_receipt_whose_foreign_inputs_cannot_be_resolved() { + let (_, info, receive) = wallet_with_receive_address(); + let mut foreign_key = vec![0x02]; + foreign_key.extend([7u8; 32]); + let receipt = signed_by( + spend_of(&[outside(7)], vec![pay(FUNDING, &receive)]), + 0, + &foreign_key, + ); + + let accounted = history_accounting(&info, std::slice::from_ref(&receipt), &[]); + + assert_eq!( + accounted, + HistoryAccounting { + rows: vec![WalletAccounting { + txid: receipt.txid(), + net_amount: FUNDING as i64, + direction: TransactionDirection::Incoming, + fee: None, + }], + unresolved: Vec::new(), + } + ); + } + /// A record rebuilt at load from bytes alone (an asset lock's funding /// transaction) is re-emitted on chain-lock promotion with a placeholder /// net of 0: it must reach the host as "no accounting", not as 0. diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 96f6d277cdf..2a07ec6f12f 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -128,6 +128,10 @@ impl PlatformWalletManager

{ let mut inserted_in_wallets: Vec<(WalletId, Arc)> = Vec::new(); let mut load_error: Option = None; + // Wallets this load found already registered and skipped. Their + // accounting backfill still runs below: one that did not complete at + // an earlier load is retried by this one. + let mut already_registered: Vec = Vec::new(); // Each wallet's start state, and whether it was re-read under the // durable-cursor lock that `held` still holds (see the publication @@ -387,6 +391,7 @@ impl PlatformWalletManager

{ .get_wallet(&wallet_id) .is_some() { + already_registered.push(wallet_id); continue 'load; } if !reread && durable_cursors.contains_key(&wallet_id) { @@ -660,9 +665,16 @@ impl PlatformWalletManager

{ } // Past the rollback point. Accounting is display-only, so a failure - // here is logged and never fails the load. - for (wallet_id, _) in &inserted_in_wallets { - self.backfill_history_accounting(*wallet_id).await; + // here is logged and never fails the load. Every wallet this load saw + // is offered the backfill, so an unfinished one is retried at the + // next load; a finished one costs the host one marker read. + let seen: std::collections::BTreeSet = inserted_in_wallets + .iter() + .map(|(wallet_id, _)| *wallet_id) + .chain(already_registered) + .collect(); + for wallet_id in seen { + self.backfill_history_accounting(wallet_id).await; } // Past the rollback point: every registration here is one this load @@ -749,7 +761,9 @@ impl PlatformWalletManager

{ /// persister holds without one (see /// [`PlatformWalletPersistence::load_core_history_for_accounting`]). /// Nothing is written unless every step succeeds, so a failed backfill - /// simply runs again at the next load. + /// simply runs again at the next load. A transaction whose wallet-signed + /// input cannot be resolved yet gets no row and keeps the backfill open + /// (no completion marker), so the next load retries it. async fn backfill_history_accounting(&self, wallet_id: WalletId) { let persister = Arc::clone(&self.persister); let unaccounted = @@ -775,12 +789,14 @@ impl PlatformWalletManager

{ funding_txids(&info.core_wallet, &unaccounted) }; // Fetched without the manager lock held: each lookup calls the host. + // A stored funding transaction the host cannot hand over fails the + // round: read as missing, a wallet input could pass for a foreign one. let persister = Arc::clone(&self.persister); let funding = match run_blocking_load(move || { let mut funding = Vec::new(); for txid in &needed { - if let Some(record) = persister.get_core_tx_record(wallet_id, txid)? { - funding.push(record.transaction); + if let Some(transaction) = persister.get_core_transaction(wallet_id, txid)? { + funding.push(transaction); } } Ok(funding) @@ -797,17 +813,29 @@ impl PlatformWalletManager

{ return; } }; - let rows = { + let accounted = { let wm = self.wallet_manager.read().await; let Some(info) = wm.get_wallet_info(&wallet_id) else { return; }; history_accounting(&info.core_wallet, &unaccounted, &funding) }; + let complete = accounted.is_complete(); + if !complete { + tracing::info!( + wallet_id = %hex::encode(wallet_id), + unresolved = accounted.unresolved.len(), + "load: some stored transactions spend wallet coins whose funding \ + transaction is unavailable; leaving them unaccounted for the next load" + ); + } + let rows = accounted.rows; let persister = Arc::clone(&self.persister); let count = rows.len(); - if let Err(e) = - run_blocking_load(move || persister.store_history_accounting(wallet_id, &rows)).await + if let Err(e) = run_blocking_load(move || { + persister.store_history_accounting(wallet_id, &rows, complete) + }) + .await { tracing::warn!( wallet_id = %hex::encode(wallet_id), @@ -1405,15 +1433,20 @@ mod idempotent_load_tests { /// Hands back the stored transactions lacking accounting (`None` once /// backfilled), serves funding transactions by txid, and records what - /// the manager persists. + /// the manager persists. Like a host, a stored row takes its + /// transaction out of the unaccounted set and a complete backfill sets + /// the marker. struct HistoryPersister { wallet: Wallet, managed: ManagedWalletInfo, - unaccounted: Option>, - stored_transactions: BTreeMap, + unaccounted: Mutex>>, + stored_transactions: Mutex>, failing_lookup: bool, + /// Every stored transaction is found but cannot be handed over: the + /// lenient record read reports a miss, the strict body read fails. + unusable_funding: bool, lookups: Mutex>, - stored: Mutex)>>, + stored: Mutex, bool)>>, } impl PlatformWalletPersistence for HistoryPersister { @@ -1442,40 +1475,72 @@ mod idempotent_load_tests { _wallet_id: WalletId, txid: &Txid, ) -> Result, PersistenceError> { - self.lookups.lock().unwrap().push(*txid); if self.failing_lookup { return Err(PersistenceError::backend("store unreadable")); } - Ok(self.stored_transactions.get(txid).map(|tx| { - TransactionRecord::new( - tx.clone(), - AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - }, - TransactionContext::Mempool, - TransactionType::Standard, - TransactionDirection::Incoming, - Vec::new(), - Vec::new(), - 0, - ) - })) + if self.unusable_funding { + return Ok(None); + } + Ok(self + .stored_transactions + .lock() + .unwrap() + .get(txid) + .map(|tx| { + TransactionRecord::new( + tx.clone(), + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + }, + TransactionContext::Mempool, + TransactionType::Standard, + TransactionDirection::Incoming, + Vec::new(), + Vec::new(), + 0, + ) + })) + } + + fn get_core_transaction( + &self, + wallet_id: WalletId, + txid: &Txid, + ) -> Result, PersistenceError> { + self.lookups.lock().unwrap().push(*txid); + if self.unusable_funding && self.stored_transactions.lock().unwrap().contains_key(txid) + { + return Err(PersistenceError::backend("stored but unreadable")); + } + Ok(self + .get_core_tx_record(wallet_id, txid)? + .map(|record| record.transaction)) } fn load_core_history_for_accounting( &self, _wallet_id: WalletId, ) -> Result>, PersistenceError> { - Ok(self.unaccounted.clone()) + Ok(self.unaccounted.lock().unwrap().clone()) } fn store_history_accounting( &self, wallet_id: WalletId, rows: &[WalletAccounting], + complete: bool, ) -> Result<(), PersistenceError> { - self.stored.lock().unwrap().push((wallet_id, rows.to_vec())); + self.stored + .lock() + .unwrap() + .push((wallet_id, rows.to_vec(), complete)); + let mut unaccounted = self.unaccounted.lock().unwrap(); + if complete { + *unaccounted = None; + } else if let Some(unaccounted) = unaccounted.as_mut() { + unaccounted.retain(|tx| rows.iter().all(|row| row.txid != tx.txid())); + } Ok(()) } } @@ -1511,9 +1576,12 @@ mod idempotent_load_tests { HistoryPersister { wallet, managed, - unaccounted, - stored_transactions: stored.iter().map(|tx| (tx.txid(), (*tx).clone())).collect(), + unaccounted: Mutex::new(unaccounted), + stored_transactions: Mutex::new( + stored.iter().map(|tx| (tx.txid(), (*tx).clone())).collect(), + ), failing_lookup: false, + unusable_funding: false, lookups: Mutex::new(Vec::new()), stored: Mutex::new(Vec::new()), } @@ -1559,7 +1627,8 @@ mod idempotent_load_tests { direction: TransactionDirection::Outgoing, fee: Some(26_000), }, - ] + ], + true )] ); assert!( @@ -1585,6 +1654,7 @@ mod idempotent_load_tests { assert_eq!(*persister.lookups.lock().unwrap(), vec![funding.txid()]); let stored = persister.stored.lock().unwrap().clone(); assert_eq!(stored.len(), 1); + assert!(stored[0].2, "every transaction was accounted"); assert_eq!( stored[0].1, vec![WalletAccounting { @@ -1610,6 +1680,110 @@ mod idempotent_load_tests { assert!(persister.stored.lock().unwrap().is_empty()); } + /// A funding transaction the host stores but cannot hand over (an + /// InstantSend-only row the lenient record read drops, a failing read) + /// fails the round: nothing is written, not even the rows that did + /// resolve, so the next load runs the whole backfill again. + #[tokio::test] + async fn load_writes_no_backfill_when_a_stored_funding_transaction_is_unusable() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let mut persister = history_persister( + wallet, + without_utxos(managed), + Some(vec![spend]), + &[&funding], + ); + persister.unusable_funding = true; + let persister = backfill(persister).await; + + assert_eq!(*persister.lookups.lock().unwrap(), vec![funding.txid()]); + assert!(persister.stored.lock().unwrap().is_empty()); + } + + /// `spend` with its input redeemed by the key behind `funding`'s output + /// 0, the wallet's own receive address: what the wallet itself signed. + fn signed_by_the_wallet( + managed: &ManagedWalletInfo, + funding: &Transaction, + mut spend: Transaction, + ) -> Transaction { + use key_wallet::managed_account::address_pool::PublicKeyType; + use key_wallet::managed_account::ManagedAccountRef; + + let address = + dashcore::Address::from_script(&funding.output[0].script_pubkey, managed.network) + .expect("the funding pays a P2PKH address"); + let Some(PublicKeyType::ECDSA(pubkey)) = + ManagedAccountRef::Funds(&managed.accounts.standard_bip44_accounts[&0]) + .get_address_info(&address) + .and_then(|info| info.public_key) + else { + panic!("the wallet derived the funded address from an ECDSA key"); + }; + spend.input[0].script_sig = dashcore::script::Builder::new() + .push_slice(<&dashcore::script::PushBytes>::try_from(&[0x30; 71][..]).unwrap()) + .push_slice(<&dashcore::script::PushBytes>::try_from(&pubkey[..]).unwrap()) + .into_script(); + spend + } + + /// A spend the wallet signed whose funding transaction the host does not + /// have yet is left without a row and keeps the backfill open: no + /// completion marker. A later load of the same, already registered, + /// wallet retries it and writes the row once the funding transaction is + /// there, and only then completes. A load after that reads nothing. + #[tokio::test] + async fn load_leaves_an_unresolvable_wallet_spend_open_then_fills_it_at_a_later_load() { + let (wallet, managed, funding, spend) = funded_wallet().await; + let wallet_id = wallet.compute_wallet_id(); + let managed = without_utxos(managed); + let spend = signed_by_the_wallet(&managed, &funding, spend); + let persister = Arc::new(history_persister( + wallet, + managed, + Some(vec![spend.clone()]), + &[], + )); + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + let manager = PlatformWalletManager::new(sdk, Arc::clone(&persister), event_handler); + + manager.load_from_persistor().await.expect("first load"); + assert_eq!( + *persister.stored.lock().unwrap(), + vec![(wallet_id, Vec::new(), false)], + "skipped: no row, and the backfill stays open" + ); + + persister + .stored_transactions + .lock() + .unwrap() + .insert(funding.txid(), funding.clone()); + manager.load_from_persistor().await.expect("second load"); + assert_eq!( + persister.stored.lock().unwrap()[1..], + [( + wallet_id, + vec![WalletAccounting { + txid: spend.txid(), + net_amount: -100_000, + direction: TransactionDirection::Outgoing, + fee: Some(26_000), + }], + true + )], + "filled by the retry on the already registered wallet" + ); + + manager.load_from_persistor().await.expect("third load"); + assert_eq!( + persister.stored.lock().unwrap().len(), + 2, + "a completed backfill is not run again" + ); + } + /// Nothing unaccounted still completes the backfill, so the host stops /// being asked; an already backfilled wallet is left alone. #[tokio::test] @@ -1623,7 +1797,10 @@ mod idempotent_load_tests { &[], )) .await; - assert_eq!(*empty.stored.lock().unwrap(), vec![(wallet_id, Vec::new())]); + assert_eq!( + *empty.stored.lock().unwrap(), + vec![(wallet_id, Vec::new(), true)] + ); let finished = backfill(history_persister(wallet, managed, None, &[])).await; assert!(finished.stored.lock().unwrap().is_empty()); From 4b0879a66e47eaf9d71686f410050d5411e9f457 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:36:22 +0000 Subject: [PATCH 46/84] fix(platform-wallet-ffi): read accounting funding bodies strictly and refuse an unbracketed backfill - `get_core_transaction` shares one raw read with `get_core_tx_record` but keeps every hit, InstantSend included, and reports a non-zero return, a hit without bytes or an undecodable body as an error, so the backfill fails its round instead of reading a wallet input as foreign. `get_core_tx_record` keeps its miss policy for the asset-lock and DashPay callers. - A history answer with a null array and a non-zero count is an error (the loan is still freed), not an empty history that completes the backfill. - The backfill needs the changeset begin/end pair like the other atomic capabilities: without it the host is not read and a store writes nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../rs-platform-wallet-ffi/src/persistence.rs | 529 +++++++++++++----- 1 file changed, 381 insertions(+), 148 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 0309bf18b7a..0828d7eef04 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -340,9 +340,10 @@ pub type PersistTransactionAccountingFn = unsafe extern "C" fn( /// stored for this wallet. Otherwise returns the wallet's stored /// transactions that have no accounting row for it; any subset is safe, /// since Rust fetches the funding transactions it needs itself -/// (`on_get_core_tx_record_fn`) and a transaction left out only stays -/// without a row. The host keeps the array valid until -/// [`FreeCoreHistoryForAccountingFn`]. +/// (`on_get_core_tx_record_fn`, where a hit in any context is used and a +/// non-zero return fails the backfill) and a transaction left out only +/// stays without a row. A null array with a non-zero count is an error. The +/// host keeps the array valid until [`FreeCoreHistoryForAccountingFn`]. pub type LoadCoreHistoryForAccountingFn = unsafe extern "C" fn( context: *mut c_void, wallet_id: *const u8, @@ -565,7 +566,8 @@ pub struct PersistenceCallbacksExtension { ), >, /// Backfill completion marker (see [`PersistCoreHistoryAccountedFn`]). - /// The backfill runs only when all four accounting slots are present. + /// The backfill runs only when all four accounting slots and the + /// changeset begin/end pair are present. pub on_persist_core_history_accounted_fn: Option i32>, } @@ -1845,6 +1847,120 @@ impl FFIPersister { } } +/// What `on_get_core_tx_record_fn` answered for one txid. +enum CoreTxRead { + /// The callback is unset, or the host does not store the txid. + Missing, + /// The callback returned this non-zero code. + Failed(i32), + /// The host reported a hit but handed over no decodable body. + Unreadable(&'static str), + /// A hit: the host's raw context fields and the decoded body. + Found { + context_kind: u8, + block_height: u32, + block_hash: [u8; 32], + block_timestamp: u32, + transaction: dashcore::Transaction, + }, +} + +impl FFIPersister { + /// Call `on_get_core_tx_record_fn` for `txid` and decode its answer, + /// handing the tx-bytes buffer back to the host on every path. Each + /// caller applies its own policy to the outcome. + fn read_core_tx(&self, wallet_id: WalletId, txid: &dashcore::Txid) -> CoreTxRead { + use dashcore::consensus::Decodable; + use dashcore::hashes::Hash; + + let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { + return CoreTxRead::Missing; + }; + + let txid_bytes: [u8; 32] = *txid.as_byte_array(); + let mut context_kind: u8 = 0; + let mut block_height: u32 = 0; + let mut block_hash: [u8; 32] = [0u8; 32]; + let mut block_timestamp: u32 = 0; + let mut tx_bytes_ptr: *const u8 = std::ptr::null(); + let mut tx_bytes_len: usize = 0; + let mut found: bool = false; + + // SAFETY: All output pointers reference Rust-owned stack + // locals that outlive the callback invocation. `wallet_id` + // and `txid` are fixed-size byte arrays. + let rc = unsafe { + get_cb( + self.callbacks.context, + wallet_id.as_ptr(), + txid_bytes.as_ptr(), + &mut context_kind, + &mut block_height, + block_hash.as_mut_ptr(), + &mut block_timestamp, + &mut tx_bytes_ptr, + &mut tx_bytes_len, + &mut found, + ) + }; + + // RAII guard so the tx-bytes free callback fires on every + // exit path past this point, handing the buffer back to the host. + struct TxBytesGuard { + ptr: *const u8, + len: usize, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + tx_bytes: *const u8, + tx_bytes_len: usize, + ), + >, + ctx: *mut c_void, + } + impl Drop for TxBytesGuard { + fn drop(&mut self) { + if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) { + // SAFETY: ptr+len match the values the lookup + // callback wrote; the host owns the allocation + // until this free fires. + unsafe { free(self.ctx, self.ptr, self.len) }; + } + } + } + let _bytes_guard = TxBytesGuard { + ptr: tx_bytes_ptr, + len: tx_bytes_len, + free_fn: self.callbacks.on_get_core_tx_record_free_fn, + ctx: self.callbacks.context, + }; + + if rc != 0 { + return CoreTxRead::Failed(rc); + } + if !found { + return CoreTxRead::Missing; + } + if tx_bytes_ptr.is_null() || tx_bytes_len == 0 { + return CoreTxRead::Unreadable("handed over no transaction bytes"); + } + // SAFETY: the host guarantees `tx_bytes_ptr` points to + // `tx_bytes_len` valid bytes until the free callback — + // `_bytes_guard` keeps that window open until this returns. + let tx_slice = unsafe { slice::from_raw_parts(tx_bytes_ptr, tx_bytes_len) }; + match dashcore::Transaction::consensus_decode(&mut &tx_slice[..]) { + Ok(transaction) => CoreTxRead::Found { + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + }, + Err(_) => CoreTxRead::Unreadable("its transaction bytes do not decode"), + } + } +} + impl PlatformWalletPersistence for FFIPersister { /// A host attests `ATOMIC_CHANGESETS` for a round it commits or rolls back /// whole; nothing of a rolled-back round survives on this side either, so @@ -1934,13 +2050,15 @@ impl PlatformWalletPersistence for FFIPersister { &self, wallet_id: WalletId, ) -> Result>, PersistenceError> { - // A backfill that could not be stored or completed is not worth - // reading. - let (Some(load), Some(free), Some(_), Some(_)) = ( + // A backfill that could not be stored or completed, or not as one + // round, is not worth reading. + let (Some(load), Some(free), Some(_), Some(_), Some(_), Some(_)) = ( self.load_core_history_for_accounting_callback, self.load_core_history_for_accounting_free_callback, self.persist_transaction_accounting_callback, self.persist_core_history_accounted_callback, + self.callbacks.on_changeset_begin_fn, + self.callbacks.on_changeset_end_fn, ) else { return Ok(None); }; @@ -1962,6 +2080,13 @@ impl PlatformWalletPersistence for FFIPersister { format!("on_load_core_history_for_accounting_fn returned error code {rc}"), )); } + if rows_ptr.is_null() && count > 0 { + unsafe { free(self.callbacks.context, rows_ptr, count) }; + return Err(PersistenceError::backend(format!( + "on_load_core_history_for_accounting_fn reported {count} transactions \ + but handed over no array" + ))); + } let mut out = Vec::with_capacity(count); if !rows_ptr.is_null() && count > 0 { let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; @@ -1996,20 +2121,29 @@ impl PlatformWalletPersistence for FFIPersister { ) else { return Ok(()); }; + // Rows and marker must commit as one unit; without the round pair + // nothing would make them, so nothing is written. + let (Some(begin), Some(end)) = ( + self.callbacks.on_changeset_begin_fn, + self.callbacks.on_changeset_end_fn, + ) else { + return Err(PersistenceError::backend( + "the accounting backfill needs the changeset begin/end pair to commit \ + atomically; nothing was written", + )); + }; let mut round = self.round_lock.lock(); round.begin_round()?; - if let Some(cb) = self.callbacks.on_changeset_begin_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - let _ = round.end_round(); - return Err(persist_callback_error( - result, - format!( - "changeset-begin callback returned error code {result}; \ - accounting backfill aborted before any write" - ), - )); - } + let result = unsafe { begin(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + let _ = round.end_round(); + return Err(persist_callback_error( + result, + format!( + "changeset-begin callback returned error code {result}; \ + accounting backfill aborted before any write" + ), + )); } let mut outcome = RoundOutcome::default(); if !rows.is_empty() { @@ -2033,20 +2167,18 @@ impl PlatformWalletPersistence for FFIPersister { outcome.record(result); } } - if let Some(cb) = self.callbacks.on_changeset_end_fn { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - outcome.is_success(), - ) - }; - if result != 0 { - if outcome.is_success() { - outcome.record(result); - } else { - outcome.record_fatal(); - } + let result = unsafe { + end( + self.callbacks.context, + wallet_id.as_ptr(), + outcome.is_success(), + ) + }; + if result != 0 { + if outcome.is_success() { + outcome.record(result); + } else { + outcome.record_fatal(); } } round.end_round()?; @@ -2096,6 +2228,13 @@ impl PlatformWalletPersistence for FFIPersister { format!("on_load_tracked_masternodes_fn returned error code {rc}"), )); } + if rows_ptr.is_null() && count > 0 { + unsafe { free(self.callbacks.context, rows_ptr, count) }; + return Err(PersistenceError::backend(format!( + "on_load_core_history_for_accounting_fn reported {count} transactions \ + but handed over no array" + ))); + } let mut out = Vec::with_capacity(count); if !rows_ptr.is_null() && count > 0 { let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; @@ -4270,7 +4409,6 @@ impl PlatformWalletPersistence for FFIPersister { Option, PersistenceError, > { - use dashcore::consensus::Decodable; use dashcore::hashes::Hash; use key_wallet::account::{AccountType, StandardAccountType}; use key_wallet::managed_account::transaction_record::{ @@ -4278,91 +4416,49 @@ impl PlatformWalletPersistence for FFIPersister { }; use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; - let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { - return Ok(None); - }; - - let txid_bytes: [u8; 32] = *txid.as_byte_array(); - let mut context_kind: u8 = 0; - let mut block_height: u32 = 0; - let mut block_hash: [u8; 32] = [0u8; 32]; - let mut block_timestamp: u32 = 0; - let mut tx_bytes_ptr: *const u8 = std::ptr::null(); - let mut tx_bytes_len: usize = 0; - let mut found: bool = false; - - // SAFETY: All output pointers reference Rust-owned stack - // locals that outlive the callback invocation. `wallet_id` - // and `txid` are fixed-size byte arrays. - let rc = unsafe { - get_cb( - self.callbacks.context, - wallet_id.as_ptr(), - txid_bytes.as_ptr(), - &mut context_kind, - &mut block_height, - block_hash.as_mut_ptr(), - &mut block_timestamp, - &mut tx_bytes_ptr, - &mut tx_bytes_len, - &mut found, - ) - }; - - // RAII guard so the tx-bytes free callback fires on every - // exit path past this point — early returns for unknown - // context kinds, decode failures, and the IS-skip case all - // correctly hand the buffer back to Swift. - struct TxBytesGuard<'a> { - ptr: *const u8, - len: usize, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - tx_bytes: *const u8, - tx_bytes_len: usize, - ), - >, - ctx: *mut c_void, - _marker: std::marker::PhantomData<&'a ()>, - } - impl<'a> Drop for TxBytesGuard<'a> { - fn drop(&mut self) { - if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) { - // SAFETY: ptr+len match the values the lookup - // callback wrote; Swift owns the allocation - // until this free fires. - unsafe { free(self.ctx, self.ptr, self.len) }; + let (context_kind, block_height, block_hash, block_timestamp, transaction) = + match self.read_core_tx(wallet_id, txid) { + CoreTxRead::Missing => return Ok(None), + CoreTxRead::Failed(rc) => { + // TODO(tx-record-read-sentinels): `on_get_core_tx_record_fn` + // collapses every non-zero return, sentinels included, into a + // miss, so the transient-vs-permanent read distinction is + // unreachable from FFI hosts. Deferred deliberately: converting it + // to `persist_callback_error` and letting + // `get_core_tx_record_or_transient_miss` do the collapsing is a + // behaviour change for every host on the current contract. + // `get_core_transaction` already reports it. + tracing::debug!( + txid = %txid, + rc, + "on_get_core_tx_record_fn returned a non-zero result; \ + treating as miss" + ); + return Ok(None); } - } - } - let _bytes_guard = TxBytesGuard { - ptr: tx_bytes_ptr, - len: tx_bytes_len, - free_fn: self.callbacks.on_get_core_tx_record_free_fn, - ctx: self.callbacks.context, - _marker: std::marker::PhantomData, - }; - - if rc != 0 { - // TODO(tx-record-read-sentinels): `on_get_core_tx_record_fn` - // collapses every non-zero return, sentinels included, into a - // miss, so the transient-vs-permanent read distinction is - // unreachable from FFI hosts. Deferred deliberately: converting it - // to `persist_callback_error` and letting - // `get_core_tx_record_or_transient_miss` do the collapsing is a - // behaviour change for every host on the current contract. - tracing::debug!( - txid = %txid, - rc, - "on_get_core_tx_record_fn returned a non-zero result; \ - treating as miss" - ); - return Ok(None); - } - if !found { - return Ok(None); - } + CoreTxRead::Unreadable(reason) => { + tracing::debug!( + txid = %txid, + reason, + "on_get_core_tx_record_fn reported a hit it could not hand \ + over; treating as miss" + ); + return Ok(None); + } + CoreTxRead::Found { + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + } => ( + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + ), + }; let context = match context_kind { 0 => TransactionContext::Mempool, @@ -4398,34 +4494,6 @@ impl PlatformWalletPersistence for FFIPersister { } }; - if tx_bytes_ptr.is_null() || tx_bytes_len == 0 { - tracing::debug!( - txid = %txid, - "on_get_core_tx_record_fn reported a hit but no tx \ - bytes; treating as miss" - ); - return Ok(None); - } - // SAFETY: Swift guarantees `tx_bytes_ptr` points to - // `tx_bytes_len` valid bytes for the duration of the - // callback window — `_bytes_guard` keeps that window open - // until this function returns. - let tx_slice = unsafe { slice::from_raw_parts(tx_bytes_ptr, tx_bytes_len) }; - let transaction = match dashcore::blockdata::transaction::Transaction::consensus_decode( - &mut &tx_slice[..], - ) { - Ok(tx) => tx, - Err(err) => { - tracing::debug!( - txid = %txid, - error = %err, - "on_get_core_tx_record_fn returned undecodable \ - tx bytes; treating as miss" - ); - return Ok(None); - } - }; - Ok(Some(TransactionRecord { transaction, txid: *txid, @@ -4444,6 +4512,30 @@ impl PlatformWalletPersistence for FFIPersister { })) } + /// The body behind `on_get_core_tx_record_fn`, read strictly for the + /// accounting backfill: a hit in any context (InstantSend included) is + /// returned, and a host failure or a hit with no decodable body is an + /// error, so the backfill fails its round instead of reading a wallet + /// input as foreign. [`Self::get_core_tx_record`]'s miss policy is + /// unchanged. + fn get_core_transaction( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result, PersistenceError> { + match self.read_core_tx(wallet_id, txid) { + CoreTxRead::Missing => Ok(None), + CoreTxRead::Failed(rc) => Err(persist_callback_error( + rc, + format!("on_get_core_tx_record_fn returned error code {rc} for {txid}"), + )), + CoreTxRead::Unreadable(reason) => Err(PersistenceError::backend(format!( + "on_get_core_tx_record_fn reported {txid} but {reason}" + ))), + CoreTxRead::Found { transaction, .. } => Ok(Some(transaction)), + } + } + fn list_wallet_core_txids( &self, wallet_id: WalletId, @@ -10176,6 +10268,9 @@ mod tests { struct Host { backfilled: bool, rows: Vec, + /// Reported instead of `rows.len()`, with a null array when + /// `rows` is empty. + claimed_count: Option, fail_rows: bool, loads: std::sync::Mutex, frees: std::sync::Mutex, @@ -10195,8 +10290,12 @@ mod tests { *host.loads.lock().unwrap() += 1; *out_backfilled = host.backfilled; if !host.backfilled { - *out_rows = host.rows.as_ptr(); - *out_count = host.rows.len(); + *out_rows = if host.rows.is_empty() { + std::ptr::null() + } else { + host.rows.as_ptr() + }; + *out_count = host.claimed_count.unwrap_or(host.rows.len()); } 0 } @@ -10255,6 +10354,7 @@ mod tests { tx_bytes_len: body.len(), }) .collect(), + claimed_count: None, fail_rows, loads: std::sync::Mutex::new(0), frees: std::sync::Mutex::new(0), @@ -10375,6 +10475,139 @@ mod tests { None ); assert_eq!(*host.loads.lock().unwrap(), 0); + + // A count with no array is a broken answer, not an empty history: + // reading it as empty would let the round record completion. + let mut host = make_host(false, false); + host.rows.clear(); + host.claimed_count = Some(3); + assert!(persister_for(&host, extensions) + .load_core_history_for_accounting([1; 32]) + .is_err()); + assert_eq!(*host.frees.lock().unwrap(), 1, "the loan is still returned"); + + // Without the begin/end pair the rows and the marker could not commit + // as one unit: the host is never read, and a store writes nothing. + let host = make_host(false, false); + let unbracketed = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: &host as *const Host as *mut c_void, + on_changeset_begin_fn: Some(begin), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + extensions, + ); + assert_eq!( + unbracketed + .load_core_history_for_accounting([1; 32]) + .expect("a host without the round pair is not read"), + None + ); + assert_eq!(*host.loads.lock().unwrap(), 0); + assert!(unbracketed + .store_history_accounting([1; 32], &[row], true) + .is_err()); + assert!(host.events.lock().unwrap().is_empty()); + } + + /// The accounting backfill's funding read uses a stored body in any + /// context, InstantSend included, and reports a host failure or an + /// unusable body as an error rather than a miss. `get_core_tx_record` + /// keeps its policy: all three read as a miss there. + #[test] + fn should_read_a_funding_body_strictly_for_the_accounting_backfill() { + use platform_wallet::test_support::core_fixtures::{ + foreign_address, outside, pay, spend_of, + }; + + struct Host { + rc: i32, + found: bool, + context_kind: u8, + body: Vec, + frees: std::sync::Mutex, + } + unsafe extern "C" fn get( + ctx: *mut c_void, + _wallet_id: *const u8, + _txid: *const u8, + out_context_kind: *mut u8, + _out_block_height: *mut u32, + _out_block_hash: *mut u8, + _out_block_timestamp: *mut u32, + out_tx_bytes: *mut *const u8, + out_tx_bytes_len: *mut usize, + out_found: *mut bool, + ) -> i32 { + let host = &*(ctx as *const Host); + *out_context_kind = host.context_kind; + *out_found = host.found; + if !host.body.is_empty() { + *out_tx_bytes = host.body.as_ptr(); + *out_tx_bytes_len = host.body.len(); + } + host.rc + } + unsafe extern "C" fn free(ctx: *mut c_void, _bytes: *const u8, _len: usize) { + *(&*(ctx as *const Host)).frees.lock().unwrap() += 1; + } + + let funding = spend_of(&[outside(1)], vec![pay(5_000, &foreign_address(2))]); + let body = dashcore::consensus::encode::serialize(&funding); + let read = |rc: i32, found: bool, context_kind: u8, body: Vec| { + let host = Host { + rc, + found, + context_kind, + body, + frees: std::sync::Mutex::new(0), + }; + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: &host as *const Host as *mut c_void, + on_get_core_tx_record_fn: Some(get), + on_get_core_tx_record_free_fn: Some(free), + ..PersistenceCallbacks::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks::default(), + ); + let strict = persister.get_core_transaction([1; 32], &funding.txid()); + let record = persister + .get_core_tx_record([1; 32], &funding.txid()) + .expect("the record read keeps reading failures as a miss") + .map(|record| record.transaction); + let frees = *host.frees.lock().unwrap(); + drop(persister); + (strict, record, frees) + }; + + // An InstantSend hit: the body is real, only the IS lock is missing. + let (strict, record, frees) = read(0, true, 1, body.clone()); + assert_eq!(strict.expect("an IS hit is a hit"), Some(funding.clone())); + assert_eq!(record, None, "the proof-facing read still skips IS"); + assert_eq!(frees, 2, "each read hands the buffer back"); + + let (strict, record, _) = read(0, true, TX_CONTEXT_RAW_IN_BLOCK as u8, body.clone()); + assert_eq!(strict.unwrap(), Some(funding.clone())); + assert_eq!(record, Some(funding.clone())); + + let (strict, record, _) = read(0, false, 0, Vec::new()); + assert_eq!(strict.unwrap(), None, "not stored is the only miss"); + assert_eq!(record, None); + + let (strict, record, _) = read(7, false, 0, Vec::new()); + assert!(strict.is_err(), "a failing host is not a miss"); + assert_eq!(record, None); + + let (strict, record, _) = read(0, true, 0, vec![0xFF]); + assert!(strict.is_err(), "an undecodable stored body is not a miss"); + assert_eq!(record, None); + + let (strict, record, _) = read(0, true, 0, Vec::new()); + assert!(strict.is_err(), "a hit without a body is not a miss"); + assert_eq!(record, None); } #[test] From 1144d8bb7ad794d804165d6ccd71564f9edde655 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:37:07 +0000 Subject: [PATCH 47/84] fix(swift-sdk): fail the accounting round on a transaction-row read error `upsertTransactionAccounting` looked its transaction up with the non-throwing `fetchTransactionRow`, so a failed read looked like a missing row: the row was skipped and the backfill marker still committed. It now uses the throwing lookup and `persistTransactionAccounting` returns false on a throw, failing the round; a genuine miss is still skipped. Also documents that a missing accounting row (nil netAmount, direction and fee) means "not yet accounted, will be filled", never zero. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../Models/PersistentTransaction.swift | 4 ++- .../PersistentTransactionAccounting.swift | 12 ++++++--- .../PlatformWalletPersistenceHandler.swift | 26 ++++++++++++++----- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index d097d974b34..d2a88879e0e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -242,7 +242,9 @@ public final class PersistentTransaction { } /// Core value movement for one wallet; `nil` when Rust has emitted no - /// accounting for this wallet and transaction. + /// accounting for this wallet and transaction yet. `nil` means "not yet + /// resolved, will be filled", not zero (see + /// `PersistentTransactionAccounting`). public func netAmount(for walletId: Data) -> Int64? { accounting(for: walletId)?.netAmount } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift index e2da437cbef..28df6c50ddd 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift @@ -6,10 +6,14 @@ import SwiftData /// `on_persist_transaction_accounting_fn`). /// /// A transaction shared by several local wallets has one row per wallet, -/// so each wallet shows its own side of it. A wallet with no row has no -/// accounting from Rust yet (a payload-only provider transaction, or a -/// stored row the load-time backfill has not reached). Pure storage: -/// nothing on the Swift side derives, corrects or vetoes these values. +/// so each wallet shows its own side of it. A missing row means "not yet +/// accounted, will be filled", never "accounted as zero": a payload-only +/// provider transaction, a stored row the load-time backfill has not +/// reached, or one whose wallet-signed input Rust cannot resolve yet (its +/// funding transaction is unavailable). Rust writes the row once it can, +/// at a later backfill or a live update, and never writes a guessed one. +/// Pure storage: nothing on the Swift side derives, corrects or vetoes +/// these values. @Model public final class PersistentTransactionAccounting { #Unique([\.walletId, \.txid]) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 404897a3f50..0f00c016433 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -9075,7 +9075,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `(walletId, txid)`. Always fired inside a round (a sync round, or the /// load-time backfill's own), so they stage with it and commit at /// `endChangeset`. Returns `false` to fail the round, like the other - /// per-kind callbacks. + /// per-kind callbacks, including when a transaction row cannot be read: + /// read as a miss, the backfill would record completion over a row it + /// never wrote. func persistTransactionAccounting( walletId: Data, rows: UnsafePointer?, @@ -9088,8 +9090,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { case .found: break } guard count > 0, let rows else { return true } - for i in 0.. Date: Thu, 29 Oct 2026 09:45:04 +0000 Subject: [PATCH 48/84] docs(platform-wallet): state the P2PKH premise of the owned-input check Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../src/changeset/wallet_accounting.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index e1b013335ce..33310c5df2c 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -156,13 +156,14 @@ pub fn funding_txids(wallet: &ManagedWalletInfo, unaccounted: &[Transaction]) -> /// transaction gets no row and is listed in /// [`HistoryAccounting::unresolved`] instead: reading the input as foreign /// would turn a payment with change into an `Incoming` receipt of the -/// change. An input redeemed any other way (a script the wallet does not -/// spend from) reads as foreign. +/// change. An input redeemed any other way reads as foreign; the wallet's +/// funds accounts hold only P2PKH scripts, so its own coins are always +/// redeemed with a public key. /// -/// No row is never a wrong row: a transaction left without one is not yet -/// accounted, and the host shows it as such until a later backfill (or the -/// live projection) writes it. Any subset of a host's unaccounted -/// transactions is therefore safe to pass. +/// A transaction left without a row is not yet accounted, and the host +/// shows it as such until a later backfill (or the live projection) writes +/// it. Any subset of a host's unaccounted transactions is therefore safe to +/// pass. pub fn history_accounting( wallet: &ManagedWalletInfo, unaccounted: &[Transaction], From 3a111c14d0389a0123ddf0590ec426f2ff9005ae Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:46:01 +0000 Subject: [PATCH 49/84] docs(swift-sdk): describe the open backfill in the V5 schema notes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- packages/swift-sdk/SCHEMA_RELEASES.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 62882b7cf65..03231e279c2 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -33,9 +33,12 @@ of each wallet Rust reads back the wallet's transactions that lack a row (`on_load_core_history_for_accounting_fn`), fetches any funding transaction it needs through `on_get_core_tx_record_fn`, and writes the rows (`on_persist_transaction_accounting_fn`) and the marker -(`on_persist_core_history_accounted_fn`) in one round. Later loads see the -marker and skip the read. Deleting a wallet deletes its rows; deleting a -transaction cascades to them. +(`on_persist_core_history_accounted_fn`) in one round. A transaction whose +wallet-signed input Rust cannot resolve yet gets no row, and the round then +writes no marker, so the next load reads the transactions still without a +row again. A missing row means "not yet accounted", never zero. Once the +marker is set, later loads skip the read. Deleting a wallet deletes its rows; +deleting a transaction cascades to them. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, From f3238493a44a4c65353f394bbac66acbe073d47c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:48:52 +0000 Subject: [PATCH 50/84] fix(swift-sdk): report a failed tx-record fetch to Rust instead of a miss `getCoreTxRecordCallback` returned 0 with `found = false` when the SwiftData fetch threw, so Rust's strict accounting read could not tell a read error from a transaction the store does not hold. `coreTxRecord` now throws on a fetch error and the callback logs it and returns 1. Rust's `get_core_tx_record` still reads a non-zero return as a miss, so the asset-lock and DashPay callers are unchanged; the accounting backfill now fails its round (no marker). A genuine miss, a row with no transaction bytes and an in-block row without a block hash stay misses (rc 0): those are stored data states, not read failures. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../PlatformWalletPersistenceHandler.swift | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 0f00c016433..31c2b1963e4 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -9327,7 +9327,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// hash that would round-trip back to Rust as a real block id), /// or when the row has no `transactionData` (the FFI write path /// always populates it, so a missing one signals a corrupt row - /// the Rust side can't decode anyway). + /// the Rust side can't decode anyway). Throws when the fetch + /// itself fails: that is not a miss, and Rust's accounting + /// backfill must not read it as one. /// /// The wallet-id is currently unused (`txid` is globally /// unique), but is accepted to match the Rust trait signature @@ -9335,13 +9337,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func coreTxRecord( walletId: Data, txid: Data - ) -> (context: UInt32, blockHeight: UInt32, blockHash: Data, blockTimestamp: UInt32, transactionData: Data)? { + ) throws -> (context: UInt32, blockHeight: UInt32, blockHash: Data, blockTimestamp: UInt32, transactionData: Data)? { _ = walletId - return onQueue { + return try onQueue { let descriptor = FetchDescriptor( predicate: #Predicate { $0.txid == txid } ) - guard let row = try? backgroundContext.fetch(descriptor).first else { + guard let row = try backgroundContext.fetch(descriptor).first else { return nil } // The Rust side decodes `transactionData` into a @@ -11420,9 +11422,11 @@ private func loadShieldedViewingKeysFreeCallback( /// the three block fields when context is 2 or 3, plus the tx /// bytes pointer + length) on a hit; returns `0`. /// - Sets `*outFound = false` on a miss; returns `0`. -/// - Returns `0` even on Swift-side errors (treated as miss); the -/// Rust side's `record_or_persister` helper logs and falls -/// through to the caller's existing not-found / poll path. +/// - Returns `1` when the SwiftData fetch fails. Rust's +/// `get_core_tx_record` still reads that as a miss (the asset-lock +/// and DashPay callers retry); the accounting backfill's strict +/// read fails its round instead of reading a wallet input as +/// foreign. private func getCoreTxRecordCallback( context: UnsafeMutableRawPointer?, walletIdPtr: UnsafePointer?, @@ -11451,7 +11455,17 @@ private func getCoreTxRecordCallback( let walletId = Data(bytes: walletIdPtr, count: 32) let txid = Data(bytes: txidPtr, count: 32) - guard let row = handler.coreTxRecord(walletId: walletId, txid: txid) else { + let found: (context: UInt32, blockHeight: UInt32, blockHash: Data, blockTimestamp: UInt32, transactionData: Data)? + do { + found = try handler.coreTxRecord(walletId: walletId, txid: txid) + } catch { + SDKLogger.event( + "persistence_core_tx_record_read_failed", category: .persistence, severity: .error, + fields: ["wallet_reference": .reference(walletId)], error: error + ) + return 1 + } + guard let row = found else { // Miss — outFound already set to false above. return 0 } From ed6b62c9308c51dcc7308f6603ca45eda27ac187 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:19:30 +0000 Subject: [PATCH 51/84] feat(wallet): retain deferred history snapshots and retry verified replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep pending wallet balances and roster visible while SPV obtains checked network-bound evidence. Retry scoped atomic loads behind lifecycle and publication fences, retaining the existing wallet handle on activation. Preserve verified InstantLocks on mined history through rewind and withhold operation-status capability until cleanup preserves durable tombstones. Working checkpoint: authorization effects, migration and FFI integration remain to be completed before this branch is ready for review. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- Cargo.lock | 46 +- Cargo.toml | 16 +- .../src/sqlite/persister.rs | 210 +++--- .../src/sqlite/schema/platform_addrs.rs | 4 +- .../sqlite_shared_history_replay.rs | 17 +- .../sqlite_shielded_viewing_keys.rs | 2 +- .../src/changeset/recorded_history.rs | 34 + .../src/changeset/traits.rs | 30 +- .../src/manager/accessors.rs | 123 +++- .../src/manager/dashpay_sync.rs | 9 +- .../src/manager/dpns_sync.rs | 9 +- .../src/manager/history_replay.rs | 121 +++- .../rs-platform-wallet/src/manager/load.rs | 611 ++++++++++++++++-- .../rs-platform-wallet/src/manager/mod.rs | 143 ++-- .../src/manager/platform_address_sync.rs | 9 +- .../src/manager/wallet_lifecycle.rs | 21 +- .../rs-platform-wallet/src/spv/runtime.rs | 36 ++ .../src/wallet/platform_wallet.rs | 66 +- 18 files changed, 1197 insertions(+), 310 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb4a9b83969..23b2ba0e781 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1271,7 +1271,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "dash-network" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "cbindgen 0.29.4", "grovedb-bincode", @@ -1784,7 +1784,7 @@ dependencies = [ [[package]] name = "dash-network-seeds" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "dash-network", ] @@ -1942,7 +1942,7 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "async-trait", "chrono", @@ -1990,7 +1990,7 @@ dependencies = [ [[package]] name = "dashcore" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "anyhow", "base64 0.22.1", @@ -2014,7 +2014,7 @@ dependencies = [ [[package]] name = "dashcore-crypto" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "base58ck", "bitcoin-crypto", @@ -2036,12 +2036,12 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" [[package]] name = "dashcore-rpc" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "dashcore-rpc-json", "hex-conservative 1.3.0", @@ -2054,7 +2054,7 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "dashcore", "grovedb-bincode", @@ -2069,7 +2069,7 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "bitcoin_hashes 0.14.101", "dash-pow", @@ -2731,7 +2731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2791,7 +2791,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" dependencies = [ "cfg-if", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -3166,7 +3166,7 @@ dependencies = [ [[package]] name = "git-state" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" [[package]] name = "glob" @@ -4118,7 +4118,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -4365,7 +4365,7 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "async-trait", "bip39", @@ -4388,7 +4388,7 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.46.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=7f66e07391d40f30d7397d5a26d9750a98822435#7f66e07391d40f30d7397d5a26d9750a98822435" +source = "git+https://github.com/dashpay/rust-dashcore?rev=5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb#5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" dependencies = [ "async-trait", "dashcore", @@ -4908,7 +4908,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5972,7 +5972,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6770,7 +6770,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6783,7 +6783,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6842,7 +6842,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -7663,7 +7663,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -9138,7 +9138,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 58f0ea221a3..aeac18f79f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,14 +70,14 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f5 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } -dashcore-crypto = { git = "https://github.com/dashpay/rust-dashcore", rev = "7f66e07391d40f30d7397d5a26d9750a98822435" } +dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +dash-network = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } +dashcore-crypto = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } tokio-metrics = "0.5" # rust-dashcore's bitcoin-crypto takes these crates from this rust-bitcoin rev, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index e95e5e1551f..907f7458da1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -7,7 +7,6 @@ use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use rusqlite::{Connection, OptionalExtension}; use dpp::prelude::Identifier; -use key_wallet::transaction_checking::TransactionContext; use platform_wallet::changeset::recorded_history::ReplayEvidence; use platform_wallet::changeset::{ ClientStartState, IdentityChangeSet, PersistenceCapabilities, PersistenceError, @@ -1318,6 +1317,8 @@ impl PlatformWalletPersistence for SqlitePersister { // full restore remains lossy. Shielded viewing keys are native when // compiled in; notes, nullifiers, and sync state use ShieldedStore. // WALLET_DRAIN folds in WALLET_RESTORE, so it stays unattested too. + // TODO(operation-tombstones): replay cleanup can erase operation UUIDs; + // ASSET_LOCK_OPERATION_IDS requires durable evidence distinguishing that from never sent. let capabilities = PersistenceCapabilities::ATOMIC_CHANGESETS .union(PersistenceCapabilities::INVITATIONS) .union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) @@ -1325,7 +1326,6 @@ impl PlatformWalletPersistence for SqlitePersister { .union(PersistenceCapabilities::PENDING_CONTACT_CRYPTO) .union(PersistenceCapabilities::DPNS_NAME_STATES) .union(PersistenceCapabilities::TRACKED_ASSET_LOCKS) - .union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS) .union(PersistenceCapabilities::TRACKED_MASTERNODES) .union(PersistenceCapabilities::CORE_SWEEP_REMOVAL) .union(PersistenceCapabilities::DASHPAY_PAYMENTS) @@ -1534,20 +1534,123 @@ impl PlatformWalletPersistence for SqlitePersister { let mut candidates = Vec::new(); for wallet_id in schema::wallets::list_ids(&conn).map_err(PersistenceError::from)? { let state = load_wallet_snapshot(&conn, wallet_id, &ctx)?; - candidates.extend(state.recorded_history.instant_locks.into_values()); - candidates.extend(state.recorded_history.transactions.into_iter().filter_map( - |record| match record.context { - TransactionContext::InstantSend(lock) => Some(lock), - _ => None, - }, - )); + candidates.extend( + platform_wallet::changeset::recorded_history::replay_lock_candidates(&state), + ); } Ok(candidates) } + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + evidence: &ReplayEvidence, + ) -> Result { + let conn = self.conn().map_err(PersistenceError::from)?; + let ctx = LoadCtx::new(self.config.load_policy); + if schema::wallets::fetch(&conn, &wallet_id) + .map_err(PersistenceError::from)? + .is_none() + { + return Ok(HistoryReplayLoad::default()); + } + let addresses = schema::platform_addrs::load_state(&conn, &wallet_id) + .map_err(PersistenceError::from)?; + let (wallet, outcome) = + load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy, evidence)?; + let mut loaded = HistoryReplayLoad::default(); + loaded.state.wallets.insert(wallet_id, wallet); + loaded.state.platform_addresses.insert(wallet_id, addresses); + loaded.outcomes.insert(wallet_id, outcome); + self.replace_load_degradation(ctx.degradation()); + Ok(loaded) + } + fn load_with_replay( &self, evidence: &ReplayEvidence, + ) -> Result { + self.load_client_snapshot(Some(evidence)) + } + + fn load_read_snapshot(&self) -> Result { + self.load_client_snapshot(None).map(|loaded| loaded.state) + } + + fn get_core_tx_record( + &self, + wallet_id: WalletId, + txid: &dashcore::Txid, + ) -> Result< + Option, + PersistenceError, + > { + let conn = self.conn().map_err(PersistenceError::from)?; + // Tally deliberately dropped — see `last_load_degradation`. The + // policy still decides whether drift is fatal, and `tolerate` still + // logs it. + let ctx = LoadCtx::new(self.config.load_policy); + schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx) + .map_err(PersistenceError::from) + } + + /// Served from the `dpns_name_states` table this persister already + /// writes (`DPNS_NAME_STATES` is attested above), so the marketplace + /// sync pass can recover a departed name's `document_id` after a + /// restart instead of orphaning the row. + /// + /// Reads the *committed* table. In [`FlushMode::Manual`] a row that + /// is still sitting in the write buffer is not visible yet and this + /// returns `Ok(None)` — the caller degrades to the pre-fix behaviour + /// for that one name, never to a wrong id. Not a concern for the + /// departure path: it is looking for a row written by an earlier + /// session, and the default [`FlushMode::Immediate`] commits on every + /// `store`. + fn get_dpns_name_state( + &self, + wallet_id: WalletId, + wallet_identity_id: &dpp::prelude::Identifier, + normalized_label: &str, + ) -> Result, PersistenceError> { + let conn = self.conn().map_err(PersistenceError::from)?; + schema::dpns_name_states::get_by_identity_and_label( + &conn, + &wallet_id, + wallet_identity_id, + normalized_label, + ) + .map_err(PersistenceError::from) + } +} + +/// Count of top-level changeset slots carrying data, for the +/// `restored_field_count` / `dropped_field_count` / `pending_field_count` +/// tracing fields. Computed +/// from the public fields so no storage-only helper leaks into the +/// `rs-platform-wallet` API. +fn populated_field_count(cs: &PendingWrites) -> usize { + // Single source of truth with the version-domain mapping: each populated + // field is exactly one touched domain. + cs.segments + .iter() + .map(|segment| schema::versions::touched_domains(segment).len()) + .sum() +} + +/// Total rows sitting in the tables `load()` has no reader for. +/// +/// Informational, not a degradation: the data is intact and a future +/// reader can pick it up — it simply is not in this `ClientStartState`. +/// One statement, not one per table: `tc_p4_012` holds `load()`'s +/// wallet-count-independent statement count to a small constant. +/// Rehydrate one wallet, or fail without touching any other. +/// +/// Extracted from `load()`'s loop so the loop has somewhere to put a +/// boundary: every `?` in here ends this wallet, not the file. +impl SqlitePersister { + fn load_client_snapshot( + &self, + evidence: Option<&ReplayEvidence>, ) -> Result { let conn = self.conn().map_err(PersistenceError::from)?; let ctx = LoadCtx::new(self.config.load_policy); @@ -1601,7 +1704,25 @@ impl PlatformWalletPersistence for SqlitePersister { if unreadable.contains(&wallet_id) { continue; } - match load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy, evidence) { + let restored = match evidence { + Some(evidence) => { + load_one_wallet(&conn, wallet_id, &ctx, self.config.load_policy, evidence) + } + None => { + let tx = rusqlite::Transaction::new_unchecked( + &conn, + rusqlite::TransactionBehavior::Deferred, + ) + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + let result = load_wallet_snapshot(&tx, wallet_id, &ctx); + tx.rollback() + .map_err(WalletStorageError::from) + .map_err(PersistenceError::from)?; + result.map(|state| (state, HistoryReplay::default())) + } + }; + match restored { Ok((wallet_state, outcome)) => { state.wallets.insert(wallet_id, wallet_state); outcomes.insert(wallet_id, outcome); @@ -1644,77 +1765,8 @@ impl PlatformWalletPersistence for SqlitePersister { self.replace_load_degradation(degradation); Ok(HistoryReplayLoad { state, outcomes }) } - - fn get_core_tx_record( - &self, - wallet_id: WalletId, - txid: &dashcore::Txid, - ) -> Result< - Option, - PersistenceError, - > { - let conn = self.conn().map_err(PersistenceError::from)?; - // Tally deliberately dropped — see `last_load_degradation`. The - // policy still decides whether drift is fatal, and `tolerate` still - // logs it. - let ctx = LoadCtx::new(self.config.load_policy); - schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx) - .map_err(PersistenceError::from) - } - - /// Served from the `dpns_name_states` table this persister already - /// writes (`DPNS_NAME_STATES` is attested above), so the marketplace - /// sync pass can recover a departed name's `document_id` after a - /// restart instead of orphaning the row. - /// - /// Reads the *committed* table. In [`FlushMode::Manual`] a row that - /// is still sitting in the write buffer is not visible yet and this - /// returns `Ok(None)` — the caller degrades to the pre-fix behaviour - /// for that one name, never to a wrong id. Not a concern for the - /// departure path: it is looking for a row written by an earlier - /// session, and the default [`FlushMode::Immediate`] commits on every - /// `store`. - fn get_dpns_name_state( - &self, - wallet_id: WalletId, - wallet_identity_id: &dpp::prelude::Identifier, - normalized_label: &str, - ) -> Result, PersistenceError> { - let conn = self.conn().map_err(PersistenceError::from)?; - schema::dpns_name_states::get_by_identity_and_label( - &conn, - &wallet_id, - wallet_identity_id, - normalized_label, - ) - .map_err(PersistenceError::from) - } } -/// Count of top-level changeset slots carrying data, for the -/// `restored_field_count` / `dropped_field_count` / `pending_field_count` -/// tracing fields. Computed -/// from the public fields so no storage-only helper leaks into the -/// `rs-platform-wallet` API. -fn populated_field_count(cs: &PendingWrites) -> usize { - // Single source of truth with the version-domain mapping: each populated - // field is exactly one touched domain. - cs.segments - .iter() - .map(|segment| schema::versions::touched_domains(segment).len()) - .sum() -} - -/// Total rows sitting in the tables `load()` has no reader for. -/// -/// Informational, not a degradation: the data is intact and a future -/// reader can pick it up — it simply is not in this `ClientStartState`. -/// One statement, not one per table: `tc_p4_012` holds `load()`'s -/// wallet-count-independent statement count to a small constant. -/// Rehydrate one wallet, or fail without touching any other. -/// -/// Extracted from `load()`'s loop so the loop has somewhere to put a -/// boundary: every `?` in here ends this wallet, not the file. fn load_one_wallet( conn: &Connection, wallet_id: WalletId, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs index 3d8e777e2d7..6b4b2f17459 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/platform_addrs.rs @@ -197,9 +197,7 @@ fn account_state_from_rows( /// network-scoped sync watermark plus the per-account committed state /// reconstructed from registrations + address rows. /// -/// `load()` uses the grouped [`load_all`] path; this per-wallet form is -/// retained for this crate's integration tests. -#[cfg(any(test, feature = "__test-helpers"))] +/// The scoped replay loader uses this form to leave other wallets untouched. pub fn load_state( conn: &Connection, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs index 2c0ab030ef2..8966f830f0e 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -6,7 +6,9 @@ use dash_spv::{ClientConfig, DashSpvClient}; use dashcore::{hashes::Hash, OutPoint}; use key_wallet::transaction_checking::TransactionContext; use platform_wallet::changeset::recorded_history::ReplayEvidence; -use platform_wallet::changeset::{CoreChangeSet, PlatformWalletPersistence}; +use platform_wallet::changeset::{ + CoreChangeSet, PersistenceCapabilities, PlatformWalletPersistence, +}; use platform_wallet::key_wallet_manager::test_utils::MockWallet; use platform_wallet::test_support::core_fixtures::{ block, outside, pay, records_of, spend_of, stored_utxo, wallet_with_receive_address, @@ -83,7 +85,7 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil let spv_directory = tempfile::TempDir::new().unwrap(); let client = DashSpvClient::new( - ClientConfig::mainnet() + ClientConfig::new(dashcore::Network::Testnet) .without_filters() .with_storage_path(spv_directory.path()), MockNetworkManager::new(), @@ -135,3 +137,14 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil .contains(&locked.txid())); assert!(changed.outcomes[&wallet.wallet_id].sweeps.is_empty()); } + +#[test] +fn should_not_attest_operation_status_after_replay_cleanup() { + let (persister, _tmp, _path) = fresh_persister(); + let capabilities = persister.persistence_capabilities(); + assert!(capabilities.contains(PersistenceCapabilities::TRACKED_ASSET_LOCKS)); + assert!( + !capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS), + "replay cleanup has no durable operation tombstone to distinguish absence from never sent" + ); +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs index d2a16697f68..1cb9a50f3e1 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shielded_viewing_keys.rs @@ -95,7 +95,7 @@ fn sqlite_does_not_attest_wallet_drain_without_wallet_restore() { // Native asset-lock persistence remains available independently of the // unsupported all-wallet shielded recovery contract. assert!(capabilities.contains(PersistenceCapabilities::TRACKED_ASSET_LOCKS)); - assert!(capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS)); + assert!(!capabilities.contains(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS)); } #[test] diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index c151cb2b5f0..cd27392ae31 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -6,9 +6,11 @@ use std::collections::BTreeMap; +use super::ClientWalletStartState; use dash_spv::VerifiedInstantLock; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::{Transaction, Txid}; +use dpp::prelude::AssetLockProof; use key_wallet::managed_account::transaction_record::{ InputDetail, TransactionDirection, TransactionRecord, }; @@ -95,3 +97,35 @@ impl From for StoredTransaction { } } } + +/// Advisory proof candidates from every persisted source of wallet finality. +/// These bytes grant no authority until the SPV verifier and fresh snapshot agree. +pub fn replay_lock_candidates(state: &ClientWalletStartState) -> Vec { + let mut locks: Vec<_> = state + .recorded_history + .instant_locks + .values() + .cloned() + .collect(); + locks.extend( + state + .recorded_history + .transactions + .iter() + .filter_map(|record| match &record.context { + TransactionContext::InstantSend(lock) => Some(lock.clone()), + _ => None, + }), + ); + locks.extend( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter_map(|tracked| match &tracked.proof { + Some(AssetLockProof::Instant(proof)) => Some(proof.instant_lock().clone()), + _ => None, + }), + ); + locks +} diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 6c2a2fe7a69..d22926b8175 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -450,6 +450,11 @@ pub trait PlatformWalletPersistence: Send + Sync { /// wallet attribution where needed. fn load(&self) -> Result; + /// Read startup data without settling history; active wallets may share this backend. + fn load_read_snapshot(&self) -> Result { + self.load() + } + /// Restore with prefetched evidence inside the backend's atomic scope. /// /// The default supplies raw history for the common owner; it does not claim @@ -465,9 +470,32 @@ pub trait PlatformWalletPersistence: Send + Sync { }) } + /// Restore only one wallet during a deferred-load retry. + /// + /// The default reads a raw snapshot without applying replay effects. Transactional + /// backends override this to ensure no other wallet can be mutated by the retry. + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + _evidence: &ReplayEvidence, + ) -> Result { + let mut state = self.load_read_snapshot()?; + state.wallets.retain(|id, _| *id == wallet_id); + state.platform_addresses.retain(|id, _| *id == wallet_id); + Ok(HistoryReplayLoad { + state, + outcomes: Default::default(), + }) + } + /// Advisory lock candidates; callers must rebind verified proof in a fresh snapshot. fn replay_lock_candidates(&self) -> Result, PersistenceError> { - Ok(Vec::new()) + Ok(self + .load_read_snapshot()? + .wallets + .values() + .flat_map(super::recorded_history::replay_lock_candidates) + .collect()) } /// Look up a single core transaction record by `txid` for `wallet_id`. diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index bc939526561..f35547c8203 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -323,6 +323,17 @@ pub fn wallet_utxos_page( wallet_id: &WalletId, after: Option<&WalletUtxoCursor>, limit: usize, +) -> (Vec, bool) { + let Some(info) = wm.get_wallet_info(wallet_id) else { + return (Vec::new(), false); + }; + wallet_utxos_page_from_info(info, after, limit) +} + +fn wallet_utxos_page_from_info( + info: &crate::wallet::platform_wallet::PlatformWalletInfo, + after: Option<&WalletUtxoCursor>, + limit: usize, ) -> (Vec, bool) { use std::ops::Bound; @@ -331,9 +342,6 @@ pub fn wallet_utxos_page( } else { limit.min(WALLET_UTXO_PAGE_MAX) }; - let Some(info) = wm.get_wallet_info(wallet_id) else { - return (Vec::new(), false); - }; let mut accounts: Vec<( AccountType, &key_wallet::managed_account::ManagedCoreFundsAccount, @@ -671,7 +679,13 @@ impl PlatformWalletManager

{ /// tokio async task. pub fn account_balances_blocking(&self, wallet_id: &WalletId) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; let last_processed_height = info.core_wallet.metadata.last_processed_height; @@ -743,7 +757,15 @@ impl PlatformWalletManager

{ /// `RwLock` read, no I/O. pub fn wallet_network_blocking(&self, wallet_id: &WalletId) -> Option { let wm = self.wallet_manager.blocking_read(); - Some(wm.get_wallet_info(wallet_id)?.core_wallet.network()) + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + Some( + wm.get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))? + .core_wallet + .network(), + ) } /// Snapshot of [`PlatformAddressSyncManager`] tunables and last- @@ -800,7 +822,12 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Option { let wm = self.wallet_manager.blocking_read(); - let info = wm.get_wallet_info(wallet_id)?; + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let info = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; let monitor_revision = info .core_wallet .accounts @@ -893,7 +920,12 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Option { let wm = self.wallet_manager.blocking_read(); - let info = wm.get_wallet_info(wallet_id)?; + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let info = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; let last_scanned_index = info .identity_manager .highest_registration_index(wallet_id) @@ -958,7 +990,13 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; info.tracked_asset_locks @@ -999,7 +1037,13 @@ impl PlatformWalletManager

{ /// untouched). pub fn instant_send_locks_blocking(&self, wallet_id: &WalletId) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; info.core_wallet @@ -1024,7 +1068,12 @@ impl PlatformWalletManager

{ target: &AccountType, ) -> Option { let wm = self.wallet_manager.blocking_read(); - let info = wm.get_wallet_info(wallet_id)?; + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let info = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; let accounts = info.core_wallet.accounts.all_accounts(); let account = accounts .iter() @@ -1058,7 +1107,13 @@ impl PlatformWalletManager

{ target: &AccountType, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; let accounts = info.core_wallet.accounts.all_accounts(); @@ -1101,6 +1156,12 @@ impl PlatformWalletManager

{ limit: usize, ) -> (Vec, bool) { let wm = self.wallet_manager.blocking_read(); + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + if let Some(snapshot) = deferred { + return wallet_utxos_page_from_info(&snapshot.info, after, limit); + } wallet_utxos_page(&wm, wallet_id, after, limit) } @@ -1112,6 +1173,7 @@ impl PlatformWalletManager

{ queries: &[OutpointOwnershipQuery], ) -> Vec { let wm = self.wallet_manager.blocking_read(); + // Pending history cannot authorize a persistence repair from spend classification. classify_outpoints(&wm, wallet_id, queries) } @@ -1122,7 +1184,13 @@ impl PlatformWalletManager

{ target: &AccountType, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; let accounts = info.core_wallet.accounts.all_accounts(); @@ -1170,7 +1238,13 @@ impl PlatformWalletManager

{ page_limit: usize, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; let accounts = info.core_wallet.accounts.all_accounts(); @@ -1234,7 +1308,12 @@ impl PlatformWalletManager

{ // provider pools (`info.core_wallet` is a `ManagedWalletInfo`). let (network, txs, operator_scan_max, platform_index) = { let wm = self.wallet_manager.blocking_read(); - let info = wm.get_wallet_info(wallet_id)?; + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let info = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; let network = info.core_wallet.network(); let mut by_txid: std::collections::BTreeMap< @@ -1374,7 +1453,13 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; info.identity_manager @@ -1392,7 +1477,13 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let Some(info) = wm.get_wallet_info(wallet_id) else { + let deferred = self + .get_wallet_blocking(wallet_id) + .and_then(|wallet| wallet.deferred_history.load_full()); + let Some(info) = wm + .get_wallet_info(wallet_id) + .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) + else { return Vec::new(); }; let Some(inner) = info.identity_manager.wallet_identities.get(wallet_id) else { diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index fcc41dc9350..5b52783b10b 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -391,7 +391,14 @@ impl DashPaySyncManager { let snapshot: Vec<(WalletId, Arc)> = { let wallets = self.wallets.load(); - wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + wallets + .iter() + .filter(|(_, wallet)| { + wallet.history_readiness() + == crate::wallet::platform_wallet::HistoryReadiness::Ready + }) + .map(|(id, w)| (*id, Arc::clone(w))) + .collect() }; let mut summary = DashPaySyncSummary::default(); diff --git a/packages/rs-platform-wallet/src/manager/dpns_sync.rs b/packages/rs-platform-wallet/src/manager/dpns_sync.rs index 35e4e6a1501..6d007d7d4a3 100644 --- a/packages/rs-platform-wallet/src/manager/dpns_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dpns_sync.rs @@ -316,7 +316,14 @@ impl DpnsSyncManager { let snapshot: Vec<(WalletId, Arc)> = { let wallets = self.wallets.load(); - wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + wallets + .iter() + .filter(|(_, wallet)| { + wallet.history_readiness() + == crate::wallet::platform_wallet::HistoryReadiness::Ready + }) + .map(|(id, w)| (*id, Arc::clone(w))) + .collect() }; let mut summary = DpnsSyncPassSummary::default(); diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 48eb46c83c5..35fd30f4b9f 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -212,7 +212,8 @@ pub async fn replay_recorded_history_with_evidence( .instant_locks .get(&stored.txid) .is_some_and(|proof| { - proof.txid() == stored.txid + proof.network() == wallet_info.network() + && proof.txid() == stored.txid && proof.instant_lock() == lock && proof.matches_transaction(&stored.transaction) }) @@ -356,6 +357,18 @@ pub async fn replay_recorded_history_with_evidence( let mut swept = plan.removed; let mut sweeps = plan.effects; replay.retain(|stored| !swept.contains(&stored.txid)); + // Verified IS evidence survives a mining-block reorg independently of the + // record's block context. Records are detached here, so this cannot sweep + // outside the common planner. Only surviving transactions retain the metadata. + for stored in &replay { + let lock = match &stored.context { + TransactionContext::InstantSend(lock) => Some(lock), + _ => instant_locks.get(&stored.txid), + }; + if let Some(lock) = lock.filter(|lock| lock_matches_record(lock, stored)) { + wallet_info.mark_instant_send_utxos(&stored.txid, lock); + } + } let (replay, set_aside): (Vec<_>, Vec<_>) = replay .into_iter() .partition(|stored| !unsafe_txids.contains(&stored.txid)); @@ -1492,7 +1505,7 @@ mod tests { let directory = tempfile::TempDir::new().unwrap(); let client = DashSpvClient::new( - ClientConfig::mainnet() + ClientConfig::new(dashcore::Network::Testnet) .without_filters() .with_storage_path(directory.path()), MockNetworkManager::new(), @@ -1577,6 +1590,110 @@ mod tests { } } + #[tokio::test] + async fn should_defer_history_proven_on_another_network() { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::mainnet() + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let mut locked = records[1].clone(); + locked.context = TransactionContext::Mempool; + let fixture = InstantLockFixture::new(&locked.transaction).unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + assert_eq!(proof.network(), dashcore::Network::Mainnet); + assert_eq!(info.network(), dashcore::Network::Testnet); + let outcome = replay_recorded_history_with_evidence( + &mut info, + &mut wallet, + history( + vec![locked.clone()], + BTreeMap::from([(locked.txid, fixture.instant_lock().clone())]), + ), + &ReplayEvidence { + instant_locks: BTreeMap::from([(locked.txid, proof)]), + }, + ) + .await; + assert!(outcome.deferred.contains(&locked.txid)); + assert!(outcome.sweeps.is_empty()); + assert!(outcome.resendable(vec![locked.transaction]).is_empty()); + } + + #[tokio::test] + async fn should_retain_verified_mined_lock_when_history_is_rewound() { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let locked = records[1].clone(); + let fixture = InstantLockFixture::new(&locked.transaction).unwrap(); + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::testnet() + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + let outcome = replay_recorded_history_with_evidence( + &mut info, + &mut wallet, + history( + records, + BTreeMap::from([(locked.txid, fixture.instant_lock().clone())]), + ), + &ReplayEvidence { + instant_locks: BTreeMap::from([(locked.txid, proof)]), + }, + ) + .await; + assert!(outcome.deferred.is_empty()); + assert!(info.instant_send_locks().contains(&locked.txid)); + let before = info.accounts.standard_bip44_accounts[&0].transactions()[&locked.txid].clone(); + assert_eq!( + before.context, locked.context, + "InstantSend evidence must preserve the mined block context" + ); + info.truncate_above(100).unwrap(); + assert!( + info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&locked.txid), + "verified InstantSend survives removal of its mining block" + ); + } + /// A persister that stores no per-input ownership leaves the replay /// nothing to stage, so a spend whose funding is not in the history marks /// no coin spent. A spend no fork can drop, in a chain-locked block or diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 364e357c234..23d7837349f 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -7,8 +7,11 @@ use crate::changeset::{ funding_txids, history_accounting, ClientStartState, ClientWalletStartState, PersistenceCapabilities, PlatformWalletPersistence, }; +use crate::changeset::{ + persists_backfill_coverage, CoreChangeSet, DashPayBackfillRecord, DurableCursor, + PlatformWalletChangeSet, +}; use crate::error::PlatformWalletError; -use crate::manager::history_replay::replay_recorded_history; use crate::wallet::asset_lock::sync::recovery::{ add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, reattach_records, recorded_input_conflicts, seed_observed_input_conflicts, @@ -16,8 +19,11 @@ use crate::wallet::asset_lock::sync::recovery::{ }; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; -use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use crate::wallet::platform_wallet::{ + DeferredWalletSnapshot, HistoryReadiness, PlatformWalletInfo, WalletId, +}; use crate::wallet::PlatformWallet; +use key_wallet_manager::WalletManager; use std::time::Duration; @@ -45,6 +51,117 @@ use super::{run_blocking_load, PlatformWalletManager}; const RESEND_TRANSPORT_READY_WAIT: Duration = Duration::from_secs(90); impl PlatformWalletManager

{ + /// Restore wallets, keeping unverifiable snapshots visible and read-only while SPV synchronizes. + pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { + self.load_tracked_masternodes_from_persistence(); + let driver = Arc::new(LoadDriver::new(self)); + let evidence = driver.prefetch().await?; + driver.load(None, &evidence).await?; + let mut task = self.history_retry_join.lock().await; + if task.is_none() && !self.history_retry_cancel.is_cancelled() { + *task = driver.start_retry(self.history_retry_cancel.clone()); + } + Ok(()) + } +} + +pub(super) struct LoadDriver { + sdk: Arc, + persister: Arc

, + wallet_manager: Arc>>, + wallets: Arc>>>, + lock_notify: Arc, + spv_manager: Arc, + sync_fault: Arc, + durable_cursors: crate::changeset::DurableCursors, + in_broadcast_fences: + Arc>>>, +} + +impl LoadDriver

{ + pub(super) fn new(manager: &PlatformWalletManager

) -> Self { + Self { + sdk: Arc::clone(&manager.sdk), + persister: Arc::clone(&manager.persister), + wallet_manager: Arc::clone(&manager.wallet_manager), + wallets: Arc::clone(&manager.wallets), + lock_notify: Arc::clone(&manager.lock_notify), + spv_manager: Arc::clone(&manager.spv_manager), + sync_fault: Arc::clone(&manager.sync_fault), + durable_cursors: Arc::clone(&manager.durable_cursors), + in_broadcast_fences: Arc::clone(&manager.in_broadcast_fences), + } + } + + async fn prefetch( + &self, + ) -> Result { + let persister = Arc::clone(&self.persister); + let candidates = run_blocking_load(move || persister.replay_lock_candidates()) + .await + .map_err(PlatformWalletError::from_load_failure)?; + Ok(self.spv_manager.verify_history_locks(candidates).await) + } + + fn start_retry( + self: Arc, + cancel: tokio_util::sync::CancellationToken, + ) -> Option> { + if !self + .wallets + .load() + .values() + .any(|wallet| wallet.history_readiness() == HistoryReadiness::AwaitingProof) + { + return None; + } + Some(tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel.cancelled() => break, + _ = tokio::time::sleep(Duration::from_secs(1)) => {} + } + let pending: Vec<_> = self + .wallets + .load() + .values() + .filter(|wallet| wallet.history_readiness() == HistoryReadiness::AwaitingProof) + .cloned() + .collect(); + if pending.is_empty() { + continue; + } + // Network work precedes lifecycle, cursor, manager and backend locks. + let evidence = match self.prefetch().await { + Ok(evidence) => evidence, + Err(error) => { + tracing::debug!(%error, "history proof prefetch failed; retry remains scheduled"); + continue; + } + }; + for wallet in pending { + if cancel.is_cancelled() { + break; + } + let wallet_id = wallet.core().wallet_id(); + let current = self.wallets.load().get(&wallet_id).is_some_and(|current| { + Arc::ptr_eq(current.generation(), wallet.generation()) + && current.history_readiness() == HistoryReadiness::AwaitingProof + }); + if !current { + continue; + } + // Finish an admitted atomic load even if cancellation arrives; shutdown joins this worker. + if let Err(error) = self.load(Some(wallet_id), &evidence).await { + tracing::debug!(%error, wallet_id = %hex::encode(wallet_id), "deferred wallet restore failed; retry remains scheduled"); + } + } + } + })) + } +} + +impl LoadDriver

{ /// Load the full [`ClientStartState`] from the configured persister /// and rehydrate the manager's `wallet_manager` and `wallets` maps. /// @@ -78,13 +195,34 @@ impl PlatformWalletManager

{ /// the store and call again, or reconstruct. Reconstructing over the same /// path needs every strong persister reference released first. Dropping /// the manager releases its own references; wallet handles, workers and - /// in-flight operations can retain others. [`shutdown`](Self::shutdown) + /// in-flight operations can retain others. [`shutdown`](PlatformWalletManager::shutdown) /// takes `&self`, so it cannot release the manager's own `Arc

`. /// /// [`WalletManager`]: key_wallet_manager::WalletManager - pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { + async fn load( + &self, + target: Option, + evidence: &crate::changeset::recorded_history::ReplayEvidence, + ) -> Result<(), PlatformWalletError> { let persister = Arc::clone(&self.persister); - let start_state = match run_blocking_load(move || persister.load()).await { + let loaded = match run_blocking_load(move || match target { + Some(wallet_id) => persister.load_read_snapshot().map(|mut state| { + state.wallets.retain(|id, _| *id == wallet_id); + state.platform_addresses.retain(|id, _| *id == wallet_id); + crate::manager::history_replay::HistoryReplayLoad { + state, + outcomes: Default::default(), + } + }), + None => persister.load_read_snapshot().map(|state| { + crate::manager::history_replay::HistoryReplayLoad { + state, + outcomes: Default::default(), + } + }), + }) + .await + { Ok(state) => state, Err(e) => { // Debug, not Display: it carries the real cause (e.g. a @@ -93,6 +231,10 @@ impl PlatformWalletManager

{ return Err(PlatformWalletError::from_load_failure(e)); } }; + let crate::manager::history_replay::HistoryReplayLoad { + state: start_state, + mut outcomes, + } = loaded; let ClientStartState { mut platform_addresses, wallets, @@ -120,7 +262,6 @@ impl PlatformWalletManager

{ // Tracked (wallet-independent) masternodes ride the same startup // hydration; a failure logs and starts empty rather than failing // wallet restore. - self.load_tracked_masternodes_from_persistence(); let persister_dyn: Arc = Arc::clone(&self.persister) as _; @@ -167,8 +308,77 @@ impl PlatformWalletManager

{ // the wallet processed from it. Nothing between the two takes the // lock, and it is released before the rollback below. let mut held = None; + let mut held_lifecycle = None; 'load: while let Some((expected_wallet_id, wallet_state, reread)) = pending.pop_front() { + if !reread { + held_lifecycle = None; + // Pending publication shares removal's generation gate; a retry cannot resurrect a removed handle. + let candidate = self.wallets.load().get(&expected_wallet_id).cloned(); + if target.is_some() && candidate.is_none() { + continue 'load; + } + if let Some(candidate) = &candidate { + if candidate.history_readiness() == HistoryReadiness::Ready { + already_registered.push(expected_wallet_id); + continue 'load; + } + held_lifecycle = Some(candidate.generation().teardown_guard().await); + } + let cursors = Arc::clone(&self.durable_cursors.cursors).lock_owned().await; + if self + .wallet_manager + .read() + .await + .get_wallet(&expected_wallet_id) + .is_some() + { + held_lifecycle = None; + already_registered.push(expected_wallet_id); + continue 'load; + } + if let Some(candidate) = &candidate { + if !self + .wallets + .load() + .get(&expected_wallet_id) + .is_some_and(|current| { + Arc::ptr_eq(current.generation(), candidate.generation()) + }) + { + held_lifecycle = None; + continue 'load; + } + } + let persister = Arc::clone(&self.persister); + let prefetched = evidence.clone(); + let lifecycle = held_lifecycle.take(); + match run_blocking_load(move || { + let loaded = + persister.load_wallet_with_replay(expected_wallet_id, &prefetched)?; + // An abandoned async caller cannot release publication fences while this transaction still runs. + Ok((loaded, cursors, lifecycle)) + }) + .await + { + Ok((mut loaded, cursors, lifecycle)) => { + held_lifecycle = lifecycle; + outcomes.extend(loaded.outcomes); + platform_addresses.extend(loaded.state.platform_addresses); + if let Some(state) = loaded.state.wallets.remove(&expected_wallet_id) { + pending.push_front((expected_wallet_id, state, true)); + held = Some(cursors); + } else { + held_lifecycle = None; + } + } + Err(error) => { + load_error = Some(PlatformWalletError::from_load_failure(error)); + break 'load; + } + } + continue 'load; + } let ClientWalletStartState { mut wallet, mut wallet_info, @@ -208,10 +418,20 @@ impl PlatformWalletManager

{ // replay out as well, or the replay would plan it as unconfirmed. let withheld_records = detach_unconfirmed_records(&mut wallet_info, &withheld); let mut defeated = HashSet::new(); + let backend_outcome = outcomes.remove(&expected_wallet_id).unwrap_or_default(); + let mut deferred = !backend_outcome.deferred.is_empty(); + let read_only = backend_outcome.read_only; + unconfirmed_outgoing_txs = backend_outcome.resendable(unconfirmed_outgoing_txs); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); let outcome = - replay_recorded_history(&mut wallet_info, &mut wallet, recorded_history).await; + crate::manager::history_replay::replay_recorded_history_with_evidence( + &mut wallet_info, + &mut wallet, + recorded_history, + evidence, + ) + .await; tracing::info!( wallet_id = %hex::encode(expected_wallet_id), offered, @@ -221,6 +441,7 @@ impl PlatformWalletManager

{ ); // A send the replay retired is not dispatched again below. unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); + deferred = !outcome.deferred.is_empty(); defeated = outcome.defeated; } reattach_records(&mut wallet_info, withheld_records); @@ -310,9 +531,20 @@ impl PlatformWalletManager

{ // // A fresh PROCESS still starts empty: this registry is not durable. // See `InBroadcastFences` for what closing that half requires. - let generation = Arc::new(WalletGeneration::with_fences( - self.in_broadcast_fences_for(&wallet_id), - )); + let previous = self + .wallets + .load() + .get(&wallet_id) + .cloned() + .filter(|wallet| wallet.history_readiness() != HistoryReadiness::Ready); + let generation = previous + .as_ref() + .map(|wallet| Arc::clone(wallet.generation())) + .unwrap_or_else(|| { + Arc::new(WalletGeneration::with_fences( + self.in_broadcast_fences_for(&wallet_id), + )) + }); // Mirror the inner `ManagedWalletInfo.balance` (already // recomputed from the freshly-loaded UTXO set on the FFI // side via `update_balance`) into the lock-free `Arc` the @@ -397,6 +629,55 @@ impl PlatformWalletManager

{ break 'load; } + if deferred || read_only { + let _cursors = match held.take() { + Some(guard) => guard, + None => Arc::clone(&self.durable_cursors.cursors).lock_owned().await, + }; + let wm = self.wallet_manager.read().await; + if wm.get_wallet(&wallet_id).is_some() { + held_lifecycle = None; + continue 'load; + } + let newly_visible = previous.is_none(); + let handle = previous.unwrap_or_else(|| { + Arc::new(PlatformWallet::new( + Arc::clone(&self.sdk), + wallet_id, + Arc::clone(&self.wallet_manager), + Arc::clone(&generation), + Arc::clone(&self.lock_notify), + Arc::clone(&persister_dyn), + Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( + &self.spv_manager, + ))), + Arc::clone(&self.sync_fault), + Arc::clone(&self.durable_cursors), + )) + }); + handle + .deferred_history + .store(Some(Arc::new(DeferredWalletSnapshot { + wallet, + info: platform_info, + readiness: if read_only { + HistoryReadiness::Recovery + } else { + HistoryReadiness::AwaitingProof + }, + }))); + self.wallets.rcu(|wallets| { + let mut wallets = BTreeMap::clone(wallets); + wallets.insert(wallet_id, Arc::clone(&handle)); + wallets + }); + if newly_visible { + inserted_in_wallets.push((wallet_id, generation)); + } + held_lifecycle = None; + continue 'load; + } + // Insert into `wallet_manager` first so we have a wallet handle // to build the `PlatformWallet` against. Track success in // `inserted_in_manager` so the batch-rollback at the bottom can @@ -462,7 +743,7 @@ impl PlatformWalletManager

{ { let mut durable_cursors = match held.take() { Some(guard) => guard, - None => self.durable_cursors.lock().await, + None => Arc::clone(&self.durable_cursors.cursors).lock_owned().await, }; if self .wallet_manager @@ -474,28 +755,6 @@ impl PlatformWalletManager

{ already_registered.push(wallet_id); continue 'load; } - if !reread && durable_cursors.contains_key(&wallet_id) { - let persister = Arc::clone(&self.persister); - let current = match run_blocking_load(move || persister.load()).await { - Ok(mut state) => state.wallets.remove(&expected_wallet_id), - Err(e) => { - load_error = Some(PlatformWalletError::from_load_failure(e)); - break 'load; - } - }; - match current { - Some(state) => { - pending.push_front((expected_wallet_id, state, true)); - held = Some(durable_cursors); - } - None => tracing::warn!( - wallet_id = %hex::encode(expected_wallet_id), - "load: the wallet is no longer on the host; not publishing its \ - stale snapshot" - ), - } - continue 'load; - } // Coverage for a receival account this wallet did not restore // vouches for scripts nothing will watch; it is dropped here, // and from the host before the wallet is published, exactly @@ -594,17 +853,19 @@ impl PlatformWalletManager

{ )); } - let platform_wallet = PlatformWallet::new( - Arc::clone(&self.sdk), - wallet_id, - Arc::clone(&self.wallet_manager), - generation, - Arc::clone(&self.lock_notify), - Arc::clone(&persister_dyn), - broadcaster, - Arc::clone(&self.sync_fault), - Arc::clone(&self.durable_cursors), - ); + let platform_wallet = previous.unwrap_or_else(|| { + Arc::new(PlatformWallet::new( + Arc::clone(&self.sdk), + wallet_id, + Arc::clone(&self.wallet_manager), + generation, + Arc::clone(&self.lock_notify), + Arc::clone(&persister_dyn), + broadcaster, + Arc::clone(&self.sync_fault), + Arc::clone(&self.durable_cursors), + )) + }); // Initialize the platform-address provider. If the snapshot // carried a slice for this wallet, restore it directly; @@ -626,7 +887,7 @@ impl PlatformWalletManager

{ platform_wallet.platform().initialize().await; } - let platform_wallet = Arc::new(platform_wallet); + platform_wallet.deferred_history.store(None); self.wallets.rcu(|wallets| { let mut wallets = std::collections::BTreeMap::clone(wallets); wallets.insert(wallet_id, Arc::clone(&platform_wallet)); @@ -666,6 +927,7 @@ impl PlatformWalletManager

{ } } } + drop(held_lifecycle); // A `break` between a re-read and its publication leaves the lock // held; the rollback must not run under it. drop(held); @@ -925,6 +1187,125 @@ impl PlatformWalletManager

{ ); } } + + pub(super) fn remove_from_wallet_manager( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + ) -> Result<(), key_wallet_manager::WalletError> { + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let (_, info) = wm.remove_wallet(wallet_id)?; + let retiring = info + .rewind_barrier + .retire(WalletInfoInterface::account_generation(&info)); + let mut retired = self.durable_cursors.retired(); + let entry = retired.entry(*wallet_id).or_default(); + entry.in_flight = entry.in_flight.saturating_add(retiring.in_flight); + entry.epoch = entry.epoch.max(retiring.epoch); + entry.account_generation = entry.account_generation.max(retiring.account_generation); + Ok(()) + } + + pub(super) fn settle_published_backfill( + &self, + durable_cursors: &mut BTreeMap, + wallet_id: WalletId, + record: DashPayBackfillRecord, + dropped: bool, + own_cursor: u32, + snapshot_cursor: u32, + ) -> Result { + let inline = self.persister.store_commits_inline(); + let durable = persists_backfill_coverage(&*self.persister); + let predecessor = durable_cursors.contains_key(&wallet_id); + let held_on_host = !record.is_empty() || dropped; + let record = if durable { + record + } else { + DashPayBackfillRecord::default() + }; + // A backend that cannot hold the record durably never had one + // written by this process, so only a record it handed back can need + // invalidating; one that can is replaced when the snapshot's record + // may disagree with what the wallet carries. + let replace = if durable { + dropped || predecessor + } else { + held_on_host + }; + if !replace { + return Ok(record); + } + let host_cursor = durable_cursors + .get(&wallet_id) + .map_or(snapshot_cursor, |entry| entry.height); + let cursor = (!record.is_empty() && own_cursor < host_cursor).then_some(own_cursor); + tracing::info!( + wallet_id = %hex::encode(wallet_id), + covered = record.covered().len(), + dropped, + predecessor, + durable, + cursor = ?cursor, + "the host's DashPay backfill record is replaced with the one this wallet \ + carries before the wallet is published" + ); + let changeset = PlatformWalletChangeSet { + core: cursor.map(|cursor| CoreChangeSet { + synced_height: Some(cursor), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(record.clone()), + ..PlatformWalletChangeSet::default() + }; + self.persister.store(wallet_id, changeset).map_err(|e| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "failed to replace the host's DashPay backfill record; \ + the wallet was not published" + ); + PlatformWalletError::from_store_failure(&*self.persister, e) + })?; + if !inline { + self.persister.flush(wallet_id).map_err(|e| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "failed to commit the replaced DashPay backfill record; \ + the wallet was not published" + ); + PlatformWalletError::from_flush_failure(e) + })?; + } + if let Some(cursor) = cursor { + durable_cursors.insert(wallet_id, DurableCursor::at(cursor)); + } + Ok(record) + } + pub(super) fn inherit_rewind_barrier( + &self, + wm: &mut WalletManager, + wallet_id: &WalletId, + ) { + let retired = self.durable_cursors.retired().remove(wallet_id); + if let (Some(retired), Some(info)) = (retired, wm.get_wallet_info_mut(wallet_id)) { + info.rewind_barrier.inherit(retired); + } + } + pub(super) fn in_broadcast_fences_for( + &self, + wallet_id: &WalletId, + ) -> Arc { + Arc::clone( + self.in_broadcast_fences + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(*wallet_id) + .or_default(), + ) + } } /// What one re-dispatch attempt did, so a caller — and a test — can tell the @@ -1562,6 +1943,91 @@ mod idempotent_load_tests { } } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_retry_pending_history_without_hiding_the_wallet() { + use crate::wallet::platform_wallet::HistoryReadiness; + use dash_spv::test_utils::InstantLockFixture; + + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let wallet_id = ctx.wallet.compute_wallet_id(); + let fixture = InstantLockFixture::new(&funding).unwrap(); + let history = RecordedHistory { + transactions: vec![StoredTransaction { + txid: funding.txid(), + transaction: funding.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }], + instant_locks: BTreeMap::from([(funding.txid(), fixture.instant_lock().clone())]), + }; + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(RecordedHistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history, + asset_locks: BTreeMap::new(), + }), + Arc::new(NoopTestEventHandler), + ); + manager.load_from_persistor().await.unwrap(); + assert_eq!(manager.list_wallet_ids_blocking(), vec![wallet_id]); + let visible = manager.get_wallet(&wallet_id).await.unwrap(); + assert_eq!(visible.balance().total(), 100_000); + assert!( + manager + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_none(), + "unverified history must remain outside the active SPV wallet manager" + ); + assert!(!visible.core().is_current_generation().await); + assert_eq!(visible.state().await.core_wallet.balance.total(), 100_000); + let storage = tempfile::TempDir::new().unwrap(); + let offline = dash_spv::ClientConfig::testnet() + .with_storage_path(storage.path()) + .with_restrict_to_configured_peers(true); + assert!(offline.peers.is_empty() && offline.restrict_to_configured_peers); + assert!(manager + .spv_manager + .start_and_spawn(offline, || true) + .await + .unwrap()); + assert!(manager.spv_manager.is_started()); + let revision_before = manager.wallet_manager.read().await.monitor_revision(); + manager.spv_manager.install_history_fixture(&fixture).await; + tokio::time::timeout(Duration::from_secs(10), async { + while visible.history_readiness() != HistoryReadiness::Ready { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("SML readiness must activate the existing visible handle"); + assert!(visible.core().is_current_generation().await); + assert!( + manager.wallet_manager.read().await.monitor_revision() > revision_before, + "activation must invalidate the running SPV script/filter view" + ); + assert_eq!(manager.list_wallet_ids_blocking(), vec![wallet_id]); + assert!(Arc::ptr_eq( + &visible, + &manager.get_wallet(&wallet_id).await.unwrap() + )); + assert!(manager + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_some()); + assert!(manager.shutdown().await.all_clean()); + } + /// `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 @@ -2131,12 +2597,10 @@ mod idempotent_load_tests { )]), _ => BTreeMap::new(), }; + let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); let instant = |txid| { - let lock = InstantLock { - txid, - inputs: vec![contested, other], - ..Default::default() - }; + let mut lock = fixture.instant_lock().clone(); + lock.txid = txid; AssetLockProof::Instant(InstantAssetLockProof::new(lock, asset_lock.clone(), 0)) }; let (status, proof) = match evidence { @@ -2164,7 +2628,12 @@ mod idempotent_load_tests { let competitor_context = match competing { Competitor::Unconfirmed => TransactionContext::Mempool, Competitor::PlainBlock => in_block(120), - Competitor::ChainLocked => in_block(100), + Competitor::ChainLocked => match in_block(100) { + TransactionContext::InBlock(block) => { + TransactionContext::InChainLockedBlock(block) + } + _ => unreachable!(), + }, }; let mut wallet = ctx.wallet.clone(); let manager = make_history_manager(RecordedHistoryPersister { @@ -2206,6 +2675,17 @@ mod idempotent_load_tests { .collect() }, }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; manager .load_from_persistor() .await @@ -2254,7 +2734,6 @@ mod idempotent_load_tests { /// transaction that no longer stands. #[tokio::test] async fn should_not_claim_inputs_of_a_tracked_funding_the_replay_defeated() { - use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::prelude::AssetLockProof; @@ -2315,11 +2794,8 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; - let instant_lock = InstantLock { - txid: asset_lock.txid(), - inputs: vec![contested, other], - ..Default::default() - }; + let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let instant_lock = fixture.instant_lock().clone(); let mut wallet = ctx.wallet.clone(); let manager = make_history_manager(RecordedHistoryPersister { wallet: ctx.wallet, @@ -2327,7 +2803,15 @@ mod idempotent_load_tests { history: RecordedHistory { transactions: vec![ stored(&asset_lock, TransactionContext::Mempool), - stored(&winner, in_block(100)), + stored( + &winner, + match in_block(100) { + TransactionContext::InBlock(block) => { + TransactionContext::InChainLockedBlock(block) + } + _ => unreachable!(), + }, + ), ], instant_locks: BTreeMap::new(), }, @@ -2352,6 +2836,17 @@ mod idempotent_load_tests { .into_iter() .collect(), }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; manager .load_from_persistor() .await diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index fd3407bb8e1..3b2d4e9fe20 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -31,9 +31,8 @@ use key_wallet::wallet::ManagedWalletInfo; use key_wallet_manager::WalletManager; use crate::changeset::{ - persists_backfill_coverage, spawn_wallet_event_adapter_with_durable_cursors, CoreChangeSet, - DashPayBackfillRecord, DurableCursor, DurableCursors, PlatformWalletChangeSet, - PlatformWalletPersistence, + spawn_wallet_event_adapter_with_durable_cursors, DashPayBackfillRecord, DurableCursor, + DurableCursors, PlatformWalletPersistence, }; use crate::error::PlatformWalletError; use crate::events::{PlatformEventHandler, PlatformEventManager}; @@ -60,6 +59,8 @@ use crate::wallet::PlatformWallet; /// surfacing a panicked loop — before the host drops the tokio runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum WalletWorker { + /// Retries quarantined history after SPV proof readiness changes. + HistoryReplay, /// Platform-address (BLAST / DIP-17) balance sync coordinator. PlatformAddressSync, /// Per-identity token-state sync coordinator. @@ -472,6 +473,8 @@ pub struct PlatformWalletManager { /// is torn down. pub(super) event_adapter_cancel: CancellationToken, pub(super) event_adapter_join: tokio::sync::Mutex>>, + pub(super) history_retry_cancel: CancellationToken, + pub(super) history_retry_join: tokio::sync::Mutex>>, /// Shared lifecycle registry for the periodic coordinator threads. /// Each coordinator spawns its loop through `registry.start_thread` at /// `start`, handing the registry ownership of the OS thread and its @@ -513,8 +516,10 @@ pub struct PlatformWalletManager { /// /// A `std::sync::Mutex`: touched only at wallet registration and load, for /// one map lookup, and never held across an await. - pub(super) in_broadcast_fences: std::sync::Mutex< - std::collections::BTreeMap>, + pub(super) in_broadcast_fences: Arc< + std::sync::Mutex< + std::collections::BTreeMap>, + >, >, } @@ -692,10 +697,12 @@ impl PlatformWalletManager

{ tracked_masternodes: std::sync::Arc::new(Default::default()), event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), + history_retry_cancel: CancellationToken::new(), + history_retry_join: tokio::sync::Mutex::new(None), registry, sync_fault, durable_cursors, - in_broadcast_fences: std::sync::Mutex::new(std::collections::BTreeMap::new()), + in_broadcast_fences: Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new())), } } @@ -711,17 +718,7 @@ impl PlatformWalletManager

{ wm: &mut WalletManager, wallet_id: &WalletId, ) -> Result<(), key_wallet_manager::WalletError> { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - let (_, info) = wm.remove_wallet(wallet_id)?; - let retiring = info - .rewind_barrier - .retire(WalletInfoInterface::account_generation(&info)); - let mut retired = self.durable_cursors.retired(); - let entry = retired.entry(*wallet_id).or_default(); - entry.in_flight = entry.in_flight.saturating_add(retiring.in_flight); - entry.epoch = entry.epoch.max(retiring.epoch); - entry.account_generation = entry.account_generation.max(retiring.account_generation); - Ok(()) + load::LoadDriver::new(self).remove_from_wallet_manager(wm, wallet_id) } /// Settle the host's DashPay backfill record for a wallet about to be @@ -765,74 +762,14 @@ impl PlatformWalletManager

{ own_cursor: u32, snapshot_cursor: u32, ) -> Result { - let inline = self.persister.store_commits_inline(); - let durable = persists_backfill_coverage(&*self.persister); - let predecessor = durable_cursors.contains_key(&wallet_id); - let held_on_host = !record.is_empty() || dropped; - let record = if durable { - record - } else { - DashPayBackfillRecord::default() - }; - // A backend that cannot hold the record durably never had one - // written by this process, so only a record it handed back can need - // invalidating; one that can is replaced when the snapshot's record - // may disagree with what the wallet carries. - let replace = if durable { - dropped || predecessor - } else { - held_on_host - }; - if !replace { - return Ok(record); - } - let host_cursor = durable_cursors - .get(&wallet_id) - .map_or(snapshot_cursor, |entry| entry.height); - let cursor = (!record.is_empty() && own_cursor < host_cursor).then_some(own_cursor); - tracing::info!( - wallet_id = %hex::encode(wallet_id), - covered = record.covered().len(), + load::LoadDriver::new(self).settle_published_backfill( + durable_cursors, + wallet_id, + record, dropped, - predecessor, - durable, - cursor = ?cursor, - "the host's DashPay backfill record is replaced with the one this wallet \ - carries before the wallet is published" - ); - let changeset = PlatformWalletChangeSet { - core: cursor.map(|cursor| CoreChangeSet { - synced_height: Some(cursor), - synced_height_is_rewind: true, - ..CoreChangeSet::default() - }), - dashpay_backfill: Some(record.clone()), - ..PlatformWalletChangeSet::default() - }; - self.persister.store(wallet_id, changeset).map_err(|e| { - tracing::error!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "failed to replace the host's DashPay backfill record; \ - the wallet was not published" - ); - PlatformWalletError::from_store_failure(&*self.persister, e) - })?; - if !inline { - self.persister.flush(wallet_id).map_err(|e| { - tracing::error!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "failed to commit the replaced DashPay backfill record; \ - the wallet was not published" - ); - PlatformWalletError::from_flush_failure(e) - })?; - } - if let Some(cursor) = cursor { - durable_cursors.insert(wallet_id, DurableCursor::at(cursor)); - } - Ok(record) + own_cursor, + snapshot_cursor, + ) } /// Hand a newly published wallet the barrier state a removed wallet of @@ -845,10 +782,7 @@ impl PlatformWalletManager

{ wm: &mut WalletManager, wallet_id: &WalletId, ) { - let retired = self.durable_cursors.retired().remove(wallet_id); - if let (Some(retired), Some(info)) = (retired, wm.get_wallet_info_mut(wallet_id)) { - info.rewind_barrier.inherit(retired); - } + load::LoadDriver::new(self).inherit_rewind_barrier(wm, wallet_id) } /// The in-broadcast fence map for `wallet_id`, creating it on first use. @@ -862,13 +796,7 @@ impl PlatformWalletManager

{ &self, wallet_id: &WalletId, ) -> Arc { - Arc::clone( - self.in_broadcast_fences - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .entry(*wallet_id) - .or_default(), - ) + load::LoadDriver::new(self).in_broadcast_fences_for(wallet_id) } /// Whether the wallet-event adapter has frozen a durable sync @@ -1171,6 +1099,26 @@ impl PlatformWalletManager

{ /// /// [`WorkerStatus`]: dash_async::WorkerStatus pub async fn shutdown(&self) -> ShutdownReport { + self.history_retry_cancel.cancel(); + let history_status = { + let mut slot = self.history_retry_join.lock().await; + match slot.take() { + None => WorkerStatus::NotRunning, + Some(mut handle) => { + match tokio::time::timeout(EVENT_ADAPTER_JOIN_BUDGET, &mut handle).await { + Ok(Ok(())) => WorkerStatus::Ok, + Ok(Err(error)) if error.is_panic() => { + WorkerStatus::Panicked(error.to_string()) + } + Ok(Err(error)) => WorkerStatus::Stopped(Some(error.to_string())), + Err(_) => { + *slot = Some(handle); + WorkerStatus::Timeout + } + } + } + } + }; // SPV first: it is the event source feeding everything below. Its // `stop` bounds the wait for teardown but never aborts it, so a // timeout means SPV work is still live. Its outcome lands in the @@ -1232,6 +1180,9 @@ impl PlatformWalletManager

{ // it waits for the actual OS thread to terminate before the host // drops the runtime. let mut report = self.registry.shutdown().await; + report + .per_worker + .insert(WalletWorker::HistoryReplay, history_status); // Fold drain timeouts in. A timed-out drain means a pass — // possibly a *direct* `sync_now` running on a host FFI thread the @@ -1272,6 +1223,7 @@ impl PlatformWalletManager

{ // not a registry worker; on a join timeout the live handle is // re-parked so a destroy retry can re-join it rather than // silently detaching the task. + self.history_retry_cancel.cancel(); self.event_adapter_cancel.cancel(); let adapter_status = { let mut slot = self.event_adapter_join.lock().await; @@ -1328,6 +1280,7 @@ impl Drop for PlatformWalletManager

{ // next `recv` and exits after committing whatever its drain claimed the // persister for. Aborting stops it at whatever await it is parked on, // dropping a claimed batch mid-commit. + self.history_retry_cancel.cancel(); self.event_adapter_cancel.cancel(); } } diff --git a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index 295427f9fb1..081f2160775 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -384,7 +384,14 @@ impl PlatformAddressSyncManager { let snapshot: Vec<(WalletId, Arc)> = { let wallets = self.wallets.load(); - wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() + wallets + .iter() + .filter(|(_, wallet)| { + wallet.history_readiness() + == crate::wallet::platform_wallet::HistoryReadiness::Ready + }) + .map(|(id, w)| (*id, Arc::clone(w))) + .collect() }; let config = self.current_config(); diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 2fc4e269785..6a0fa271447 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -375,7 +375,9 @@ impl PlatformWalletManager

{ // a wallet registered between the two checks still collides there. { let wm = self.wallet_manager.read().await; - if wm.get_wallet_info(®istration_wallet_id).is_some() { + if wm.get_wallet_info(®istration_wallet_id).is_some() + || self.wallets.load().contains_key(®istration_wallet_id) + { return Err(already_registered(registration_wallet_id)); } } @@ -523,6 +525,9 @@ impl PlatformWalletManager

{ { return Err(already_registered(registration_wallet_id)); } + if self.wallets.load().contains_key(®istration_wallet_id) { + return Err(already_registered(registration_wallet_id)); + } let host_backfill = self.settle_published_backfill( &mut durable_cursors, registration_wallet_id, @@ -991,12 +996,14 @@ impl PlatformWalletManager

{ their identity-sync rows are left for the next launch to reconcile" ); } - if let Err(e) = self.remove_from_wallet_manager(&mut wm, wallet_id) { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "remove_wallet: inner wallet-manager removal failed (state may be inconsistent)" - ); + if wm.get_wallet(wallet_id).is_some() { + if let Err(e) = self.remove_from_wallet_manager(&mut wm, wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "remove_wallet: inner wallet-manager removal failed (state may be inconsistent)" + ); + } } } diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index 7ee912d6710..bdd6bbd6160 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -326,6 +326,42 @@ impl SpvRuntime { self.task.lock().unwrap_or_else(PoisonError::into_inner) } + /// Prefetch checked history evidence without holding a wallet or persistence lock. + pub(crate) async fn verify_history_locks( + &self, + candidates: Vec, + ) -> crate::changeset::recorded_history::ReplayEvidence { + let mut evidence = crate::changeset::recorded_history::ReplayEvidence::default(); + let client = self.client.read().await; + let Some(client) = client.as_ref() else { + return evidence; + }; + for lock in candidates { + match client.verify_instant_lock(&lock).await { + Ok(Some(proof)) => { + evidence.instant_locks.insert(proof.txid(), proof); + } + Ok(None) => {} + Err(error) => { + tracing::debug!(%error, txid = %lock.txid, "stored InstantLock failed SPV verification; wallet remains read-only") + } + } + } + evidence + } + + #[cfg(test)] + pub(crate) async fn install_history_fixture( + &self, + fixture: &dash_spv::test_utils::InstantLockFixture, + ) { + let client = self.client.read().await; + fixture + .install(client.as_ref().expect("SPV started")) + .await + .unwrap(); + } + /// Check whether the SPV client has been started. pub fn is_started(&self) -> bool { self.client.try_read().map(|c| c.is_some()).unwrap_or(false) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 50a564b04df..eb62128996c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -314,6 +314,23 @@ pub struct PlatformWalletInfo { pub rewind_barrier: crate::changeset::core_bridge::RewindBarrier, } +/// Whether restored history can participate in live wallet operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryReadiness { + /// All required evidence was verified and the wallet is active. + Ready, + /// Stored finality awaits the SPV verifier; the snapshot remains read-only. + AwaitingProof, + /// Explicit recovery mode keeps the snapshot read-only. + Recovery, +} + +pub(crate) struct DeferredWalletSnapshot { + pub wallet: Wallet, + pub info: PlatformWalletInfo, + pub readiness: HistoryReadiness, +} + /// A platform wallet that combines core UTXO functionality with identity management. /// /// This is SPV-free. It needs only key material and an `Sdk`. @@ -326,6 +343,7 @@ pub struct PlatformWalletInfo { /// clones see the same UTXOs, balances, and identities through the shared /// `WalletManager` lock. pub struct PlatformWallet { + pub(crate) deferred_history: Arc>, wallet_id: WalletId, pub(crate) sdk: Arc, pub(crate) wallet_manager: Arc>>, @@ -394,6 +412,14 @@ pub struct PlatformWallet { } impl PlatformWallet { + /// Readiness of this wallet's restored history, including conservative cold starts. + pub fn history_readiness(&self) -> HistoryReadiness { + self.deferred_history + .load() + .as_ref() + .map_or(HistoryReadiness::Ready, |snapshot| snapshot.readiness) + } + /// Access the core wallet (balance, UTXOs, addresses). pub fn core(&self) -> &CoreWallet { &self.core @@ -472,10 +498,7 @@ impl PlatformWallet { /// Read-lock the wallet manager and return a guard that derefs to this /// wallet's `PlatformWalletInfo`. pub async fn state(&self) -> WalletStateReadGuard<'_> { - WalletStateReadGuard { - guard: self.wallet_manager.read().await, - wallet_id: self.wallet_id, - } + self.read_guard(self.wallet_manager.read().await) } /// Write-lock the wallet manager and return a guard that derefs to this @@ -489,10 +512,7 @@ impl PlatformWallet { /// Blocking read-lock. pub fn state_blocking(&self) -> WalletStateReadGuard<'_> { - WalletStateReadGuard { - guard: self.wallet_manager.blocking_read(), - wallet_id: self.wallet_id, - } + self.read_guard(self.wallet_manager.blocking_read()) } /// Blocking write-lock. @@ -655,10 +675,23 @@ impl PlatformWallet { self.wallet_manager .try_read() .ok() - .map(|guard| WalletStateReadGuard { - guard, - wallet_id: self.wallet_id, - }) + .map(|guard| self.read_guard(guard)) + } + + fn read_guard<'a>( + &self, + guard: RwLockReadGuard<'a, WalletManager>, + ) -> WalletStateReadGuard<'a> { + let deferred = if guard.get_wallet_info(&self.wallet_id).is_none() { + self.deferred_history.load_full() + } else { + None + }; + WalletStateReadGuard { + guard, + wallet_id: self.wallet_id, + deferred, + } } /// Non-blocking write-lock. Returns `None` if the lock is currently @@ -756,6 +789,7 @@ impl PlatformWallet { ); Self { + deferred_history: Arc::new(arc_swap::ArcSwapOption::empty()), wallet_id, sdk, wallet_manager, @@ -2261,6 +2295,7 @@ impl PlatformWallet { impl Clone for PlatformWallet { fn clone(&self) -> Self { Self { + deferred_history: Arc::clone(&self.deferred_history), wallet_id: self.wallet_id, sdk: self.sdk.clone(), wallet_manager: self.wallet_manager.clone(), @@ -2295,6 +2330,7 @@ impl std::fmt::Debug for PlatformWallet { /// Read guard that locks `WalletManager` and derefs to this wallet's /// `PlatformWalletInfo`. Also provides `.wallet()` for key material access. pub struct WalletStateReadGuard<'a> { + deferred: Option>, guard: RwLockReadGuard<'a, WalletManager>, wallet_id: WalletId, } @@ -2302,6 +2338,9 @@ pub struct WalletStateReadGuard<'a> { impl<'a> WalletStateReadGuard<'a> { /// Access the immutable `Wallet` (key material). pub fn wallet(&self) -> &Wallet { + if let Some(snapshot) = &self.deferred { + return &snapshot.wallet; + } self.guard .get_wallet(&self.wallet_id) .expect("wallet exists in guard") @@ -2311,6 +2350,9 @@ impl<'a> WalletStateReadGuard<'a> { impl Deref for WalletStateReadGuard<'_> { type Target = PlatformWalletInfo; fn deref(&self) -> &PlatformWalletInfo { + if let Some(snapshot) = &self.deferred { + return &snapshot.info; + } self.guard .get_wallet_info(&self.wallet_id) .expect("wallet exists in guard") From e4fc7278e3d0ea50d8f3e47df8651b830050f8dd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:51:16 +0000 Subject: [PATCH 52/84] feat(wallet): plan owner authorization repair for stored history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify selected history through the key-wallet owner policy and return candidate descendant rejection with all surviving claimant provenance. Bind funding to canonical bodies; preserve unsupported authorization as a typed error and retain noncandidate records. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude gpt-6-astra --- .../src/manager/history_authorization.rs | 397 ++++++++++++++++++ .../rs-platform-wallet/src/manager/mod.rs | 1 + 2 files changed, 398 insertions(+) create mode 100644 packages/rs-platform-wallet/src/manager/history_authorization.rs diff --git a/packages/rs-platform-wallet/src/manager/history_authorization.rs b/packages/rs-platform-wallet/src/manager/history_authorization.rs new file mode 100644 index 00000000000..5f10fdd0c70 --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_authorization.rs @@ -0,0 +1,397 @@ +//! Pure owner-authorization planning for a selected portion of stored history. + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::{OutPoint, TxOut, Txid}; +use key_wallet::transaction_checking::spend_authorization::{ + verify_input_authorization, SpendAuthorizationError, +}; + +use crate::changeset::StoredTransaction; + +/// Rejected history and the claims retained by every surviving transaction. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct HistoryAuthorization { + /// Candidate transactions with a demonstrably invalid owner authorization. + pub invalid_owner: BTreeSet, + /// Invalid owners and their descendants within the candidate subgraph. + pub rejected: BTreeSet, + /// All surviving claimants, including transactions outside the candidate set. + pub surviving_claimants: BTreeMap>, +} + +/// Plan authorization repair without interpreting stored finality or changing state. +/// +/// The caller selects candidates after resolving finality, supplies wallet-owned +/// prevouts, and validates that every `owned_inputs` annotation belongs to this +/// wallet. An annotation is an ownership hint, never proof of authorization. +/// Canonical funding bodies take precedence over remembered prevouts and hints; +/// a missing output in a present funding body is an invalid claim. +/// +/// All identifiers are computed from transaction bodies, independently of stored +/// row keys. Unsupported authorization returns an error so callers can preserve +/// existing holds instead of treating unavailable verification as rejection. +pub fn plan_history_authorization( + records: &[StoredTransaction], + candidates: &BTreeSet, + owned_prevouts: &BTreeMap, +) -> Result { + let records: Vec<_> = records + .iter() + .map(|record| (record.transaction.txid(), record)) + .collect(); + let funding: BTreeMap<_, _> = records + .iter() + .map(|(txid, record)| (*txid, &record.transaction)) + .collect(); + let mut result = HistoryAuthorization::default(); + let mut children: BTreeMap> = BTreeMap::new(); + + for (txid, record) in &records { + if !candidates.contains(txid) { + continue; + } + for (index, input) in record.transaction.input.iter().enumerate() { + let outpoint = input.previous_output; + children.entry(outpoint.txid).or_default().insert(*txid); + let annotation = record + .owned_inputs + .iter() + .find(|detail| usize::try_from(detail.index) == Ok(index)); + let remembered = owned_prevouts.get(&outpoint); + if remembered.is_none() && annotation.is_none() { + continue; + } + let fallback; + let prevout = if let Some(parent) = funding.get(&outpoint.txid) { + let Some(output) = usize::try_from(outpoint.vout) + .ok() + .and_then(|vout| parent.output.get(vout)) + else { + result.invalid_owner.insert(*txid); + continue; + }; + output + } else if let Some(remembered) = remembered { + remembered + } else if let Some(annotation) = annotation { + fallback = TxOut { + value: annotation.value, + script_pubkey: annotation.address.script_pubkey(), + }; + &fallback + } else { + continue; + }; + if !verify_input_authorization(&record.transaction, index, prevout)? { + result.invalid_owner.insert(*txid); + } + } + } + + result.rejected = result.invalid_owner.clone(); + let mut pending = result.invalid_owner.clone(); + while let Some(parent) = pending.pop_first() { + if let Some(descendants) = children.get(&parent) { + for child in descendants { + if result.rejected.insert(*child) { + pending.insert(*child); + } + } + } + } + for (txid, record) in records { + if !result.rejected.contains(&txid) { + for input in &record.transaction.input { + result + .surviving_claimants + .entry(input.previous_output) + .or_default() + .insert(txid); + } + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::crypto::ecdsa::EcdsaSecretKey; + use dashcore::hashes::{hash160, Hash}; + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use dashcore::{Address, Network, ScriptBuf, Transaction, TxIn}; + use key_wallet::managed_account::transaction_record::InputDetail; + use key_wallet::transaction_checking::TransactionContext; + + fn coin(key: u8) -> TxOut { + let key = EcdsaSecretKey::from_bytes(&[key; 32]).unwrap(); + TxOut { + value: 100_000, + script_pubkey: ScriptBuf::new_p2pkh( + &hash160::Hash::hash(&key.public_key().to_compressed()).into(), + ), + } + } + + fn spend(outpoint: OutPoint, key: u8) -> StoredTransaction { + let prevout = coin(key); + let secret = EcdsaSecretKey::from_bytes(&[key; 32]).unwrap(); + let mut transaction = Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: outpoint, + ..Default::default() + }], + output: vec![prevout.clone()], + special_transaction_payload: None, + }; + let hash = SighashCache::new(&transaction) + .legacy_signature_hash(0, &prevout.script_pubkey, 1) + .unwrap(); + let mut signature = secret + .sign(&hash.to_byte_array()) + .with_der(|der| der.to_vec()); + signature.push(1); + transaction.input[0].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(signature).unwrap()) + .push_slice(secret.public_key().to_compressed()) + .into_script(); + StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: vec![], + } + } + + fn outpoint(tag: u8) -> OutPoint { + OutPoint { + txid: Txid::from_byte_array([tag; 32]), + vout: 0, + } + } + + fn annotate(record: &mut StoredTransaction, key: u8) { + record.owned_inputs.push(InputDetail { + index: 0, + value: coin(key).value, + address: Address::from_script(&coin(key).script_pubkey, Network::Testnet).unwrap(), + }); + } + + fn candidates(records: &[StoredTransaction]) -> BTreeSet { + records.iter().map(|r| r.transaction.txid()).collect() + } + + #[test] + fn should_preserve_signed_claimants_and_reject_forged_claims_across_accounts() { + let valid = spend(outpoint(1), 7); + let forged = spend(outpoint(2), 8); + let records = [valid.clone(), forged.clone()]; + let result = plan_history_authorization( + &records, + &candidates(&records), + &BTreeMap::from([(outpoint(1), coin(7)), (outpoint(2), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.invalid_owner, + BTreeSet::from([forged.transaction.txid()]) + ); + assert_eq!(result.rejected, result.invalid_owner); + assert_eq!( + result.surviving_claimants, + BTreeMap::from([(outpoint(1), BTreeSet::from([valid.transaction.txid()]))]) + ); + } + + #[test] + fn should_reject_candidate_descendants_in_reverse_order_using_body_ids() { + let mut invalid = spend(outpoint(1), 8); + invalid.txid = outpoint(99).txid; + let child = spend( + OutPoint { + txid: invalid.transaction.txid(), + vout: 0, + }, + 8, + ); + let grandchild = spend( + OutPoint { + txid: child.transaction.txid(), + vout: 0, + }, + 8, + ); + let records = [grandchild, child, invalid.clone()]; + let result = plan_history_authorization( + &records, + &candidates(&records), + &BTreeMap::from([(outpoint(1), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.invalid_owner, + BTreeSet::from([invalid.transaction.txid()]) + ); + assert_eq!(result.rejected, candidates(&records)); + assert!(result.surviving_claimants.is_empty()); + } + + #[test] + fn should_keep_every_surviving_claimant_including_non_candidates() { + let invalid = spend(outpoint(1), 8); + let final_record = spend(outpoint(1), 9); + let valid = spend(outpoint(1), 7); + let records = [invalid.clone(), final_record.clone(), valid.clone()]; + let selected = BTreeSet::from([invalid.transaction.txid(), valid.transaction.txid()]); + let result = plan_history_authorization( + &records, + &selected, + &BTreeMap::from([(outpoint(1), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.rejected, + BTreeSet::from([invalid.transaction.txid()]) + ); + assert_eq!( + result.surviving_claimants[&outpoint(1)], + BTreeSet::from([final_record.transaction.txid(), valid.transaction.txid()]) + ); + } + + #[test] + fn should_use_canonical_funding_before_corrupt_annotation_or_coin() { + let parent = spend(outpoint(1), 7); + let prevout = OutPoint { + txid: parent.transaction.txid(), + vout: 0, + }; + let mut valid = spend(prevout, 7); + annotate(&mut valid, 8); + let mut forged = spend(prevout, 8); + annotate(&mut forged, 8); + let selected = BTreeSet::from([valid.transaction.txid(), forged.transaction.txid()]); + let result = plan_history_authorization( + &[forged.clone(), valid, parent], + &selected, + &BTreeMap::from([(prevout, coin(8))]), + ) + .unwrap(); + assert_eq!( + result.invalid_owner, + BTreeSet::from([forged.transaction.txid()]) + ); + } + + #[test] + fn should_reject_missing_canonical_output_without_annotation_fallback() { + let parent = spend(outpoint(1), 7); + let prevout = OutPoint { + txid: parent.transaction.txid(), + vout: 5, + }; + let mut child = spend(prevout, 7); + annotate(&mut child, 7); + let selected = BTreeSet::from([child.transaction.txid()]); + let result = plan_history_authorization( + &[child, parent], + &selected, + &BTreeMap::from([(prevout, coin(7))]), + ) + .unwrap(); + assert_eq!(result.invalid_owner, selected); + } + + #[test] + fn should_verify_annotation_only_owned_inputs_and_ignore_external_inputs() { + let mut valid = spend(outpoint(1), 7); + annotate(&mut valid, 7); + let mut forged = spend(outpoint(2), 8); + annotate(&mut forged, 7); + let mut external = spend(outpoint(3), 8); + external.transaction.input[0].script_sig = ScriptBuf::new(); + let records = [valid.clone(), forged.clone(), external.clone()]; + let result = + plan_history_authorization(&records, &candidates(&records), &BTreeMap::new()).unwrap(); + assert_eq!(result.rejected, BTreeSet::from([forged.transaction.txid()])); + assert_eq!( + result.surviving_claimants[&outpoint(1)], + BTreeSet::from([valid.transaction.txid()]) + ); + assert_eq!( + result.surviving_claimants[&outpoint(3)], + BTreeSet::from([external.transaction.txid()]) + ); + } + + #[test] + fn should_stop_descendant_rejection_at_a_non_candidate() { + let invalid = spend(outpoint(1), 8); + let final_record = spend( + OutPoint { + txid: invalid.transaction.txid(), + vout: 0, + }, + 8, + ); + let child = spend( + OutPoint { + txid: final_record.transaction.txid(), + vout: 0, + }, + 8, + ); + let records = [child.clone(), final_record.clone(), invalid.clone()]; + let selected = BTreeSet::from([invalid.transaction.txid(), child.transaction.txid()]); + let result = plan_history_authorization( + &records, + &selected, + &BTreeMap::from([(outpoint(1), coin(7))]), + ) + .unwrap(); + assert_eq!( + result.rejected, + BTreeSet::from([invalid.transaction.txid()]) + ); + assert!(result + .surviving_claimants + .values() + .any(|ids| ids.contains(&child.transaction.txid()))); + let mut reversed = records.to_vec(); + reversed.reverse(); + assert_eq!( + result, + plan_history_authorization( + &reversed, + &selected, + &BTreeMap::from([(outpoint(1), coin(7))]) + ) + .unwrap() + ); + } + + #[test] + fn should_return_unknown_authorization_as_error_without_a_repair_plan() { + let record = spend(outpoint(1), 7); + let selected = BTreeSet::from([record.transaction.txid()]); + let unknown = TxOut { + value: 100_000, + script_pubkey: ScriptBuf::new(), + }; + assert_eq!( + plan_history_authorization( + &[record], + &selected, + &BTreeMap::from([(outpoint(1), unknown)]) + ), + Err(SpendAuthorizationError::UnsupportedScript) + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3b2d4e9fe20..ec7e483b766 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_authorization; pub mod history_replay; pub mod identity_sync; mod load; From e3899844d82c4a607c11808f21e8cd81a2cfb7a4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:34:25 +0000 Subject: [PATCH 53/84] feat(wallet): apply owner authorization verdicts during atomic replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate stored owned-input claims through key-wallet and carry rejection and surviving-claim outcomes to the SQL transaction. Preserve physical-key mapping, rollback and Recovery semantics. Restore producer credit verdicts and named durable claims needed to release only attributable invalid spends. Working checkpoint: focused planner/SQL regressions pass; 21 enabled legacy replay/loader tests require fixture classification and adaptation. Broader shared preparation, migration and binding integration remain unfinished. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../rs-platform-wallet-ffi/src/persistence.rs | 5 +- .../src/sqlite/history_replay.rs | 162 ++++++++++++- .../src/sqlite/persister.rs | 27 ++- .../src/sqlite/schema/core_state.rs | 53 ++++- .../tests/integration/common/mod.rs | 35 +++ .../integration/sqlite_spent_rehydration.rs | 9 +- .../src/changeset/recorded_history.rs | 4 +- .../src/manager/history_authorization.rs | 2 +- .../src/manager/history_replay.rs | 222 ++++++++++++++---- .../rs-platform-wallet/src/manager/load.rs | 17 ++ .../rs-platform-wallet/src/manager/mod.rs | 2 +- .../src/wallet/asset_lock/sync/recovery.rs | 3 + 12 files changed, 475 insertions(+), 66 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index d96ba0f5b58..cd0b23191b3 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -5937,6 +5937,7 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor ); } RecordedHistory { + owned_prevouts: Default::default(), transactions, instant_locks: BTreeMap::new(), } @@ -14321,7 +14322,9 @@ mod tests { 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; + replay_recorded_history(&mut info, &mut wallet, state.recorded_history) + .await + .unwrap(); info.check_core_transaction( &funding, TransactionContext::InBlock(BlockInfo::new( diff --git a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs index 2fdab28d86f..90a880affa1 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs @@ -1,10 +1,13 @@ //! SQL adapter for wallet-owned replay effects. -use dashcore::{hashes::Hash, OutPoint, Txid}; +use crate::sqlite::util::safe_cast::i64_to_u64; +use dashcore::{hashes::Hash, OutPoint, ScriptBuf, TxOut, Txid}; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::manager::history_replay::HistoryReplay; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::slice; use crate::sqlite::schema::{asset_locks, blob, core_state, dashpay}; use crate::WalletStorageError; @@ -46,9 +49,10 @@ pub(crate) fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, outcome: &mut HistoryReplay, - claims: &[(OutPoint, Option)], ) -> Result<(), WalletStorageError> { - let held = outcome.protect_durable_claims(claims); + apply_authorization(tx, wallet_id, outcome)?; + let claims = load_spent_claims(tx, wallet_id)?; + let held = outcome.protect_durable_claims(&claims); let mut preserved = Vec::new(); for outpoint in held { let key = blob::encode_outpoint(&outpoint)?; @@ -77,3 +81,155 @@ pub(crate) fn apply( } Ok(()) } + +/// Stored coin material is validated against wallet address ownership by the owner. +pub(crate) fn load_prevouts( + conn: &Connection, + wallet_id: &WalletId, +) -> Result, WalletStorageError> { + let mut coins = BTreeMap::new(); + let limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); + let mut stmt = conn.prepare_cached("SELECT length(outpoint), CASE WHEN length(outpoint) <= ?2 THEN outpoint END, value, length(script), CASE WHEN length(script) <= ?2 THEN script END FROM core_utxos WHERE wallet_id = ?1 AND is_sweep_placeholder = 0")?; + let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + blob::check_size(row.get(3)?)?; + let outpoint: Vec = row.get(1)?; + let script: Vec = row.get(4)?; + coins.insert( + blob::decode_outpoint(&outpoint)?, + TxOut { + value: i64_to_u64("core_utxos.value", row.get(2)?)?, + script_pubkey: ScriptBuf::from_bytes(script), + }, + ); + } + Ok(coins) +} + +/// Map canonical owner verdicts to physical rows without inferring authorization in SQL. +fn apply_authorization( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outcome: &HistoryReplay, +) -> Result<(), WalletStorageError> { + let rejected: HashSet<_> = outcome.authorization.rejected.iter().copied().collect(); + if rejected.is_empty() { + return Ok(()); + } + let mut record_keys: HashMap> = HashMap::new(); + let limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); + let mut stmt = tx.prepare_cached("SELECT length(txid), CASE WHEN length(txid) = 32 THEN txid END, length(record_blob), CASE WHEN length(record_blob) <= ?2 THEN record_blob END FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL")?; + let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.txid")?; + blob::check_size(row.get(2)?)?; + let key: Vec = row.get(1)?; + let raw: Vec = row.get(3)?; + let record = core_state::decode_transaction_record(&raw)?; + record_keys + .entry(record.transaction.txid()) + .or_default() + .push(Txid::from_slice(&key)?); + } + let survivors: HashMap<_, _> = outcome + .authorization + .surviving_claimants + .iter() + .filter_map(|(outpoint, claims)| claims.first().map(|claim| (*outpoint, *claim))) + .collect(); + let physical_keys: HashSet = rejected + .iter() + .flat_map(|txid| { + record_keys + .get(txid) + .map(Vec::as_slice) + .unwrap_or_else(|| slice::from_ref(txid)) + }) + .copied() + .collect(); + let surviving_txids: HashSet = record_keys + .keys() + .filter(|txid| !rejected.contains(*txid)) + .copied() + .collect(); + let protected_keys: HashSet = physical_keys + .intersection(&surviving_txids) + .copied() + .collect(); + // A corrupt physical key can also name a surviving body: its evidence belongs to that survivor. + let rejected_evidence: HashSet = rejected + .union(&physical_keys) + .filter(|txid| !surviving_txids.contains(*txid)) + .copied() + .collect(); + let mut protected_claims = Vec::new(); + // Release before deleting bodies: their cleanup trigger clears claimant IDs. + // Never reinterpret an anonymous or recordless final claim as a rejected peer claim. + let mut claims = Vec::new(); + let mut stmt = tx.prepare_cached("SELECT length(outpoint), CASE WHEN length(outpoint) <= ?2 THEN outpoint END, length(spent_in_txid), CASE WHEN length(spent_in_txid) = 32 THEN spent_in_txid END, is_sweep_placeholder FROM core_utxos WHERE wallet_id = ?1 AND spent = 1 AND spent_in_txid IS NOT NULL")?; + let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; + while let Some(row) = rows.next()? { + blob::check_size(row.get(0)?)?; + let raw: Vec = row.get(1)?; + blob::check_fixed_width(row.get(2)?, 32, "core_utxos.spent_in_txid")?; + let claimant: Vec = row.get(3)?; + let claimant = Txid::from_slice(&claimant)?; + if protected_keys.contains(&claimant) { + protected_claims.push((raw, claimant)); + } else if rejected_evidence.contains(&claimant) { + claims.push((raw, row.get::<_, bool>(4)?)); + } + } + for (raw, placeholder) in claims { + let op = blob::decode_outpoint(&raw)?; + if let Some(survivor) = survivors.get(&op) { + if protected_keys.contains(survivor) { + protected_claims.push((raw.clone(), *survivor)); + } + tx.execute( + "UPDATE core_utxos SET spent_in_txid = ?3 WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + raw, + survivor.as_byte_array().as_slice() + ], + )?; + } else if placeholder { + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), raw], + )?; + } else { + tx.execute("UPDATE core_utxos SET spent = 0, spent_in_txid = NULL, winner_mined_height = NULL WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet_id.as_slice(), raw])?; + } + } + for txid in &physical_keys { + tx.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + )?; + } + // The row-delete trigger clears claims by physical key, even when that key + // names a surviving blob. Restore only the claims we just protected. + for (raw, claimant) in protected_claims { + tx.execute("UPDATE core_utxos SET spent_in_txid = ?3 WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 1 AND spent_in_txid IS NULL", + params![wallet_id.as_slice(), raw, claimant.as_byte_array().as_slice()])?; + } + for txid in &rejected_evidence { + let key = params![wallet_id.as_slice(), txid.as_byte_array().as_slice()]; + tx.execute( + "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", + key, + )?; + // `encode_outpoint_txid_occupies_bytes_two_to_thirty_three` pins this prefix. + tx.execute( + "DELETE FROM core_utxos \ + WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2", + key, + )?; + } + asset_locks::remove_swept(tx, wallet_id, &rejected_evidence)?; + dashpay::settle_swept_payments(tx, wallet_id, &rejected_evidence)?; + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 907f7458da1..9e1c15c437f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1767,6 +1767,15 @@ impl SqlitePersister { } } +fn replay_failure(error: HistoryReplayError) -> PersistenceError { + match error { + HistoryReplayError::SpendAuthorization(error) => { + WalletStorageError::SpendAuthorization(error).into() + } + error => PersistenceError::backend(error), + } +} + fn load_one_wallet( conn: &Connection, wallet_id: WalletId, @@ -1791,12 +1800,13 @@ fn load_one_wallet( history.clone(), evidence, ) - .map_err(PersistenceError::backend)?; + .map_err(replay_failure)?; let mut claims = history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; - if outcome.deferred.is_empty() && !outcome.sweeps.is_empty() { - history_replay::apply(&tx, &wallet_id, &mut outcome, &claims) - .map_err(PersistenceError::from)?; + if outcome.deferred.is_empty() + && (!outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty()) + { + history_replay::apply(&tx, &wallet_id, &mut outcome).map_err(PersistenceError::from)?; state = load_wallet_snapshot(&tx, wallet_id, ctx)?; let remaining = replay_recorded_history_blocking( &mut state.wallet_info, @@ -1804,8 +1814,11 @@ fn load_one_wallet( std::mem::take(&mut state.recorded_history), evidence, ) - .map_err(PersistenceError::backend)?; - if !remaining.sweeps.is_empty() || !remaining.deferred.is_empty() { + .map_err(replay_failure)?; + if !remaining.sweeps.is_empty() + || !remaining.deferred.is_empty() + || !remaining.authorization.rejected.is_empty() + { return Err(PersistenceError::backend( HistoryReplayError::UnsettledAfterReplay, )); @@ -2021,6 +2034,8 @@ fn load_wallet_snapshot( // field existed. unconfirmed_outgoing_txs: Vec::new(), recorded_history: platform_wallet::changeset::RecordedHistory { + owned_prevouts: history_replay::load_prevouts(conn, &wallet_id) + .map_err(PersistenceError::from)?, transactions: core_state.records.into_iter().map(Into::into).collect(), instant_locks: core_state.instant_locks_for_non_final_records, }, diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 657b661c052..0bf075db875 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -7,9 +7,11 @@ use std::collections::{HashMap, HashSet}; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use dashcore::ephemerealdata::chain_lock::ChainLock; +use dashcore::{OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; +use platform_wallet::changeset::changeset::UtxoCreditVerdict; use platform_wallet::changeset::CoreChangeSet; use platform_wallet::wallet::platform_wallet::WalletId; @@ -146,7 +148,26 @@ pub fn apply( refresh the record itself to update its confirmation height" ); } - execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, false)?; + let spent = match cs.utxo_credit_verdicts.get(&utxo.outpoint) { + Some(UtxoCreditVerdict::ObservedSpent { .. } | UtxoCreditVerdict::Doomed) => true, + Some(UtxoCreditVerdict::Uncredited) => { + let prior_spent: Option = tx + .prepare_cached( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + )? + .query_row( + params![wallet_id.as_slice(), blob::encode_outpoint(&utxo.outpoint)?], + |row| row.get(0), + ) + .optional()?; + let Some(prior_spent) = prior_spent else { + continue; + }; + prior_spent + } + None => false, + }; + execute_upsert_utxo(&mut utxo_stmt, wallet_id, utxo, spent, None)?; } } if !cs.spent_utxos.is_empty() { @@ -163,20 +184,36 @@ pub fn apply( "SELECT 1 FROM core_utxos \ WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 0", )?; + // A reservation created here names the record that made it, so a + // conflict removing that record can release the coin. Only the unspent + // -> spent transition is attributed: an existing claim, known or not, stands. + let mut spenders: HashMap = HashMap::new(); + for record in &cs.records { + for input in &record.transaction.input { + spenders.entry(input.previous_output).or_insert(record.txid); + } + } let mut mark_spent_stmt = tx.prepare_cached( - "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1 AND outpoint = ?2", + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 0 THEN ?3 ELSE spent_in_txid END \ + WHERE wallet_id = ?1 AND outpoint = ?2", )?; let mut upsert_stmt = tx.prepare_cached(UPSERT_UTXO_SQL)?; for utxo in &cs.spent_utxos { let op = blob::encode_outpoint(&utxo.outpoint)?; + let spender = spenders.get(&utxo.outpoint); let materialised: bool = materialised_stmt .query_row(params![wallet_id.as_slice(), &op[..]], |_| Ok(true)) .optional()? .unwrap_or(false); if materialised { - mark_spent_stmt.execute(params![wallet_id.as_slice(), &op[..]])?; + mark_spent_stmt.execute(params![ + wallet_id.as_slice(), + &op[..], + spender.map(AsRef::<[u8]>::as_ref) + ])?; } else { - execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true)?; + execute_upsert_utxo(&mut upsert_stmt, wallet_id, utxo, true, spender)?; } } } @@ -766,8 +803,8 @@ fn apply_sweep( } const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ - (wallet_id, outpoint, value, script, spent) \ - VALUES (?1, ?2, ?3, ?4, ?5) \ + (wallet_id, outpoint, value, script, spent, spent_in_txid) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ ON CONFLICT(wallet_id, outpoint) DO UPDATE SET \ value = excluded.value, \ script = excluded.script, \ @@ -781,6 +818,8 @@ const UPSERT_UTXO_SQL: &str = "INSERT INTO core_utxos \ ELSE NULL END"; /// Upsert one `core_utxos` row; `spent` marks spent-only synthetic rows. +/// `spender` is the claimant of a freshly inserted row only: an existing row +/// keeps the claim it already has. /// /// # Errors /// @@ -793,6 +832,7 @@ fn execute_upsert_utxo( wallet_id: &WalletId, utxo: &Utxo, spent: bool, + spender: Option<&Txid>, ) -> Result<(), WalletStorageError> { if utxo.txout.script_pubkey.as_bytes().is_empty() { return Err(WalletStorageError::EmptyUtxoScript { @@ -806,6 +846,7 @@ fn execute_upsert_utxo( crate::sqlite::util::safe_cast::u64_to_i64("core_utxos.value", utxo.value())?, utxo.txout.script_pubkey.as_bytes(), spent, + spender.map(AsRef::<[u8]>::as_ref), ])?; Ok(()) } diff --git a/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs b/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs index 37dca4f7e06..3d3d7d085b1 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs @@ -4,7 +4,14 @@ #![allow(dead_code)] +use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; +use dash_spv::{ClientConfig, DashSpvClient}; +use dashcore::{InstantLock, Network, Transaction}; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::key_wallet_manager::test_utils::MockWallet; use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::wallet::platform_wallet::WalletId; @@ -411,3 +418,31 @@ pub fn authorize_with_mode( } tx } + +/// Produce evidence through the public verifier; no synthetic token constructor. +pub async fn checked_instant_lock(transaction: &Transaction) -> (InstantLock, ReplayEvidence) { + let fixture = InstantLockFixture::new(transaction).unwrap(); + let directory = secure_tempdir().unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(Network::Testnet) + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + ( + fixture.instant_lock().clone(), + ReplayEvidence { + instant_locks: [(proof.txid(), proof)].into(), + }, + ) +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs index 149321bb029..39536454723 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs @@ -2770,12 +2770,9 @@ async fn should_repair_invalid_claims_without_releasing_authorized_competitors() let mut alternative = info.clone(); records.extend(records_of(&mut info, &mut wallet, &bad, MEMPOOL).await); records.extend(records_of(&mut info, &mut wallet, &child, MEMPOOL).await); + let (checked_lock, evidence) = common::checked_instant_lock(&survivor).await; let context = if matches!(context, TransactionContext::InstantSend(_)) { - TransactionContext::InstantSend(dashcore::InstantLock { - txid: survivor.txid(), - inputs: vec![shared], - ..Default::default() - }) + TransactionContext::InstantSend(checked_lock) } else { context }; @@ -2802,7 +2799,7 @@ async fn should_repair_invalid_claims_without_releasing_authorized_competitors() }, ); for _ in 0..2 { - let mut state = persister.load().unwrap(); + let mut state = persister.load_with_replay(&evidence).unwrap().state; let restored = state.wallets.get_mut(&wallet.wallet_id).unwrap(); let mut expected = vec![extra, OutPoint::new(survivor.txid(), 0)]; expected.sort(); diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index cd27392ae31..e15f82c1958 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -9,7 +9,7 @@ use std::collections::BTreeMap; use super::ClientWalletStartState; use dash_spv::VerifiedInstantLock; use dashcore::ephemerealdata::instant_lock::InstantLock; -use dashcore::{Transaction, Txid}; +use dashcore::{OutPoint, Transaction, TxOut, Txid}; use dpp::prelude::AssetLockProof; use key_wallet::managed_account::transaction_record::{ InputDetail, TransactionDirection, TransactionRecord, @@ -40,6 +40,8 @@ pub struct ReplayEvidence { /// already attached to the restored wallet, which it puts back afterwards. #[derive(Debug, Clone, Default)] pub struct RecordedHistory { + /// Stored coin material, including spent rows; ownership is checked by the wallet. + pub owned_prevouts: BTreeMap, /// Stored transactions, in any order; the replay orders them. pub transactions: Vec, /// Persisted InstantSend locks keyed by the txid they lock. A lock that diff --git a/packages/rs-platform-wallet/src/manager/history_authorization.rs b/packages/rs-platform-wallet/src/manager/history_authorization.rs index 5f10fdd0c70..cc43c5918b3 100644 --- a/packages/rs-platform-wallet/src/manager/history_authorization.rs +++ b/packages/rs-platform-wallet/src/manager/history_authorization.rs @@ -31,7 +31,7 @@ pub struct HistoryAuthorization { /// All identifiers are computed from transaction bodies, independently of stored /// row keys. Unsupported authorization returns an error so callers can preserve /// existing holds instead of treating unavailable verification as rejection. -pub fn plan_history_authorization( +pub(super) fn plan_history_authorization( records: &[StoredTransaction], candidates: &BTreeSet, owned_prevouts: &BTreeMap, diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 35fd30f4b9f..549ac6fa03f 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -31,10 +31,13 @@ use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; use key_wallet::Utxo; +use super::history_authorization::plan_history_authorization; +pub use super::history_authorization::HistoryAuthorization; use crate::changeset::changeset::SweepBatch; use crate::changeset::recorded_history::ReplayEvidence; use crate::changeset::{is_owned, ClientStartState, RecordedHistory, StoredTransaction}; use crate::wallet::platform_wallet::WalletId; +use key_wallet::transaction_checking::spend_authorization::SpendAuthorizationError; /// Backend snapshot and the replay verdict committed with each wallet. #[derive(Debug, Default)] @@ -48,6 +51,8 @@ pub struct HistoryReplayLoad { /// What [`replay_recorded_history`] made of the stored history. #[derive(Debug, Default)] pub struct HistoryReplay { + /// Owner-authorized rejections and surviving claims for atomic backend repair. + pub authorization: HistoryAuthorization, /// How many records went through the wallet checker. pub replayed: usize, /// Stored transactions the replay removed or left out: the losers of a @@ -81,7 +86,10 @@ impl HistoryReplay { .iter() .filter_map(|(outpoint, claimant)| { (candidates.contains(outpoint) - && claimant.is_none_or(|txid| !self.defeated.contains(&txid))) + && claimant.is_none_or(|txid| { + !self.defeated.contains(&txid) + && !self.authorization.rejected.contains(&txid) + })) .then_some(*outpoint) }) .collect(); @@ -111,6 +119,9 @@ impl HistoryReplay { /// A blocking backend cannot commit a replay that suspended part-way through. #[derive(Debug, thiserror::Error)] pub enum HistoryReplayError { + /// Stored input authorization cannot be checked with supported rules. + #[error("cannot verify stored input authorization")] + SpendAuthorization(#[from] SpendAuthorizationError), /// The wallet checker requested asynchronous work during local restoration. #[error("wallet history replay requires asynchronous work")] Suspended, @@ -140,7 +151,12 @@ pub fn replay_recorded_history_blocking( .poll(&mut Context::from_waker(Waker::noop())) }; match outcome { - Poll::Ready(replay) => Ok(replay), + Poll::Ready(Ok(replay)) => Ok(replay), + Poll::Ready(Err(error)) => { + *wallet_info = original_info; + *wallet = original_wallet; + Err(error) + } Poll::Pending => { *wallet_info = original_info; *wallet = original_wallet; @@ -184,7 +200,7 @@ pub async fn replay_recorded_history( wallet_info: &mut ManagedWalletInfo, wallet: &mut Wallet, history: RecordedHistory, -) -> HistoryReplay { +) -> Result { replay_recorded_history_with_evidence(wallet_info, wallet, history, &ReplayEvidence::default()) .await } @@ -195,7 +211,7 @@ pub async fn replay_recorded_history_with_evidence( wallet: &mut Wallet, history: RecordedHistory, evidence: &ReplayEvidence, -) -> HistoryReplay { +) -> Result { let mut deferred = BTreeSet::new(); for stored in &history.transactions { let locks = @@ -253,18 +269,76 @@ pub async fn replay_recorded_history_with_evidence( .collect(); wallet_info.restore_spent_outpoints(&claims); wallet_info.update_balance(); - return HistoryReplay { + return Ok(HistoryReplay { deferred, ..Default::default() - }; + }); } let RecordedHistory { - transactions, + mut owned_prevouts, + mut transactions, instant_locks, } = history; if transactions.is_empty() { - return HistoryReplay::default(); + return Ok(HistoryReplay::default()); + } + let owns = |script: &dashcore::ScriptBuf| { + dashcore::Address::from_script(script, wallet_info.network()) + .ok() + .is_some_and(|address| { + wallet_info + .accounts + .all_funding_accounts() + .into_iter() + .any(|account| { + !funds_account_type(account).is_contact_owned() + && account.contains_address(&address) + }) + }) + }; + owned_prevouts.retain(|_, output| owns(&output.script_pubkey)); + for account in wallet_info.accounts.all_funding_accounts() { + if !funds_account_type(account).is_contact_owned() { + for (outpoint, coin) in &account.utxos { + owned_prevouts.insert(*outpoint, coin.txout.clone()); + } + } } + for stored in &mut transactions { + stored + .owned_inputs + .retain(|detail| owns(&detail.address.script_pubkey())); + for (vout, output) in stored.transaction.output.iter().enumerate() { + if owns(&output.script_pubkey) { + owned_prevouts.insert( + OutPoint::new(stored.transaction.txid(), vout as u32), + output.clone(), + ); + } + } + } + let candidates = transactions + .iter() + .filter(|stored| { + matches!(stored.context, TransactionContext::Mempool) + && !instant_locks.contains_key(&stored.txid) + }) + .map(|stored| stored.transaction.txid()) + .collect(); + let authorization = plan_history_authorization(&transactions, &candidates, &owned_prevouts)?; + // A backend will rebuild released credits from its committed projection. + // Until then, never replay the rejected body or restore its fallback record. + for account in wallet_info.accounts.all_funding_accounts_mut() { + account + .utxos + .retain(|outpoint, _| !authorization.rejected.contains(&outpoint.txid)); + } + for mut account in wallet_info.accounts.all_accounts_mut() { + account + .transactions_mut() + .retain(|_, record| !authorization.rejected.contains(&record.transaction.txid())); + } + transactions.retain(|stored| !authorization.rejected.contains(&stored.transaction.txid())); // Where the load projection parked each unspent outpoint; its keys are // the outputs persistence still considers unspent. let placed: HashMap = wallet_info @@ -570,14 +644,16 @@ pub async fn replay_recorded_history_with_evidence( wallet_info.update_balance(); let defeated = swept.clone(); swept.extend(set_aside.iter().map(|stored| stored.txid)); - HistoryReplay { + swept.extend(authorization.rejected.iter().copied()); + Ok(HistoryReplay { + authorization, replayed: replay.len(), retired: swept, defeated, sweeps, deferred, read_only: false, - } + }) } fn sanitize_unsafe_record(record: &mut TransactionRecord) { @@ -1203,6 +1279,7 @@ mod tests { instant_locks: BTreeMap, ) -> RecordedHistory { RecordedHistory { + owned_prevouts: Default::default(), transactions: records.into_iter().map(StoredTransaction::from).collect(), instant_locks, } @@ -1336,6 +1413,7 @@ mod tests { history(records, BTreeMap::new()), ) .await + .unwrap() .replayed; assert_eq!(count, 2); replayed @@ -1388,6 +1466,7 @@ mod tests { 100, )); let replay = |transactions| RecordedHistory { + owned_prevouts: Default::default(), transactions, instant_locks: BTreeMap::new(), }; @@ -1402,7 +1481,9 @@ mod tests { }; let mut replayed = restored.clone(); - replay_recorded_history(&mut replayed, &mut wallet, replay(spender.clone())).await; + replay_recorded_history(&mut replayed, &mut wallet, replay(spender.clone())) + .await + .unwrap(); assert!( !replayed.accounts.standard_bip44_accounts[&0] .utxos @@ -1446,7 +1527,9 @@ mod tests { signature: Default::default(), }); - replay_recorded_history(&mut info, &mut wallet, history(records, BTreeMap::new())).await; + replay_recorded_history(&mut info, &mut wallet, history(records, BTreeMap::new())) + .await + .unwrap(); for account in info.accounts.all_accounts() { for (txid, context) in &contexts { @@ -1487,7 +1570,8 @@ mod tests { &mut wallet, history(vec![spender], BTreeMap::from([(lock.txid, lock)])), ) - .await; + .await + .unwrap(); assert!(outcome.defeated.is_empty()); assert!( outcome.resendable(sends).is_empty(), @@ -1538,7 +1622,8 @@ mod tests { raw.clone(), &ReplayEvidence::default(), ) - .await; + .await + .unwrap(); assert!(pending.deferred.contains(&locked.txid)); assert!(pending.sweeps.is_empty()); assert!(pending @@ -1560,7 +1645,8 @@ mod tests { raw.clone(), &evidence, ) - .await; + .await + .unwrap(); assert!(ready.deferred.is_empty()); assert!( !ready.defeated.contains(&locked.txid), @@ -1584,7 +1670,8 @@ mod tests { changed, &evidence, ) - .await; + .await + .unwrap(); assert!(deferred.deferred.contains(&locked.txid)); assert!(deferred.sweeps.is_empty()); } @@ -1632,7 +1719,8 @@ mod tests { instant_locks: BTreeMap::from([(locked.txid, proof)]), }, ) - .await; + .await + .unwrap(); assert!(outcome.deferred.contains(&locked.txid)); assert!(outcome.sweeps.is_empty()); assert!(outcome.resendable(vec![locked.transaction]).is_empty()); @@ -1677,7 +1765,8 @@ mod tests { instant_locks: BTreeMap::from([(locked.txid, proof)]), }, ) - .await; + .await + .unwrap(); assert!(outcome.deferred.is_empty()); assert!(info.instant_send_locks().contains(&locked.txid)); let before = info.accounts.standard_bip44_accounts[&0].transactions()[&locked.txid].clone(); @@ -1777,10 +1866,13 @@ mod tests { ..spender.clone() }; let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![without_ownership], instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut info, &mut wallet, history).await; + replay_recorded_history(&mut info, &mut wallet, history) + .await + .unwrap(); info.apply_chain_lock(chain_lock(300)); info.update_synced_height(300); assert!(info.observed_spent_outpoints().is_empty(), "{case}"); @@ -1824,13 +1916,16 @@ mod tests { ); let mut info = restored; let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![StoredTransaction { owned_inputs: Vec::new(), ..spender }], instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut info, &mut wallet, history).await; + replay_recorded_history(&mut info, &mut wallet, history) + .await + .unwrap(); info.truncate_above(100).unwrap(); let block = BlockInfo::new(100, BlockHash::from_byte_array([100; 32]), 100); @@ -1887,6 +1982,7 @@ mod tests { Utxo::new(spent, funding.output[0].clone(), address, 100, false), ); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![StoredTransaction { context: TransactionContext::Mempool, owned_inputs: Vec::new(), @@ -1894,7 +1990,9 @@ mod tests { }], instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut info, &mut wallet, history).await; + replay_recorded_history(&mut info, &mut wallet, history) + .await + .unwrap(); info.apply_chain_lock(ChainLock { block_height: 300, block_hash: BlockHash::from_byte_array([30; 32]), @@ -1946,6 +2044,7 @@ mod tests { history(records.clone(), BTreeMap::new()), ) .await + .unwrap() .replayed; restored .check_core_transaction( @@ -1985,7 +2084,8 @@ mod tests { &mut wallet, history(vec![held], BTreeMap::new()), ) - .await; + .await + .unwrap(); assert_eq!( restored.accounts.standard_bip44_accounts[&0].transactions()[&txid].label, "proof lookup" @@ -2036,7 +2136,8 @@ mod tests { &mut wallet, history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), ) - .await; + .await + .unwrap(); assert!( !info.accounts.standard_bip44_accounts[&0] .transactions() @@ -2079,7 +2180,8 @@ mod tests { &mut wallet, history(vec![winner], BTreeMap::new()), ) - .await; + .await + .unwrap(); assert!(!restored.accounts.standard_bip44_accounts[&0] .transactions() @@ -2131,7 +2233,8 @@ mod tests { &mut wallet, history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), ) - .await; + .await + .unwrap(); let transactions = info.accounts.standard_bip44_accounts[&0].transactions(); assert!( !transactions.contains_key(&loser.txid), @@ -2268,7 +2371,9 @@ mod tests { }; let locks = [(winner.txid(), lock)].into_iter().collect(); - replay_recorded_history(&mut restored, &mut wallet, history(records, locks)).await; + replay_recorded_history(&mut restored, &mut wallet, history(records, locks)) + .await + .unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; for (name, txid) in [("loser", loser.txid()), ("child", child.txid())] { @@ -2399,7 +2504,8 @@ mod tests { &mut wallet, history(records, BTreeMap::new()), ) - .await; + .await + .unwrap(); for (index, account) in &restored.accounts.standard_bip44_accounts { for (name, txid) in [("root", root.txid()), ("child", child.txid())] { @@ -2510,11 +2616,13 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + owned_prevouts: Default::default(), transactions: stored, instant_locks: BTreeMap::new(), }, ) .await + .unwrap() .replayed; restored .check_core_transaction(&funding, block(100), &mut wallet, true, true) @@ -2637,11 +2745,13 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + owned_prevouts: Default::default(), transactions: history, instant_locks: BTreeMap::new(), }, ) - .await; + .await + .unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; assert!(!account.transactions().contains_key(&loser)); @@ -2678,11 +2788,13 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + owned_prevouts: Default::default(), transactions: history, instant_locks: BTreeMap::new(), }, ) - .await; + .await + .unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; assert!(account.transactions().contains_key(&loser)); @@ -2754,11 +2866,13 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![loser], instant_locks: BTreeMap::new(), }, ) - .await; + .await + .unwrap(); assert_eq!(outcome.replayed, 0); assert!( restored.accounts.standard_bip44_accounts[&0] @@ -2938,6 +3052,7 @@ mod tests { }; let block = BlockInfo::new(101, BlockHash::from_byte_array([101; 32]), 101); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![ stored(&winner, TransactionContext::InBlock(block)), stored(&loser, TransactionContext::Mempool), @@ -2965,7 +3080,9 @@ mod tests { ); } - let outcome = replay_recorded_history(&mut restored, &mut wallet, history).await; + let outcome = replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); for (retired, why) in [ (&loser, "the loser of a settled conflict"), @@ -3255,6 +3372,7 @@ mod tests { }; let block = BlockInfo::new(100, BlockHash::from_byte_array([82; 32]), 100); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![ stored(funding.clone(), TransactionContext::InBlock(block)), stored(child.clone(), TransactionContext::Mempool), @@ -3280,7 +3398,9 @@ mod tests { ), ); - replay_recorded_history(&mut restored, &mut wallet, history).await; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); let [fallback, owner] = [0, 1].map(|index| &restored.accounts.standard_bip44_accounts[&index]); @@ -3377,6 +3497,7 @@ mod tests { ); } let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: unsafe_spends .iter() .zip(stored_only) @@ -3399,7 +3520,9 @@ mod tests { .collect(), instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut restored, &mut wallet, history).await; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); assert_eq!( restored.balance.total(), 0, @@ -3579,6 +3702,7 @@ mod tests { } } let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: parents .iter() .chain(&forged) @@ -3593,7 +3717,9 @@ mod tests { .collect(), instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut restored, &mut wallet, history).await; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); assert!(!redelivery_credits(&restored, &mut wallet, &fundings[0], &genuine).await); let settled = deliver_confirmed_spend(&mut restored, &mut wallet, &[one, other]).await; @@ -3685,6 +3811,7 @@ mod tests { .utxos .insert(coin, parked); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: funding.txid(), transaction: funding, @@ -3696,7 +3823,9 @@ mod tests { instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut restored, &mut wallet, history).await; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); let [fallback, owner] = [0, 1].map(|index| &restored.accounts.standard_bip44_accounts[&index]); @@ -3775,6 +3904,7 @@ mod tests { .utxos .insert(coin, stored_coin); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: funding.txid(), transaction: funding, @@ -3786,7 +3916,9 @@ mod tests { instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut restored, &mut wallet, history).await; + replay_recorded_history(&mut restored, &mut wallet, history) + .await + .unwrap(); let kept = &restored.accounts.standard_bip44_accounts[&0].utxos[&coin]; assert_eq!(kept.is_confirmed.then_some(kept.height), expected, "{case}"); @@ -3908,6 +4040,7 @@ mod tests { let mut info = restored.clone(); let count = replay_recorded_history(&mut info, &mut wallet, RecordedHistory::default()) .await + .unwrap() .replayed; assert_eq!(count, 0); assert_eq!(info.balance.total(), restored.balance.total()); @@ -4017,7 +4150,8 @@ mod tests { &mut wallet, history(records, BTreeMap::new()), ) - .await; + .await + .unwrap(); let coins = &restored.accounts.standard_bip44_accounts[&0].utxos; assert!(!coins.contains_key(&spent), "{case}"); @@ -4112,7 +4246,9 @@ mod tests { // 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; + replay_recorded_history(&mut alone, &mut wallet, history(pair, locks.clone())) + .await + .unwrap(); assert!( alone.accounts.standard_bip44_accounts[&0] .transactions() @@ -4142,7 +4278,8 @@ mod tests { &mut wallet, history(records.clone(), locks.clone()), ) - .await; + .await + .unwrap(); assert!( !contested.accounts.standard_bip44_accounts[&0] .transactions() @@ -4299,7 +4436,9 @@ mod tests { } } - replay_recorded_history(&mut restored, &mut wallet, history(records, locks)).await; + replay_recorded_history(&mut restored, &mut wallet, history(records, locks)) + .await + .unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") @@ -4414,7 +4553,8 @@ mod tests { 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; + .await + .unwrap(); let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); assert!( !transactions diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 23d7837349f..4383066a119 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -432,6 +432,15 @@ impl LoadDriver

{ evidence, ) .await; + let outcome = match outcome { + Ok(outcome) => outcome, + Err(error) => { + load_error = Some(PlatformWalletError::from_load_failure( + crate::changeset::PersistenceError::backend(error), + )); + break 'load; + } + }; tracing::info!( wallet_id = %hex::encode(expected_wallet_id), offered, @@ -1954,6 +1963,7 @@ mod idempotent_load_tests { let wallet_id = ctx.wallet.compute_wallet_id(); let fixture = InstantLockFixture::new(&funding).unwrap(); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: funding.txid(), transaction: funding.clone(), @@ -2070,6 +2080,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![stored(spend), stored(funding)], instant_locks: BTreeMap::new(), }, @@ -2154,6 +2165,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + owned_prevouts: Default::default(), transactions, instant_locks: BTreeMap::new(), }, @@ -2291,6 +2303,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + owned_prevouts: Default::default(), // The funding's row never left the mempool context. transactions: vec![StoredTransaction { txid: asset_lock.txid(), @@ -2640,6 +2653,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![ stored(&asset_lock, TransactionContext::Mempool), stored(&competitor, competitor_context), @@ -2801,6 +2815,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![ stored(&asset_lock, TransactionContext::Mempool), stored( @@ -2899,6 +2914,7 @@ mod idempotent_load_tests { managed: ManagedWalletInfo::from_wallet(&ctx.wallet, 0), wallet: ctx.wallet, history: RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: asset_lock.txid(), transaction: asset_lock.clone(), @@ -2994,6 +3010,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![stored(unsafe_spend), stored(funding)], instant_locks: BTreeMap::new(), }, diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index ec7e483b766..8e15ae328ee 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -3,7 +3,7 @@ pub mod accessors; pub mod dashpay_sync; pub mod dpns_sync; -pub mod history_authorization; +mod history_authorization; pub mod history_replay; pub mod identity_sync; mod load; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 50c4f747cf7..b7fc17e0e2c 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -4017,6 +4017,7 @@ mod tests { ]; let spender = spending(contested, 1); let history = RecordedHistory { + owned_prevouts: Default::default(), transactions: vec![ stored(spender.clone(), chain_locked_at(100)), stored(confirmed_lock, confirmed_at(101)), @@ -4121,6 +4122,7 @@ mod tests { ), ]; let mut history = RecordedHistory { + owned_prevouts: Default::default(), transactions: [ &matching, &foreign_lock, @@ -4209,6 +4211,7 @@ mod tests { &foreign, ]; let mut history = RecordedHistory { + owned_prevouts: Default::default(), transactions: unconfirmed .map(|transaction| stored(transaction, TransactionContext::Mempool)) .into_iter() From 71012537301a23eb187cd93e9a533f91399611ca Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:38:01 +0000 Subject: [PATCH 54/84] fix(swift-sdk): expose accounting formatter and correct migration assertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow SwiftExampleApp to call the shared formatter across the module boundary. Assert that migrated V4 rows have no wallet-scoped amount until accounting rows are written, retaining the explicit missing-data contract. Validated 32 scoped Rust/FFI tests and three-crate Clippy. SwiftData and SwiftExampleApp execution require a macOS runner unavailable on this host. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../Persistence/Models/PersistentTransaction.swift | 2 +- .../SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift index d2a88879e0e..fde28b02c95 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransaction.swift @@ -288,7 +288,7 @@ public final class PersistentTransaction { public static let noAccounting = "—" /// Signed DASH text for a duff amount; `magnitude` cannot trap on `Int64.min`. - static func format(duffs: Int64) -> String { + public static func format(duffs: Int64) -> String { String(format: "%@%.8f DASH", duffs >= 0 ? "+" : "-", Double(duffs.magnitude) / 100_000_000) } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index ae7e3e0713f..aa42eab571b 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -110,6 +110,7 @@ final class DashReleasedSchemaTests: XCTestCase { /// without one and keep their stored amount, and a row written after the /// migration survives a reopen and cascades with its transaction. @MainActor + // TODO(macOS-validation): Run the V4-to-V5 SwiftData migration test on macOS. func testShouldMigrateV4ToPerWalletAccountingRows() throws { let fixture = try XCTUnwrap(DashReleasedSchemaRegistry.fixtures.first { $0.version.versionIdentifier == Schema.Version(3, 0, 0) @@ -133,7 +134,7 @@ final class DashReleasedSchemaTests: XCTestCase { let row = try XCTUnwrap(container.mainContext.fetch(FetchDescriptor()) .first { $0.txid == txid }) XCTAssertTrue(row.walletAccounting.isEmpty, "V4 rows carry no per-wallet accounting yet") - XCTAssertEqual(row.netAmount(for: walletId), row.netAmount) + XCTAssertNil(row.netAmount(for: walletId), "Global V4 accounting is not wallet-scoped") let accounting = PersistentTransactionAccounting( walletId: walletId, txid: txid, netAmount: -1_234, direction: CoreDirectionCode.outgoing, fee: 34, transaction: nil From 167fbf04caaac15b35a1f816a84ce95fa5775fa2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:49:26 +0000 Subject: [PATCH 55/84] fix(wallet): fail history load when required finality evidence is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return typed transaction-specific evidence errors before replay mutation, including Recovery loads. Reuse existing load failure and publication rollback semantics; callers explicitly retry after SPV evidence becomes available. Remove deferred wallet snapshots, readiness state, fallback accessors and the background activation worker. Preserve proof prefetch, authoritative snapshot rebinding and the existing publication fence. Six focused failure/retry tests and scoped Clippy pass; legacy fixture integration and the Swift startup ordering fix remain open. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../rs-platform-wallet-ffi/src/manager.rs | 4 + .../src/sqlite/persister.rs | 19 +- .../sqlite_shared_history_replay.rs | 43 +- .../src/manager/accessors.rs | 123 +---- .../src/manager/dashpay_sync.rs | 9 +- .../src/manager/dpns_sync.rs | 9 +- .../src/manager/history_replay.rs | 84 +--- .../rs-platform-wallet/src/manager/load.rs | 464 ++---------------- .../rs-platform-wallet/src/manager/mod.rs | 143 ++++-- .../src/manager/platform_address_sync.rs | 9 +- .../src/manager/wallet_lifecycle.rs | 21 +- .../rs-platform-wallet/src/spv/runtime.rs | 2 +- .../src/wallet/platform_wallet.rs | 66 +-- .../PlatformWalletManager.swift | 4 + 14 files changed, 248 insertions(+), 752 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3ecdc18b954..007bafc2dae 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -689,6 +689,10 @@ pub unsafe extern "C" fn platform_wallet_manager_create_wallet_from_mnemonic_wit /// [`platform_wallet_manager_get_wallet`] per `wallet_id` it knows /// about. /// +/// History requiring InstantLock evidence needs a started SPV client. Missing +/// evidence returns a load error with transaction IDs; keep SPV running and +/// retry explicitly after synchronization. Startup alone does not prove readiness. +/// /// On error the handle stays valid and the manager is unchanged: fix the /// store and call again, or destroy the manager and reconstruct it. Reopening /// the same store path requires all persister references to be released. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 9e1c15c437f..ff7967ad513 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1733,6 +1733,11 @@ impl SqlitePersister { // rather than the boundary's own wrapper, so a caller // matching on a specific cause still sees it. Err(original) => { + if matches!(&original, PersistenceError::Backend { source, .. } + if matches!(source.downcast_ref::(), Some(HistoryReplayError::MissingFinalityEvidence { .. }))) + { + return Err(original); + } let cause = wallet_storage_kind(&original); record_wallet_degradation(&ctx, wallet_id, cause, &original) .map_err(|_| original)?; @@ -1797,15 +1802,13 @@ fn load_one_wallet( let mut outcome = replay_recorded_history_blocking( &mut state.wallet_info, &mut state.wallet, - history.clone(), + history, evidence, ) .map_err(replay_failure)?; let mut claims = history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; - if outcome.deferred.is_empty() - && (!outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty()) - { + if !outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty() { history_replay::apply(&tx, &wallet_id, &mut outcome).map_err(PersistenceError::from)?; state = load_wallet_snapshot(&tx, wallet_id, ctx)?; let remaining = replay_recorded_history_blocking( @@ -1815,10 +1818,7 @@ fn load_one_wallet( evidence, ) .map_err(replay_failure)?; - if !remaining.sweeps.is_empty() - || !remaining.deferred.is_empty() - || !remaining.authorization.rejected.is_empty() - { + if !remaining.sweeps.is_empty() || !remaining.authorization.rejected.is_empty() { return Err(PersistenceError::backend( HistoryReplayError::UnsettledAfterReplay, )); @@ -1827,9 +1827,6 @@ fn load_one_wallet( history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; } state.wallet_info.restore_spent_outpoints(&claims); - if !outcome.deferred.is_empty() { - state.recorded_history = history; - } outcome.read_only = policy == LoadPolicy::Recovery; if outcome.read_only { tx.rollback() diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs index 8966f830f0e..0bb6c7c1ce2 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -16,7 +16,9 @@ use platform_wallet::test_support::core_fixtures::{ use platform_wallet_storage::sqlite::schema::blob; use tokio::sync::RwLock; -use crate::common::{authorize, fresh_persister, registration}; +use crate::common::{ + authorize, fresh_persister, registration, LoadPolicy, SqlitePersister, SqlitePersisterConfig, +}; #[tokio::test] async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_filter() { @@ -35,7 +37,7 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); let fixture = InstantLockFixture::new(&locked).unwrap(); - let (persister, _directory, _path) = fresh_persister(); + let (persister, _directory, path) = fresh_persister(); persister .store( wallet.wallet_id, @@ -70,14 +72,12 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil ) .unwrap(); - let pending = persister - .load_with_replay(&ReplayEvidence::default()) - .unwrap(); - assert!(pending.state.wallets.contains_key(&wallet.wallet_id)); - assert!(pending.outcomes[&wallet.wallet_id] - .deferred - .contains(&locked.txid())); - assert!(pending.outcomes[&wallet.wallet_id].sweeps.is_empty()); + assert!( + persister + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "missing proof must fail before SQL effects" + ); assert!(persister .get_core_tx_record(wallet.wallet_id, &locked.txid()) .unwrap() @@ -109,7 +109,6 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil instant_locks: BTreeMap::from([(proof.txid(), proof)]), }; let ready = persister.load_with_replay(&evidence).unwrap(); - assert!(ready.outcomes[&wallet.wallet_id].deferred.is_empty()); assert!(!ready.outcomes[&wallet.wallet_id] .defeated .contains(&locked.txid())); @@ -131,11 +130,23 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil ], ) .unwrap(); - let changed = persister.load_with_replay(&evidence).unwrap(); - assert!(changed.outcomes[&wallet.wallet_id] - .deferred - .contains(&locked.txid())); - assert!(changed.outcomes[&wallet.wallet_id].sweeps.is_empty()); + assert!( + persister.load_with_replay(&evidence).is_err(), + "prefetched proof must be rebound to the authoritative snapshot" + ); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(path).with_load_policy(LoadPolicy::Recovery), + ) + .unwrap(); + assert!( + recovery.load_with_replay(&evidence).is_err(), + "Recovery must not hide a missing required proof" + ); + assert!(recovery + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); } #[test] diff --git a/packages/rs-platform-wallet/src/manager/accessors.rs b/packages/rs-platform-wallet/src/manager/accessors.rs index f35547c8203..bc939526561 100644 --- a/packages/rs-platform-wallet/src/manager/accessors.rs +++ b/packages/rs-platform-wallet/src/manager/accessors.rs @@ -323,17 +323,6 @@ pub fn wallet_utxos_page( wallet_id: &WalletId, after: Option<&WalletUtxoCursor>, limit: usize, -) -> (Vec, bool) { - let Some(info) = wm.get_wallet_info(wallet_id) else { - return (Vec::new(), false); - }; - wallet_utxos_page_from_info(info, after, limit) -} - -fn wallet_utxos_page_from_info( - info: &crate::wallet::platform_wallet::PlatformWalletInfo, - after: Option<&WalletUtxoCursor>, - limit: usize, ) -> (Vec, bool) { use std::ops::Bound; @@ -342,6 +331,9 @@ fn wallet_utxos_page_from_info( } else { limit.min(WALLET_UTXO_PAGE_MAX) }; + let Some(info) = wm.get_wallet_info(wallet_id) else { + return (Vec::new(), false); + }; let mut accounts: Vec<( AccountType, &key_wallet::managed_account::ManagedCoreFundsAccount, @@ -679,13 +671,7 @@ impl PlatformWalletManager

{ /// tokio async task. pub fn account_balances_blocking(&self, wallet_id: &WalletId) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; let last_processed_height = info.core_wallet.metadata.last_processed_height; @@ -757,15 +743,7 @@ impl PlatformWalletManager

{ /// `RwLock` read, no I/O. pub fn wallet_network_blocking(&self, wallet_id: &WalletId) -> Option { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - Some( - wm.get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))? - .core_wallet - .network(), - ) + Some(wm.get_wallet_info(wallet_id)?.core_wallet.network()) } /// Snapshot of [`PlatformAddressSyncManager`] tunables and last- @@ -822,12 +800,7 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Option { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let info = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; + let info = wm.get_wallet_info(wallet_id)?; let monitor_revision = info .core_wallet .accounts @@ -920,12 +893,7 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Option { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let info = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; + let info = wm.get_wallet_info(wallet_id)?; let last_scanned_index = info .identity_manager .highest_registration_index(wallet_id) @@ -990,13 +958,7 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; info.tracked_asset_locks @@ -1037,13 +999,7 @@ impl PlatformWalletManager

{ /// untouched). pub fn instant_send_locks_blocking(&self, wallet_id: &WalletId) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; info.core_wallet @@ -1068,12 +1024,7 @@ impl PlatformWalletManager

{ target: &AccountType, ) -> Option { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let info = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; + let info = wm.get_wallet_info(wallet_id)?; let accounts = info.core_wallet.accounts.all_accounts(); let account = accounts .iter() @@ -1107,13 +1058,7 @@ impl PlatformWalletManager

{ target: &AccountType, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; let accounts = info.core_wallet.accounts.all_accounts(); @@ -1156,12 +1101,6 @@ impl PlatformWalletManager

{ limit: usize, ) -> (Vec, bool) { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - if let Some(snapshot) = deferred { - return wallet_utxos_page_from_info(&snapshot.info, after, limit); - } wallet_utxos_page(&wm, wallet_id, after, limit) } @@ -1173,7 +1112,6 @@ impl PlatformWalletManager

{ queries: &[OutpointOwnershipQuery], ) -> Vec { let wm = self.wallet_manager.blocking_read(); - // Pending history cannot authorize a persistence repair from spend classification. classify_outpoints(&wm, wallet_id, queries) } @@ -1184,13 +1122,7 @@ impl PlatformWalletManager

{ target: &AccountType, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; let accounts = info.core_wallet.accounts.all_accounts(); @@ -1238,13 +1170,7 @@ impl PlatformWalletManager

{ page_limit: usize, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; let accounts = info.core_wallet.accounts.all_accounts(); @@ -1308,12 +1234,7 @@ impl PlatformWalletManager

{ // provider pools (`info.core_wallet` is a `ManagedWalletInfo`). let (network, txs, operator_scan_max, platform_index) = { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let info = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info))?; + let info = wm.get_wallet_info(wallet_id)?; let network = info.core_wallet.network(); let mut by_txid: std::collections::BTreeMap< @@ -1453,13 +1374,7 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; info.identity_manager @@ -1477,13 +1392,7 @@ impl PlatformWalletManager

{ wallet_id: &WalletId, ) -> Vec { let wm = self.wallet_manager.blocking_read(); - let deferred = self - .get_wallet_blocking(wallet_id) - .and_then(|wallet| wallet.deferred_history.load_full()); - let Some(info) = wm - .get_wallet_info(wallet_id) - .or_else(|| deferred.as_ref().map(|snapshot| &snapshot.info)) - else { + let Some(info) = wm.get_wallet_info(wallet_id) else { return Vec::new(); }; let Some(inner) = info.identity_manager.wallet_identities.get(wallet_id) else { diff --git a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs index 5b52783b10b..fcc41dc9350 100644 --- a/packages/rs-platform-wallet/src/manager/dashpay_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dashpay_sync.rs @@ -391,14 +391,7 @@ impl DashPaySyncManager { let snapshot: Vec<(WalletId, Arc)> = { let wallets = self.wallets.load(); - wallets - .iter() - .filter(|(_, wallet)| { - wallet.history_readiness() - == crate::wallet::platform_wallet::HistoryReadiness::Ready - }) - .map(|(id, w)| (*id, Arc::clone(w))) - .collect() + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; let mut summary = DashPaySyncSummary::default(); diff --git a/packages/rs-platform-wallet/src/manager/dpns_sync.rs b/packages/rs-platform-wallet/src/manager/dpns_sync.rs index 6d007d7d4a3..35e4e6a1501 100644 --- a/packages/rs-platform-wallet/src/manager/dpns_sync.rs +++ b/packages/rs-platform-wallet/src/manager/dpns_sync.rs @@ -316,14 +316,7 @@ impl DpnsSyncManager { let snapshot: Vec<(WalletId, Arc)> = { let wallets = self.wallets.load(); - wallets - .iter() - .filter(|(_, wallet)| { - wallet.history_readiness() - == crate::wallet::platform_wallet::HistoryReadiness::Ready - }) - .map(|(id, w)| (*id, Arc::clone(w))) - .collect() + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; let mut summary = DpnsSyncPassSummary::default(); diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 549ac6fa03f..b98909ec60e 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -65,8 +65,6 @@ pub struct HistoryReplay { pub defeated: HashSet, /// Conflict effects for the backend to apply in its load transaction. pub sweeps: Vec, - /// Stored finality observations awaiting proof from the SPV verifier. - pub deferred: BTreeSet, /// Recovery views cannot settle or resend even when proof is available. pub read_only: bool, } @@ -106,7 +104,7 @@ impl HistoryReplay { /// sighting: a retired loser would be recorded again, and a record with /// unsafe amounts would reach the checker the replay kept it from. pub fn resendable(&self, sends: Vec) -> Vec { - if self.read_only || !self.deferred.is_empty() { + if self.read_only { return Vec::new(); } sends @@ -119,6 +117,9 @@ impl HistoryReplay { /// A blocking backend cannot commit a replay that suspended part-way through. #[derive(Debug, thiserror::Error)] pub enum HistoryReplayError { + /// The snapshot requires evidence the running SPV verifier cannot yet provide. + #[error("wallet history requires verified finality evidence for {txids:?}; synchronize SPV and retry load")] + MissingFinalityEvidence { txids: BTreeSet }, /// Stored input authorization cannot be checked with supported rules. #[error("cannot verify stored input authorization")] SpendAuthorization(#[from] SpendAuthorizationError), @@ -239,40 +240,7 @@ pub async fn replay_recorded_history_with_evidence( } } if !deferred.is_empty() { - // No checker, settlement or resend can run before this wallet is ready. - let mut claims = BTreeMap::>::new(); - for stored in &history.transactions { - for input in &stored.transaction.input { - claims - .entry(input.previous_output) - .or_default() - .insert(stored.txid); - } - } - for account in wallet_info.accounts.all_funding_accounts_mut() { - account - .utxos - .retain(|outpoint, _| !claims.contains_key(outpoint)); - } - let claims: Vec<_> = claims - .into_iter() - .map(|(outpoint, claimants)| { - ( - outpoint, - if claimants.len() == 1 { - claimants.first().copied() - } else { - None - }, - ) - }) - .collect(); - wallet_info.restore_spent_outpoints(&claims); - wallet_info.update_balance(); - return Ok(HistoryReplay { - deferred, - ..Default::default() - }); + return Err(HistoryReplayError::MissingFinalityEvidence { txids: deferred }); } let RecordedHistory { mut owned_prevouts, @@ -651,7 +619,6 @@ pub async fn replay_recorded_history_with_evidence( retired: swept, defeated, sweeps, - deferred, read_only: false, }) } @@ -1550,7 +1517,7 @@ mod tests { } #[tokio::test] - async fn should_defer_resend_until_a_persisted_lock_is_verified() { + async fn should_fail_before_resend_without_persisted_lock_verification() { let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; let mut spender = records[1].clone(); spender.context = TransactionContext::Mempool; @@ -1564,18 +1531,16 @@ mod tests { .collect(), ..Default::default() }; - let sends = vec![spender.transaction.clone()]; + let txid = spender.txid; let outcome = replay_recorded_history( &mut info, &mut wallet, history(vec![spender], BTreeMap::from([(lock.txid, lock)])), ) .await - .unwrap(); - assert!(outcome.defeated.is_empty()); + .unwrap_err(); assert!( - outcome.resendable(sends).is_empty(), - "persisted lock bytes are not verified finality" + matches!(outcome, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&txid)) ); } @@ -1623,12 +1588,10 @@ mod tests { &ReplayEvidence::default(), ) .await - .unwrap(); - assert!(pending.deferred.contains(&locked.txid)); - assert!(pending.sweeps.is_empty()); - assert!(pending - .resendable(vec![locked.transaction.clone()]) - .is_empty()); + .unwrap_err(); + assert!( + matches!(pending, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&locked.txid)) + ); fixture.install(&client).await.unwrap(); let proof = client @@ -1647,7 +1610,6 @@ mod tests { ) .await .unwrap(); - assert!(ready.deferred.is_empty()); assert!( !ready.defeated.contains(&locked.txid), "unsafe stored arithmetic cannot let a plain block defeat verified InstantSend" @@ -1671,14 +1633,15 @@ mod tests { &evidence, ) .await - .unwrap(); - assert!(deferred.deferred.contains(&locked.txid)); - assert!(deferred.sweeps.is_empty()); + .unwrap_err(); + assert!( + matches!(deferred, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&locked.txid)) + ); } } #[tokio::test] - async fn should_defer_history_proven_on_another_network() { + async fn should_reject_history_proven_on_another_network() { use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; use dash_spv::{ClientConfig, DashSpvClient}; use key_wallet_manager::test_utils::MockWallet; @@ -1720,10 +1683,10 @@ mod tests { }, ) .await - .unwrap(); - assert!(outcome.deferred.contains(&locked.txid)); - assert!(outcome.sweeps.is_empty()); - assert!(outcome.resendable(vec![locked.transaction]).is_empty()); + .unwrap_err(); + assert!( + matches!(outcome, HistoryReplayError::MissingFinalityEvidence { txids } if txids.contains(&locked.txid)) + ); } #[tokio::test] @@ -1754,7 +1717,7 @@ mod tests { .await .unwrap() .unwrap(); - let outcome = replay_recorded_history_with_evidence( + replay_recorded_history_with_evidence( &mut info, &mut wallet, history( @@ -1767,7 +1730,6 @@ mod tests { ) .await .unwrap(); - assert!(outcome.deferred.is_empty()); assert!(info.instant_send_locks().contains(&locked.txid)); let before = info.accounts.standard_bip44_accounts[&0].transactions()[&locked.txid].clone(); assert_eq!( diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 4383066a119..d1961e6eb73 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -7,10 +7,6 @@ use crate::changeset::{ funding_txids, history_accounting, ClientStartState, ClientWalletStartState, PersistenceCapabilities, PlatformWalletPersistence, }; -use crate::changeset::{ - persists_backfill_coverage, CoreChangeSet, DashPayBackfillRecord, DurableCursor, - PlatformWalletChangeSet, -}; use crate::error::PlatformWalletError; use crate::wallet::asset_lock::sync::recovery::{ add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, @@ -19,11 +15,8 @@ use crate::wallet::asset_lock::sync::recovery::{ }; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; -use crate::wallet::platform_wallet::{ - DeferredWalletSnapshot, HistoryReadiness, PlatformWalletInfo, WalletId, -}; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; use crate::wallet::PlatformWallet; -use key_wallet_manager::WalletManager; use std::time::Duration; @@ -51,117 +44,6 @@ use super::{run_blocking_load, PlatformWalletManager}; const RESEND_TRANSPORT_READY_WAIT: Duration = Duration::from_secs(90); impl PlatformWalletManager

{ - /// Restore wallets, keeping unverifiable snapshots visible and read-only while SPV synchronizes. - pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { - self.load_tracked_masternodes_from_persistence(); - let driver = Arc::new(LoadDriver::new(self)); - let evidence = driver.prefetch().await?; - driver.load(None, &evidence).await?; - let mut task = self.history_retry_join.lock().await; - if task.is_none() && !self.history_retry_cancel.is_cancelled() { - *task = driver.start_retry(self.history_retry_cancel.clone()); - } - Ok(()) - } -} - -pub(super) struct LoadDriver { - sdk: Arc, - persister: Arc

, - wallet_manager: Arc>>, - wallets: Arc>>>, - lock_notify: Arc, - spv_manager: Arc, - sync_fault: Arc, - durable_cursors: crate::changeset::DurableCursors, - in_broadcast_fences: - Arc>>>, -} - -impl LoadDriver

{ - pub(super) fn new(manager: &PlatformWalletManager

) -> Self { - Self { - sdk: Arc::clone(&manager.sdk), - persister: Arc::clone(&manager.persister), - wallet_manager: Arc::clone(&manager.wallet_manager), - wallets: Arc::clone(&manager.wallets), - lock_notify: Arc::clone(&manager.lock_notify), - spv_manager: Arc::clone(&manager.spv_manager), - sync_fault: Arc::clone(&manager.sync_fault), - durable_cursors: Arc::clone(&manager.durable_cursors), - in_broadcast_fences: Arc::clone(&manager.in_broadcast_fences), - } - } - - async fn prefetch( - &self, - ) -> Result { - let persister = Arc::clone(&self.persister); - let candidates = run_blocking_load(move || persister.replay_lock_candidates()) - .await - .map_err(PlatformWalletError::from_load_failure)?; - Ok(self.spv_manager.verify_history_locks(candidates).await) - } - - fn start_retry( - self: Arc, - cancel: tokio_util::sync::CancellationToken, - ) -> Option> { - if !self - .wallets - .load() - .values() - .any(|wallet| wallet.history_readiness() == HistoryReadiness::AwaitingProof) - { - return None; - } - Some(tokio::spawn(async move { - loop { - tokio::select! { - _ = cancel.cancelled() => break, - _ = tokio::time::sleep(Duration::from_secs(1)) => {} - } - let pending: Vec<_> = self - .wallets - .load() - .values() - .filter(|wallet| wallet.history_readiness() == HistoryReadiness::AwaitingProof) - .cloned() - .collect(); - if pending.is_empty() { - continue; - } - // Network work precedes lifecycle, cursor, manager and backend locks. - let evidence = match self.prefetch().await { - Ok(evidence) => evidence, - Err(error) => { - tracing::debug!(%error, "history proof prefetch failed; retry remains scheduled"); - continue; - } - }; - for wallet in pending { - if cancel.is_cancelled() { - break; - } - let wallet_id = wallet.core().wallet_id(); - let current = self.wallets.load().get(&wallet_id).is_some_and(|current| { - Arc::ptr_eq(current.generation(), wallet.generation()) - && current.history_readiness() == HistoryReadiness::AwaitingProof - }); - if !current { - continue; - } - // Finish an admitted atomic load even if cancellation arrives; shutdown joins this worker. - if let Err(error) = self.load(Some(wallet_id), &evidence).await { - tracing::debug!(%error, wallet_id = %hex::encode(wallet_id), "deferred wallet restore failed; retry remains scheduled"); - } - } - } - })) - } -} - -impl LoadDriver

{ /// Load the full [`ClientStartState`] from the configured persister /// and rehydrate the manager's `wallet_manager` and `wallets` maps. /// @@ -190,6 +72,9 @@ impl LoadDriver

{ /// /// Persister errors surface after one attempt so the caller controls retry /// policy. The synchronous read runs on the blocking pool. + /// Start SPV before loading history that requires InstantLock verification. + /// If its quorum evidence is unavailable, load fails without registering + /// that history; the caller may retry after SPV synchronization progresses. /// /// Any `Err` rolls back partial inserts and leaves the manager usable: fix /// the store and call again, or reconstruct. Reconstructing over the same @@ -199,42 +84,17 @@ impl LoadDriver

{ /// takes `&self`, so it cannot release the manager's own `Arc

`. /// /// [`WalletManager`]: key_wallet_manager::WalletManager - async fn load( - &self, - target: Option, - evidence: &crate::changeset::recorded_history::ReplayEvidence, - ) -> Result<(), PlatformWalletError> { + pub async fn load_from_persistor(&self) -> Result<(), PlatformWalletError> { let persister = Arc::clone(&self.persister); - let loaded = match run_blocking_load(move || match target { - Some(wallet_id) => persister.load_read_snapshot().map(|mut state| { - state.wallets.retain(|id, _| *id == wallet_id); - state.platform_addresses.retain(|id, _| *id == wallet_id); - crate::manager::history_replay::HistoryReplayLoad { - state, - outcomes: Default::default(), - } - }), - None => persister.load_read_snapshot().map(|state| { - crate::manager::history_replay::HistoryReplayLoad { - state, - outcomes: Default::default(), - } - }), - }) - .await - { - Ok(state) => state, - Err(e) => { - // Debug, not Display: it carries the real cause (e.g. a - // bincode decode failure) rather than flattening the chain. - tracing::debug!(error = ?e, "persister load failed during rehydration"); - return Err(PlatformWalletError::from_load_failure(e)); - } - }; - let crate::manager::history_replay::HistoryReplayLoad { - state: start_state, - mut outcomes, - } = loaded; + let candidates = run_blocking_load(move || persister.replay_lock_candidates()) + .await + .map_err(PlatformWalletError::from_load_failure)?; + let evidence = self.spv_manager.verify_history_locks(candidates).await; + let persister = Arc::clone(&self.persister); + let start_state = run_blocking_load(move || persister.load_read_snapshot()) + .await + .map_err(PlatformWalletError::from_load_failure)?; + let mut outcomes = BTreeMap::new(); let ClientStartState { mut platform_addresses, wallets, @@ -259,9 +119,7 @@ impl LoadDriver

{ ); } - // Tracked (wallet-independent) masternodes ride the same startup - // hydration; a failure logs and starts empty rather than failing - // wallet restore. + self.load_tracked_masternodes_from_persistence(); let persister_dyn: Arc = Arc::clone(&self.persister) as _; @@ -308,23 +166,9 @@ impl LoadDriver

{ // the wallet processed from it. Nothing between the two takes the // lock, and it is released before the rollback below. let mut held = None; - let mut held_lifecycle = None; 'load: while let Some((expected_wallet_id, wallet_state, reread)) = pending.pop_front() { if !reread { - held_lifecycle = None; - // Pending publication shares removal's generation gate; a retry cannot resurrect a removed handle. - let candidate = self.wallets.load().get(&expected_wallet_id).cloned(); - if target.is_some() && candidate.is_none() { - continue 'load; - } - if let Some(candidate) = &candidate { - if candidate.history_readiness() == HistoryReadiness::Ready { - already_registered.push(expected_wallet_id); - continue 'load; - } - held_lifecycle = Some(candidate.generation().teardown_guard().await); - } let cursors = Arc::clone(&self.durable_cursors.cursors).lock_owned().await; if self .wallet_manager @@ -333,43 +177,25 @@ impl LoadDriver

{ .get_wallet(&expected_wallet_id) .is_some() { - held_lifecycle = None; already_registered.push(expected_wallet_id); continue 'load; } - if let Some(candidate) = &candidate { - if !self - .wallets - .load() - .get(&expected_wallet_id) - .is_some_and(|current| { - Arc::ptr_eq(current.generation(), candidate.generation()) - }) - { - held_lifecycle = None; - continue 'load; - } - } let persister = Arc::clone(&self.persister); let prefetched = evidence.clone(); - let lifecycle = held_lifecycle.take(); match run_blocking_load(move || { let loaded = persister.load_wallet_with_replay(expected_wallet_id, &prefetched)?; - // An abandoned async caller cannot release publication fences while this transaction still runs. - Ok((loaded, cursors, lifecycle)) + // Keep the existing publication fence through an admitted backend transaction. + Ok((loaded, cursors)) }) .await { - Ok((mut loaded, cursors, lifecycle)) => { - held_lifecycle = lifecycle; + Ok((mut loaded, cursors)) => { outcomes.extend(loaded.outcomes); platform_addresses.extend(loaded.state.platform_addresses); if let Some(state) = loaded.state.wallets.remove(&expected_wallet_id) { pending.push_front((expected_wallet_id, state, true)); held = Some(cursors); - } else { - held_lifecycle = None; } } Err(error) => { @@ -419,8 +245,6 @@ impl LoadDriver

{ let withheld_records = detach_unconfirmed_records(&mut wallet_info, &withheld); let mut defeated = HashSet::new(); let backend_outcome = outcomes.remove(&expected_wallet_id).unwrap_or_default(); - let mut deferred = !backend_outcome.deferred.is_empty(); - let read_only = backend_outcome.read_only; unconfirmed_outgoing_txs = backend_outcome.resendable(unconfirmed_outgoing_txs); if !recorded_history.is_empty() { let offered = recorded_history.transactions.len(); @@ -429,7 +253,7 @@ impl LoadDriver

{ &mut wallet_info, &mut wallet, recorded_history, - evidence, + &evidence, ) .await; let outcome = match outcome { @@ -450,7 +274,6 @@ impl LoadDriver

{ ); // A send the replay retired is not dispatched again below. unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); - deferred = !outcome.deferred.is_empty(); defeated = outcome.defeated; } reattach_records(&mut wallet_info, withheld_records); @@ -540,20 +363,9 @@ impl LoadDriver

{ // // A fresh PROCESS still starts empty: this registry is not durable. // See `InBroadcastFences` for what closing that half requires. - let previous = self - .wallets - .load() - .get(&wallet_id) - .cloned() - .filter(|wallet| wallet.history_readiness() != HistoryReadiness::Ready); - let generation = previous - .as_ref() - .map(|wallet| Arc::clone(wallet.generation())) - .unwrap_or_else(|| { - Arc::new(WalletGeneration::with_fences( - self.in_broadcast_fences_for(&wallet_id), - )) - }); + let generation = Arc::new(WalletGeneration::with_fences( + self.in_broadcast_fences_for(&wallet_id), + )); // Mirror the inner `ManagedWalletInfo.balance` (already // recomputed from the freshly-loaded UTXO set on the FFI // side via `update_balance`) into the lock-free `Arc` the @@ -638,55 +450,6 @@ impl LoadDriver

{ break 'load; } - if deferred || read_only { - let _cursors = match held.take() { - Some(guard) => guard, - None => Arc::clone(&self.durable_cursors.cursors).lock_owned().await, - }; - let wm = self.wallet_manager.read().await; - if wm.get_wallet(&wallet_id).is_some() { - held_lifecycle = None; - continue 'load; - } - let newly_visible = previous.is_none(); - let handle = previous.unwrap_or_else(|| { - Arc::new(PlatformWallet::new( - Arc::clone(&self.sdk), - wallet_id, - Arc::clone(&self.wallet_manager), - Arc::clone(&generation), - Arc::clone(&self.lock_notify), - Arc::clone(&persister_dyn), - Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone( - &self.spv_manager, - ))), - Arc::clone(&self.sync_fault), - Arc::clone(&self.durable_cursors), - )) - }); - handle - .deferred_history - .store(Some(Arc::new(DeferredWalletSnapshot { - wallet, - info: platform_info, - readiness: if read_only { - HistoryReadiness::Recovery - } else { - HistoryReadiness::AwaitingProof - }, - }))); - self.wallets.rcu(|wallets| { - let mut wallets = BTreeMap::clone(wallets); - wallets.insert(wallet_id, Arc::clone(&handle)); - wallets - }); - if newly_visible { - inserted_in_wallets.push((wallet_id, generation)); - } - held_lifecycle = None; - continue 'load; - } - // Insert into `wallet_manager` first so we have a wallet handle // to build the `PlatformWallet` against. Track success in // `inserted_in_manager` so the batch-rollback at the bottom can @@ -862,19 +625,17 @@ impl LoadDriver

{ )); } - let platform_wallet = previous.unwrap_or_else(|| { - Arc::new(PlatformWallet::new( - Arc::clone(&self.sdk), - wallet_id, - Arc::clone(&self.wallet_manager), - generation, - Arc::clone(&self.lock_notify), - Arc::clone(&persister_dyn), - broadcaster, - Arc::clone(&self.sync_fault), - Arc::clone(&self.durable_cursors), - )) - }); + let platform_wallet = Arc::new(PlatformWallet::new( + Arc::clone(&self.sdk), + wallet_id, + Arc::clone(&self.wallet_manager), + generation, + Arc::clone(&self.lock_notify), + Arc::clone(&persister_dyn), + broadcaster, + Arc::clone(&self.sync_fault), + Arc::clone(&self.durable_cursors), + )); // Initialize the platform-address provider. If the snapshot // carried a slice for this wallet, restore it directly; @@ -896,7 +657,6 @@ impl LoadDriver

{ platform_wallet.platform().initialize().await; } - platform_wallet.deferred_history.store(None); self.wallets.rcu(|wallets| { let mut wallets = std::collections::BTreeMap::clone(wallets); wallets.insert(wallet_id, Arc::clone(&platform_wallet)); @@ -936,7 +696,6 @@ impl LoadDriver

{ } } } - drop(held_lifecycle); // A `break` between a re-read and its publication leaves the lock // held; the rollback must not run under it. drop(held); @@ -1196,125 +955,6 @@ impl LoadDriver

{ ); } } - - pub(super) fn remove_from_wallet_manager( - &self, - wm: &mut WalletManager, - wallet_id: &WalletId, - ) -> Result<(), key_wallet_manager::WalletError> { - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - let (_, info) = wm.remove_wallet(wallet_id)?; - let retiring = info - .rewind_barrier - .retire(WalletInfoInterface::account_generation(&info)); - let mut retired = self.durable_cursors.retired(); - let entry = retired.entry(*wallet_id).or_default(); - entry.in_flight = entry.in_flight.saturating_add(retiring.in_flight); - entry.epoch = entry.epoch.max(retiring.epoch); - entry.account_generation = entry.account_generation.max(retiring.account_generation); - Ok(()) - } - - pub(super) fn settle_published_backfill( - &self, - durable_cursors: &mut BTreeMap, - wallet_id: WalletId, - record: DashPayBackfillRecord, - dropped: bool, - own_cursor: u32, - snapshot_cursor: u32, - ) -> Result { - let inline = self.persister.store_commits_inline(); - let durable = persists_backfill_coverage(&*self.persister); - let predecessor = durable_cursors.contains_key(&wallet_id); - let held_on_host = !record.is_empty() || dropped; - let record = if durable { - record - } else { - DashPayBackfillRecord::default() - }; - // A backend that cannot hold the record durably never had one - // written by this process, so only a record it handed back can need - // invalidating; one that can is replaced when the snapshot's record - // may disagree with what the wallet carries. - let replace = if durable { - dropped || predecessor - } else { - held_on_host - }; - if !replace { - return Ok(record); - } - let host_cursor = durable_cursors - .get(&wallet_id) - .map_or(snapshot_cursor, |entry| entry.height); - let cursor = (!record.is_empty() && own_cursor < host_cursor).then_some(own_cursor); - tracing::info!( - wallet_id = %hex::encode(wallet_id), - covered = record.covered().len(), - dropped, - predecessor, - durable, - cursor = ?cursor, - "the host's DashPay backfill record is replaced with the one this wallet \ - carries before the wallet is published" - ); - let changeset = PlatformWalletChangeSet { - core: cursor.map(|cursor| CoreChangeSet { - synced_height: Some(cursor), - synced_height_is_rewind: true, - ..CoreChangeSet::default() - }), - dashpay_backfill: Some(record.clone()), - ..PlatformWalletChangeSet::default() - }; - self.persister.store(wallet_id, changeset).map_err(|e| { - tracing::error!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "failed to replace the host's DashPay backfill record; \ - the wallet was not published" - ); - PlatformWalletError::from_store_failure(&*self.persister, e) - })?; - if !inline { - self.persister.flush(wallet_id).map_err(|e| { - tracing::error!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "failed to commit the replaced DashPay backfill record; \ - the wallet was not published" - ); - PlatformWalletError::from_flush_failure(e) - })?; - } - if let Some(cursor) = cursor { - durable_cursors.insert(wallet_id, DurableCursor::at(cursor)); - } - Ok(record) - } - pub(super) fn inherit_rewind_barrier( - &self, - wm: &mut WalletManager, - wallet_id: &WalletId, - ) { - let retired = self.durable_cursors.retired().remove(wallet_id); - if let (Some(retired), Some(info)) = (retired, wm.get_wallet_info_mut(wallet_id)) { - info.rewind_barrier.inherit(retired); - } - } - pub(super) fn in_broadcast_fences_for( - &self, - wallet_id: &WalletId, - ) -> Arc { - Arc::clone( - self.in_broadcast_fences - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .entry(*wallet_id) - .or_default(), - ) - } } /// What one re-dispatch attempt did, so a caller — and a test — can tell the @@ -1953,8 +1593,7 @@ mod idempotent_load_tests { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn should_retry_pending_history_without_hiding_the_wallet() { - use crate::wallet::platform_wallet::HistoryReadiness; + async fn should_fail_load_without_proof_then_allow_an_explicit_verified_retry() { use dash_spv::test_utils::InstantLockFixture; let (ctx, funding) = TestWalletContext::new_random() @@ -1984,21 +1623,18 @@ mod idempotent_load_tests { }), Arc::new(NoopTestEventHandler), ); - manager.load_from_persistor().await.unwrap(); - assert_eq!(manager.list_wallet_ids_blocking(), vec![wallet_id]); - let visible = manager.get_wallet(&wallet_id).await.unwrap(); - assert_eq!(visible.balance().total(), 100_000); assert!( - manager - .wallet_manager - .read() - .await - .get_wallet(&wallet_id) - .is_none(), - "unverified history must remain outside the active SPV wallet manager" + manager.load_from_persistor().await.is_err(), + "missing proof must fail the load" ); - assert!(!visible.core().is_current_generation().await); - assert_eq!(visible.state().await.core_wallet.balance.total(), 100_000); + assert!(manager.list_wallet_ids_blocking().is_empty()); + assert!(manager.get_wallet(&wallet_id).await.is_none()); + assert!(manager + .wallet_manager + .read() + .await + .get_wallet(&wallet_id) + .is_none()); let storage = tempfile::TempDir::new().unwrap(); let offline = dash_spv::ClientConfig::testnet() .with_storage_path(storage.path()) @@ -2012,17 +1648,13 @@ mod idempotent_load_tests { assert!(manager.spv_manager.is_started()); let revision_before = manager.wallet_manager.read().await.monitor_revision(); manager.spv_manager.install_history_fixture(&fixture).await; - tokio::time::timeout(Duration::from_secs(10), async { - while visible.history_readiness() != HistoryReadiness::Ready { - tokio::time::sleep(Duration::from_millis(20)).await; - } - }) - .await - .expect("SML readiness must activate the existing visible handle"); + manager.load_from_persistor().await.unwrap(); + let visible = manager.get_wallet(&wallet_id).await.unwrap(); + assert_eq!(visible.balance().total(), 100_000); assert!(visible.core().is_current_generation().await); assert!( manager.wallet_manager.read().await.monitor_revision() > revision_before, - "activation must invalidate the running SPV script/filter view" + "successful explicit retry must register scripts with the running SPV client" ); assert_eq!(manager.list_wallet_ids_blocking(), vec![wallet_id]); assert!(Arc::ptr_eq( diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 8e15ae328ee..3015e45130c 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -32,8 +32,9 @@ use key_wallet::wallet::ManagedWalletInfo; use key_wallet_manager::WalletManager; use crate::changeset::{ - spawn_wallet_event_adapter_with_durable_cursors, DashPayBackfillRecord, DurableCursor, - DurableCursors, PlatformWalletPersistence, + persists_backfill_coverage, spawn_wallet_event_adapter_with_durable_cursors, CoreChangeSet, + DashPayBackfillRecord, DurableCursor, DurableCursors, PlatformWalletChangeSet, + PlatformWalletPersistence, }; use crate::error::PlatformWalletError; use crate::events::{PlatformEventHandler, PlatformEventManager}; @@ -60,8 +61,6 @@ use crate::wallet::PlatformWallet; /// surfacing a panicked loop — before the host drops the tokio runtime. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum WalletWorker { - /// Retries quarantined history after SPV proof readiness changes. - HistoryReplay, /// Platform-address (BLAST / DIP-17) balance sync coordinator. PlatformAddressSync, /// Per-identity token-state sync coordinator. @@ -474,8 +473,6 @@ pub struct PlatformWalletManager { /// is torn down. pub(super) event_adapter_cancel: CancellationToken, pub(super) event_adapter_join: tokio::sync::Mutex>>, - pub(super) history_retry_cancel: CancellationToken, - pub(super) history_retry_join: tokio::sync::Mutex>>, /// Shared lifecycle registry for the periodic coordinator threads. /// Each coordinator spawns its loop through `registry.start_thread` at /// `start`, handing the registry ownership of the OS thread and its @@ -517,10 +514,8 @@ pub struct PlatformWalletManager { /// /// A `std::sync::Mutex`: touched only at wallet registration and load, for /// one map lookup, and never held across an await. - pub(super) in_broadcast_fences: Arc< - std::sync::Mutex< - std::collections::BTreeMap>, - >, + pub(super) in_broadcast_fences: std::sync::Mutex< + std::collections::BTreeMap>, >, } @@ -698,12 +693,10 @@ impl PlatformWalletManager

{ tracked_masternodes: std::sync::Arc::new(Default::default()), event_adapter_cancel, event_adapter_join: tokio::sync::Mutex::new(Some(event_adapter_join)), - history_retry_cancel: CancellationToken::new(), - history_retry_join: tokio::sync::Mutex::new(None), registry, sync_fault, durable_cursors, - in_broadcast_fences: Arc::new(std::sync::Mutex::new(std::collections::BTreeMap::new())), + in_broadcast_fences: std::sync::Mutex::new(std::collections::BTreeMap::new()), } } @@ -719,7 +712,17 @@ impl PlatformWalletManager

{ wm: &mut WalletManager, wallet_id: &WalletId, ) -> Result<(), key_wallet_manager::WalletError> { - load::LoadDriver::new(self).remove_from_wallet_manager(wm, wallet_id) + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + let (_, info) = wm.remove_wallet(wallet_id)?; + let retiring = info + .rewind_barrier + .retire(WalletInfoInterface::account_generation(&info)); + let mut retired = self.durable_cursors.retired(); + let entry = retired.entry(*wallet_id).or_default(); + entry.in_flight = entry.in_flight.saturating_add(retiring.in_flight); + entry.epoch = entry.epoch.max(retiring.epoch); + entry.account_generation = entry.account_generation.max(retiring.account_generation); + Ok(()) } /// Settle the host's DashPay backfill record for a wallet about to be @@ -763,14 +766,74 @@ impl PlatformWalletManager

{ own_cursor: u32, snapshot_cursor: u32, ) -> Result { - load::LoadDriver::new(self).settle_published_backfill( - durable_cursors, - wallet_id, - record, + let inline = self.persister.store_commits_inline(); + let durable = persists_backfill_coverage(&*self.persister); + let predecessor = durable_cursors.contains_key(&wallet_id); + let held_on_host = !record.is_empty() || dropped; + let record = if durable { + record + } else { + DashPayBackfillRecord::default() + }; + // A backend that cannot hold the record durably never had one + // written by this process, so only a record it handed back can need + // invalidating; one that can is replaced when the snapshot's record + // may disagree with what the wallet carries. + let replace = if durable { + dropped || predecessor + } else { + held_on_host + }; + if !replace { + return Ok(record); + } + let host_cursor = durable_cursors + .get(&wallet_id) + .map_or(snapshot_cursor, |entry| entry.height); + let cursor = (!record.is_empty() && own_cursor < host_cursor).then_some(own_cursor); + tracing::info!( + wallet_id = %hex::encode(wallet_id), + covered = record.covered().len(), dropped, - own_cursor, - snapshot_cursor, - ) + predecessor, + durable, + cursor = ?cursor, + "the host's DashPay backfill record is replaced with the one this wallet \ + carries before the wallet is published" + ); + let changeset = PlatformWalletChangeSet { + core: cursor.map(|cursor| CoreChangeSet { + synced_height: Some(cursor), + synced_height_is_rewind: true, + ..CoreChangeSet::default() + }), + dashpay_backfill: Some(record.clone()), + ..PlatformWalletChangeSet::default() + }; + self.persister.store(wallet_id, changeset).map_err(|e| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "failed to replace the host's DashPay backfill record; \ + the wallet was not published" + ); + PlatformWalletError::from_store_failure(&*self.persister, e) + })?; + if !inline { + self.persister.flush(wallet_id).map_err(|e| { + tracing::error!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "failed to commit the replaced DashPay backfill record; \ + the wallet was not published" + ); + PlatformWalletError::from_flush_failure(e) + })?; + } + if let Some(cursor) = cursor { + durable_cursors.insert(wallet_id, DurableCursor::at(cursor)); + } + Ok(record) } /// Hand a newly published wallet the barrier state a removed wallet of @@ -783,7 +846,10 @@ impl PlatformWalletManager

{ wm: &mut WalletManager, wallet_id: &WalletId, ) { - load::LoadDriver::new(self).inherit_rewind_barrier(wm, wallet_id) + let retired = self.durable_cursors.retired().remove(wallet_id); + if let (Some(retired), Some(info)) = (retired, wm.get_wallet_info_mut(wallet_id)) { + info.rewind_barrier.inherit(retired); + } } /// The in-broadcast fence map for `wallet_id`, creating it on first use. @@ -797,7 +863,13 @@ impl PlatformWalletManager

{ &self, wallet_id: &WalletId, ) -> Arc { - load::LoadDriver::new(self).in_broadcast_fences_for(wallet_id) + Arc::clone( + self.in_broadcast_fences + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .entry(*wallet_id) + .or_default(), + ) } /// Whether the wallet-event adapter has frozen a durable sync @@ -1100,26 +1172,6 @@ impl PlatformWalletManager

{ /// /// [`WorkerStatus`]: dash_async::WorkerStatus pub async fn shutdown(&self) -> ShutdownReport { - self.history_retry_cancel.cancel(); - let history_status = { - let mut slot = self.history_retry_join.lock().await; - match slot.take() { - None => WorkerStatus::NotRunning, - Some(mut handle) => { - match tokio::time::timeout(EVENT_ADAPTER_JOIN_BUDGET, &mut handle).await { - Ok(Ok(())) => WorkerStatus::Ok, - Ok(Err(error)) if error.is_panic() => { - WorkerStatus::Panicked(error.to_string()) - } - Ok(Err(error)) => WorkerStatus::Stopped(Some(error.to_string())), - Err(_) => { - *slot = Some(handle); - WorkerStatus::Timeout - } - } - } - } - }; // SPV first: it is the event source feeding everything below. Its // `stop` bounds the wait for teardown but never aborts it, so a // timeout means SPV work is still live. Its outcome lands in the @@ -1181,9 +1233,6 @@ impl PlatformWalletManager

{ // it waits for the actual OS thread to terminate before the host // drops the runtime. let mut report = self.registry.shutdown().await; - report - .per_worker - .insert(WalletWorker::HistoryReplay, history_status); // Fold drain timeouts in. A timed-out drain means a pass — // possibly a *direct* `sync_now` running on a host FFI thread the @@ -1224,7 +1273,6 @@ impl PlatformWalletManager

{ // not a registry worker; on a join timeout the live handle is // re-parked so a destroy retry can re-join it rather than // silently detaching the task. - self.history_retry_cancel.cancel(); self.event_adapter_cancel.cancel(); let adapter_status = { let mut slot = self.event_adapter_join.lock().await; @@ -1281,7 +1329,6 @@ impl Drop for PlatformWalletManager

{ // next `recv` and exits after committing whatever its drain claimed the // persister for. Aborting stops it at whatever await it is parked on, // dropping a claimed batch mid-commit. - self.history_retry_cancel.cancel(); self.event_adapter_cancel.cancel(); } } diff --git a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs index 081f2160775..295427f9fb1 100644 --- a/packages/rs-platform-wallet/src/manager/platform_address_sync.rs +++ b/packages/rs-platform-wallet/src/manager/platform_address_sync.rs @@ -384,14 +384,7 @@ impl PlatformAddressSyncManager { let snapshot: Vec<(WalletId, Arc)> = { let wallets = self.wallets.load(); - wallets - .iter() - .filter(|(_, wallet)| { - wallet.history_readiness() - == crate::wallet::platform_wallet::HistoryReadiness::Ready - }) - .map(|(id, w)| (*id, Arc::clone(w))) - .collect() + wallets.iter().map(|(id, w)| (*id, Arc::clone(w))).collect() }; let config = self.current_config(); diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 6a0fa271447..2fc4e269785 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -375,9 +375,7 @@ impl PlatformWalletManager

{ // a wallet registered between the two checks still collides there. { let wm = self.wallet_manager.read().await; - if wm.get_wallet_info(®istration_wallet_id).is_some() - || self.wallets.load().contains_key(®istration_wallet_id) - { + if wm.get_wallet_info(®istration_wallet_id).is_some() { return Err(already_registered(registration_wallet_id)); } } @@ -525,9 +523,6 @@ impl PlatformWalletManager

{ { return Err(already_registered(registration_wallet_id)); } - if self.wallets.load().contains_key(®istration_wallet_id) { - return Err(already_registered(registration_wallet_id)); - } let host_backfill = self.settle_published_backfill( &mut durable_cursors, registration_wallet_id, @@ -996,14 +991,12 @@ impl PlatformWalletManager

{ their identity-sync rows are left for the next launch to reconcile" ); } - if wm.get_wallet(wallet_id).is_some() { - if let Err(e) = self.remove_from_wallet_manager(&mut wm, wallet_id) { - tracing::warn!( - wallet_id = %hex::encode(wallet_id), - error = %e, - "remove_wallet: inner wallet-manager removal failed (state may be inconsistent)" - ); - } + if let Err(e) = self.remove_from_wallet_manager(&mut wm, wallet_id) { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + error = %e, + "remove_wallet: inner wallet-manager removal failed (state may be inconsistent)" + ); } } diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs index bdd6bbd6160..b4c3dfcf6b9 100644 --- a/packages/rs-platform-wallet/src/spv/runtime.rs +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -343,7 +343,7 @@ impl SpvRuntime { } Ok(None) => {} Err(error) => { - tracing::debug!(%error, txid = %lock.txid, "stored InstantLock failed SPV verification; wallet remains read-only") + tracing::debug!(%error, txid = %lock.txid, "stored InstantLock failed SPV verification; history load will fail") } } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index eb62128996c..50a564b04df 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -314,23 +314,6 @@ pub struct PlatformWalletInfo { pub rewind_barrier: crate::changeset::core_bridge::RewindBarrier, } -/// Whether restored history can participate in live wallet operations. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum HistoryReadiness { - /// All required evidence was verified and the wallet is active. - Ready, - /// Stored finality awaits the SPV verifier; the snapshot remains read-only. - AwaitingProof, - /// Explicit recovery mode keeps the snapshot read-only. - Recovery, -} - -pub(crate) struct DeferredWalletSnapshot { - pub wallet: Wallet, - pub info: PlatformWalletInfo, - pub readiness: HistoryReadiness, -} - /// A platform wallet that combines core UTXO functionality with identity management. /// /// This is SPV-free. It needs only key material and an `Sdk`. @@ -343,7 +326,6 @@ pub(crate) struct DeferredWalletSnapshot { /// clones see the same UTXOs, balances, and identities through the shared /// `WalletManager` lock. pub struct PlatformWallet { - pub(crate) deferred_history: Arc>, wallet_id: WalletId, pub(crate) sdk: Arc, pub(crate) wallet_manager: Arc>>, @@ -412,14 +394,6 @@ pub struct PlatformWallet { } impl PlatformWallet { - /// Readiness of this wallet's restored history, including conservative cold starts. - pub fn history_readiness(&self) -> HistoryReadiness { - self.deferred_history - .load() - .as_ref() - .map_or(HistoryReadiness::Ready, |snapshot| snapshot.readiness) - } - /// Access the core wallet (balance, UTXOs, addresses). pub fn core(&self) -> &CoreWallet { &self.core @@ -498,7 +472,10 @@ impl PlatformWallet { /// Read-lock the wallet manager and return a guard that derefs to this /// wallet's `PlatformWalletInfo`. pub async fn state(&self) -> WalletStateReadGuard<'_> { - self.read_guard(self.wallet_manager.read().await) + WalletStateReadGuard { + guard: self.wallet_manager.read().await, + wallet_id: self.wallet_id, + } } /// Write-lock the wallet manager and return a guard that derefs to this @@ -512,7 +489,10 @@ impl PlatformWallet { /// Blocking read-lock. pub fn state_blocking(&self) -> WalletStateReadGuard<'_> { - self.read_guard(self.wallet_manager.blocking_read()) + WalletStateReadGuard { + guard: self.wallet_manager.blocking_read(), + wallet_id: self.wallet_id, + } } /// Blocking write-lock. @@ -675,23 +655,10 @@ impl PlatformWallet { self.wallet_manager .try_read() .ok() - .map(|guard| self.read_guard(guard)) - } - - fn read_guard<'a>( - &self, - guard: RwLockReadGuard<'a, WalletManager>, - ) -> WalletStateReadGuard<'a> { - let deferred = if guard.get_wallet_info(&self.wallet_id).is_none() { - self.deferred_history.load_full() - } else { - None - }; - WalletStateReadGuard { - guard, - wallet_id: self.wallet_id, - deferred, - } + .map(|guard| WalletStateReadGuard { + guard, + wallet_id: self.wallet_id, + }) } /// Non-blocking write-lock. Returns `None` if the lock is currently @@ -789,7 +756,6 @@ impl PlatformWallet { ); Self { - deferred_history: Arc::new(arc_swap::ArcSwapOption::empty()), wallet_id, sdk, wallet_manager, @@ -2295,7 +2261,6 @@ impl PlatformWallet { impl Clone for PlatformWallet { fn clone(&self) -> Self { Self { - deferred_history: Arc::clone(&self.deferred_history), wallet_id: self.wallet_id, sdk: self.sdk.clone(), wallet_manager: self.wallet_manager.clone(), @@ -2330,7 +2295,6 @@ impl std::fmt::Debug for PlatformWallet { /// Read guard that locks `WalletManager` and derefs to this wallet's /// `PlatformWalletInfo`. Also provides `.wallet()` for key material access. pub struct WalletStateReadGuard<'a> { - deferred: Option>, guard: RwLockReadGuard<'a, WalletManager>, wallet_id: WalletId, } @@ -2338,9 +2302,6 @@ pub struct WalletStateReadGuard<'a> { impl<'a> WalletStateReadGuard<'a> { /// Access the immutable `Wallet` (key material). pub fn wallet(&self) -> &Wallet { - if let Some(snapshot) = &self.deferred { - return &snapshot.wallet; - } self.guard .get_wallet(&self.wallet_id) .expect("wallet exists in guard") @@ -2350,9 +2311,6 @@ impl<'a> WalletStateReadGuard<'a> { impl Deref for WalletStateReadGuard<'_> { type Target = PlatformWalletInfo; fn deref(&self) -> &PlatformWalletInfo { - if let Some(snapshot) = &self.deferred { - return &snapshot.info; - } self.guard .get_wallet_info(&self.wallet_id) .expect("wallet exists in guard") diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 34d0ef6427c..81d43b5e210 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -1650,6 +1650,10 @@ public class PlatformWalletManager: ObservableObject { /// Rehydrate wallets from SwiftData on app launch. /// + /// Start SPV before restoring history with InstantLocks. Missing verified + /// quorum evidence throws; callers may retry after synchronization while + /// SPV continues running. A started client does not imply evidence readiness. + /// /// Calls `platform_wallet_manager_load_from_persistor` which fires /// the Swift-side `on_load_wallet_list_fn` callback. For each /// persisted wallet, Rust reconstructs an **external-signable** From 12fec4e588e54fba720af9f4c22db451cf82cde9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:48:57 +0000 Subject: [PATCH 56/84] test(wallet): authorize replay fixtures and verify finality evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign pending owner spends before deriving transaction IDs and use checked InstantLock fixtures for finality scenarios. Preserve reservation and sweep assertions and retain an enabled regression for the new fail-closed owner API. Validation: 43/44 scoped tests pass; mismatched finality evidence deliberately remains red until the owner changes deferred success into an error. All-targets/all-features wallet CI clippy passes. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/manager/history_replay.rs | 418 +++++++++++------- .../rs-platform-wallet/src/manager/load.rs | 48 +- 2 files changed, 317 insertions(+), 149 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index b98909ec60e..3abf9a957ad 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -1240,6 +1240,72 @@ mod tests { Wallet::from_mnemonic(mnemonic, Network::Testnet, accounts).unwrap() } + fn authorize( + wallet: &key_wallet::Wallet, + info: &key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + mut tx: dashcore::Transaction, + ) -> dashcore::Transaction { + use dashcore::hashes::Hash; + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let path = info + .accounts + .all_funding_accounts() + .into_iter() + .find_map(|account| account.address_derivation_path(address)) + .unwrap(); + let key = wallet.derive_private_key(&path).unwrap(); + for index in 0..tx.input.len() { + let hash = SighashCache::new(&tx) + .legacy_signature_hash(index, &address.script_pubkey(), 1) + .unwrap(); + let mut sig = key.sign(&hash.to_byte_array()).with_der(|der| der.to_vec()); + sig.push(1); + tx.input[index].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(sig).unwrap()) + .push_slice(key.public_key().to_compressed()) + .into_script(); + } + tx + } + + async fn checked_instant_lock( + transaction: &dashcore::Transaction, + ) -> (InstantLock, ReplayEvidence) { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + let fixture = InstantLockFixture::new(transaction).unwrap(); + let directory = tempfile::TempDir::new().unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(Network::Testnet) + .without_filters() + .with_storage_path(directory.path()), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + ( + fixture.instant_lock().clone(), + ReplayEvidence { + instant_locks: [(proof.txid(), proof)].into(), + }, + ) + } + /// Stored history built from checker-produced records. fn history( records: Vec, @@ -1313,6 +1379,7 @@ mod tests { }], special_transaction_payload: None, }; + let spending = authorize(&wallet, &info, &address, spending); let block = |height| { TransactionContext::InBlock(BlockInfo::new( height, @@ -1781,11 +1848,8 @@ mod tests { height, )) }; - let locked = TransactionContext::InstantSend(InstantLock { - txid: spender.txid, - inputs: vec![spent], - ..Default::default() - }); + let (lock, evidence) = checked_instant_lock(&spender.transaction).await; + let locked = TransactionContext::InstantSend(lock); // A payment the stored history knows nothing about. let unrelated = Transaction { version: 1, @@ -1803,7 +1867,7 @@ mod tests { let chain_lock = |block_height| ChainLock { block_height, - block_hash: BlockHash::from_byte_array([30; 32]), + block_hash: BlockHash::from_byte_array([block_height as u8; 32]), signature: BLSSignature::from([0; 96]), }; let in_block = spender.context.clone(); @@ -1832,7 +1896,7 @@ mod tests { transactions: vec![without_ownership], instant_locks: BTreeMap::new(), }; - replay_recorded_history(&mut info, &mut wallet, history) + replay_recorded_history_with_evidence(&mut info, &mut wallet, history, &evidence) .await .unwrap(); info.apply_chain_lock(chain_lock(300)); @@ -2064,22 +2128,17 @@ mod tests { let mut child = loser.clone(); child.transaction.input[0].previous_output = OutPoint::new(loser.txid, 0); child.txid = child.transaction.txid(); + child.input_details.clear(); 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() - }) + let evidence = if instant { + let (lock, evidence) = checked_instant_lock(&winner.transaction).await; + winner.context = TransactionContext::InstantSend(lock); + evidence } else { - records[1].context.clone() + winner.context = records[1].context.clone(); + ReplayEvidence::default() }; info.accounts .standard_bip44_accounts @@ -2093,10 +2152,11 @@ mod tests { .unwrap() .transactions_mut() .insert(child.txid, child.clone()); - replay_recorded_history( + replay_recorded_history_with_evidence( &mut info, &mut wallet, history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), + &evidence, ) .await .unwrap(); @@ -2168,32 +2228,25 @@ mod tests { let mut winner = loser.clone(); winner.transaction.output[0].value -= delta; winner.txid = winner.transaction.txid(); - winner.context = TransactionContext::InstantSend(InstantLock { - txid: winner.txid, - inputs: winner - .transaction - .input - .iter() - .map(|input| input.previous_output) - .collect(), - ..Default::default() - }); winner }; for winner_first in [true, false] { - let winner = (1..) + let mut winner = (1..) .map(winner_with) .find(|winner| (winner.txid < loser.txid) == winner_first) .unwrap(); + let (lock, evidence) = checked_instant_lock(&winner.transaction).await; + winner.context = TransactionContext::InstantSend(lock); let mut info = restored.clone(); let account = info.accounts.standard_bip44_accounts.get_mut(&0).unwrap(); for raw in [&loser, &child] { account.transactions_mut().insert(raw.txid, raw.clone()); } - replay_recorded_history( + replay_recorded_history_with_evidence( &mut info, &mut wallet, history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), + &evidence, ) .await .unwrap(); @@ -2221,25 +2274,34 @@ mod tests { use key_wallet::transaction_checking::BlockInfo; let (mut wallet, mut info, address) = wallet_with_receive_address(); - let pay = |previous_output: OutPoint, outputs: &[(u64, bool)]| Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output, - ..Default::default() - }], - output: outputs - .iter() - .map(|(value, ours)| TxOut { - value: *value, - script_pubkey: if *ours { - address.script_pubkey() - } else { - dashcore::ScriptBuf::new() - }, - }) - .collect(), - special_transaction_payload: None, + let signing_wallet = wallet.clone(); + let signing_info = info.clone(); + let pay = |previous_output: OutPoint, outputs: &[(u64, bool)]| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output, + ..Default::default() + }], + output: outputs + .iter() + .map(|(value, ours)| TxOut { + value: *value, + script_pubkey: if *ours { + address.script_pubkey() + } else { + dashcore::ScriptBuf::new() + }, + }) + .collect(), + special_transaction_payload: None, + }, + ) }; let funding = pay( OutPoint::new(Txid::from_byte_array([51; 32]), 0), @@ -2326,16 +2388,17 @@ mod tests { Utxo::new(outpoint, txout.clone(), address.clone(), 0, false), ); } - let lock = InstantLock { - inputs: vec![coin], - txid: winner.txid(), - ..Default::default() - }; + let (lock, evidence) = checked_instant_lock(&winner).await; let locks = [(winner.txid(), lock)].into_iter().collect(); - replay_recorded_history(&mut restored, &mut wallet, history(records, locks)) - .await - .unwrap(); + replay_recorded_history_with_evidence( + &mut restored, + &mut wallet, + history(records, locks), + &evidence, + ) + .await + .unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; for (name, txid) in [("loser", loser.txid()), ("child", child.txid())] { @@ -2539,7 +2602,7 @@ mod tests { } else { coin }; - let unsafe_spend = pay(spent, &outputs); + let unsafe_spend = authorize(&wallet, &info, &address, pay(spent, &outputs)); let mut stored: Vec = funded.into_iter().map(StoredTransaction::from).collect(); stored.push(StoredTransaction { @@ -2671,7 +2734,12 @@ mod tests { .map(StoredTransaction::from) .collect(); history.push(stored( - spend(&coins, 1 << 63, address.script_pubkey()), + authorize( + &wallet, + &info, + &address, + spend(&coins, 1 << 63, address.script_pubkey()), + ), TransactionContext::Mempool, )); let winner = stored( @@ -2817,6 +2885,7 @@ mod tests { account.utxos.clear(); let mut loser = records.pop().unwrap(); loser.transaction.output[0].value = 25_000; + loser.transaction = authorize(&wallet, &restored, &address, loser.transaction); loser.txid = loser.transaction.txid(); loser.owned_inputs = vec![InputDetail { index: 1, @@ -2979,23 +3048,31 @@ mod tests { use dashcore::{BlockHash, Transaction, TxIn}; use key_wallet::transaction_checking::BlockInfo; - let (mut wallet, _, address) = wallet_with_receive_address(); + let (mut wallet, signing_info, address) = wallet_with_receive_address(); let coin = |byte: u8| OutPoint::new(Txid::from_byte_array([byte; 32]), 0); - let spend = |spends: &[OutPoint], value: u64| Transaction { - version: 1, - lock_time: 0, - input: spends - .iter() - .map(|previous_output| TxIn { - previous_output: *previous_output, - ..Default::default() - }) - .collect(), - output: vec![TxOut { - value, - script_pubkey: address.script_pubkey(), - }], - special_transaction_payload: None, + let signing_wallet = wallet.clone(); + let spend = |spends: &[OutPoint], value: u64| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: spends + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![TxOut { + value, + script_pubkey: address.script_pubkey(), + }], + special_transaction_payload: None, + }, + ) }; // The winner took coin 1 in a block; the loser wanted coins 1 and 2. let winner = spend(&[coin(1)], 9_000); @@ -3323,7 +3400,7 @@ mod tests { }; let funding = pay(OutPoint::new(Txid::from_byte_array([81; 32]), 0), value); let coin = OutPoint::new(funding.txid(), 0); - let child = pay(coin, 1); + let child = authorize(&wallet, &restored, &addresses[1], pay(coin, 1)); let stored = |transaction: Transaction, context| StoredTransaction { txid: transaction.txid(), transaction, @@ -3441,6 +3518,10 @@ mod tests { by_stored_amounts[1], ), ]; + let (_, signing_info, _) = wallet_with_receive_address(); + for (spend, _, _) in &mut spends { + *spend = authorize(&wallet, &signing_info, &address, spend.clone()); + } spends.sort_by_key(|(spend, _, _)| spend.txid()); let own = [spends[0].1, spends[1].1]; let stored_only = [spends[0].2, spends[1].2]; @@ -3590,9 +3671,8 @@ mod tests { ); } - /// Amounts a peer can make the wallet record: unconfirmed parents paying - /// it `1 << 62` twice, and two spends of both parent outputs and of one - /// genuine coin. The parents' bodies prove the spends take in more than + /// Stored unconfirmed parents pay the wallet `1 << 62` twice; two signed + /// spends consume both parent outputs and one genuine coin. The parents' bodies prove the spends take in more than /// can exist, so neither spend can ever confirm, and once a settled /// transaction has swept both, the genuine coin must be released. #[tokio::test] @@ -3601,7 +3681,7 @@ mod tests { use dashcore::{Transaction, TxIn}; for one_parent in [false, true] { - let (mut wallet, _, address) = wallet_with_receive_address(); + let (mut wallet, signing_info, address) = wallet_with_receive_address(); let pay = |spends: &[OutPoint], values: &[u64]| Transaction { version: 1, lock_time: 0, @@ -3645,8 +3725,14 @@ mod tests { ]; let [genuine, one, other] = [0, 1, 2].map(|index| OutPoint::new(fundings[index].txid(), 0)); - let forged = [(one, 5_000), (other, 6_000)] - .map(|(own, value)| pay(&[fakes[0], fakes[1], genuine, own], &[value])); + let forged = [(one, 5_000), (other, 6_000)].map(|(own, value)| { + authorize( + &wallet, + &signing_info, + &address, + pay(&[fakes[0], fakes[1], genuine, own], &[value]), + ) + }); let mut restored = ManagedWalletInfo::from_wallet(&wallet, 0); let account = restored @@ -4151,18 +4237,27 @@ mod tests { }], 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 signing_wallet = wallet.clone(); + let signing_info = info.clone(); + let spend = |value| { + authorize( + &signing_wallet, + &signing_info, + &address, + 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, @@ -4196,11 +4291,7 @@ mod tests { 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 (lock, evidence) = checked_instant_lock(&winner).await; let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); let record_of = |txid: Txid| records.iter().find(|r| r.txid == txid).unwrap().clone(); @@ -4208,9 +4299,14 @@ mod tests { // 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 - .unwrap(); + replay_recorded_history_with_evidence( + &mut alone, + &mut wallet, + history(pair, locks.clone()), + &evidence, + ) + .await + .unwrap(); assert!( alone.accounts.standard_bip44_accounts[&0] .transactions() @@ -4235,10 +4331,11 @@ mod tests { .unwrap() .transactions_mut() .insert(winner.txid(), record_of(winner.txid())); - replay_recorded_history( + replay_recorded_history_with_evidence( &mut contested, &mut wallet, history(records.clone(), locks.clone()), + &evidence, ) .await .unwrap(); @@ -4290,24 +4387,33 @@ mod tests { }], special_transaction_payload: None, }; - let spend = |change| Transaction { - version: 1, - lock_time: 0, - input: vec![TxIn { - previous_output: OutPoint::new(funding.txid(), 0), - ..Default::default() - }], - output: vec![ - TxOut { - value: 99_000 - change, - script_pubkey: dashcore::ScriptBuf::new(), - }, - TxOut { - value: change, - script_pubkey: address.script_pubkey(), + let signing_wallet = wallet.clone(); + let signing_info = info.clone(); + let spend = |change| { + authorize( + &signing_wallet, + &signing_info, + &address, + Transaction { + version: 1, + lock_time: 0, + input: vec![TxIn { + previous_output: OutPoint::new(funding.txid(), 0), + ..Default::default() + }], + output: vec![ + TxOut { + value: 99_000 - change, + script_pubkey: dashcore::ScriptBuf::new(), + }, + TxOut { + value: change, + script_pubkey: address.script_pubkey(), + }, + ], + special_transaction_payload: None, }, - ], - special_transaction_payload: None, + ) }; let block = TransactionContext::InBlock(BlockInfo::new( 100, @@ -4351,14 +4457,12 @@ mod tests { ); } assert_eq!(records.len(), 3, "{case}"); - let lock_of = |txid| InstantLock { - inputs: vec![OutPoint::new(funding.txid(), 0)], - txid, - ..Default::default() - }; + let mut evidence = ReplayEvidence::default(); let mut locks = BTreeMap::new(); if settlement == "instant_send" { - locks.insert(winner.txid(), lock_of(winner.txid())); + let (lock, checked) = checked_instant_lock(&winner).await; + locks.insert(winner.txid(), lock); + evidence.instant_locks.extend(checked.instant_locks); } else { let block = BlockInfo::new(101, BlockHash::from_byte_array([10; 32]), 101); let record = records @@ -4383,7 +4487,9 @@ mod tests { }); } if locked_competitor { - locks.insert(loser.txid(), lock_of(loser.txid())); + let (lock, checked) = checked_instant_lock(&loser).await; + locks.insert(loser.txid(), lock); + evidence.instant_locks.extend(checked.instant_locks); } if raw_restored { let account = restored @@ -4398,9 +4504,14 @@ mod tests { } } - replay_recorded_history(&mut restored, &mut wallet, history(records, locks)) - .await - .unwrap(); + replay_recorded_history_with_evidence( + &mut restored, + &mut wallet, + history(records, locks), + &evidence, + ) + .await + .unwrap(); let account = &restored.accounts.standard_bip44_accounts[&0]; let keep_competitor = matches!(settlement, "block" | "persisted_chainlock_below") @@ -4430,9 +4541,7 @@ mod tests { } } - /// 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. + /// Mismatched persisted finality evidence fails without upgrading or replaying records. #[tokio::test] async fn should_not_upgrade_record_to_instant_send_with_mismatched_lock() { use dashcore::hashes::Hash; @@ -4488,12 +4597,10 @@ mod tests { .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 foreign_transaction = spend.clone(); + foreign_transaction.output[0].value -= 1; + let foreign = foreign_transaction.txid(); + let (foreign_lock, evidence) = checked_instant_lock(&foreign_transaction).await; let mut forged_record = records.clone(); forged_record .iter_mut() @@ -4506,17 +4613,26 @@ mod tests { "lock txid", records.clone(), spend.txid(), - lock_for(foreign), + foreign_lock.clone(), ), // Record and lock agree, but the record's transaction is another one. - ("record txid", forged_record, foreign, lock_for(foreign)), + ("record txid", forged_record, foreign, foreign_lock.clone()), ]; 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 - .unwrap(); + let before = restored.clone(); + let result = replay_recorded_history_with_evidence( + &mut restored, + &mut wallet, + history(records, locks), + &evidence, + ); + // TODO: integrate the owner fail-closed result for missing finality evidence. + assert!( + result.await.is_err(), + "{case}: mismatched finality evidence must fail" + ); let transactions = restored.accounts.standard_bip44_accounts[&0].transactions(); assert!( !transactions @@ -4525,8 +4641,14 @@ mod tests { "{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" + transactions.is_empty(), + "{case}: failed replay must not attach transaction records" + ); + assert_eq!(restored.balance, before.balance, "{case}"); + assert_eq!( + restored.accounts.standard_bip44_accounts[&0].utxos, + before.accounts.standard_bip44_accounts[&0].utxos, + "{case}" ); } } diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index d1961e6eb73..d4030b4c7ad 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1670,6 +1670,37 @@ mod idempotent_load_tests { assert!(manager.shutdown().await.all_clean()); } + fn authorize( + wallet: &key_wallet::Wallet, + info: &key_wallet::wallet::ManagedWalletInfo, + address: &dashcore::Address, + mut tx: dashcore::Transaction, + ) -> dashcore::Transaction { + use dashcore::hashes::Hash; + use dashcore::script::{Builder, PushBytesBuf}; + use dashcore::sighash::SighashCache; + use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; + let path = info + .accounts + .all_funding_accounts() + .into_iter() + .find_map(|account| account.address_derivation_path(address)) + .unwrap(); + let key = wallet.derive_private_key(&path).unwrap(); + for index in 0..tx.input.len() { + let hash = SighashCache::new(&tx) + .legacy_signature_hash(index, &address.script_pubkey(), 1) + .unwrap(); + let mut sig = key.sign(&hash.to_byte_array()).with_der(|der| der.to_vec()); + sig.push(1); + tx.input[index].script_sig = Builder::new() + .push_slice(PushBytesBuf::try_from(sig).unwrap()) + .push_slice(key.public_key().to_compressed()) + .into_script(); + } + tx + } + /// `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 @@ -1684,7 +1715,15 @@ mod idempotent_load_tests { txid: funding.txid(), vout: 0, }; - let spend = spend_to(funded_outpoint, 74_000); + let address = &ctx.managed_wallet.accounts.standard_bip44_accounts[&0].utxos + [&funded_outpoint] + .address; + let spend = authorize( + &ctx.wallet, + &ctx.managed_wallet, + address, + 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); @@ -2621,6 +2660,13 @@ mod idempotent_load_tests { let mut unsafe_spend = spend_to(funded_outpoint, 1 << 63); unsafe_spend.output[0].script_pubkey = coin.txout.script_pubkey.clone(); + let unsafe_spend = authorize( + &ctx.wallet, + &ctx.managed_wallet, + &coin.address, + unsafe_spend, + ); + let mut projection = ManagedWalletInfo::from_wallet(&ctx.wallet, 0); projection .accounts From 1fd23169828d345aaa62a05d8bc1dd5d0e5b0632 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:54:18 +0000 Subject: [PATCH 57/84] fix(swift-sdk): start SPV before retrying persisted wallet history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Count persisted wallets in the existing auto-start gate even when history load failed. Retry load explicitly after startup and surface failure through the existing bootstrap error UI. Reuse the SDK and manager on Retry so the running SPV client can continue obtaining evidence. The combined owner/loader suite passes all 65 selected tests. Native Swift startup gate tests require macOS and were not executed on this Linux host. Document the existing trusted-store boundary for mined transaction contexts. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/manager/history_replay.rs | 4 +++- .../Core/Services/CoreSpvLauncher.swift | 6 ++++-- .../SwiftExampleApp/SwiftExampleAppApp.swift | 21 +++++++++++++------ .../CoreSpvAutoStartTests.swift | 21 +++++++++++++++++++ 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 3abf9a957ad..0b10562a7dc 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -285,6 +285,9 @@ pub async fn replay_recorded_history_with_evidence( } } } + // Mined contexts retain the existing trusted local producer/store contract; + // they are not independently reverified transaction-inclusion proofs here. + // InstantSend observations require the opaque SPV evidence checked above. let candidates = transactions .iter() .filter(|stored| { @@ -4628,7 +4631,6 @@ mod tests { history(records, locks), &evidence, ); - // TODO: integrate the owner fail-closed result for missing finality evidence. assert!( result.await.is_err(), "{case}: mismatched finality evidence must fail" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift index 2a1df95d016..c5875a5c3eb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/CoreSpvLauncher.swift @@ -164,7 +164,8 @@ enum CoreSpvLauncher { /// non-obvious rules the caller relies on: /// /// * a **no-wallets** launch does NOT latch (leaving the door open for -/// a future call once wallets exist), whereas +/// a future call once wallets exist); persisted wallets count even when +/// their history load failed for unavailable finality evidence, whereas /// * an **already-running** client DOES latch (the auto-start is /// considered "handled" — we don't want to keep re-checking). enum CoreSpvAutoStart { @@ -196,10 +197,11 @@ enum CoreSpvAutoStart { static func decision( alreadyLatched: Bool, hasWallets: Bool, + hasPersistedWallets: Bool = false, spvRunning: Bool ) -> Decision { if alreadyLatched { return .skipAlreadyLatched } - if !hasWallets { return .skipNoWallets } + if !hasWallets && !hasPersistedWallets { return .skipNoWallets } if spvRunning { return .latchOnly } return .startAndLatch } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift index 62db9b2d36d..4987437f12f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/SwiftExampleAppApp.swift @@ -372,6 +372,7 @@ struct SwiftExampleAppApp: App { let decision = CoreSpvAutoStart.decision( alreadyLatched: didAutoStartCoreSpv, hasWallets: !manager.wallets.isEmpty, + hasPersistedWallets: !(manager.persistence?.restorableWalletIds().isEmpty ?? true), spvRunning: manager.spvIsRunning ) if decision.shouldLatch { didAutoStartCoreSpv = true } @@ -441,18 +442,19 @@ struct SwiftExampleAppApp: App { await PlatformWalletManager.warmUpShieldedProver() } - platformState.initializeSDK(modelContext: modelContainer.mainContext) - - // Give the Platform SDK a moment to finish its internal init. - try? await Task.sleep(for: .milliseconds(500)) + // An explicit restore retry keeps the same manager and its running SPV client. + if platformState.sdk == nil { + platformState.initializeSDK(modelContext: modelContainer.mainContext) + try? await Task.sleep(for: .milliseconds(500)) + } if let sdk = platformState.sdk { // Activate the per-network manager for the launch // network. The store creates + configures the // manager and runs `loadFromPersistor` against it // (filtered to the launch network's wallets via - // the network-aware persistence handler), so no - // separate restore pass is needed here. + // the network-aware persistence handler). History requiring + // SPV evidence gets an explicit retry after startup below. try walletManagerStore.activate( network: platformState.currentNetwork, sdk: sdk @@ -496,6 +498,12 @@ struct SwiftExampleAppApp: App { // bumps last wins and the other bails (never a double-start). isInitialized = true await autoStartCoreSpvIfNeeded() + if platformState.sdk != nil { + // Starting SPV opens its cache but does not guarantee quorum readiness. + // A load error leaves SPV running and the existing Retry action can try again. + _ = try await walletManager.loadFromPersistor() + rebindWalletScopedServices() + } SDKLogger.event( "app_initialization_completed", category: .lifecycle, @@ -505,6 +513,7 @@ struct SwiftExampleAppApp: App { ] ) } catch { + isInitialized = false bootstrapError = error SDKLogger.event( "app_initialization_failed", diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift index 8d45e46fcba..dcf678a07c2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CoreSpvAutoStartTests.swift @@ -54,6 +54,27 @@ final class CoreSpvAutoStartTests: XCTestCase { XCTAssertFalse(decision.shouldStart) } + // TODO(macOS-validation): Run startup gate tests with the SwiftExampleApp target. + func testShouldStartForPersistedWalletsWhenHistoryLoadHasFailed() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: false, + hasPersistedWallets: true, + spvRunning: false + ) + XCTAssertEqual(decision, .startAndLatch) + } + + func testShouldKeepRunningSpvForAnExplicitHistoryLoadRetry() { + let decision = CoreSpvAutoStart.decision( + alreadyLatched: false, + hasWallets: false, + hasPersistedWallets: true, + spvRunning: true + ) + XCTAssertEqual(decision, .latchOnly) + } + // MARK: - Already-running gate latches without starting func testAlreadyRunningLatchesButDoesNotStart() { From 03bac0b4e15249d63acdd0ccbcb0e933d33aadbd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:04:53 +0000 Subject: [PATCH 58/84] refactor(wallet): prepare tracked history before atomic replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use one wallet-owned preparation path for manager and SQLite restoration. Bind tracked funding locks before settlement, preserve recorded conflict observations, and restore outgoing/final funding claims in that same path. Regression RED: tracked proof omitted before SQL replay. GREEN: 24 focused manager/storage tests and scoped Clippy across the three wallet crates. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/sqlite/persister.rs | 22 +-- .../sqlite_shared_history_replay.rs | 88 ++++++++- .../changeset/client_wallet_start_state.rs | 8 +- .../src/manager/history_replay.rs | 110 ++++++++++- .../rs-platform-wallet/src/manager/load.rs | 173 +++--------------- .../src/wallet/asset_lock/sync/recovery.rs | 4 +- 6 files changed, 231 insertions(+), 174 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index ff7967ad513..3ba3352f49c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -13,7 +13,7 @@ use platform_wallet::changeset::{ PlatformWalletChangeSet, PlatformWalletPersistence, }; use platform_wallet::manager::history_replay::{ - replay_recorded_history_blocking, HistoryReplay, HistoryReplayError, HistoryReplayLoad, + replay_wallet_history_blocking, HistoryReplay, HistoryReplayError, HistoryReplayLoad, }; use platform_wallet::wallet::identity::ManagedIdentity; use platform_wallet::wallet::platform_wallet::WalletId; @@ -1798,26 +1798,16 @@ fn load_one_wallet( .map_err(WalletStorageError::from) .map_err(PersistenceError::from)?; let mut state = load_wallet_snapshot(&tx, wallet_id, ctx)?; - let history = std::mem::take(&mut state.recorded_history); - let mut outcome = replay_recorded_history_blocking( - &mut state.wallet_info, - &mut state.wallet, - history, - evidence, - ) - .map_err(replay_failure)?; + let mut outcome = + replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; let mut claims = history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; if !outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty() { history_replay::apply(&tx, &wallet_id, &mut outcome).map_err(PersistenceError::from)?; state = load_wallet_snapshot(&tx, wallet_id, ctx)?; - let remaining = replay_recorded_history_blocking( - &mut state.wallet_info, - &mut state.wallet, - std::mem::take(&mut state.recorded_history), - evidence, - ) - .map_err(replay_failure)?; + let remaining = + replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; + state.unconfirmed_outgoing_txs = outcome.resendable(state.unconfirmed_outgoing_txs); if !remaining.sweeps.is_empty() || !remaining.authorization.rejected.is_empty() { return Err(PersistenceError::backend( HistoryReplayError::UnsettledAfterReplay, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs index 0bb6c7c1ce2..2d7433b170a 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -4,8 +4,12 @@ use std::sync::Arc; use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; use dash_spv::{ClientConfig, DashSpvClient}; use dashcore::{hashes::Hash, OutPoint}; +use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; +use dpp::prelude::AssetLockProof; use key_wallet::transaction_checking::TransactionContext; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; use platform_wallet::changeset::{ CoreChangeSet, PersistenceCapabilities, PlatformWalletPersistence, }; @@ -13,11 +17,13 @@ use platform_wallet::key_wallet_manager::test_utils::MockWallet; use platform_wallet::test_support::core_fixtures::{ block, outside, pay, records_of, spend_of, stored_utxo, wallet_with_receive_address, }; +use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; use platform_wallet_storage::sqlite::schema::blob; use tokio::sync::RwLock; use crate::common::{ - authorize, fresh_persister, registration, LoadPolicy, SqlitePersister, SqlitePersisterConfig, + authorize, checked_instant_lock, fresh_persister, registration, LoadPolicy, SqlitePersister, + SqlitePersisterConfig, }; #[tokio::test] @@ -159,3 +165,83 @@ fn should_not_attest_operation_status_after_replay_cleanup() { "replay cleanup has no durable operation tombstone to distinguish absence from never sent" ); } + +#[tokio::test] +async fn should_verify_tracked_funding_before_atomic_history_settlement() { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(125)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coin = OutPoint::new(funding.txid(), 0); + let locked = authorize( + &wallet, + &info, + &address, + spend_of(&[coin], vec![pay(99_000, &address)]), + ); + let competitor = spend_of(&[coin], vec![pay(98_000, &address)]); + let mut alternative = info.clone(); + records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); + records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); + let (instant_lock, evidence) = checked_instant_lock(&locked).await; + let (persister, _directory, path) = fresh_persister(); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ); + let out_point = OutPoint::new(locked.txid(), 0); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: BTreeMap::from([( + out_point, + AssetLockEntry { + out_point, + transaction: locked.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status: AssetLockStatus::InstantSendLocked, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + locked.clone(), + 0, + ))), + operation_id: None, + }, + )]), + ..Default::default() + }); + persister.store(wallet.wallet_id, changes).unwrap(); + assert!( + persister + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "a tracked funding proof must be required before any SQL settlement" + ); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); + let loaded = persister.load_with_replay(&evidence).unwrap(); + assert!(!loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&locked.txid())); + assert!(persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened.load_with_replay(&evidence).unwrap(); + assert!(loaded.state.wallets[&wallet.wallet_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&out_point))); + assert!(reopened + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); +} 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 8ed780f220d..8ce5c8cc389 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 @@ -45,9 +45,7 @@ pub struct ClientWalletStartState { /// runs — dropping the input from `utxos` and recording it in /// `spent_outpoints`. That call is async and needs the `Wallet` and /// the `ManagedWalletInfo` together, so the replay happens at the - /// async boundary in - /// [`load_from_persistor`](crate::manager::load), not while this - /// snapshot is being built. + /// common wallet replay before the backend commits its load effects. /// /// # Why the replay exists /// @@ -70,9 +68,7 @@ pub struct ClientWalletStartState { /// 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. + /// Applied by the common wallet replay inside the backend's load scope. pub recorded_history: RecordedHistory, /// Durable record of the DashPay coreHeight backfill, as the host last /// stored it — which receival contacts the historical rescan already diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 0b10562a7dc..36b1e03f386 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -35,14 +35,21 @@ use super::history_authorization::plan_history_authorization; pub use super::history_authorization::HistoryAuthorization; use crate::changeset::changeset::SweepBatch; use crate::changeset::recorded_history::ReplayEvidence; -use crate::changeset::{is_owned, ClientStartState, RecordedHistory, StoredTransaction}; +use crate::changeset::{ + is_owned, ClientStartState, ClientWalletStartState, RecordedHistory, StoredTransaction, +}; +use crate::wallet::asset_lock::sync::recovery::{ + add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, + reattach_records, recorded_input_conflicts, withhold_chain_final_fundings, +}; +use crate::wallet::platform_wallet::ObservedInputConflict; use crate::wallet::platform_wallet::WalletId; use key_wallet::transaction_checking::spend_authorization::SpendAuthorizationError; /// Backend snapshot and the replay verdict committed with each wallet. #[derive(Debug, Default)] pub struct HistoryReplayLoad { - /// Authoritative wallet snapshots, including conservative deferred views. + /// Authoritative wallet snapshots after successful replay. pub state: ClientStartState, /// Replay outcomes keyed by wallet; missing means the backend supplied raw history. pub outcomes: BTreeMap, @@ -51,6 +58,8 @@ pub struct HistoryReplayLoad { /// What [`replay_recorded_history`] made of the stored history. #[derive(Debug, Default)] pub struct HistoryReplay { + /// Confirmed competing spends observed before replay can evict their records. + pub recorded_conflicts: BTreeMap, /// Owner-authorized rejections and surviving claims for atomic backend repair. pub authorization: HistoryAuthorization, /// How many records went through the wallet checker. @@ -131,6 +140,102 @@ pub enum HistoryReplayError { UnsettledAfterReplay, } +/// Prepare every persisted source before the common replay settles any history. +pub async fn replay_wallet_history( + state: &mut ClientWalletStartState, + evidence: &ReplayEvidence, +) -> Result { + let mut history = state.recorded_history.clone(); + let history_txids: HashSet<_> = history + .transactions + .iter() + .map(|stored| stored.txid) + .collect(); + let recorded_conflicts = recorded_input_conflicts( + state.unused_asset_locks.values().flat_map(BTreeMap::values), + &history, + ); + add_tracked_instant_locks( + &mut history, + &history_txids, + state.unused_asset_locks.values().flat_map(BTreeMap::values), + ); + let withheld = withhold_chain_final_fundings( + &mut history, + state.unused_asset_locks.values().flat_map(BTreeMap::values), + ); + let withheld_records = detach_unconfirmed_records(&mut state.wallet_info, &withheld); + let mut outcome = replay_recorded_history_with_evidence( + &mut state.wallet_info, + &mut state.wallet, + history, + evidence, + ) + .await?; + reattach_records(&mut state.wallet_info, withheld_records); + outcome.recorded_conflicts = recorded_conflicts; + state.unconfirmed_outgoing_txs = + outcome.resendable(std::mem::take(&mut state.unconfirmed_outgoing_txs)); + for transaction in state + .unconfirmed_outgoing_txs + .iter() + .filter(|tx| !history_txids.contains(&tx.txid())) + { + state + .wallet_info + .check_core_transaction( + transaction, + TransactionContext::Mempool, + &mut state.wallet, + true, + true, + ) + .await; + } + state + .wallet_info + .restore_spent_outpoints(&final_lock_input_claims( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter(|lock| { + let txid = lock.transaction.txid(); + !outcome.defeated.contains(&txid) + && !outcome.authorization.rejected.contains(&txid) + }), + )); + state.recorded_history = RecordedHistory::default(); + Ok(outcome) +} + +/// Run wallet preparation and replay in the backend's synchronous atomic scope. +pub fn replay_wallet_history_blocking( + state: &mut ClientWalletStartState, + evidence: &ReplayEvidence, +) -> Result { + let original_wallet = state.wallet.clone(); + let original_info = state.wallet_info.clone(); + let original_history = state.recorded_history.clone(); + let original_sends = state.unconfirmed_outgoing_txs.clone(); + let result = { + let mut replay = std::pin::pin!(replay_wallet_history(state, evidence)); + replay + .as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + }; + let error = match result { + Poll::Ready(Ok(outcome)) => return Ok(outcome), + Poll::Ready(Err(error)) => error, + Poll::Pending => HistoryReplayError::Suspended, + }; + state.wallet = original_wallet; + state.wallet_info = original_info; + state.recorded_history = original_history; + state.unconfirmed_outgoing_txs = original_sends; + Err(error) +} + /// Run the common replay inside a synchronous backend's transaction. pub fn replay_recorded_history_blocking( wallet_info: &mut ManagedWalletInfo, @@ -617,6 +722,7 @@ pub async fn replay_recorded_history_with_evidence( swept.extend(set_aside.iter().map(|stored| stored.txid)); swept.extend(authorization.rejected.iter().copied()); Ok(HistoryReplay { + recorded_conflicts: BTreeMap::new(), authorization, replayed: replay.len(), retired: swept, diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index d4030b4c7ad..06cf3d11544 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,6 +1,6 @@ //! Hydrate a [`PlatformWalletManager`] from its persister. -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use crate::changeset::{ @@ -8,11 +8,7 @@ use crate::changeset::{ PersistenceCapabilities, PlatformWalletPersistence, }; use crate::error::PlatformWalletError; -use crate::wallet::asset_lock::sync::recovery::{ - add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, - reattach_records, recorded_input_conflicts, seed_observed_input_conflicts, - withhold_chain_final_fundings, -}; +use crate::wallet::asset_lock::sync::recovery::seed_observed_input_conflicts; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; @@ -21,13 +17,10 @@ use crate::wallet::PlatformWallet; use std::time::Duration; use crate::broadcaster::{BroadcastError, TransactionBroadcaster}; -use dashcore::Txid; use dpp::prelude::Identifier; use key_wallet::account::derivation::AccountDerivation; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; -use key_wallet::transaction_checking::transaction_context::TransactionContext; -use key_wallet::transaction_checking::wallet_checker::WalletTransactionChecker; use super::{run_blocking_load, PlatformWalletManager}; @@ -205,149 +198,35 @@ impl PlatformWalletManager

{ } continue 'load; } + let mut wallet_state = wallet_state; + let outcome = match outcomes.remove(&expected_wallet_id) { + Some(outcome) => outcome, + None => { + match super::history_replay::replay_wallet_history(&mut wallet_state, &evidence) + .await + { + Ok(outcome) => outcome, + Err(error) => { + load_error = Some(PlatformWalletError::from_load_failure( + crate::changeset::PersistenceError::backend(error), + )); + break 'load; + } + } + } + }; let ClientWalletStartState { - mut wallet, - mut wallet_info, + wallet, + wallet_info, identity_manager, unused_asset_locks, - mut unconfirmed_outgoing_txs, - mut recorded_history, + unconfirmed_outgoing_txs, dashpay_backfill, + .. } = wallet_state; - - // Read before the replay consumes the history: its finalization - // evicts a buried spender's record, where the seeding below looks. - let recorded_conflicts = recorded_input_conflicts( - unused_asset_locks.values().flat_map(BTreeMap::values), - &recorded_history, - ); - - // 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(); - add_tracked_instant_locks( - &mut recorded_history, - &history_txids, - unused_asset_locks.values().flat_map(BTreeMap::values), - ); - let withheld = withhold_chain_final_fundings( - &mut recorded_history, - unused_asset_locks.values().flat_map(BTreeMap::values), - ); - // A record a host attached for a withheld funding sits the - // replay out as well, or the replay would plan it as unconfirmed. - let withheld_records = detach_unconfirmed_records(&mut wallet_info, &withheld); - let mut defeated = HashSet::new(); - let backend_outcome = outcomes.remove(&expected_wallet_id).unwrap_or_default(); - unconfirmed_outgoing_txs = backend_outcome.resendable(unconfirmed_outgoing_txs); - if !recorded_history.is_empty() { - let offered = recorded_history.transactions.len(); - let outcome = - crate::manager::history_replay::replay_recorded_history_with_evidence( - &mut wallet_info, - &mut wallet, - recorded_history, - &evidence, - ) - .await; - let outcome = match outcome { - Ok(outcome) => outcome, - Err(error) => { - load_error = Some(PlatformWalletError::from_load_failure( - crate::changeset::PersistenceError::backend(error), - )); - break 'load; - } - }; - tracing::info!( - wallet_id = %hex::encode(expected_wallet_id), - offered, - replayed = outcome.replayed, - retired = outcome.retired.len(), - "load: replayed stored transaction history" - ); - // A send the replay retired is not dispatched again below. - unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); - defeated = outcome.defeated; - } - reattach_records(&mut wallet_info, withheld_records); - - // Replay the sends the host still holds as unconfirmed, before - // anything reads the restored balance. - // - // Their spend effect is never persisted: `isSpent` stays `false` - // on the input row until the spending transaction reaches a - // block, because a mempool-only sighting is reversible by - // eviction. So the UTXO restore above has just handed those - // inputs back as spendable. A live process was still correct — - // it held the effect in memory — and until now a restart - // recovered it only by re-observing the transaction on the - // network. A transaction that never reached the network cannot - // be re-observed, so its input stayed spendable for good and the - // balance re-counted the coin. - // - // Routing each record through the ordinary mempool check (rather - // than inserting it into `transactions_mut()` raw, the way the - // asset-lock record restore does) is the whole point: it runs - // `update_utxos`, which drops the input from `utxos` and records - // it in `spent_outpoints`, reproducing exactly the state the - // live process held. A raw insert would leave `spent_outpoints` - // empty AND make every later re-dispatch a no-op, because - // `has_transaction` would then report the record as not new. - // - // `update_state` and `update_balance` are both on: the balance - // this produces is what `generation.set(..)` mirrors a few lines - // below, and the UI reads that. - if !unconfirmed_outgoing_txs.is_empty() { - let mut replayed = 0usize; - // 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, - TransactionContext::Mempool, - &mut wallet, - true, - true, - ) - .await; - if result.is_relevant { - replayed += 1; - } - } - tracing::info!( - wallet_id = %hex::encode(expected_wallet_id), - offered = unconfirmed_outgoing_txs.len(), - replayed, - "load: replayed unconfirmed outgoing sends" - ); - } - - // Flatten the (account → outpoint → lock) map into the flat - // OutPoint → TrackedAssetLock map that `PlatformWalletInfo` - // holds today. - let mut tracked_asset_locks = BTreeMap::new(); - for (_account_index, account_locks) in unused_asset_locks { - tracked_asset_locks.extend(account_locks); - } - // A host may drop a final lock's inputs from the projection while - // the funding's own record guards nothing. A funding the replay - // defeated spends nothing any more, whatever its lock's status. - wallet_info.restore_spent_outpoints(&final_lock_input_claims( - tracked_asset_locks - .values() - .filter(|lock| !defeated.contains(&lock.transaction.txid())), - )); + let unconfirmed_outgoing_txs = outcome.resendable(unconfirmed_outgoing_txs); + let recorded_conflicts = outcome.recorded_conflicts; + let tracked_asset_locks = unused_asset_locks.into_values().flatten().collect(); // Canonical id recomputed from the wallet's own key material. // Computed up front — before `insert_wallet` consumes `wallet` — diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index b7fc17e0e2c..2005ff9336d 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -540,8 +540,8 @@ pub(crate) fn final_lock_input_claims<'a>( /// while the lock's proof carries the real one. A lock is taken only when it, /// the proof's transaction and the tracked funding name one transaction the /// history holds (`recorded`, its txids), and a lock the store supplied itself -/// stands. Its signature is not verified at load, like the locks a store keeps -/// for its own records. +/// stands. These bytes only nominate evidence: the common replay requires a +/// matching SPV-verified token before using their finality. pub(crate) fn add_tracked_instant_locks<'a>( history: &mut RecordedHistory, recorded: &HashSet, From 144368de01067f900f2e222ad9e712fae41abef9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:22:22 +0000 Subject: [PATCH 59/84] feat(wallet-ffi): preserve raw InstantLock evidence across restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append size-gated raw-lock callbacks without changing legacy row strides. Persist opaque consensus bytes in the unreleased Swift V5 sidecar and require checked owner evidence for stored InstantSend observations. Rust proof/transport gates:5 tests; ABI/free-path follow-up:2 tests; scoped Clippy across3 wallet crates passed. SwiftData rollback/reopen and network-scoped deletion tests require macOS and were not run here. Atomic FFI replay effects remain a separate implementation checkpoint. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/core_wallet_types.rs | 11 +- .../rs-platform-wallet-ffi/src/manager.rs | 68 +++- .../rs-platform-wallet-ffi/src/persistence.rs | 346 +++++++++++++++++- .../src/wallet_restore_types.rs | 2 +- .../src/sqlite/persister.rs | 1 + .../src/changeset/recorded_history.rs | 4 +- .../src/manager/history_replay.rs | 56 +++ .../rs-platform-wallet/src/manager/load.rs | 8 + .../src/wallet/asset_lock/sync/recovery.rs | 3 + .../Persistence/DashModelContainer.swift | 5 +- .../Models/PersistentCoreInstantLock.swift | 21 ++ .../PlatformWalletPersistenceHandler.swift | 94 +++++ .../CoreInstantLockPersistenceTests.swift | 56 +++ 13 files changed, 649 insertions(+), 26 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs index d494ff93df5..c31c2f7d3ef 100644 --- a/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs +++ b/packages/rs-platform-wallet-ffi/src/core_wallet_types.rs @@ -522,15 +522,8 @@ impl WalletChangeSetFFI { /// snapshots delivered through the `BalanceUpdateHandler`, not as /// a delta on the persistence path. /// - /// TODO(events): wire `instant_locks_for_non_final_records` through - /// the FFI surface. Today the Swift side learns about IS-lock state - /// only when a re-emitted `TransactionRecord` flows through `records` - /// with `context = InstantSend(..)`. The standalone IS-lock map is - /// dropped here. Acceptable as long as the event adapter re-emits - /// affected records (it does for `TransactionDetected`, - /// `BlockProcessed` and `TransactionInstantLocked`). - /// When the standalone IS-lock event needs to flow to Swift, add - /// a `BTreeMap`-shaped FFI field here and populate it. + /// Raw InstantLocks ride the size-gated persistence extension callback; + /// they do not change this legacy struct's allocation or array stride. pub fn from_changeset(cs: &platform_wallet::changeset::CoreChangeSet) -> Self { use key_wallet::account::AccountType; use std::ffi::CString; diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 007bafc2dae..3323e4256c7 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,14 +8,14 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeCoreHistoryForAccountingFn, FreeTrackedMasternodesFn, - LoadAssetLockOperationIdFn, LoadCoreHistoryForAccountingFn, LoadIdentityBalanceBlockTimeFn, - LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistCoreHistoryAccountedFn, - PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, - PersistTransactionAccountingFn, PersistWalletChangesetChainLockHeightFn, - PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, - PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, - PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + FFIPersister, FreeCoreHistoryForAccountingFn, FreeCoreInstantLockFn, FreeTrackedMasternodesFn, + LoadAssetLockOperationIdFn, LoadCoreHistoryForAccountingFn, LoadCoreInstantLockFn, + LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, + PersistCoreHistoryAccountedFn, PersistCoreInstantLockFn, PersistDpnsNameStatesFn, + PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistTransactionAccountingFn, + PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, + PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, + PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -308,6 +308,9 @@ unsafe fn persistence_extension_callbacks( on_persist_core_history_accounted_fn, PersistCoreHistoryAccountedFn ), + persist_core_instant_lock: slot!(on_persist_core_instant_lock_fn, PersistCoreInstantLockFn), + load_core_instant_lock: slot!(on_load_core_instant_lock_fn, LoadCoreInstantLockFn), + free_core_instant_lock: slot!(on_load_core_instant_lock_free_fn, FreeCoreInstantLockFn), } } @@ -1394,6 +1397,55 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + #[test] + fn should_size_gate_raw_instant_lock_callbacks_after_accounting() { + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *const u8, + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *const u8, + _: *mut *const u8, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const u8, _: usize) {} + let mut ext = PersistenceCallbacksExtension { + on_persist_core_instant_lock_fn: Some(persist), + on_load_core_instant_lock_fn: Some(load), + on_load_core_instant_lock_free_fn: Some(free), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_core_instant_lock.is_some()); + assert!(parsed.load_core_instant_lock.is_some()); + assert!(parsed.free_core_instant_lock.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_instant_lock_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_core_instant_lock.is_none()); + assert!(parsed.load_core_instant_lock.is_none()); + assert!(parsed.free_core_instant_lock.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_instant_lock_free_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_core_instant_lock.is_some()); + assert!(parsed.load_core_instant_lock.is_some()); + assert!(parsed.free_core_instant_lock.is_none()); + } + #[test] fn should_size_gate_asset_lock_operation_id_slots_independently() { unsafe extern "C" fn persist( diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index cd0b23191b3..af9cb717286 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -172,6 +172,26 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = /// wallet-list load callback pair wired. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_HISTORY_RESTORE: u64 = 1 << 14; +/// Persist consensus InstantLock bytes inside the existing changeset transaction. +pub type PersistCoreInstantLockFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + bytes: *const u8, + len: usize, +) -> i32; +/// Read an optional raw lock; null/zero means absent, never verified. +pub type LoadCoreInstantLockFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + out_bytes: *mut *const u8, + out_len: *mut usize, +) -> i32; +/// Free the exact allocation returned by the raw-lock loader, including error paths. +pub type FreeCoreInstantLockFn = + unsafe extern "C" fn(context: *mut c_void, bytes: *const u8, len: usize); + /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by /// pointer without a size field, so growing it would make Rust read beyond an @@ -582,6 +602,28 @@ pub struct PersistenceCallbacksExtension { /// changeset begin/end pair are present. pub on_persist_core_history_accounted_fn: Option i32>, + /// Raw InstantLock sidecar; the legacy transaction row stride remains unchanged. + pub on_persist_core_instant_lock_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + bytes: *const u8, + len: usize, + ) -> i32, + >, + /// Returns consensus bytes, which Rust must verify against the SPV quorum. + pub on_load_core_instant_lock_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + txid: *const u8, + out_bytes: *mut *const u8, + out_len: *mut usize, + ) -> i32, + >, + pub on_load_core_instant_lock_free_fn: + Option, } impl Default for PersistenceCallbacksExtension { @@ -606,6 +648,9 @@ impl Default for PersistenceCallbacksExtension { on_load_core_history_for_accounting_fn: None, on_load_core_history_for_accounting_free_fn: None, on_persist_core_history_accounted_fn: None, + on_persist_core_instant_lock_fn: None, + on_load_core_instant_lock_fn: None, + on_load_core_instant_lock_free_fn: None, } } } @@ -631,6 +676,9 @@ pub struct PersistenceExtensionCallbacks { pub load_core_history_for_accounting: Option, pub load_core_history_for_accounting_free: Option, pub persist_core_history_accounted: Option, + pub persist_core_instant_lock: Option, + pub load_core_instant_lock: Option, + pub free_core_instant_lock: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1581,6 +1629,9 @@ pub struct FFIPersister { load_core_history_for_accounting_callback: Option, load_core_history_for_accounting_free_callback: Option, persist_core_history_accounted_callback: Option, + persist_core_instant_lock_callback: Option, + load_core_instant_lock_callback: Option, + free_core_instant_lock_callback: Option, /// Semantic capability declaration supplied separately from the callback /// vtable by the additive manager-create API. Keeping this out of /// `PersistenceCallbacks` preserves that established C struct's size. @@ -1711,11 +1762,83 @@ impl FFIPersister { load_core_history_for_accounting_free_callback: extensions .load_core_history_for_accounting_free, persist_core_history_accounted_callback: extensions.persist_core_history_accounted, + persist_core_instant_lock_callback: extensions.persist_core_instant_lock, + load_core_instant_lock_callback: extensions.load_core_instant_lock, + free_core_instant_lock_callback: extensions.free_core_instant_lock, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), } } + fn load_core_instant_locks( + &self, + wallet_id: &WalletId, + history: &mut RecordedHistory, + ) -> Result<(), PersistenceError> { + let (load, free) = match ( + self.load_core_instant_lock_callback, + self.free_core_instant_lock_callback, + ) { + (None, None) => return Ok(()), + (Some(load), Some(free)) => (load, free), + _ => { + return Err(PersistenceError::backend( + "raw InstantLock load/free callbacks must be provided together", + )) + } + }; + struct Guard { + context: *mut c_void, + bytes: *const u8, + len: usize, + free: FreeCoreInstantLockFn, + } + impl Drop for Guard { + fn drop(&mut self) { + if !self.bytes.is_null() { + unsafe { (self.free)(self.context, self.bytes, self.len) }; + } + } + } + for record in &history.transactions { + let mut bytes = std::ptr::null(); + let mut len = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + AsRef::<[u8]>::as_ref(&record.txid).as_ptr(), + &mut bytes, + &mut len, + ) + }; + let _guard = Guard { + context: self.callbacks.context, + bytes, + len, + free, + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "raw InstantLock load failed".to_owned(), + )); + } + if bytes.is_null() && len == 0 { + continue; + } + if bytes.is_null() || len == 0 || len > dashcore::consensus::encode::MAX_VEC_SIZE { + return Err(PersistenceError::backend( + "raw InstantLock loader returned an invalid or oversized buffer", + )); + } + let raw = unsafe { slice::from_raw_parts(bytes, len) }; + let lock = dashcore::consensus::deserialize(raw).map_err(PersistenceError::backend)?; + history.instant_locks.insert(record.txid, lock); + } + Ok(()) + } + /// Narrow a round's failure kind to what the caller may safely act on: /// `Transient` survives only where /// [`Self::store_transient_is_reissuable`] holds, since losing a retry @@ -2689,6 +2812,32 @@ impl PlatformWalletPersistence for FFIPersister { } } + if let Some(callback) = self.persist_core_instant_lock_callback { + let mut locks = core_cs.instant_locks_for_non_final_records.clone(); + for record in &core_cs.records { + if let key_wallet::transaction_checking::TransactionContext::InstantSend(lock) = + &record.context + { + locks.entry(record.txid).or_insert_with(|| lock.clone()); + } + } + for (txid, lock) in locks { + let bytes = dashcore::consensus::serialize(&lock); + let rc = unsafe { + callback( + self.callbacks.context, + wallet_id.as_ptr(), + AsRef::<[u8]>::as_ref(&txid).as_ptr(), + bytes.as_ptr(), + bytes.len(), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + } + // The numeric chainlock height rides its own size-negotiated // extension slot for the same layout reason the sweeps below do: // the bincode blob on the changeset struct is opaque to a @@ -3885,6 +4034,7 @@ impl PlatformWalletPersistence for FFIPersister { )); } let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + self.load_core_instant_locks(&entry.wallet_id, &mut wallet_state.recorded_history)?; if let Some(cb) = self .tracked_masternodes_callbacks .load_asset_lock_operation_id @@ -5840,9 +5990,8 @@ fn order_unconfirmed_outgoing( /// 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. +/// InstantSend observations nominate required evidence. Their provisional +/// mempool context is never replayed without the owner's verified lock gate. fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistory { use dashcore::consensus::deserialize; use dashcore::hashes::Hash; @@ -5861,6 +6010,7 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor } }; let mut transactions = Vec::with_capacity(recs.len()); + let mut required_instant_locks = std::collections::BTreeSet::new(); let mut dropped_decode = 0usize; let mut dropped_identity = 0usize; let mut dropped_context = 0usize; @@ -5882,7 +6032,11 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor } }; let context = match rec.context { - TX_CONTEXT_RAW_MEMPOOL | TX_CONTEXT_RAW_INSTANT_SEND => TransactionContext::Mempool, + TX_CONTEXT_RAW_MEMPOOL => TransactionContext::Mempool, + TX_CONTEXT_RAW_INSTANT_SEND => { + required_instant_locks.insert(transaction.txid()); + TransactionContext::Mempool + } ctx @ (TX_CONTEXT_RAW_IN_BLOCK | TX_CONTEXT_RAW_IN_CHAIN_LOCKED_BLOCK) => { let mut info = BlockInfo::new( rec.block_height, @@ -5937,6 +6091,7 @@ fn decode_recorded_transactions(entry: &WalletRestoreEntryFFI) -> RecordedHistor ); } RecordedHistory { + required_instant_locks, owned_prevouts: Default::default(), transactions, instant_locks: BTreeMap::new(), @@ -11305,6 +11460,31 @@ mod tests { PersistenceCallbacksExtension, on_persist_core_history_accounted_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_instant_lock_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_core_instant_lock_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_core_instant_lock_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_core_instant_lock_fn) + + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_instant_lock_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_core_instant_lock_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -14411,7 +14591,7 @@ mod tests { assert!(matches!(contexts[0], TransactionContext::Mempool)); assert!( matches!(contexts[1], TransactionContext::Mempool), - "InstantSend rows carry no lock and replay as mempool" + "the provisional context cannot grant finality without the required proof" ); assert!(matches!( contexts[2], @@ -14428,6 +14608,162 @@ mod tests { ); assert_eq!(history.transactions[3].stored_direction, None); assert!(history.instant_locks.is_empty()); + assert_eq!(history.required_instant_locks, [tx(2).txid()].into()); + } + + #[test] + fn should_round_trip_raw_instant_lock_sidecars_and_free_failed_reads() { + use platform_wallet::changeset::CoreChangeSet; + #[derive(Default)] + struct Host { + bytes: Vec, + frees: usize, + fail_load: bool, + } + unsafe extern "C" fn persist( + ctx: *mut c_void, + _: *const u8, + _: *const u8, + bytes: *const u8, + len: usize, + ) -> i32 { + let host = &mut *ctx.cast::(); + host.bytes = slice::from_raw_parts(bytes, len).to_vec(); + 0 + } + unsafe extern "C" fn load( + ctx: *mut c_void, + _: *const u8, + _: *const u8, + bytes: *mut *const u8, + len: *mut usize, + ) -> i32 { + let host = &mut *ctx.cast::(); + let copy = host.bytes.clone().into_boxed_slice(); + *len = copy.len(); + *bytes = Box::into_raw(copy).cast::(); + if host.fail_load { + 1 + } else { + 0 + } + } + unsafe extern "C" fn free(ctx: *mut c_void, bytes: *const u8, len: usize) { + let host = &mut *ctx.cast::(); + host.frees += 1; + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + bytes.cast_mut(), + len, + ))); + } + let ctx = TestWalletContext::new_random(); + let transaction = pay( + dashcore::OutPoint::null(), + vec![TxOut { + value: 1_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }], + ); + let txid = transaction.txid(); + // Transport bytes are deliberately unverified; loading them cannot mint evidence. + let lock = dashcore::InstantLock { + txid, + ..Default::default() + }; + let mut host = Host::default(); + let mut persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&mut host as *mut Host).cast(), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + persist_core_instant_lock: Some(persist), + load_core_instant_lock: Some(load), + free_core_instant_lock: Some(free), + ..Default::default() + }, + ); + persister + .store( + ctx.wallet.wallet_id, + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + instant_locks_for_non_final_records: [(txid, lock.clone())].into(), + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(host.bytes, dashcore::consensus::serialize(&lock)); + let mut row = RecordedRow::new(&transaction, TX_CONTEXT_RAW_INSTANT_SEND, 0, None); + let ffi = row.ffi(); + let entry = WalletRestoreEntryFFI { + recorded_transactions: &ffi, + recorded_transactions_count: 1, + ..Default::default() + }; + let mut history = decode_recorded_transactions(&entry); + persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .unwrap(); + assert_eq!(history.instant_locks[&txid], lock); + assert_eq!(host.frees, 1); + host.fail_load = true; + assert!(persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .is_err()); + assert_eq!( + host.frees, 2, + "a callback error must still release its allocation" + ); + host.fail_load = false; + host.bytes = vec![1, 2, 3]; + assert!(persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .is_err()); + assert_eq!( + host.frees, 3, + "decode failure must still release the host buffer" + ); + persister.free_core_instant_lock_callback = None; + assert!(persister + .load_core_instant_locks(&ctx.wallet.wallet_id, &mut history) + .is_err()); + assert_eq!( + host.frees, 3, + "an incomplete callback pair must fail before allocation" + ); + } + + #[tokio::test] + async fn should_fail_restore_instead_of_downgrading_a_stored_instant_send_observation() { + use platform_wallet::manager::history_replay::{ + replay_recorded_history, HistoryReplayError, + }; + let ctx = TestWalletContext::new_random(); + let transaction = pay( + dashcore::OutPoint::null(), + vec![TxOut { + value: 1_000, + script_pubkey: ctx.receive_address.script_pubkey(), + }], + ); + let mut row = RecordedRow::new(&transaction, TX_CONTEXT_RAW_INSTANT_SEND, 0, None); + let ffi = row.ffi(); + let entry = WalletRestoreEntryFFI { + recorded_transactions: &ffi, + recorded_transactions_count: 1, + ..Default::default() + }; + let history = decode_recorded_transactions(&entry); + let mut wallet = ctx.wallet; + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + assert!( + matches!(replay_recorded_history(&mut info, &mut wallet, history).await, + Err(HistoryReplayError::MissingFinalityEvidence { txids }) if txids.contains(&transaction.txid())) + ); } #[test] 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 b7b39aa1e66..815d32ca6ec 100644 --- a/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs +++ b/packages/rs-platform-wallet-ffi/src/wallet_restore_types.rs @@ -634,7 +634,7 @@ pub struct RecordedTransactionRestoreFFI { 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); + /// chain-locked block. `1` requires SPV-verified lock evidence before replay; /// any other value drops the record. pub context: u32, /// Block fields, read only for contexts `2` and `3`. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 3ba3352f49c..9b703ab8884 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -2021,6 +2021,7 @@ fn load_wallet_snapshot( // field existed. unconfirmed_outgoing_txs: Vec::new(), recorded_history: platform_wallet::changeset::RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: history_replay::load_prevouts(conn, &wallet_id) .map_err(PersistenceError::from)?, transactions: core_state.records.into_iter().map(Into::into).collect(), diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index e15f82c1958..8cd04b96f68 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -4,7 +4,7 @@ //! [`history_replay`](crate::manager::history_replay); this module only //! carries the persister-agnostic input shape. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use super::ClientWalletStartState; use dash_spv::VerifiedInstantLock; @@ -40,6 +40,8 @@ pub struct ReplayEvidence { /// already attached to the restored wallet, which it puts back afterwards. #[derive(Debug, Clone, Default)] pub struct RecordedHistory { + /// Persisted InstantSend observations requiring proof, even when the host lost the payload. + pub required_instant_locks: BTreeSet, /// Stored coin material, including spent rows; ownership is checked by the wallet. pub owned_prevouts: BTreeMap, /// Stored transactions, in any order; the replay orders them. diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 36b1e03f386..72a8b03de6c 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -319,6 +319,23 @@ pub async fn replay_recorded_history_with_evidence( evidence: &ReplayEvidence, ) -> Result { let mut deferred = BTreeSet::new(); + let mut history = history; + deferred.extend(history.required_instant_locks.iter().copied()); + for stored in &history.transactions { + if history.required_instant_locks.contains(&stored.txid) { + if let Some(proof) = evidence.instant_locks.get(&stored.txid).filter(|proof| { + proof.network() == wallet_info.network() + && proof.txid() == stored.txid + && proof.matches_transaction(&stored.transaction) + }) { + deferred.remove(&stored.txid); + history + .instant_locks + .entry(stored.txid) + .or_insert_with(|| proof.instant_lock().clone()); + } + } + } for stored in &history.transactions { let locks = history @@ -348,6 +365,7 @@ pub async fn replay_recorded_history_with_evidence( return Err(HistoryReplayError::MissingFinalityEvidence { txids: deferred }); } let RecordedHistory { + required_instant_locks: _, mut owned_prevouts, mut transactions, instant_locks, @@ -1421,6 +1439,7 @@ mod tests { instant_locks: BTreeMap, ) -> RecordedHistory { RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: records.into_iter().map(StoredTransaction::from).collect(), instant_locks, @@ -1609,6 +1628,7 @@ mod tests { 100, )); let replay = |transactions| RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions, instant_locks: BTreeMap::new(), @@ -1791,6 +1811,29 @@ mod tests { "unsafe stored arithmetic cannot let a plain block defeat verified InstantSend" ); + let mut observation = raw.clone(); + observation.required_instant_locks.insert(locked.txid); + observation.instant_locks.clear(); + assert!(matches!( + replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + observation.clone(), + &ReplayEvidence::default() + ) + .await, + Err(HistoryReplayError::MissingFinalityEvidence { .. }) + )); + let ready = replay_recorded_history_with_evidence( + &mut original.clone(), + &mut wallet.clone(), + observation, + &evidence, + ) + .await + .unwrap(); + assert!(!ready.defeated.contains(&locked.txid)); + for change_body in [false, true] { let mut changed = raw.clone(); if change_body { @@ -2001,6 +2044,7 @@ mod tests { ..spender.clone() }; let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![without_ownership], instant_locks: BTreeMap::new(), @@ -2051,6 +2095,7 @@ mod tests { ); let mut info = restored; let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![StoredTransaction { owned_inputs: Vec::new(), @@ -2117,6 +2162,7 @@ mod tests { Utxo::new(spent, funding.output[0].clone(), address, 100, false), ); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![StoredTransaction { context: TransactionContext::Mempool, @@ -2750,6 +2796,7 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: stored, instant_locks: BTreeMap::new(), @@ -2884,6 +2931,7 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: history, instant_locks: BTreeMap::new(), @@ -2927,6 +2975,7 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: history, instant_locks: BTreeMap::new(), @@ -3006,6 +3055,7 @@ mod tests { &mut restored, &mut wallet, RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![loser], instant_locks: BTreeMap::new(), @@ -3200,6 +3250,7 @@ mod tests { }; let block = BlockInfo::new(101, BlockHash::from_byte_array([101; 32]), 101); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![ stored(&winner, TransactionContext::InBlock(block)), @@ -3520,6 +3571,7 @@ mod tests { }; let block = BlockInfo::new(100, BlockHash::from_byte_array([82; 32]), 100); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![ stored(funding.clone(), TransactionContext::InBlock(block)), @@ -3649,6 +3701,7 @@ mod tests { ); } let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: unsafe_spends .iter() @@ -3859,6 +3912,7 @@ mod tests { } } let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: parents .iter() @@ -3968,6 +4022,7 @@ mod tests { .utxos .insert(coin, parked); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: funding.txid(), @@ -4061,6 +4116,7 @@ mod tests { .utxos .insert(coin, stored_coin); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: funding.txid(), diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 06cf3d11544..fcccd517ee9 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1481,6 +1481,7 @@ mod idempotent_load_tests { let wallet_id = ctx.wallet.compute_wallet_id(); let fixture = InstantLockFixture::new(&funding).unwrap(); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: funding.txid(), @@ -1630,6 +1631,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![stored(spend), stored(funding)], instant_locks: BTreeMap::new(), @@ -1715,6 +1717,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions, instant_locks: BTreeMap::new(), @@ -1853,6 +1856,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), // The funding's row never left the mempool context. transactions: vec![StoredTransaction { @@ -2203,6 +2207,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![ stored(&asset_lock, TransactionContext::Mempool), @@ -2365,6 +2370,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![ stored(&asset_lock, TransactionContext::Mempool), @@ -2464,6 +2470,7 @@ mod idempotent_load_tests { managed: ManagedWalletInfo::from_wallet(&ctx.wallet, 0), wallet: ctx.wallet, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![StoredTransaction { txid: asset_lock.txid(), @@ -2567,6 +2574,7 @@ mod idempotent_load_tests { wallet: ctx.wallet, managed: projection, history: RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![stored(unsafe_spend), stored(funding)], instant_locks: BTreeMap::new(), diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index 2005ff9336d..ea7986e7cd5 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -4017,6 +4017,7 @@ mod tests { ]; let spender = spending(contested, 1); let history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: vec![ stored(spender.clone(), chain_locked_at(100)), @@ -4122,6 +4123,7 @@ mod tests { ), ]; let mut history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: [ &matching, @@ -4211,6 +4213,7 @@ mod tests { &foreign, ]; let mut history = RecordedHistory { + required_instant_locks: Default::default(), owned_prevouts: Default::default(), transactions: unconfirmed .map(|transaction| stored(transaction, TransactionContext::Mempool)) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index 0395d0115ac..7702a85c56e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -95,7 +95,8 @@ public enum DashModelContainer { PersistentMasternode.self, PersistentTrackedMasternode.self, PersistentIdentityBalanceMetadata.self, - PersistentTransactionAccounting.self + PersistentTransactionAccounting.self, + PersistentCoreInstantLock.self ] } @@ -404,7 +405,7 @@ public enum DashSchemaV4: VersionedSchema { } /// Current schema: per-wallet transaction accounting (`PersistentTransactionAccounting`) -/// and the wallet's accounting-backfill marker. +/// the wallet's accounting-backfill marker, and raw InstantLock sidecars. public enum DashSchemaV5: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift new file mode 100644 index 00000000000..bfe422e0d52 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift @@ -0,0 +1,21 @@ +import Foundation +import SwiftData + +/// Raw consensus bytes for Rust's load-time SPV verification; storage grants no finality. +@Model +public final class PersistentCoreInstantLock { + #Unique([\.networkRaw, \.walletId, \.txid]) + #Index([\.walletId]) + + public var networkRaw: UInt32 + public var walletId: Data + public var txid: Data + public var consensusBytes: Data + + public init(networkRaw: UInt32, walletId: Data, txid: Data, consensusBytes: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.txid = txid + self.consensusBytes = consensusBytes + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 7a767acb723..1174459959d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3308,6 +3308,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_core_history_for_accounting_free_fn = loadCoreHistoryForAccountingFreeCallback extensionCallbacks.on_persist_core_history_accounted_fn = persistCoreHistoryAccountedCallback + extensionCallbacks.on_persist_core_instant_lock_fn = persistCoreInstantLockCallback + extensionCallbacks.on_load_core_instant_lock_fn = loadCoreInstantLockCallback + extensionCallbacks.on_load_core_instant_lock_free_fn = freeCoreInstantLockCallback return extensionCallbacks } @@ -5077,6 +5080,44 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (written, skipped, failed) } + // MARK: - Raw InstantLock storage + + private func coreInstantLockDescriptor(walletId: Data, txid: Data) throws + -> FetchDescriptor { + guard let network = self.network ?? walletNetwork(walletId: walletId) else { + throw PlatformWalletError.walletOperation("Raw InstantLock storage requires the wallet network") + } + let networkRaw = network.rawValue + return FetchDescriptor(predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.txid == txid + }) + } + + func persistCoreInstantLock(walletId: Data, txid: Data, bytes: Data) throws { + try onQueue { + guard inChangeset else { + throw PlatformWalletError.walletOperation("Raw InstantLock storage requires a changeset") + } + let descriptor = try coreInstantLockDescriptor(walletId: walletId, txid: txid) + if let row = try backgroundContext.fetch(descriptor).first { + row.consensusBytes = bytes + } else { + guard let network = self.network ?? walletNetwork(walletId: walletId) else { + throw PlatformWalletError.walletOperation("Raw InstantLock storage requires the wallet network") + } + backgroundContext.insert(PersistentCoreInstantLock( + networkRaw: network.rawValue, walletId: walletId, txid: txid, consensusBytes: bytes)) + } + } + } + + func loadCoreInstantLock(walletId: Data, txid: Data) throws -> Data? { + try onQueue { + try backgroundContext.fetch(coreInstantLockDescriptor(walletId: walletId, txid: txid)) + .first?.consensusBytes + } + } + // MARK: - Identity balance freshness (additive persistence extension) private func balanceMetadataDescriptor(walletId: Data, identityId: Data) @@ -6666,6 +6707,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(row) } + let rawLocks: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + rawLocks = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + rawLocks = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + for row in try backgroundContext.fetch(rawLocks) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } + if let walletRow = walletRow { // The departing wallet's accounting rows go with it; a // shared transaction keeps the other wallets' rows. @@ -12325,3 +12379,43 @@ private func loadIdentityBalanceBlockTimeCallback( return 0 } catch { return -1 } } + +private func persistCoreInstantLockCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, txid: UnsafePointer?, + bytes: UnsafePointer?, len: UInt +) -> Int32 { + guard let context, let walletId, let txid, let bytes, len > 0 else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistCoreInstantLock(walletId: Data(bytes: walletId, count: 32), + txid: Data(bytes: txid, count: 32), + bytes: Data(bytes: bytes, count: Int(len))) + return 0 + } catch { return -1 } +} + +private func loadCoreInstantLockCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, txid: UnsafePointer?, + outBytes: UnsafeMutablePointer?>?, outLen: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let txid, let outBytes, let outLen else { return -1 } + outBytes.pointee = nil + outLen.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + guard let bytes = try handler.loadCoreInstantLock(walletId: Data(bytes: walletId, count: 32), + txid: Data(bytes: txid, count: 32)) else { return 0 } + guard !bytes.isEmpty else { return -1 } + let buffer = UnsafeMutablePointer.allocate(capacity: bytes.count) + bytes.copyBytes(to: buffer, count: bytes.count) + outBytes.pointee = UnsafePointer(buffer) + outLen.pointee = UInt(bytes.count) + return 0 + } catch { return -1 } +} + +private func freeCoreInstantLockCallback( + context: UnsafeMutableRawPointer?, bytes: UnsafePointer?, len: UInt +) { + if let bytes { UnsafeMutablePointer(mutating: bytes).deallocate() } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift new file mode 100644 index 00000000000..59b549f6ba1 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/CoreInstantLockPersistenceTests.swift @@ -0,0 +1,56 @@ +import XCTest +import SwiftData +@testable import SwiftDashSDK + +final class CoreInstantLockPersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let txid = Data(repeating: 7, count: 32) + + // TODO(macOS-validation): run these SwiftData rollback/reopen tests on an Apple SDK runner. + func testShouldCommitRawLockAndRollbackReplacement() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([1, 2, 3])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([4])) + XCTAssertFalse(handler.endChangeset(walletId: walletId, success: false)) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([1, 2, 3])) + XCTAssertNil(try handler.loadCoreInstantLock(walletId: Data(repeating: 43, count: 32), txid: txid)) + let mainnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + XCTAssertNil(try mainnet.loadCoreInstantLock(walletId: walletId, txid: txid)) + XCTAssertThrowsError(try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([5]))) + } + + func testShouldDeleteOnlyTheSelectedWalletNetworkSidecar() throws { + let container = try DashModelContainer.createInMemory() + let testnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + let mainnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + for handler in [testnet, mainnet] { + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([1])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + try testnet.deleteWalletData(walletId: walletId) + XCTAssertNil(try testnet.loadCoreInstantLock(walletId: walletId, txid: txid)) + XCTAssertEqual(try mainnet.loadCoreInstantLock(walletId: walletId, txid: txid), Data([1])) + } + + func testShouldPreserveRawLockAcrossStoreReopen() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("wallet.store") + try autoreleasepool { + let container = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([9, 8, 7])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + let reopened = try DashModelContainer.create(url: url) + let handler = PlatformWalletPersistenceHandler(modelContainer: reopened, network: .testnet) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([9, 8, 7])) + } +} From de296284bee930e285dd0e56797d873d53191014 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:35:15 +0000 Subject: [PATCH 60/84] fix(wallet-storage): validate transaction bodies during bulk history load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the existing record identity decoder before applying typed-column drift policy. Reject body/identifier corruption under Strict and Recovery while preserving valid-body column-drift behavior. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/schema/core_state.rs | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 657b661c052..dd59d2c7b12 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -945,7 +945,7 @@ pub fn load_state( if let Some(record_blob_len) = row.get::<_, Option>(3)? { blob::check_size(record_blob_len)?; let payload: Vec = row.get(4)?; - let record = blob::decode::(&payload)?; + let record = decode_transaction_record(&payload)?; effective_txid = record.txid; effective_height = record.block_info().map(|block_info| block_info.height()); if let Err(mismatch) = @@ -2254,6 +2254,65 @@ mod tests { assert!(!loaded.is_confirmed); } + fn assert_bulk_load_rejects_body_identity_mismatch(ctx: &LoadCtx) { + let wallet_id = [0x72; 32]; + let conn = wallet_db(&wallet_id); + let record = transaction_record(TransactionContext::Mempool); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, record_blob, finalized) \ + VALUES (?1, ?2, ?3, 0)", + params![ + wallet_id.as_slice(), + record.txid.as_byte_array().as_slice(), + blob::encode(&record).unwrap() + ], + ) + .unwrap(); + let (loaded, _) = load_state(&conn, &wallet_id, dashcore::Network::Testnet, ctx).unwrap(); + assert_eq!(loaded.records[0].txid, record.txid); + + for change_body in [true, false] { + let mut tampered = record.clone(); + if change_body { + tampered.transaction.lock_time += 1; + } else { + tampered.txid = Txid::from_byte_array([0x73; 32]); + } + let payload = blob::encode(&tampered).unwrap(); + conn.execute( + "UPDATE core_transactions SET txid = ?1, record_blob = ?2 WHERE wallet_id = ?3", + params![ + tampered.txid.as_byte_array().as_slice(), + &payload, + wallet_id.as_slice() + ], + ) + .unwrap(); + assert!(matches!( + load_state(&conn, &wallet_id, dashcore::Network::Testnet, ctx), + Err(WalletStorageError::BlobDecode { .. }) + )); + let stored: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1", + params![wallet_id.as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, payload, "a failed read must not rewrite history"); + } + } + + #[test] + fn should_reject_bulk_history_body_identity_mismatch_in_strict_mode() { + assert_bulk_load_rejects_body_identity_mismatch(&LoadCtx::strict()); + } + + #[test] + fn should_reject_bulk_history_body_identity_mismatch_in_recovery_mode() { + assert_bulk_load_rejects_body_identity_mismatch(&LoadCtx::recovery()); + } + #[test] fn load_state_tolerates_transaction_blob_txid_drift_in_recovery_without_repairing() { let mut conn = rusqlite::Connection::open_in_memory().unwrap(); From 236867c3fb9665970e6a3010ae0845868a63b6c2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:53:04 +0000 Subject: [PATCH 61/84] test(wallet-storage): preserve historical identities in bulk reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise the inherited old-writer AssetUnlock fixture through bulk load under Strict and Recovery without rekeying or rewriting record bytes. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/schema/core_state.rs | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index d738df8a8be..8611432337f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -2443,6 +2443,43 @@ mod tests { assert!(!loaded.is_confirmed); } + #[test] + fn should_bulk_load_historical_asset_unlock_without_rekeying_under_both_policies() { + let wallet_id = [0x78; 32]; + let conn = wallet_db(&wallet_id); + let rows = historical_asset_unlock_rows(); + assert_ne!(rows[0].0, rows[0].2.transaction.txid()); + for (txid, bytes, _) in &rows { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 0, ?3)", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice(), bytes], + ) + .unwrap(); + } + for ctx in [LoadCtx::strict(), LoadCtx::recovery()] { + let (state, _) = + load_state(&conn, &wallet_id, dashcore::Network::Testnet, &ctx).unwrap(); + assert_eq!(state.records.len(), rows.len()); + for (txid, bytes, original) in &rows { + let loaded = state.records.iter().find(|r| r.txid == *txid).unwrap(); + assert_eq!(loaded.transaction, original.transaction); + assert_eq!(blob::encode(loaded).unwrap(), *bytes); + let stored: Vec = conn + .query_row( + "SELECT record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stored, *bytes, + "bulk read preserves historical identity and bytes" + ); + } + } + } + fn assert_bulk_load_rejects_body_identity_mismatch(ctx: &LoadCtx) { let wallet_id = [0x72; 32]; let conn = wallet_db(&wallet_id); From 20bc7a8bc898c56a51efce1ac5b81eb7ca17935b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:58:26 +0000 Subject: [PATCH 62/84] feat(wallet): preserve required finality evidence through history rescan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep V020 accounting additive and run the Core history rescan in V021. Preserve all Core history for wallets with InstantSend observations while rewinding coverage, and retain the original accounting archive. Require authenticated IS only where the common conflict planner needs it. Chain-final records and definite losers do not depend on archived IS; a descendant's own observation remains protected. Terminal operation proofs do not reactivate history or discard their durable claims. Validated shared replay, V020/V021 migration boundaries, original rescan regressions, and scoped Clippy. Atomic FFI adapter remains follow-up work. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../migrations/V021__core_history_rescan.rs | 8 + .../src/sqlite/migrations.rs | 6 + .../src/sqlite/migrations/v020.rs | 22 +- .../src/sqlite/migrations/v021.rs | 151 ++++++ .../src/sqlite/schema/asset_locks.rs | 8 + .../tests/integration/main.rs | 1 + .../tests/integration/sqlite_core_rescan.rs | 28 +- .../integration/sqlite_replay_migration.rs | 457 ++++++++++++++++++ .../src/changeset/recorded_history.rs | 2 +- .../src/manager/history_replay.rs | 223 ++++++++- .../src/wallet/asset_lock/sync/recovery.rs | 46 ++ 11 files changed, 920 insertions(+), 32 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs create mode 100644 packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs diff --git a/packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs b/packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs new file mode 100644 index 00000000000..9335a6adc00 --- /dev/null +++ b/packages/rs-platform-wallet-storage/migrations/V021__core_history_rescan.rs @@ -0,0 +1,8 @@ +//! Access paths for the typed, proof-preserving Core history rescan. + +pub fn migration() -> String { + "CREATE INDEX idx_core_utxos_funding ON core_utxos(wallet_id, substr(outpoint, 2, 32)); + CREATE INDEX idx_core_utxos_claimant ON core_utxos(wallet_id, spent_in_txid) + WHERE spent_in_txid IS NOT NULL;" + .to_owned() +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs index ace5352dd61..3a4203e7b04 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations.rs @@ -12,6 +12,7 @@ use refinery_core::error::WrapMigrationError; mod legacy_v008; mod v020; +mod v021; // Generates a `migrations` module with `runner()`; path is relative to // the crate root. @@ -53,6 +54,7 @@ fn run_with_runner( registration_sql: hook_sql(8), pool_sql: hook_sql(11), accounting_sql: hook_sql(20), + rescan_sql: hook_sql(21), policy, }; // Grouped reports never claim that rolled-back migrations were applied. @@ -69,6 +71,7 @@ struct MigrationTransaction<'conn> { registration_sql: String, pool_sql: String, accounting_sql: String, + rescan_sql: String, policy: LoadPolicy, } @@ -88,6 +91,8 @@ impl refinery_core::traits::sync::Transaction for MigrationTransaction<'_> { legacy_v008::convert_pools(&self.tx)?; } else if query == self.accounting_sql { v020::backfill_accounting(&self.tx, self.policy)?; + } else if query == self.rescan_sql { + v021::rescan(&self.tx, self.policy)?; } count += 1; } @@ -439,6 +444,7 @@ pub fn embedded_migrations_sql() -> Vec { pub(crate) fn rewind_v020(conn: &rusqlite::Connection) { conn.execute_batch( "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DROP INDEX IF EXISTS idx_core_utxos_funding; DROP INDEX IF EXISTS idx_core_utxos_claimant; \ DELETE FROM refinery_schema_history WHERE version >= 20;", ) .unwrap(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs index b1b7cc9e863..0477fe190d0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v020.rs @@ -117,7 +117,12 @@ mod tests { let mut conn = wallet_db(&wallet); conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, 200, 1, ?3)", params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), bytes]).unwrap(); rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(20)), + LoadPolicy::Strict, + ) + .unwrap(); let remaining: (i64, i64) = conn.query_row("SELECT (SELECT count(*) FROM core_transactions), (SELECT count(*) FROM core_transaction_inputs)", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); assert_eq!(remaining.0, 1, "{name}: mined history must remain"); assert_eq!(remaining.1, record.transaction.input.len() as i64); @@ -210,7 +215,12 @@ mod tests { let mut conn = wallet_db(&wallet); conn.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized, record_blob) VALUES (?1, ?2, NULL, 0, ?3)", params![wallet.as_slice(), txid, bytes]).unwrap(); rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(20)), + LoadPolicy::Strict, + ) + .unwrap(); let indexed: (Vec, Vec) = conn .query_row( "SELECT txid, outpoint FROM core_transaction_inputs", @@ -247,8 +257,12 @@ mod tests { .expect("Recovery skips oversized bodies"); } rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Recovery) - .expect("Recovery completes V020"); + migrations::run_with_runner( + &mut conn, + migrations::migrations::runner().set_target(refinery::Target::Version(20)), + LoadPolicy::Recovery, + ) + .expect("Recovery completes V020"); let count: i64 = conn .query_row("SELECT count(*) FROM core_transaction_inputs", [], |r| { r.get(0) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs new file mode 100644 index 00000000000..6bd9a2655f2 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs @@ -0,0 +1,151 @@ +//! Rewind coverage without discarding any wallet's unresolved InstantSend evidence. + +use key_wallet::transaction_checking::TransactionContext; +use rusqlite::{params, Transaction}; + +use crate::sqlite::config::LoadPolicy; +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::schema::{asset_locks, blob, core_state, wallets}; +use crate::sqlite::util::safe_cast::{i64_to_u32, u64_to_i64}; + +pub(super) fn rescan(tx: &Transaction<'_>, policy: LoadPolicy) -> Result<(), WalletStorageError> { + rewind_sync_heights(tx, policy)?; + tx.execute_batch( + "CREATE TEMP TABLE core_rescan_preserved_wallets (wallet_id BLOB PRIMARY KEY)", + )?; + for wallet_id in wallets::list_ids(tx)? { + let has_raw: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM core_instant_locks WHERE wallet_id=?1) + OR EXISTS(SELECT 1 FROM asset_locks WHERE wallet_id=?1 AND status='is_locked')", + [wallet_id.as_slice()], + |row| row.get(0), + )?; + // These bytes preserve the entire conflict graph; they do not grant finality. + if has_raw + || has_record_observation(tx, &wallet_id, policy)? + || has_asset_observation(tx, &wallet_id, policy)? + { + tx.execute( + "INSERT INTO core_rescan_preserved_wallets(wallet_id) VALUES (?1)", + [wallet_id.as_slice()], + )?; + } + } + tx.execute_batch( + "DELETE FROM core_utxos WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) AND NOT ( + EXISTS (SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_utxos.wallet_id + AND t.txid = substr(core_utxos.outpoint, 2, 32) AND t.height IS NULL) + OR (spent = 1 AND winner_mined_height IS NULL AND NOT EXISTS ( + SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_utxos.wallet_id + AND t.txid = core_utxos.spent_in_txid AND t.height IS NOT NULL)) + ); + DELETE FROM core_instant_locks WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) + AND NOT EXISTS (SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_instant_locks.wallet_id + AND t.txid = core_instant_locks.txid AND t.height IS NULL); + DELETE FROM core_transaction_inputs WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) + AND EXISTS (SELECT 1 FROM core_transactions t WHERE t.wallet_id = core_transaction_inputs.wallet_id + AND t.txid = core_transaction_inputs.txid AND t.height IS NOT NULL); + DELETE FROM core_transactions WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) + AND height IS NOT NULL AND NOT EXISTS (SELECT 1 FROM core_utxos u + WHERE u.wallet_id = core_transactions.wallet_id AND substr(u.outpoint, 2, 32) = core_transactions.txid); + UPDATE core_transactions SET block_hash = NULL, block_time = NULL, finalized = 0, record_blob = NULL + WHERE wallet_id NOT IN (SELECT wallet_id FROM core_rescan_preserved_wallets) AND height IS NOT NULL; + DROP TABLE core_rescan_preserved_wallets;", + )?; + Ok(()) +} + +fn has_record_observation( + tx: &Transaction<'_>, + wallet_id: &[u8; 32], + policy: LoadPolicy, +) -> Result { + scan_observations(tx, wallet_id, policy, + "SELECT CASE WHEN typeof(record_blob)='blob' THEN length(record_blob) END, + CASE WHEN typeof(record_blob)='blob' AND length(record_blob)<=?2 THEN record_blob END + FROM core_transactions WHERE wallet_id=?1 AND record_blob IS NOT NULL", + |bytes| Ok(matches!(core_state::decode_transaction_record(bytes)?.context, TransactionContext::InstantSend(_)))) +} + +fn has_asset_observation( + tx: &Transaction<'_>, + wallet_id: &[u8; 32], + policy: LoadPolicy, +) -> Result { + scan_observations(tx, wallet_id, policy, + "SELECT CASE WHEN typeof(lifecycle_blob)='blob' THEN length(lifecycle_blob) END, + CASE WHEN typeof(lifecycle_blob)='blob' AND length(lifecycle_blob)<=?2 THEN lifecycle_blob END + FROM asset_locks WHERE wallet_id=?1", + asset_locks::has_instant_observation) +} + +fn scan_observations( + tx: &Transaction<'_>, + wallet_id: &[u8; 32], + policy: LoadPolicy, + query: &str, + decode: impl Fn(&[u8]) -> Result, +) -> Result { + let mut stmt = tx.prepare(query)?; + let mut rows = stmt.query(params![ + wallet_id.as_slice(), + u64_to_i64("record_blob.limit", blob::BLOB_SIZE_LIMIT_BYTES as u64)? + ])?; + while let Some(row) = rows.next()? { + let length = row.get::<_, Option>(0)?; + let decoded = match length { + Some(length) => blob::check_size(length).and_then(|()| { + let bytes: Vec = row.get(1)?; + decode(&bytes) + }), + None => Err(WalletStorageError::blob_decode( + "rescan evidence must be a BLOB", + )), + }; + match decoded { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(error) if policy == LoadPolicy::Recovery && !error.is_transient() => { + tracing::warn!(wallet_id = %hex::encode(wallet_id), %error, + "V021 preserved wallet history because finality evidence is unreadable"); + return Ok(true); + } + Err(error) => return Err(error), + } + } + Ok(false) +} + +/// Validate every birth height before V021 can discard mined history. +fn rewind_sync_heights(tx: &Transaction<'_>, policy: LoadPolicy) -> Result<(), WalletStorageError> { + let mut stmt = tx.prepare("SELECT rowid, birth_height FROM wallets")?; + let mut rows = stmt.query([])?; + let mut rewinds = Vec::new(); + while let Some(row) = rows.next()? { + let rowid: i64 = row.get(0)?; + let stored_birth = row.get::<_, i64>(1).map_err(WalletStorageError::from); + let (rewind, full_rescan) = match stored_birth + .and_then(|birth| i64_to_u32("wallets.birth_height", birth)) + { + Ok(birth) => (birth.saturating_sub(1), false), + Err(error) => match policy { + LoadPolicy::Strict => return Err(error), + LoadPolicy::Recovery => { + tracing::warn!(rowid, error_kind = error.error_kind_str(), %error, + "V021 rewound the sync watermark to zero for an invalid birth height; wallet metadata is unchanged"); + (0, true) + } + }, + }; + rewinds.push((rowid, rewind, full_rescan)); + } + for (rowid, rewind, full_rescan) in rewinds { + tx.execute( + "UPDATE core_sync_state SET synced_height = CASE WHEN ?3 THEN 0 + ELSE MIN(COALESCE(synced_height, ?2), ?2) END + WHERE wallet_id = (SELECT wallet_id FROM wallets WHERE rowid = ?1)", + params![rowid, rewind, full_rescan], + )?; + } + Ok(()) +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index 10c4d7ef50b..29719405e7e 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -128,6 +128,14 @@ impl AssetLockEntryWire { } } +/// Detect an observation for migration preservation without accepting its finality. +pub(crate) fn has_instant_observation(bytes: &[u8]) -> Result { + let wire: AssetLockEntryWire = blob::decode(bytes)?; + let entry = wire.into_entry()?; + Ok(entry.status == AssetLockStatus::InstantSendLocked + || matches!(entry.proof, Some(AssetLockProof::Instant(_)))) +} + pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 19fb2f4fa91..5df1d7b35c5 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -92,4 +92,5 @@ mod sqlite_clean_restart; mod sqlite_core_rescan; +mod sqlite_replay_migration; mod sqlite_shared_history_replay; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs index 0a6148068c0..60e58f746cb 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs @@ -1,6 +1,6 @@ #![cfg(feature = "sqlite")] -//! A wallet whose mined Core history was wiped (what V020 does to an older store) +//! A wallet whose mined Core history was wiped (what V021 does to an older store) //! rebuilds it by rescanning from its birth height. These tests drive that //! rescan through the real manager, event adapter, store and load replay. @@ -83,7 +83,7 @@ impl Chain { } } -/// A registered wallet with no Core history, as V020 leaves it, and its first +/// A registered wallet with no Core history, as V021 leaves it, and its first /// receive address. fn wiped_wallet(persister: &SqlitePersister) -> (WalletId, Address) { let (wallet, _, address) = wallet_with_receive_address(); @@ -186,7 +186,7 @@ async fn should_rebuild_accounting_when_a_wiped_wallet_rescans_from_birth() { } } -/// R3: a wallet upgraded from V018. V020 drops its mined Core history but keeps +/// R3: a wallet upgraded from V018. V021 drops its mined Core history but keeps /// `last_processed_height` and the ChainLock, both above that history, so the /// rescan replays every historical block below a ChainLock the loaded wallet /// already holds: key-wallet's ChainLocked path, not the ordinary block one. @@ -240,13 +240,13 @@ async fn should_rebuild_accounting_when_an_upgraded_wallet_rescans_below_its_cha } assert_eq!(sync_state(), (Some(500), Some(500), Some(400))); - // `rescan` opens through `SqlitePersister::open`, so V020 runs first. + // `rescan` opens through `SqlitePersister::open`, so V020 and V021 run first. let state = rescan(&path, wallet_id, &chain.blocks()).await; assert_eq!( sync_state(), (Some(500), Some(0), Some(400)), - "V020 rewinds only the filter checkpoint" + "V021 rewinds only the filter checkpoint" ); for tx in [&chain.funding, &chain.spend, &chain.lock] { assert!( @@ -469,7 +469,7 @@ fn manager_of( } /// R4: a V019 store holding unmined spends of a confirmed funding. A block -/// rescan redelivers the funding but never the spends, so V020 keeps their +/// rescan redelivers the funding but never the spends, so V021 keeps their /// evidence for the load to authenticate: an InstantSend-locked spend and a /// signed pending send keep their inputs reserved, a forged claim is /// released. Nothing is redelivered from the mempool or InstantSend. @@ -501,11 +501,7 @@ async fn should_keep_unmined_spends_reserved_when_an_upgraded_wallet_rescans() { spend_of(&[coin(1)], vec![pay(49_000, &foreign_address(6))]), ); let forged = spend_of(&[coin(2)], vec![pay(29_000, &foreign_address(5))]); - let lock = dashcore::InstantLock { - txid: locked.txid(), - inputs: vec![coin(0)], - ..Default::default() - }; + let (lock, evidence) = common::checked_instant_lock(&locked).await; let (manager, mut events) = manager_of(wallet, info); let persister = Arc::new(persister); mine(&manager, wallet_id, funding.clone(), 100).await; @@ -524,8 +520,9 @@ async fn should_keep_unmined_spends_reserved_when_an_upgraded_wallet_rescans() { let persister = Arc::new(SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap()); let loaded = persister - .load() + .load_with_replay(&evidence) .unwrap() + .state .wallets .remove(&wallet_id) .unwrap(); @@ -541,7 +538,7 @@ async fn should_keep_unmined_spends_reserved_when_an_upgraded_wallet_rescans() { .unwrap() .core_wallet, ); - let reloaded = persister.load().unwrap(); + let reloaded = persister.load_with_replay(&evidence).unwrap().state; let mut expected = vec![coin(2), OutPoint::new(locked.txid(), 1)]; expected.sort(); @@ -581,8 +578,9 @@ async fn should_keep_unmined_spends_reserved_when_an_upgraded_wallet_rescans() { drop((manager, events, persister)); let mined = SqlitePersister::open(SqlitePersisterConfig::new(&path)) .unwrap() - .load() - .unwrap(); + .load_with_replay(&evidence) + .unwrap() + .state; assert_eq!(row(&locked), Some((Some(101), true))); assert_eq!(row(&signed), Some((Some(102), true))); for (tx, value, net) in [(&locked, 100_000, -61_000), (&signed, 50_000, -50_000)] { diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs new file mode 100644 index 00000000000..ed871b85142 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_replay_migration.rs @@ -0,0 +1,457 @@ +use std::collections::BTreeMap; + +use dashcore::{hashes::Hash, OutPoint}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{CoreChangeSet, PlatformWalletPersistence}; +use platform_wallet::test_support::core_fixtures::{ + block, outside, pay, records_of, spend_of, stored_utxo, wallet_with_receive_address, +}; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; + +use crate::common::{authorize, checked_instant_lock, fresh_persister, registration}; + +fn rewind_to_v020(path: &std::path::Path) { + rusqlite::Connection::open(path) + .unwrap() + .execute_batch( + "DROP INDEX IF EXISTS idx_core_utxos_funding; + DROP INDEX IF EXISTS idx_core_utxos_claimant; + DELETE FROM refinery_schema_history WHERE version >= 21;", + ) + .unwrap(); +} + +#[tokio::test] +async fn should_rescan_plain_wallet_without_erasing_another_wallet_mined_instant_lock() { + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(131)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let locked = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ), + ); + records.extend(records_of(&mut info, &mut wallet, &locked, block(101)).await); + let (raw_lock, evidence) = checked_instant_lock(&locked).await; + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&locked, 0, address)], + instant_locks_for_non_final_records: BTreeMap::from([(locked.txid(), raw_lock)]), + synced_height: Some(150), + ..Default::default() + }), + ); + changes.wallet_metadata.as_mut().unwrap().birth_height = 80; + persister.store(wallet.wallet_id, changes).unwrap(); + + let (mut plain, mut plain_info, plain_address) = wallet_with_receive_address(); + let plain_tx = spend_of(&[outside(132)], vec![pay(10_000, &plain_address)]); + let plain_records = records_of(&mut plain_info, &mut plain, &plain_tx, block(100)).await; + let mut changes = registration( + &plain, + Some(CoreChangeSet { + records: plain_records, + new_utxos: vec![stored_utxo(&plain_tx, 0, plain_address)], + synced_height: Some(150), + ..Default::default() + }), + ); + changes.wallet_metadata.as_mut().unwrap().birth_height = 60; + persister.store(plain.wallet_id, changes).unwrap(); + drop(persister); + rewind_to_v020(&path); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert!( + reopened + .get_core_tx_record(plain.wallet_id, &plain_tx.txid()) + .unwrap() + .is_none(), + "the wallet without IS evidence must rebuild mined history by rescan" + ); + let conn = reopened.lock_conn_for_test(); + let height = |wallet: &[u8; 32]| { + conn.query_row( + "SELECT synced_height FROM core_sync_state WHERE wallet_id = ?1", + [wallet.as_slice()], + |row| row.get::<_, u32>(0), + ) + .unwrap() + }; + assert_eq!(height(&plain.wallet_id), 59); + assert_eq!(height(&wallet.wallet_id), 79); + let raw: Vec = conn + .query_row( + "SELECT islock_blob FROM core_instant_locks WHERE wallet_id=?1 AND txid=?2", + rusqlite::params![ + wallet.wallet_id.as_slice(), + locked.txid().as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + assert!(!raw.is_empty()); + drop(conn); + assert!(reopened + .load_with_replay(&ReplayEvidence::default()) + .is_err()); + assert!(reopened + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some()); + let mut loaded = reopened.load_with_replay(&evidence).unwrap(); + let mut state = loaded.state.wallets.remove(&wallet.wallet_id).unwrap(); + assert!(state + .wallet_info + .instant_send_locks() + .contains(&locked.txid())); + state.wallet_info.truncate_above(100).unwrap(); + assert!( + state.wallet_info.accounts.standard_bip44_accounts[&0] + .transactions() + .contains_key(&locked.txid()), + "the authenticated IS must survive removal of its mining block after migration" + ); +} + +#[tokio::test] +async fn should_preserve_context_and_terminal_tracked_proofs_without_raw_lock_rows() { + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::transaction_checking::TransactionContext; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::{AssetLockChangeSet, AssetLockEntry}; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + for tracked in [false, true] { + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(133)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let locked = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ), + ); + let (raw, evidence) = checked_instant_lock(&locked).await; + let context = if tracked { + TransactionContext::Mempool + } else { + TransactionContext::InstantSend(raw.clone()) + }; + records.extend(records_of(&mut info, &mut wallet, &locked, context).await); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + synced_height: Some(150), + ..Default::default() + }), + ); + if tracked { + let core = changes.core.as_mut().unwrap(); + core.spent_utxos.push(core.new_utxos[0].clone()); + let out_point = OutPoint::new(locked.txid(), 0); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: BTreeMap::from([( + out_point, + AssetLockEntry { + out_point, + transaction: locked.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status: AssetLockStatus::Consumed, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + raw, + locked.clone(), + 0, + ))), + operation_id: None, + }, + )]), + ..Default::default() + }); + } + persister.store(wallet.wallet_id, changes).unwrap(); + drop(persister); + rewind_to_v020(&path); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert!( + reopened + .get_core_tx_record(wallet.wallet_id, &funding.txid()) + .unwrap() + .is_some(), + "tracked={tracked}" + ); + if !tracked { + assert!( + reopened + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "a stored InstantSend context still needs proof" + ); + } + let no_evidence = ReplayEvidence::default(); + let loaded = reopened + .load_with_replay(if tracked { &no_evidence } else { &evidence }) + .unwrap(); + let state = &loaded.state.wallets[&wallet.wallet_id]; + assert_eq!( + state + .wallet_info + .instant_send_locks() + .contains(&locked.txid()), + !tracked + ); + if tracked { + assert!(state + .wallet_info + .accounts + .all_funding_accounts() + .iter() + .all(|account| !account + .utxos + .contains_key(&OutPoint::new(funding.txid(), 0)))); + assert!( + state.unused_asset_locks.values().all(BTreeMap::is_empty), + "reading a consumed proof must not reactivate its lifecycle" + ); + } + } +} + +#[tokio::test] +async fn should_preserve_a_chain_locked_rival_when_rescanning_an_instant_lock_wallet() { + use key_wallet::transaction_checking::TransactionContext; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(134)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let locked = authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(99_000, &address)], + ), + ); + let rival = spend_of( + &[OutPoint::new(funding.txid(), 0)], + vec![pay(98_000, &address)], + ); + let mut rival_info = info.clone(); + records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); + let TransactionContext::InBlock(block) = block(102) else { + unreachable!() + }; + records.extend( + records_of( + &mut rival_info, + &mut wallet, + &rival, + TransactionContext::InChainLockedBlock(block), + ) + .await, + ); + let (raw, _) = checked_instant_lock(&locked).await; + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + instant_locks_for_non_final_records: [(locked.txid(), raw)].into(), + ..Default::default() + }), + ), + ) + .unwrap(); + drop(persister); + rewind_to_v020(&path); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + assert!(reopened + .get_core_tx_record(wallet.wallet_id, &rival.txid()) + .unwrap() + .is_some()); + let loaded = reopened + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert!(loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&locked.txid())); + assert!(!loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&rival.txid())); +} + +#[tokio::test] +async fn should_preserve_unreadable_evidence_in_recovery_without_destructive_cleanup() { + use crate::common::LoadPolicy; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(135)], vec![pay(10_000, &address)]); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ), + ) + .unwrap(); + persister + .lock_conn_for_test() + .execute("UPDATE core_transactions SET record_blob=X'010203'", []) + .unwrap(); + drop(persister); + rewind_to_v020(&path); + assert!(SqlitePersister::open(SqlitePersisterConfig::new(&path)).is_err()); + let reopened = SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_load_policy(LoadPolicy::Recovery) + .with_auto_backup_dir(Some(path.with_extension("recovery-backups"))), + ) + .unwrap(); + let conn = reopened.lock_conn_for_test(); + let body: Vec = conn + .query_row("SELECT record_blob FROM core_transactions", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(body, [1, 2, 3]); + assert_eq!( + conn.query_row("SELECT count(*) FROM core_utxos", [], |row| row + .get::<_, u32>(0)) + .unwrap(), + 1 + ); +} + +#[tokio::test] +async fn should_roll_back_the_entire_rescan_on_a_storage_write_failure() { + use crate::common::LoadPolicy; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(136)], vec![pay(10_000, &address)]); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + synced_height: Some(150), + ..Default::default() + }), + ), + ) + .unwrap(); + drop(persister); + rewind_to_v020(&path); + let conn = rusqlite::Connection::open(&path).unwrap(); + conn.execute_batch("CREATE TRIGGER fail_rescan BEFORE DELETE ON core_transactions BEGIN SELECT RAISE(ABORT, 'injected migration failure'); END").unwrap(); + for policy in [LoadPolicy::Strict, LoadPolicy::Recovery] { + let result = SqlitePersister::open( + SqlitePersisterConfig::new(&path) + .with_load_policy(policy) + .with_auto_backup_dir(Some(path.with_extension(format!("{policy:?}-backups")))), + ); + let error = match result { + Ok(_) => panic!("injected write must fail"), + Err(error) => error, + }; + assert!( + format!("{error:?}").contains("injected migration failure"), + "{error:?}" + ); + let state: (u32, u32, u32, u32) = conn + .query_row( + "SELECT (SELECT max(version) FROM refinery_schema_history), + (SELECT count(*) FROM core_transactions), (SELECT count(*) FROM core_utxos), + (SELECT synced_height FROM core_sync_state)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(state, (20, 1, 1, 150)); + } +} + +#[tokio::test] +async fn should_keep_v020_accounting_archive_when_v021_rescans_the_record() { + use platform_wallet_storage::sqlite::schema::blob; + let (persister, _directory, path) = fresh_persister(); + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(137)], vec![pay(10_000, &address)]); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let mut legacy = records[0].clone(); + legacy.net_amount = 0; + let original = blob::encode(&legacy).unwrap(); + persister + .store( + wallet.wallet_id, + registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ), + ) + .unwrap(); + let conn = persister.lock_conn_for_test(); + conn.execute("UPDATE core_transactions SET record_blob=?1", [&original]) + .unwrap(); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; + DROP INDEX idx_core_utxos_funding; DROP INDEX idx_core_utxos_claimant; + DELETE FROM refinery_schema_history WHERE version>=20;", + ) + .unwrap(); + drop(conn); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let conn = reopened.lock_conn_for_test(); + let archived: Vec = conn + .query_row( + "SELECT record_blob FROM core_transaction_record_originals", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + archived, original, + "V021 must preserve V020's original bytes after clearing the live record" + ); + let counts: (u32, u32, u32) = conn.query_row("SELECT (SELECT count(*) FROM core_transactions), + (SELECT count(*) FROM core_transaction_inputs), + (SELECT count(*) FROM sqlite_master WHERE type='index' AND name IN ('idx_core_utxos_funding','idx_core_utxos_claimant','idx_core_transaction_inputs_outpoint'))", + [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))).unwrap(); + assert_eq!(counts, (0, 0, 3)); +} diff --git a/packages/rs-platform-wallet/src/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs index 8cd04b96f68..5452f0a1628 100644 --- a/packages/rs-platform-wallet/src/changeset/recorded_history.rs +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -40,7 +40,7 @@ pub struct ReplayEvidence { /// already attached to the restored wallet, which it puts back afterwards. #[derive(Debug, Clone, Default)] pub struct RecordedHistory { - /// Persisted InstantSend observations requiring proof, even when the host lost the payload. + /// Persisted InstantSend observations; the owner decides whether their proof is required. pub required_instant_locks: BTreeSet, /// Stored coin material, including spent rows; ownership is checked by the wallet. pub owned_prevouts: BTreeMap, diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 72a8b03de6c..ee94cfa624a 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -320,6 +320,91 @@ pub async fn replay_recorded_history_with_evidence( ) -> Result { let mut deferred = BTreeSet::new(); let mut history = history; + // A checkpoint proves its exact block, never every lower height. + for stored in &mut history.transactions { + if let TransactionContext::InBlock(block) = stored.context { + if wallet_info + .metadata + .last_applied_chain_lock + .as_ref() + .is_some_and(|lock| { + block.height() == lock.block_height && block.block_hash() == lock.block_hash + }) + { + stored.context = TransactionContext::InChainLockedBlock(block); + } + } + } + // Preserve the planner's protection of observed IS descendants, including sidecars. + // This is a local candidate context; verification below still gates every effect. + for stored in &mut history.transactions { + if matches!(stored.context, TransactionContext::Mempool) { + if let Some(lock) = history + .instant_locks + .get(&stored.txid) + .filter(|lock| lock_matches_record(lock, stored)) + { + stored.context = TransactionContext::InstantSend(lock.clone()); + } + } + } + // Preview only authoritative ChainLock winners with the same conflict planner. + // Their own old IS metadata, and that of definite losers, cannot change the verdict. + // No preview effect is applied before all remaining required proof is rebound. + let operands: Vec> = history + .transactions + .iter() + .map(|stored| { + ( + stored.txid, + &stored.transaction, + stored.owned_inputs.as_slice(), + ) + }) + .collect(); + let unsafe_txids = unsafe_replay_amounts(wallet_info, &operands) + .into_keys() + .collect(); + let marker_preview = (!history.required_instant_locks.is_empty()).then(|| { + let mut records = history.transactions.clone(); + for stored in &mut records { + if history.required_instant_locks.contains(&stored.txid) + && matches!(stored.context, TransactionContext::Mempool) + { + // The scratch sweep reads only this observation's variant, not a signature. + // Never return this placeholder to the replay or an evidence provider. + stored.context = TransactionContext::InstantSend(InstantLock { + txid: stored.txid, + inputs: stored + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }); + } + } + records + }); + let chain_final_losers = plan_replay_sweeps_where( + wallet_info, + marker_preview.as_deref().unwrap_or(&history.transactions), + &unsafe_txids, + |stored| matches!(stored.context, TransactionContext::InChainLockedBlock(_)), + ) + .removed; + for stored in &mut history.transactions { + if matches!(stored.context, TransactionContext::InChainLockedBlock(_)) + || chain_final_losers.contains(&stored.txid) + { + history.required_instant_locks.remove(&stored.txid); + history.instant_locks.remove(&stored.txid); + if matches!(stored.context, TransactionContext::InstantSend(_)) { + stored.context = TransactionContext::Mempool; + } + } + } deferred.extend(history.required_instant_locks.iter().copied()); for stored in &history.transactions { if history.required_instant_locks.contains(&stored.txid) { @@ -416,6 +501,7 @@ pub async fn replay_recorded_history_with_evidence( .filter(|stored| { matches!(stored.context, TransactionContext::Mempool) && !instant_locks.contains_key(&stored.txid) + && !chain_final_losers.contains(&stored.txid) }) .map(|stored| stored.transaction.txid()) .collect(); @@ -474,20 +560,8 @@ pub async fn replay_recorded_history_with_evidence( // IS-locked or confirmed spend releases it. Sibling of // TODO(release-repair-spends-after-reorg) in the SQLite `core_history`. let mut replay = replay_order(transactions); - // A checkpoint proves its exact block, not ancestry of every lower height. for stored in &mut replay { stored.context = match &stored.context { - TransactionContext::InBlock(block) - if wallet_info - .metadata - .last_applied_chain_lock - .as_ref() - .is_some_and(|lock| { - block.height() == lock.block_height && block.block_hash() == lock.block_hash - }) => - { - TransactionContext::InChainLockedBlock(*block) - } TransactionContext::Mempool => instant_locks .get(&stored.txid) .filter(|lock| lock_matches_record(lock, stored)) @@ -950,6 +1024,15 @@ fn plan_replay_sweeps( wallet_info: &ManagedWalletInfo, records: &[StoredTransaction], unsafe_txids: &HashSet, +) -> SweepPlan { + plan_replay_sweeps_where(wallet_info, records, unsafe_txids, |_| true) +} + +fn plan_replay_sweeps_where( + wallet_info: &ManagedWalletInfo, + records: &[StoredTransaction], + unsafe_txids: &HashSet, + eligible_winner: impl Fn(&StoredTransaction) -> bool, ) -> SweepPlan { // The scratch wallet lends the sweep its rules and nothing to scan: its // unconfirmed records move to the pool, the rest and its coins are dropped. @@ -987,6 +1070,7 @@ fn plan_replay_sweeps( let mut winners: Vec<&StoredTransaction> = records .iter() .filter(|stored| !matches!(stored.context, TransactionContext::Mempool)) + .filter(|stored| eligible_winner(stored)) // A record with unsafe amounts can lose a conflict, never win one. .filter(|stored| !unsafe_txids.contains(&stored.txid)) .collect(); @@ -1859,6 +1943,121 @@ mod tests { } } + #[tokio::test] + async fn should_not_require_an_archived_instant_lock_for_a_chain_final_transaction() { + for exact_checkpoint in [false, true] { + let (mut wallet, mut info, _, mut records, _) = confirmed_spend_fixture().await; + let transaction = records[1].transaction.clone(); + let TransactionContext::InBlock(block) = records[1].context else { + unreachable!() + }; + if exact_checkpoint { + info.metadata.last_applied_chain_lock = Some(dashcore::ChainLock { + block_height: block.height(), + block_hash: block.block_hash(), + signature: Default::default(), + }); + } else { + records[1].context = TransactionContext::InChainLockedBlock(block); + } + let raw = InstantLock { + txid: transaction.txid(), + inputs: transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }; + let mut stored = history(records, [(transaction.txid(), raw)].into()); + stored.required_instant_locks.insert(transaction.txid()); + replay_recorded_history(&mut info, &mut wallet, stored) + .await + .unwrap(); + assert!( + !info.instant_send_locks().contains(&transaction.txid()), + "unused raw metadata must not mint authenticated IS state" + ); + } + } + + async fn assert_chain_final_rival_evidence(chain_final: bool, child_observation: u8) { + let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; + let mut loser = records[1].clone(); + let raw = InstantLock { + txid: loser.txid, + inputs: loser + .transaction + .input + .iter() + .map(|input| input.previous_output) + .collect(), + ..Default::default() + }; + loser.context = TransactionContext::InstantSend(raw.clone()); + let mut winner = records[1].clone(); + winner.transaction.output[0].value -= 1; + winner.txid = winner.transaction.txid(); + if chain_final { + let TransactionContext::InBlock(block) = winner.context else { + unreachable!() + }; + winner.context = TransactionContext::InChainLockedBlock(block); + } + let mut child = loser.clone(); + child.transaction.input[0].previous_output = OutPoint::new(loser.txid, 0); + child.txid = child.transaction.txid(); + let child_lock = InstantLock { + txid: child.txid, + inputs: vec![child.transaction.input[0].previous_output], + ..Default::default() + }; + child.context = TransactionContext::Mempool; + let mut stored = history( + vec![ + records[0].clone(), + loser.clone(), + winner.clone(), + child.clone(), + ], + [(loser.txid, raw), (child.txid, child_lock)].into(), + ); + if child_observation != 1 { + stored.instant_locks.remove(&child.txid); + } + if child_observation == 2 { + stored.required_instant_locks.insert(child.txid); + } + let result = replay_recorded_history(&mut info, &mut wallet, stored).await; + if chain_final && child_observation == 0 { + let outcome = result.unwrap(); + assert!(outcome.defeated.contains(&loser.txid)); + assert!(outcome.defeated.contains(&child.txid)); + assert!(!outcome.defeated.contains(&winner.txid)); + } else { + assert!(matches!( + result, + Err(HistoryReplayError::MissingFinalityEvidence { .. }) + )); + } + } + + #[tokio::test] + async fn should_remove_mempool_descendants_only_after_a_chain_final_rival_settles() { + assert_chain_final_rival_evidence(false, 0).await; + assert_chain_final_rival_evidence(true, 0).await; + } + + #[tokio::test] + async fn should_require_a_descendants_own_raw_instant_lock_after_parent_is_defeated() { + assert_chain_final_rival_evidence(true, 1).await; + } + + #[tokio::test] + async fn should_require_a_descendants_own_instant_send_marker_without_payload() { + assert_chain_final_rival_evidence(true, 2).await; + } + #[tokio::test] async fn should_reject_history_proven_on_another_network() { use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs index ea7986e7cd5..59a0bb5fdcc 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/sync/recovery.rs @@ -548,6 +548,11 @@ pub(crate) fn add_tracked_instant_locks<'a>( locks: impl IntoIterator, ) { for lock in locks { + // Terminal proofs are archival; durable claims remain restored separately. + if lock.status.is_consumed() { + continue; + } + let Some(AssetLockProof::Instant(proof)) = &lock.proof else { continue; }; @@ -4153,6 +4158,47 @@ mod tests { ); } + #[test] + fn should_not_require_archival_instant_proof_from_consumed_operations() { + use dashcore::ephemerealdata::instant_lock::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use std::collections::HashSet; + + let funding = transaction_spending(OutPoint::new(Txid::from_byte_array([71; 32]), 0)); + for status in [AssetLockStatus::Consumed, AssetLockStatus::DrainCompleted] { + let lock = TrackedAssetLock { + operation_id: Some([72; 16]), + out_point: OutPoint::new(funding.txid(), 0), + transaction: funding.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 1, + status, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + InstantLock { + txid: funding.txid(), + ..Default::default() + }, + funding.clone(), + 0, + ))), + }; + let mut history = RecordedHistory::default(); + super::add_tracked_instant_locks( + &mut history, + &HashSet::from([funding.txid()]), + [&lock], + ); + assert!(history.instant_locks.is_empty()); + assert_eq!( + super::final_lock_input_claims([&lock]), + vec![(funding.input[0].previous_output, Some(funding.txid()))] + ); + } + } + /// Only the unconfirmed record of a funding that a tracked lock holds as /// chain-final leaves the history, and only when no lock in the history /// settles it; a lock filed under it that names another transaction From 84baf52fa1bda4c1e5917962f3c93b4b90f122bf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:09:07 +0000 Subject: [PATCH 63/84] test(wallet): define shared replay claim projection contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Freeze the storage-independent claim/effects API for the isolated owner implementation. Four semantic tests are intentionally RED against the empty projection stub; no production caller uses the new method yet. The existing replay and SQL paths are unchanged. Targeted RED ledger df33ab22e6ee921c114f74a801f21d33; scoped wallet Clippy passed. This is a development handoff, not a merge-ready feature. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/manager/history_claims.rs | 206 ++++++++++++++++++ .../src/manager/history_replay.rs | 1 + .../rs-platform-wallet/src/manager/mod.rs | 1 + 3 files changed, 208 insertions(+) create mode 100644 packages/rs-platform-wallet/src/manager/history_claims.rs diff --git a/packages/rs-platform-wallet/src/manager/history_claims.rs b/packages/rs-platform-wallet/src/manager/history_claims.rs new file mode 100644 index 00000000000..f5d512a623a --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_claims.rs @@ -0,0 +1,206 @@ +//! Wallet-owned projection of replay verdicts onto durable input claims. + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::{OutPoint, Txid}; + +use super::history_replay::HistoryReplay; +use crate::changeset::StoredTransaction; + +/// One durable hold, independent of a backend's physical row representation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StoredReplayClaim { + /// Input held by persistence, including a placeholder without a funding output. + pub outpoint: OutPoint, + /// None preserves an anonymous hold; it never means permission to release. + pub claimant: Option, + /// Existing lifetime stamp, retained when a new unmined winner has none. + pub mined_height: Option, +} + +/// The exact state a backend applies to one input after deleting rejected records. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReplayClaimEffect { + /// The funding itself was rejected: remove the output or placeholder. + Remove, + /// Release a materialized output, or delete a placeholder. + Release, + /// Keep or create a hold; a missing claimant preserves anonymous provenance. + Hold { + /// The surviving claimant, or the preexisting anonymous hold. + claimant: Option, + /// The retained winner's own mining height, when available. + mined_height: Option, + }, +} + +/// Storage-independent effects computed from one authoritative wallet snapshot. +#[derive(Debug, Default, PartialEq, Eq)] +pub struct HistoryReplayEffects { + /// Physical history keys to remove from this wallet. + pub removed_records: BTreeSet, + /// Rejected transaction identities whose outputs, proofs and operations are removed. + pub removed_evidence: BTreeSet, + /// Apply after row deletion, whose storage cascades may clear old claimant links. + pub claims: BTreeMap, +} + +impl HistoryReplay { + /// Project already-verified replay verdicts; this does not accept new finality evidence. + pub fn storage_effects( + &self, + _records: &[StoredTransaction], + _claims: &[StoredReplayClaim], + ) -> HistoryReplayEffects { + HistoryReplayEffects::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::changeset::SweepBatch; + use dashcore::{hashes::Hash, Transaction, TxIn}; + use key_wallet::transaction_checking::TransactionContext; + + fn point(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), 0) + } + + fn record(marker: u32, inputs: &[OutPoint]) -> StoredTransaction { + let transaction = Transaction { + version: 1, + lock_time: marker, + input: inputs + .iter() + .map(|previous_output| TxIn { + previous_output: *previous_output, + ..Default::default() + }) + .collect(), + output: vec![], + special_transaction_payload: None, + }; + StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: vec![], + } + } + + fn claim(outpoint: OutPoint, claimant: Option) -> StoredReplayClaim { + StoredReplayClaim { + outpoint, + claimant, + mined_height: None, + } + } + + #[test] + fn should_release_only_rejected_claims_and_keep_anonymous_or_recordless_holds() { + let inputs = [point(1), point(2), point(3)]; + let bad = record(1, &inputs); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad.txid); + let claims = [ + claim(inputs[0], Some(bad.txid)), + claim(inputs[1], None), + claim(inputs[2], Some(point(9).txid)), + ]; + let effects = outcome.storage_effects(std::slice::from_ref(&bad), &claims); + assert_eq!(effects.removed_records, [bad.txid].into()); + assert_eq!(effects.claims[&inputs[0]], ReplayClaimEffect::Release); + assert_eq!( + effects.claims[&inputs[1]], + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None + } + ); + assert_eq!( + effects.claims[&inputs[2]], + ReplayClaimEffect::Hold { + claimant: Some(point(9).txid), + mined_height: None + } + ); + } + + #[test] + fn should_reassign_rejected_claims_to_a_surviving_body() { + let input = point(4); + let bad = record(1, &[input]); + let survivor = record(2, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad.txid); + let effects = outcome.storage_effects( + &[bad.clone(), survivor.clone()], + &[claim(input, Some(bad.txid))], + ); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor.txid), + mined_height: None + } + ); + } + + #[test] + fn should_project_sweep_releases_and_hold_unfunded_inputs_without_losing_names() { + let inputs = [point(5), point(6)]; + let loser = record(1, &inputs); + let child = record(2, &[OutPoint::new(loser.txid, 0)]); + let winner = record(3, &[inputs[0]]); + let mut outcome = HistoryReplay::default(); + outcome.defeated.extend([loser.txid, child.txid]); + outcome.sweeps.push(SweepBatch { + txids: vec![loser.txid, child.txid], + superseded_by: winner.txid, + winner_mined_height: Some(42), + released_outpoints: vec![inputs[1]], + }); + let effects = outcome.storage_effects(&[loser.clone(), child], &[]); + assert_eq!( + effects.claims[&inputs[0]], + ReplayClaimEffect::Hold { + claimant: Some(winner.txid), + mined_height: Some(42) + } + ); + assert_eq!(effects.claims[&inputs[1]], ReplayClaimEffect::Release); + assert_eq!( + effects.claims[&OutPoint::new(loser.txid, 0)], + ReplayClaimEffect::Remove + ); + } + + #[test] + fn should_preserve_evidence_when_a_rejected_physical_key_names_a_surviving_body() { + let input = point(7); + let mut survivor = record(2, &[input]); + let surviving_body = survivor.txid; + survivor.txid = point(8).txid; + let mut bad = record(1, &[input]); + let bad_body = bad.txid; + bad.txid = surviving_body; + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad_body); + let effects = outcome.storage_effects( + &[bad, survivor.clone()], + &[claim(input, Some(surviving_body))], + ); + assert!(effects.removed_records.contains(&surviving_body)); + assert!(!effects.removed_evidence.contains(&surviving_body)); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(surviving_body), + mined_height: None + } + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index ee94cfa624a..aecae4da033 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -33,6 +33,7 @@ use key_wallet::Utxo; use super::history_authorization::plan_history_authorization; pub use super::history_authorization::HistoryAuthorization; +pub use super::history_claims::{HistoryReplayEffects, ReplayClaimEffect, StoredReplayClaim}; use crate::changeset::changeset::SweepBatch; use crate::changeset::recorded_history::ReplayEvidence; use crate::changeset::{ diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 3015e45130c..1a309c99db8 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -4,6 +4,7 @@ pub mod accessors; pub mod dashpay_sync; pub mod dpns_sync; mod history_authorization; +mod history_claims; pub mod history_replay; pub mod identity_sync; mod load; From 1674bfc85b3346c18fb27511d8f109d04cbd3f64 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:05:37 +0000 Subject: [PATCH 64/84] feat(wallet-ffi): add atomic scoped history replay callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep proof verification outside the synchronous host transaction and bind it to the fresh wallet snapshot. Preserve callback errors, reject missing or duplicate invocations, and gate restore capability on the complete adapter. Operation status awaits durable cleanup tombstones. Working checkpoint: claim projection integration and the Swift storage adapter remain pending. Shared-claims semantic tests remain enabled RED until the independently reviewed owner implementation is integrated. Validation: 14 targeted FFI tests; scoped three-crate CI Clippy. Existing locked dependency versions retained. Swift runtime validation unavailable. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- Cargo.lock | 3 + packages/rs-platform-wallet-ffi/Cargo.toml | 3 + .../rs-platform-wallet-ffi/src/manager.rs | 78 +- .../rs-platform-wallet-ffi/src/persistence.rs | 5186 +++++++++-------- .../src/persistence/replay.rs | 821 +++ 5 files changed, 3558 insertions(+), 2533 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/persistence/replay.rs diff --git a/Cargo.lock b/Cargo.lock index 23b2ba0e781..1c52bec888c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5472,17 +5472,20 @@ dependencies = [ "bs58", "cbindgen 0.27.0", "dash-sdk", + "dash-spv", "dashcore", "dpp", "grovedb-bincode", "hex", "key-wallet", + "key-wallet-manager", "lazy_static", "once_cell", "parking_lot", "platform-wallet", "rs-sdk-ffi", "serde_json", + "thiserror 2.0.18", "tokio", "tokio-metrics", "tracing", diff --git a/packages/rs-platform-wallet-ffi/Cargo.toml b/packages/rs-platform-wallet-ffi/Cargo.toml index cd07f035bd2..87508ac761c 100644 --- a/packages/rs-platform-wallet-ffi/Cargo.toml +++ b/packages/rs-platform-wallet-ffi/Cargo.toml @@ -47,6 +47,7 @@ hex = "0.4" # Persistence loader emits structured warnings for skipped / # corrupt rows so operators can detect snapshot drift without a # native debugger attached. +thiserror = "=2.0.18" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } @@ -64,6 +65,8 @@ bs58 = "0.5" zeroize = { version = "1", features = ["derive"] } [dev-dependencies] +dash-spv = { workspace = true, features = ["test-utils"] } +key-wallet-manager = { workspace = true, features = ["test-utils"] } dpp = { path = "../rs-dpp", features = ["fixtures-and-mocks"] } platform-wallet = { path = "../rs-platform-wallet", features = ["test-utils"] } diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 3323e4256c7..10637eb5f8f 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,15 +8,16 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeCoreHistoryForAccountingFn, FreeCoreInstantLockFn, FreeTrackedMasternodesFn, - LoadAssetLockOperationIdFn, LoadCoreHistoryForAccountingFn, LoadCoreInstantLockFn, - LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, + ApplyReplayEffectsFn, FFIPersister, FreeCoreHistoryForAccountingFn, FreeCoreInstantLockFn, + FreeReplayClaimsFn, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, + LoadCoreHistoryForAccountingFn, LoadCoreInstantLockFn, LoadIdentityBalanceBlockTimeFn, + LoadReplayClaimsFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistCoreHistoryAccountedFn, PersistCoreInstantLockFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, PersistTransactionAccountingFn, PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, - PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, + ReplayWalletFn, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; use crate::types::{FFINetwork, Network}; @@ -311,6 +312,10 @@ unsafe fn persistence_extension_callbacks( persist_core_instant_lock: slot!(on_persist_core_instant_lock_fn, PersistCoreInstantLockFn), load_core_instant_lock: slot!(on_load_core_instant_lock_fn, LoadCoreInstantLockFn), free_core_instant_lock: slot!(on_load_core_instant_lock_free_fn, FreeCoreInstantLockFn), + replay_wallet: slot!(on_replay_wallet_fn, ReplayWalletFn), + load_replay_claims: slot!(on_load_replay_claims_fn, LoadReplayClaimsFn), + free_replay_claims: slot!(on_load_replay_claims_free_fn, FreeReplayClaimsFn), + apply_replay_effects: slot!(on_apply_replay_effects_fn, ApplyReplayEffectsFn), } } @@ -1555,6 +1560,71 @@ mod tests { assert_eq!(present(&ext), [false; 4]); } + #[test] + fn should_size_gate_every_atomic_replay_callback_without_reading_a_partial_slot() { + unsafe extern "C" fn scope( + _: *mut c_void, + _: *const u8, + _: ReplayOperationFn, + _: *mut c_void, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const ReplayClaimRestoreFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) {} + unsafe extern "C" fn apply( + _: *mut c_void, + _: *const u8, + _: *const HistoryReplayEffectsFFI, + ) -> i32 { + 0 + } + use crate::persistence::{ + HistoryReplayEffectsFFI, ReplayClaimRestoreFFI, ReplayOperationFn, + }; + let mut ext = PersistenceCallbacksExtension { + on_replay_wallet_fn: Some(scope), + on_load_replay_claims_fn: Some(load), + on_load_replay_claims_free_fn: Some(free), + on_apply_replay_effects_fn: Some(apply), + ..Default::default() + }; + let present = |ext: &PersistenceCallbacksExtension| { + let parsed = unsafe { persistence_extension_callbacks(ext) }; + [ + parsed.replay_wallet.is_some(), + parsed.load_replay_claims.is_some(), + parsed.free_replay_claims.is_some(), + parsed.apply_replay_effects.is_some(), + ] + }; + assert_eq!(present(&ext), [true; 4]); + for (index, offset) in [ + std::mem::offset_of!(PersistenceCallbacksExtension, on_replay_wallet_fn), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_fn), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_free_fn), + std::mem::offset_of!(PersistenceCallbacksExtension, on_apply_replay_effects_fn), + ] + .into_iter() + .enumerate() + { + for length in [ + offset, + offset + std::mem::size_of::>() - 1, + ] { + ext.struct_size = length; + assert_eq!(present(&ext), std::array::from_fn(|slot| slot < index)); + } + } + } + /// A host whose `struct_size` stops right after the chainlock-height /// slot (built before the credit-verdict slot existed) keeps every /// earlier slot and simply never has the verdict slot read; a host diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index af9cb717286..c2ddf1dcf0a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -9,6 +9,13 @@ //! `rs-unified-sdk-jni`'s `RESOLVE_*` mnemonic-resolver codes, which reuse the //! same integers on a different callback family. +mod replay; +use replay::ReplayCallbacks; +pub use replay::{ + ApplyReplayEffectsFn, FreeReplayClaimsFn, HistoryReplayEffectsFFI, LoadReplayClaimsFn, + ReplayClaimEffectFFI, ReplayClaimRestoreFFI, ReplayOperationFn, ReplayWalletFn, +}; + use bincode::config; use key_wallet::account::account_collection::AccountCollection; use key_wallet::account::{Account, AccountType, StandardAccountType}; @@ -624,6 +631,37 @@ pub struct PersistenceCallbacksExtension { >, pub on_load_core_instant_lock_free_fn: Option, + /// Synchronous exactly-once scope; rollback if the operation or commit fails. + pub on_replay_wallet_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + operation: unsafe extern "C" fn(operation_context: *mut c_void) -> i32, + operation_context: *mut c_void, + ) -> i32, + >, + pub on_load_replay_claims_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_rows: *mut *const ReplayClaimRestoreFFI, + out_count: *mut usize, + ) -> i32, + >, + pub on_load_replay_claims_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + rows: *const ReplayClaimRestoreFFI, + count: usize, + ), + >, + pub on_apply_replay_effects_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + effects: *const HistoryReplayEffectsFFI, + ) -> i32, + >, } impl Default for PersistenceCallbacksExtension { @@ -651,6 +689,10 @@ impl Default for PersistenceCallbacksExtension { on_persist_core_instant_lock_fn: None, on_load_core_instant_lock_fn: None, on_load_core_instant_lock_free_fn: None, + on_replay_wallet_fn: None, + on_load_replay_claims_fn: None, + on_load_replay_claims_free_fn: None, + on_apply_replay_effects_fn: None, } } } @@ -679,6 +721,10 @@ pub struct PersistenceExtensionCallbacks { pub persist_core_instant_lock: Option, pub load_core_instant_lock: Option, pub free_core_instant_lock: Option, + pub replay_wallet: Option, + pub load_replay_claims: Option, + pub free_replay_claims: Option, + pub apply_replay_effects: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1650,6 +1696,7 @@ pub struct FFIPersister { /// each round atomic with respect to every other round and the complete /// restore read (including additive sidecars). round_lock: Mutex, + replay_callbacks: ReplayCallbacks, } /// Releases the host callback context when the persister's last owner @@ -1767,1738 +1814,1431 @@ impl FFIPersister { free_core_instant_lock_callback: extensions.free_core_instant_lock, declared_capabilities, round_lock: Mutex::new(RoundGuardState::default()), + replay_callbacks: ReplayCallbacks { + transaction: extensions.replay_wallet, + load_claims: extensions.load_replay_claims, + free_claims: extensions.free_replay_claims, + apply: extensions.apply_replay_effects, + }, } } - fn load_core_instant_locks( + /// Read under the caller's round guard; atomic replay must not acquire it recursively. + fn load_snapshot_unlocked( &self, - wallet_id: &WalletId, - history: &mut RecordedHistory, - ) -> Result<(), PersistenceError> { - let (load, free) = match ( - self.load_core_instant_lock_callback, - self.free_core_instant_lock_callback, - ) { - (None, None) => return Ok(()), - (Some(load), Some(free)) => (load, free), - _ => { - return Err(PersistenceError::backend( - "raw InstantLock load/free callbacks must be provided together", - )) - } + wallet_id: Option, + ) -> Result { + // If Swift hasn't wired up `on_load_wallet_list_fn` there's + // nothing to restore — treat as a fresh client. + let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else { + return Ok(ClientStartState::default()); }; - struct Guard { - context: *mut c_void, - bytes: *const u8, - len: usize, - free: FreeCoreInstantLockFn, + + // Swift allocates the entries array (and every nested byte + // buffer); we read it under a `Guard` that calls the matching + // free callback on drop, so failures inside the loop don't + // leak. + let mut entries_ptr: *const WalletRestoreEntryFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_wallet_list_fn returned error code {}", rc), + )); } - impl Drop for Guard { - fn drop(&mut self) { - if !self.bytes.is_null() { - unsafe { (self.free)(self.context, self.bytes, self.len) }; + let _guard = LoadGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_wallet_list_free_fn, + entries: entries_ptr, + count, + }; + + let mut out = ClientStartState::default(); + if entries_ptr.is_null() && count != 0 { + return Err(PersistenceError::backend( + "wallet restore: missing row buffer", + )); + } + if count == 0 { + return Ok(out); + } + + // SAFETY: Swift guarantees `entries_ptr` points to `count` + // contiguous `WalletRestoreEntryFFI` values for the lifetime + // of the callback window. `_guard` ensures the free callback + // fires before we leave this function. + let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; + for entry in entries { + if wallet_id.is_some_and(|wallet_id| entry.wallet_id != wallet_id) { + continue; + } + if out.wallets.contains_key(&entry.wallet_id) { + return Err(PersistenceError::backend( + "wallet restore: duplicate wallet ID", + )); + } + let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; + self.load_core_instant_locks(&entry.wallet_id, &mut wallet_state.recorded_history)?; + if let Some(cb) = self + .tracked_masternodes_callbacks + .load_asset_lock_operation_id + { + load_asset_lock_operation_ids( + cb, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state.unused_asset_locks, + )?; + } + if let Some(cb) = self.load_identity_balance_block_time_callback { + for identities in wallet_state.identity_manager.wallet_identities.values_mut() { + for managed in identities.values_mut() { + let mut found = false; + let mut stamp = crate::types::BlockTime { + height: 0, + core_height: 0, + timestamp: 0, + }; + let rc = unsafe { + cb( + self.callbacks.context, + entry.wallet_id.as_ptr(), + managed.id().as_bytes().as_ptr(), + &mut found, + &mut stamp, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + "Loading identity balance block time failed".to_string(), + )); + } + managed.last_updated_balance_block_time = found.then(|| stamp.into()); + } } } + out.wallets.insert(entry.wallet_id, wallet_state); + if let Some(platform_address_state) = platform_address_state { + out.platform_addresses + .insert(entry.wallet_id, platform_address_state); + } } - for record in &history.transactions { - let mut bytes = std::ptr::null(); - let mut len = 0; - let rc = unsafe { - load( - self.callbacks.context, - wallet_id.as_ptr(), - AsRef::<[u8]>::as_ref(&record.txid).as_ptr(), - &mut bytes, - &mut len, - ) - }; - let _guard = Guard { - context: self.callbacks.context, - bytes, - len, - free, + + // Restore shielded sub-wallet state if the host has wired + // up the optional callbacks. Notes and per-subwallet sync + // states travel separately so the host can populate them + // from independent SwiftData fetch descriptors. Both arms + // walk the same `(wallet_id, account_index)` key space and + // funnel into a single `SubwalletId` map on + // `ClientStartState.shielded`. + #[cfg(feature = "shielded")] + { + use crate::shielded_persistence::*; + use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; + use platform_wallet::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, ShieldedNote, + ShieldedOutgoingNote, SubwalletId, }; - if rc != 0 { - return Err(persist_callback_error( - rc, - "raw InstantLock load failed".to_owned(), + + let mut shielded_state = ShieldedSyncStartState::default(); + + // Fail fast on a half-wired callback pair: a loader without + // its matching free callback leaks the host-allocated buffer + // on every successful load (the guard's `Drop` is a no-op + // when `free_fn` is `None`). + if self.callbacks.on_load_shielded_notes_fn.is_some() + != self.callbacks.on_load_shielded_notes_free_fn.is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ + provided together", )); } - if bytes.is_null() && len == 0 { - continue; - } - if bytes.is_null() || len == 0 || len > dashcore::consensus::encode::MAX_VEC_SIZE { + if self.callbacks.on_load_shielded_sync_states_fn.is_some() + != self + .callbacks + .on_load_shielded_sync_states_free_fn + .is_some() + { return Err(PersistenceError::backend( - "raw InstantLock loader returned an invalid or oversized buffer", + "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ + must be provided together", )); } - let raw = unsafe { slice::from_raw_parts(bytes, len) }; - let lock = dashcore::consensus::deserialize(raw).map_err(PersistenceError::backend)?; - history.instant_locks.insert(record.txid, lock); - } - Ok(()) - } - - /// Narrow a round's failure kind to what the caller may safely act on: - /// `Transient` survives only where - /// [`Self::store_transient_is_reissuable`] holds, since losing a retry - /// opportunity costs less than the rows a re-sent partial round would - /// duplicate. `Constraint` and `Fatal` invite no retry, so they pass - /// through. - fn reportable_round_kind(&self, reported: PersistenceErrorKind) -> PersistenceErrorKind { - match reported { - PersistenceErrorKind::Transient if !self.store_transient_is_reissuable() => { - PersistenceErrorKind::Fatal + if self.callbacks.on_load_shielded_outgoing_notes_fn.is_some() + != self + .callbacks + .on_load_shielded_outgoing_notes_free_fn + .is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_outgoing_notes_fn and \ + on_load_shielded_outgoing_notes_free_fn must be provided together", + )); } - kind => kind, - } - } - - /// Compute the callback contracts that are structurally complete in this - /// vtable. This mask is only an upper bound: the host must separately attest - /// the semantics it actually implements. - fn callback_capabilities(&self) -> PersistenceCapabilities { - let mut capabilities = PersistenceCapabilities::NONE; - if self.callbacks.on_changeset_begin_fn.is_some() - && self.callbacks.on_changeset_end_fn.is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::ATOMIC_CHANGESETS); - } - if self.callbacks.on_persist_invitations_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::INVITATIONS); - } - if self.dpns_name_states_callback.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::DPNS_NAME_STATES); - } - let wallet_restore = self.callbacks.on_load_wallet_list_fn.is_some() - && self.callbacks.on_load_wallet_list_free_fn.is_some(); - if self.callbacks.on_persist_account_registrations_fn.is_some() - && self.callbacks.on_persist_account_address_pools_fn.is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES); - } - if 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); - } - if wallet_restore - && self.callbacks.on_persist_asset_locks_fn.is_some() - && self - .tracked_masternodes_callbacks - .persist_asset_lock_operation_id - .is_some() - && self - .tracked_masternodes_callbacks - .load_asset_lock_operation_id - .is_some() - { - // The declaration additionally attests UUID preservation, consumed - // tombstones and fail-closed loads. Legacy hosts do not set this bit. - capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS); - } - if self - .tracked_masternodes_callbacks - .persist_tracked_masternodes - .is_some() - && self - .tracked_masternodes_callbacks - .load_tracked_masternodes - .is_some() - && self - .tracked_masternodes_callbacks - .load_tracked_masternodes_free - .is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::TRACKED_MASTERNODES); - } - if self.callbacks.on_persist_wallet_changeset_fn.is_some() - && wallet_restore - && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) - { - capabilities = capabilities.union(PersistenceCapabilities::PROVIDER_TRANSACTIONS); - } - if self.callbacks.on_persist_token_balances_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); - } - // The dashpay-payments slot is what the sweep's Failed flip rides - // (`dashpay_payments_overlay` on the store round). A host that - // never wired it — Android deliberately keeps payment recording - // in-memory-only — must not read as payment-durable, or the - // wallet-event adapter would couple the flip to a round that - // silently drops it: the accepted-and-ignored shape the sweep - // bit's own gating exists to prevent, reproduced one channel over. - if self.callbacks.on_persist_dashpay_payments_fn.is_some() { - capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); - } - // Sweeps travel through the size-tagged extension callback, so — - // unlike the legacy `on_persist_wallet_changeset_fn`, whose - // unchanged C signature proves nothing about what a host actually - // reads — this slot being `Some` is a genuine structural - // attestation: it exists only when the host's declared extension - // `struct_size` covered the field. The changeset callback is still - // required alongside it because a sweep only corrects state that - // callback persists; a sweeps slot with no changeset slot would - // attest removals against rows the host never writes. The bit is - // still additionally gated by `declared_capabilities` in - // `persistence_capabilities()` below, like every other bit: the - // host must attest the semantic contract, not just wire pointers. - // - // The begin/end pair and `ATOMIC_CHANGESETS` are required on top, - // and only for this bit, because moving sweeps onto their own slot - // split one logical `CoreChangeSet` across two calls. Without a - // round that commits or rolls back as a unit, the changeset call - // can make the watermark and the additive rows durable and the - // process can stop before the sweep call applies the removal — - // leaving a host that restarts past a deletion it never performed - // and reloads the dead transaction. Nothing before sweeps could - // fail this way: every core field arrived through one callback. - if self.wallet_changeset_sweeps_callback.is_some() - && self.callbacks.on_persist_wallet_changeset_fn.is_some() - && self.callbacks.on_changeset_begin_fn.is_some() - && self.callbacks.on_changeset_end_fn.is_some() - && self - .declared_capabilities - .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) - { - capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); - } - #[cfg(feature = "shielded")] - if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() - && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() - && self - .callbacks - .on_load_shielded_viewing_keys_free_fn - .is_some() - { - capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS); - } - - // Deliberately absent: PersistenceCallbacks has no deferred contact - // crypto add/clear callback pair, so FFI hosts cannot attest it. - capabilities - } -} - -/// What `on_get_core_tx_record_fn` answered for one txid. -enum CoreTxRead { - /// The callback is unset, or the host does not store the txid. - Missing, - /// The callback returned this non-zero code. - Failed(i32), - /// The host reported a hit but handed over no decodable body. - Unreadable(&'static str), - /// A hit: the host's raw context fields and the decoded body. - Found { - context_kind: u8, - block_height: u32, - block_hash: [u8; 32], - block_timestamp: u32, - transaction: dashcore::Transaction, - }, -} - -impl FFIPersister { - /// Call `on_get_core_tx_record_fn` for `txid` and decode its answer, - /// handing the tx-bytes buffer back to the host on every path. Each - /// caller applies its own policy to the outcome. - fn read_core_tx(&self, wallet_id: WalletId, txid: &dashcore::Txid) -> CoreTxRead { - use dashcore::consensus::Decodable; - use dashcore::hashes::Hash; - - let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { - return CoreTxRead::Missing; - }; - - let txid_bytes: [u8; 32] = *txid.as_byte_array(); - let mut context_kind: u8 = 0; - let mut block_height: u32 = 0; - let mut block_hash: [u8; 32] = [0u8; 32]; - let mut block_timestamp: u32 = 0; - let mut tx_bytes_ptr: *const u8 = std::ptr::null(); - let mut tx_bytes_len: usize = 0; - let mut found: bool = false; - - // SAFETY: All output pointers reference Rust-owned stack - // locals that outlive the callback invocation. `wallet_id` - // and `txid` are fixed-size byte arrays. - let rc = unsafe { - get_cb( - self.callbacks.context, - wallet_id.as_ptr(), - txid_bytes.as_ptr(), - &mut context_kind, - &mut block_height, - block_hash.as_mut_ptr(), - &mut block_timestamp, - &mut tx_bytes_ptr, - &mut tx_bytes_len, - &mut found, - ) - }; - - // RAII guard so the tx-bytes free callback fires on every - // exit path past this point, handing the buffer back to the host. - struct TxBytesGuard { - ptr: *const u8, - len: usize, - free_fn: Option< - unsafe extern "C" fn( + // 1) notes + if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { + let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); + let mut notes_count: usize = 0; + let rc = + unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_shielded_notes_fn returned error code {}", rc), + )); + } + struct NotesGuard { context: *mut c_void, - tx_bytes: *const u8, - tx_bytes_len: usize, - ), - >, - ctx: *mut c_void, - } - impl Drop for TxBytesGuard { - fn drop(&mut self) { - if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) { - // SAFETY: ptr+len match the values the lookup - // callback wrote; the host owns the allocation - // until this free fires. - unsafe { free(self.ctx, self.ptr, self.len) }; + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedNoteRestoreFFI, + count: usize, + } + impl Drop for NotesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _notes_guard = NotesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_notes_free_fn, + entries: notes_ptr, + count: notes_count, + }; + if !notes_ptr.is_null() && notes_count > 0 { + let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; + for ffi in slice { + if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { + continue; + } + let note_data = unsafe { + std::slice::from_raw_parts(ffi.note_data_ptr, ffi.note_data_len) + .to_vec() + }; + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.notes.push(ShieldedNote { + position: ffi.position, + cmx: ffi.cmx, + nullifier: ffi.nullifier, + block_height: ffi.block_height, + is_spent: ffi.is_spent != 0, + value: ffi.value, + note_data, + }); + } } } - } - let _bytes_guard = TxBytesGuard { - ptr: tx_bytes_ptr, - len: tx_bytes_len, - free_fn: self.callbacks.on_get_core_tx_record_free_fn, - ctx: self.callbacks.context, - }; - - if rc != 0 { - return CoreTxRead::Failed(rc); - } - if !found { - return CoreTxRead::Missing; - } - if tx_bytes_ptr.is_null() || tx_bytes_len == 0 { - return CoreTxRead::Unreadable("handed over no transaction bytes"); - } - // SAFETY: the host guarantees `tx_bytes_ptr` points to - // `tx_bytes_len` valid bytes until the free callback — - // `_bytes_guard` keeps that window open until this returns. - let tx_slice = unsafe { slice::from_raw_parts(tx_bytes_ptr, tx_bytes_len) }; - match dashcore::Transaction::consensus_decode(&mut &tx_slice[..]) { - Ok(transaction) => CoreTxRead::Found { - context_kind, - block_height, - block_hash, - block_timestamp, - transaction, - }, - Err(_) => CoreTxRead::Unreadable("its transaction bytes do not decode"), - } - } -} - -impl PlatformWalletPersistence for FFIPersister { - /// A host attests `ATOMIC_CHANGESETS` for a round it commits or rolls back - /// whole; nothing of a rolled-back round survives on this side either, so - /// the changeset is the caller's to re-issue. The capability is the - /// declaration intersected with the wired brackets, so an attestation - /// without an `end` callback to roll anything back does not count. - fn store_transient_is_reissuable(&self) -> bool { - self.persistence_capabilities() - .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) - } - - // Fan-out coverage note: `pending_contact_crypto_added` / - // `pending_contact_crypto_cleared` have no vtable slots yet, so the - // deferred contact-crypto queue is NOT durable on FFI hosts — the - // recurring sweep re-enqueues after a restart (see the field docs on - // `PlatformWalletChangeSet`). Wire host callbacks before relying on - // restart-immediate drains. - fn persistence_capabilities(&self) -> PersistenceCapabilities { - self.declared_capabilities - .intersection(self.callback_capabilities()) - } - - fn persist_tracked_masternodes( - &self, - network: dashcore::Network, - records: &[platform_wallet::masternode::TrackedMasternode], - ) -> Result<(), PersistenceError> { - // No callback ⇒ the honest default: a session-scoped no-op. The - // TRACKED_MASTERNODES capability bit is not attested in that case, - // so callers know the difference. - let Some(persist) = self - .tracked_masternodes_callbacks - .persist_tracked_masternodes - else { - return Ok(()); - }; - let network_c = - CString::new(network.to_string()).expect("network names contain no interior NUL"); - // Own every string for the duration of the call. - let storage: Vec<(Option, CString)> = records - .iter() - .map(|record| { - let label = record.label.as_deref().and_then(|l| CString::new(l).ok()); - let snapshot = CString::new(platform_wallet::masternode::snapshot_to_json( - &record.snapshot, - )) - .expect("snapshot JSON contains no interior NUL"); - (label, snapshot) - }) - .collect(); - let rows: Vec = records - .iter() - .zip(storage.iter()) - .map(|(record, (label, snapshot))| TrackedMasternodeFFI { - pro_tx_hash: record.pro_tx_hash, - label: label - .as_ref() - .map(|l| l.as_ptr()) - .unwrap_or(std::ptr::null()), - added_at: record.added_at, - snapshot_json: snapshot.as_ptr(), - }) - .collect(); - let rc = unsafe { - persist( - self.callbacks.context, - network_c.as_ptr(), - if rows.is_empty() { - std::ptr::null() - } else { - rows.as_ptr() - }, - rows.len(), - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_persist_tracked_masternodes_fn returned error code {rc}"), - )); - } - Ok(()) - } + // 2) outgoing (sent) notes recovered via OVK + if let Some(load_outgoing) = self.callbacks.on_load_shielded_outgoing_notes_fn { + let mut out_ptr: *const ShieldedOutgoingNoteRestoreFFI = std::ptr::null(); + let mut out_count: usize = 0; + let rc = + unsafe { load_outgoing(self.callbacks.context, &mut out_ptr, &mut out_count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!( + "on_load_shielded_outgoing_notes_fn returned error code {}", + rc + ), + )); + } + struct OutgoingGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedOutgoingNoteRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedOutgoingNoteRestoreFFI, + count: usize, + } + impl Drop for OutgoingGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _outgoing_guard = OutgoingGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_outgoing_notes_free_fn, + entries: out_ptr, + count: out_count, + }; + if !out_ptr.is_null() && out_count > 0 { + let slice = unsafe { slice::from_raw_parts(out_ptr, out_count) }; + for ffi in slice { + let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { + Vec::new() + } else { + unsafe { + std::slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() + } + }; + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.outgoing_notes.push(ShieldedOutgoingNote { + cmx: ffi.cmx, + recipient: ffi.recipient.to_vec(), + value: ffi.value, + memo, + block_height: ffi.block_height, + }); + } + } + } - fn load_core_history_for_accounting( - &self, - wallet_id: WalletId, - ) -> Result>, PersistenceError> { - // A backfill that could not be stored or completed, or not as one - // round, is not worth reading. - let (Some(load), Some(free), Some(_), Some(_), Some(_), Some(_)) = ( - self.load_core_history_for_accounting_callback, - self.load_core_history_for_accounting_free_callback, - self.persist_transaction_accounting_callback, - self.persist_core_history_accounted_callback, - self.callbacks.on_changeset_begin_fn, - self.callbacks.on_changeset_end_fn, - ) else { - return Ok(None); - }; - let mut backfilled = false; - let mut rows_ptr: *const CoreHistoryTransactionFFI = std::ptr::null(); - let mut count: usize = 0; - let rc = unsafe { - load( - self.callbacks.context, - wallet_id.as_ptr(), - &mut backfilled, - &mut rows_ptr, - &mut count, - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_core_history_for_accounting_fn returned error code {rc}"), - )); - } - if rows_ptr.is_null() && count > 0 { - unsafe { free(self.callbacks.context, rows_ptr, count) }; - return Err(PersistenceError::backend(format!( - "on_load_core_history_for_accounting_fn reported {count} transactions \ - but handed over no array" - ))); - } - let mut out = Vec::with_capacity(count); - if !rows_ptr.is_null() && count > 0 { - let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; - for row in rows { - let bytes = unsafe { slice_from_raw(row.tx_bytes, row.tx_bytes_len) }; - match dashcore::consensus::encode::deserialize::(bytes) { - Ok(transaction) => out.push(transaction), - // One undecodable row only goes without accounting. - Err(e) => tracing::warn!( - error = %e, - "load: skipping an undecodable stored transaction in the accounting backfill" - ), + // 3) per-subwallet sync states + if let Some(load_states) = self.callbacks.on_load_shielded_sync_states_fn { + let mut states_ptr: *const ShieldedSubwalletSyncStateFFI = std::ptr::null(); + let mut states_count: usize = 0; + let rc = unsafe { + load_states(self.callbacks.context, &mut states_ptr, &mut states_count) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_shielded_sync_states_fn returned error code {}", rc), + )); + } + struct StatesGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + ), + >, + entries: *const ShieldedSubwalletSyncStateFFI, + count: usize, + } + impl Drop for StatesGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _states_guard = StatesGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_sync_states_free_fn, + entries: states_ptr, + count: states_count, + }; + if !states_ptr.is_null() && states_count > 0 { + let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; + for ffi in slice { + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.last_synced_index = ffi.last_synced_index; + entry.has_sync_state = true; + } } } - } - unsafe { free(self.callbacks.context, rows_ptr, count) }; - Ok((!backfilled).then_some(out)) - } - /// The backfill's rows and, when `complete`, its completion marker, as - /// one round under `round_lock`: they commit together or not at all, and - /// never land in another wallet's open round. - fn store_history_accounting( - &self, - wallet_id: WalletId, - rows: &[WalletAccounting], - complete: bool, - ) -> Result<(), PersistenceError> { - let (Some(persist_rows), Some(accounted)) = ( - self.persist_transaction_accounting_callback, - self.persist_core_history_accounted_callback, - ) else { - return Ok(()); - }; - // Rows and marker must commit as one unit; without the round pair - // nothing would make them, so nothing is written. - let (Some(begin), Some(end)) = ( - self.callbacks.on_changeset_begin_fn, - self.callbacks.on_changeset_end_fn, - ) else { - return Err(PersistenceError::backend( - "the accounting backfill needs the changeset begin/end pair to commit \ - atomically; nothing was written", - )); - }; - let mut round = self.round_lock.lock(); - round.begin_round()?; - let result = unsafe { begin(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - let _ = round.end_round(); - return Err(persist_callback_error( - result, - format!( - "changeset-begin callback returned error code {result}; \ - accounting backfill aborted before any write" - ), - )); - } - let mut outcome = RoundOutcome::default(); - if !rows.is_empty() { - let rows: Vec = - rows.iter().map(TransactionAccountingFFI::from).collect(); - let result = unsafe { - persist_rows( - self.callbacks.context, - wallet_id.as_ptr(), - rows.as_ptr(), - rows.len(), - ) - }; - if result != 0 { - outcome.record(result); - } - } - if complete && outcome.is_success() { - let result = unsafe { accounted(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - outcome.record(result); + // 4) derived activity entries + if self.callbacks.on_load_shielded_activity_fn.is_some() + != self.callbacks.on_load_shielded_activity_free_fn.is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_activity_fn and on_load_shielded_activity_free_fn must be \ + provided together", + )); } - } - let result = unsafe { - end( - self.callbacks.context, - wallet_id.as_ptr(), - outcome.is_success(), - ) - }; - if result != 0 { - if outcome.is_success() { - outcome.record(result); - } else { - outcome.record_fatal(); + if let Some(load_activity) = self.callbacks.on_load_shielded_activity_fn { + let mut act_ptr: *const ShieldedActivityRestoreFFI = std::ptr::null(); + let mut act_count: usize = 0; + let rc = + unsafe { load_activity(self.callbacks.context, &mut act_ptr, &mut act_count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_shielded_activity_fn returned error code {}", rc), + )); + } + struct ActivityGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedActivityRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedActivityRestoreFFI, + count: usize, + } + impl Drop for ActivityGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _activity_guard = ActivityGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_activity_free_fn, + entries: act_ptr, + count: act_count, + }; + if !act_ptr.is_null() && act_count > 0 { + let slice = unsafe { slice::from_raw_parts(act_ptr, act_count) }; + for ffi in slice { + let kind = match ffi.kind_tag { + 0 => ShieldedActivityKind::Shield, + 1 => ShieldedActivityKind::ShieldFromAssetLock, + 2 => ShieldedActivityKind::Received, + 3 => ShieldedActivityKind::Sent, + 4 => ShieldedActivityKind::Unshield, + 5 => ShieldedActivityKind::Withdrawal, + 6 => ShieldedActivityKind::IdentityCreate { + identity_id: ffi.identity_id, + }, + 8 => ShieldedActivityKind::ShieldFromIdentity { + identity_id: ffi.identity_id, + }, + 9 => ShieldedActivityKind::IdentityTopUp { + identity_id: ffi.identity_id, + }, + // 7 and any unknown tag fall back to the + // residual — a forward-compat tag we don't yet + // model still loads as an opaque spend rather + // than getting dropped. + _ => ShieldedActivityKind::ShieldedSpend, + }; + let direction = match ffi.direction { + 0 => ShieldedDirection::In, + 1 => ShieldedDirection::Out, + 2 => ShieldedDirection::SelfTransfer, + other => { + // Unlike kind_tag (whose residual + // `ShieldedSpend` variant is a designed + // forward-compat catch-all), direction has + // no "unknown" bucket — make a corrupted / + // future byte loud instead of silently + // reading as a real classification. + tracing::warn!( + direction = other, + "unknown shielded-activity direction byte on load; folding to SelfTransfer" + ); + ShieldedDirection::SelfTransfer + } + }; + let status = activity_status_from_tag(ffi.status); + let counterparty = if ffi.counterparty_ptr.is_null() + || ffi.counterparty_len == 0 + { + None + } else { + Some(unsafe { + slice::from_raw_parts(ffi.counterparty_ptr, ffi.counterparty_len) + .to_vec() + }) + }; + let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { + None + } else { + Some(unsafe { + slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() + }) + }; + let note_cmxs = + unsafe { decode_cmx_array(ffi.note_cmxs_ptr, ffi.note_cmxs_count) }; + let spent_nullifiers = unsafe { + decode_cmx_array(ffi.spent_nullifiers_ptr, ffi.spent_nullifiers_count) + }; + + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + let entry = shielded_state + .per_subwallet + .entry(id) + .or_insert_with(ShieldedSubwalletStartState::default); + entry.activity.push(ShieldedActivityEntry { + id: ffi.entry_id, + kind, + direction, + amount: ffi.amount, + fee: if ffi.has_fee != 0 { + Some(ffi.fee) + } else { + None + }, + counterparty, + memo, + block_height: if ffi.has_block_height != 0 { + Some(ffi.block_height) + } else { + None + }, + status, + created_at_ms: ffi.created_at_ms, + min_note_position: if ffi.has_min_note_position != 0 { + Some(ffi.min_note_position) + } else { + None + }, + note_cmxs, + spent_nullifiers, + }); + } + } } - } - round.end_round()?; - match outcome.failure_kind() { - Some(kind) => Err(PersistenceError::backend_with_kind( - self.reportable_round_kind(kind), - "the accounting backfill round failed and was rolled back", - )), - None => Ok(()), - } - } - fn load_tracked_masternodes( - &self, - network: dashcore::Network, - ) -> Result, PersistenceError> { - let Some(load) = self.tracked_masternodes_callbacks.load_tracked_masternodes else { - return Ok(Vec::new()); - }; - // Fail closed on a half-wired pair: without the free callback the - // host-allocated rows could never be returned, so every load would - // leak. Same rule as the shielded load/free arms. - let Some(free) = self - .tracked_masternodes_callbacks - .load_tracked_masternodes_free - else { - return Err(PersistenceError::backend( - "on_load_tracked_masternodes_fn requires on_load_tracked_masternodes_free_fn; \ - wire both or neither", - )); - }; - let network_c = - CString::new(network.to_string()).expect("network names contain no interior NUL"); - let mut rows_ptr: *const TrackedMasternodeFFI = std::ptr::null(); - let mut count: usize = 0; - let rc = unsafe { - load( - self.callbacks.context, - network_c.as_ptr(), - &mut rows_ptr, - &mut count, - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_tracked_masternodes_fn returned error code {rc}"), - )); - } - if rows_ptr.is_null() && count > 0 { - unsafe { free(self.callbacks.context, rows_ptr, count) }; - return Err(PersistenceError::backend(format!( - "on_load_core_history_for_accounting_fn reported {count} transactions \ - but handed over no array" - ))); - } - let mut out = Vec::with_capacity(count); - if !rows_ptr.is_null() && count > 0 { - let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; - for row in rows { - let label = if row.label.is_null() { - None - } else { - unsafe { CStr::from_ptr(row.label) } - .to_str() - .ok() - .map(str::to_string) + // 5) persisted Orchard viewing keys (raw 96-byte FVK + // encodings), consumed by + // `PlatformWallet::bind_shielded_from_persisted` so a + // launch-time rebind needs no mnemonic resolve. + if self.callbacks.on_load_shielded_viewing_keys_fn.is_some() + != self + .callbacks + .on_load_shielded_viewing_keys_free_fn + .is_some() + { + return Err(PersistenceError::backend( + "on_load_shielded_viewing_keys_fn and \ + on_load_shielded_viewing_keys_free_fn must be provided together", + )); + } + if let Some(load_viewing_keys) = self.callbacks.on_load_shielded_viewing_keys_fn { + let mut vk_ptr: *const ShieldedViewingKeyRestoreFFI = std::ptr::null(); + let mut vk_count: usize = 0; + let rc = unsafe { + load_viewing_keys(self.callbacks.context, &mut vk_ptr, &mut vk_count) }; - let snapshot = if row.snapshot_json.is_null() { - platform_wallet::masternode::TrackedMasternodeSnapshot::default() - } else { - platform_wallet::masternode::snapshot_from_json( - unsafe { CStr::from_ptr(row.snapshot_json) } - .to_str() - .unwrap_or(""), - ) + if rc != 0 { + return Err(persist_callback_error( + rc, + format!( + "on_load_shielded_viewing_keys_fn returned error code {}", + rc + ), + )); + } + struct ViewingKeysGuard { + context: *mut c_void, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + entries: *const ShieldedViewingKeyRestoreFFI, + count: usize, + ), + >, + entries: *const ShieldedViewingKeyRestoreFFI, + count: usize, + } + impl Drop for ViewingKeysGuard { + fn drop(&mut self) { + if let Some(free_fn) = self.free_fn { + unsafe { free_fn(self.context, self.entries, self.count) }; + } + } + } + let _viewing_keys_guard = ViewingKeysGuard { + context: self.callbacks.context, + free_fn: self.callbacks.on_load_shielded_viewing_keys_free_fn, + entries: vk_ptr, + count: vk_count, }; - out.push(platform_wallet::masternode::TrackedMasternode { - pro_tx_hash: row.pro_tx_hash, - label, - added_at: row.added_at, - snapshot, - }); + if !vk_ptr.is_null() && vk_count > 0 { + let slice = unsafe { slice::from_raw_parts(vk_ptr, vk_count) }; + for ffi in slice { + let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); + shielded_state + .viewing_keys + .insert(id, ffi.fvk_bytes.to_vec()); + } + } } - } - unsafe { free(self.callbacks.context, rows_ptr, count) }; - Ok(out) - } - fn stores_dashpay_backfill(&self) -> bool { - // The record reaches the host only through its extension slot; a - // host without it cannot store one, nor replace one it holds. And - // coverage vouches for the cursor it rides with, which reaches the - // host only through the changeset callback: `store` withholds every - // covering record from a host without one, so claiming the record - // here would let the owning layer count coverage as stored that the - // host never received. Such a host is treated like any other that - // cannot keep coverage durably, which still sends it the empty - // record that clears coverage it holds. - self.wallet_dashpay_backfill_callback.is_some() - && self.callbacks.on_persist_wallet_changeset_fn.is_some() - } + out.shielded = shielded_state; + } - fn store_commits_inline(&self) -> bool { - // The end callback commits (or rolls back) the host transaction before - // `store` returns. `flush` is only a later general-purpose notification. - self.callbacks.on_changeset_end_fn.is_some() + Ok(out) } - fn store( + fn load_core_instant_locks( &self, - wallet_id: WalletId, - changeset: PlatformWalletChangeSet, + wallet_id: &WalletId, + history: &mut RecordedHistory, ) -> Result<(), PersistenceError> { - // Serialize the ENTIRE begin→per-kind→end round against every - // other round producer (see `round_lock`'s field doc and - // dashpay/platform#4069). The lock is a synchronous - // `parking_lot::Mutex`, NOT a `tokio::sync::Mutex`: `store()` - // is a synchronous trait method invoked directly (blocking) from - // both async tasks and blocking FFI entry points, so an async - // mutex cannot be `.await`ed here and `blocking_lock()` panics - // inside a runtime. Serialization — not async yielding — is the - // requirement; callers already block for the round's duration, so - // the sync mutex only adds waiting under genuine round contention. - let mut round = self.round_lock.lock(); - - // An empty backfill record is an invalidation: Rust sends one to - // clear coverage the host handed back on load, before publishing a - // wallet that will scan without it. A host without the backfill slot - // can still hand coverage back — its load path decodes the record - // whether or not the write slot is wired — but it cannot replace it. - // Acknowledging the round would leave that coverage on disk beside - // cursors scanned without its accounts, for a later session with the - // slot to trust. So the round fails before anything is applied, and - // the publication with it (dashpay/platform#4302 review). - if self.wallet_dashpay_backfill_callback.is_none() - && changeset - .dashpay_backfill - .as_ref() - .is_some_and(|record| record.is_empty()) - { - return Err(PersistenceError::backend_with_kind( - PersistenceErrorKind::Fatal, - "the host holds a DashPay backfill record but registered no backfill \ - callback to invalidate it", - )); + let (load, free) = match ( + self.load_core_instant_lock_callback, + self.free_core_instant_lock_callback, + ) { + (None, None) => return Ok(()), + (Some(load), Some(free)) => (load, free), + _ => { + return Err(PersistenceError::backend( + "raw InstantLock load/free callbacks must be provided together", + )) + } + }; + struct Guard { + context: *mut c_void, + bytes: *const u8, + len: usize, + free: FreeCoreInstantLockFn, } - - // Open the round on the Rust side (rejects a nested begin / an - // unclean round left open by a prior unwind — error, never - // panic). Matched 1:1 with the `round.end_round()` below. - round.begin_round()?; - - // Bracket the whole per-kind callback sequence with a - // begin/end pair so clients (Swift, etc.) can treat the - // round as a single atomic transaction: begin opens a - // batch, each per-kind callback mutates staged state, and - // end either commits (`success == true`) or rolls back - // (`success == false`). `success` flips to false on any - // sub-callback returning non-zero; the result is advisory - // for clients (Swift uses it to decide save vs rollback). - if let Some(cb) = self.callbacks.on_changeset_begin_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - // A nonzero begin means the client could NOT open its - // transaction. Proceeding would run every per-kind - // callback against no batch and then fire an unmatched - // `end`. Close the Rust-side round (so `in_round` doesn't - // wedge) and fail now, before any per-kind write — nothing - // was applied, so the host's own classification of `result` - // is reported as-is. - let _ = round.end_round(); + impl Drop for Guard { + fn drop(&mut self) { + if !self.bytes.is_null() { + unsafe { (self.free)(self.context, self.bytes, self.len) }; + } + } + } + for record in &history.transactions { + let mut bytes = std::ptr::null(); + let mut len = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + AsRef::<[u8]>::as_ref(&record.txid).as_ptr(), + &mut bytes, + &mut len, + ) + }; + let _guard = Guard { + context: self.callbacks.context, + bytes, + len, + free, + }; + if rc != 0 { return Err(persist_callback_error( - result, - format!( - "changeset-begin callback returned error code {result}; \ - round aborted before any write" - ), + rc, + "raw InstantLock load failed".to_owned(), )); } + if bytes.is_null() && len == 0 { + continue; + } + if bytes.is_null() || len == 0 || len > dashcore::consensus::encode::MAX_VEC_SIZE { + return Err(PersistenceError::backend( + "raw InstantLock loader returned an invalid or oversized buffer", + )); + } + let raw = unsafe { slice::from_raw_parts(bytes, len) }; + let lock = dashcore::consensus::deserialize(raw).map_err(PersistenceError::backend)?; + history.instant_locks.insert(record.txid, lock); } - let mut outcome = RoundOutcome::default(); - // Whether this round's `synced_height` reached the host. A backfill - // record rides the round that lowers the cursor it vouches for, and - // must not reach the host when that cursor did not: a host without - // atomic rounds would keep the coverage over its old cursor, and no - // rollback on this side can take it back (dashpay/platform#4302 - // review). - let mut cursor_delivered = false; + Ok(()) + } - // Wallet-registration metadata. Fires at most once per round - // (registration emits the entry; subsequent rounds carry - // `wallet_metadata: None` so no callback fires). - if let Some(meta) = changeset.wallet_metadata.as_ref() { - if let Some(cb) = self.callbacks.on_persist_wallet_metadata_fn { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - meta.network.into(), - meta.wallet_group_id.as_ptr(), - meta.birth_height, - ) - }; - if result != 0 { - eprintln!( - "Wallet metadata persistence callback returned error code {}", - result - ); - outcome.record(result); - } + /// Narrow a round's failure kind to what the caller may safely act on: + /// `Transient` survives only where + /// [`Self::store_transient_is_reissuable`] holds, since losing a retry + /// opportunity costs less than the rows a re-sent partial round would + /// duplicate. `Constraint` and `Fatal` invite no retry, so they pass + /// through. + fn reportable_round_kind(&self, reported: PersistenceErrorKind) -> PersistenceErrorKind { + match reported { + PersistenceErrorKind::Transient if !self.store_transient_is_reissuable() => { + PersistenceErrorKind::Fatal } + kind => kind, } + } - // Per-account registration entries. The `_xpub_bytes_storage` - // Vec keeps the bincoded xpub buffers alive for the callback - // window — `AccountSpecFFI.account_xpub_bytes` borrows into - // it. Same lifetime discipline the prior dedicated callback - // used. - if !changeset.account_registrations.is_empty() - || !changeset.provider_key_account_registrations.is_empty() + /// Compute the callback contracts that are structurally complete in this + /// vtable. This mask is only an upper bound: the host must separately attest + /// the semantics it actually implements. + fn callback_capabilities(&self) -> PersistenceCapabilities { + let mut capabilities = PersistenceCapabilities::NONE; + + if self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() { - if let Some(cb) = self.callbacks.on_persist_account_registrations_fn { - match build_account_specs_for_callback( - &changeset.account_registrations, - &changeset.provider_key_account_registrations, - ) { - Ok((specs, _xpub_bytes_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - specs.as_ptr(), - specs.len(), - ) - }; - // Force the spec / byte buffers to live until after - // the callback even though their drop happens on - // scope exit anyway. - drop(specs); - drop(_xpub_bytes_storage); - if result != 0 { - eprintln!( - "Account registrations persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode account registration specs: {}", e); - outcome.record_fatal(); - } - } - } + capabilities = capabilities.union(PersistenceCapabilities::ATOMIC_CHANGESETS); } - - // Per-account address-pool snapshots. The `_string_storage` - // Vec keeps every owned `CString` alive for the callback - // window; `_address_storage` keeps every per-pool - // `Vec` alive (each pool holds pointers - // into a sibling string buffer); `_pools` is the heap-array - // the callback iterates over. - if !changeset.account_address_pools.is_empty() { - if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { - match build_address_pools_for_callback(&changeset.account_address_pools) { - Ok((pools, _address_storage, _string_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - pools.as_ptr(), - pools.len(), - ) - }; - drop(pools); - drop(_address_storage); - drop(_string_storage); - if result != 0 { - eprintln!( - "Account address pools persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode account address pool entries: {}", e); - outcome.record_fatal(); - } - } + if self.callbacks.on_persist_invitations_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::INVITATIONS); + } + if self.dpns_name_states_callback.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DPNS_NAME_STATES); + } + let wallet_restore = self.callbacks.on_load_wallet_list_fn.is_some() + && self.callbacks.on_load_wallet_list_free_fn.is_some(); + if self.callbacks.on_persist_account_registrations_fn.is_some() + && self.callbacks.on_persist_account_address_pools_fn.is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES); + } + if 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::CORE_HISTORY_RESTORE); + if self.replay_callbacks.complete() { + capabilities = capabilities.union(PersistenceCapabilities::WALLET_RESTORE); } } + if self.callbacks.on_persist_asset_locks_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::TRACKED_ASSET_LOCKS); + } + // Replay cleanup has no durable operation tombstones yet; UUID callbacks + // alone cannot attest ASSET_LOCK_OPERATION_IDS after conflicting history is removed. + if self + .tracked_masternodes_callbacks + .persist_tracked_masternodes + .is_some() + && self + .tracked_masternodes_callbacks + .load_tracked_masternodes + .is_some() + && self + .tracked_masternodes_callbacks + .load_tracked_masternodes_free + .is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::TRACKED_MASTERNODES); + } + if self.callbacks.on_persist_wallet_changeset_fn.is_some() + && wallet_restore + && capabilities.contains(PersistenceCapabilities::ASSET_LOCK_FUNDING_INDICES) + { + capabilities = capabilities.union(PersistenceCapabilities::PROVIDER_TRANSACTIONS); + } + if self.callbacks.on_persist_token_balances_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); + } + // The dashpay-payments slot is what the sweep's Failed flip rides + // (`dashpay_payments_overlay` on the store round). A host that + // never wired it — Android deliberately keeps payment recording + // in-memory-only — must not read as payment-durable, or the + // wallet-event adapter would couple the flip to a round that + // silently drops it: the accepted-and-ignored shape the sweep + // bit's own gating exists to prevent, reproduced one channel over. + if self.callbacks.on_persist_dashpay_payments_fn.is_some() { + capabilities = capabilities.union(PersistenceCapabilities::DASHPAY_PAYMENTS); + } + // Sweeps travel through the size-tagged extension callback, so — + // unlike the legacy `on_persist_wallet_changeset_fn`, whose + // unchanged C signature proves nothing about what a host actually + // reads — this slot being `Some` is a genuine structural + // attestation: it exists only when the host's declared extension + // `struct_size` covered the field. The changeset callback is still + // required alongside it because a sweep only corrects state that + // callback persists; a sweeps slot with no changeset slot would + // attest removals against rows the host never writes. The bit is + // still additionally gated by `declared_capabilities` in + // `persistence_capabilities()` below, like every other bit: the + // host must attest the semantic contract, not just wire pointers. + // + // The begin/end pair and `ATOMIC_CHANGESETS` are required on top, + // and only for this bit, because moving sweeps onto their own slot + // split one logical `CoreChangeSet` across two calls. Without a + // round that commits or rolls back as a unit, the changeset call + // can make the watermark and the additive rows durable and the + // process can stop before the sweep call applies the removal — + // leaving a host that restarts past a deletion it never performed + // and reloads the dead transaction. Nothing before sweeps could + // fail this way: every core field arrived through one callback. + if self.wallet_changeset_sweeps_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self.callbacks.on_changeset_begin_fn.is_some() + && self.callbacks.on_changeset_end_fn.is_some() + && self + .declared_capabilities + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) + { + capabilities = capabilities.union(PersistenceCapabilities::CORE_SWEEP_REMOVAL); + } + #[cfg(feature = "shielded")] + if self.callbacks.on_persist_shielded_viewing_keys_fn.is_some() + && self.callbacks.on_load_shielded_viewing_keys_fn.is_some() + && self + .callbacks + .on_load_shielded_viewing_keys_free_fn + .is_some() + { + capabilities = capabilities.union(PersistenceCapabilities::SHIELDED_VIEWING_KEYS); + } - // Send incremental address balance updates before merging. - if let Some(ref addr_cs) = changeset.platform_addresses { - if let Some(cb) = self.callbacks.on_persist_address_balances_fn { - let entries: Vec = addr_cs - .addresses - .iter() - .map(|entry| AddressBalanceEntryFFI { - address: entry.address.into(), - balance: entry.funds.balance, - nonce: entry.funds.nonce, - account_index: entry.account_index, - address_index: entry.address_index, - as_of_height: entry.funds.as_of_height, - }) - .collect(); - if !entries.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), - ) - }; - if result != 0 { - eprintln!( - "Address balance persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - } - } + // Deliberately absent: PersistenceCallbacks has no deferred contact + // crypto add/clear callback pair, so FFI hosts cannot attest it. + capabilities + } +} - // Send core wallet changeset (accounts, transactions, UTXOs). - if let Some(ref core_cs) = changeset.core { - // Fan out gap-limit-extension addresses BEFORE the wallet - // changeset itself: the changeset's UTXOs reference these - // addresses, and the Swift-side `upsertUtxo`'s - // `coreAddress` link lookup is keyed on the address row - // existing. Emitting the pool snapshot first means a - // brand-new TXO landing on a freshly-derived address - // finds the matching `PersistentCoreAddress` in the same - // changeset round and the cascade-delete chain stays - // intact. Reuses `on_persist_account_address_pools_fn` - // so the Swift side handles both registration-time emit - // and event-time emit through `persistAccountAddresses` - // — single Swift code path covers both. - if !core_cs.addresses_derived.is_empty() { - if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { - match build_address_pools_from_derived(&core_cs.addresses_derived) { - Ok((pools, _address_storage, _string_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - pools.as_ptr(), - pools.len(), - ) - }; - drop(pools); - drop(_address_storage); - drop(_string_storage); - if result != 0 { - eprintln!( - "Derived-address persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode derived address pool entries: {}", e); - outcome.record_fatal(); - } - } - } - } +/// What `on_get_core_tx_record_fn` answered for one txid. +enum CoreTxRead { + /// The callback is unset, or the host does not store the txid. + Missing, + /// The callback returned this non-zero code. + Failed(i32), + /// The host reported a hit but handed over no decodable body. + Unreadable(&'static str), + /// A hit: the host's raw context fields and the decoded body. + Found { + context_kind: u8, + block_height: u32, + block_hash: [u8; 32], + block_timestamp: u32, + transaction: dashcore::Transaction, + }, +} - // Fan out used-flag flips AFTER the derived-address emit: - // a tx can land on a freshly-derived address in the same - // round, and the Swift-side `persistAccountAddresses` - // overwrites `isUsed` with whatever the latest emit says — - // derived-first (`is_used: false`) then marked-used - // (`is_used: true`) leaves the row correctly flipped. - // These entries carry the authoritative post-mark - // `AddressInfo` from the wallet's pools (see - // `CoreChangeSet::addresses_marked_used`), so reusing the - // whole-pool snapshot encoder is exact, not approximate. - if !core_cs.addresses_marked_used.is_empty() { - if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { - let entries = - group_marked_used_into_pool_entries(&core_cs.addresses_marked_used); - match build_address_pools_for_callback(&entries) { - Ok((pools, _address_storage, _string_storage)) => { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - pools.as_ptr(), - pools.len(), - ) - }; - drop(pools); - drop(_address_storage); - drop(_string_storage); - if result != 0 { - eprintln!( - "Marked-used address persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - Err(e) => { - eprintln!("Failed to encode marked-used address pool entries: {}", e); - outcome.record_fatal(); - } - } - } - } +impl FFIPersister { + /// Call `on_get_core_tx_record_fn` for `txid` and decode its answer, + /// handing the tx-bytes buffer back to the host on every path. Each + /// caller applies its own policy to the outcome. + fn read_core_tx(&self, wallet_id: WalletId, txid: &dashcore::Txid) -> CoreTxRead { + use dashcore::consensus::Decodable; + use dashcore::hashes::Hash; - // The engine's credit verdicts ride their own size-negotiated - // extension slot (see the layout note on `WalletChangeSetFFI`) - // and are fired BEFORE the changeset callback: a persister that - // derives its UTXO rows from record roles needs the verdicts in - // hand while it materialises this round's `utxos_added` - // entries, or it stages the very phantom row the verdict exists - // to prevent. Fired only when the round carries a verdict, so a - // host without the slot — or a round where every output was - // credited — behaves exactly as before. - if !core_cs.utxo_credit_verdicts.is_empty() { - if let Some(cb) = self.wallet_changeset_utxo_verdicts_callback { - let verdicts = build_utxo_credit_verdicts_for_callback(core_cs); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - verdicts.as_ptr(), - verdicts.len(), - ) - }; - if result != 0 { - eprintln!( - "Wallet changeset credit-verdict persistence callback returned error \ - code {}", - result - ); - outcome.record(result); - } - } - } + let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { + return CoreTxRead::Missing; + }; - if let Some(cb) = self.callbacks.on_persist_wallet_changeset_fn { - let ffi_cs = WalletChangeSetFFI::from_changeset(core_cs); - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), &ffi_cs) }; - unsafe { free_wallet_changeset_ffi(&ffi_cs) }; - if result != 0 { - eprintln!( - "Wallet changeset persistence callback returned error code {}", - result - ); - outcome.record(result); - } else if core_cs.synced_height.is_some() { - cursor_delivered = true; - } - } + let txid_bytes: [u8; 32] = *txid.as_byte_array(); + let mut context_kind: u8 = 0; + let mut block_height: u32 = 0; + let mut block_hash: [u8; 32] = [0u8; 32]; + let mut block_timestamp: u32 = 0; + let mut tx_bytes_ptr: *const u8 = std::ptr::null(); + let mut tx_bytes_len: usize = 0; + let mut found: bool = false; - // Per-wallet accounting rides its own size-negotiated slot: the - // frozen `TransactionRecordFFI` stride cannot say "this record - // carries no accounting", and this row can, by being absent. - // Fired after the changeset callback so the host already holds - // the transaction rows these belong to. - if let Some(cb) = self.persist_transaction_accounting_callback { - let rows: Vec = core_cs - .records - .iter() - .filter_map(record_accounting) - .map(|row| TransactionAccountingFFI::from(&row)) - .collect(); - if !rows.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - rows.as_ptr(), - rows.len(), - ) - }; - if result != 0 { - eprintln!( - "Transaction accounting persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - } + // SAFETY: All output pointers reference Rust-owned stack + // locals that outlive the callback invocation. `wallet_id` + // and `txid` are fixed-size byte arrays. + let rc = unsafe { + get_cb( + self.callbacks.context, + wallet_id.as_ptr(), + txid_bytes.as_ptr(), + &mut context_kind, + &mut block_height, + block_hash.as_mut_ptr(), + &mut block_timestamp, + &mut tx_bytes_ptr, + &mut tx_bytes_len, + &mut found, + ) + }; - if let Some(callback) = self.persist_core_instant_lock_callback { - let mut locks = core_cs.instant_locks_for_non_final_records.clone(); - for record in &core_cs.records { - if let key_wallet::transaction_checking::TransactionContext::InstantSend(lock) = - &record.context - { - locks.entry(record.txid).or_insert_with(|| lock.clone()); - } - } - for (txid, lock) in locks { - let bytes = dashcore::consensus::serialize(&lock); - let rc = unsafe { - callback( - self.callbacks.context, - wallet_id.as_ptr(), - AsRef::<[u8]>::as_ref(&txid).as_ptr(), - bytes.as_ptr(), - bytes.len(), - ) - }; - if rc != 0 { - outcome.record(rc); - } + // RAII guard so the tx-bytes free callback fires on every + // exit path past this point, handing the buffer back to the host. + struct TxBytesGuard { + ptr: *const u8, + len: usize, + free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + tx_bytes: *const u8, + tx_bytes_len: usize, + ), + >, + ctx: *mut c_void, + } + impl Drop for TxBytesGuard { + fn drop(&mut self) { + if let (Some(free), false) = (self.free_fn, self.ptr.is_null()) { + // SAFETY: ptr+len match the values the lookup + // callback wrote; the host owns the allocation + // until this free fires. + unsafe { free(self.ctx, self.ptr, self.len) }; } } + } + let _bytes_guard = TxBytesGuard { + ptr: tx_bytes_ptr, + len: tx_bytes_len, + free_fn: self.callbacks.on_get_core_tx_record_free_fn, + ctx: self.callbacks.context, + }; - // The numeric chainlock height rides its own size-negotiated - // extension slot for the same layout reason the sweeps below do: - // the bincode blob on the changeset struct is opaque to a - // non-Rust host, and the frozen `WalletChangeSetFFI` cannot grow - // a numeric field. Fired before the sweeps so a round carrying - // both has the boundary stored before any tombstone the sweep - // writes could be measured against it. - if let Some(cl) = core_cs.last_applied_chain_lock.as_ref() { - if let Some(cb) = self.wallet_changeset_chain_lock_height_callback { - let result = - unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), cl.block_height) }; - if result != 0 { - eprintln!( - "Wallet changeset chainlock-height persistence callback returned \ - error code {}", - result - ); - outcome.record(result); - } - } - } + if rc != 0 { + return CoreTxRead::Failed(rc); + } + if !found { + return CoreTxRead::Missing; + } + if tx_bytes_ptr.is_null() || tx_bytes_len == 0 { + return CoreTxRead::Unreadable("handed over no transaction bytes"); + } + // SAFETY: the host guarantees `tx_bytes_ptr` points to + // `tx_bytes_len` valid bytes until the free callback — + // `_bytes_guard` keeps that window open until this returns. + let tx_slice = unsafe { slice::from_raw_parts(tx_bytes_ptr, tx_bytes_len) }; + match dashcore::Transaction::consensus_decode(&mut &tx_slice[..]) { + Ok(transaction) => CoreTxRead::Found { + context_kind, + block_height, + block_hash, + block_timestamp, + transaction, + }, + Err(_) => CoreTxRead::Unreadable("its transaction bytes do not decode"), + } + } +} - // The round's sweeps ride their own size-negotiated extension - // callback rather than the changeset struct (see the layout note - // on `WalletChangeSetFFI`), fired after the changeset callback - // and after the chainlock-height slot, still inside the same - // begin/end bracket — so the additive half of the round, a - // wallet-relevant winner's own record included, is already - // staged when the removal decides which links point at a dead - // transaction. - // - // A host without the slot simply never sees them, and this block - // stays silent about that on purpose: such a host can never - // attest `CORE_SWEEP_REMOVAL` (the derivation below requires the - // slot structurally), so the round is refused one layer up - // instead. NOTE: that refusal is the watermark-strip gate in the - // core bridge, which lands with the producer — nothing in THIS - // crate consults `CORE_SWEEP_REMOVAL` yet, and until the - // producer exists no round can carry sweeps at all. - if !core_cs.sweeps.is_empty() { - if let Some(cb) = self.wallet_changeset_sweeps_callback { - let (batches, _batch_storage) = build_sweep_batches_for_callback(core_cs); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - batches.as_ptr(), - batches.len(), - ) - }; - if result != 0 { - eprintln!( - "Wallet changeset sweeps persistence callback returned error code {}", - result - ); - outcome.record(result); - } +impl PlatformWalletPersistence for FFIPersister { + /// A host attests `ATOMIC_CHANGESETS` for a round it commits or rolls back + /// whole; nothing of a rolled-back round survives on this side either, so + /// the changeset is the caller's to re-issue. The capability is the + /// declaration intersected with the wired brackets, so an attestation + /// without an `end` callback to roll anything back does not count. + fn store_transient_is_reissuable(&self) -> bool { + self.persistence_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS) + } + + // Fan-out coverage note: `pending_contact_crypto_added` / + // `pending_contact_crypto_cleared` have no vtable slots yet, so the + // deferred contact-crypto queue is NOT durable on FFI hosts — the + // recurring sweep re-enqueues after a restart (see the field docs on + // `PlatformWalletChangeSet`). Wire host callbacks before relying on + // restart-immediate drains. + + fn persistence_capabilities(&self) -> PersistenceCapabilities { + self.declared_capabilities + .intersection(self.callback_capabilities()) + } + + fn persist_tracked_masternodes( + &self, + network: dashcore::Network, + records: &[platform_wallet::masternode::TrackedMasternode], + ) -> Result<(), PersistenceError> { + // No callback ⇒ the honest default: a session-scoped no-op. The + // TRACKED_MASTERNODES capability bit is not attested in that case, + // so callers know the difference. + let Some(persist) = self + .tracked_masternodes_callbacks + .persist_tracked_masternodes + else { + return Ok(()); + }; + let network_c = + CString::new(network.to_string()).expect("network names contain no interior NUL"); + // Own every string for the duration of the call. + let storage: Vec<(Option, CString)> = records + .iter() + .map(|record| { + let label = record.label.as_deref().and_then(|l| CString::new(l).ok()); + let snapshot = CString::new(platform_wallet::masternode::snapshot_to_json( + &record.snapshot, + )) + .expect("snapshot JSON contains no interior NUL"); + (label, snapshot) + }) + .collect(); + let rows: Vec = records + .iter() + .zip(storage.iter()) + .map(|(record, (label, snapshot))| TrackedMasternodeFFI { + pro_tx_hash: record.pro_tx_hash, + label: label + .as_ref() + .map(|l| l.as_ptr()) + .unwrap_or(std::ptr::null()), + added_at: record.added_at, + snapshot_json: snapshot.as_ptr(), + }) + .collect(); + let rc = unsafe { + persist( + self.callbacks.context, + network_c.as_ptr(), + if rows.is_empty() { + std::ptr::null() + } else { + rows.as_ptr() + }, + rows.len(), + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_persist_tracked_masternodes_fn returned error code {rc}"), + )); + } + Ok(()) + } + + fn load_core_history_for_accounting( + &self, + wallet_id: WalletId, + ) -> Result>, PersistenceError> { + // A backfill that could not be stored or completed, or not as one + // round, is not worth reading. + let (Some(load), Some(free), Some(_), Some(_), Some(_), Some(_)) = ( + self.load_core_history_for_accounting_callback, + self.load_core_history_for_accounting_free_callback, + self.persist_transaction_accounting_callback, + self.persist_core_history_accounted_callback, + self.callbacks.on_changeset_begin_fn, + self.callbacks.on_changeset_end_fn, + ) else { + return Ok(None); + }; + let mut backfilled = false; + let mut rows_ptr: *const CoreHistoryTransactionFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + &mut backfilled, + &mut rows_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_core_history_for_accounting_fn returned error code {rc}"), + )); + } + if rows_ptr.is_null() && count > 0 { + unsafe { free(self.callbacks.context, rows_ptr, count) }; + return Err(PersistenceError::backend(format!( + "on_load_core_history_for_accounting_fn reported {count} transactions \ + but handed over no array" + ))); + } + let mut out = Vec::with_capacity(count); + if !rows_ptr.is_null() && count > 0 { + let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; + for row in rows { + let bytes = unsafe { slice_from_raw(row.tx_bytes, row.tx_bytes_len) }; + match dashcore::consensus::encode::deserialize::(bytes) { + Ok(transaction) => out.push(transaction), + // One undecodable row only goes without accounting. + Err(e) => tracing::warn!( + error = %e, + "load: skipping an undecodable stored transaction in the accounting backfill" + ), } } } + unsafe { free(self.callbacks.context, rows_ptr, count) }; + Ok((!backfilled).then_some(out)) + } - // The DashPay backfill record rides its own size-negotiated slot, - // after the core changeset callback so the lowered cursor a rescan - // round carries is staged before the record that vouches for it. - // Whole-record semantics: the host replaces what it holds. - // - // A host that registered no changeset callback stores no cursor at - // all, so no record can vouch for one it holds: it never receives - // the record, not even on a record-only round. Withholding only the - // cursor-bearing round is not enough — that round still returns - // `Ok`, the reconcile records its cursor as durable, and a later - // record-only round would then deliver coverage beside a host cursor - // that never moved. - // - // Nor does a host whose rounds are not atomic: callbacks that run - // after this one (identities, payments, tokens, shielded) can still - // fail the round, and such a host would keep the cursor and the - // coverage from a round Rust reports as failed — coverage that then - // suppresses the rescan on the next launch. Only a host attesting - // `ATOMIC_CHANGESETS` rolls the whole round back, record included. - // - // Both gates guard COVERAGE. An empty record covers no contact, so it - // vouches for no cursor and is safe beside any, on any host. It is - // how Rust invalidates coverage a host already holds: - // `settle_published_backfill` sends one before publishing a wallet to - // a host that cannot keep coverage durably, so the host cannot - // restore an earlier run's coverage beside cursors this run scanned - // without it. Withholding it here would let `store` return `Ok` with - // the old coverage still on disk. So an empty record goes to every - // host with the slot, and a failing callback fails the round, which - // fails the publication. A host without the slot never received a - // record, so it holds none to invalidate. - let host_stores_cursors = self.callbacks.on_persist_wallet_changeset_fn.is_some() - && self - .persistence_capabilities() - .contains(PersistenceCapabilities::ATOMIC_CHANGESETS); - let backfill_cursor_owed = changeset - .core - .as_ref() - .is_some_and(|core| core.synced_height.is_some()); - if let Some(record) = changeset.dashpay_backfill.as_ref().filter(|record| { - record.is_empty() - || (host_stores_cursors && (!backfill_cursor_owed || cursor_delivered)) - }) { - if let Some(cb) = self.wallet_dashpay_backfill_callback { - let covered = build_dashpay_backfill_covered_for_callback(record); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - record.floor, - record.rewound_from, - if covered.is_empty() { - std::ptr::null() - } else { - covered.as_ptr() - }, - covered.len(), - ) - }; - if result != 0 { - eprintln!( - "Wallet DashPay backfill persistence callback returned error code {}", - result - ); - outcome.record(result); - } + /// The backfill's rows and, when `complete`, its completion marker, as + /// one round under `round_lock`: they commit together or not at all, and + /// never land in another wallet's open round. + fn store_history_accounting( + &self, + wallet_id: WalletId, + rows: &[WalletAccounting], + complete: bool, + ) -> Result<(), PersistenceError> { + let (Some(persist_rows), Some(accounted)) = ( + self.persist_transaction_accounting_callback, + self.persist_core_history_accounted_callback, + ) else { + return Ok(()); + }; + // Rows and marker must commit as one unit; without the round pair + // nothing would make them, so nothing is written. + let (Some(begin), Some(end)) = ( + self.callbacks.on_changeset_begin_fn, + self.callbacks.on_changeset_end_fn, + ) else { + return Err(PersistenceError::backend( + "the accounting backfill needs the changeset begin/end pair to commit \ + atomically; nothing was written", + )); + }; + let mut round = self.round_lock.lock(); + round.begin_round()?; + let result = unsafe { begin(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + let _ = round.end_round(); + return Err(persist_callback_error( + result, + format!( + "changeset-begin callback returned error code {result}; \ + accounting backfill aborted before any write" + ), + )); + } + let mut outcome = RoundOutcome::default(); + if !rows.is_empty() { + let rows: Vec = + rows.iter().map(TransactionAccountingFFI::from).collect(); + let result = unsafe { + persist_rows( + self.callbacks.context, + wallet_id.as_ptr(), + rows.as_ptr(), + rows.len(), + ) + }; + if result != 0 { + outcome.record(result); } } - - // Send identity scalar changeset — upserts and removals. - // Swift handler maps these onto `PersistentIdentity` row - // upserts / tombstones. - if let Some(ref id_cs) = changeset.identities { - if let Some(cb) = self.callbacks.on_persist_identities_fn { - // Build heap-allocated mirrors. Scoped so the Vec - // drops only after the free-loop runs even if the - // callback invocation panics. - let mut upserts: Vec = id_cs - .identities - .values() - .map(IdentityEntryFFI::from_entry) - .collect(); - let removed: Vec<[u8; 32]> = - id_cs.removed.iter().map(|id| id.to_buffer()).collect(); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - upserts.as_ptr(), - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - // Release every heap-allocated field on the FFI - // entries before the Vec drops its storage. - for entry in upserts.iter_mut() { - unsafe { free_identity_entry_ffi(entry) }; - } - if result != 0 { - eprintln!( - "Identity changeset persistence callback returned error code {}", - result - ); - outcome.record(result); - } + if complete && outcome.is_success() { + let result = unsafe { accounted(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + outcome.record(result); + } + } + let result = unsafe { + end( + self.callbacks.context, + wallet_id.as_ptr(), + outcome.is_success(), + ) + }; + if result != 0 { + if outcome.is_success() { + outcome.record(result); + } else { + outcome.record_fatal(); } } + round.end_round()?; + match outcome.failure_kind() { + Some(kind) => Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(kind), + "the accounting backfill round failed and was rolled back", + )), + None => Ok(()), + } + } - // Stamp sidecars share the begin/end transaction with identity scalars. - // Run after the legacy callback has staged any new identity rows. - if let (Some(id_cs), Some(cb)) = ( - changeset.identities.as_ref(), - self.persist_identity_balance_block_time_callback, - ) { - for entry in id_cs.identities.values() { - // The restore ABI carries only identities owned by this wallet. - // Clear any previous sidecar when ownership is absent or changed; - // observed identities must not leave an unrestorable watermark. - let stamp = entry - .last_updated_balance_block_time - .filter(|_| entry.wallet_id == Some(wallet_id)) - .map(crate::types::BlockTime::from); - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entry.id.as_bytes().as_ptr(), - stamp.as_ref().map_or(std::ptr::null(), |stamp| stamp), - ) + fn load_tracked_masternodes( + &self, + network: dashcore::Network, + ) -> Result, PersistenceError> { + let Some(load) = self.tracked_masternodes_callbacks.load_tracked_masternodes else { + return Ok(Vec::new()); + }; + // Fail closed on a half-wired pair: without the free callback the + // host-allocated rows could never be returned, so every load would + // leak. Same rule as the shielded load/free arms. + let Some(free) = self + .tracked_masternodes_callbacks + .load_tracked_masternodes_free + else { + return Err(PersistenceError::backend( + "on_load_tracked_masternodes_fn requires on_load_tracked_masternodes_free_fn; \ + wire both or neither", + )); + }; + let network_c = + CString::new(network.to_string()).expect("network names contain no interior NUL"); + let mut rows_ptr: *const TrackedMasternodeFFI = std::ptr::null(); + let mut count: usize = 0; + let rc = unsafe { + load( + self.callbacks.context, + network_c.as_ptr(), + &mut rows_ptr, + &mut count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_tracked_masternodes_fn returned error code {rc}"), + )); + } + if rows_ptr.is_null() && count > 0 { + unsafe { free(self.callbacks.context, rows_ptr, count) }; + return Err(PersistenceError::backend(format!( + "on_load_core_history_for_accounting_fn reported {count} transactions \ + but handed over no array" + ))); + } + let mut out = Vec::with_capacity(count); + if !rows_ptr.is_null() && count > 0 { + let rows = unsafe { slice::from_raw_parts(rows_ptr, count) }; + for row in rows { + let label = if row.label.is_null() { + None + } else { + unsafe { CStr::from_ptr(row.label) } + .to_str() + .ok() + .map(str::to_string) }; - if rc != 0 { - outcome.record(rc); - } - } - for identity_id in &id_cs.removed { - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - identity_id.as_bytes().as_ptr(), - std::ptr::null(), + let snapshot = if row.snapshot_json.is_null() { + platform_wallet::masternode::TrackedMasternodeSnapshot::default() + } else { + platform_wallet::masternode::snapshot_from_json( + unsafe { CStr::from_ptr(row.snapshot_json) } + .to_str() + .unwrap_or(""), ) }; - if rc != 0 { - outcome.record(rc); - } + out.push(platform_wallet::masternode::TrackedMasternode { + pro_tx_hash: row.pro_tx_hash, + label, + added_at: row.added_at, + snapshot, + }); } } + unsafe { free(self.callbacks.context, rows_ptr, count) }; + Ok(out) + } - // Send DashPay payment-history rows — the `dashpay_payments_overlay` - // ONLY. `record_dashpay_payment`, the single writer for every - // payment mutation (live sends, confirm-sweep flips, reconstruction - // upserts), emits exactly the changed `(owner, txid)` row on the - // overlay, so per-round work here is bounded by the delta. The - // full-map `IdentityEntry.dashpay_payments` snapshots riding - // `changeset.identities` are deliberately NOT projected: replaying - // an identity's complete history on every snapshot (including - // unrelated scalar mutations) is unbounded per-call work as history - // grows, against the persistence trait's bounded-work guidance — - // history bootstrap is the host restore buffer's job, and the - // getter-backed reconciler covers gaps. Fires AFTER the identities - // callback so a brand-new owner's `PersistentIdentity` row is - // already staged in the same round when the host resolves the - // payment's owner link. - if let Some(ref overlay) = changeset.dashpay_payments_overlay { - if let Some(cb) = self.callbacks.on_persist_dashpay_payments_fn { - let (entries, _string_storage) = build_payment_persist_entries(overlay); - if !entries.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), - ) - }; - drop(entries); - drop(_string_storage); - if result != 0 { - eprintln!( - "DashPay payment persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - } - } + fn stores_dashpay_backfill(&self) -> bool { + // The record reaches the host only through its extension slot; a + // host without it cannot store one, nor replace one it holds. And + // coverage vouches for the cursor it rides with, which reaches the + // host only through the changeset callback: `store` withholds every + // covering record from a host without one, so claiming the record + // here would let the owning layer count coverage as stored that the + // host never received. Such a host is treated like any other that + // cannot keep coverage durably, which still sends it the empty + // record that clears coverage it holds. + self.wallet_dashpay_backfill_callback.is_some() + && self.callbacks.on_persist_wallet_changeset_fn.is_some() + } - // Send identity-keys changeset — per-key upserts + - // `(identity_id, key_id)` removals. Maps onto Swift's - // `PersistentPublicKey` rows. - if let Some(ref keys_cs) = changeset.identity_keys { - if let Some(cb) = self.callbacks.on_persist_identity_keys_fn { - let mut upserts: Vec = keys_cs - .upserts - .values() - .map(IdentityKeyEntryFFI::from_entry) - .collect(); - let removed: Vec = keys_cs - .removed - .iter() - .map(|(id, key_id)| IdentityKeyRemovalFFI { - identity_id: id.to_buffer(), - key_id: *key_id, - }) - .collect(); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - upserts.as_ptr(), - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - for entry in upserts.iter_mut() { - unsafe { free_identity_key_entry_ffi(entry) }; - } - if result != 0 { - eprintln!( - "Identity keys changeset persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } + fn store_commits_inline(&self) -> bool { + // The end callback commits (or rolls back) the host transaction before + // `store` returns. `flush` is only a later general-purpose notification. + self.callbacks.on_changeset_end_fn.is_some() + } + + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + // Serialize the ENTIRE begin→per-kind→end round against every + // other round producer (see `round_lock`'s field doc and + // dashpay/platform#4069). The lock is a synchronous + // `parking_lot::Mutex`, NOT a `tokio::sync::Mutex`: `store()` + // is a synchronous trait method invoked directly (blocking) from + // both async tasks and blocking FFI entry points, so an async + // mutex cannot be `.await`ed here and `blocking_lock()` panics + // inside a runtime. Serialization — not async yielding — is the + // requirement; callers already block for the round's duration, so + // the sync mutex only adds waiting under genuine round contention. + let mut round = self.round_lock.lock(); + + // An empty backfill record is an invalidation: Rust sends one to + // clear coverage the host handed back on load, before publishing a + // wallet that will scan without it. A host without the backfill slot + // can still hand coverage back — its load path decodes the record + // whether or not the write slot is wired — but it cannot replace it. + // Acknowledging the round would leave that coverage on disk beside + // cursors scanned without its accounts, for a later session with the + // slot to trust. So the round fails before anything is applied, and + // the publication with it (dashpay/platform#4302 review). + if self.wallet_dashpay_backfill_callback.is_none() + && changeset + .dashpay_backfill + .as_ref() + .is_some_and(|record| record.is_empty()) + { + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Fatal, + "the host holds a DashPay backfill record but registered no backfill \ + callback to invalidate it", + )); } - // Send token-balance changeset — `(identity_id, token_id) -> - // balance` upserts and `(identity_id, token_id)` tombstones. - // Maps onto Swift's `PersistentTokenBalance` rows. - if let Some(ref tb_cs) = changeset.token_balances { - if let Some(cb) = self.callbacks.on_persist_token_balances_fn { - let upserts: Vec = tb_cs - .balances - .iter() - .map(|((iid, tid), amount)| TokenBalanceUpsertFFI { - identity_id: iid.to_buffer(), - token_id: tid.to_buffer(), - balance: *amount, - }) - .collect(); - let removals: Vec = tb_cs - .removed_balances - .iter() - .map(|(iid, tid)| TokenBalanceRemovalFFI { - identity_id: iid.to_buffer(), - token_id: tid.to_buffer(), - }) - .collect(); - if !upserts.is_empty() || !removals.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removals.is_empty() { - std::ptr::null() - } else { - removals.as_ptr() - }, - removals.len(), - ) - }; - if result != 0 { - eprintln!( - "Token balance persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } + // Open the round on the Rust side (rejects a nested begin / an + // unclean round left open by a prior unwind — error, never + // panic). Matched 1:1 with the `round.end_round()` below. + round.begin_round()?; + + // Bracket the whole per-kind callback sequence with a + // begin/end pair so clients (Swift, etc.) can treat the + // round as a single atomic transaction: begin opens a + // batch, each per-kind callback mutates staged state, and + // end either commits (`success == true`) or rolls back + // (`success == false`). `success` flips to false on any + // sub-callback returning non-zero; the result is advisory + // for clients (Swift uses it to decide save vs rollback). + if let Some(cb) = self.callbacks.on_changeset_begin_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + // A nonzero begin means the client could NOT open its + // transaction. Proceeding would run every per-kind + // callback against no batch and then fire an unmatched + // `end`. Close the Rust-side round (so `in_round` doesn't + // wedge) and fail now, before any per-kind write — nothing + // was applied, so the host's own classification of `result` + // is reported as-is. + let _ = round.end_round(); + return Err(persist_callback_error( + result, + format!( + "changeset-begin callback returned error code {result}; \ + round aborted before any write" + ), + )); } } + let mut outcome = RoundOutcome::default(); + // Whether this round's `synced_height` reached the host. A backfill + // record rides the round that lowers the cursor it vouches for, and + // must not reach the host when that cursor did not: a host without + // atomic rounds would keep the coverage over its old cursor, and no + // rollback on this side can take it back (dashpay/platform#4302 + // review). + let mut cursor_delivered = false; - // Send asset-lock changeset — tracked-lock upserts (one row - // per credit output, addressed by outpoint) and outpoint - // tombstones (consumed-by-registration removals). Maps onto - // Swift's `PersistentAssetLock` rows. - if let Some(ref al_cs) = changeset.asset_locks { - if let Some(cb) = self.callbacks.on_persist_asset_locks_fn { - let upsert_refs: Vec<&platform_wallet::changeset::AssetLockEntry> = - al_cs.asset_locks.values().collect(); - let (upserts, _storage) = build_asset_lock_entries(&upsert_refs); - let removed: Vec<[u8; 36]> = al_cs.removed.iter().map(outpoint_to_bytes).collect(); - if !upserts.is_empty() || !removed.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - // Pin both byte-buffer storage (`_storage`) and - // the FFI Vec until after the callback so the - // pointers stay valid through the C call. - drop(upserts); - drop(_storage); - if result != 0 { - eprintln!( - "Asset lock persistence callback returned error code {}", - result - ); - outcome.record(result); - } - } - } - // Preserve the legacy row stride. UUIDs are an additive sidecar, - // written only after their base rows in the same atomic round. - for entry in al_cs.asset_locks.values() { - if let Some(operation_id) = entry.operation_id { - let Some(cb) = self - .tracked_masternodes_callbacks - .persist_asset_lock_operation_id - else { - outcome.record(1); - continue; - }; - let out_point = outpoint_to_bytes(&entry.out_point); - let rc = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - out_point.as_ptr(), - operation_id.as_ptr(), - ) - }; - if rc != 0 { - outcome.record(rc); - } + // Wallet-registration metadata. Fires at most once per round + // (registration emits the entry; subsequent rounds carry + // `wallet_metadata: None` so no callback fires). + if let Some(meta) = changeset.wallet_metadata.as_ref() { + if let Some(cb) = self.callbacks.on_persist_wallet_metadata_fn { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + meta.network.into(), + meta.wallet_group_id.as_ptr(), + meta.birth_height, + ) + }; + if result != 0 { + eprintln!( + "Wallet metadata persistence callback returned error code {}", + result + ); + outcome.record(result); } } } - // Send invitation changeset — DIP-13 sent-invitation records, one - // upsert row per funded voucher (keyed by outpoint) plus outpoint - // tombstones. All-POD entries, so no owned-buffer storage to pin. - // Maps onto Swift's `PersistentInvitation` rows. - if let Some(ref inv_cs) = changeset.invitations { - if let Some(cb) = self.callbacks.on_persist_invitations_fn { - let upsert_refs: Vec<&platform_wallet::changeset::InvitationEntry> = - inv_cs.invitations.values().collect(); - let upserts = build_invitation_entries(&upsert_refs); - let removed: Vec<[u8; 36]> = inv_cs.removed.iter().map(outpoint_to_bytes).collect(); - if !upserts.is_empty() || !removed.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - drop(upserts); - if result != 0 { - eprintln!( - "Invitation persistence callback returned error code {}", - result - ); - outcome.record(result); + // Per-account registration entries. The `_xpub_bytes_storage` + // Vec keeps the bincoded xpub buffers alive for the callback + // window — `AccountSpecFFI.account_xpub_bytes` borrows into + // it. Same lifetime discipline the prior dedicated callback + // used. + if !changeset.account_registrations.is_empty() + || !changeset.provider_key_account_registrations.is_empty() + { + if let Some(cb) = self.callbacks.on_persist_account_registrations_fn { + match build_account_specs_for_callback( + &changeset.account_registrations, + &changeset.provider_key_account_registrations, + ) { + Ok((specs, _xpub_bytes_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + specs.as_ptr(), + specs.len(), + ) + }; + // Force the spec / byte buffers to live until after + // the callback even though their drop happens on + // scope exit anyway. + drop(specs); + drop(_xpub_bytes_storage); + if result != 0 { + eprintln!( + "Account registrations persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode account registration specs: {}", e); + outcome.record_fatal(); } } } } - // Send the DPNS username-marketplace changeset — one upsert row - // per tracked `domain` document (keyed by document id) plus - // document-id tombstones. Maps onto the host's DPNS-name rows, - // whose marketplace columns (price, sale status, counterparty) - // these rows own. - // - // Fires AFTER the identities callback so a brand-new identity's - // row is already staged in the same round when the host resolves - // a marketplace row's owning identity. - if let Some(ref dpns_cs) = changeset.dpns_name_states { - if let Some(cb) = self.dpns_name_states_callback { - let upsert_refs: Vec<&platform_wallet::changeset::DpnsNameStateEntry> = - dpns_cs.names.values().collect(); - let mut upserts = build_dpns_name_state_entries(&upsert_refs); - let removed: Vec<[u8; 32]> = - dpns_cs.removed.iter().map(|id| id.to_buffer()).collect(); - if !upserts.is_empty() || !removed.is_empty() { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed.is_empty() { - std::ptr::null() - } else { - removed.as_ptr() - }, - removed.len(), - ) - }; - // Release the per-row label strings on EVERY path, - // including the callback-reported-failure one, before - // the Vec drops its storage. - unsafe { free_dpns_name_state_entries(&mut upserts) }; - if result != 0 { - eprintln!( - "DPNS name state persistence callback returned error code {}", - result - ); - outcome.record(result); + // Per-account address-pool snapshots. The `_string_storage` + // Vec keeps every owned `CString` alive for the callback + // window; `_address_storage` keeps every per-pool + // `Vec` alive (each pool holds pointers + // into a sibling string buffer); `_pools` is the heap-array + // the callback iterates over. + if !changeset.account_address_pools.is_empty() { + if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { + match build_address_pools_for_callback(&changeset.account_address_pools) { + Ok((pools, _address_storage, _string_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + pools.as_ptr(), + pools.len(), + ) + }; + drop(pools); + drop(_address_storage); + drop(_string_storage); + if result != 0 { + eprintln!( + "Account address pools persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode account address pool entries: {}", e); + outcome.record_fatal(); } } } } - // Send DashPay contact-request changeset. - // - // The flat upsert array is built by walking every source - // bucket on the changeset: - // - `sent_requests` ⇒ one outgoing row per entry - // - `incoming_requests` ⇒ one incoming row per entry - // - `established` ⇒ two rows per entry (the underlying - // outgoing + incoming `ContactRequest` on - // `EstablishedContact`) so the Swift uniqueness key - // `(network, owner, contact, is_outgoing)` upserts both - // directions cleanly. The auto-establishment contract on - // the Rust side drops any matching `sent_requests` / - // `incoming_requests` entry when promoting to established, - // so this projection never produces a duplicate row in a - // single round. - // - // Removal arrays mirror the changeset's two tombstone fields - // 1:1 — Swift deletes rows by `(owner, contact, is_outgoing)` - // with the direction implied by which bucket they came from. - if let Some(ref contacts_cs) = changeset.contacts { - if let Some(cb) = self.callbacks.on_persist_contacts_fn { - let mut upserts: Vec = Vec::with_capacity( - contacts_cs.sent_requests.len() - + contacts_cs.incoming_requests.len() - + contacts_cs.established.len() * 2, - ); - for (key, entry) in &contacts_cs.sent_requests { - upserts.push(ContactRequestFFI::from_outgoing( - key.owner_id.to_buffer(), - key.recipient_id.to_buffer(), - &entry.request, - )); - } - for (key, entry) in &contacts_cs.incoming_requests { - upserts.push(ContactRequestFFI::from_incoming( - key.owner_id.to_buffer(), - key.sender_id.to_buffer(), - &entry.request, - )); - } - for (key, established) in &contacts_cs.established { - // Replicate the relationship's broken-channel flag - // and owner-private metadata (alias/note/hidden — - // contactInfo, M3) onto BOTH the outgoing and - // incoming row — they are properties of the - // established pair, not of one direction, so the - // Swift handler persists them on each - // `(owner, contact, is_outgoing)` row. - upserts.push(ContactRequestFFI::from_established_outgoing( - key.owner_id.to_buffer(), - key.recipient_id.to_buffer(), - &established.outgoing_request, - established.payment_channel_broken, - established.alias.as_deref(), - established.note.as_deref(), - established.is_hidden, - &established.accepted_accounts, - established.external_account_reference, - )); - upserts.push(ContactRequestFFI::from_established_incoming( - key.owner_id.to_buffer(), - key.recipient_id.to_buffer(), - &established.incoming_request, - established.payment_channel_broken, - established.alias.as_deref(), - established.note.as_deref(), - established.is_hidden, - // Direction-specific: the contact's account label - // rides only the incoming row. - established.contact_account_label.as_deref(), - &established.accepted_accounts, - established.external_account_reference, - )); - } - let removed_sent: Vec = contacts_cs - .removed_sent - .iter() - .map(|key| ContactRequestRemovalFFI { - owner_id: key.owner_id.to_buffer(), - contact_id: key.recipient_id.to_buffer(), - }) - .collect(); - let removed_incoming: Vec = contacts_cs - .removed_incoming + // Send incremental address balance updates before merging. + if let Some(ref addr_cs) = changeset.platform_addresses { + if let Some(cb) = self.callbacks.on_persist_address_balances_fn { + let entries: Vec = addr_cs + .addresses .iter() - .map(|key| ContactRequestRemovalFFI { - owner_id: key.owner_id.to_buffer(), - contact_id: key.sender_id.to_buffer(), + .map(|entry| AddressBalanceEntryFFI { + address: entry.address.into(), + balance: entry.funds.balance, + nonce: entry.funds.nonce, + account_index: entry.account_index, + address_index: entry.address_index, + as_of_height: entry.funds.as_of_height, }) .collect(); - // Per-sender ignore deltas, keyed `(owner, sender)`. The - // `ignored` set projects to rows with `is_ignored == true` - // (persist the ignored-sender row); the `unignored` set to - // rows with `is_ignored == false` (delete it). Both ride a - // single array so the host applies a mixed delta in one - // callback. - let ignored: Vec = - contacts_cs - .ignored - .iter() - .map(|(owner, sender)| ContactIgnoredSenderFFI::new(owner, sender, true)) - .chain(contacts_cs.unignored.iter().map(|(owner, sender)| { - ContactIgnoredSenderFFI::new(owner, sender, false) - })) - .collect(); - if !upserts.is_empty() - || !removed_sent.is_empty() - || !removed_incoming.is_empty() - || !ignored.is_empty() - { + if !entries.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - if upserts.is_empty() { - std::ptr::null() - } else { - upserts.as_ptr() - }, - upserts.len(), - if removed_sent.is_empty() { - std::ptr::null() - } else { - removed_sent.as_ptr() - }, - removed_sent.len(), - if removed_incoming.is_empty() { - std::ptr::null() - } else { - removed_incoming.as_ptr() - }, - removed_incoming.len(), - if ignored.is_empty() { - std::ptr::null() - } else { - ignored.as_ptr() - }, - ignored.len(), + entries.as_ptr(), + entries.len(), ) }; - // Release every heap-allocated payload before the - // outer Vec drops its storage. - if !upserts.is_empty() { - unsafe { free_contact_requests_ffi(upserts.as_mut_ptr(), upserts.len()) }; - } if result != 0 { eprintln!( - "Contact persistence callback returned error code {}", + "Address balance persistence callback returned error code {}", result ); outcome.record(result); @@ -3507,78 +3247,118 @@ impl PlatformWalletPersistence for FFIPersister { } } - // Send sync state updates. - if let Some(ref addr_cs) = changeset.platform_addresses { - if let Some(cb) = self.callbacks.on_persist_sync_state_fn { - let height = addr_cs.sync_height.unwrap_or(0); - let timestamp = addr_cs.sync_timestamp.unwrap_or(0); - let recent = addr_cs.last_known_recent_block.unwrap_or(0); - if height > 0 || timestamp > 0 || recent > 0 { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - height, - timestamp, - recent, - ) - }; - if result != 0 { - eprintln!( - "Sync state persistence callback returned error code {}", - result - ); - outcome.record(result); + // Send core wallet changeset (accounts, transactions, UTXOs). + if let Some(ref core_cs) = changeset.core { + // Fan out gap-limit-extension addresses BEFORE the wallet + // changeset itself: the changeset's UTXOs reference these + // addresses, and the Swift-side `upsertUtxo`'s + // `coreAddress` link lookup is keyed on the address row + // existing. Emitting the pool snapshot first means a + // brand-new TXO landing on a freshly-derived address + // finds the matching `PersistentCoreAddress` in the same + // changeset round and the cascade-delete chain stays + // intact. Reuses `on_persist_account_address_pools_fn` + // so the Swift side handles both registration-time emit + // and event-time emit through `persistAccountAddresses` + // — single Swift code path covers both. + if !core_cs.addresses_derived.is_empty() { + if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { + match build_address_pools_from_derived(&core_cs.addresses_derived) { + Ok((pools, _address_storage, _string_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + pools.as_ptr(), + pools.len(), + ) + }; + drop(pools); + drop(_address_storage); + drop(_string_storage); + if result != 0 { + eprintln!( + "Derived-address persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode derived address pool entries: {}", e); + outcome.record_fatal(); + } + } + } + } + + // Fan out used-flag flips AFTER the derived-address emit: + // a tx can land on a freshly-derived address in the same + // round, and the Swift-side `persistAccountAddresses` + // overwrites `isUsed` with whatever the latest emit says — + // derived-first (`is_used: false`) then marked-used + // (`is_used: true`) leaves the row correctly flipped. + // These entries carry the authoritative post-mark + // `AddressInfo` from the wallet's pools (see + // `CoreChangeSet::addresses_marked_used`), so reusing the + // whole-pool snapshot encoder is exact, not approximate. + if !core_cs.addresses_marked_used.is_empty() { + if let Some(cb) = self.callbacks.on_persist_account_address_pools_fn { + let entries = + group_marked_used_into_pool_entries(&core_cs.addresses_marked_used); + match build_address_pools_for_callback(&entries) { + Ok((pools, _address_storage, _string_storage)) => { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + pools.as_ptr(), + pools.len(), + ) + }; + drop(pools); + drop(_address_storage); + drop(_string_storage); + if result != 0 { + eprintln!( + "Marked-used address persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + Err(e) => { + eprintln!("Failed to encode marked-used address pool entries: {}", e); + outcome.record_fatal(); + } } } } - } - - // Shielded changeset (Orchard): four flat callback batches - // mirroring the four `ShieldedChangeSet` fields. Notes - // first so a follow-up `mark_spent` for the same nullifier - // upserts onto an existing row instead of falling on - // missing-row floor. - #[cfg(feature = "shielded")] - if let Some(ref shielded_cs) = changeset.shielded { - use crate::shielded_persistence::*; - // 1) notes_saved - if !shielded_cs.notes_saved.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_notes_fn { - // Flatten the per-subwallet map into a single - // contiguous Vec so the callback gets one - // `entries: *const ShieldedNoteFFI` slice. The - // host copies `note_data` bytes during the call. - let entries: Vec = shielded_cs - .notes_saved - .iter() - .flat_map(|(id, notes)| { - notes.iter().map(|n| ShieldedNoteFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - position: n.position, - cmx: n.cmx, - nullifier: n.nullifier, - block_height: n.block_height, - is_spent: u8::from(n.is_spent), - value: n.value, - note_data_ptr: n.note_data.as_ptr(), - note_data_len: n.note_data.len(), - }) - }) - .collect(); + // The engine's credit verdicts ride their own size-negotiated + // extension slot (see the layout note on `WalletChangeSetFFI`) + // and are fired BEFORE the changeset callback: a persister that + // derives its UTXO rows from record roles needs the verdicts in + // hand while it materialises this round's `utxos_added` + // entries, or it stages the very phantom row the verdict exists + // to prevent. Fired only when the round carries a verdict, so a + // host without the slot — or a round where every output was + // credited — behaves exactly as before. + if !core_cs.utxo_credit_verdicts.is_empty() { + if let Some(cb) = self.wallet_changeset_utxo_verdicts_callback { + let verdicts = build_utxo_credit_verdicts_for_callback(core_cs); let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + verdicts.as_ptr(), + verdicts.len(), ) }; if result != 0 { eprintln!( - "Shielded notes persistence callback returned error code {}", + "Wallet changeset credit-verdict persistence callback returned error \ + code {}", result ); outcome.record(result); @@ -3586,31 +3366,45 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 2) nullifiers_spent - if !shielded_cs.nullifiers_spent.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_nullifiers_spent_fn { - let entries: Vec = shielded_cs - .nullifiers_spent - .iter() - .flat_map(|(id, nfs)| { - nfs.iter().map(|nf| ShieldedNullifierSpentFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - nullifier: *nf, - }) - }) - .collect(); + if let Some(cb) = self.callbacks.on_persist_wallet_changeset_fn { + let ffi_cs = WalletChangeSetFFI::from_changeset(core_cs); + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), &ffi_cs) }; + unsafe { free_wallet_changeset_ffi(&ffi_cs) }; + if result != 0 { + eprintln!( + "Wallet changeset persistence callback returned error code {}", + result + ); + outcome.record(result); + } else if core_cs.synced_height.is_some() { + cursor_delivered = true; + } + } + + // Per-wallet accounting rides its own size-negotiated slot: the + // frozen `TransactionRecordFFI` stride cannot say "this record + // carries no accounting", and this row can, by being absent. + // Fired after the changeset callback so the host already holds + // the transaction rows these belong to. + if let Some(cb) = self.persist_transaction_accounting_callback { + let rows: Vec = core_cs + .records + .iter() + .filter_map(record_accounting) + .map(|row| TransactionAccountingFFI::from(&row)) + .collect(); + if !rows.is_empty() { let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + rows.as_ptr(), + rows.len(), ) }; if result != 0 { eprintln!( - "Shielded nullifier-spent persistence callback returned error code {}", + "Transaction accounting persistence callback returned error code {}", result ); outcome.record(result); @@ -3618,89 +3412,47 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 3) outgoing_notes (OVK-recovered send history). Each - // `memo_ptr` borrows into the changeset's owned `memo` - // Vec, which lives for the whole `store()` call, so the - // pointer stays valid for the callback window (same - // discipline as `note_data_ptr` above). - if !shielded_cs.outgoing_notes.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_outgoing_notes_fn { - let entries: Vec = shielded_cs - .outgoing_notes - .iter() - .flat_map(|(id, notes)| { - notes.iter().filter_map(|n| { - // `recipient` is a 43-byte raw Orchard address - // stored as a `Vec` (serde-derive only covers - // arrays <= 32). It is always exactly 43 bytes - // from OVK recovery; reject (skip + warn) a - // malformed row rather than silently zero-padding - // it into a wrong address. - let recipient: [u8; 43] = match n.recipient.as_slice().try_into() { - Ok(r) => r, - Err(_) => { - tracing::warn!( - recipient_len = n.recipient.len(), - "skipping outgoing-note persist row: \ - recipient is not the expected 43 bytes" - ); - return None; - } - }; - Some(ShieldedOutgoingNoteFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - cmx: n.cmx, - recipient, - value: n.value, - block_height: n.block_height, - memo_ptr: n.memo.as_ptr(), - memo_len: n.memo.len(), - }) - }) - }) - .collect(); - let result = unsafe { - cb( + if let Some(callback) = self.persist_core_instant_lock_callback { + let mut locks = core_cs.instant_locks_for_non_final_records.clone(); + for record in &core_cs.records { + if let key_wallet::transaction_checking::TransactionContext::InstantSend(lock) = + &record.context + { + locks.entry(record.txid).or_insert_with(|| lock.clone()); + } + } + for (txid, lock) in locks { + let bytes = dashcore::consensus::serialize(&lock); + let rc = unsafe { + callback( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + AsRef::<[u8]>::as_ref(&txid).as_ptr(), + bytes.as_ptr(), + bytes.len(), ) }; - if result != 0 { - eprintln!( - "Shielded outgoing-notes persistence callback returned error code {}", - result - ); - outcome.record(result); + if rc != 0 { + outcome.record(rc); } } } - // 4) synced_indices - if !shielded_cs.synced_indices.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_synced_indices_fn { - let entries: Vec = shielded_cs - .synced_indices - .iter() - .map(|(id, &idx)| ShieldedSyncedIndexFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - last_synced_index: idx, - }) - .collect(); - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), - ) - }; + // The numeric chainlock height rides its own size-negotiated + // extension slot for the same layout reason the sweeps below do: + // the bincode blob on the changeset struct is opaque to a + // non-Rust host, and the frozen `WalletChangeSetFFI` cannot grow + // a numeric field. Fired before the sweeps so a round carrying + // both has the boundary stored before any tombstone the sweep + // writes could be measured against it. + if let Some(cl) = core_cs.last_applied_chain_lock.as_ref() { + if let Some(cb) = self.wallet_changeset_chain_lock_height_callback { + let result = + unsafe { cb(self.callbacks.context, wallet_id.as_ptr(), cl.block_height) }; if result != 0 { eprintln!( - "Shielded synced-index persistence callback returned error code {}", + "Wallet changeset chainlock-height persistence callback returned \ + error code {}", result ); outcome.record(result); @@ -3708,154 +3460,218 @@ impl PlatformWalletPersistence for FFIPersister { } } - // 5) viewing keys (raw 96-byte FVK encodings). Fixed-size - // rows, no borrowed pointers. A malformed length can - // only come from a corrupted changeset; skip + warn so - // one bad row doesn't sink the flush. - if !shielded_cs.viewing_keys.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_viewing_keys_fn { - let entries: Vec = shielded_cs - .viewing_keys - .iter() - .filter_map(|(id, fvk)| { - let fvk_bytes: [u8; 96] = match fvk.as_slice().try_into() { - Ok(b) => b, - Err(_) => { - tracing::warn!( - fvk_len = fvk.len(), - "skipping viewing-key persist row: \ - FVK is not the expected 96 bytes" - ); - return None; - } - }; - Some(ShieldedViewingKeyFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - fvk_bytes, - }) - }) - .collect(); + // The round's sweeps ride their own size-negotiated extension + // callback rather than the changeset struct (see the layout note + // on `WalletChangeSetFFI`), fired after the changeset callback + // and after the chainlock-height slot, still inside the same + // begin/end bracket — so the additive half of the round, a + // wallet-relevant winner's own record included, is already + // staged when the removal decides which links point at a dead + // transaction. + // + // A host without the slot simply never sees them, and this block + // stays silent about that on purpose: such a host can never + // attest `CORE_SWEEP_REMOVAL` (the derivation below requires the + // slot structurally), so the round is refused one layer up + // instead. NOTE: that refusal is the watermark-strip gate in the + // core bridge, which lands with the producer — nothing in THIS + // crate consults `CORE_SWEEP_REMOVAL` yet, and until the + // producer exists no round can carry sweeps at all. + if !core_cs.sweeps.is_empty() { + if let Some(cb) = self.wallet_changeset_sweeps_callback { + let (batches, _batch_storage) = build_sweep_batches_for_callback(core_cs); let result = unsafe { cb( self.callbacks.context, wallet_id.as_ptr(), - entries.as_ptr(), - entries.len(), + batches.as_ptr(), + batches.len(), ) }; if result != 0 { eprintln!( - "Shielded viewing-key persistence callback returned error code {}", + "Wallet changeset sweeps persistence callback returned error code {}", result ); outcome.record(result); } } } + } - // 6) activity entries (derived activity log). The variable- - // length fields (counterparty / memo / cmx + nullifier - // arrays) borrow into `backing`, a Vec of owned byte - // buffers that outlives the callback — same pointer-validity - // discipline as `note_data_ptr` / `memo_ptr` above. The - // host upserts by `(wallet_id, account_index, entry_id)`. - if !shielded_cs.activity_entries.is_empty() { - if let Some(cb) = self.callbacks.on_persist_shielded_activity_fn { - // One pass pairs each entry with its owned cmx / - // nullifier buffers STRUCTURALLY (same tuple), so the - // pointer-into-backing invariant can't silently - // mis-pair if either side is ever filtered or - // reordered. The buffers live in `rows` (immutable - // once built) for the whole callback window; an inner - // `Vec`'s heap allocation is stable even as the - // outer Vec grows. - let rows: Vec<( - &platform_wallet::wallet::shielded::SubwalletId, - &platform_wallet::wallet::shielded::ShieldedActivityEntry, - Vec, - Vec, - )> = shielded_cs - .activity_entries - .iter() - .flat_map(|(id, entries)| entries.iter().map(move |e| (id, e))) - .map(|(id, e)| { - let mut cmx_buf = Vec::with_capacity(e.note_cmxs.len() * 32); - for c in &e.note_cmxs { - cmx_buf.extend_from_slice(c); - } - let mut nf_buf = Vec::with_capacity(e.spent_nullifiers.len() * 32); - for n in &e.spent_nullifiers { - nf_buf.extend_from_slice(n); - } - (id, e, cmx_buf, nf_buf) - }) - .collect(); - let entries: Vec = rows - .iter() - .map(|(id, e, cmx_buf, nf_buf)| { - let (identity_id, has_identity_id) = match &e.kind { - platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityCreate { - identity_id, - } - | platform_wallet::wallet::shielded::ShieldedActivityKind::ShieldFromIdentity { - identity_id, - } - | platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityTopUp { - identity_id, - } => (*identity_id, 1u8), - _ => ([0u8; 32], 0u8), - }; - let (counterparty_ptr, counterparty_len) = match &e.counterparty { - Some(c) if !c.is_empty() => (c.as_ptr(), c.len()), - _ => (std::ptr::null(), 0), - }; - let (memo_ptr, memo_len) = match &e.memo { - Some(m) if !m.is_empty() => (m.as_ptr(), m.len()), - _ => (std::ptr::null(), 0), - }; - ShieldedActivityFFI { - wallet_id: id.wallet_id, - account_index: id.account_index, - entry_id: e.id, - kind_tag: e.kind.tag(), - direction: activity_direction_tag(&e.direction), - status: activity_status_tag(&e.status), - amount: e.amount, - fee: e.fee.unwrap_or(0), - has_fee: u8::from(e.fee.is_some()), - block_height: e.block_height.unwrap_or(0), - has_block_height: u8::from(e.block_height.is_some()), - created_at_ms: e.created_at_ms, - min_note_position: e.min_note_position.unwrap_or(0), - has_min_note_position: u8::from(e.min_note_position.is_some()), - identity_id, - has_identity_id, - counterparty_ptr, - counterparty_len, - memo_ptr, - memo_len, - // Match the documented "valid or null" - // contract (and the counterparty/memo - // siblings): an empty Vec's `as_ptr()` is - // a dangling non-null sentinel, so emit a - // real null when there's nothing to point - // at. - note_cmxs_ptr: if cmx_buf.is_empty() { - std::ptr::null() - } else { - cmx_buf.as_ptr() - }, - note_cmxs_count: cmx_buf.len() / 32, - spent_nullifiers_ptr: if nf_buf.is_empty() { - std::ptr::null() - } else { - nf_buf.as_ptr() - }, - spent_nullifiers_count: nf_buf.len() / 32, - } - }) - .collect(); + // The DashPay backfill record rides its own size-negotiated slot, + // after the core changeset callback so the lowered cursor a rescan + // round carries is staged before the record that vouches for it. + // Whole-record semantics: the host replaces what it holds. + // + // A host that registered no changeset callback stores no cursor at + // all, so no record can vouch for one it holds: it never receives + // the record, not even on a record-only round. Withholding only the + // cursor-bearing round is not enough — that round still returns + // `Ok`, the reconcile records its cursor as durable, and a later + // record-only round would then deliver coverage beside a host cursor + // that never moved. + // + // Nor does a host whose rounds are not atomic: callbacks that run + // after this one (identities, payments, tokens, shielded) can still + // fail the round, and such a host would keep the cursor and the + // coverage from a round Rust reports as failed — coverage that then + // suppresses the rescan on the next launch. Only a host attesting + // `ATOMIC_CHANGESETS` rolls the whole round back, record included. + // + // Both gates guard COVERAGE. An empty record covers no contact, so it + // vouches for no cursor and is safe beside any, on any host. It is + // how Rust invalidates coverage a host already holds: + // `settle_published_backfill` sends one before publishing a wallet to + // a host that cannot keep coverage durably, so the host cannot + // restore an earlier run's coverage beside cursors this run scanned + // without it. Withholding it here would let `store` return `Ok` with + // the old coverage still on disk. So an empty record goes to every + // host with the slot, and a failing callback fails the round, which + // fails the publication. A host without the slot never received a + // record, so it holds none to invalidate. + let host_stores_cursors = self.callbacks.on_persist_wallet_changeset_fn.is_some() + && self + .persistence_capabilities() + .contains(PersistenceCapabilities::ATOMIC_CHANGESETS); + let backfill_cursor_owed = changeset + .core + .as_ref() + .is_some_and(|core| core.synced_height.is_some()); + if let Some(record) = changeset.dashpay_backfill.as_ref().filter(|record| { + record.is_empty() + || (host_stores_cursors && (!backfill_cursor_owed || cursor_delivered)) + }) { + if let Some(cb) = self.wallet_dashpay_backfill_callback { + let covered = build_dashpay_backfill_covered_for_callback(record); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + record.floor, + record.rewound_from, + if covered.is_empty() { + std::ptr::null() + } else { + covered.as_ptr() + }, + covered.len(), + ) + }; + if result != 0 { + eprintln!( + "Wallet DashPay backfill persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + + // Send identity scalar changeset — upserts and removals. + // Swift handler maps these onto `PersistentIdentity` row + // upserts / tombstones. + if let Some(ref id_cs) = changeset.identities { + if let Some(cb) = self.callbacks.on_persist_identities_fn { + // Build heap-allocated mirrors. Scoped so the Vec + // drops only after the free-loop runs even if the + // callback invocation panics. + let mut upserts: Vec = id_cs + .identities + .values() + .map(IdentityEntryFFI::from_entry) + .collect(); + let removed: Vec<[u8; 32]> = + id_cs.removed.iter().map(|id| id.to_buffer()).collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + upserts.as_ptr(), + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Release every heap-allocated field on the FFI + // entries before the Vec drops its storage. + for entry in upserts.iter_mut() { + unsafe { free_identity_entry_ffi(entry) }; + } + if result != 0 { + eprintln!( + "Identity changeset persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } + } + + // Stamp sidecars share the begin/end transaction with identity scalars. + // Run after the legacy callback has staged any new identity rows. + if let (Some(id_cs), Some(cb)) = ( + changeset.identities.as_ref(), + self.persist_identity_balance_block_time_callback, + ) { + for entry in id_cs.identities.values() { + // The restore ABI carries only identities owned by this wallet. + // Clear any previous sidecar when ownership is absent or changed; + // observed identities must not leave an unrestorable watermark. + let stamp = entry + .last_updated_balance_block_time + .filter(|_| entry.wallet_id == Some(wallet_id)) + .map(crate::types::BlockTime::from); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entry.id.as_bytes().as_ptr(), + stamp.as_ref().map_or(std::ptr::null(), |stamp| stamp), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + for identity_id in &id_cs.removed { + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + identity_id.as_bytes().as_ptr(), + std::ptr::null(), + ) + }; + if rc != 0 { + outcome.record(rc); + } + } + } + + // Send DashPay payment-history rows — the `dashpay_payments_overlay` + // ONLY. `record_dashpay_payment`, the single writer for every + // payment mutation (live sends, confirm-sweep flips, reconstruction + // upserts), emits exactly the changed `(owner, txid)` row on the + // overlay, so per-round work here is bounded by the delta. The + // full-map `IdentityEntry.dashpay_payments` snapshots riding + // `changeset.identities` are deliberately NOT projected: replaying + // an identity's complete history on every snapshot (including + // unrelated scalar mutations) is unbounded per-call work as history + // grows, against the persistence trait's bounded-work guidance — + // history bootstrap is the host restore buffer's job, and the + // getter-backed reconciler covers gaps. Fires AFTER the identities + // callback so a brand-new owner's `PersistentIdentity` row is + // already staged in the same round when the host resolves the + // payment's owner link. + if let Some(ref overlay) = changeset.dashpay_payments_overlay { + if let Some(cb) = self.callbacks.on_persist_dashpay_payments_fn { + let (entries, _string_storage) = build_payment_persist_entries(overlay); + if !entries.is_empty() { let result = unsafe { cb( self.callbacks.context, @@ -3864,677 +3680,939 @@ impl PlatformWalletPersistence for FFIPersister { entries.len(), ) }; + drop(entries); + drop(_string_storage); if result != 0 { eprintln!( - "Shielded activity persistence callback returned error code {}", + "DashPay payment persistence callback returned error code {}", result ); outcome.record(result); } - // `rows` and `entries` drop here, after the callback - // has copied everything it needs. - drop(rows); - } - } - } - - // Close the round. Clients use this to commit (if the round - // succeeded) or roll back (otherwise) the - // staged writes accumulated across the per-kind callbacks - // above, making the whole store() call a single atomic - // transaction from their perspective. - if let Some(cb) = self.callbacks.on_changeset_end_fn { - let result = unsafe { - cb( - self.callbacks.context, - wallet_id.as_ptr(), - outcome.is_success(), - ) - }; - if result != 0 { - eprintln!("Changeset-end callback returned error code {}", result); - // Either way `store()` must return `Err`, so the caller does - // NOT advance / clear its in-memory state (pending queues, - // cleared drain entries, ignored-sender deltas) against data - // that never reached durable storage. What differs is what the - // caller may then do about it. - if outcome.is_success() { - // A clean round: `end` is the COMMIT (the SwiftData atomic - // `save()`), and its failure applied nothing. The host's - // classification of that is legitimate. - outcome.record(result); - } else { - // A failing round: `end` fired with `success = false` to - // drive the ROLLBACK, and it failed. The round's - // disposition is now unknown, whatever the host classifies - // it as — and unknown is never retryable, because a - // re-issued changeset merges by appending. - outcome.record_fatal(); - } - } - } - - // Close the round: its `end` callback has fired (committing or - // rolling back the client transaction), or there was no end - // callback wired. Clear the state-machine flag now — BEFORE any - // early return below — so a rejected round doesn't wedge the - // persister into permanent "round already open" rejection on the - // next `store()`. (`end_round` only errors on an unmatched end, - // which cannot happen here since `begin_round` succeeded above.) - round.end_round()?; - - if let Some(kind) = outcome.failure_kind() { - return Err(PersistenceError::backend_with_kind( - self.reportable_round_kind(kind), - "one or more persistence callbacks failed; changeset was rolled back", - )); - } - - // Preserve the legacy notification phase. With an end callback, the - // host transaction is already committed and a notification failure is - // advisory. Without that atomic boundary, preserve the established - // `store()` error contract for legacy hosts that use this callback as - // their durable-write boundary. - if let Some(cb) = self.callbacks.on_store_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - if self.callbacks.on_changeset_end_fn.is_some() { - eprintln!( - "Persistence store callback returned post-commit error code {result}; \ - ignored" - ); - } else { - // No end callback, so the per-kind writes already landed - // individually and the round is not all-or-nothing — - // `reportable_round_kind` withholds a retryable verdict. - return Err(PersistenceError::backend_with_kind( - self.reportable_round_kind(persist_rc_kind(result)), - format!("Persistence store callback returned error code {result}"), - )); } } } - Ok(()) - } - - fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { - // TODO: deferred — trailing-byte validation on decoded FFI - // payloads is not yet applied here. - // Notify caller. - if let Some(cb) = self.callbacks.on_flush_fn { - let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; - if result != 0 { - return Err(persist_callback_error( - result, - format!("Persistence flush callback returned error code {}", result), - )); - } - } - - Ok(()) - } - - fn load(&self) -> Result { - // Host loaders may read the same context that store() stages into. - // Keep base rows and every sidecar in one committed snapshot: a load - // between per-kind callbacks and end must never expose a Consumed row - // whose commit can still fail. Callbacks must not re-enter this API. - let round = self.round_lock.lock(); - if round.in_round { - return Err(PersistenceError::backend( - "FFIPersister: cannot restore from an unclosed changeset round", - )); - } - // If Swift hasn't wired up `on_load_wallet_list_fn` there's - // nothing to restore — treat as a fresh client. - let Some(load_cb) = self.callbacks.on_load_wallet_list_fn else { - return Ok(ClientStartState::default()); - }; - - // Swift allocates the entries array (and every nested byte - // buffer); we read it under a `Guard` that calls the matching - // free callback on drop, so failures inside the loop don't - // leak. - let mut entries_ptr: *const WalletRestoreEntryFFI = std::ptr::null(); - let mut count: usize = 0; - let rc = unsafe { load_cb(self.callbacks.context, &mut entries_ptr, &mut count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_wallet_list_fn returned error code {}", rc), - )); - } - let _guard = LoadGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_wallet_list_free_fn, - entries: entries_ptr, - count, - }; - - let mut out = ClientStartState::default(); - if entries_ptr.is_null() && count != 0 { - return Err(PersistenceError::backend( - "wallet restore: missing row buffer", - )); - } - if count == 0 { - return Ok(out); - } - - // SAFETY: Swift guarantees `entries_ptr` points to `count` - // contiguous `WalletRestoreEntryFFI` values for the lifetime - // of the callback window. `_guard` ensures the free callback - // fires before we leave this function. - let entries = unsafe { slice::from_raw_parts(entries_ptr, count) }; - for entry in entries { - if out.wallets.contains_key(&entry.wallet_id) { - return Err(PersistenceError::backend( - "wallet restore: duplicate wallet ID", - )); - } - let (mut wallet_state, platform_address_state) = build_wallet_start_state(entry)?; - self.load_core_instant_locks(&entry.wallet_id, &mut wallet_state.recorded_history)?; - if let Some(cb) = self - .tracked_masternodes_callbacks - .load_asset_lock_operation_id - { - load_asset_lock_operation_ids( - cb, - self.callbacks.context, - &entry.wallet_id, - &mut wallet_state.unused_asset_locks, - )?; - } - if let Some(cb) = self.load_identity_balance_block_time_callback { - for identities in wallet_state.identity_manager.wallet_identities.values_mut() { - for managed in identities.values_mut() { - let mut found = false; - let mut stamp = crate::types::BlockTime { - height: 0, - core_height: 0, - timestamp: 0, - }; - let rc = unsafe { - cb( - self.callbacks.context, - entry.wallet_id.as_ptr(), - managed.id().as_bytes().as_ptr(), - &mut found, - &mut stamp, - ) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - "Loading identity balance block time failed".to_string(), - )); - } - managed.last_updated_balance_block_time = found.then(|| stamp.into()); - } + // Send identity-keys changeset — per-key upserts + + // `(identity_id, key_id)` removals. Maps onto Swift's + // `PersistentPublicKey` rows. + if let Some(ref keys_cs) = changeset.identity_keys { + if let Some(cb) = self.callbacks.on_persist_identity_keys_fn { + let mut upserts: Vec = keys_cs + .upserts + .values() + .map(IdentityKeyEntryFFI::from_entry) + .collect(); + let removed: Vec = keys_cs + .removed + .iter() + .map(|(id, key_id)| IdentityKeyRemovalFFI { + identity_id: id.to_buffer(), + key_id: *key_id, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + upserts.as_ptr(), + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + for entry in upserts.iter_mut() { + unsafe { free_identity_key_entry_ffi(entry) }; + } + if result != 0 { + eprintln!( + "Identity keys changeset persistence callback returned error code {}", + result + ); + outcome.record(result); } - } - out.wallets.insert(entry.wallet_id, wallet_state); - if let Some(platform_address_state) = platform_address_state { - out.platform_addresses - .insert(entry.wallet_id, platform_address_state); } } - // Restore shielded sub-wallet state if the host has wired - // up the optional callbacks. Notes and per-subwallet sync - // states travel separately so the host can populate them - // from independent SwiftData fetch descriptors. Both arms - // walk the same `(wallet_id, account_index)` key space and - // funnel into a single `SubwalletId` map on - // `ClientStartState.shielded`. - #[cfg(feature = "shielded")] - { - use crate::shielded_persistence::*; - use platform_wallet::changeset::{ShieldedSubwalletStartState, ShieldedSyncStartState}; - use platform_wallet::wallet::shielded::{ - ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, ShieldedNote, - ShieldedOutgoingNote, SubwalletId, - }; - - let mut shielded_state = ShieldedSyncStartState::default(); - - // Fail fast on a half-wired callback pair: a loader without - // its matching free callback leaks the host-allocated buffer - // on every successful load (the guard's `Drop` is a no-op - // when `free_fn` is `None`). - if self.callbacks.on_load_shielded_notes_fn.is_some() - != self.callbacks.on_load_shielded_notes_free_fn.is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_notes_fn and on_load_shielded_notes_free_fn must be \ - provided together", - )); - } - if self.callbacks.on_load_shielded_sync_states_fn.is_some() - != self - .callbacks - .on_load_shielded_sync_states_free_fn - .is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_sync_states_fn and on_load_shielded_sync_states_free_fn \ - must be provided together", - )); - } - if self.callbacks.on_load_shielded_outgoing_notes_fn.is_some() - != self - .callbacks - .on_load_shielded_outgoing_notes_free_fn - .is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_outgoing_notes_fn and \ - on_load_shielded_outgoing_notes_free_fn must be provided together", - )); + // Send token-balance changeset — `(identity_id, token_id) -> + // balance` upserts and `(identity_id, token_id)` tombstones. + // Maps onto Swift's `PersistentTokenBalance` rows. + if let Some(ref tb_cs) = changeset.token_balances { + if let Some(cb) = self.callbacks.on_persist_token_balances_fn { + let upserts: Vec = tb_cs + .balances + .iter() + .map(|((iid, tid), amount)| TokenBalanceUpsertFFI { + identity_id: iid.to_buffer(), + token_id: tid.to_buffer(), + balance: *amount, + }) + .collect(); + let removals: Vec = tb_cs + .removed_balances + .iter() + .map(|(iid, tid)| TokenBalanceRemovalFFI { + identity_id: iid.to_buffer(), + token_id: tid.to_buffer(), + }) + .collect(); + if !upserts.is_empty() || !removals.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removals.is_empty() { + std::ptr::null() + } else { + removals.as_ptr() + }, + removals.len(), + ) + }; + if result != 0 { + eprintln!( + "Token balance persistence callback returned error code {}", + result + ); + outcome.record(result); + } + } } + } - // 1) notes - if let Some(load_notes) = self.callbacks.on_load_shielded_notes_fn { - let mut notes_ptr: *const ShieldedNoteRestoreFFI = std::ptr::null(); - let mut notes_count: usize = 0; - let rc = - unsafe { load_notes(self.callbacks.context, &mut notes_ptr, &mut notes_count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_shielded_notes_fn returned error code {}", rc), - )); + // Send asset-lock changeset — tracked-lock upserts (one row + // per credit output, addressed by outpoint) and outpoint + // tombstones (consumed-by-registration removals). Maps onto + // Swift's `PersistentAssetLock` rows. + if let Some(ref al_cs) = changeset.asset_locks { + if let Some(cb) = self.callbacks.on_persist_asset_locks_fn { + let upsert_refs: Vec<&platform_wallet::changeset::AssetLockEntry> = + al_cs.asset_locks.values().collect(); + let (upserts, _storage) = build_asset_lock_entries(&upsert_refs); + let removed: Vec<[u8; 36]> = al_cs.removed.iter().map(outpoint_to_bytes).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Pin both byte-buffer storage (`_storage`) and + // the FFI Vec until after the callback so the + // pointers stay valid through the C call. + drop(upserts); + drop(_storage); + if result != 0 { + eprintln!( + "Asset lock persistence callback returned error code {}", + result + ); + outcome.record(result); + } } - struct NotesGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedNoteRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedNoteRestoreFFI, - count: usize, + } + // Preserve the legacy row stride. UUIDs are an additive sidecar, + // written only after their base rows in the same atomic round. + for entry in al_cs.asset_locks.values() { + if let Some(operation_id) = entry.operation_id { + let Some(cb) = self + .tracked_masternodes_callbacks + .persist_asset_lock_operation_id + else { + outcome.record(1); + continue; + }; + let out_point = outpoint_to_bytes(&entry.out_point); + let rc = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + out_point.as_ptr(), + operation_id.as_ptr(), + ) + }; + if rc != 0 { + outcome.record(rc); + } } - impl Drop for NotesGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } + } + } + + // Send invitation changeset — DIP-13 sent-invitation records, one + // upsert row per funded voucher (keyed by outpoint) plus outpoint + // tombstones. All-POD entries, so no owned-buffer storage to pin. + // Maps onto Swift's `PersistentInvitation` rows. + if let Some(ref inv_cs) = changeset.invitations { + if let Some(cb) = self.callbacks.on_persist_invitations_fn { + let upsert_refs: Vec<&platform_wallet::changeset::InvitationEntry> = + inv_cs.invitations.values().collect(); + let upserts = build_invitation_entries(&upsert_refs); + let removed: Vec<[u8; 36]> = inv_cs.removed.iter().map(outpoint_to_bytes).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + drop(upserts); + if result != 0 { + eprintln!( + "Invitation persistence callback returned error code {}", + result + ); + outcome.record(result); } } - let _notes_guard = NotesGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_notes_free_fn, - entries: notes_ptr, - count: notes_count, - }; - if !notes_ptr.is_null() && notes_count > 0 { - let slice = unsafe { slice::from_raw_parts(notes_ptr, notes_count) }; - for ffi in slice { - if ffi.note_data_ptr.is_null() || ffi.note_data_len == 0 { - continue; - } - let note_data = unsafe { - std::slice::from_raw_parts(ffi.note_data_ptr, ffi.note_data_len) - .to_vec() - }; - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.notes.push(ShieldedNote { - position: ffi.position, - cmx: ffi.cmx, - nullifier: ffi.nullifier, - block_height: ffi.block_height, - is_spent: ffi.is_spent != 0, - value: ffi.value, - note_data, - }); + } + } + + // Send the DPNS username-marketplace changeset — one upsert row + // per tracked `domain` document (keyed by document id) plus + // document-id tombstones. Maps onto the host's DPNS-name rows, + // whose marketplace columns (price, sale status, counterparty) + // these rows own. + // + // Fires AFTER the identities callback so a brand-new identity's + // row is already staged in the same round when the host resolves + // a marketplace row's owning identity. + if let Some(ref dpns_cs) = changeset.dpns_name_states { + if let Some(cb) = self.dpns_name_states_callback { + let upsert_refs: Vec<&platform_wallet::changeset::DpnsNameStateEntry> = + dpns_cs.names.values().collect(); + let mut upserts = build_dpns_name_state_entries(&upsert_refs); + let removed: Vec<[u8; 32]> = + dpns_cs.removed.iter().map(|id| id.to_buffer()).collect(); + if !upserts.is_empty() || !removed.is_empty() { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed.is_empty() { + std::ptr::null() + } else { + removed.as_ptr() + }, + removed.len(), + ) + }; + // Release the per-row label strings on EVERY path, + // including the callback-reported-failure one, before + // the Vec drops its storage. + unsafe { free_dpns_name_state_entries(&mut upserts) }; + if result != 0 { + eprintln!( + "DPNS name state persistence callback returned error code {}", + result + ); + outcome.record(result); } } } + } - // 2) outgoing (sent) notes recovered via OVK - if let Some(load_outgoing) = self.callbacks.on_load_shielded_outgoing_notes_fn { - let mut out_ptr: *const ShieldedOutgoingNoteRestoreFFI = std::ptr::null(); - let mut out_count: usize = 0; - let rc = - unsafe { load_outgoing(self.callbacks.context, &mut out_ptr, &mut out_count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!( - "on_load_shielded_outgoing_notes_fn returned error code {}", - rc - ), + // Send DashPay contact-request changeset. + // + // The flat upsert array is built by walking every source + // bucket on the changeset: + // - `sent_requests` ⇒ one outgoing row per entry + // - `incoming_requests` ⇒ one incoming row per entry + // - `established` ⇒ two rows per entry (the underlying + // outgoing + incoming `ContactRequest` on + // `EstablishedContact`) so the Swift uniqueness key + // `(network, owner, contact, is_outgoing)` upserts both + // directions cleanly. The auto-establishment contract on + // the Rust side drops any matching `sent_requests` / + // `incoming_requests` entry when promoting to established, + // so this projection never produces a duplicate row in a + // single round. + // + // Removal arrays mirror the changeset's two tombstone fields + // 1:1 — Swift deletes rows by `(owner, contact, is_outgoing)` + // with the direction implied by which bucket they came from. + if let Some(ref contacts_cs) = changeset.contacts { + if let Some(cb) = self.callbacks.on_persist_contacts_fn { + let mut upserts: Vec = Vec::with_capacity( + contacts_cs.sent_requests.len() + + contacts_cs.incoming_requests.len() + + contacts_cs.established.len() * 2, + ); + for (key, entry) in &contacts_cs.sent_requests { + upserts.push(ContactRequestFFI::from_outgoing( + key.owner_id.to_buffer(), + key.recipient_id.to_buffer(), + &entry.request, )); } - struct OutgoingGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedOutgoingNoteRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedOutgoingNoteRestoreFFI, - count: usize, + for (key, entry) in &contacts_cs.incoming_requests { + upserts.push(ContactRequestFFI::from_incoming( + key.owner_id.to_buffer(), + key.sender_id.to_buffer(), + &entry.request, + )); } - impl Drop for OutgoingGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } + for (key, established) in &contacts_cs.established { + // Replicate the relationship's broken-channel flag + // and owner-private metadata (alias/note/hidden — + // contactInfo, M3) onto BOTH the outgoing and + // incoming row — they are properties of the + // established pair, not of one direction, so the + // Swift handler persists them on each + // `(owner, contact, is_outgoing)` row. + upserts.push(ContactRequestFFI::from_established_outgoing( + key.owner_id.to_buffer(), + key.recipient_id.to_buffer(), + &established.outgoing_request, + established.payment_channel_broken, + established.alias.as_deref(), + established.note.as_deref(), + established.is_hidden, + &established.accepted_accounts, + established.external_account_reference, + )); + upserts.push(ContactRequestFFI::from_established_incoming( + key.owner_id.to_buffer(), + key.recipient_id.to_buffer(), + &established.incoming_request, + established.payment_channel_broken, + established.alias.as_deref(), + established.note.as_deref(), + established.is_hidden, + // Direction-specific: the contact's account label + // rides only the incoming row. + established.contact_account_label.as_deref(), + &established.accepted_accounts, + established.external_account_reference, + )); + } + let removed_sent: Vec = contacts_cs + .removed_sent + .iter() + .map(|key| ContactRequestRemovalFFI { + owner_id: key.owner_id.to_buffer(), + contact_id: key.recipient_id.to_buffer(), + }) + .collect(); + let removed_incoming: Vec = contacts_cs + .removed_incoming + .iter() + .map(|key| ContactRequestRemovalFFI { + owner_id: key.owner_id.to_buffer(), + contact_id: key.sender_id.to_buffer(), + }) + .collect(); + // Per-sender ignore deltas, keyed `(owner, sender)`. The + // `ignored` set projects to rows with `is_ignored == true` + // (persist the ignored-sender row); the `unignored` set to + // rows with `is_ignored == false` (delete it). Both ride a + // single array so the host applies a mixed delta in one + // callback. + let ignored: Vec = + contacts_cs + .ignored + .iter() + .map(|(owner, sender)| ContactIgnoredSenderFFI::new(owner, sender, true)) + .chain(contacts_cs.unignored.iter().map(|(owner, sender)| { + ContactIgnoredSenderFFI::new(owner, sender, false) + })) + .collect(); + if !upserts.is_empty() + || !removed_sent.is_empty() + || !removed_incoming.is_empty() + || !ignored.is_empty() + { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + if upserts.is_empty() { + std::ptr::null() + } else { + upserts.as_ptr() + }, + upserts.len(), + if removed_sent.is_empty() { + std::ptr::null() + } else { + removed_sent.as_ptr() + }, + removed_sent.len(), + if removed_incoming.is_empty() { + std::ptr::null() + } else { + removed_incoming.as_ptr() + }, + removed_incoming.len(), + if ignored.is_empty() { + std::ptr::null() + } else { + ignored.as_ptr() + }, + ignored.len(), + ) + }; + // Release every heap-allocated payload before the + // outer Vec drops its storage. + if !upserts.is_empty() { + unsafe { free_contact_requests_ffi(upserts.as_mut_ptr(), upserts.len()) }; + } + if result != 0 { + eprintln!( + "Contact persistence callback returned error code {}", + result + ); + outcome.record(result); } } - let _outgoing_guard = OutgoingGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_outgoing_notes_free_fn, - entries: out_ptr, - count: out_count, - }; - if !out_ptr.is_null() && out_count > 0 { - let slice = unsafe { slice::from_raw_parts(out_ptr, out_count) }; - for ffi in slice { - let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { - Vec::new() - } else { - unsafe { - std::slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() - } - }; - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.outgoing_notes.push(ShieldedOutgoingNote { - cmx: ffi.cmx, - recipient: ffi.recipient.to_vec(), - value: ffi.value, - memo, - block_height: ffi.block_height, - }); + } + } + + // Send sync state updates. + if let Some(ref addr_cs) = changeset.platform_addresses { + if let Some(cb) = self.callbacks.on_persist_sync_state_fn { + let height = addr_cs.sync_height.unwrap_or(0); + let timestamp = addr_cs.sync_timestamp.unwrap_or(0); + let recent = addr_cs.last_known_recent_block.unwrap_or(0); + if height > 0 || timestamp > 0 || recent > 0 { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + height, + timestamp, + recent, + ) + }; + if result != 0 { + eprintln!( + "Sync state persistence callback returned error code {}", + result + ); + outcome.record(result); } } } + } - // 3) per-subwallet sync states - if let Some(load_states) = self.callbacks.on_load_shielded_sync_states_fn { - let mut states_ptr: *const ShieldedSubwalletSyncStateFFI = std::ptr::null(); - let mut states_count: usize = 0; - let rc = unsafe { - load_states(self.callbacks.context, &mut states_ptr, &mut states_count) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_shielded_sync_states_fn returned error code {}", rc), - )); + // Shielded changeset (Orchard): four flat callback batches + // mirroring the four `ShieldedChangeSet` fields. Notes + // first so a follow-up `mark_spent` for the same nullifier + // upserts onto an existing row instead of falling on + // missing-row floor. + #[cfg(feature = "shielded")] + if let Some(ref shielded_cs) = changeset.shielded { + use crate::shielded_persistence::*; + + // 1) notes_saved + if !shielded_cs.notes_saved.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_notes_fn { + // Flatten the per-subwallet map into a single + // contiguous Vec so the callback gets one + // `entries: *const ShieldedNoteFFI` slice. The + // host copies `note_data` bytes during the call. + let entries: Vec = shielded_cs + .notes_saved + .iter() + .flat_map(|(id, notes)| { + notes.iter().map(|n| ShieldedNoteFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + position: n.position, + cmx: n.cmx, + nullifier: n.nullifier, + block_height: n.block_height, + is_spent: u8::from(n.is_spent), + value: n.value, + note_data_ptr: n.note_data.as_ptr(), + note_data_len: n.note_data.len(), + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded notes persistence callback returned error code {}", + result + ); + outcome.record(result); + } } - struct StatesGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedSubwalletSyncStateFFI, - count: usize, - ), - >, - entries: *const ShieldedSubwalletSyncStateFFI, - count: usize, + } + + // 2) nullifiers_spent + if !shielded_cs.nullifiers_spent.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_nullifiers_spent_fn { + let entries: Vec = shielded_cs + .nullifiers_spent + .iter() + .flat_map(|(id, nfs)| { + nfs.iter().map(|nf| ShieldedNullifierSpentFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + nullifier: *nf, + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded nullifier-spent persistence callback returned error code {}", + result + ); + outcome.record(result); + } } - impl Drop for StatesGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } + } + + // 3) outgoing_notes (OVK-recovered send history). Each + // `memo_ptr` borrows into the changeset's owned `memo` + // Vec, which lives for the whole `store()` call, so the + // pointer stays valid for the callback window (same + // discipline as `note_data_ptr` above). + if !shielded_cs.outgoing_notes.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_outgoing_notes_fn { + let entries: Vec = shielded_cs + .outgoing_notes + .iter() + .flat_map(|(id, notes)| { + notes.iter().filter_map(|n| { + // `recipient` is a 43-byte raw Orchard address + // stored as a `Vec` (serde-derive only covers + // arrays <= 32). It is always exactly 43 bytes + // from OVK recovery; reject (skip + warn) a + // malformed row rather than silently zero-padding + // it into a wrong address. + let recipient: [u8; 43] = match n.recipient.as_slice().try_into() { + Ok(r) => r, + Err(_) => { + tracing::warn!( + recipient_len = n.recipient.len(), + "skipping outgoing-note persist row: \ + recipient is not the expected 43 bytes" + ); + return None; + } + }; + Some(ShieldedOutgoingNoteFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + cmx: n.cmx, + recipient, + value: n.value, + block_height: n.block_height, + memo_ptr: n.memo.as_ptr(), + memo_len: n.memo.len(), + }) + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded outgoing-notes persistence callback returned error code {}", + result + ); + outcome.record(result); } } - let _states_guard = StatesGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_sync_states_free_fn, - entries: states_ptr, - count: states_count, - }; - if !states_ptr.is_null() && states_count > 0 { - let slice = unsafe { slice::from_raw_parts(states_ptr, states_count) }; - for ffi in slice { - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.last_synced_index = ffi.last_synced_index; - entry.has_sync_state = true; + } + + // 4) synced_indices + if !shielded_cs.synced_indices.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_synced_indices_fn { + let entries: Vec = shielded_cs + .synced_indices + .iter() + .map(|(id, &idx)| ShieldedSyncedIndexFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + last_synced_index: idx, + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded synced-index persistence callback returned error code {}", + result + ); + outcome.record(result); } } } - // 4) derived activity entries - if self.callbacks.on_load_shielded_activity_fn.is_some() - != self.callbacks.on_load_shielded_activity_free_fn.is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_activity_fn and on_load_shielded_activity_free_fn must be \ - provided together", - )); - } - if let Some(load_activity) = self.callbacks.on_load_shielded_activity_fn { - let mut act_ptr: *const ShieldedActivityRestoreFFI = std::ptr::null(); - let mut act_count: usize = 0; - let rc = - unsafe { load_activity(self.callbacks.context, &mut act_ptr, &mut act_count) }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!("on_load_shielded_activity_fn returned error code {}", rc), - )); - } - struct ActivityGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedActivityRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedActivityRestoreFFI, - count: usize, - } - impl Drop for ActivityGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } + // 5) viewing keys (raw 96-byte FVK encodings). Fixed-size + // rows, no borrowed pointers. A malformed length can + // only come from a corrupted changeset; skip + warn so + // one bad row doesn't sink the flush. + if !shielded_cs.viewing_keys.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_viewing_keys_fn { + let entries: Vec = shielded_cs + .viewing_keys + .iter() + .filter_map(|(id, fvk)| { + let fvk_bytes: [u8; 96] = match fvk.as_slice().try_into() { + Ok(b) => b, + Err(_) => { + tracing::warn!( + fvk_len = fvk.len(), + "skipping viewing-key persist row: \ + FVK is not the expected 96 bytes" + ); + return None; + } + }; + Some(ShieldedViewingKeyFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + fvk_bytes, + }) + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded viewing-key persistence callback returned error code {}", + result + ); + outcome.record(result); } } - let _activity_guard = ActivityGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_activity_free_fn, - entries: act_ptr, - count: act_count, - }; - if !act_ptr.is_null() && act_count > 0 { - let slice = unsafe { slice::from_raw_parts(act_ptr, act_count) }; - for ffi in slice { - let kind = match ffi.kind_tag { - 0 => ShieldedActivityKind::Shield, - 1 => ShieldedActivityKind::ShieldFromAssetLock, - 2 => ShieldedActivityKind::Received, - 3 => ShieldedActivityKind::Sent, - 4 => ShieldedActivityKind::Unshield, - 5 => ShieldedActivityKind::Withdrawal, - 6 => ShieldedActivityKind::IdentityCreate { - identity_id: ffi.identity_id, - }, - 8 => ShieldedActivityKind::ShieldFromIdentity { - identity_id: ffi.identity_id, - }, - 9 => ShieldedActivityKind::IdentityTopUp { - identity_id: ffi.identity_id, - }, - // 7 and any unknown tag fall back to the - // residual — a forward-compat tag we don't yet - // model still loads as an opaque spend rather - // than getting dropped. - _ => ShieldedActivityKind::ShieldedSpend, - }; - let direction = match ffi.direction { - 0 => ShieldedDirection::In, - 1 => ShieldedDirection::Out, - 2 => ShieldedDirection::SelfTransfer, - other => { - // Unlike kind_tag (whose residual - // `ShieldedSpend` variant is a designed - // forward-compat catch-all), direction has - // no "unknown" bucket — make a corrupted / - // future byte loud instead of silently - // reading as a real classification. - tracing::warn!( - direction = other, - "unknown shielded-activity direction byte on load; folding to SelfTransfer" - ); - ShieldedDirection::SelfTransfer - } - }; - let status = activity_status_from_tag(ffi.status); - let counterparty = if ffi.counterparty_ptr.is_null() - || ffi.counterparty_len == 0 - { - None - } else { - Some(unsafe { - slice::from_raw_parts(ffi.counterparty_ptr, ffi.counterparty_len) - .to_vec() - }) - }; - let memo = if ffi.memo_ptr.is_null() || ffi.memo_len == 0 { - None - } else { - Some(unsafe { - slice::from_raw_parts(ffi.memo_ptr, ffi.memo_len).to_vec() - }) - }; - let note_cmxs = - unsafe { decode_cmx_array(ffi.note_cmxs_ptr, ffi.note_cmxs_count) }; - let spent_nullifiers = unsafe { - decode_cmx_array(ffi.spent_nullifiers_ptr, ffi.spent_nullifiers_count) - }; + } - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - let entry = shielded_state - .per_subwallet - .entry(id) - .or_insert_with(ShieldedSubwalletStartState::default); - entry.activity.push(ShieldedActivityEntry { - id: ffi.entry_id, - kind, - direction, - amount: ffi.amount, - fee: if ffi.has_fee != 0 { - Some(ffi.fee) - } else { - None - }, - counterparty, - memo, - block_height: if ffi.has_block_height != 0 { - Some(ffi.block_height) - } else { - None - }, - status, - created_at_ms: ffi.created_at_ms, - min_note_position: if ffi.has_min_note_position != 0 { - Some(ffi.min_note_position) - } else { - None - }, - note_cmxs, - spent_nullifiers, - }); + // 6) activity entries (derived activity log). The variable- + // length fields (counterparty / memo / cmx + nullifier + // arrays) borrow into `backing`, a Vec of owned byte + // buffers that outlives the callback — same pointer-validity + // discipline as `note_data_ptr` / `memo_ptr` above. The + // host upserts by `(wallet_id, account_index, entry_id)`. + if !shielded_cs.activity_entries.is_empty() { + if let Some(cb) = self.callbacks.on_persist_shielded_activity_fn { + // One pass pairs each entry with its owned cmx / + // nullifier buffers STRUCTURALLY (same tuple), so the + // pointer-into-backing invariant can't silently + // mis-pair if either side is ever filtered or + // reordered. The buffers live in `rows` (immutable + // once built) for the whole callback window; an inner + // `Vec`'s heap allocation is stable even as the + // outer Vec grows. + let rows: Vec<( + &platform_wallet::wallet::shielded::SubwalletId, + &platform_wallet::wallet::shielded::ShieldedActivityEntry, + Vec, + Vec, + )> = shielded_cs + .activity_entries + .iter() + .flat_map(|(id, entries)| entries.iter().map(move |e| (id, e))) + .map(|(id, e)| { + let mut cmx_buf = Vec::with_capacity(e.note_cmxs.len() * 32); + for c in &e.note_cmxs { + cmx_buf.extend_from_slice(c); + } + let mut nf_buf = Vec::with_capacity(e.spent_nullifiers.len() * 32); + for n in &e.spent_nullifiers { + nf_buf.extend_from_slice(n); + } + (id, e, cmx_buf, nf_buf) + }) + .collect(); + let entries: Vec = rows + .iter() + .map(|(id, e, cmx_buf, nf_buf)| { + let (identity_id, has_identity_id) = match &e.kind { + platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityCreate { + identity_id, + } + | platform_wallet::wallet::shielded::ShieldedActivityKind::ShieldFromIdentity { + identity_id, + } + | platform_wallet::wallet::shielded::ShieldedActivityKind::IdentityTopUp { + identity_id, + } => (*identity_id, 1u8), + _ => ([0u8; 32], 0u8), + }; + let (counterparty_ptr, counterparty_len) = match &e.counterparty { + Some(c) if !c.is_empty() => (c.as_ptr(), c.len()), + _ => (std::ptr::null(), 0), + }; + let (memo_ptr, memo_len) = match &e.memo { + Some(m) if !m.is_empty() => (m.as_ptr(), m.len()), + _ => (std::ptr::null(), 0), + }; + ShieldedActivityFFI { + wallet_id: id.wallet_id, + account_index: id.account_index, + entry_id: e.id, + kind_tag: e.kind.tag(), + direction: activity_direction_tag(&e.direction), + status: activity_status_tag(&e.status), + amount: e.amount, + fee: e.fee.unwrap_or(0), + has_fee: u8::from(e.fee.is_some()), + block_height: e.block_height.unwrap_or(0), + has_block_height: u8::from(e.block_height.is_some()), + created_at_ms: e.created_at_ms, + min_note_position: e.min_note_position.unwrap_or(0), + has_min_note_position: u8::from(e.min_note_position.is_some()), + identity_id, + has_identity_id, + counterparty_ptr, + counterparty_len, + memo_ptr, + memo_len, + // Match the documented "valid or null" + // contract (and the counterparty/memo + // siblings): an empty Vec's `as_ptr()` is + // a dangling non-null sentinel, so emit a + // real null when there's nothing to point + // at. + note_cmxs_ptr: if cmx_buf.is_empty() { + std::ptr::null() + } else { + cmx_buf.as_ptr() + }, + note_cmxs_count: cmx_buf.len() / 32, + spent_nullifiers_ptr: if nf_buf.is_empty() { + std::ptr::null() + } else { + nf_buf.as_ptr() + }, + spent_nullifiers_count: nf_buf.len() / 32, + } + }) + .collect(); + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + entries.as_ptr(), + entries.len(), + ) + }; + if result != 0 { + eprintln!( + "Shielded activity persistence callback returned error code {}", + result + ); + outcome.record(result); } + // `rows` and `entries` drop here, after the callback + // has copied everything it needs. + drop(rows); } } + } - // 5) persisted Orchard viewing keys (raw 96-byte FVK - // encodings), consumed by - // `PlatformWallet::bind_shielded_from_persisted` so a - // launch-time rebind needs no mnemonic resolve. - if self.callbacks.on_load_shielded_viewing_keys_fn.is_some() - != self - .callbacks - .on_load_shielded_viewing_keys_free_fn - .is_some() - { - return Err(PersistenceError::backend( - "on_load_shielded_viewing_keys_fn and \ - on_load_shielded_viewing_keys_free_fn must be provided together", - )); + // Close the round. Clients use this to commit (if the round + // succeeded) or roll back (otherwise) the + // staged writes accumulated across the per-kind callbacks + // above, making the whole store() call a single atomic + // transaction from their perspective. + if let Some(cb) = self.callbacks.on_changeset_end_fn { + let result = unsafe { + cb( + self.callbacks.context, + wallet_id.as_ptr(), + outcome.is_success(), + ) + }; + if result != 0 { + eprintln!("Changeset-end callback returned error code {}", result); + // Either way `store()` must return `Err`, so the caller does + // NOT advance / clear its in-memory state (pending queues, + // cleared drain entries, ignored-sender deltas) against data + // that never reached durable storage. What differs is what the + // caller may then do about it. + if outcome.is_success() { + // A clean round: `end` is the COMMIT (the SwiftData atomic + // `save()`), and its failure applied nothing. The host's + // classification of that is legitimate. + outcome.record(result); + } else { + // A failing round: `end` fired with `success = false` to + // drive the ROLLBACK, and it failed. The round's + // disposition is now unknown, whatever the host classifies + // it as — and unknown is never retryable, because a + // re-issued changeset merges by appending. + outcome.record_fatal(); + } } - if let Some(load_viewing_keys) = self.callbacks.on_load_shielded_viewing_keys_fn { - let mut vk_ptr: *const ShieldedViewingKeyRestoreFFI = std::ptr::null(); - let mut vk_count: usize = 0; - let rc = unsafe { - load_viewing_keys(self.callbacks.context, &mut vk_ptr, &mut vk_count) - }; - if rc != 0 { - return Err(persist_callback_error( - rc, - format!( - "on_load_shielded_viewing_keys_fn returned error code {}", - rc - ), + } + + // Close the round: its `end` callback has fired (committing or + // rolling back the client transaction), or there was no end + // callback wired. Clear the state-machine flag now — BEFORE any + // early return below — so a rejected round doesn't wedge the + // persister into permanent "round already open" rejection on the + // next `store()`. (`end_round` only errors on an unmatched end, + // which cannot happen here since `begin_round` succeeded above.) + round.end_round()?; + + if let Some(kind) = outcome.failure_kind() { + return Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(kind), + "one or more persistence callbacks failed; changeset was rolled back", + )); + } + + // Preserve the legacy notification phase. With an end callback, the + // host transaction is already committed and a notification failure is + // advisory. Without that atomic boundary, preserve the established + // `store()` error contract for legacy hosts that use this callback as + // their durable-write boundary. + if let Some(cb) = self.callbacks.on_store_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + if self.callbacks.on_changeset_end_fn.is_some() { + eprintln!( + "Persistence store callback returned post-commit error code {result}; \ + ignored" + ); + } else { + // No end callback, so the per-kind writes already landed + // individually and the round is not all-or-nothing — + // `reportable_round_kind` withholds a retryable verdict. + return Err(PersistenceError::backend_with_kind( + self.reportable_round_kind(persist_rc_kind(result)), + format!("Persistence store callback returned error code {result}"), )); } - struct ViewingKeysGuard { - context: *mut c_void, - free_fn: Option< - unsafe extern "C" fn( - context: *mut c_void, - entries: *const ShieldedViewingKeyRestoreFFI, - count: usize, - ), - >, - entries: *const ShieldedViewingKeyRestoreFFI, - count: usize, - } - impl Drop for ViewingKeysGuard { - fn drop(&mut self) { - if let Some(free_fn) = self.free_fn { - unsafe { free_fn(self.context, self.entries, self.count) }; - } - } - } - let _viewing_keys_guard = ViewingKeysGuard { - context: self.callbacks.context, - free_fn: self.callbacks.on_load_shielded_viewing_keys_free_fn, - entries: vk_ptr, - count: vk_count, - }; - if !vk_ptr.is_null() && vk_count > 0 { - let slice = unsafe { slice::from_raw_parts(vk_ptr, vk_count) }; - for ffi in slice { - let id = SubwalletId::new(ffi.wallet_id, ffi.account_index); - shielded_state - .viewing_keys - .insert(id, ffi.fvk_bytes.to_vec()); - } - } } + } - out.shielded = shielded_state; + Ok(()) + } + + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + // TODO: deferred — trailing-byte validation on decoded FFI + // payloads is not yet applied here. + // Notify caller. + if let Some(cb) = self.callbacks.on_flush_fn { + let result = unsafe { cb(self.callbacks.context, wallet_id.as_ptr()) }; + if result != 0 { + return Err(persist_callback_error( + result, + format!("Persistence flush callback returned error code {}", result), + )); + } } - Ok(out) + Ok(()) + } + + fn load(&self) -> Result { + let round = self.round_lock.lock(); + if round.in_round { + return Err(PersistenceError::backend( + "FFIPersister: cannot restore from an unclosed changeset round", + )); + } + self.load_snapshot_unlocked(None) + } + + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + evidence: &platform_wallet::changeset::recorded_history::ReplayEvidence, + ) -> Result { + self.load_wallet_replay_scope(wallet_id, evidence) + } + + fn load_with_replay( + &self, + evidence: &platform_wallet::changeset::recorded_history::ReplayEvidence, + ) -> Result { + let snapshot = self.load_read_snapshot()?; + let mut loaded = platform_wallet::manager::history_replay::HistoryReplayLoad::default(); + #[cfg(feature = "shielded")] + { + loaded.state.shielded = snapshot.shielded; + } + for wallet_id in snapshot.wallets.keys() { + let one = self.load_wallet_replay_scope(*wallet_id, evidence)?; + loaded.state.wallets.extend(one.state.wallets); + loaded + .state + .platform_addresses + .extend(one.state.platform_addresses); + loaded.outcomes.extend(one.outcomes); + } + Ok(loaded) } /// Look up a transaction record by `txid` via the @@ -11048,7 +11126,7 @@ mod tests { } #[test] - fn should_require_explicit_operation_id_persistence_attestation() { + fn should_withhold_operation_id_capability_until_replay_preserves_tombstones() { unsafe extern "C" fn persist( _: *mut c_void, _: *const u8, @@ -11090,7 +11168,7 @@ mod tests { .persistence_capabilities() .contains(required) ); - assert!(persister(callbacks(), required, extensions) + assert!(!persister(callbacks(), required, extensions) .persistence_capabilities() .contains(required)); assert!(!declared_persister(callbacks(), required) @@ -11220,11 +11298,41 @@ mod tests { cb.on_persist_token_balances_fn = Some(noop_token_balances); // "Fully wired" includes the extension's sweeps slot — the legacy // vtable alone can no longer attest CORE_SWEEP_REMOVAL. - let capabilities = FFIPersister::new_with_persistence_capabilities_and_extension_callbacks( + unsafe extern "C" fn scope( + _: *mut c_void, + _: *const u8, + _: ReplayOperationFn, + _: *mut c_void, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const ReplayClaimRestoreFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) {} + unsafe extern "C" fn apply( + _: *mut c_void, + _: *const u8, + _: *const HistoryReplayEffectsFFI, + ) -> i32 { + 0 + } + let capabilities = FFIPersister::new_with_persistence_capabilities_and_extensions( cb, expected, - None, - Some(noop_wallet_changeset_sweeps), + PersistenceExtensionCallbacks { + wallet_changeset_sweeps: Some(noop_wallet_changeset_sweeps), + replay_wallet: Some(scope), + load_replay_claims: Some(load), + free_replay_claims: Some(free), + apply_replay_effects: Some(apply), + ..Default::default() + }, ) .persistence_capabilities(); @@ -11485,6 +11593,26 @@ mod tests { PersistenceCallbacksExtension, on_load_core_instant_lock_free_fn ) + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_replay_wallet_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_replay_wallet_fn) + + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_fn) + + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_free_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_load_replay_claims_free_fn) + + std::mem::size_of::>(), + std::mem::offset_of!(PersistenceCallbacksExtension, on_apply_replay_effects_fn) + ); + assert_eq!( + std::mem::offset_of!(PersistenceCallbacksExtension, on_apply_replay_effects_fn) + + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs new file mode 100644 index 00000000000..6dc14f4fe18 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -0,0 +1,821 @@ +//! Synchronous host transaction adapter for wallet-owned history replay. + +use std::collections::BTreeMap; +use std::os::raw::c_void; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use dashcore::{hashes::Hash, OutPoint, ScriptBuf, TxOut, Txid}; +use parking_lot::Mutex; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::{PersistenceError, RecordedHistory}; +use platform_wallet::manager::history_replay::{ + replay_wallet_history_blocking, HistoryReplayError, HistoryReplayLoad, ReplayClaimEffect, + StoredReplayClaim, +}; +use platform_wallet::wallet::platform_wallet::WalletId; + +use super::FFIPersister; + +/// Scope-only callback. The host must invoke it once synchronously and never retain it. +pub type ReplayOperationFn = unsafe extern "C" fn(operation_context: *mut c_void) -> i32; +/// Run one wallet's replay inside a host storage transaction; nonzero means rollback. +pub type ReplayWalletFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + operation: ReplayOperationFn, + operation_context: *mut c_void, +) -> i32; +/// Load owned coin material and durable claims from the current wallet snapshot. +pub type LoadReplayClaimsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_rows: *mut *const ReplayClaimRestoreFFI, + out_count: *mut usize, +) -> i32; +/// Release the complete host-owned array and its nested script buffers. +pub type FreeReplayClaimsFn = + unsafe extern "C" fn(context: *mut c_void, rows: *const ReplayClaimRestoreFFI, count: usize); +/// Apply exactly the owner-computed effects inside the current wallet transaction. +pub type ApplyReplayEffectsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + effects: *const HistoryReplayEffectsFFI, +) -> i32; + +/// Coin material and optional hold; repeated outpoints may carry distinct claims. +#[repr(C)] +#[derive(Clone, Copy)] +pub struct ReplayClaimRestoreFFI { + pub txid: [u8; 32], + pub vout: u32, + pub has_output: bool, + pub value: u64, + pub script: *const u8, + pub script_len: usize, + pub is_held: bool, + pub has_claimant: bool, + pub claimant: [u8; 32], + pub has_mined_height: bool, + pub mined_height: u32, +} + +/// One Rust-owned claim verdict. The host must not infer its own release policy. +#[repr(C)] +pub struct ReplayClaimEffectFFI { + pub txid: [u8; 32], + pub vout: u32, + /// 0 removes the output/placeholder, 1 releases it, 2 holds it. + pub action: u8, + pub has_claimant: bool, + pub claimant: [u8; 32], + pub has_mined_height: bool, + pub mined_height: u32, +} + +/// Borrowed only for ApplyReplayEffectsFn; all removals concern the supplied wallet. +#[repr(C)] +pub struct HistoryReplayEffectsFFI { + pub removed_records: *const [u8; 32], + pub removed_records_count: usize, + pub removed_evidence: *const [u8; 32], + pub removed_evidence_count: usize, + pub claims: *const ReplayClaimEffectFFI, + pub claims_count: usize, +} + +#[derive(Clone, Copy, Default)] +pub(super) struct ReplayCallbacks { + pub transaction: Option, + pub load_claims: Option, + pub free_claims: Option, + pub apply: Option, +} + +#[derive(Debug, thiserror::Error)] +enum ReplayAdapterError { + #[error("wallet history replay requires all atomic transaction, claim load/free and effect callbacks")] + IncompleteCallbacks, + #[error("host must invoke the wallet replay operation exactly once before its transaction callback returns")] + InvocationProtocol, + #[error("host wallet replay transaction failed (code {0})")] + HostFailure(i32), + #[error("host wallet replay transaction failed (code {code}): {operation}")] + HostAndOperationFailure { + code: i32, + #[source] + operation: PersistenceError, + }, + #[error("wallet replay operation panicked; host must roll back the transaction")] + Panicked, + #[error( + "wallet replay claim callback returned invalid buffers or conflicting output material" + )] + InvalidClaims, + #[error("wallet replay claim callback failed (code {0})")] + ClaimRead(i32), + #[error("wallet replay effect callback failed (code {0})")] + EffectWrite(i32), + #[error("wallet disappeared during atomic history replay")] + MissingWallet, +} + +impl ReplayCallbacks { + fn any(self) -> bool { + self.transaction.is_some() + || self.load_claims.is_some() + || self.free_claims.is_some() + || self.apply.is_some() + } + pub(super) fn complete(self) -> bool { + self.transaction.is_some() + && self.load_claims.is_some() + && self.free_claims.is_some() + && self.apply.is_some() + } +} + +struct ReplayInvocation<'a> { + persister: &'a FFIPersister, + wallet_id: WalletId, + evidence: &'a ReplayEvidence, + calls: AtomicUsize, + result: Mutex>>, +} + +unsafe extern "C" fn invoke_replay(context: *mut c_void) -> i32 { + // SAFETY: the host contract permits this borrowed context only during its callback. + let invocation = &*context.cast::>(); + let previous = invocation.calls.fetch_add(1, Ordering::SeqCst); + if previous != 0 { + return 1; + } + let result = catch_unwind(AssertUnwindSafe(|| { + invocation + .persister + .replay_in_scope(invocation.wallet_id, invocation.evidence) + })) + .unwrap_or_else(|_| Err(PersistenceError::backend(ReplayAdapterError::Panicked))); + let failed = result.is_err(); + *invocation.result.lock() = Some(result); + i32::from(failed) +} + +impl FFIPersister { + pub(super) fn load_wallet_replay_scope( + &self, + wallet_id: WalletId, + evidence: &ReplayEvidence, + ) -> Result { + let round = self.round_lock.lock(); + if round.in_round { + return Err(PersistenceError::backend( + "cannot replay an unclosed persistence round", + )); + } + if !self.replay_callbacks.any() { + return Ok(HistoryReplayLoad { + state: self.load_snapshot_unlocked(Some(wallet_id))?, + outcomes: BTreeMap::new(), + }); + } + if !self.replay_callbacks.complete() { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + } + let Some(transaction) = self.replay_callbacks.transaction else { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + }; + let invocation = ReplayInvocation { + persister: self, + wallet_id, + evidence, + calls: AtomicUsize::new(0), + result: Mutex::new(None), + }; + // Synchronous host -> Rust -> private unlocked reads; no recursive round mutex. + let code = unsafe { + transaction( + self.callbacks.context, + wallet_id.as_ptr(), + invoke_replay, + (&invocation as *const ReplayInvocation<'_>) + .cast_mut() + .cast(), + ) + }; + let result = invocation.result.into_inner(); + if invocation.calls.load(Ordering::SeqCst) != 1 || result.is_none() { + return Err(PersistenceError::backend( + ReplayAdapterError::InvocationProtocol, + )); + } + match (code, result) { + (0, Some(result)) => result, + (code, Some(Err(operation))) => Err(PersistenceError::backend( + ReplayAdapterError::HostAndOperationFailure { code, operation }, + )), + (code, _) => Err(PersistenceError::backend(ReplayAdapterError::HostFailure( + code, + ))), + } + } + + fn replay_in_scope( + &self, + wallet_id: WalletId, + evidence: &ReplayEvidence, + ) -> Result { + let mut state = self.load_snapshot_unlocked(Some(wallet_id))?; + let Some(wallet) = state.wallets.get_mut(&wallet_id) else { + return Ok(HistoryReplayLoad { + state, + outcomes: BTreeMap::new(), + }); + }; + let mut claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; + let records = wallet.recorded_history.transactions.clone(); + let outcome = + replay_wallet_history_blocking(wallet, evidence).map_err(PersistenceError::backend)?; + let effects = outcome.storage_effects(&records, &claims); + let has_effects = !effects.removed_records.is_empty() + || !effects.removed_evidence.is_empty() + || !effects.claims.is_empty(); + if (!outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty()) + && !has_effects + { + return Err(PersistenceError::backend( + HistoryReplayError::UnsettledAfterReplay, + )); + } + if has_effects { + let Some(apply) = self.replay_callbacks.apply else { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + }; + let removed_records: Vec<_> = effects + .removed_records + .iter() + .map(|id| id.to_byte_array()) + .collect(); + let removed_evidence: Vec<_> = effects + .removed_evidence + .iter() + .map(|id| id.to_byte_array()) + .collect(); + let claim_effects: Vec<_> = effects + .claims + .iter() + .map(|(outpoint, effect)| { + let (action, claimant, height) = match effect { + ReplayClaimEffect::Remove => (0, None, None), + ReplayClaimEffect::Release => (1, None, None), + ReplayClaimEffect::Hold { + claimant, + mined_height, + } => (2, *claimant, *mined_height), + }; + ReplayClaimEffectFFI { + txid: outpoint.txid.to_byte_array(), + vout: outpoint.vout, + action, + has_claimant: claimant.is_some(), + claimant: claimant.map(|id| id.to_byte_array()).unwrap_or_default(), + has_mined_height: height.is_some(), + mined_height: height.unwrap_or_default(), + } + }) + .collect(); + let ffi = HistoryReplayEffectsFFI { + removed_records: slice_pointer(&removed_records), + removed_records_count: removed_records.len(), + removed_evidence: slice_pointer(&removed_evidence), + removed_evidence_count: removed_evidence.len(), + claims: slice_pointer(&claim_effects), + claims_count: claim_effects.len(), + }; + let code = unsafe { apply(self.callbacks.context, wallet_id.as_ptr(), &ffi) }; + if code != 0 { + return Err(PersistenceError::backend(ReplayAdapterError::EffectWrite( + code, + ))); + } + state = self.load_snapshot_unlocked(Some(wallet_id))?; + let wallet = state + .wallets + .get_mut(&wallet_id) + .ok_or_else(|| PersistenceError::backend(ReplayAdapterError::MissingWallet))?; + claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; + let remaining = replay_wallet_history_blocking(wallet, evidence) + .map_err(PersistenceError::backend)?; + if !remaining.sweeps.is_empty() || !remaining.authorization.rejected.is_empty() { + return Err(PersistenceError::backend( + HistoryReplayError::UnsettledAfterReplay, + )); + } + wallet.unconfirmed_outgoing_txs = + outcome.resendable(std::mem::take(&mut wallet.unconfirmed_outgoing_txs)); + } + if let Some(wallet) = state.wallets.get_mut(&wallet_id) { + wallet.wallet_info.restore_spent_outpoints( + &claims + .iter() + .map(|claim| (claim.outpoint, claim.claimant)) + .collect::>(), + ); + } + Ok(HistoryReplayLoad { + state, + outcomes: [(wallet_id, outcome)].into(), + }) + } + + fn load_replay_claims( + &self, + wallet_id: WalletId, + history: &mut RecordedHistory, + ) -> Result, PersistenceError> { + let (Some(load), Some(free)) = ( + self.replay_callbacks.load_claims, + self.replay_callbacks.free_claims, + ) else { + return Err(PersistenceError::backend( + ReplayAdapterError::IncompleteCallbacks, + )); + }; + let mut rows = std::ptr::null(); + let mut count = 0; + let code = unsafe { + load( + self.callbacks.context, + wallet_id.as_ptr(), + &mut rows, + &mut count, + ) + }; + let _guard = ClaimRowsGuard { + context: self.callbacks.context, + free, + rows, + count, + }; + if code != 0 { + return Err(PersistenceError::backend(ReplayAdapterError::ClaimRead( + code, + ))); + } + if count + > dashcore::consensus::encode::MAX_VEC_SIZE + / std::mem::size_of::() + || (rows.is_null() && count != 0) + { + return Err(PersistenceError::backend(ReplayAdapterError::InvalidClaims)); + } + let rows = if count == 0 { + &[] + } else { + unsafe { std::slice::from_raw_parts(rows, count) } + }; + let mut claims = Vec::new(); + for row in rows { + let outpoint = OutPoint::new(Txid::from_byte_array(row.txid), row.vout); + if row.has_output { + if row.script_len > dashcore::consensus::encode::MAX_VEC_SIZE + || (row.script.is_null() && row.script_len != 0) + { + return Err(PersistenceError::backend(ReplayAdapterError::InvalidClaims)); + } + let script = if row.script_len == 0 { + Vec::new() + } else { + unsafe { std::slice::from_raw_parts(row.script, row.script_len) }.to_vec() + }; + let output = TxOut { + value: row.value, + script_pubkey: ScriptBuf::from_bytes(script), + }; + if history + .owned_prevouts + .insert(outpoint, output.clone()) + .is_some_and(|old| old != output) + { + return Err(PersistenceError::backend(ReplayAdapterError::InvalidClaims)); + } + } + if row.is_held { + claims.push(StoredReplayClaim { + outpoint, + claimant: row + .has_claimant + .then(|| Txid::from_byte_array(row.claimant)), + mined_height: row.has_mined_height.then_some(row.mined_height), + }); + } + } + Ok(claims) + } +} + +fn slice_pointer(values: &[T]) -> *const T { + if values.is_empty() { + std::ptr::null() + } else { + values.as_ptr() + } +} + +struct ClaimRowsGuard { + context: *mut c_void, + free: FreeReplayClaimsFn, + rows: *const ReplayClaimRestoreFFI, + count: usize, +} +impl Drop for ClaimRowsGuard { + fn drop(&mut self) { + unsafe { (self.free)(self.context, self.rows, self.count) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::persistence::{FFIPersister, PersistenceCallbacks, PersistenceExtensionCallbacks}; + use crate::types::FFINetwork; + use crate::wallet_restore_types::RecordedTransactionRestoreFFI; + use crate::wallet_restore_types::WalletRestoreEntryFFI; + use dashcore::{Transaction, TxIn}; + use platform_wallet::changeset::recorded_history::ReplayEvidence; + use platform_wallet::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; + use std::cell::{Cell, RefCell}; + + #[derive(Default)] + struct Host { + scopes: Cell, + inside: Cell, + scoped_reads: Cell, + mode: Cell, + fail_read: Cell, + transaction: RefCell>, + replacement: RefCell>, + claim_frees: Cell, + claim_mode: Cell, + effects: Cell, + } + + unsafe extern "C" fn scope( + ctx: *mut c_void, + _: *const u8, + operation: ReplayOperationFn, + operation_ctx: *mut c_void, + ) -> i32 { + let host = &*ctx.cast::(); + host.scopes.set(host.scopes.get() + 1); + host.inside.set(true); + if let Some(replacement) = host.replacement.borrow_mut().take() { + *host.transaction.borrow_mut() = Some(replacement); + } + let first = if host.mode.get() == 5 { + let address = operation_ctx as usize; + std::thread::scope(|scope| { + scope + .spawn(move || operation(address as *mut c_void)) + .join() + .unwrap() + }) + } else if host.mode.get() == 1 { + 0 + } else { + operation(operation_ctx) + }; + if host.mode.get() == 2 { + let _ = operation(operation_ctx); + } + host.inside.set(false); + match host.mode.get() { + 1..=3 => 0, + 4 => 71, + _ => first, + } + } + + unsafe extern "C" fn load( + ctx: *mut c_void, + rows: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let host = &*ctx.cast::(); + if host.inside.get() { + host.scoped_reads.set(host.scoped_reads.get() + 1); + } + if host.fail_read.get() { + return 73; + } + let history = host.transaction.borrow().as_ref().map(|transaction| { + let bytes = dashcore::consensus::serialize(transaction).into_boxed_slice(); + let len = bytes.len(); + Box::into_raw(Box::new(RecordedTransactionRestoreFFI { + txid: transaction.txid().to_byte_array(), + tx_bytes: Box::into_raw(bytes).cast(), + tx_bytes_len: len, + context: 1, + block_height: 0, + block_hash: [0; 32], + block_timestamp: 0, + block_position: 0, + has_block_position: false, + net_amount: 0, + direction: 0, + })) + }); + *rows = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [41; 32], + network: FFINetwork::Testnet, + recorded_transactions: history.unwrap_or(std::ptr::null_mut()), + recorded_transactions_count: usize::from(history.is_some()), + ..Default::default() + })); + *count = 1; + 0 + } + unsafe extern "C" fn free(_: *mut c_void, rows: *const WalletRestoreEntryFFI, _: usize) { + if !rows.is_null() { + let entry = Box::from_raw(rows.cast_mut()); + if !entry.recorded_transactions.is_null() { + let record = Box::from_raw(entry.recorded_transactions.cast_mut()); + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + record.tx_bytes, + record.tx_bytes_len, + ))); + } + } + } + unsafe extern "C" fn claims( + ctx: *mut c_void, + _: *const u8, + rows: *mut *const ReplayClaimRestoreFFI, + count: *mut usize, + ) -> i32 { + *rows = std::ptr::null(); + let host = &*ctx.cast::(); + *count = usize::from(host.claim_mode.get() == 2); + if host.claim_mode.get() == 1 { + 79 + } else { + 0 + } + } + unsafe extern "C" fn free_claims(ctx: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) { + let host = &*ctx.cast::(); + host.claim_frees.set(host.claim_frees.get() + 1); + } + unsafe extern "C" fn apply( + ctx: *mut c_void, + _: *const u8, + _: *const HistoryReplayEffectsFFI, + ) -> i32 { + let host = &*ctx.cast::(); + host.effects.set(host.effects.get() + 1); + 0 + } + + fn persister(host: &Host) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (host as *const Host).cast_mut().cast(), + on_load_wallet_list_fn: Some(load), + on_load_wallet_list_free_fn: Some(free), + ..Default::default() + }, + PersistenceCapabilities::NONE, + PersistenceExtensionCallbacks { + replay_wallet: Some(scope), + load_replay_claims: Some(claims), + free_replay_claims: Some(free_claims), + apply_replay_effects: Some(apply), + ..Default::default() + }, + ) + } + + fn transaction(marker: u32) -> Transaction { + Transaction { + version: 1, + lock_time: marker, + input: vec![TxIn { + previous_output: OutPoint::new(Txid::from_byte_array([7; 32]), 0), + ..Default::default() + }], + output: vec![], + special_transaction_payload: None, + } + } + + async fn checked_evidence(transaction: &Transaction) -> ReplayEvidence { + use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; + use dash_spv::{ClientConfig, DashSpvClient}; + use key_wallet_manager::test_utils::MockWallet; + use std::sync::Arc; + use tokio::sync::RwLock; + + struct Storage(std::path::PathBuf); + impl Drop for Storage { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + static NEXT: AtomicUsize = AtomicUsize::new(0); + let storage = Storage(std::env::temp_dir().join(format!( + "ffi-replay-proof-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + ))); + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&storage.0).unwrap(); + let client = DashSpvClient::new( + ClientConfig::new(dashcore::Network::Testnet) + .without_filters() + .with_storage_path(&storage.0), + MockNetworkManager::new(), + Arc::new(RwLock::new(MockWallet::new())), + Vec::new(), + ) + .await + .unwrap(); + let fixture = InstantLockFixture::new(transaction).unwrap(); + fixture.install(&client).await.unwrap(); + let proof = client + .verify_instant_lock(fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + ReplayEvidence { + instant_locks: [(proof.txid(), proof)].into(), + } + } + + #[tokio::test] + async fn should_allow_explicit_replay_retry_only_with_checked_matching_evidence() { + let host = Host::default(); + let transaction = transaction(2); + *host.transaction.borrow_mut() = Some(transaction.clone()); + let persister = persister(&host); + assert!(persister + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err()); + let evidence = checked_evidence(&transaction).await; + let restored = persister + .load_wallet_with_replay([41; 32], &evidence) + .unwrap(); + assert!(restored.state.wallets.contains_key(&[41; 32])); + assert_eq!(host.effects.get(), 0); + assert_eq!(host.scopes.get(), 2); + } + + #[tokio::test] + async fn should_rebind_prefetched_evidence_to_the_fresh_host_transaction() { + let host = Host::default(); + let old = transaction(3); + *host.transaction.borrow_mut() = Some(old.clone()); + let persister = persister(&host); + let _discovery = persister.load_read_snapshot().unwrap(); + let evidence = checked_evidence(&old).await; + let fresh = transaction(4); + let fresh_id = fresh.txid(); + *host.replacement.borrow_mut() = Some(fresh); + let error = persister + .load_wallet_with_replay([41; 32], &evidence) + .unwrap_err(); + assert!(error.to_string().contains(&fresh_id.to_string())); + assert_eq!(host.effects.get(), 0); + } + + #[test] + fn should_fail_missing_required_proof_before_any_host_effects() { + let host = Host::default(); + let transaction = transaction(1); + let txid = transaction.txid(); + *host.transaction.borrow_mut() = Some(transaction); + let error = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap_err(); + assert!(error.to_string().contains(&txid.to_string())); + assert_eq!(host.effects.get(), 0); + assert_eq!(host.claim_frees.get(), 1); + assert!(host.transaction.borrow().is_some()); + } + + #[test] + fn should_free_claim_buffers_on_callback_and_decode_failure() { + for mode in [1, 2] { + let host = Host::default(); + host.claim_mode.set(mode); + assert!(persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err()); + assert_eq!(host.claim_frees.get(), 1); + assert_eq!(host.effects.get(), 0); + } + } + + #[test] + fn should_require_complete_atomic_callbacks_for_wallet_restore_capability() { + let host = Host::default(); + let mut persister = persister(&host); + persister.declared_capabilities = PersistenceCapabilities::WALLET_RESTORE; + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::WALLET_RESTORE)); + for callbacks in [ + ReplayCallbacks { + transaction: None, + ..persister.replay_callbacks + }, + ReplayCallbacks { + load_claims: None, + ..persister.replay_callbacks + }, + ReplayCallbacks { + free_claims: None, + ..persister.replay_callbacks + }, + ReplayCallbacks { + apply: None, + ..persister.replay_callbacks + }, + ReplayCallbacks::default(), + ] { + persister.replay_callbacks = callbacks; + assert!(!persister + .persistence_capabilities() + .contains(PersistenceCapabilities::WALLET_RESTORE)); + } + } + + #[test] + fn should_read_wallet_inside_exactly_one_atomic_replay_scope() { + let host = Host::default(); + let loaded = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap(); + assert!(loaded.state.wallets.contains_key(&[41; 32])); + assert_eq!(host.scopes.get(), 1); + assert!(host.scoped_reads.get() > 0); + assert!(!host.inside.get()); + } + + #[test] + fn should_accept_a_synchronous_replay_thunk_on_the_hosts_serial_thread() { + let host = Host::default(); + host.mode.set(5); + let loaded = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap(); + assert!(loaded.state.wallets.contains_key(&[41; 32])); + assert_eq!(host.scopes.get(), 1); + assert_eq!(host.scoped_reads.get(), 1); + } + + #[test] + fn should_reject_missing_or_repeated_atomic_replay_thunk() { + for mode in [1, 2] { + let host = Host::default(); + host.mode.set(mode); + assert!( + persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err(), + "mode={mode}" + ); + } + } + + #[test] + fn should_preserve_rust_failure_when_host_reports_replay_success() { + let host = Host::default(); + host.mode.set(3); + host.fail_read.set(true); + let error = persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .unwrap_err(); + assert!(error.to_string().contains("73")); + assert_eq!(host.scopes.get(), 1); + } + + #[test] + fn should_reject_host_failure_after_successful_replay() { + let host = Host::default(); + host.mode.set(4); + assert!(persister(&host) + .load_wallet_with_replay([41; 32], &ReplayEvidence::default()) + .is_err()); + } +} From c9550d23929f4c253a17108e52282e4d5640bf1f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:35:34 +0000 Subject: [PATCH 65/84] feat(wallet): project replay verdicts onto durable input claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map canonical authorization and physical sweep identities to backend-neutral record, evidence and claim effects. Preserve surviving and anonymous holds, ordered sweep provenance and placeholder lifetimes; reject incomplete claim outcomes before returning effects. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/manager/history_claims.rs | 579 +++++++++++++++++- 1 file changed, 563 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/history_claims.rs b/packages/rs-platform-wallet/src/manager/history_claims.rs index f5d512a623a..de46f27daf8 100644 --- a/packages/rs-platform-wallet/src/manager/history_claims.rs +++ b/packages/rs-platform-wallet/src/manager/history_claims.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use dashcore::{OutPoint, Txid}; -use super::history_replay::HistoryReplay; +use super::history_replay::{HistoryReplay, HistoryReplayError}; use crate::changeset::StoredTransaction; /// One durable hold, independent of a backend's physical row representation. @@ -46,16 +46,252 @@ pub struct HistoryReplayEffects { } impl HistoryReplay { - /// Project already-verified replay verdicts; this does not accept new finality evidence. + /// Project already-verified replay verdicts from one wallet snapshot, without new finality evidence. + /// + /// # Errors + /// Returns [`HistoryReplayError::UnsettledAfterReplay`] if a final held winner + /// was removed and neither a surviving body nor durable claim can protect its input, + /// or an input has no authorization rejection or sweep permitting its release. pub fn storage_effects( &self, - _records: &[StoredTransaction], - _claims: &[StoredReplayClaim], - ) -> HistoryReplayEffects { - HistoryReplayEffects::default() + records: &[StoredTransaction], + claims: &[StoredReplayClaim], + ) -> Result { + let records_by_key: BTreeMap<_, _> = records + .iter() + .map(|record| (record.txid, (record.transaction.txid(), record))) + .collect(); + // Sweep verdicts name physical keys; authorization names canonical bodies. + let mut removed_records: BTreeSet<_> = self.defeated.iter().copied().collect(); + removed_records.extend( + self.sweeps + .iter() + .flat_map(|sweep| sweep.txids.iter().copied()), + ); + removed_records.extend(records_by_key.iter().filter_map(|(key, (body, _))| { + self.authorization.rejected.contains(body).then_some(*key) + })); + let surviving_bodies: BTreeSet<_> = records_by_key + .iter() + .filter(|(key, _)| !removed_records.contains(*key)) + .map(|(_, (body, _))| *body) + .collect(); + let mut removed_evidence = self.authorization.rejected.clone(); + removed_evidence.extend(removed_records.iter().copied()); + removed_evidence.extend( + records_by_key + .iter() + .filter_map(|(key, (body, _))| removed_records.contains(key).then_some(*body)), + ); + removed_evidence.retain(|txid| !surviving_bodies.contains(txid)); + let mut authorization_evidence = self.authorization.rejected.clone(); + authorization_evidence.extend(records_by_key.iter().filter_map(|(key, (body, _))| { + self.authorization.rejected.contains(body).then_some(*key) + })); + authorization_evidence.retain(|txid| !surviving_bodies.contains(txid)); + + let mut affected = BTreeSet::new(); + let mut release_allowed = BTreeSet::new(); + let mut survivors: BTreeMap>> = BTreeMap::new(); + for (key, (body, record)) in &records_by_key { + for input in &record.transaction.input { + if removed_records.contains(key) { + affected.insert(input.previous_output); + if self.authorization.rejected.contains(body) { + release_allowed.insert(input.previous_output); + } + } else { + let height = record.context.block_info().map(|block| block.height()); + survivors + .entry(input.previous_output) + .or_default() + .entry(*body) + .and_modify(|stamp| *stamp = merge_same_claim_stamp(*stamp, height)) + .or_insert(height); + } + } + } + for (outpoint, claimants) in &self.authorization.surviving_claimants { + for claimant in claimants + .iter() + .filter(|id| !removed_evidence.contains(*id)) + { + survivors + .entry(*outpoint) + .or_default() + .entry(*claimant) + .or_insert(None); + } + } + let mut stored_claims: BTreeMap> = BTreeMap::new(); + for claim in claims { + if claim + .claimant + .is_some_and(|id| authorization_evidence.contains(&id)) + { + release_allowed.insert(claim.outpoint); + } + stored_claims + .entry(claim.outpoint) + .or_default() + .push(*claim); + // Deleting a protected physical alias can still clear its claimant links. + if removed_evidence.contains(&claim.outpoint.txid) + || claim.claimant.is_some_and(|id| { + removed_evidence.contains(&id) || removed_records.contains(&id) + }) + { + affected.insert(claim.outpoint); + } + } + let mut pending: BTreeMap<_, _> = affected + .iter() + .filter_map(|outpoint| { + stored_claims + .get(outpoint) + .and_then(|claims| select_claim(claims, |_| true)) + .map(|claim| (*outpoint, claim)) + }) + .collect(); + let mut sweep_held = BTreeSet::new(); + for sweep in &self.sweeps { + let mut loser_identities: BTreeSet<_> = sweep.txids.iter().copied().collect(); + let mut inputs: BTreeSet<_> = sweep.released_outpoints.iter().copied().collect(); + for loser in &sweep.txids { + if let Some((body, record)) = records_by_key.get(loser) { + loser_identities.insert(*body); + inputs.extend( + record + .transaction + .input + .iter() + .map(|input| input.previous_output), + ); + } + } + // A placeholder survives its record, including an intermediate sweep winner. + inputs.extend(claims.iter().chain(pending.values()).filter_map(|claim| { + claim + .claimant + .filter(|id| loser_identities.contains(id)) + .map(|_| claim.outpoint) + })); + let released: BTreeSet<_> = sweep.released_outpoints.iter().copied().collect(); + release_allowed.extend(released.iter().copied()); + for outpoint in inputs { + affected.insert(outpoint); + if released.contains(&outpoint) { + pending.remove(&outpoint); + sweep_held.remove(&outpoint); + } else { + let stamp = sweep + .winner_mined_height + .or_else(|| pending.get(&outpoint).and_then(|claim| claim.mined_height)); + pending.insert( + outpoint, + StoredReplayClaim { + outpoint, + claimant: Some(sweep.superseded_by), + mined_height: stamp, + }, + ); + sweep_held.insert(outpoint); + } + } + } + let mut projected = BTreeMap::new(); + for outpoint in affected { + let strong = stored_claims.get(&outpoint).and_then(|claims| { + select_claim(claims, |claim| { + claim + .claimant + .is_none_or(|id| !removed_evidence.contains(&id)) + }) + }); + let survivor = survivors.get(&outpoint).and_then(BTreeMap::first_key_value); + let provisional = pending.get(&outpoint); + let effect = if removed_evidence.contains(&outpoint.txid) { + ReplayClaimEffect::Remove + } else if let Some(claim) = strong { + claim.effect() + } else if sweep_held.contains(&outpoint) { + let resolved = provisional.and_then(|claim| { + let id = claim.claimant?; + let claimant = if !removed_evidence.contains(&id) { + Some(id) + } else { + records_by_key + .get(&id) + .map(|(body, _)| *body) + .filter(|body| surviving_bodies.contains(body)) + }?; + Some(ReplayClaimEffect::Hold { + claimant: Some(claimant), + mined_height: claim.mined_height, + }) + }); + if let Some(effect) = resolved { + effect + } else if let Some((&claimant, &height)) = survivor { + ReplayClaimEffect::Hold { + claimant: Some(claimant), + mined_height: provisional.and_then(|claim| claim.mined_height).or(height), + } + } else { + return Err(HistoryReplayError::UnsettledAfterReplay); + } + } else if let Some((&claimant, &height)) = survivor { + ReplayClaimEffect::Hold { + claimant: Some(claimant), + mined_height: provisional.and_then(|claim| claim.mined_height).or(height), + } + } else if release_allowed.contains(&outpoint) { + ReplayClaimEffect::Release + } else { + return Err(HistoryReplayError::UnsettledAfterReplay); + }; + projected.insert(outpoint, effect); + } + Ok(HistoryReplayEffects { + removed_records, + removed_evidence, + claims: projected, + }) } } +impl StoredReplayClaim { + fn effect(self) -> ReplayClaimEffect { + ReplayClaimEffect::Hold { + claimant: self.claimant, + mined_height: self.mined_height, + } + } +} + +// An unbounded observation cannot become collectible because another row has a stamp. +fn merge_same_claim_stamp(left: Option, right: Option) -> Option { + left.zip(right).map(|(left, right)| left.max(right)) +} + +fn select_claim( + claims: &[StoredReplayClaim], + retain: impl Fn(&StoredReplayClaim) -> bool, +) -> Option { + let mut selected: Option = None; + for claim in claims.iter().filter(|claim| retain(claim)) { + match selected.as_mut() { + // Option ordering preserves anonymous provenance before any named claim. + Some(prior) if prior.claimant < claim.claimant => {} + Some(prior) if prior.claimant == claim.claimant => { + prior.mined_height = merge_same_claim_stamp(prior.mined_height, claim.mined_height); + } + _ => selected = Some(*claim), + } + } + selected +} + #[cfg(test)] mod tests { use super::*; @@ -110,7 +346,9 @@ mod tests { claim(inputs[1], None), claim(inputs[2], Some(point(9).txid)), ]; - let effects = outcome.storage_effects(std::slice::from_ref(&bad), &claims); + let effects = outcome + .storage_effects(std::slice::from_ref(&bad), &claims) + .unwrap(); assert_eq!(effects.removed_records, [bad.txid].into()); assert_eq!(effects.claims[&inputs[0]], ReplayClaimEffect::Release); assert_eq!( @@ -136,10 +374,12 @@ mod tests { let survivor = record(2, &[input]); let mut outcome = HistoryReplay::default(); outcome.authorization.rejected.insert(bad.txid); - let effects = outcome.storage_effects( - &[bad.clone(), survivor.clone()], - &[claim(input, Some(bad.txid))], - ); + let effects = outcome + .storage_effects( + &[bad.clone(), survivor.clone()], + &[claim(input, Some(bad.txid))], + ) + .unwrap(); assert_eq!( effects.claims[&input], ReplayClaimEffect::Hold { @@ -163,7 +403,9 @@ mod tests { winner_mined_height: Some(42), released_outpoints: vec![inputs[1]], }); - let effects = outcome.storage_effects(&[loser.clone(), child], &[]); + let effects = outcome + .storage_effects(&[loser.clone(), child], &[]) + .unwrap(); assert_eq!( effects.claims[&inputs[0]], ReplayClaimEffect::Hold { @@ -189,10 +431,12 @@ mod tests { bad.txid = surviving_body; let mut outcome = HistoryReplay::default(); outcome.authorization.rejected.insert(bad_body); - let effects = outcome.storage_effects( - &[bad, survivor.clone()], - &[claim(input, Some(surviving_body))], - ); + let effects = outcome + .storage_effects( + &[bad, survivor.clone()], + &[claim(input, Some(surviving_body))], + ) + .unwrap(); assert!(effects.removed_records.contains(&surviving_body)); assert!(!effects.removed_evidence.contains(&surviving_body)); assert_eq!( @@ -203,4 +447,307 @@ mod tests { } ); } + + #[test] + fn should_map_defeated_physical_keys_without_removing_surviving_canonical_evidence() { + let input = point(10); + let mut survivor = record(2, &[input]); + let survivor_body = survivor.txid; + survivor.txid = point(11).txid; + let mut loser = record(1, &[input]); + let loser_body = loser.txid; + loser.txid = survivor_body; + let mut outcome = HistoryReplay::default(); + outcome.defeated.insert(loser.txid); + let effects = outcome.storage_effects(&[loser, survivor], &[]).unwrap(); + assert_eq!(effects.removed_records, [survivor_body].into()); + assert_eq!(effects.removed_evidence, [loser_body].into()); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor_body), + mined_height: None, + } + ); + } + + fn chained(release: bool) -> (HistoryReplay, StoredTransaction, Txid, OutPoint) { + let input = point(12); + let loser = record(1, &[input]); + let middle = point(13).txid; + let winner = point(14).txid; + let mut outcome = HistoryReplay::default(); + outcome.defeated.extend([loser.txid, middle]); + outcome.sweeps = vec![ + SweepBatch { + txids: vec![loser.txid], + superseded_by: middle, + winner_mined_height: Some(42), + released_outpoints: vec![], + }, + SweepBatch { + txids: vec![middle], + superseded_by: winner, + winner_mined_height: None, + released_outpoints: if release { vec![input] } else { vec![] }, + }, + ]; + (outcome, loser, winner, input) + } + + #[test] + fn should_follow_chained_claims_without_an_intermediate_body_and_keep_the_mined_stamp() { + let (outcome, loser, winner, input) = chained(false); + let effects = outcome.storage_effects(&[loser], &[]).unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(winner), + mined_height: Some(42), + } + ); + assert!(!effects.removed_evidence.contains(&winner)); + } + + #[test] + fn should_apply_a_later_release_to_a_recordless_intermediate_claim() { + let (outcome, loser, _, input) = chained(true); + let effects = outcome.storage_effects(&[loser], &[]).unwrap(); + assert_eq!(effects.claims[&input], ReplayClaimEffect::Release); + } + + #[test] + fn should_keep_anonymous_provenance_over_named_claims_and_a_sweep_winner() { + let (outcome, loser, _, input) = chained(false); + let mut claims = [claim(input, Some(point(15).txid)), claim(input, None)]; + claims[0].mined_height = Some(99); + let effects = outcome + .storage_effects(std::slice::from_ref(&loser), &claims) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None, + } + ); + claims.reverse(); + assert_eq!(effects, outcome.storage_effects(&[loser], &claims).unwrap()); + } + + #[test] + fn should_choose_survivors_deterministically_and_preserve_existing_valid_provenance() { + let input = point(16); + let bad = record(1, &[input]); + let a = record(2, &[input]); + let b = record(3, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad.txid); + let mut records = [bad.clone(), a.clone(), b.clone()]; + let effects = outcome + .storage_effects(&records, &[claim(input, Some(bad.txid))]) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(a.txid.min(b.txid)), + mined_height: None, + } + ); + records.reverse(); + assert_eq!( + effects, + outcome + .storage_effects(&records, &[claim(input, Some(bad.txid))]) + .unwrap() + ); + let retained = a.txid.max(b.txid); + let effects = outcome + .storage_effects(&records, &[claim(input, Some(retained))]) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(retained), + mined_height: None, + } + ); + } + + #[test] + fn should_not_borrow_a_stamp_from_another_surviving_claimant() { + let (outcome, loser, _, input) = chained(false); + let survivor = point(17).txid; + let mut claims = [claim(input, Some(survivor)), claim(input, Some(loser.txid))]; + claims[1].mined_height = Some(70); + let effects = outcome + .storage_effects(std::slice::from_ref(&loser), &claims) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor), + mined_height: None, + } + ); + claims[1].claimant = Some(survivor); + claims[0].mined_height = Some(50); + assert_eq!( + outcome + .storage_effects(std::slice::from_ref(&loser), &claims) + .unwrap() + .claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor), + mined_height: Some(70), + } + ); + claims[0].mined_height = None; + assert_eq!( + outcome.storage_effects(&[loser], &claims).unwrap().claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor), + mined_height: None, + } + ); + } + + #[test] + fn should_remove_co_removed_funding_even_with_an_anonymous_hold() { + let funding = record(1, &[]); + let outpoint = OutPoint::new(funding.txid, 0); + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(funding.txid); + let effects = outcome + .storage_effects(&[funding], &[claim(outpoint, None)]) + .unwrap(); + assert_eq!(effects.claims[&outpoint], ReplayClaimEffect::Remove); + } + + #[test] + fn should_leave_retired_only_records_and_unaffected_claims_unchanged() { + let input = point(18); + let unsafe_record = record(1, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.retired.insert(unsafe_record.txid); + let claims = [claim(input, Some(unsafe_record.txid))]; + assert_eq!( + outcome.storage_effects(&[unsafe_record], &claims).unwrap(), + HistoryReplayEffects::default() + ); + } + + #[test] + fn should_not_delete_an_unrelated_physical_row_for_a_missing_rejected_body() { + let mut survivor = record(1, &[]); + let rejected = point(19).txid; + survivor.txid = rejected; + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(rejected); + let effects = outcome.storage_effects(&[survivor], &[]).unwrap(); + assert!(effects.removed_records.is_empty()); + } + + #[test] + fn should_fail_without_partial_effects_when_a_final_sweep_winner_cannot_be_resolved() { + let (mut outcome, loser, winner, _) = chained(false); + outcome.authorization.rejected.insert(winner); + assert!(matches!( + outcome.storage_effects(&[loser], &[]), + Err(HistoryReplayError::UnsettledAfterReplay) + )); + } + + #[test] + fn should_resolve_a_sweep_winners_removed_physical_alias_to_its_surviving_body() { + let input = point(20); + let mut loser = record(1, &[input]); + let mut winner = record(2, &[input]); + winner.txid = loser.txid; + loser.txid = point(21).txid; + let winner_body = winner.transaction.txid(); + let mut outcome = HistoryReplay::default(); + outcome.defeated.insert(loser.txid); + outcome.sweeps.push(SweepBatch { + txids: vec![loser.txid], + superseded_by: winner.txid, + winner_mined_height: Some(44), + released_outpoints: vec![], + }); + let effects = outcome.storage_effects(&[loser, winner], &[]).unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(winner_body), + mined_height: Some(44), + } + ); + } + + #[test] + fn should_restore_a_protected_alias_claim_even_outside_the_removed_bodys_inputs() { + let input = point(22); + let mut survivor = record(2, &[input]); + let survivor_body = survivor.txid; + survivor.txid = point(23).txid; + let mut bad = record(1, &[]); + let bad_body = bad.txid; + bad.txid = survivor_body; + let mut outcome = HistoryReplay::default(); + outcome.authorization.rejected.insert(bad_body); + let effects = outcome + .storage_effects(&[bad, survivor], &[claim(input, Some(survivor_body))]) + .unwrap(); + assert_eq!( + effects.claims[&input], + ReplayClaimEffect::Hold { + claimant: Some(survivor_body), + mined_height: None, + } + ); + } + + #[test] + fn should_refuse_to_release_a_defeated_claim_without_a_sweep_verdict() { + let input = point(24); + let loser = record(1, &[input]); + let mut outcome = HistoryReplay::default(); + outcome.defeated.insert(loser.txid); + for claims in [vec![], vec![claim(input, Some(loser.txid))]] { + assert!(matches!( + outcome.storage_effects(std::slice::from_ref(&loser), &claims), + Err(HistoryReplayError::UnsettledAfterReplay) + )); + } + } + + #[test] + fn should_leave_a_settled_recordless_chain_unchanged_on_repeated_projection() { + let (outcome, loser, winner, input) = chained(false); + let first = outcome.storage_effects(&[loser], &[]).unwrap(); + let ReplayClaimEffect::Hold { + claimant, + mined_height, + } = first.claims[&input] + else { + panic!("the settled input must stay held") + }; + assert_eq!(claimant, Some(winner)); + let second = outcome + .storage_effects( + &[], + &[StoredReplayClaim { + outpoint: input, + claimant, + mined_height, + }], + ) + .unwrap(); + assert_eq!(first.removed_records, second.removed_records); + assert_eq!(first.removed_evidence, second.removed_evidence); + assert!( + second.claims.is_empty(), + "the surviving hold requires no rewrite" + ); + } } From 6a65b58d2d01ba48faf235ef953f17ea2c253468 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 15:26:32 +0000 Subject: [PATCH 66/84] refactor(wallet-storage): apply shared replay claim effects atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compute the shared owner's Result before any mutation. SQLite applies record/evidence removals followed by exact claim verdicts, preserving anonymous holds and explicit lifetime stamps without another SQL policy. Keep physical record keys alongside decoded startup records so Recovery repairs retain their original row identity without a second decode. Validation: 28 targeted SQL/FFI integration tests, including rollback, wallet isolation, migration gates, checked proof retry and Recovery drift; three-crate CI Clippy clean. Swift atomic adapter remains pending. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/persistence/replay.rs | 4 +- .../src/sqlite/history_replay.rs | 329 ++++++++++-------- .../src/sqlite/persister.rs | 38 +- .../src/sqlite/schema/core_state.rs | 26 +- .../src/manager/history_replay.rs | 28 -- 5 files changed, 234 insertions(+), 191 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs index 6dc14f4fe18..1ada73714f7 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -240,7 +240,9 @@ impl FFIPersister { let records = wallet.recorded_history.transactions.clone(); let outcome = replay_wallet_history_blocking(wallet, evidence).map_err(PersistenceError::backend)?; - let effects = outcome.storage_effects(&records, &claims); + let effects = outcome + .storage_effects(&records, &claims) + .map_err(PersistenceError::backend)?; let has_effects = !effects.removed_records.is_empty() || !effects.removed_evidence.is_empty() || !effects.claims.is_empty(); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs index 90a880affa1..df532f00025 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs @@ -1,29 +1,29 @@ //! SQL adapter for wallet-owned replay effects. -use crate::sqlite::util::safe_cast::i64_to_u64; +use crate::sqlite::util::safe_cast::{i64_to_u32, i64_to_u64}; use dashcore::{hashes::Hash, OutPoint, ScriptBuf, TxOut, Txid}; -use platform_wallet::changeset::CoreChangeSet; -use platform_wallet::manager::history_replay::HistoryReplay; +use platform_wallet::manager::history_replay::{ + HistoryReplayEffects, ReplayClaimEffect, StoredReplayClaim, +}; use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; -use std::collections::{BTreeMap, HashMap, HashSet}; -use std::slice; +use std::collections::BTreeMap; -use crate::sqlite::schema::{asset_locks, blob, core_state, dashpay}; +use crate::sqlite::schema::{asset_locks, blob, dashpay}; use crate::WalletStorageError; /// Decode durable claim provenance from the same snapshot as wallet history. pub(crate) fn load_spent_claims( conn: &Connection, wallet_id: &WalletId, -) -> Result)>, WalletStorageError> { +) -> Result, WalletStorageError> { // SQLite evaluates every result column when the row is stepped, but a // CASE lazily: a payload past its gate is never loaded, and the length // beside it reports the typed error below. let mut stmt = conn.prepare_cached( "SELECT length(outpoint), CASE WHEN length(outpoint) <= ?2 THEN outpoint END, \ length(spent_in_txid), \ - CASE WHEN length(spent_in_txid) = 32 THEN spent_in_txid END \ + CASE WHEN length(spent_in_txid) = 32 THEN spent_in_txid END, winner_mined_height \ FROM core_utxos WHERE wallet_id = ?1 AND spent = 1", )?; let limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); @@ -39,49 +39,18 @@ pub(crate) fn load_spent_claims( } else { None }; - outpoints.push((blob::decode_outpoint(&bytes)?, claimant)); + outpoints.push(StoredReplayClaim { + outpoint: blob::decode_outpoint(&bytes)?, + claimant, + mined_height: row + .get::<_, Option>(4)? + .map(|height| i64_to_u32("core_utxos.winner_mined_height", height)) + .transpose()?, + }); } Ok(outpoints) } -/// Apply an owner's effects atomically, preserving claim provenance it protects. -pub(crate) fn apply( - tx: &Transaction<'_>, - wallet_id: &WalletId, - outcome: &mut HistoryReplay, -) -> Result<(), WalletStorageError> { - apply_authorization(tx, wallet_id, outcome)?; - let claims = load_spent_claims(tx, wallet_id)?; - let held = outcome.protect_durable_claims(&claims); - let mut preserved = Vec::new(); - for outpoint in held { - let key = blob::encode_outpoint(&outpoint)?; - let (claimant, height): (Option>, Option) = tx.query_row( - "SELECT spent_in_txid, winner_mined_height FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", - params![wallet_id.as_slice(), &key[..]], - |row| Ok((row.get(0)?, row.get(1)?)), - )?; - preserved.push((key, claimant, height)); - } - asset_locks::remove_swept(tx, wallet_id, &outcome.defeated)?; - dashpay::settle_swept_payments(tx, wallet_id, &outcome.defeated)?; - core_state::apply( - tx, - wallet_id, - &CoreChangeSet { - sweeps: outcome.sweeps.clone(), - ..Default::default() - }, - )?; - for (outpoint, claimant, height) in preserved { - tx.execute( - "UPDATE core_utxos SET spent = 1, spent_in_txid = ?3, winner_mined_height = ?4 WHERE wallet_id = ?1 AND outpoint = ?2", - params![wallet_id.as_slice(), &outpoint[..], claimant, height], - )?; - } - Ok(()) -} - /// Stored coin material is validated against wallet address ownership by the owner. pub(crate) fn load_prevouts( conn: &Connection, @@ -107,129 +76,181 @@ pub(crate) fn load_prevouts( Ok(coins) } -/// Map canonical owner verdicts to physical rows without inferring authorization in SQL. -fn apply_authorization( +/// Apply storage-independent effects without selecting claimants in SQL. +pub(crate) fn apply_effects( tx: &Transaction<'_>, wallet_id: &WalletId, - outcome: &HistoryReplay, + effects: &HistoryReplayEffects, ) -> Result<(), WalletStorageError> { - let rejected: HashSet<_> = outcome.authorization.rejected.iter().copied().collect(); - if rejected.is_empty() { - return Ok(()); - } - let mut record_keys: HashMap> = HashMap::new(); - let limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); - let mut stmt = tx.prepare_cached("SELECT length(txid), CASE WHEN length(txid) = 32 THEN txid END, length(record_blob), CASE WHEN length(record_blob) <= ?2 THEN record_blob END FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL")?; - let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; - while let Some(row) = rows.next()? { - blob::check_fixed_width(row.get(0)?, 32, "core_transactions.txid")?; - blob::check_size(row.get(2)?)?; - let key: Vec = row.get(1)?; - let raw: Vec = row.get(3)?; - let record = core_state::decode_transaction_record(&raw)?; - record_keys - .entry(record.transaction.txid()) - .or_default() - .push(Txid::from_slice(&key)?); - } - let survivors: HashMap<_, _> = outcome - .authorization - .surviving_claimants - .iter() - .filter_map(|(outpoint, claims)| claims.first().map(|claim| (*outpoint, *claim))) - .collect(); - let physical_keys: HashSet = rejected - .iter() - .flat_map(|txid| { - record_keys - .get(txid) - .map(Vec::as_slice) - .unwrap_or_else(|| slice::from_ref(txid)) - }) - .copied() - .collect(); - let surviving_txids: HashSet = record_keys - .keys() - .filter(|txid| !rejected.contains(*txid)) - .copied() - .collect(); - let protected_keys: HashSet = physical_keys - .intersection(&surviving_txids) - .copied() - .collect(); - // A corrupt physical key can also name a surviving body: its evidence belongs to that survivor. - let rejected_evidence: HashSet = rejected - .union(&physical_keys) - .filter(|txid| !surviving_txids.contains(*txid)) - .copied() - .collect(); - let mut protected_claims = Vec::new(); - // Release before deleting bodies: their cleanup trigger clears claimant IDs. - // Never reinterpret an anonymous or recordless final claim as a rejected peer claim. - let mut claims = Vec::new(); - let mut stmt = tx.prepare_cached("SELECT length(outpoint), CASE WHEN length(outpoint) <= ?2 THEN outpoint END, length(spent_in_txid), CASE WHEN length(spent_in_txid) = 32 THEN spent_in_txid END, is_sweep_placeholder FROM core_utxos WHERE wallet_id = ?1 AND spent = 1 AND spent_in_txid IS NOT NULL")?; - let mut rows = stmt.query(params![wallet_id.as_slice(), limit])?; - while let Some(row) = rows.next()? { - blob::check_size(row.get(0)?)?; - let raw: Vec = row.get(1)?; - blob::check_fixed_width(row.get(2)?, 32, "core_utxos.spent_in_txid")?; - let claimant: Vec = row.get(3)?; - let claimant = Txid::from_slice(&claimant)?; - if protected_keys.contains(&claimant) { - protected_claims.push((raw, claimant)); - } else if rejected_evidence.contains(&claimant) { - claims.push((raw, row.get::<_, bool>(4)?)); - } - } - for (raw, placeholder) in claims { - let op = blob::decode_outpoint(&raw)?; - if let Some(survivor) = survivors.get(&op) { - if protected_keys.contains(survivor) { - protected_claims.push((raw.clone(), *survivor)); - } - tx.execute( - "UPDATE core_utxos SET spent_in_txid = ?3 WHERE wallet_id = ?1 AND outpoint = ?2", - params![ - wallet_id.as_slice(), - raw, - survivor.as_byte_array().as_slice() - ], - )?; - } else if placeholder { - tx.execute( - "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", - params![wallet_id.as_slice(), raw], - )?; - } else { - tx.execute("UPDATE core_utxos SET spent = 0, spent_in_txid = NULL, winner_mined_height = NULL WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet_id.as_slice(), raw])?; - } - } - for txid in &physical_keys { + for txid in &effects.removed_records { tx.execute( "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", params![wallet_id.as_slice(), txid.as_byte_array().as_slice()], )?; } - // The row-delete trigger clears claims by physical key, even when that key - // names a surviving blob. Restore only the claims we just protected. - for (raw, claimant) in protected_claims { - tx.execute("UPDATE core_utxos SET spent_in_txid = ?3 WHERE wallet_id = ?1 AND outpoint = ?2 AND spent = 1 AND spent_in_txid IS NULL", - params![wallet_id.as_slice(), raw, claimant.as_byte_array().as_slice()])?; - } - for txid in &rejected_evidence { + for txid in &effects.removed_evidence { let key = params![wallet_id.as_slice(), txid.as_byte_array().as_slice()]; tx.execute( "DELETE FROM core_instant_locks WHERE wallet_id = ?1 AND txid = ?2", key, )?; - // `encode_outpoint_txid_occupies_bytes_two_to_thirty_three` pins this prefix. + // The encoded outpoint's transaction ID occupies bytes 2..=33. tx.execute( - "DELETE FROM core_utxos \ - WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2", + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND substr(outpoint, 2, 32) = ?2", key, )?; } - asset_locks::remove_swept(tx, wallet_id, &rejected_evidence)?; - dashpay::settle_swept_payments(tx, wallet_id, &rejected_evidence)?; + let removed = effects.removed_evidence.iter().copied().collect(); + asset_locks::remove_swept(tx, wallet_id, &removed)?; + dashpay::settle_swept_payments(tx, wallet_id, &removed)?; + // Row deletion can clear claimant links; exact owner effects restore them last. + for (outpoint, effect) in &effects.claims { + let key = blob::encode_outpoint(outpoint)?; + match effect { + ReplayClaimEffect::Remove => { + tx.execute( + "DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![wallet_id.as_slice(), key], + )?; + } + ReplayClaimEffect::Release => { + tx.execute("DELETE FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2 AND is_sweep_placeholder = 1", params![wallet_id.as_slice(), key])?; + tx.execute("UPDATE core_utxos SET spent = 0, spent_in_txid = NULL, winner_mined_height = NULL WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet_id.as_slice(), key])?; + } + ReplayClaimEffect::Hold { + claimant, + mined_height, + } => { + tx.execute("INSERT INTO core_utxos (wallet_id, outpoint, value, script, is_sweep_placeholder, spent, spent_in_txid, winner_mined_height) VALUES (?1, ?2, 0, X'', 1, 1, ?3, ?4) ON CONFLICT(wallet_id, outpoint) DO UPDATE SET spent = 1, spent_in_txid = excluded.spent_in_txid, winner_mined_height = excluded.winner_mined_height", + params![wallet_id.as_slice(), key, claimant.map(|id| id.to_byte_array().to_vec()), mined_height.map(i64::from)])?; + } + } + } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migrations; + use platform_wallet::manager::history_replay::{HistoryReplayEffects, ReplayClaimEffect}; + + fn database() -> Connection { + let mut connection = Connection::open_in_memory().unwrap(); + migrations::run(&mut connection).unwrap(); + for id in [[1u8; 32], [2u8; 32]] { + connection.execute("INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", params![id.as_slice()]).unwrap(); + } + connection + } + + fn point(marker: u8) -> OutPoint { + OutPoint::new(Txid::from_byte_array([marker; 32]), 0) + } + + fn seed_coin(connection: &Connection, wallet: &[u8; 32], point: OutPoint) { + connection.execute("INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent, spent_in_txid, winner_mined_height) VALUES (?1, ?2, 100, X'', 1, ?3, 99)", + params![wallet.as_slice(), blob::encode_outpoint(&point).unwrap(), point.txid.as_byte_array().as_slice()]).unwrap(); + } + + #[test] + fn should_apply_exact_owner_claims_without_inheriting_a_removed_stamp() { + let mut connection = database(); + let held = point(3); + let released = point(4); + let missing = point(5); + for id in [[1u8; 32], [2u8; 32]] { + seed_coin(&connection, &id, held); + seed_coin(&connection, &id, released); + } + let effects = HistoryReplayEffects { + claims: [ + ( + held, + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None, + }, + ), + (released, ReplayClaimEffect::Release), + ( + missing, + ReplayClaimEffect::Hold { + claimant: Some(point(6).txid), + mined_height: Some(101), + }, + ), + ] + .into(), + ..Default::default() + }; + let tx = connection.transaction().unwrap(); + apply_effects(&tx, &[1; 32], &effects).unwrap(); + tx.commit().unwrap(); + let row = |wallet: [u8; 32], + point: OutPoint| + -> (bool, Option>, Option, bool) { + connection.query_row("SELECT spent, spent_in_txid, winner_mined_height, is_sweep_placeholder FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", params![wallet.as_slice(), blob::encode_outpoint(&point).unwrap()], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))).unwrap() + }; + assert_eq!(row([1; 32], held), (true, None, None, false)); + assert_eq!(row([1; 32], released), (false, None, None, false)); + assert_eq!( + row([1; 32], missing), + ( + true, + Some(point(6).txid.to_byte_array().to_vec()), + Some(101), + true + ) + ); + assert_eq!( + row([2; 32], held), + ( + true, + Some(held.txid.to_byte_array().to_vec()), + Some(99), + false + ) + ); + } + + #[test] + fn should_roll_back_removals_when_a_later_claim_write_fails() { + let mut connection = database(); + let record = point(7).txid; + let held = point(8); + seed_coin(&connection, &[1; 32], held); + connection.execute("INSERT INTO core_transactions (wallet_id, txid, height, finalized) VALUES (?1, ?2, 1, 0)", params![[1u8; 32].as_slice(), record.as_byte_array().as_slice()]).unwrap(); + connection.execute_batch("CREATE TRIGGER fail_replay_claim BEFORE UPDATE ON core_utxos WHEN NOT EXISTS (SELECT 1 FROM core_transactions) BEGIN SELECT RAISE(ABORT, 'replay claim fault'); END;").unwrap(); + { + let tx = connection.transaction().unwrap(); + let effects = HistoryReplayEffects { + removed_records: [record].into(), + claims: [( + held, + ReplayClaimEffect::Hold { + claimant: None, + mined_height: None, + }, + )] + .into(), + ..Default::default() + }; + let error = apply_effects(&tx, &[1; 32], &effects).unwrap_err(); + assert!( + matches!(&error, WalletStorageError::Sqlite(cause) if cause.to_string().contains("replay claim fault")), + "{error:?}" + ); + } + assert_eq!( + connection + .query_row( + "SELECT count(*) FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![[1u8; 32].as_slice(), record.as_byte_array().as_slice()], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 9b703ab8884..45161b94cc4 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1798,12 +1798,19 @@ fn load_one_wallet( .map_err(WalletStorageError::from) .map_err(PersistenceError::from)?; let mut state = load_wallet_snapshot(&tx, wallet_id, ctx)?; + let records = state.recorded_history.transactions.clone(); let mut outcome = replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; let mut claims = history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; - if !outcome.sweeps.is_empty() || !outcome.authorization.rejected.is_empty() { - history_replay::apply(&tx, &wallet_id, &mut outcome).map_err(PersistenceError::from)?; + let effects = outcome + .storage_effects(&records, &claims) + .map_err(replay_failure)?; + if !effects.removed_records.is_empty() + || !effects.removed_evidence.is_empty() + || !effects.claims.is_empty() + { + history_replay::apply_effects(&tx, &wallet_id, &effects).map_err(PersistenceError::from)?; state = load_wallet_snapshot(&tx, wallet_id, ctx)?; let remaining = replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; @@ -1816,7 +1823,12 @@ fn load_one_wallet( claims = history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; } - state.wallet_info.restore_spent_outpoints(&claims); + state.wallet_info.restore_spent_outpoints( + &claims + .iter() + .map(|claim| (claim.outpoint, claim.claimant)) + .collect::>(), + ); outcome.read_only = policy == LoadPolicy::Recovery; if outcome.read_only { tx.rollback() @@ -1851,9 +1863,12 @@ fn load_wallet_snapshot( let account_manifest = schema::accounts::load_state(conn, &wallet_id, ctx).map_err(PersistenceError::from)?; - let (core_state, utxo_accounts) = - schema::core_state::load_state(conn, &wallet_id, network, ctx) - .map_err(PersistenceError::from)?; + let schema::core_state::CoreStateSnapshot { + changes: core_state, + utxo_accounts, + record_keys, + } = schema::core_state::load_snapshot(conn, &wallet_id, network, ctx) + .map_err(PersistenceError::from)?; // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader // already carrying its own public keys + contact state (matching // the FFI persister), so signing works immediately post-load @@ -2024,7 +2039,16 @@ fn load_wallet_snapshot( required_instant_locks: Default::default(), owned_prevouts: history_replay::load_prevouts(conn, &wallet_id) .map_err(PersistenceError::from)?, - transactions: core_state.records.into_iter().map(Into::into).collect(), + transactions: core_state + .records + .into_iter() + .zip(record_keys) + .map(|(record, key)| { + let mut stored = platform_wallet::changeset::StoredTransaction::from(record); + stored.txid = key; + stored + }) + .collect(), instant_locks: core_state.instant_locks_for_non_final_records, }, // Not persisted by this backend yet: the backfill record rides the diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 0bf075db875..1f2a2007b85 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -960,7 +960,26 @@ pub fn load_state( ), WalletStorageError, > { + let snapshot = load_snapshot(conn, wallet_id, network, ctx)?; + Ok((snapshot.changes, snapshot.utxo_accounts)) +} + +/// Physical keys stay paired with accepted records, including tolerated Recovery drift. +pub(crate) struct CoreStateSnapshot { + pub changes: CoreChangeSet, + pub utxo_accounts: HashMap, + pub record_keys: Vec, +} + +/// Read projection and replay row identities together without decoding records twice. +pub(crate) fn load_snapshot( + conn: &Connection, + wallet_id: &WalletId, + network: dashcore::Network, + ctx: &LoadCtx, +) -> Result { let mut cs = CoreChangeSet::default(); + let mut record_keys = Vec::new(); let mut utxo_accounts: HashMap = HashMap::new(); let mut transaction_heights: HashMap> = HashMap::new(); @@ -997,6 +1016,7 @@ pub fn load_state( ctx.tolerate(LoadSite::CoreTransactionColumnDrift, mismatch)?; } cs.records.push(record); + record_keys.push(txid); transaction_heights.insert(effective_txid, effective_height); blob_backed_transaction_heights.insert(effective_txid); } else if !blob_backed_transaction_heights.contains(&effective_txid) { @@ -1115,7 +1135,11 @@ pub fn load_state( } } - Ok((cs, utxo_accounts)) + Ok(CoreStateSnapshot { + changes: cs, + utxo_accounts, + record_keys, + }) } /// Every address that has ever held a `core_utxos` row for this wallet — diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index aecae4da033..eb7138ab4af 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -80,34 +80,6 @@ pub struct HistoryReplay { } impl HistoryReplay { - /// Keep durable anonymous or surviving claims out of the release effects. - pub fn protect_durable_claims( - &mut self, - claims: &[(OutPoint, Option)], - ) -> BTreeSet { - let candidates: HashSet<_> = self - .sweeps - .iter() - .flat_map(|sweep| sweep.released_outpoints.iter().copied()) - .collect(); - let held: BTreeSet<_> = claims - .iter() - .filter_map(|(outpoint, claimant)| { - (candidates.contains(outpoint) - && claimant.is_none_or(|txid| { - !self.defeated.contains(&txid) - && !self.authorization.rejected.contains(&txid) - })) - .then_some(*outpoint) - }) - .collect(); - for sweep in &mut self.sweeps { - sweep - .released_outpoints - .retain(|outpoint| !held.contains(outpoint)); - } - held - } /// The unconfirmed sends of `sends` still worth dispatching again. /// /// A dispatch hands the transaction back to the wallet as a mempool From 5c176cf173d6ea0ac39870e74ef83a44f12120d2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:00:37 +0000 Subject: [PATCH 67/84] feat(platform-wallet): apply shared replay effects in host transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project asset-lock removals and DashPay retirement in the wallet owner, then apply exact effects in SQLite and synchronous FFI transactions. Swift marshals claim metadata and wallet-scoped row changes without choosing finality or claimant policy. Reentrant callbacks share one queue and storage scope; failures roll back before publication. Targeted Rust owner and adapter tests pass, including effect-write rollback and wallet-linked identities without a registration index. Scoped three-crate Clippy passes. Swift tests are written but cannot run on this Linux host; Apple compilation/runtime validation remains open. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/persistence/replay.rs | 194 ++++++- .../src/sqlite/history_replay.rs | 5 +- .../src/sqlite/persister.rs | 5 + .../src/sqlite/schema/asset_locks.rs | 27 - .../src/sqlite/schema/dashpay.rs | 38 +- .../src/manager/history_claims.rs | 227 ++++++++- .../src/manager/history_replay.rs | 4 +- .../PlatformWalletPersistenceHandler.swift | 478 ++++++++++++------ .../AtomicHistoryReplayTests.swift | 105 ++++ .../IdentityIsLocalPersistenceTests.swift | 12 +- 10 files changed, 840 insertions(+), 255 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs index 1ada73714f7..a66a70adec2 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -16,6 +16,8 @@ use platform_wallet::manager::history_replay::{ use platform_wallet::wallet::platform_wallet::WalletId; use super::FFIPersister; +use crate::core_wallet_types::OutPointFFI; +use crate::dashpay_payment::{build_payment_persist_entries, DashpayPaymentPersistEntryFFI}; /// Scope-only callback. The host must invoke it once synchronously and never retain it. pub type ReplayOperationFn = unsafe extern "C" fn(operation_context: *mut c_void) -> i32; @@ -82,6 +84,10 @@ pub struct HistoryReplayEffectsFFI { pub removed_evidence_count: usize, pub claims: *const ReplayClaimEffectFFI, pub claims_count: usize, + pub removed_asset_locks: *const OutPointFFI, + pub removed_asset_locks_count: usize, + pub payments: *const DashpayPaymentPersistEntryFFI, + pub payments_count: usize, } #[derive(Clone, Copy, Default)] @@ -292,6 +298,14 @@ impl FFIPersister { } }) .collect(); + let operations = effects.operation_effects(wallet); + let asset_locks: Vec<_> = operations + .asset_locks + .removed + .iter() + .map(OutPointFFI::from) + .collect(); + let (payments, _payment_strings) = build_payment_persist_entries(&operations.payments); let ffi = HistoryReplayEffectsFFI { removed_records: slice_pointer(&removed_records), removed_records_count: removed_records.len(), @@ -299,6 +313,10 @@ impl FFIPersister { removed_evidence_count: removed_evidence.len(), claims: slice_pointer(&claim_effects), claims_count: claim_effects.len(), + removed_asset_locks: slice_pointer(&asset_locks), + removed_asset_locks_count: asset_locks.len(), + payments: slice_pointer(&payments), + payments_count: payments.len(), }; let code = unsafe { apply(self.callbacks.context, wallet_id.as_ptr(), &ffi) }; if code != 0 { @@ -454,6 +472,8 @@ mod tests { use platform_wallet::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; use std::cell::{Cell, RefCell}; + type AppliedClaim = (OutPoint, u8, Option, Option); + #[derive(Default)] struct Host { scopes: Cell, @@ -466,6 +486,9 @@ mod tests { claim_frees: Cell, claim_mode: Cell, effects: Cell, + additional: RefCell>, + account_xpub: Vec, + applied_claims: RefCell>, } unsafe extern "C" fn scope( @@ -477,6 +500,7 @@ mod tests { let host = &*ctx.cast::(); host.scopes.set(host.scopes.get() + 1); host.inside.set(true); + let saved = host.additional.borrow().clone(); if let Some(replacement) = host.replacement.borrow_mut().take() { *host.transaction.borrow_mut() = Some(replacement); } @@ -497,6 +521,9 @@ mod tests { let _ = operation(operation_ctx); } host.inside.set(false); + if first != 0 || host.mode.get() == 4 { + *host.additional.borrow_mut() = saved; + } match host.mode.get() { 1..=3 => 0, 4 => 71, @@ -516,28 +543,53 @@ mod tests { if host.fail_read.get() { return 73; } - let history = host.transaction.borrow().as_ref().map(|transaction| { - let bytes = dashcore::consensus::serialize(transaction).into_boxed_slice(); - let len = bytes.len(); - Box::into_raw(Box::new(RecordedTransactionRestoreFFI { - txid: transaction.txid().to_byte_array(), - tx_bytes: Box::into_raw(bytes).cast(), - tx_bytes_len: len, - context: 1, - block_height: 0, - block_hash: [0; 32], - block_timestamp: 0, - block_position: 0, - has_block_position: false, - net_amount: 0, - direction: 0, - })) + let transactions: Vec<_> = host + .transaction + .borrow() + .iter() + .cloned() + .map(|tx| (tx, 1)) + .chain(host.additional.borrow().iter().cloned()) + .collect(); + let history: Vec<_> = transactions + .iter() + .map(|(transaction, context)| { + let bytes = dashcore::consensus::serialize(transaction).into_boxed_slice(); + let len = bytes.len(); + RecordedTransactionRestoreFFI { + txid: transaction.txid().to_byte_array(), + tx_bytes: Box::into_raw(bytes).cast(), + tx_bytes_len: len, + context: *context, + block_height: if *context >= 2 { 42 } else { 0 }, + block_hash: [1; 32], + block_timestamp: 0, + block_position: 0, + has_block_position: false, + net_amount: 0, + direction: 0, + } + }) + .collect(); + let history_len = history.len(); + let history = Box::into_raw(history.into_boxed_slice()).cast(); + let account = (!host.account_xpub.is_empty()).then(|| { + let kind = key_wallet::account::AccountType::Standard { + index: 0, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + }; + Box::into_raw(Box::new(super::super::build_account_spec_ffi( + &kind, + &host.account_xpub, + ))) }); *rows = Box::into_raw(Box::new(WalletRestoreEntryFFI { wallet_id: [41; 32], + accounts: account.unwrap_or(std::ptr::null_mut()), + accounts_count: usize::from(account.is_some()), network: FFINetwork::Testnet, - recorded_transactions: history.unwrap_or(std::ptr::null_mut()), - recorded_transactions_count: usize::from(history.is_some()), + recorded_transactions: history, + recorded_transactions_count: history_len, ..Default::default() })); *count = 1; @@ -546,12 +598,20 @@ mod tests { unsafe extern "C" fn free(_: *mut c_void, rows: *const WalletRestoreEntryFFI, _: usize) { if !rows.is_null() { let entry = Box::from_raw(rows.cast_mut()); + if !entry.accounts.is_null() { + drop(Box::from_raw(entry.accounts.cast_mut())); + } if !entry.recorded_transactions.is_null() { - let record = Box::from_raw(entry.recorded_transactions.cast_mut()); - drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( - record.tx_bytes, - record.tx_bytes_len, - ))); + let records = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entry.recorded_transactions.cast_mut(), + entry.recorded_transactions_count, + )); + for record in &records { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + record.tx_bytes, + record.tx_bytes_len, + ))); + } } } } @@ -577,11 +637,95 @@ mod tests { unsafe extern "C" fn apply( ctx: *mut c_void, _: *const u8, - _: *const HistoryReplayEffectsFFI, + effects: *const HistoryReplayEffectsFFI, ) -> i32 { let host = &*ctx.cast::(); + assert!(host.inside.get()); host.effects.set(host.effects.get() + 1); - 0 + let effects = &*effects; + if effects.removed_records_count != 0 { + let removed = + std::slice::from_raw_parts(effects.removed_records, effects.removed_records_count); + host.additional + .borrow_mut() + .retain(|(tx, _)| !removed.contains(&tx.txid().to_byte_array())); + } + if effects.claims_count != 0 { + let claims = std::slice::from_raw_parts(effects.claims, effects.claims_count); + *host.applied_claims.borrow_mut() = claims + .iter() + .map(|claim| { + ( + OutPoint::new(Txid::from_byte_array(claim.txid), claim.vout), + claim.action, + claim + .has_claimant + .then(|| Txid::from_byte_array(claim.claimant)), + claim.has_mined_height.then_some(claim.mined_height), + ) + }) + .collect(); + } + if host.mode.get() == 6 { + 81 + } else { + 0 + } + } + + fn host_with_funds_account() -> Host { + let wallet = key_wallet::Wallet::from_seed_bytes( + [0x51; 64], + dashcore::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .unwrap(); + Host { + account_xpub: bincode::encode_to_vec( + wallet.get_bip44_account(0).unwrap().account_xpub, + bincode::config::standard(), + ) + .unwrap(), + ..Default::default() + } + } + + #[test] + fn should_apply_exact_owner_claims_and_reread_inside_the_host_transaction() { + let winner = transaction(1); + let loser = transaction(2); + let host = host_with_funds_account(); + *host.additional.borrow_mut() = vec![(winner.clone(), 3), (loser, 0)]; + let loaded = persister(&host) + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert_eq!(loaded.state.wallets.len(), 1); + assert_eq!(host.effects.get(), 1); + assert_eq!(*host.additional.borrow(), vec![(winner.clone(), 3)]); + assert_eq!( + *host.applied_claims.borrow(), + vec![( + winner.input[0].previous_output, + 2, + Some(winner.txid()), + Some(42) + )] + ); + assert!(host.scoped_reads.get() >= 2); + } + + #[test] + fn should_rollback_host_row_deletion_when_effect_application_fails() { + let original = vec![(transaction(1), 3), (transaction(2), 0)]; + let host = host_with_funds_account(); + *host.additional.borrow_mut() = original.clone(); + host.mode.set(6); + let error = persister(&host) + .load_with_replay(&ReplayEvidence::default()) + .unwrap_err(); + assert!(format!("{error:?}").contains("81"), "{error:?}"); + assert_eq!(host.effects.get(), 1); + assert_eq!(*host.additional.borrow(), original); } fn persister(host: &Host) -> FFIPersister { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs index df532f00025..07b443e8989 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/history_replay.rs @@ -9,7 +9,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, Transaction}; use std::collections::BTreeMap; -use crate::sqlite::schema::{asset_locks, blob, dashpay}; +use crate::sqlite::schema::blob; use crate::WalletStorageError; /// Decode durable claim provenance from the same snapshot as wallet history. @@ -100,9 +100,6 @@ pub(crate) fn apply_effects( key, )?; } - let removed = effects.removed_evidence.iter().copied().collect(); - asset_locks::remove_swept(tx, wallet_id, &removed)?; - dashpay::settle_swept_payments(tx, wallet_id, &removed)?; // Row deletion can clear claimant links; exact owner effects restore them last. for (outpoint, effect) in &effects.claims { let key = blob::encode_outpoint(outpoint)?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 45161b94cc4..c36789f0b1c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1810,7 +1810,12 @@ fn load_one_wallet( || !effects.removed_evidence.is_empty() || !effects.claims.is_empty() { + let operations = effects.operation_effects(&state); history_replay::apply_effects(&tx, &wallet_id, &effects).map_err(PersistenceError::from)?; + schema::asset_locks::apply(&tx, &wallet_id, &operations.asset_locks) + .map_err(PersistenceError::from)?; + schema::dashpay::apply(&tx, &wallet_id, None, Some(&operations.payments)) + .map_err(PersistenceError::from)?; state = load_wallet_snapshot(&tx, wallet_id, ctx)?; let remaining = replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs index 29719405e7e..545806c2be8 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs @@ -10,9 +10,6 @@ //! the surrounding fields on the serde encoder — mirroring `IdentityKeyWire` //! in `identity_keys.rs`. -use dashcore::Txid; -use std::collections::HashSet; - use rusqlite::{params, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; @@ -249,30 +246,6 @@ pub fn apply( Ok(()) } -/// Apply wallet-owned replay retirement to this storage domain. -pub(crate) fn remove_swept( - tx: &Transaction<'_>, - wallet_id: &WalletId, - txids: &HashSet, -) -> Result<(), WalletStorageError> { - let mut changes = AssetLockChangeSet::default(); - { - let mut stmt = tx.prepare_cached( - "SELECT length(outpoint), outpoint FROM asset_locks WHERE wallet_id = ?1 AND status != 'consumed'", - )?; - let mut rows = stmt.query(params![wallet_id.as_slice()])?; - while let Some(row) = rows.next()? { - blob::check_size(row.get(0)?)?; - let bytes: Vec = row.get(1)?; - let outpoint = blob::decode_outpoint(&bytes)?; - if txids.contains(&outpoint.txid) { - changes.removed.insert(outpoint); - } - } - } - apply(tx, wallet_id, &changes) -} - /// Test-only drift guard for the `asset_locks.status` TEXT-column /// domain **as the writer sees it** (production code never reads this /// — the writer maps through [`status_str`] and the on-disk CHECK diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs index 521da82efc4..dad012fd907 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/dashpay.rs @@ -33,9 +33,7 @@ //! here and propagates [`WalletStorageError::WalletIdMismatch`] on a //! mis-attributed caller. -use dashcore::Txid; -use platform_wallet::changeset::swept_payment_status; -use std::collections::{BTreeMap, HashSet}; +use std::collections::BTreeMap; use rusqlite::{params, Transaction}; @@ -47,7 +45,6 @@ use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; use crate::sqlite::schema::blob; use crate::sqlite::schema::blob::impl_persistable_blob; -use crate::sqlite::schema::wallet_id_to_param; // PUBLIC material only: DashPay overlay types reaching `_blob` columns. impl_persistable_blob!(DashPayProfile, PaymentEntry); @@ -163,36 +160,3 @@ fn patch_payments_into_entry_blobs( } Ok(()) } - -/// Apply wallet-owned replay retirement to this storage domain. -pub(crate) fn settle_swept_payments( - tx: &Transaction<'_>, - wallet_id: &WalletId, - swept: &HashSet, -) -> Result<(), WalletStorageError> { - let mut verdicts: BTreeMap> = BTreeMap::new(); - { - let mut stmt = tx.prepare_cached( - "SELECT length(entry_blob), entry_blob FROM identities WHERE wallet_id IS ?1", - )?; - let mut rows = stmt.query(params![wallet_id_to_param(wallet_id)])?; - while let Some(row) = rows.next()? { - blob::check_size(row.get(0)?)?; - let payload: Vec = row.get(1)?; - let entry: IdentityEntry = blob::decode(&payload)?; - for txid in swept.iter().map(Txid::to_string) { - let Some(payment) = entry.dashpay_payments.get(&txid) else { - continue; - }; - if let Some(status) = swept_payment_status(payment) { - let payment = PaymentEntry { - status, - ..payment.clone() - }; - verdicts.entry(entry.id).or_default().insert(txid, payment); - } - } - } - } - apply(tx, wallet_id, None, Some(&verdicts)) -} diff --git a/packages/rs-platform-wallet/src/manager/history_claims.rs b/packages/rs-platform-wallet/src/manager/history_claims.rs index de46f27daf8..30d6640dcc8 100644 --- a/packages/rs-platform-wallet/src/manager/history_claims.rs +++ b/packages/rs-platform-wallet/src/manager/history_claims.rs @@ -3,9 +3,15 @@ use std::collections::{BTreeMap, BTreeSet}; use dashcore::{OutPoint, Txid}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::prelude::Identifier; use super::history_replay::{HistoryReplay, HistoryReplayError}; -use crate::changeset::StoredTransaction; +use crate::changeset::{ + swept_payment_status, AssetLockChangeSet, ClientWalletStartState, StoredTransaction, +}; +use crate::wallet::asset_lock::tracked::AssetLockStatus; +use crate::wallet::identity::PaymentEntry; /// One durable hold, independent of a backend's physical row representation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -45,6 +51,225 @@ pub struct HistoryReplayEffects { pub claims: BTreeMap, } +/// Operation retirement selected by the wallet, applied in the replay transaction. +#[derive(Debug, Default, PartialEq)] +pub struct HistoryReplayOperationEffects { + /// Exact nonterminal asset-lock removals. + pub asset_locks: AssetLockChangeSet, + /// Exact payment overlays, preserving unrelated payment history. + pub payments: BTreeMap>, +} + +impl HistoryReplayEffects { + /// Select operation changes from the same fresh wallet snapshot as these effects. + pub fn operation_effects( + &self, + state: &ClientWalletStartState, + ) -> HistoryReplayOperationEffects { + self.select_operations( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .map(|lock| (lock.out_point, &lock.status)), + state + .identity_manager + .wallet_identities + .get(&state.wallet.compute_wallet_id()) + .into_iter() + .flat_map(BTreeMap::values) + .chain( + state + .identity_manager + .out_of_wallet_identities + .values() + .filter(|identity| { + identity.wallet_id == Some(state.wallet.compute_wallet_id()) + }), + ) + .map(|identity| (identity.identity.id(), &identity.dashpay().payments)), + ) + } + + fn select_operations<'a>( + &self, + locks: impl Iterator, + payments: impl Iterator)>, + ) -> HistoryReplayOperationEffects { + let mut effects = HistoryReplayOperationEffects::default(); + for (outpoint, status) in locks { + if self.removed_evidence.contains(&outpoint.txid) && !status.is_consumed() { + effects.asset_locks.removed.insert(outpoint); + } + } + let retired: BTreeSet<_> = self.removed_evidence.iter().map(Txid::to_string).collect(); + for (identity, rows) in payments { + for (txid, payment) in rows.iter().filter(|(txid, _)| retired.contains(*txid)) { + if let Some(status) = swept_payment_status(payment) { + effects.payments.entry(identity).or_default().insert( + txid.clone(), + PaymentEntry { + status, + ..payment.clone() + }, + ); + } + } + } + effects + } +} + +#[cfg(test)] +mod operation_tests { + use super::*; + use crate::wallet::identity::PaymentStatus; + use dashcore::hashes::Hash; + + #[test] + fn should_settle_wallet_linked_payment_without_a_registration_index() { + use crate::changeset::IdentityManagerStartState; + use crate::wallet::identity::ManagedIdentity; + use dpp::identity::{v0::IdentityV0, Identity}; + let wallet = key_wallet::Wallet::from_seed_bytes( + [6; 64], + dashcore::Network::Testnet, + key_wallet::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .unwrap(); + let wallet_id = wallet.compute_wallet_id(); + let txid = Txid::from_byte_array([7; 32]); + let mut identities = IdentityManagerStartState::default(); + for (marker, owner) in [(8, Some(wallet_id)), (9, None)] { + let id = Identifier::from([marker; 32]); + let mut identity = ManagedIdentity::new_out_of_wallet(Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + })); + identity.wallet_id = owner; + identity + .dashpay_payments_mut() + .insert(txid.to_string(), PaymentEntry::new_sent(id, 42, None)); + identities.out_of_wallet_identities.insert(id, identity); + } + let state = ClientWalletStartState { + wallet_info: key_wallet::wallet::ManagedWalletInfo::from_wallet(&wallet, 0), + wallet, + identity_manager: identities, + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + recorded_history: Default::default(), + dashpay_backfill: Default::default(), + }; + let effects = HistoryReplayEffects { + removed_evidence: [txid].into(), + ..Default::default() + }; + let operations = effects.operation_effects(&state); + assert_eq!(operations.payments.len(), 1); + assert_eq!( + operations.payments[&Identifier::from([8; 32])][&txid.to_string()].status, + PaymentStatus::Failed + ); + } + + #[test] + fn should_retire_only_rejected_nonterminal_asset_lock_operations() { + let rejected = Txid::from_byte_array([1; 32]); + let effects = HistoryReplayEffects { + removed_evidence: [rejected].into(), + ..Default::default() + }; + let locks = [ + ( + OutPoint { + txid: rejected, + vout: 0, + }, + AssetLockStatus::Broadcast, + ), + ( + OutPoint { + txid: rejected, + vout: 1, + }, + AssetLockStatus::Consumed, + ), + ( + OutPoint { + txid: rejected, + vout: 2, + }, + AssetLockStatus::DrainCompleted, + ), + ( + OutPoint { + txid: Txid::from_byte_array([2; 32]), + vout: 0, + }, + AssetLockStatus::Broadcast, + ), + ]; + let result = effects.select_operations( + locks.iter().map(|(key, status)| (*key, status)), + std::iter::empty(), + ); + assert_eq!(result.asset_locks.removed, [locks[0].0].into()); + } + + #[test] + fn should_project_existing_payment_retirement_without_changing_received_rows() { + let owner = Identifier::from([3; 32]); + let ids: Vec<_> = (1..=4) + .map(|byte| Txid::from_byte_array([byte; 32])) + .collect(); + let sent = PaymentEntry::new_sent(owner, 42, Some("memo".into())); + let mut confirmed = sent.clone(); + confirmed.status = PaymentStatus::Confirmed; + let rows = [ + (ids[0].to_string(), sent.clone()), + (ids[1].to_string(), confirmed), + ( + ids[2].to_string(), + PaymentEntry::new_received(owner, 42, None), + ), + (ids[3].to_string(), sent.clone()), + ] + .into(); + let effects = HistoryReplayEffects { + removed_evidence: ids[..3].iter().copied().collect(), + ..Default::default() + }; + let result = effects.select_operations(std::iter::empty(), [(owner, &rows)].into_iter()); + assert_eq!( + result.payments, + [( + owner, + [ + ( + ids[0].to_string(), + PaymentEntry { + status: PaymentStatus::Failed, + ..sent.clone() + } + ), + ( + ids[1].to_string(), + PaymentEntry { + status: PaymentStatus::Failed, + ..sent + } + ), + ] + .into() + )] + .into() + ); + } +} + impl HistoryReplay { /// Project already-verified replay verdicts from one wallet snapshot, without new finality evidence. /// diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index eb7138ab4af..584b4d9678b 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -33,7 +33,9 @@ use key_wallet::Utxo; use super::history_authorization::plan_history_authorization; pub use super::history_authorization::HistoryAuthorization; -pub use super::history_claims::{HistoryReplayEffects, ReplayClaimEffect, StoredReplayClaim}; +pub use super::history_claims::{ + HistoryReplayEffects, HistoryReplayOperationEffects, ReplayClaimEffect, StoredReplayClaim, +}; use crate::changeset::changeset::SweepBatch; use crate::changeset::recorded_history::ReplayEvidence; use crate::changeset::{ diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 1174459959d..94efab86f3c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -18,6 +18,11 @@ protocol ModelFetching: Sendable { ) throws -> [T] } +private enum HistoryReplayPersistenceError: Error { + case operationFailed(Int32) + case invalidEffects +} + private enum AssetLockPersistenceError: Error { case malformedRecord case duplicateOperationId @@ -172,6 +177,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// entry points — both the FFI callback shims and the /// app-facing accessors — funnel through `onQueue` so the /// context is only ever touched on this queue. + private let serialQueueKey = DispatchSpecificKey() + private var replayWalletId: Data? private let serialQueue = DispatchQueue( label: "org.dash.platform-wallet.persistence", qos: .userInitiated @@ -351,6 +358,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { self.backgroundContext.autosaveEnabled = false self.trackedMasternodeContext = ModelContext(modelContainer) self.trackedMasternodeContext.autosaveEnabled = false + self.serialQueue.setSpecific(key: serialQueueKey, value: 1) } /// Synchronously run `body` on `serialQueue`. @@ -362,11 +370,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// turns the queue into the handler's de-facto actor: only /// one thread runs SwiftData operations at a time. /// - /// Do not call `onQueue` from another method that already - /// runs on the queue; `DispatchQueue.sync` will deadlock on - /// recursive entry. The internal helpers in this file all - /// assume they are already on the queue and call - /// `backgroundContext` directly. + /// A synchronous Rust replay thunk re-enters through callbacks on this queue. + /// Queue-specific dispatch keeps those nested calls inside the same transaction. /// Every caller arrives from a Rust-owned tokio worker thread, and such a /// thread has no autorelease pool: nothing on it ever drains, because the /// drain is normally done by the run loop or by GCD's own worker wrapper, @@ -384,7 +389,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// The pool goes inside the `sync` so it wraps exactly one unit of work /// and is drained before the Rust caller is resumed. private func onQueue(_ body: () throws -> T) rethrows -> T { - try serialQueue.sync { + if DispatchQueue.getSpecific(key: serialQueueKey) != nil { + return try body() + } + return try serialQueue.sync { try autoreleasepool { try body() } } } @@ -3311,6 +3319,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_persist_core_instant_lock_fn = persistCoreInstantLockCallback extensionCallbacks.on_load_core_instant_lock_fn = loadCoreInstantLockCallback extensionCallbacks.on_load_core_instant_lock_free_fn = freeCoreInstantLockCallback + extensionCallbacks.on_replay_wallet_fn = replayWalletCallback + extensionCallbacks.on_load_replay_claims_fn = loadReplayClaimsCallback + extensionCallbacks.on_load_replay_claims_free_fn = freeReplayClaimsCallback + extensionCallbacks.on_apply_replay_effects_fn = applyReplayEffectsCallback return extensionCallbacks } @@ -5080,6 +5092,237 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (written, skipped, failed) } + /// The Rust thunk reads, decides, applies and rereads synchronously in this scope. + // TODO(macOS-validation): run AtomicHistoryReplayTests against the generated FFI header and SwiftData store. + func replayWallet(walletId: Data, operation: () -> Int32) -> Int32 { + onQueue { + guard !inChangeset, replayWalletId == nil, !backgroundContext.hasChanges else { + return 1 + } + replayWalletId = walletId + inChangeset = true + roundIndex = nil + defer { + roundIndex = nil + inChangeset = false + replayWalletId = nil + } + do { + try backgroundContext.transaction { + try healIdentityIsLocalFlags(walletId: walletId) + let result = operation() + guard result == 0 else { + throw HistoryReplayPersistenceError.operationFailed(result) + } + } + return 0 + } catch { + backgroundContext.rollback() + return 1 + } + } + } + + /// Raw material only; Rust validates ownership and chooses claim provenance. + func loadReplayClaims(walletId: Data) throws -> [ReplayClaimRestoreFFI] { + try onQueue { + guard replayWalletId == walletId else { throw HistoryReplayPersistenceError.invalidEffects } + let empty = Data() + let coins = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId || $0.walletId == empty } + )).filter { Self.resolvedWalletId(of: $0) == walletId } + let pending = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + // Validate before allocating any nested buffers, so failure cannot leak them. + guard coins.allSatisfy({ $0.outpoint.count == 36 + && $0.outpoint == PersistentTxo.makeOutpoint(txid: Data($0.outpoint.prefix(32)), vout: $0.vout) + && ($0.supersededByTxid ?? $0.spendingTransaction?.txid).map { $0.count == 32 } != false }), + pending.allSatisfy({ $0.outpoint.count == 36 && $0.spendingTxid.count == 32 }) + else { throw HistoryReplayPersistenceError.invalidEffects } + let pendingByOutpoint = Dictionary(grouping: pending, by: \.outpoint) + var result: [ReplayClaimRestoreFFI] = [] + for coin in coins { + var row = ReplayClaimRestoreFFI() + copyBytes(Data(coin.outpoint.prefix(32)), into: &row.txid) + row.vout = coin.vout + row.has_output = true + row.value = coin.amount + if !coin.scriptPubKey.isEmpty { + let script = UnsafeMutablePointer.allocate(capacity: coin.scriptPubKey.count) + coin.scriptPubKey.copyBytes(to: script, count: coin.scriptPubKey.count) + row.script = UnsafePointer(script) + row.script_len = UInt(coin.scriptPubKey.count) + } + let claimant = coin.supersededByTxid ?? coin.spendingTransaction?.txid + // A matching sidecar carries the lifetime absent from the TXO schema. + let hasSidecar = pendingByOutpoint[coin.outpoint]?.contains { + $0.spendingTxid == claimant + } ?? false + row.is_held = coin.isSpent && !hasSidecar + if let claimant { + row.has_claimant = true + copyBytes(claimant, into: &row.claimant) + } + result.append(row) + } + for claim in pending { + var row = ReplayClaimRestoreFFI() + copyBytes(Data(claim.outpoint.prefix(32)), into: &row.txid) + row.vout = claim.outpoint.suffix(4).enumerated().reduce(UInt32(0)) { + $0 | (UInt32($1.element) << (8 * $1.offset)) + } + row.is_held = true + row.has_claimant = true + copyBytes(claim.spendingTxid, into: &row.claimant) + row.has_mined_height = claim.winnerMinedHeight != nil + row.mined_height = claim.winnerMinedHeight ?? 0 + result.append(row) + } + return result + } + } + + /// Apply the owner's exact verdicts. No save: the enclosing replay owns commit/rollback. + func applyReplayEffects(walletId: Data, effects: HistoryReplayEffectsFFI) throws { + try onQueue { + guard replayWalletId == walletId, inChangeset else { + throw HistoryReplayPersistenceError.invalidEffects + } + let records = try replayRows(effects.removed_records, count: effects.removed_records_count) + .map { dataFromTuple32($0) } + let evidence = try replayRows(effects.removed_evidence, count: effects.removed_evidence_count) + .map { dataFromTuple32($0) } + let claims = try replayRows(effects.claims, count: effects.claims_count) + let locks = try replayRows(effects.removed_asset_locks, count: effects.removed_asset_locks_count) + let payments = try replayRows(effects.payments, count: effects.payments_count) + guard claims.allSatisfy({ $0.action <= 2 }) else { + throw HistoryReplayPersistenceError.invalidEffects + } + for txid in records { + let matches = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.txid == txid } + )) + for transaction in matches { + // History rows are shared: detach only this wallet's membership. + for output in transaction.outputs.filter({ Self.resolvedWalletId(of: $0) == walletId }) { + output.transaction = nil + backgroundContext.delete(output) + } + for input in transaction.inputs.filter({ Self.resolvedWalletId(of: $0) == walletId }) { + input.spendingTransaction = nil + } + for input in transaction.pendingInputs.filter({ $0.walletId == walletId }) { + input.spendingTransaction = nil + backgroundContext.delete(input) + } + for accounting in transaction.walletAccounting.filter({ $0.walletId == walletId }) { + accounting.transaction = nil + backgroundContext.delete(accounting) + } + transaction.involvedAccounts.removeAll { $0.wallet.walletId == walletId } + if transaction.outputs.isEmpty && transaction.inputs.isEmpty + && transaction.pendingInputs.isEmpty && transaction.walletAccounting.isEmpty + && transaction.involvedAccounts.isEmpty { + backgroundContext.delete(transaction) + } + } + } + for txid in evidence { + for row in try backgroundContext.fetch(coreInstantLockDescriptor(walletId: walletId, txid: txid)) { + backgroundContext.delete(row) + } + } + if !evidence.isEmpty { + let removed = Set(evidence) + let empty = Data() + let coins = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId || $0.walletId == empty } + )) + for coin in coins where Self.resolvedWalletId(of: coin) == walletId + && removed.contains(Data(coin.outpoint.prefix(32))) { + backgroundContext.delete(coin) + } + let pending = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId } + )) + for row in pending where removed.contains(Data(row.outpoint.prefix(32))) { + backgroundContext.delete(row) + } + } + for effect in claims { + let outpoint = PersistentTxo.makeOutpoint(txid: dataFromTuple32(effect.txid), vout: effect.vout) + let coins = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.outpoint == outpoint } + )).filter { Self.resolvedWalletId(of: $0) == walletId } + let pending = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.outpoint == outpoint } + )) + for row in pending { backgroundContext.delete(row) } + switch effect.action { + case 0: + for coin in coins { backgroundContext.delete(coin) } + case 1: + for coin in coins { + coin.isSpent = false + coin.supersededByTxid = nil + coin.spendingTransaction = nil + } + case 2: + let claimant = effect.has_claimant ? dataFromTuple32(effect.claimant) : nil + for coin in coins { + coin.isSpent = true + coin.supersededByTxid = claimant + coin.spendingTransaction = nil + } + // Keep the exact lifetime separately: TXOs have no winner-height column. + if let claimant, coins.isEmpty || effect.has_mined_height { + let row = PersistentPendingInput(outpoint: outpoint, inputIndex: 0, + spendingTxid: claimant, spendingTransaction: nil, walletId: walletId) + row.isSweptTombstone = true + row.winnerMinedHeight = effect.has_mined_height ? effect.mined_height : nil + backgroundContext.insert(row) + } else if coins.isEmpty || (claimant == nil && effect.has_mined_height) { + // This schema cannot represent anonymous placeholder/stamp metadata. + throw HistoryReplayPersistenceError.invalidEffects + } + default: + throw HistoryReplayPersistenceError.invalidEffects + } + } + for outpoint in locks { + let bytes = PersistentTxo.makeOutpoint(txid: dataFromTuple32(outpoint.txid), vout: outpoint.vout) + let encoded = PersistentAssetLock.encodeOutPoint(rawBytes: bytes) + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId && $0.outPointHex == encoded } + )) { backgroundContext.delete(row) } + } + for entry in payments { + let ownerId = dataFromTuple32(entry.owner_identity_id) + guard let txidPointer = entry.txid, + let direction = DashPayPaymentDirection(rawValue: entry.direction_raw), + let status = DashPayPaymentStatus(rawValue: entry.status_raw), + let owner = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.identityId == ownerId } + )).first, owner.wallet?.walletId == walletId + else { throw HistoryReplayPersistenceError.invalidEffects } + let txid = String(cString: txidPointer) + let networkRaw = owner.networkRaw + guard let row = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.ownerIdentityId == ownerId && $0.txid == txid + } + )).first else { throw HistoryReplayPersistenceError.invalidEffects } + row.counterpartyIdentityId = dataFromTuple32(entry.counterparty_id) + row.amountDuffs = entry.amount_duffs + row.directionRaw = direction.rawValue + row.statusRaw = status.rawValue + row.memo = entry.memo.map { String(cString: $0) } + row.lastUpdated = Date() + } + } + } + // MARK: - Raw InstantLock storage private func coreInstantLockDescriptor(walletId: Data, txid: Data) throws @@ -7078,77 +7321,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// wallet_id, accounts)`; accounts come directly from the spec /// array, wallet id from the top-level struct. /// - /// One-shot upgrade heal: promote `isLocal` on wallet-linked rows - /// still carrying `false` — the persister used to write a - /// constant `false`, so a wallet's own identities (which are - /// always local) were mis-marked on stores from that era. - /// Promote-only and idempotent; a `true` on an unlinked row - /// (manual add) is never touched. Runs here because load is the - /// one guaranteed per-launch pass over the store, outside any - /// changeset round. - private func healIdentityIsLocalFlags() { - guard !inChangeset else { return } - guard let rows = try? backgroundContext.fetch( - FetchDescriptor() - ) else { return } - var healed = 0 - for row in rows where row.wallet != nil && !row.isLocal { + /// Stage wallet-linked identity metadata only inside the atomic replay transaction. + private func healIdentityIsLocalFlags(walletId: Data) throws { + let rows = try backgroundContext.fetch(FetchDescriptor()) + for row in rows where row.wallet?.walletId == walletId && !row.isLocal { + guard network == nil || row.networkRaw == network?.rawValue else { continue } row.isLocal = true - healed += 1 - } - guard healed > 0 else { return } - do { - try backgroundContext.save() - SDKLogger.event( - "persistence_identity_flags_healed", - category: .persistence, - fields: ["row_count": .integer(Int64(healed))] - ) - } catch { - // Non-fatal: the next launch retries. Roll back so the - // failed heal can't bleed into the restore fetches below. - backgroundContext.rollback() - SDKLogger.event( - "persistence_identity_flags_heal_failed", - category: .persistence, - severity: .error, - fields: ["row_count": .integer(Int64(healed))], - error: error - ) } } - /// Wire-order funding txids of every persisted asset lock whose - /// status is `InstantSendLocked` or beyond — the locks whose - /// funding TXOs are provably gone. - /// - /// Not scoped to a wallet: InstantSend / chain finality is a - /// property of the spending transaction, and that transaction can - /// consume inputs tracked by more than one wallet. Scoping would - /// leave a sibling wallet's input of the same lock stale. - /// - /// Throws rather than returning an empty set on a fetch failure. An - /// empty set is a positive claim — "no lock has finalized" — and the - /// caller acts on it by restoring every `isSpent == false` row, - /// exactly the phantom UTXOs this guard exists to withhold. A read - /// fault must reject the snapshot instead. - private func finalizedAssetLockFundingTxids() throws -> Set { - let finalized = Self.assetLockStatusInstantSendLocked - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.statusRaw >= finalized } - ) - let rows = try modelFetcher.fetch(descriptor, in: backgroundContext) - var txids = Set() - txids.reserveCapacity(rows.count) - for row in rows { - guard let txid = Self.assetLockFundingTxid(outPointHex: row.outPointHex) else { - continue - } - txids.insert(txid) - } - return txids - } - /// Returns `(nil, 0)` if nothing is restorable. func loadWalletList() -> (entries: UnsafePointer?, count: Int, errored: Bool) { SDKLogger.event( @@ -7159,7 +7340,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ] ) return onQueue { - healIdentityIsLocalFlags() // Scope the fetch to the handler's bound network so a // per-network manager only sees its own wallets. If // `network` is `nil` (legacy callers that haven't threaded @@ -7195,7 +7375,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { return (nil, 0, true) } let restorable = wallets.filter { wallet in - wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } + (replayWalletId == nil || wallet.walletId == replayWalletId) + && wallet.accounts.contains { ($0.accountExtendedPubKeyBytes?.isEmpty == false) } } if restorable.isEmpty { SDKLogger.event( @@ -7226,8 +7407,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) unspentDescriptor.relationshipKeyPathsForPrefetching = [ \.account, - // Read once per row by the asset-lock spend guard below. - \.spendingTransaction, // Read once per row for the txid and the creating // transaction's context in `buildUtxoRestoreBuffer`. \.transaction, @@ -7251,84 +7430,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { ) return (nil, 0, true) } - // Finalized-asset-lock guard, and the one-shot heal for - // rows a missed callback left behind (mirror of Kotlin - // `buildUtxoRestoreData`). An asset-lock tx burns its value - // into the special-tx payload and often has no wallet-owned - // standard output, so SPV block matching can miss it: the - // spender's transaction row never leaves mempool context and - // the in-block flip in `resolveInputOutpoint` never runs, - // leaving its funding TXOs at `isSpent == false` — which this - // fetch hands straight back to Rust as spendable, on every - // launch. The lock's own status is the finality signal that - // did arrive; from `InstantSendLocked` on, the output is gone. - // - // The callback-time reconcile in `persistAssetLocks` cannot - // cover these: `Consumed` is terminal and never re-upserts, so - // a wallet whose lock finalized before that reconcile existed - // has no future callback to repair it. Load is the one - // guaranteed per-launch pass, so it repairs them here. - // - // Exclusion does not depend on the repair being durable: rows - // are dropped from the restore set first, and the heal is - // saved opportunistically afterwards. - let finalizedLockTxids: Set - do { - finalizedLockTxids = try finalizedAssetLockFundingTxids() - } catch { - // Same contract as the unspent fetch above: bail with - // `errored = true` rather than degrade. Treating an - // unreadable lock table as "no lock has finalized" - // rehydrates the phantom inputs the guard exists to - // withhold — the exact inverse of its safety claim. - NSLog( - "[persistor-load:swift] PersistentAssetLock finalized fetch failed: %@", - String(describing: error) - ) - return (nil, 0, true) - } - var liveUnspent = unspent - if !finalizedLockTxids.isEmpty { - var kept: [PersistentTxo] = [] - kept.reserveCapacity(unspent.count) - var healed = 0 - for row in unspent { - if let spendingTxid = row.spendingTransaction?.txid, - finalizedLockTxids.contains(spendingTxid) { - if !row.isSpent { - row.isSpent = true - row.lastUpdated = Date() - healed += 1 - } - continue - } - kept.append(row) - } - liveUnspent = kept - // Skip the write mid-round: saving inside a changeset - // bracket would commit the round's staged writes early. - // The exclusion above already holds for this launch. - if healed > 0 && !self.inChangeset { - do { - try backgroundContext.save() - NSLog( - "[persistor-load:swift] healed %d asset-lock-consumed UTXO row(s)", - healed - ) - } catch { - // Non-fatal: the next launch retries. Roll back so - // the failed heal can't bleed into the restore - // marshalling below. - backgroundContext.rollback() - NSLog( - "[persistor-load:swift] asset-lock UTXO heal save failed: %@", - String(describing: error) - ) - } - } - } + // This is raw storage input; Rust verifies proofs and decides which coins remain held. unspentBuckets.reserveCapacity(restorable.count) - for row in liveUnspent { + for row in unspent { guard row.account != nil else { continue } let key: Data if !row.walletId.isEmpty { @@ -12419,3 +12523,69 @@ private func freeCoreInstantLockCallback( ) { if let bytes { UnsafeMutablePointer(mutating: bytes).deallocate() } } + +private func replayRows(_ pointer: UnsafePointer?, count: UInt) throws -> UnsafeBufferPointer { + guard let length = Int(exactly: count), length == 0 || pointer != nil else { + throw HistoryReplayPersistenceError.invalidEffects + } + return UnsafeBufferPointer(start: pointer, count: length) +} + +private func replayWalletCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + operation: (@convention(c) (UnsafeMutableRawPointer?) -> Int32)?, + operationContext: UnsafeMutableRawPointer? +) -> Int32 { + guard let context, let walletId, let operation else { return 1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + return handler.replayWallet(walletId: Data(bytes: walletId, count: 32)) { + operation(operationContext) + } +} + +private func loadReplayClaimsCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + outRows: UnsafeMutablePointer?>?, + outCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let outRows, let outCount else { return 1 } + outRows.pointee = nil + outCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + let rows = try handler.loadReplayClaims(walletId: Data(bytes: walletId, count: 32)) + if !rows.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: rows.count) + rows.withUnsafeBufferPointer { source in + if let base = source.baseAddress { buffer.initialize(from: base, count: source.count) } + } + outRows.pointee = UnsafePointer(buffer) + outCount.pointee = UInt(rows.count) + } + return 0 + } catch { return 1 } +} + +private func freeReplayClaimsCallback( + context: UnsafeMutableRawPointer?, rows: UnsafePointer?, count: UInt +) { + guard let rows else { return } + for row in UnsafeBufferPointer(start: rows, count: Int(count)) { + if let script = row.script { UnsafeMutablePointer(mutating: script).deallocate() } + } + let buffer = UnsafeMutablePointer(mutating: rows) + buffer.deinitialize(count: Int(count)) + buffer.deallocate() +} + +private func applyReplayEffectsCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + effects: UnsafePointer? +) -> Int32 { + guard let context, let walletId, let effects else { return 1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.applyReplayEffects(walletId: Data(bytes: walletId, count: 32), effects: effects.pointee) + return 0 + } catch { return 1 } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift new file mode 100644 index 00000000000..155a250a635 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AtomicHistoryReplayTests.swift @@ -0,0 +1,105 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +@MainActor +final class AtomicHistoryReplayTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let txid = Data(repeating: 7, count: 32) + + // TODO(macOS-validation): run the reentrant transaction and rollback tests on an Apple SDK runner. + func testShouldRollbackWritesMadeByAReentrantReplayOperation() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + handler.beginChangeset(walletId: walletId) + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([1])) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + var calls = 0 + let result = handler.replayWallet(walletId: walletId) { + calls += 1 + do { + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([2])) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([2])) + return 71 + } catch { + XCTFail("Unexpected staging error: \(error)") + return 72 + } + } + XCTAssertNotEqual(result, 0) + XCTAssertEqual(calls, 1) + XCTAssertEqual(try handler.loadCoreInstantLock(walletId: walletId, txid: txid), Data([1])) + } + + func testShouldCommitReplayAndRejectANestedTransaction() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + var nestedCalls = 0 + let result = handler.replayWallet(walletId: walletId) { + XCTAssertNotEqual(handler.replayWallet(walletId: walletId) { + nestedCalls += 1 + return 0 + }, 0) + do { + try handler.persistCoreInstantLock(walletId: walletId, txid: txid, bytes: Data([3])) + return 0 + } catch { + XCTFail("Unexpected staging error: \(error)") + return 1 + } + } + XCTAssertEqual(result, 0) + XCTAssertEqual(nestedCalls, 0) + let reopened = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertEqual(try reopened.loadCoreInstantLock(walletId: walletId, txid: txid), Data([3])) + } + + func testShouldApplyExactClaimStampAndRollbackAllEffectsOnFailure() throws { + let container = try DashModelContainer.createInMemory() + let seed = ModelContext(container) + let transaction = PersistentTransaction(txid: txid, transactionData: Data([1])) + seed.insert(transaction) + let coin = PersistentTxo(transaction: transaction, vout: 0, amount: 42, address: "unused", height: 1) + coin.walletId = walletId + seed.insert(coin) + try seed.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + var claim = ReplayClaimEffectFFI() + withUnsafeMutableBytes(of: &claim.txid) { $0.copyBytes(from: txid) } + claim.action = 2 + claim.has_claimant = true + withUnsafeMutableBytes(of: &claim.claimant) { $0.copyBytes(from: Data(repeating: 9, count: 32)) } + claim.has_mined_height = true + claim.mined_height = 123 + let result = handler.replayWallet(walletId: walletId) { + do { + try withUnsafePointer(to: &claim) { pointer in + var effects = HistoryReplayEffectsFFI() + effects.claims = pointer + effects.claims_count = 1 + try handler.applyReplayEffects(walletId: walletId, effects: effects) + } + let rows = try handler.loadReplayClaims(walletId: walletId) + defer { + for row in rows { + if let script = row.script { UnsafeMutablePointer(mutating: script).deallocate() } + } + } + let held = rows.filter { $0.is_held } + XCTAssertEqual(held.count, 1, "the output must not add an unstamped duplicate claim") + XCTAssertEqual(held.first?.mined_height, 123) + XCTAssertEqual(held.first?.has_mined_height, true) + return 71 + } catch { + XCTFail("Unexpected replay error: \(error)") + return 72 + } + } + XCTAssertNotEqual(result, 0) + let reopened = ModelContext(container) + XCTAssertFalse(try XCTUnwrap(reopened.fetch(FetchDescriptor()).first).isSpent) + XCTAssertTrue(try reopened.fetch(FetchDescriptor()).isEmpty) + } + +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift index dc891ae1469..0393f3ea3ee 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityIsLocalPersistenceTests.swift @@ -255,10 +255,8 @@ final class IdentityIsLocalPersistenceTests: XCTestCase { // MARK: Startup heal - /// `loadWalletList()` promotes wallet-linked rows still carrying - /// the constant-`false` of the pre-fix persister; unlinked rows - /// (manual adds) are untouched in both directions. - func testLoadWalletListHealsWalletLinkedRows() throws { + /// Discovery is read-only; the atomic load owns the wallet-scoped metadata repair. + func testAtomicReplayHealsWalletLinkedRowsAfterReadOnlyDiscovery() throws { try insertWalletRow() let context = ModelContext(container) let wallet = try XCTUnwrap( @@ -280,13 +278,15 @@ final class IdentityIsLocalPersistenceTests: XCTestCase { try context.save() // The fixture wallet has no restorable accounts, so the - // returned list is empty — the heal runs regardless. + // returned list is empty; discovery itself must remain read-only. let result = handler.loadWalletList() XCTAssertNil(result.entries) XCTAssertFalse(result.errored) + XCTAssertFalse(try XCTUnwrap(try fetchIdentity(ownIdentityId)).isLocal) + XCTAssertEqual(handler.replayWallet(walletId: walletId) { 0 }, 0) let healed = try XCTUnwrap(try fetchIdentity(ownIdentityId)) - XCTAssertTrue(healed.isLocal, "wallet-linked rows heal to local") + XCTAssertTrue(healed.isLocal, "wallet-linked rows heal inside the committed replay") let manual = try XCTUnwrap(try fetchIdentity(observedIdentityId)) XCTAssertTrue(manual.isLocal, "manual adds are untouched by the heal") From 79f4bd0b3b95711ee67f2a140d8db300ac86a5f6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:21:33 +0000 Subject: [PATCH 68/84] fix(platform-wallet): gate every active history source through replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepare outgoing sends and nonterminal tracked fundings before the shared authorization and finality checks. Retain the prepared identities until backends project their exact effects. Consumed proofs remain archival. Legacy raw persisters now fail load when a durable repair is required; only backend-owned replay outcomes can publish repaired history. Four new regressions went red first. All 40 selected owner, manager and adapter tests are covered by resolving runs, including tracked-only proof persistence and reopen. Updated legacy fixtures use actual checked proofs; the consumed-archive case preserves its rival. Scoped three-crate Clippy passes. No network transport is used by these tests. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/persistence/replay.rs | 2 +- .../src/sqlite/persister.rs | 2 +- .../sqlite_shared_history_replay.rs | 165 +++++----- .../src/changeset/traits.rs | 7 +- .../src/manager/history_replay.rs | 65 ++-- .../rs-platform-wallet/src/manager/load.rs | 301 +++++++++++++++++- 6 files changed, 427 insertions(+), 115 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs index a66a70adec2..08668b80471 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -243,9 +243,9 @@ impl FFIPersister { }); }; let mut claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; - let records = wallet.recorded_history.transactions.clone(); let outcome = replay_wallet_history_blocking(wallet, evidence).map_err(PersistenceError::backend)?; + let records = wallet.recorded_history.transactions.clone(); let effects = outcome .storage_effects(&records, &claims) .map_err(PersistenceError::backend)?; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index c36789f0b1c..03f16847821 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1798,9 +1798,9 @@ fn load_one_wallet( .map_err(WalletStorageError::from) .map_err(PersistenceError::from)?; let mut state = load_wallet_snapshot(&tx, wallet_id, ctx)?; - let records = state.recorded_history.transactions.clone(); let mut outcome = replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; + let records = state.recorded_history.transactions.clone(); let mut claims = history_replay::load_spent_claims(&tx, &wallet_id).map_err(PersistenceError::from)?; let effects = outcome diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs index 2d7433b170a..d955edb2375 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -168,80 +168,95 @@ fn should_not_attest_operation_status_after_replay_cleanup() { #[tokio::test] async fn should_verify_tracked_funding_before_atomic_history_settlement() { - let (mut wallet, mut info, address) = wallet_with_receive_address(); - let funding = spend_of(&[outside(125)], vec![pay(100_000, &address)]); - let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; - let coin = OutPoint::new(funding.txid(), 0); - let locked = authorize( - &wallet, - &info, - &address, - spend_of(&[coin], vec![pay(99_000, &address)]), - ); - let competitor = spend_of(&[coin], vec![pay(98_000, &address)]); - let mut alternative = info.clone(); - records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); - records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); - let (instant_lock, evidence) = checked_instant_lock(&locked).await; - let (persister, _directory, path) = fresh_persister(); - let mut changes = registration( - &wallet, - Some(CoreChangeSet { - records, - new_utxos: vec![stored_utxo(&funding, 0, address)], - ..Default::default() - }), - ); - let out_point = OutPoint::new(locked.txid(), 0); - changes.asset_locks = Some(AssetLockChangeSet { - asset_locks: BTreeMap::from([( - out_point, - AssetLockEntry { + for recorded in [true, false] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of(&[outside(125)], vec![pay(100_000, &address)]); + let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let coin = OutPoint::new(funding.txid(), 0); + let locked = authorize( + &wallet, + &info, + &address, + spend_of(&[coin], vec![pay(99_000, &address)]), + ); + let competitor = spend_of(&[coin], vec![pay(98_000, &address)]); + let mut alternative = info.clone(); + let locked_records = + records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await; + if recorded { + records.extend(locked_records); + } + records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); + let (instant_lock, evidence) = checked_instant_lock(&locked).await; + let (persister, _directory, path) = fresh_persister(); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![stored_utxo(&funding, 0, address)], + ..Default::default() + }), + ); + let out_point = OutPoint::new(locked.txid(), 0); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: BTreeMap::from([( out_point, - transaction: locked.clone(), - account_index: 0, - funding_type: AssetLockFundingType::IdentityTopUp, - identity_index: 0, - amount_duffs: 99_000, - status: AssetLockStatus::InstantSendLocked, - proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( - instant_lock, - locked.clone(), - 0, - ))), - operation_id: None, - }, - )]), - ..Default::default() - }); - persister.store(wallet.wallet_id, changes).unwrap(); - assert!( - persister - .load_with_replay(&ReplayEvidence::default()) - .is_err(), - "a tracked funding proof must be required before any SQL settlement" - ); - assert!(persister - .get_core_tx_record(wallet.wallet_id, &locked.txid()) - .unwrap() - .is_some()); - let loaded = persister.load_with_replay(&evidence).unwrap(); - assert!(!loaded.outcomes[&wallet.wallet_id] - .defeated - .contains(&locked.txid())); - assert!(persister - .get_core_tx_record(wallet.wallet_id, &locked.txid()) - .unwrap() - .is_some()); - drop(persister); - let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); - let loaded = reopened.load_with_replay(&evidence).unwrap(); - assert!(loaded.state.wallets[&wallet.wallet_id] - .unused_asset_locks - .values() - .any(|locks| locks.contains_key(&out_point))); - assert!(reopened - .get_core_tx_record(wallet.wallet_id, &locked.txid()) - .unwrap() - .is_some()); + AssetLockEntry { + out_point, + transaction: locked.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status: AssetLockStatus::InstantSendLocked, + proof: Some(AssetLockProof::Instant(InstantAssetLockProof::new( + instant_lock, + locked.clone(), + 0, + ))), + operation_id: None, + }, + )]), + ..Default::default() + }); + persister.store(wallet.wallet_id, changes).unwrap(); + assert!( + persister + .load_with_replay(&ReplayEvidence::default()) + .is_err(), + "a tracked funding proof must be required before any SQL settlement" + ); + assert!( + persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some() + == recorded + ); + let loaded = persister.load_with_replay(&evidence).unwrap(); + assert!(!loaded.outcomes[&wallet.wallet_id] + .defeated + .contains(&locked.txid())); + assert!( + persister + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some() + == recorded + ); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened.load_with_replay(&evidence).unwrap(); + assert!(loaded.state.wallets[&wallet.wallet_id] + .unused_asset_locks + .values() + .any(|locks| locks.contains_key(&out_point))); + assert!( + reopened + .get_core_tx_record(wallet.wallet_id, &locked.txid()) + .unwrap() + .is_some() + == recorded + ); + } } diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index d22926b8175..bbc28ccad46 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -470,10 +470,11 @@ pub trait PlatformWalletPersistence: Send + Sync { }) } - /// Restore only one wallet during a deferred-load retry. + /// Restore one wallet under the manager's publication fence, including explicit retries. /// - /// The default reads a raw snapshot without applying replay effects. Transactional - /// backends override this to ensure no other wallet can be mutated by the retry. + /// The default reads raw history without applying effects. The manager rejects + /// histories requiring durable repair unless this method returns a committed outcome. + /// Transactional backends override this method as well as `load_with_replay`. fn load_wallet_with_replay( &self, wallet_id: WalletId, diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 584b4d9678b..8baed60c8aa 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -7,8 +7,8 @@ //! redelivered funding transaction re-credits an output a confirmed spend //! already consumed. //! -//! A persister that must repair its own rows while it replays does so inside -//! its `load` and hands back an empty history; the SQLite one does. +//! Backends apply typed repair effects in their atomic load scope and return +//! the replay outcome with the snapshot; raw legacy loads cannot claim a repair. use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::future::Future; @@ -45,6 +45,7 @@ use crate::wallet::asset_lock::sync::recovery::{ add_tracked_instant_locks, detach_unconfirmed_records, final_lock_input_claims, reattach_records, recorded_input_conflicts, withhold_chain_final_fundings, }; +use crate::wallet::asset_lock::tracked::AssetLockStatus; use crate::wallet::platform_wallet::ObservedInputConflict; use crate::wallet::platform_wallet::WalletId; use key_wallet::transaction_checking::spend_authorization::SpendAuthorizationError; @@ -107,6 +108,9 @@ pub enum HistoryReplayError { /// Stored input authorization cannot be checked with supported rules. #[error("cannot verify stored input authorization")] SpendAuthorization(#[from] SpendAuthorizationError), + /// A legacy backend cannot persist the repair required by the common replay. + #[error("wallet history requires an atomic persistence replay before it can be loaded")] + AtomicPersistenceRequired, /// The wallet checker requested asynchronous work during local restoration. #[error("wallet history replay requires asynchronous work")] Suspended, @@ -115,12 +119,46 @@ pub enum HistoryReplayError { UnsettledAfterReplay, } -/// Prepare every persisted source before the common replay settles any history. +/// Prepare every active persisted source before the common replay settles history. +/// On success, `recorded_history` retains the prepared identities for storage effects. pub async fn replay_wallet_history( state: &mut ClientWalletStartState, evidence: &ReplayEvidence, ) -> Result { let mut history = state.recorded_history.clone(); + let mut bodies: HashSet<_> = history + .transactions + .iter() + .map(|row| row.transaction.txid()) + .collect(); + // Every active persisted body enters the same authorization/finality pipeline. + // Terminal tracked proofs stay archival and do not create replay candidates. + for transaction in state.unconfirmed_outgoing_txs.iter().chain( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter(|lock| !lock.status.is_consumed()) + .map(|lock| &lock.transaction), + ) { + if bodies.insert(transaction.txid()) { + history.transactions.push(StoredTransaction { + txid: transaction.txid(), + transaction: transaction.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }); + } + } + for lock in state.unused_asset_locks.values().flat_map(BTreeMap::values) { + if lock.status == AssetLockStatus::InstantSendLocked { + history + .required_instant_locks + .insert(lock.transaction.txid()); + } + } let history_txids: HashSet<_> = history .transactions .iter() @@ -135,6 +173,7 @@ pub async fn replay_wallet_history( &history_txids, state.unused_asset_locks.values().flat_map(BTreeMap::values), ); + let prepared_history = history.clone(); let withheld = withhold_chain_final_fundings( &mut history, state.unused_asset_locks.values().flat_map(BTreeMap::values), @@ -148,25 +187,10 @@ pub async fn replay_wallet_history( ) .await?; reattach_records(&mut state.wallet_info, withheld_records); + state.recorded_history = prepared_history; outcome.recorded_conflicts = recorded_conflicts; state.unconfirmed_outgoing_txs = outcome.resendable(std::mem::take(&mut state.unconfirmed_outgoing_txs)); - for transaction in state - .unconfirmed_outgoing_txs - .iter() - .filter(|tx| !history_txids.contains(&tx.txid())) - { - state - .wallet_info - .check_core_transaction( - transaction, - TransactionContext::Mempool, - &mut state.wallet, - true, - true, - ) - .await; - } state .wallet_info .restore_spent_outpoints(&final_lock_input_claims( @@ -180,7 +204,6 @@ pub async fn replay_wallet_history( && !outcome.authorization.rejected.contains(&txid) }), )); - state.recorded_history = RecordedHistory::default(); Ok(outcome) } @@ -530,7 +553,7 @@ pub async fn replay_recorded_history_with_evidence( // 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 + // on every load with no expiry, so an authorized 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`. diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index fcccd517ee9..f2cf8fdb0a5 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -22,6 +22,7 @@ use key_wallet::account::derivation::AccountDerivation; use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use super::history_replay::HistoryReplayError; use super::{run_blocking_load, PlatformWalletManager}; /// How long the load-time re-dispatch waits for the SPV transport before @@ -205,7 +206,21 @@ impl PlatformWalletManager

{ match super::history_replay::replay_wallet_history(&mut wallet_state, &evidence) .await { - Ok(outcome) => outcome, + Ok(outcome) + if outcome.sweeps.is_empty() + && outcome.defeated.is_empty() + && outcome.authorization.rejected.is_empty() => + { + outcome + } + Ok(_) => { + load_error = Some(PlatformWalletError::from_load_failure( + crate::changeset::PersistenceError::backend( + HistoryReplayError::AtomicPersistenceRequired, + ), + )); + break 'load; + } Err(error) => { load_error = Some(PlatformWalletError::from_load_failure( crate::changeset::PersistenceError::backend(error), @@ -1401,7 +1416,17 @@ mod idempotent_load_tests { // Without a replay this is what the restore alone would leave // standing, so it is also the failure the assertion below catches. - let spend = spend_to(funded_outpoint, 74_000); + let address = dashcore::Address::from_script( + &funding.output[0].script_pubkey, + dashcore::Network::Testnet, + ) + .unwrap(); + let spend = authorize( + &ctx.wallet, + &ctx.managed_wallet, + &address, + spend_to(funded_outpoint, 74_000), + ); let manager = make_pending_manager(PendingSendPersister { wallet: ctx.wallet, managed: ctx.managed_wallet, @@ -1471,6 +1496,163 @@ mod idempotent_load_tests { } } + #[tokio::test] + async fn should_gate_nonterminal_tracked_only_instant_send_and_leave_consumed_archival() { + use dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof; + use dpp::prelude::AssetLockProof; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + for (status, has_proof, needs_evidence) in [ + (AssetLockStatus::InstantSendLocked, false, true), + (AssetLockStatus::Broadcast, true, true), + (AssetLockStatus::Consumed, true, false), + ] { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let fixture = dash_spv::test_utils::InstantLockFixture::new(&spend).unwrap(); + let lock = TrackedAssetLock { + operation_id: None, + out_point: OutPoint::new(spend.txid(), 0), + transaction: spend.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 74_000, + status, + proof: has_proof.then(|| { + AssetLockProof::Instant(InstantAssetLockProof::new( + fixture.instant_lock().clone(), + spend, + 0, + )) + }), + }; + let persister = RecordedHistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history: Default::default(), + asset_locks: [(lock.out_point, lock)].into(), + }; + let mut loaded = persister.load().unwrap(); + let wallet = loaded.wallets.values_mut().next().unwrap(); + let result = super::super::history_replay::replay_wallet_history_blocking( + wallet, + &Default::default(), + ); + if needs_evidence { + assert!(matches!( + result, + Err( + super::super::history_replay::HistoryReplayError::MissingFinalityEvidence { .. } + ) + )); + } else { + result.unwrap(); + assert!( + wallet.recorded_history.transactions.is_empty(), + "consumed history stays archival" + ); + } + } + } + + #[tokio::test] + async fn should_reject_legacy_load_when_history_requires_durable_repair() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let id = ctx.wallet.compute_wallet_id(); + let invalid = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let stored = |transaction: Transaction| StoredTransaction { + txid: transaction.txid(), + transaction, + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: Vec::new(), + }; + let persister = RecordedHistoryPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + history: RecordedHistory { + transactions: vec![stored(funding), stored(invalid)], + ..Default::default() + }, + asset_locks: BTreeMap::new(), + }; + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(persister), + Arc::new(NoopTestEventHandler), + ); + let error = manager + .load_from_persistor() + .await + .expect_err("a legacy backend cannot commit replay repair"); + assert!( + format!("{error:?}").contains("AtomicPersistenceRequired"), + "{error:?}" + ); + assert!(manager.get_wallet(&id).await.is_none()); + assert!(manager.list_wallet_ids_blocking().is_empty()); + } + + #[tokio::test] + async fn should_authorize_additional_outgoing_history_before_replay_or_resend() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let invalid = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let persister = PendingSendPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + pending: vec![invalid.clone()], + }; + let mut loaded = persister.load().unwrap(); + let wallet = loaded.wallets.values_mut().next().unwrap(); + let outcome = + super::super::history_replay::replay_wallet_history(wallet, &Default::default()) + .await + .unwrap(); + assert!(outcome.authorization.rejected.contains(&invalid.txid())); + assert!(wallet.unconfirmed_outgoing_txs.is_empty()); + let effects = outcome + .storage_effects(&wallet.recorded_history.transactions, &[]) + .unwrap(); + assert!(effects.removed_records.contains(&invalid.txid())); + } + + #[tokio::test] + async fn should_require_matching_evidence_for_additional_outgoing_instant_lock() { + let (ctx, funding) = TestWalletContext::new_random() + .with_mempool_funding(100_000) + .await; + let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); + let fixture = dash_spv::test_utils::InstantLockFixture::new(&spend).unwrap(); + let persister = PendingSendPersister { + wallet: ctx.wallet, + managed: ctx.managed_wallet, + pending: vec![spend.clone()], + }; + let mut loaded = persister.load().unwrap(); + let wallet = loaded.wallets.values_mut().next().unwrap(); + wallet + .recorded_history + .instant_locks + .insert(spend.txid(), fixture.instant_lock().clone()); + let error = super::super::history_replay::replay_wallet_history_blocking( + wallet, + &Default::default(), + ) + .unwrap_err(); + assert!(matches!( + error, + super::super::history_replay::HistoryReplayError::MissingFinalityEvidence { .. } + )); + assert_eq!(wallet.unconfirmed_outgoing_txs, vec![spend]); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn should_fail_load_without_proof_then_allow_an_explicit_verified_retry() { use dash_spv::test_utils::InstantLockFixture; @@ -1851,6 +2033,14 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; + let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let proof = (status == AssetLockStatus::InstantSendLocked).then(|| { + dpp::prelude::AssetLockProof::Instant( + dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof::new( + fixture.instant_lock().clone(), asset_lock.clone(), 0, + ), + ) + }); let mut wallet = ctx.wallet.clone(); let manager = make_history_manager(RecordedHistoryPersister { wallet: ctx.wallet, @@ -1880,12 +2070,23 @@ mod idempotent_load_tests { identity_index: 0, amount: 74_000, status, - proof: None, + proof, }, )] .into_iter() .collect(), }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; manager .load_from_persistor() .await @@ -1963,7 +2164,7 @@ mod idempotent_load_tests { enum Evidence { Instant, ChainFinal, - ChainFinalWithInstant, + ConsumedWithArchivedInstant, ChainFinalWithForeignHistoryLock, ChainFinalWithPendingSibling, InstantOfAnotherTransaction, @@ -2016,9 +2217,10 @@ mod idempotent_load_tests { (true, true, true), ), ( - Evidence::ChainFinalWithInstant, + // Archival proof preserves the consumed hold but cannot settle a rival. + Evidence::ConsumedWithArchivedInstant, Competitor::Unconfirmed, - (true, false, true), + (true, true, true), ), ( Evidence::ChainFinalWithForeignHistoryLock, @@ -2184,7 +2386,7 @@ mod idempotent_load_tests { out_point, }), ), - Evidence::ChainFinalWithInstant => { + Evidence::ConsumedWithArchivedInstant => { (AssetLockStatus::Consumed, instant(asset_lock.txid())) } Evidence::InstantOfAnotherTransaction => ( @@ -2255,10 +2457,18 @@ mod idempotent_load_tests { .await .unwrap(); manager.spv_manager.install_history_fixture(&fixture).await; - manager - .load_from_persistor() - .await - .expect("the wallet must load"); + let loaded = manager.load_from_persistor().await; + if matches!(evidence, Evidence::InstantOfAnotherTransaction) + && !matches!(competing, Competitor::ChainLocked) + { + assert!( + loaded.is_err(), + "mismatched required proof cannot authorize load" + ); + assert!(manager.list_wallet_ids_blocking().is_empty()); + continue; + } + loaded.expect("the wallet must load"); let mut core = manager .wallet_manager @@ -2465,6 +2675,14 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; + let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let proof = dpp::prelude::AssetLockProof::Instant( + dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof::new( + fixture.instant_lock().clone(), + asset_lock.clone(), + 0, + ), + ); let mut wallet = ctx.wallet.clone(); let manager = make_history_manager(RecordedHistoryPersister { managed: ManagedWalletInfo::from_wallet(&ctx.wallet, 0), @@ -2493,12 +2711,23 @@ mod idempotent_load_tests { identity_index: 0, amount: 74_000, status: AssetLockStatus::InstantSendLocked, - proof: None, + proof: Some(proof), }, )] .into_iter() .collect(), }); + let spv_storage = tempfile::TempDir::new().unwrap(); + manager + .spv_manager + .start( + dash_spv::ClientConfig::testnet() + .with_storage_path(spv_storage.path()) + .with_restrict_to_configured_peers(true), + ) + .await + .unwrap(); + manager.spv_manager.install_history_fixture(&fixture).await; manager .load_from_persistor() .await @@ -2599,14 +2828,58 @@ mod idempotent_load_tests { ); } + /// Loader unit fixture: supplies owner-prepared outcomes; real backend transaction + /// durability is covered by SQLite/FFI integration tests. + struct PreparedHistoryPersister(RecordedHistoryPersister); + + impl PlatformWalletPersistence for PreparedHistoryPersister { + fn store( + &self, + wallet_id: WalletId, + changes: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.store(wallet_id, changes) + } + fn flush(&self, wallet_id: WalletId) -> Result<(), PersistenceError> { + self.0.flush(wallet_id) + } + fn load(&self) -> Result { + self.0.load() + } + fn load_wallet_with_replay( + &self, + wallet_id: WalletId, + evidence: &crate::changeset::recorded_history::ReplayEvidence, + ) -> Result { + let mut loaded = self.load_with_replay(evidence)?; + loaded.state.wallets.retain(|id, _| *id == wallet_id); + loaded.outcomes.retain(|id, _| *id == wallet_id); + Ok(loaded) + } + fn load_with_replay( + &self, + evidence: &crate::changeset::recorded_history::ReplayEvidence, + ) -> Result { + let mut state = self.load()?; + let mut outcomes = BTreeMap::new(); + for (id, wallet) in &mut state.wallets { + let outcome = + super::super::history_replay::replay_wallet_history_blocking(wallet, evidence) + .map_err(PersistenceError::backend)?; + outcomes.insert(*id, outcome); + } + Ok(super::super::history_replay::HistoryReplayLoad { state, outcomes }) + } + } + fn make_history_manager( persister: RecordedHistoryPersister, - ) -> Arc> { + ) -> 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), + Arc::new(PreparedHistoryPersister(persister)), event_handler, )) } From a27c681538039d571433f869c63ca4ec2336ce88 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:46:05 +0000 Subject: [PATCH 69/84] fix(platform-wallet): preserve replay projection and migration regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore updated-record UTXO projection, contact-only exclusions and live late-funding attribution while keeping V020 accounting backfill pure. Preserve cross-account descendants of unsafe stored records and reject surviving mined records with unsafe operands after proof and conflict gates. Transfer original claim/size/migration regressions to their owning paths. Scoped tests and Clippy pass for the local fixes. Four enabled regressions still require the separately reviewed bulk-integrity and claim-attribution parents; this checkpoint does not include those integrations. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/sqlite/schema/core_history.rs | 55 ++- .../src/sqlite/schema/core_state.rs | 453 +++++++++++++++++- .../tests/integration/sqlite_core_rescan.rs | 43 +- .../integration/sqlite_spent_rehydration.rs | 66 +-- .../src/changeset/core_bridge.rs | 20 +- .../src/manager/history_replay.rs | 56 ++- 6 files changed, 617 insertions(+), 76 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index c515557dce5..d5583857a88 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -98,6 +98,24 @@ pub(crate) fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + apply_records(tx, wallet_id, cs, false) +} + +/// Project live producer spends; V020 accounting backfill never calls this path. +pub(super) fn apply_live( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, +) -> Result<(), WalletStorageError> { + apply_records(tx, wallet_id, cs, true) +} + +fn apply_records( + tx: &Transaction<'_>, + wallet_id: &WalletId, + cs: &CoreChangeSet, + attribute_spends: bool, ) -> Result<(), WalletStorageError> { let mut affected: HashSet = cs.records.iter().map(|r| r.txid).collect(); let mut consumers = tx.prepare_cached( @@ -119,7 +137,7 @@ pub(crate) fn apply( } let network = network(tx, wallet_id)?; for txid in affected { - repair_record(tx, wallet_id, &txid, network)?; + repair_record(tx, wallet_id, &txid, network, attribute_spends)?; } Ok(()) } @@ -196,6 +214,7 @@ fn repair_record( wallet_id: &WalletId, txid: &Txid, network: dashcore::Network, + attribute_spends: bool, ) -> Result<(), WalletStorageError> { let Some(mut record) = prior_record(tx, wallet_id, txid)? else { return Ok(()); @@ -219,6 +238,21 @@ fn repair_record( address, }, ); + // Only the live projection attributes confirmed spends; accounting + // backfill stays read-only with respect to coins and reservations. + if attribute_spends && !matches!(record.context, TransactionContext::Mempool) { + tx.execute( + "UPDATE core_utxos SET spent = 1, \ + spent_in_txid = CASE WHEN spent = 1 AND spent_in_txid IS NOT NULL \ + THEN spent_in_txid ELSE ?3 END \ + WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&input.previous_output)?, + txid.as_byte_array().as_slice() + ], + )?; + } } } let mut outputs = BTreeMap::new(); @@ -488,8 +522,14 @@ mod tests { .unwrap(); let tx = conn.transaction().unwrap(); - let err = - repair_record(&tx, &WALLET_ID, &stored.txid, dashcore::Network::Testnet).unwrap_err(); + let err = repair_record( + &tx, + &WALLET_ID, + &stored.txid, + dashcore::Network::Testnet, + false, + ) + .unwrap_err(); assert!( matches!( @@ -702,7 +742,14 @@ mod tests { let mut conn = wallet_db("testnet"); store(&conn, live); let tx = conn.transaction().unwrap(); - repair_record(&tx, &WALLET_ID, &live.txid, dashcore::Network::Testnet).unwrap(); + repair_record( + &tx, + &WALLET_ID, + &live.txid, + dashcore::Network::Testnet, + false, + ) + .unwrap(); let repaired = prior_record(&tx, &WALLET_ID, &live.txid).unwrap().unwrap(); assert_eq!( diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 1f2a2007b85..4719df301b0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -266,7 +266,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - core_history::apply(tx, wallet_id, cs)?; + core_history::apply_live(tx, wallet_id, cs)?; return Ok(()); } @@ -430,7 +430,7 @@ pub fn apply( if heights_advanced { collect_finalized_tombstones(tx, wallet_id)?; } - core_history::apply(tx, wallet_id, cs)?; + core_history::apply_live(tx, wallet_id, cs)?; Ok(()) } @@ -487,12 +487,15 @@ fn surviving_stored_input_claims( ) -> Result, WalletStorageError> { use dashcore::hashes::Hash; - let mut stmt = - tx.prepare_cached("SELECT txid, record_blob FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL")?; + let mut stmt = tx.prepare_cached( + "SELECT length(txid), txid, length(record_blob), record_blob \ + FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL", + )?; let mut rows = stmt.query(params![wallet_id.as_slice()])?; let mut claims: HashSet = HashSet::new(); while let Some(row) = rows.next()? { - let txid_bytes: Vec = row.get(0)?; + blob::check_size(row.get(0)?)?; + let txid_bytes: Vec = row.get(1)?; let Ok(txid_array) = <[u8; 32]>::try_from(txid_bytes.as_slice()) else { return Err(WalletStorageError::blob_decode( "core_transactions.txid must be exactly 32 bytes", @@ -502,7 +505,8 @@ fn surviving_stored_input_claims( if swept_txids.contains(&key_txid) { continue; } - let blob_bytes: Vec = row.get(1)?; + blob::check_size(row.get(2)?)?; + let blob_bytes: Vec = row.get(3)?; let record: TransactionRecord = blob::decode(&blob_bytes)?; if record.txid != key_txid { return Err(WalletStorageError::blob_decode( @@ -1050,6 +1054,10 @@ pub(crate) fn load_snapshot( let value = crate::sqlite::util::safe_cast::i64_to_u64("core_utxos.value", value)?; let height = transaction_heights.get(&outpoint.txid).copied().flatten(); let script = dashcore::ScriptBuf::from_bytes(script_bytes); + // Contact-only historical outputs must not enter the spendable fallback. + if core_history::contact_only_script(conn, wallet_id, script.as_bytes())? { + continue; + } if let Some(owner) = owning_account_for_script(conn, wallet_id, script.as_bytes())? { utxo_accounts.insert(outpoint, owner); } @@ -1436,6 +1444,7 @@ mod tests { TransactionDirection, TransactionRecord, }; use key_wallet::transaction_checking::{BlockInfo, TransactionContext, TransactionType}; + use platform_wallet::changeset::changeset::SweepBatch; fn transaction_record(context: TransactionContext) -> TransactionRecord { TransactionRecord::new( @@ -1598,7 +1607,10 @@ mod tests { let before = snapshot(&conn); rewind_v020(&conn); - migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); let repaired = stored_record(&conn, &wallet_id, &spending.txid); assert_eq!(repaired.net_amount, -150_000); @@ -1761,6 +1773,11 @@ mod tests { assert_eq!(repaired.direction, TransactionDirection::Internal); assert_eq!(repaired.input_details.len(), 1); assert_eq!(repaired.block_info().unwrap().height(), 101); + assert_eq!( + spend_claim(&tx, &wallet_id, &funding.outpoint), + (true, Some(spending.txid.as_byte_array().to_vec())), + "late funding retains the confirmed spender's identity" + ); let original = original_blob(&tx, &wallet_id, &spending.txid); assert_eq!( blob::decode::(&original) @@ -2756,4 +2773,426 @@ mod tests { corrupt.extend_from_slice(&[0xFFu8; 4]); assert_eq!(chain_lock_height(&corrupt), None); } + + #[test] + fn should_apply_credit_verdicts_to_the_stored_spent_flag() { + use UtxoCreditVerdict::{Doomed, ObservedSpent, Uncredited}; + let wallet_id = [0xAEu8; 32]; + let mut conn = wallet_db(&wallet_id); + let tx = conn.transaction().unwrap(); + let stored_spent = |outpoint: &OutPoint| -> Option { + tx.query_row( + "SELECT spent FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| row.get(0), + ) + .optional() + .unwrap() + }; + // (case, row before the round, verdict, stored spent flag after it) + let cases = [ + ( + "observed spent", + "none", + ObservedSpent { height: 101 }, + Some(true), + ), + ("doomed", "none", Doomed, Some(true)), + ("uncredited never materializes", "none", Uncredited, None), + ( + "uncredited cannot invent a spend", + "unspent", + Uncredited, + Some(false), + ), + ( + "uncredited cannot clear a spend", + "spent", + Uncredited, + Some(true), + ), + ( + "uncredited replay keeps a doomed spend", + "doomed", + Uncredited, + Some(true), + ), + ]; + for (marker, (case, prior, verdict, expected)) in (1..).zip(cases) { + let utxo = sample_utxo(Txid::from_byte_array([marker; 32]), 100, true); + let unspent = round(vec![], vec![utxo.clone()]); + let spent = CoreChangeSet { + spent_utxos: vec![utxo.clone()], + ..Default::default() + }; + let prior_rounds = match prior { + "none" => vec![], + "unspent" => vec![unspent], + "spent" => vec![unspent, spent], + _ => vec![CoreChangeSet { + utxo_credit_verdicts: [(utxo.outpoint, Doomed)].into(), + ..unspent + }], + }; + for prior_round in &prior_rounds { + apply(&tx, &wallet_id, prior_round).unwrap(); + } + let mut verdict_round = round(vec![], vec![utxo.clone()]); + verdict_round.utxo_credit_verdicts = [(utxo.outpoint, verdict)].into(); + apply(&tx, &wallet_id, &verdict_round).unwrap(); + assert_eq!(stored_spent(&utxo.outpoint), expected, "{case}"); + } + } + + #[test] + fn should_keep_contact_only_rows_but_never_load_them_as_spendable() { + let wallet_id = [0xB1u8; 32]; + let mut conn = wallet_db(&wallet_id); + let contact = sample_utxo(Txid::from_byte_array([0x51; 32]), 100, true); + let mut own = sample_utxo(Txid::from_byte_array([0x52; 32]), 100, true); + own.address = dashcore::Address::new( + dashcore::Network::Testnet, + Payload::PubkeyHash(PubkeyHash::from_byte_array([0x24u8; 20])), + ); + own.txout.script_pubkey = own.address.script_pubkey(); + insert_contact_script(&conn, &wallet_id, &contact.txout.script_pubkey); + let tx = conn.transaction().unwrap(); + let outpoints = [contact.outpoint, own.outpoint]; + apply(&tx, &wallet_id, &round(vec![], vec![contact, own])).unwrap(); + tx.commit().unwrap(); + for outpoint in &outpoints { + assert!( + !spend_claim(&conn, &wallet_id, outpoint).0, + "stored rows stay unspent, never destroyed" + ); + } + let (cs, owners) = load_state( + &conn, + &wallet_id, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + let loaded: Vec<_> = cs.new_utxos.iter().map(|u| u.outpoint).collect(); + assert_eq!( + loaded, + vec![outpoints[1]], + "a contact's coin is not ours to spend" + ); + assert!(!owners.contains_key(&outpoints[0])); + } + + fn mempool_spend(coins: &[&Utxo]) -> TransactionRecord { + let coins: Vec<_> = coins.iter().map(|coin| coin.outpoint).collect(); + spend(&coins, TransactionContext::Mempool) + } + + fn spend_claim( + conn: &Connection, + wallet_id: &[u8; 32], + outpoint: &OutPoint, + ) -> (bool, Option>) { + conn.query_row( + "SELECT spent, spent_in_txid FROM core_utxos WHERE wallet_id = ?1 AND outpoint = ?2", + params![&wallet_id[..], blob::encode_outpoint(outpoint).unwrap()], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap() + } + + #[test] + fn should_record_spender_for_new_mempool_reservation() { + let wallet_id = [0x61; 32]; + let mut conn = wallet_db(&wallet_id); + let stored = sample_utxo(Txid::from_byte_array([0x62; 32]), 10, true); + // No funding row yet: the spend synthesises its own spent row. + let unseen = sample_utxo(Txid::from_byte_array([0x63; 32]), 10, true); + let spender = mempool_spend(&[&stored, &unseen]); + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &round(vec![], vec![stored.clone()])).unwrap(); + + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spender.clone()], + spent_utxos: vec![stored.clone(), unseen.clone()], + ..Default::default() + }, + ) + .unwrap(); + + let claimant = Some(AsRef::<[u8]>::as_ref(&spender.txid).to_vec()); + for coin in [&stored, &unseen] { + assert_eq!( + spend_claim(&tx, &wallet_id, &coin.outpoint), + (true, claimant.clone()), + "a new mempool reservation must name the transaction that made it" + ); + } + } + + #[test] + fn should_keep_existing_claims_when_mempool_spend_is_redelivered() { + let wallet_id = [0x64; 32]; + let mut conn = wallet_db(&wallet_id); + let unknown = sample_utxo(Txid::from_byte_array([0x65; 32]), 10, true); + let foreign = sample_utxo(Txid::from_byte_array([0x66; 32]), 10, true); + let held = sample_utxo(Txid::from_byte_array([0x67; 32]), 10, true); + let other_spender = [0x68u8; 32]; + let tx = conn.transaction().unwrap(); + let unspent = round(vec![], vec![unknown.clone(), foreign.clone()]); + apply(&tx, &wallet_id, &unspent).unwrap(); + tx.execute( + "UPDATE core_utxos SET spent = 1 WHERE wallet_id = ?1", + params![&wallet_id[..]], + ) + .unwrap(); + tx.execute( + "UPDATE core_utxos SET spent_in_txid = ?3 WHERE wallet_id = ?1 AND outpoint = ?2", + params![ + &wallet_id[..], + blob::encode_outpoint(&foreign.outpoint).unwrap(), + &other_spender[..] + ], + ) + .unwrap(); + // A sweep hold for an input whose funding this store never saw. + tx.execute( + "INSERT INTO core_utxos \ + (wallet_id, outpoint, value, script, is_sweep_placeholder, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'', 1, 1, ?3)", + params![ + &wallet_id[..], + blob::encode_outpoint(&held.outpoint).unwrap(), + &other_spender[..] + ], + ) + .unwrap(); + let spender = mempool_spend(&[&unknown, &foreign, &held]); + + apply( + &tx, + &wallet_id, + &CoreChangeSet { + records: vec![spender], + spent_utxos: vec![unknown.clone(), foreign.clone(), held.clone()], + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!( + spend_claim(&tx, &wallet_id, &unknown.outpoint), + (true, None), + "an unknown durable claim must not be adopted by a later mempool spender" + ); + for coin in [&foreign, &held] { + assert_eq!( + spend_claim(&tx, &wallet_id, &coin.outpoint), + (true, Some(other_spender.to_vec())), + "another spender's claim must stand" + ); + } + } + + #[test] + fn should_reject_oversize_spent_claim_before_reading_it() { + use rusqlite::limits::Limit; + + use crate::sqlite::conn::SQLITE_MAX_BLOB_BYTES; + + const WALLET: [u8; 32] = [0x5F; 32]; + // Over the connection's length cap, so reading the column itself + // fails: only a length-only pre-read reports it as oversize. + let oversize = vec![0u8; SQLITE_MAX_BLOB_BYTES as usize + 1]; + let outpoint = + blob::encode_outpoint(&OutPoint::new(Txid::from_byte_array([0x42; 32]), 0)).unwrap(); + for (stored_outpoint, claimant) in [ + (&oversize[..], &[0x43u8; 32][..]), + (&outpoint[..], &oversize[..]), + ] { + let conn = wallet_db(&WALLET); + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent, spent_in_txid) \ + VALUES (?1, ?2, 0, X'00', 1, ?3)", + params![&WALLET[..], stored_outpoint, claimant], + ) + .unwrap(); + conn.set_limit(Limit::SQLITE_LIMIT_LENGTH, SQLITE_MAX_BLOB_BYTES) + .unwrap(); + + let err = crate::sqlite::history_replay::load_spent_claims(&conn, &WALLET).unwrap_err(); + + assert!( + matches!(err, WalletStorageError::BlobTooLarge { .. }), + "got {err:?}" + ); + } + } + + #[test] + fn should_reject_oversize_stored_claimant_before_reading_it() { + use rusqlite::types::Value; + + let oversize = blob::BLOB_SIZE_LIMIT_BYTES + 1; + let key = || Value::Blob(vec![0x11; 32]); + let cases = [ + ( + "txid", + Value::Blob(vec![0x11; oversize]), + Value::Blob(vec![0]), + ), + ("record", key(), Value::Blob(vec![0; oversize])), + // A blob reader cannot read text at all, so only a gate that + // runs before the read can report this one by its size. + ( + "record stored as text", + key(), + Value::Text("a".repeat(oversize)), + ), + ]; + let mut ungated = Vec::new(); + for (column, txid, record) in cases { + let wallet_id = [0x4Cu8; 32]; + let mut conn = wallet_db(&wallet_id); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) \ + VALUES (?1, ?2, 1, ?3)", + params![&wallet_id[..], txid, record], + ) + .unwrap(); + let tx = conn.transaction().unwrap(); + + let error = surviving_stored_input_claims(&tx, &wallet_id, &HashSet::new()) + .expect_err("an oversize stored claimant must fail the scan"); + + let gated = matches!( + error, + WalletStorageError::BlobTooLarge { len_bytes, .. } if len_bytes == oversize + ); + if !gated { + ungated.push(format!("{column}: {error:?}")); + } + } + assert_eq!(ungated, Vec::::new()); + } + + fn indexed_txid(tag: u8, index: u32) -> Txid { + let mut bytes = [tag; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + Txid::from_byte_array(bytes) + } + + #[test] + fn should_migrate_to_v021_in_work_linear_in_kept_coins_and_dropped_history() { + let ops = |n: u32| { + let wallet_id = [0xB8u8; 32]; + let mut conn = wallet_db(&wallet_id); + for index in 0..n { + let (receipt, mined) = (indexed_txid(1, index), indexed_txid(2, index)); + for (txid, height) in [(receipt, None), (mined, Some(200))] { + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, height, finalized) \ + VALUES (?1, ?2, ?3, 0)", + params![&wallet_id[..], txid.as_byte_array().as_slice(), height], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent) \ + VALUES (?1, ?2, 1, X'51', 0)", + params![ + &wallet_id[..], + blob::encode_outpoint(&OutPoint::new(receipt, 0)).unwrap() + ], + ) + .unwrap(); + } + rewind_v020(&conn); + let ops = count_vm_ops(&conn); + migrations::run(&mut conn).unwrap(); + let kept: i64 = conn + .query_row("SELECT COUNT(*) FROM core_transactions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(kept, i64::from(n), "only the unmined receipts stay"); + ops.load(std::sync::atomic::Ordering::Relaxed) + }; + let (small, large) = (ops(200), ops(800)); + assert!( + large < 8 * small, + "V021 work must grow linearly, not with kept coins times dropped records: \ + {small} VM ops for 200 of each, {large} for 800" + ); + } + + #[test] + fn should_hand_a_withheld_input_to_its_kept_survivor_after_migrating_to_v021() { + let wallet_id = [0xB7u8; 32]; + let mut conn = wallet_db(&wallet_id); + let coin = |marker| sample_utxo(Txid::from_byte_array([marker; 32]), 150, true); + let (first, second, shared) = (coin(0x71), coin(0x72), coin(0x73)); + // The shared coin is claimed by the first record listing it: `loser`. + let loser = spend( + &[first.outpoint, shared.outpoint], + TransactionContext::Mempool, + ); + let survivor = spend( + &[second.outpoint, shared.outpoint], + TransactionContext::Mempool, + ); + let coins = vec![first.clone(), second.clone(), shared.clone()]; + let pending = CoreChangeSet { + spent_utxos: coins.clone(), + ..round(vec![loser.clone(), survivor.clone()], coins) + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &pending).unwrap(); + tx.commit().unwrap(); + let claimant = |txid: &Txid| (true, Some(txid.as_byte_array().to_vec())); + assert_eq!( + spend_claim(&conn, &wallet_id, &shared.outpoint), + claimant(&loser.txid) + ); + rewind_v020(&conn); + migrations::run(&mut conn).unwrap(); + + let block = BlockInfo::new(160, BlockHash::all_zeros(), 1); + let winner = spend(&[first.outpoint], TransactionContext::InBlock(block)); + let settled = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser.txid], + superseded_by: winner.txid, + winner_mined_height: Some(160), + released_outpoints: vec![], + }], + ..round(vec![winner], vec![]) + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &settled).unwrap(); + tx.commit().unwrap(); + + assert_eq!( + spend_claim(&conn, &wallet_id, &shared.outpoint), + claimant(&survivor.txid), + "the kept survivor, not the winner, holds the withheld input" + ); + } + fn count_vm_ops(conn: &Connection) -> std::sync::Arc { + use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }; + let count = Arc::new(AtomicU64::new(0)); + let counter = Arc::clone(&count); + conn.progress_handler( + 1, + Some(move || { + counter.fetch_add(1, Ordering::Relaxed); + false + }), + ) + .unwrap(); + count + } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs index 60e58f746cb..6648cd95dc2 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_core_rescan.rs @@ -99,19 +99,30 @@ async fn rescan( path: &std::path::Path, wallet_id: WalletId, blocks: &[(Transaction, u32)], +) -> ClientStartState { + rescan_with_evidence(path, wallet_id, blocks, &Default::default()).await +} + +async fn rescan_with_evidence( + path: &std::path::Path, + wallet_id: WalletId, + blocks: &[(Transaction, u32)], + evidence: &platform_wallet::changeset::recorded_history::ReplayEvidence, ) -> ClientStartState { let persister = Arc::new(SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap()); let loaded = persister - .load() + .load_with_replay(evidence) .unwrap() + .state .wallets .remove(&wallet_id) .unwrap(); assert!( loaded.wallet_info.accounts.standard_bip44_accounts[&0] .transactions() - .is_empty(), - "the wiped wallet loads without history" + .values() + .all(|record| record.context.block_info().is_none()), + "the wiped wallet has only its still-active outgoing history" ); let mut manager = WalletManager::::new(Network::Testnet); let mut events = manager.take_persistence_receiver().unwrap(); @@ -127,8 +138,9 @@ async fn rescan( drop((manager, events, persister)); SqlitePersister::open(SqlitePersisterConfig::new(path)) .unwrap() - .load() + .load_with_replay(evidence) .unwrap() + .state } /// Undo V020 on the store at `path`, so its next open replays V020 over the @@ -381,13 +393,25 @@ async fn should_keep_unconsumed_asset_locks_when_a_wiped_wallet_loads_and_rescan (out_point, entry) }; let other = |byte| spend_of(&[outside(byte)], vec![]); - let locks: BTreeMap<_, _> = [ + let instant = other(32); + let (lock, evidence) = common::checked_instant_lock(&instant).await; + let mut locks: BTreeMap<_, _> = [ entry(&other(31), AssetLockStatus::Built), entry(&chain.lock, AssetLockStatus::Broadcast), - entry(&other(32), AssetLockStatus::InstantSendLocked), + entry(&instant, AssetLockStatus::InstantSendLocked), entry(&other(33), AssetLockStatus::ChainLocked), ] .into(); + locks + .get_mut(&OutPoint::new(instant.txid(), 0)) + .unwrap() + .proof = Some(dpp::prelude::AssetLockProof::Instant( + dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof::new( + lock, + instant.clone(), + 0, + ), + )); let round = PlatformWalletChangeSet { asset_locks: Some(AssetLockChangeSet { asset_locks: locks.clone(), @@ -402,8 +426,9 @@ async fn should_keep_unconsumed_asset_locks_when_a_wiped_wallet_loads_and_rescan let loaded = SqlitePersister::open(SqlitePersisterConfig::new(&path)) .unwrap() - .load() - .unwrap(); + .load_with_replay(&evidence) + .unwrap() + .state; let restored: BTreeMap<_, _> = loaded.wallets[&wallet_id] .unused_asset_locks .values() @@ -421,7 +446,7 @@ async fn should_keep_unconsumed_asset_locks_when_a_wiped_wallet_loads_and_rescan "the load leaves the rows untouched" ); - let state = rescan(&path, wallet_id, &chain.blocks()).await; + let state = rescan_with_evidence(&path, wallet_id, &chain.blocks(), &evidence).await; assert_eq!( stored(&path, &wallet_id, &chain.lock).direction, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs index 39536454723..44d1f8b3e3d 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_spent_rehydration.rs @@ -803,7 +803,6 @@ async fn should_settle_a_persisted_conflict_on_restart() { } async fn assert_conflict_restart(case: ConflictCase) { - use dashcore::ephemerealdata::instant_lock::InstantLock; use ConflictCase::*; let record_funding = !matches!(case, HeightOnlyFunding); @@ -853,19 +852,16 @@ async fn assert_conflict_restart(case: ConflictCase) { surviving_txid = Some(extra_spender.txid()); records.extend(records_of(&mut other_spender, &mut wallet, &extra_spender, MEMPOOL).await); } - let lock = |txid, inputs| InstantLock { - inputs, - txid, - ..Default::default() - }; - let mut locks = BTreeMap::new(); + let locked = if chainlocked { &loser } else { &winner }; + let (lock, evidence) = common::checked_instant_lock(locked).await; if chainlocked { - let record = records.iter_mut().find(|r| r.txid == winner.txid()); - record.unwrap().context = block(101); - locks.insert(loser.txid(), lock(loser.txid(), vec![shared, extra])); - } else { - locks.insert(winner.txid(), lock(winner.txid(), vec![shared])); + records + .iter_mut() + .find(|r| r.txid == winner.txid()) + .unwrap() + .context = block(101); } + let locks = [(locked.txid(), lock)].into(); let (persister, _dir, path) = common::fresh_persister(); let loser_coin = Utxo::new( OutPoint::new(loser.txid(), 0), @@ -927,7 +923,12 @@ async fn assert_conflict_restart(case: ConflictCase) { &[&wallet_key, &extra_key], ); assert!(matches!( - typed_error(persister.load().unwrap_err()), + typed_error( + persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .unwrap_err() + ), WalletStorageError::BlobDecode { .. } )); assert!(loser_kept()); @@ -1026,7 +1027,10 @@ async fn assert_conflict_restart(case: ConflictCase) { persister.lock_conn_for_test().execute_batch( "CREATE TRIGGER fail_replay_repair BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent = 0 BEGIN SELECT RAISE(ABORT, 'injected replay repair failure'); END;", ).unwrap(); - assert!(persister.load().is_err()); + assert!(persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .is_err()); if assets { assert_eq!( asset_lock_rows(&persister), @@ -1040,10 +1044,13 @@ async fn assert_conflict_restart(case: ConflictCase) { } if matches!(case, Unconverged) { persister.lock_conn_for_test().execute_batch("CREATE TRIGGER preserve_history BEFORE DELETE ON core_transactions BEGIN SELECT RAISE(IGNORE); END;").unwrap(); - let error = persister.load().unwrap_err(); - assert!( - matches!(typed_error(error), WalletStorageError::WalletRehydrationFailed { wallet_id, .. } if wallet_id == wallet.wallet_id) - ); + let error = persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .unwrap_err(); + assert!(error + .to_string() + .contains("still conflicts after atomic replay")); assert!(loser_kept()); return; } @@ -1057,7 +1064,10 @@ async fn assert_conflict_restart(case: ConflictCase) { let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path).with_load_policy(policy)) .unwrap(); - let mut state = persister.load().unwrap(); + let mut state = persister + .load_with_replay(&evidence) + .map(|loaded| loaded.state) + .unwrap(); if assets { assert!( state.wallets[&wallet.wallet_id] @@ -1362,12 +1372,16 @@ async fn should_reject_overflowing_replay_amounts_with_fitting_net() { .persister .load() .expect_err("unsafe checker operands must return a typed error"); + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("expected typed backend error"); + }; assert!( matches!( - typed_error(error), - WalletStorageError::IntegerOverflow { .. } + source.downcast_ref::(), + Some(platform_wallet::manager::history_replay::HistoryReplayError::UnsafeMinedAmounts { txids }) + if txids.contains(&fixture.recorded_txid()) ), - "{label}: unsafe checker arithmetic must be rejected before replay" + "{label}: unsafe mined operands must fail before committed replay effects" ); } } @@ -2680,11 +2694,7 @@ async fn should_preserve_instant_send_replacement_when_stale_branch_has_lower_he let mut records = records_of(&mut info, &mut wallet, &funding, block(100)).await; let mut fork = info.clone(); records.extend(records_of(&mut fork, &mut wallet, &stale, block(101)).await); - let lock = dashcore::InstantLock { - txid: replacement.txid(), - inputs: vec![coin], - ..Default::default() - }; + let (lock, evidence) = common::checked_instant_lock(&replacement).await; records.extend( records_of( &mut info, @@ -2708,7 +2718,7 @@ async fn should_preserve_instant_send_replacement_when_stale_branch_has_lower_he }, ); for _ in 0..2 { - let loaded = persister.load().unwrap(); + let loaded = persister.load_with_replay(&evidence).unwrap().state; let restored = &loaded.wallets[&wallet.wallet_id].wallet_info; let account = &restored.accounts.standard_bip44_accounts[&0]; assert!( diff --git a/packages/rs-platform-wallet/src/changeset/core_bridge.rs b/packages/rs-platform-wallet/src/changeset/core_bridge.rs index 85a0c674112..8063a714e46 100644 --- a/packages/rs-platform-wallet/src/changeset/core_bridge.rs +++ b/packages/rs-platform-wallet/src/changeset/core_bridge.rs @@ -1743,17 +1743,11 @@ async fn build_core_changeset( .. } => { let mut cs = CoreChangeSet::default(); - // Inserted records bring fresh UTXOs and may consume previous ones. - for r in inserted { + // Corrections can discover owned inputs or outputs after first observation. + for r in inserted.iter().chain(updated.iter()) { cs.new_utxos.extend(derive_new_utxos(r)); cs.spent_utxos.extend(derive_spent_utxos(r)); } - // Updated records (re-confirmation, IS-lock applied to a known - // mempool tx, etc.) don't usually change UTXO topology — the - // record's content does change though, so re-emit it. - // Matured coinbase records likewise: no UTXO topology change, - // just a status update for the persister. - // // Contact watch-only records are filtered out of all three // lists: re-emitting one on confirmation would re-clobber the // funding account's row with an incoming/positive @@ -4097,6 +4091,16 @@ mod contact_watch_only_projection_tests { assert_eq!(cs.records.len(), 1); assert_eq!(cs.records[0].direction, TransactionDirection::Outgoing); + assert_eq!( + cs.spent_utxos.len(), + 1, + "updated records project recognized inputs" + ); + assert_eq!( + cs.new_utxos.len(), + 1, + "updated records project owned change" + ); } #[tokio::test] diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 8baed60c8aa..8e6e8b30e1b 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -16,7 +16,7 @@ use std::task::{Context, Poll, Waker}; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::transaction::TransactionPayload; -use dashcore::{OutPoint, Transaction, TxOut, Txid}; +use dashcore::{Address, OutPoint, Transaction, TxOut, Txid}; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; use key_wallet::managed_account::transaction_record::{ @@ -105,6 +105,9 @@ pub enum HistoryReplayError { /// The snapshot requires evidence the running SPV verifier cannot yet provide. #[error("wallet history requires verified finality evidence for {txids:?}; synchronize SPV and retry load")] MissingFinalityEvidence { txids: BTreeSet }, + /// A surviving mined record has operands no valid transaction can carry. + #[error("stored mined transactions have unsafe accounting amounts: {txids:?}")] + UnsafeMinedAmounts { txids: BTreeSet }, /// Stored input authorization cannot be checked with supported rules. #[error("cannot verify stored input authorization")] SpendAuthorization(#[from] SpendAuthorizationError), @@ -290,10 +293,9 @@ pub fn replay_recorded_history_blocking( /// nor are its unconfirmed descendants, and their persisted outputs are dropped. /// /// A record whose amounts would overflow the checker's signed arithmetic -/// ([`unsafe_replay_amounts`]) is left out rather than failing the load. No -/// valid transaction carries such amounts: an unconfirmed one is unverified -/// peer data, a settled one is store corruption, and this path has no recovery -/// mode to fall back on. The restored coins it spends stay reserved, and an +/// ([`unsafe_replay_amounts`]) is left out when unmined. A surviving mined +/// record with such operands fails load as corrupt storage, after required +/// proofs and conflict precedence have been resolved. Restored coins stay reserved, and an /// unconfirmed one stays on record, unchecked, so that a settled conflict /// still sweeps it and releases them. A coin several such records spend is /// held as [`reservation_claimant`] decides. @@ -597,6 +599,18 @@ pub async fn replay_recorded_history_with_evidence( let mut swept = plan.removed; let mut sweeps = plan.effects; replay.retain(|stored| !swept.contains(&stored.txid)); + let corrupt_mined: BTreeSet<_> = replay + .iter() + .filter(|stored| { + stored.context.block_info().is_some() && unsafe_txids.contains(&stored.txid) + }) + .map(|stored| stored.txid) + .collect(); + if !corrupt_mined.is_empty() { + return Err(HistoryReplayError::UnsafeMinedAmounts { + txids: corrupt_mined, + }); + } // Verified IS evidence survives a mining-block reorg independently of the // record's block context. Records are detached here, so this cannot sweep // outside the common planner. Only surviving transactions retain the metadata. @@ -613,23 +627,12 @@ pub async fn replay_recorded_history_with_evidence( .into_iter() .partition(|stored| !unsafe_txids.contains(&stored.txid)); if !set_aside.is_empty() { - let settled = set_aside - .iter() - .filter(|stored| stored.context.block_info().is_some()) - .count(); // Counts only: the txids of unconfirmed records are peer-supplied. tracing::warn!( wallet_id = %hex::encode(wallet_info.wallet_id), skipped = set_aside.len(), "stored transaction records with unsafe amounts left out of the shared load replay; the coins they spend stay reserved" ); - if settled > 0 { - tracing::error!( - wallet_id = %hex::encode(wallet_info.wallet_id), - settled, - "stored confirmed transaction records carry amounts no valid transaction can; the wallet store is corrupt" - ); - } } // Reserve restored coins and recorded owned inputs absent from the // projection: unsafe stored amounts do not prove the transaction invalid. @@ -761,7 +764,11 @@ pub async fn replay_recorded_history_with_evidence( .is_some() && account.contains_address(&detail.address) }); - if owners.contains(&owner) || owns_recorded_input { + let owns_output = stored.transaction.output.iter().any(|output| { + Address::from_script(&output.script_pubkey, wallet.network) + .is_ok_and(|address| account.contains_address(&address)) + }); + if owners.contains(&owner) || owns_recorded_input || owns_output { account .transactions_mut() .entry(stored.txid) @@ -2999,9 +3006,18 @@ mod tests { instant_locks: BTreeMap::new(), }, ) - .await - .unwrap() - .replayed; + .await; + if settled { + assert!( + matches!(replayed, + Err(HistoryReplayError::UnsafeMinedAmounts { txids }) + if txids.contains(&unsafe_spend.txid()) + ), + "mined unsafe operands must fail load" + ); + continue; + } + let replayed = replayed.unwrap().replayed; restored .check_core_transaction(&funding, block(100), &mut wallet, true, true) .await; From 59dc06d0e22beb3507f49d6834a229a04a82f042 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:54:58 +0000 Subject: [PATCH 70/84] fix(wallet-storage)!: reconcile historical AssetUnlock identities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt canonical history records while retaining validated legacy identity evidence in the immutable archive and preserving physical metadata anchors. Resolve historical prevouts for accounting without rewriting signed inputs, coins, claims or proof keys. Keep physical backend reads separate from the public alias projection and roll back incompatible confirmed contexts. Cover migration and redelivery with old-writer fixtures, including a real SIGHASH_ALL consumer, plus alias families, rollback and Recovery policy. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../src/sqlite/error.rs | 18 +- .../src/sqlite/load_ctx.rs | 6 + .../src/sqlite/persister.rs | 14 +- .../src/sqlite/schema/core_history.rs | 153 +++- .../src/sqlite/schema/core_history_aliases.rs | 370 ++++++++ .../src/sqlite/schema/core_state.rs | 4 +- .../src/sqlite/schema/mod.rs | 1 + .../fixtures/v019_asset_unlock_v2_signed.txt | 2 + .../tests/integration/main.rs | 1 + .../integration/sqlite_history_aliases.rs | 828 ++++++++++++++++++ .../src/changeset/changeset.rs | 19 +- .../rs-platform-wallet/src/changeset/mod.rs | 21 +- 12 files changed, 1384 insertions(+), 53 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs create mode 100644 packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt create mode 100644 packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/error.rs b/packages/rs-platform-wallet-storage/src/sqlite/error.rs index 029476658ed..ade3e9489b6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/error.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/error.rs @@ -422,6 +422,16 @@ pub enum WalletStorageError { blob_height: Option, }, + /// Aliases of one canonical transaction disagree about their confirmed block. + #[error( + "history aliases for {txid} in wallet {} have conflicting contexts", + hex::encode(wallet_id) + )] + HistoryAliasContextConflict { + wallet_id: [u8; 32], + txid: dashcore::Txid, + }, + /// An incoming transaction record reuses a stored txid with a body whose /// txid-committed content differs (witness-only differences are not a /// conflict); neither copy is trusted to replace the other. @@ -790,6 +800,7 @@ impl WalletStorageError { | Self::AssetLockEntryMismatch { .. } | Self::AssetLockStatusMismatch { .. } | Self::CoreTransactionEntryMismatch { .. } + | Self::HistoryAliasContextConflict { .. } | Self::TransactionBodyConflict { .. } | Self::UnknownWalletNetwork { .. } | Self::NetAmountOverflow { .. } @@ -840,9 +851,9 @@ impl WalletStorageError { Self::IdentityKeyWalletMismatch { .. } => PersistenceErrorKind::Constraint, // History invariants checked in Rust on the write path: the incoming // record contradicts stored history, so the data is wrong, not the engine. - Self::TransactionBodyConflict { .. } | Self::NetAmountOverflow { .. } => { - PersistenceErrorKind::Constraint - } + Self::HistoryAliasContextConflict { .. } + | Self::TransactionBodyConflict { .. } + | Self::NetAmountOverflow { .. } => PersistenceErrorKind::Constraint, // Refinery surfaces FK / constraint problems through rusqlite; // if that path leaks through here the typed variant lives in // `Self::Migration`, which we leave as `Fatal` since a @@ -984,6 +995,7 @@ impl WalletStorageError { Self::AssetLockEntryMismatch { .. } => "asset_lock_entry_mismatch", Self::AssetLockStatusMismatch { .. } => "asset_lock_status_mismatch", Self::CoreTransactionEntryMismatch { .. } => "core_transaction_entry_mismatch", + Self::HistoryAliasContextConflict { .. } => "history_alias_context_conflict", Self::TransactionBodyConflict { .. } => "transaction_body_conflict", Self::UnknownWalletNetwork { .. } => "unknown_wallet_network", Self::NetAmountOverflow { .. } => "net_amount_overflow", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs index 025b15007ef..f37ae520a5f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/load_ctx.rs @@ -329,6 +329,12 @@ impl LoadCtx { } } + /// Recheck an already-counted snapshot under the same policy without + /// adding its inconsistencies to the load's original tally again. + pub(crate) fn for_recheck(&self) -> Self { + Self::new(self.policy) + } + /// Context that aborts the load on any inconsistency. A production /// load takes its policy from the config; this is the shorthand for a /// caller driving a reader directly. diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 0a20a39d8ab..3eea6cc4768 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1619,13 +1619,17 @@ impl PlatformWalletPersistence for SqlitePersister { Option, PersistenceError, > { - let conn = self.conn().map_err(PersistenceError::from)?; + let mut conn = self.conn().map_err(PersistenceError::from)?; // Tally deliberately dropped — see `last_load_degradation`. The // policy still decides whether drift is fatal, and `tolerate` still // logs it. let ctx = LoadCtx::new(self.config.load_policy); - schema::core_state::get_tx_record(&conn, &wallet_id, txid, &ctx) - .map_err(PersistenceError::from) + // Alias evidence and its active canonical record must belong to one + // SQLite snapshot, including when another connection writes the file. + let tx = conn.transaction().map_err(WalletStorageError::from)?; + let record = schema::core_history_aliases::get_record(&tx, &wallet_id, txid, &ctx)?; + tx.commit().map_err(WalletStorageError::from)?; + Ok(record) } /// Served from the `dpns_name_states` table this persister already @@ -1719,9 +1723,11 @@ fn load_wallet_snapshot( let account_manifest = schema::accounts::load_state(conn, &wallet_id, ctx).map_err(PersistenceError::from)?; - let (core_state, utxo_accounts) = + let (mut core_state, utxo_accounts) = schema::core_state::load_state(conn, &wallet_id, network, ctx) .map_err(PersistenceError::from)?; + schema::core_history_aliases::project_records(conn, &wallet_id, &mut core_state.records, ctx) + .map_err(PersistenceError::from)?; // Pre-keyed rehydration: each `ManagedIdentity` leaves the loader // already carrying its own public keys + contact state (matching // the FFI persister), so signing works immediately post-load diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs index 5269eeff9b8..d2d5b0e78f9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history.rs @@ -13,7 +13,7 @@ use platform_wallet::wallet::platform_wallet::WalletId; use rusqlite::{params, Connection, OptionalExtension, Transaction}; use super::accounts::DASHPAY_EXTERNAL_LABEL; -use super::{blob, core_state, wallets}; +use super::{blob, core_history_aliases, core_state, wallets}; use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::LoadCtx; use crate::sqlite::util::safe_cast::i64_to_u64; @@ -24,10 +24,20 @@ pub(super) fn preserve_known_details( wallet_id: &WalletId, incoming: &TransactionRecord, ) -> Result { - let mut merged = incoming.clone(); + let merged = incoming.clone(); let Some(previous) = prior_record(tx, wallet_id, &incoming.txid)? else { return Ok(merged); }; + merge_known_details(wallet_id, &previous, incoming) +} + +/// Merge accounting evidence without assigning identity or finality policy. +pub(super) fn merge_known_details( + wallet_id: &WalletId, + previous: &TransactionRecord, + incoming: &TransactionRecord, +) -> Result { + let mut merged = incoming.clone(); // Compare only txid-committed content: a peer may attach BIP144 witnesses // to a known txid, and that must neither conflict nor replace the stored body. if previous.transaction.txid() != incoming.transaction.txid() { @@ -36,10 +46,11 @@ pub(super) fn preserve_known_details( txid: incoming.txid, }); } - merged.transaction = previous.transaction; + merged.transaction = previous.transaction.clone(); let mut inputs: BTreeMap<_, _> = previous .input_details - .into_iter() + .iter() + .cloned() .map(|d| (d.index, d)) .collect(); for detail in &incoming.input_details { @@ -47,7 +58,8 @@ pub(super) fn preserve_known_details( } let mut outputs: BTreeMap<_, _> = previous .output_details - .into_iter() + .iter() + .cloned() .map(|d| (d.index, d)) .collect(); for detail in &incoming.output_details { @@ -103,15 +115,39 @@ pub(crate) fn apply( let mut consumers = tx.prepare_cached( "SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2", )?; - for utxo in cs.new_utxos.iter().chain(&cs.spent_utxos) { - affected.insert(utxo.outpoint.txid); - let mut rows = consumers.query(params![ - wallet_id.as_slice(), - blob::encode_outpoint(&utxo.outpoint)? - ])?; - while let Some(row) = rows.next()? { - let bytes: Vec = row.get(0)?; - affected.insert(Txid::from_slice(&bytes)?); + let mut funding: Vec<_> = cs + .new_utxos + .iter() + .chain(&cs.spent_utxos) + .map(|utxo| utxo.outpoint) + .collect(); + // Adoption can connect already-stored coins to consumers without a new + // coin delivery. Ordinary records keep their existing scheduling policy. + for record in cs + .records + .iter() + .filter(|record| core_history_aliases::is_unlock(record)) + { + funding.extend( + record + .transaction + .output + .iter() + .enumerate() + .map(|(index, _)| OutPoint::new(record.txid, index as u32)), + ); + } + for changed in funding { + for outpoint in core_history_aliases::outpoints(tx, wallet_id, &changed)? { + affected.insert(outpoint.txid); + let mut rows = consumers.query(params![ + wallet_id.as_slice(), + blob::encode_outpoint(&outpoint)? + ])?; + while let Some(row) = rows.next()? { + let bytes: Vec = row.get(0)?; + affected.insert(Txid::from_slice(&bytes)?); + } } } if affected.is_empty() { @@ -144,6 +180,46 @@ fn owned_output( wallet_id: &WalletId, outpoint: &OutPoint, network: dashcore::Network, +) -> Result, WalletStorageError> { + // Exact physical evidence keeps its existing semantics. Only a missing + // exact coin can use a validated active history family's equivalent key. + if let Some(output) = physical_owned_output(tx, wallet_id, outpoint, network)? { + return Ok(Some(output)); + } + let equivalents = core_history_aliases::outpoints(tx, wallet_id, outpoint)?; + for alternative in equivalents.into_iter().filter(|other| other != outpoint) { + if let Some((value, address)) = physical_owned_output(tx, wallet_id, &alternative, network)? + { + let record = core_history_aliases::get_record( + tx, + wallet_id, + &outpoint.txid, + &LoadCtx::strict(), + )? + .ok_or_else(|| { + WalletStorageError::blob_decode( + "active history alias disappeared during accounting", + ) + })?; + let output = record.transaction.output.get(outpoint.vout as usize); + if !output.is_some_and(|output| { + output.value == value && output.script_pubkey == address.script_pubkey() + }) { + return Err(WalletStorageError::blob_decode( + "alias accounting output disagrees with canonical transaction body", + )); + } + return Ok(Some((value, address))); + } + } + Ok(None) +} + +fn physical_owned_output( + tx: &Transaction<'_>, + wallet_id: &WalletId, + outpoint: &OutPoint, + network: dashcore::Network, ) -> Result, WalletStorageError> { let encoded = blob::encode_outpoint(outpoint)?; let key = params![wallet_id.as_slice(), encoded]; @@ -265,23 +341,9 @@ fn repair_record( } record.input_details = inputs.into_values().collect(); record.output_details = outputs.into_values().collect(); - // The live projection computes the same accounting, so repair never - // flips a row it just wrote; only the overflow policy differs. - let (net, direction) = wallet_accounting(&record); - record.net_amount = match i64::try_from(net) { - Ok(net) => net, - // Nothing authenticates a mempool transaction: this is peer-supplied data, - // not corruption. Keep its stored accounting until representable evidence arrives. - Err(_) if matches!(record.context, TransactionContext::Mempool) => return Ok(()), - Err(_) => { - return Err(WalletStorageError::NetAmountOverflow { - wallet_id: *wallet_id, - txid: *txid, - value: net, - }) - } - }; - record.direction = direction; + if !recompute_accounting(wallet_id, &mut record)? { + return Ok(()); + } let repaired = blob::encode(&record)?; if repaired != original { // Append-only: the first pre-repair blob is kept verbatim and never @@ -304,6 +366,35 @@ fn repair_record( Ok(()) } +/// Apply the existing history accounting policy to a merged snapshot. Returns +/// false when absent or unrepresentable mempool evidence cannot replace it. +pub(super) fn recompute_accounting( + wallet_id: &WalletId, + record: &mut TransactionRecord, +) -> Result { + if record.input_details.is_empty() && record.output_details.is_empty() { + return Ok(false); + } + // The live projection computes the same accounting, so repair never + // flips a row it just wrote; only the overflow policy differs. + let (net, direction) = wallet_accounting(record); + record.net_amount = match i64::try_from(net) { + Ok(net) => net, + // Nothing authenticates a mempool transaction: this is peer-supplied data, + // not corruption. Keep its stored accounting until representable evidence arrives. + Err(_) if matches!(record.context, TransactionContext::Mempool) => return Ok(false), + Err(_) => { + return Err(WalletStorageError::NetAmountOverflow { + wallet_id: *wallet_id, + txid: record.txid, + value: net, + }) + } + }; + record.direction = direction; + Ok(true) +} + #[cfg(test)] mod tests { use dashcore::TxOut; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs new file mode 100644 index 00000000000..520b268dd99 --- /dev/null +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs @@ -0,0 +1,370 @@ +//! Historical AssetUnlock identities, confined to history projection and accounting. +//! +//! The original-record archive proves identity only: it never supplies ownership, +//! context or a resurrected record. An active canonical record is required. No +//! financial outpoint, claimant, proof or confirmation column is rewritten here. + +use std::collections::BTreeSet; + +use dashcore::hashes::Hash; +use dashcore::transaction::special_transaction::TransactionPayload; +use dashcore::{OutPoint, Txid}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use platform_wallet::changeset::most_advanced_transaction_context; +use platform_wallet::wallet::platform_wallet::WalletId; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; + +use super::{blob, core_history, core_state}; +use crate::sqlite::error::WalletStorageError; +use crate::sqlite::load_ctx::LoadCtx; + +pub(super) fn is_unlock(record: &TransactionRecord) -> bool { + (record.transaction.version as i16) >= 3 + && matches!(&record.transaction.special_transaction_payload, + Some(TransactionPayload::AssetUnlockPayloadType(payload)) if payload.base.version >= 2) +} + +fn validate(key: &Txid, payload: &[u8]) -> Result { + let record = core_state::decode_transaction_record(payload)?; + if record.txid != *key { + return Err(WalletStorageError::blob_decode( + "historical identity evidence disagrees with its physical key", + )); + } + Ok(record) +} + +fn archived( + conn: &Connection, + wallet: &WalletId, + key: &Txid, +) -> Result, WalletStorageError> { + let len: Option = conn.query_row( + "SELECT length(record_blob) FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), key.as_byte_array().as_slice()], |row| row.get(0), + ).optional()?; + let Some(len) = len else { return Ok(None) }; + blob::check_size(len)?; + let bytes: Vec = conn.query_row( + "SELECT record_blob FROM core_transaction_record_originals WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), key.as_byte_array().as_slice()], |row| row.get(0), + )?; + validate(key, &bytes).map(Some) +} + +struct Family { + record: TransactionRecord, + aliases: BTreeSet, + physical_aliases: Vec, +} + +impl Family { + /// Exceptional-path wallet scan: no schema change or unauthenticated alias + /// table. Current blobs supply metadata; archived blobs supply only IDs. + fn load( + conn: &Connection, + wallet: &WalletId, + canonical: &TransactionRecord, + ctx: &LoadCtx, + ) -> Result { + let canonical_id = canonical.transaction.txid(); + let mut family = Self { + record: canonical.clone(), + aliases: BTreeSet::from([canonical_id]), + physical_aliases: Vec::new(), + }; + // Query lengths first; no blob is materialized before its size gate. + let mut stmt = conn.prepare_cached( + "SELECT length(txid), txid, length(record_blob) FROM core_transactions WHERE wallet_id = ?1 AND record_blob IS NOT NULL ORDER BY txid", + )?; + let mut rows = stmt.query(params![wallet.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transactions.txid")?; + let key = Txid::from_slice(&row.get::<_, Vec>(1)?)?; + blob::check_size(row.get(2)?)?; + if key == canonical_id { + continue; + } + // The physical reader validates the body, key and context columns; + // public alias projection must never recursively call itself here. + let Some(record) = core_state::get_tx_record(conn, wallet, &key, ctx)? else { + continue; + }; + if is_unlock(&record) && record.transaction.txid() == canonical_id { + family.merge(wallet, &record)?; + family.aliases.insert(key); + family.physical_aliases.push(key); + } + } + let mut stmt = conn.prepare_cached( + "SELECT length(txid), txid FROM core_transaction_record_originals WHERE wallet_id = ?1 ORDER BY txid", + )?; + let mut rows = stmt.query(params![wallet.as_slice()])?; + while let Some(row) = rows.next()? { + blob::check_fixed_width(row.get(0)?, 32, "core_transaction_record_originals.txid")?; + let key = Txid::from_slice(&row.get::<_, Vec>(1)?)?; + let Some(record) = archived(conn, wallet, &key)? else { + continue; + }; + if is_unlock(&record) && record.transaction.txid() == canonical_id { + family.aliases.insert(key); + } + } + if !family.physical_aliases.is_empty() { + // A canonical row already present before adoption is also active + // evidence. With no legacy sibling, ordinary upsert policy applies. + if let Some(previous) = core_state::get_tx_record(conn, wallet, &canonical_id, ctx)? { + family.merge(wallet, &previous)?; + } + core_history::recompute_accounting(wallet, &mut family.record)?; + } + Ok(family) + } + + fn merge( + &mut self, + wallet: &WalletId, + previous: &TransactionRecord, + ) -> Result<(), WalletStorageError> { + if let (Some(old), Some(new)) = (previous.block_info(), self.record.block_info()) { + if old.height() != new.height() + || old.block_hash() != new.block_hash() + || old.timestamp() != new.timestamp() + || matches!((old.position(), new.position()), (Some(a), Some(b)) if a != b) + { + return Err(WalletStorageError::HistoryAliasContextConflict { + wallet_id: *wallet, + txid: self.record.txid, + }); + } + } + let context = + most_advanced_transaction_context(&self.record.context, &previous.context).clone(); + let body = self.record.transaction.clone(); + self.record = core_history::merge_known_details(wallet, previous, &self.record)?; + self.record.transaction = body; + self.record.context = context; + if self.record.fee.is_none() { + self.record.fee = previous.fee; + } + if self.record.label.is_empty() { + self.record.label.clone_from(&previous.label); + } + Ok(()) + } +} + +/// Adopt only validated current writes. Legacy-only reads remain physically keyed. +pub(super) fn adopt( + tx: &Transaction<'_>, + wallet: &WalletId, + incoming: &TransactionRecord, +) -> Result { + let canonical = core_history::preserve_known_details(tx, wallet, incoming)?; + if !is_unlock(&canonical) { + return Ok(canonical); + } + let family = Family::load(tx, wallet, &canonical, &LoadCtx::strict())?; + for alias in &family.physical_aliases { + // Append before clearing. Existing archive entries were validated above; + // the first original stays byte-for-byte immutable. + tx.execute( + "INSERT OR IGNORE INTO core_transaction_record_originals (wallet_id, txid, record_blob) SELECT wallet_id, txid, record_blob FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + // Keep the physical anchor: DELETE would clear durable claimant links + // through V014's trigger. Height/proof/finality metadata is untouched. + tx.execute( + "UPDATE core_transactions SET record_blob = NULL WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + tx.execute( + "DELETE FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![wallet.as_slice(), alias.as_byte_array().as_slice()], + )?; + } + Ok(family.record) +} + +/// Public history lookup. An archive entry alone can never resurrect history. +pub(crate) fn get_record( + conn: &Connection, + wallet: &WalletId, + key: &Txid, + ctx: &LoadCtx, +) -> Result, WalletStorageError> { + let physical = core_state::get_tx_record(conn, wallet, key, ctx)?; + let evidence = match &physical { + Some(record) => Some(record.clone()), + None => archived(conn, wallet, key)?, + }; + let Some(evidence) = evidence else { + return Ok(physical); + }; + if !is_unlock(&evidence) { + return Ok(physical); + } + let canonical_id = evidence.transaction.txid(); + let Some(canonical) = core_state::get_tx_record(conn, wallet, &canonical_id, ctx)? else { + return Ok(physical); + }; + Family::load(conn, wallet, &canonical, ctx).map(|family| Some(family.record)) +} + +/// Deduplicate only adopted families; backend readers keep physical row identities. +pub(crate) fn project_records( + conn: &Connection, + wallet: &WalletId, + records: &mut Vec, + ctx: &LoadCtx, +) -> Result<(), WalletStorageError> { + let recheck = ctx.for_recheck(); + let canonical: Vec<_> = records + .iter() + .filter(|record| is_unlock(record) && record.txid == record.transaction.txid()) + .cloned() + .collect(); + for record in canonical { + let family = Family::load(conn, wallet, &record, &recheck)?; + records.retain(|record| { + record.txid == family.record.txid || !family.aliases.contains(&record.txid) + }); + if let Some(slot) = records + .iter_mut() + .find(|record| record.txid == family.record.txid) + { + *slot = family.record; + } + } + Ok(()) +} + +/// Equivalent accounting lookup keys, never rewritten financial outpoints. +pub(super) fn outpoints( + conn: &Connection, + wallet: &WalletId, + outpoint: &OutPoint, +) -> Result, WalletStorageError> { + let Some(record) = get_record(conn, wallet, &outpoint.txid, &LoadCtx::strict())? else { + return Ok(vec![*outpoint]); + }; + if !is_unlock(&record) || record.txid != record.transaction.txid() { + return Ok(vec![*outpoint]); + } + let family = Family::load(conn, wallet, &record, &LoadCtx::strict())?; + Ok(std::iter::once(*outpoint) + .chain( + family + .aliases + .into_iter() + .filter(|id| *id != outpoint.txid) + .map(|id| OutPoint::new(id, outpoint.vout)), + ) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sqlite::migrations; + use crate::LoadPolicy; + + #[test] + fn should_project_existing_alias_duplicates_without_mutating_physical_history() { + let wallet = [0x65; 32]; + let mut conn = Connection::open_in_memory().unwrap(); + migrations::run_for_open(&mut conn, LoadPolicy::Strict).unwrap(); + conn.execute( + "INSERT INTO wallets (wallet_id, network, birth_height) VALUES (?1, 'testnet', 0)", + params![wallet.as_slice()], + ) + .unwrap(); + let legacy: TransactionRecord = blob::decode( + &hex::decode( + include_str!("../../../tests/fixtures/v019_asset_unlock_v2.txt") + .lines() + .next() + .unwrap() + .split_whitespace() + .nth(2) + .unwrap(), + ) + .unwrap(), + ) + .unwrap(); + let mut canonical = legacy.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.output_details.clear(); + canonical.net_amount = 0; + for record in [&legacy, &canonical] { + conn.execute("INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(record).unwrap()]).unwrap(); + } + let (mut raw, _) = core_state::load_state( + &conn, + &wallet, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!( + raw.records.len(), + 2, + "backend readers keep exact physical identities" + ); + project_records(&conn, &wallet, &mut raw.records, &LoadCtx::strict()).unwrap(); + assert_eq!(raw.records.len(), 1); + assert_eq!(raw.records[0].txid, canonical.txid); + assert_eq!(raw.records[0].output_details.len(), 1); + assert_eq!(raw.records[0].net_amount, 150_000); + for id in [legacy.txid, canonical.txid] { + let projected = get_record(&conn, &wallet, &id, &LoadCtx::strict()) + .unwrap() + .unwrap(); + assert_eq!( + blob::encode(&projected).unwrap(), + blob::encode(&raw.records[0]).unwrap() + ); + } + let (physical, _) = core_state::load_state( + &conn, + &wallet, + dashcore::Network::Testnet, + &LoadCtx::strict(), + ) + .unwrap(); + assert_eq!(physical.records.len(), 2); + assert_eq!( + core_state::get_tx_record(&conn, &wallet, &canonical.txid, &LoadCtx::strict()) + .unwrap() + .unwrap() + .output_details + .len(), + 0 + ); + assert_eq!( + conn.query_row::( + "SELECT count(*) FROM core_transaction_record_originals", + [], + |row| row.get(0) + ) + .unwrap(), + 0 + ); + conn.execute( + "UPDATE core_transactions SET height = 999 WHERE txid = ?1", + params![legacy.txid.as_byte_array().as_slice()], + ) + .unwrap(); + let recovery = LoadCtx::recovery(); + let (mut raw, _) = + core_state::load_state(&conn, &wallet, dashcore::Network::Testnet, &recovery).unwrap(); + let before = recovery.degradation().total; + assert_eq!(before, 1); + project_records(&conn, &wallet, &mut raw.records, &recovery).unwrap(); + assert_eq!( + recovery.degradation().total, + before, + "projection must not count a previously reported physical-row drift again" + ); + } +} diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index 035118ff694..4337ef009a9 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -18,7 +18,7 @@ use crate::sqlite::error::WalletStorageError; use crate::sqlite::load_ctx::{LoadCtx, LoadSite}; use crate::sqlite::schema::blob::impl_persistable_blob; use crate::sqlite::schema::core_pool::{owning_account_for_script, OwningAccount}; -use crate::sqlite::schema::{blob, core_history}; +use crate::sqlite::schema::{blob, core_history, core_history_aliases}; // PUBLIC material only: core-chain state reaching `record_blob` / // `islock_blob` (transaction records + InstantLocks are public chain data). @@ -95,7 +95,7 @@ pub fn apply( )?; for incoming in &cs.records { ensure_current_transaction_identity(incoming)?; - let record = core_history::preserve_known_details(tx, wallet_id, incoming)?; + let record = core_history_aliases::adopt(tx, wallet_id, incoming)?; let block_info = record.block_info(); let height = block_info.map(|b| i64::from(b.height())); let block_hash = block_info.map(|b| AsRef::<[u8]>::as_ref(&b.block_hash()).to_vec()); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs index ad4bdbb387b..8f03fe15847 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/mod.rs @@ -11,6 +11,7 @@ pub mod asset_locks; pub mod blob; pub mod contacts; pub(crate) mod core_history; +pub(crate) mod core_history_aliases; pub mod core_pool; pub mod core_state; pub mod dashpay; diff --git a/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt b/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt new file mode 100644 index 00000000000..ab1eb86ec91 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/fixtures/v019_asset_unlock_v2_signed.txt @@ -0,0 +1,2 @@ +asset_unlock_v2 d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928 03000001fcf04902001976a9143a573b102166c333339a24746760b144c3e2fcb988ac01080247fbe803c82031313131313131313131313131313131313131313131313131313131313131316042424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424242424220d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928000000000700000100000122795264765945327a47534873687751376a6765555034553850396a5a4c7136337a63fcf0490200fce09304000000 +asset_unlock_v2_spend 19cf59a88628808e9019bd69ce57a5e057228cdc88f37830ddd77002ac65f86f 02000120d2bcf5e3734674383b4a2d3b82073a55732c390d9f6e830eb4249c1f96be1928006b483045022100f71f93ce5c42c6b3f4e4ae5078ac40dd28922ff38fb452e1e0cde866c0042f3402201a0d18b9d059b1686ae4259ed3fb82edae66fcbf0f351df4749321654c791db0012102e4d24944ed947bc34ed14ea4b2e063aa27ebd8da658577a263f55a631c7f656afcffffffff0001fc0846020000002019cf59a88628808e9019bd69ce57a5e057228cdc88f37830ddd77002ac65f86f0000000000010100fcf049020022795264765945327a47534873687751376a6765555034553850396a5a4c7136337a6300fcdf9304000000 diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 0f0a6c6f051..2adf540122a 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -44,6 +44,7 @@ mod sqlite_error_classification; mod sqlite_fk_changeset_ordering; mod sqlite_foreign_db_rejection; mod sqlite_foreign_keys; +mod sqlite_history_aliases; mod sqlite_identity_hard_delete; mod sqlite_identity_index_concurrency; mod sqlite_identity_index_uniqueness; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs new file mode 100644 index 00000000000..d06ac9c0009 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -0,0 +1,828 @@ +//! Canonical redelivery of a migrated historical AssetUnlock identity. +//! Both literal fixtures were emitted by d631aaca3632cc713143476e363c9a9aca038d2e. +//! The signed fixture uses the public test key [0x19; 32] and SIGHASH_ALL; +//! signature preservation is tested independently of chain/quorum validity. + +use crate::common::{ensure_wallet_meta, fresh_persister, wid}; +use dashcore::hashes::Hash; +use dashcore::sighash::SighashCache; +use dashcore::{consensus, Address, Network, OutPoint, Txid}; +use dpp::secp256k1::{ecdsa, ecdsa::Signature, Message, PublicKey}; +use key_wallet::managed_account::transaction_record::TransactionRecord; +use key_wallet::Utxo; +use platform_wallet::changeset::{ + CoreChangeSet, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet_storage::sqlite::schema::blob; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use rusqlite::params; + +fn migrated_history() -> ( + SqlitePersister, + tempfile::TempDir, + TransactionRecord, + TransactionRecord, +) { + migrated_fixture(include_str!("../fixtures/v019_asset_unlock_v2.txt")) +} + +fn migrated_fixture( + fixture: &str, +) -> ( + SqlitePersister, + tempfile::TempDir, + TransactionRecord, + TransactionRecord, +) { + let (persister, temp, path) = fresh_persister(); + let wallet = wid(0x79); + ensure_wallet_meta(&persister, &wallet); + let mut records = Vec::new(); + { + let conn = persister.lock_conn_for_test(); + conn.execute_batch( + "DROP TABLE core_transaction_inputs; DROP TABLE core_transaction_record_originals; \ + DELETE FROM refinery_schema_history WHERE version >= 20;", + ) + .unwrap(); + for line in fixture.lines() { + let fields: Vec<_> = line.split_whitespace().collect(); + let bytes = hex::decode(fields[2]).unwrap(); + let mut record: TransactionRecord = blob::decode(&bytes).unwrap(); + assert_eq!( + record.txid, + Txid::from_slice(&hex::decode(fields[1]).unwrap()).unwrap() + ); + // Remove only the test copy's accounting evidence: raw consumer bytes stay literal. + if !record.transaction.input.is_empty() { + record.input_details.clear(); + record.net_amount = 0; + } + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), blob::encode(&record).unwrap()], + ).unwrap(); + records.push(record); + } + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, 19); + } + drop(persister); + let migrated = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + { + let conn = migrated.lock_conn_for_test(); + let version: i64 = conn + .query_row( + "SELECT max(version) FROM refinery_schema_history", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(version, 20); + } + let consumer = records.pop().unwrap(); + let funding = records.pop().unwrap(); + assert_ne!(funding.txid, funding.transaction.txid()); + assert_eq!( + consumer.transaction.input[0].previous_output, + OutPoint::new(funding.txid, 0) + ); + (migrated, temp, funding, consumer) +} + +fn canonical_redelivery(persister: &SqlitePersister, funding: &TransactionRecord, with_coin: bool) { + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.output_details.clear(); + let new_utxos = if with_coin { + let output = canonical.transaction.output[0].clone(); + vec![Utxo { + outpoint: OutPoint::new(canonical.txid, 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }] + } else { + vec![] + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + new_utxos, + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); +} + +fn assert_consumer_bytes_and_raw_index_unchanged( + persister: &SqlitePersister, + original: &TransactionRecord, +) { + let loaded = persister + .get_core_tx_record(wid(0x79), &original.txid) + .unwrap() + .unwrap(); + assert_eq!( + consensus::serialize(&loaded.transaction), + consensus::serialize(&original.transaction) + ); + let conn = persister.lock_conn_for_test(); + let indexed: Vec = conn + .query_row( + "SELECT outpoint FROM core_transaction_inputs WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + original.txid.as_byte_array().as_slice() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + blob::decode_outpoint(&indexed).unwrap(), + original.transaction.input[0].previous_output + ); +} + +#[test] +fn should_keep_one_history_record_after_migration_and_canonical_redelivery() { + let (persister, _temp, funding, consumer) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let conn = persister.lock_conn_for_test(); + let count: i64 = conn.query_row( + "SELECT count(*) FROM core_transactions WHERE wallet_id = ?1 AND txid IN (?2, ?3) AND record_blob IS NOT NULL", + params![wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice(), funding.transaction.txid().as_byte_array().as_slice()], + |row| row.get(0), + ).unwrap(); + assert_eq!( + count, 1, + "one body must not become two history records through a hash-policy upgrade" + ); +} + +#[test] +fn should_repair_a_legacy_raw_prevout_when_canonical_funding_arrives() { + let (persister, _temp, funding, consumer) = migrated_history(); + canonical_redelivery(&persister, &funding, true); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let loaded = persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap(); + assert_eq!( + loaded.net_amount, -150_000, + "canonical funding must reach the raw legacy prevout's accounting" + ); + assert_eq!(loaded.input_details.len(), 1); +} + +#[test] +fn should_merge_owned_details_when_alias_and_canonical_history_already_coexist() { + let (persister, _temp, funding, consumer) = migrated_history(); + let mut partial = funding.clone(); + partial.txid = partial.transaction.txid(); + partial.output_details.clear(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT INTO core_transactions (wallet_id, txid, finalized, record_blob) VALUES (?1, ?2, 0, ?3)", + params![wid(0x79).as_slice(), partial.txid.as_byte_array().as_slice(), blob::encode(&partial).unwrap()], + ).unwrap(); + } + canonical_redelivery(&persister, &funding, false); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + let loaded = persister + .get_core_tx_record(wid(0x79), &partial.txid) + .unwrap() + .unwrap(); + assert_eq!( + loaded.output_details.len(), + 1, + "known ownership must survive alias reconciliation" + ); +} + +fn seed_record(persister: &SqlitePersister, wallet: [u8; 32], record: &TransactionRecord) { + let conn = persister.lock_conn_for_test(); + conn.execute( + "INSERT OR REPLACE INTO core_transactions (wallet_id, txid, height, block_hash, block_time, finalized, record_blob) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![wallet.as_slice(), record.txid.as_byte_array().as_slice(), record.block_info().map(|b| b.height()), record.block_info().map(|b| b.block_hash().to_byte_array().to_vec()), record.block_info().map(|b| b.timestamp()), record.block_info().is_some(), blob::encode(record).unwrap()], + ).unwrap(); +} + +fn confirmed() -> key_wallet::transaction_checking::TransactionContext { + key_wallet::transaction_checking::TransactionContext::InBlock( + key_wallet::transaction_checking::BlockInfo::new( + 42, + dashcore::BlockHash::from_byte_array([42; 32]), + 420, + ), + ) +} + +#[test] +fn should_preserve_active_confirmed_alias_context_on_late_mempool_adoption() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + funding.fee = Some(1_000); + funding.label = "old funding".to_owned(); + seed_record(&persister, wid(0x79), &funding); + let mut late = funding.clone(); + late.context = key_wallet::transaction_checking::TransactionContext::Mempool; + late.fee = None; + late.label.clear(); + canonical_redelivery(&persister, &late, false); + let loaded = persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.context, funding.context); + assert_eq!(loaded.fee, funding.fee); + assert_eq!(loaded.label, funding.label); +} + +#[test] +fn should_keep_chainlocked_alias_context_when_canonical_snapshot_is_only_in_block() { + let (persister, _temp, mut funding, _) = migrated_history(); + let block = *confirmed().block_info().unwrap(); + funding.context = + key_wallet::transaction_checking::TransactionContext::InChainLockedBlock(block); + seed_record(&persister, wid(0x79), &funding); + let mut late = funding.clone(); + late.context = key_wallet::transaction_checking::TransactionContext::InBlock(block); + canonical_redelivery(&persister, &late, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + funding.context + ); +} + +fn snapshot(persister: &SqlitePersister, table: &str) -> Vec> { + let conn = persister.lock_conn_for_test(); + let mut stmt = conn + .prepare(&format!("SELECT * FROM {table} ORDER BY 1, 2")) + .unwrap(); + let columns = stmt.column_count(); + stmt.query_map([], |row| (0..columns).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() +} + +#[test] +fn should_resolve_multiple_legacy_instances_without_mutating_archived_originals() { + let (persister, temp, funding, consumer) = migrated_history(); + let mut second = funding.clone(); + if let Some( + dashcore::transaction::special_transaction::TransactionPayload::AssetUnlockPayloadType( + payload, + ), + ) = &mut second.transaction.special_transaction_payload + { + payload.quorum_sig = [0x43; 96].into(); + } + second.txid = second.transaction.instance_hash(); + assert_ne!(second.txid, funding.txid); + assert_eq!(second.transaction.txid(), funding.transaction.txid()); + seed_record(&persister, wid(0x79), &second); + canonical_redelivery(&persister, &funding, false); + let archive = snapshot(&persister, "core_transaction_record_originals"); + let rows = snapshot(&persister, "core_transactions"); + canonical_redelivery(&persister, &second, false); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); + assert_eq!(snapshot(&persister, "core_transactions"), rows); + drop(persister); + let persister = + SqlitePersister::open(SqlitePersisterConfig::new(temp.path().join("wallet.db"))).unwrap(); + for id in [funding.txid, second.txid, funding.transaction.txid()] { + let record = persister + .get_core_tx_record(wid(0x79), &id) + .unwrap() + .unwrap(); + assert_eq!(record.txid, funding.transaction.txid()); + assert_eq!(record.transaction, funding.transaction); + assert_eq!(record.output_details.len(), 1); + } + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); +} + +#[test] +fn should_preserve_claims_and_physical_metadata_anchors_during_history_adoption() { + let (persister, _temp, funding, _) = migrated_history(); + let output = funding.transaction.output[0].clone(); + let utxo = Utxo { + outpoint: OutPoint::new(funding.txid, 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![utxo], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_utxos SET spent = 1, spent_in_txid = ?1, winner_mined_height = 17", + params![funding.txid.as_byte_array().as_slice()], + ) + .unwrap(); + } + let before = snapshot(&persister, "core_utxos"); + canonical_redelivery(&persister, &funding, false); + assert_eq!(snapshot(&persister, "core_utxos"), before); + let conn = persister.lock_conn_for_test(); + let anchor: (Option>, Option, i64) = conn.query_row( + "SELECT record_blob, height, finalized FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice()], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ).unwrap(); + assert_eq!(anchor, (None, None, 0)); +} + +#[test] +fn should_not_resurrect_history_or_metadata_from_archive_after_canonical_removal() { + let (persister, _temp, funding, _) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "DELETE FROM core_transactions WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + funding.transaction.txid().as_byte_array().as_slice() + ], + ) + .unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .is_none()); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .unwrap() + .is_none()); + canonical_redelivery(&persister, &funding, false); + assert!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .output_details + .is_empty(), + "archive proves identity, never ownership" + ); +} + +#[test] +fn should_rollback_conflicting_confirmed_alias_contexts() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + let before = snapshot(&persister, "core_transactions"); + let archive = snapshot(&persister, "core_transaction_record_originals"); + let mut incoming = funding.clone(); + incoming.txid = incoming.transaction.txid(); + incoming.context = key_wallet::transaction_checking::TransactionContext::InBlock( + key_wallet::transaction_checking::BlockInfo::new( + 43, + dashcore::BlockHash::from_byte_array([43; 32]), + 430, + ), + ); + let error = persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![incoming], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap_err(); + let platform_wallet::changeset::PersistenceError::Backend { source, .. } = error else { + panic!("backend error expected") + }; + assert!(matches!( + source.downcast_ref::(), + Some(platform_wallet_storage::WalletStorageError::HistoryAliasContextConflict { .. }) + )); + assert_eq!(snapshot(&persister, "core_transactions"), before); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); +} + +#[test] +fn should_reject_corrupt_alias_evidence_in_strict_and_recovery_without_writes() { + let (persister, temp, funding, _) = migrated_history(); + canonical_redelivery(&persister, &funding, false); + { + let conn = persister.lock_conn_for_test(); + let mut tampered = funding.clone(); + tampered.transaction.output[0].value += 1; + conn.execute("UPDATE core_transaction_record_originals SET record_blob = ?1 WHERE wallet_id = ?2 AND txid = ?3", params![blob::encode(&tampered).unwrap(), wid(0x79).as_slice(), funding.txid.as_byte_array().as_slice()]).unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &funding.txid) + .is_err()); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .is_err()); + let before = snapshot(&persister, "core_transactions"); + let mut incoming = funding.clone(); + incoming.txid = incoming.transaction.txid(); + assert!(persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![incoming], + ..Default::default() + }), + ..Default::default() + } + ) + .is_err()); + assert_eq!(snapshot(&persister, "core_transactions"), before); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(temp.path().join("wallet.db")) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + assert!(recovery + .get_core_tx_record(wid(0x79), &funding.txid) + .is_err()); + assert!(recovery + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .is_err()); + assert_eq!(snapshot(&recovery, "core_transactions"), before); +} + +#[test] +fn should_keep_alias_projection_wallet_scoped_and_legacy_only_identity_unchanged() { + let (persister, _temp, funding, _) = migrated_history(); + let other = wid(0x80); + ensure_wallet_meta(&persister, &other); + seed_record(&persister, other, &funding); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.txid + ); + assert!(persister + .get_core_tx_record(wid(0x79), &funding.transaction.txid()) + .unwrap() + .is_none()); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(other, &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.txid + ); + assert!(persister + .get_core_tx_record(other, &funding.transaction.txid()) + .unwrap() + .is_none()); +} + +fn assert_signed_consumer(funding: &TransactionRecord, consumer: &TransactionRecord) { + let pushes: Vec<_> = consumer.transaction.input[0] + .script_sig + .instructions() + .map(|instruction| { + instruction + .unwrap() + .push_bytes() + .unwrap() + .as_bytes() + .to_vec() + }) + .collect(); + assert_eq!(pushes.len(), 2); + assert_eq!(*pushes[0].last().unwrap(), 1, "SIGHASH_ALL"); + let signature = Signature::from_der(&pushes[0][..pushes[0].len() - 1]).unwrap(); + let public = PublicKey::from_slice(&pushes[1]).unwrap(); + let script = Address::p2pkh( + &dashcore::PublicKey::from_slice(&pushes[1]).unwrap(), + Network::Testnet, + ) + .script_pubkey(); + assert_eq!(script, funding.transaction.output[0].script_pubkey); + let hash = SighashCache::new(&consumer.transaction) + .legacy_signature_hash(0, &script, 1) + .unwrap(); + ecdsa::verify( + &signature, + Message::from_digest(hash.to_byte_array()), + &public, + ) + .unwrap(); + let mut rekeyed = consumer.transaction.clone(); + rekeyed.input[0].previous_output.txid = funding.transaction.txid(); + let changed_hash = SighashCache::new(&rekeyed) + .legacy_signature_hash(0, &script, 1) + .unwrap(); + assert!( + ecdsa::verify( + &signature, + Message::from_digest(changed_hash.to_byte_array()), + &public + ) + .is_err(), + "rewriting the legacy prevout would invalidate this signature" + ); +} + +#[test] +fn should_preserve_known_key_signed_consumer_through_migration_and_redelivery() { + let fixture = include_str!("../fixtures/v019_asset_unlock_v2_signed.txt"); + let originals: Vec = fixture + .lines() + .map(|line| { + blob::decode(&hex::decode(line.split_whitespace().nth(2).unwrap()).unwrap()).unwrap() + }) + .collect(); + assert_signed_consumer(&originals[0], &originals[1]); + let (persister, _temp, funding, consumer) = migrated_fixture(fixture); + canonical_redelivery(&persister, &funding, true); + let loaded = persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap(); + assert_signed_consumer(&funding, &loaded); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + assert_eq!( + consensus::serialize(&loaded.transaction), + consensus::serialize(&originals[1].transaction) + ); + assert_eq!(loaded.net_amount, -150_000); +} + +#[test] +fn should_preserve_active_canonical_context_while_adopting_existing_legacy_sibling() { + let (persister, _temp, funding, _) = migrated_history(); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.context = confirmed(); + seed_record(&persister, wid(0x79), &canonical); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + canonical.context + ); +} + +#[test] +fn should_accept_same_block_promotion_with_newly_known_position() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + canonical.context = key_wallet::transaction_checking::TransactionContext::InChainLockedBlock( + confirmed().block_info().unwrap().with_position(7), + ); + canonical_redelivery(&persister, &canonical, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .context, + canonical.context + ); +} + +#[test] +fn should_not_promote_later_canonical_context_from_archived_or_blobless_alias() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.context = confirmed(); + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + funding.context = key_wallet::transaction_checking::TransactionContext::Mempool; + canonical_redelivery(&persister, &funding, false); + let loaded = persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.context, key_wallet::transaction_checking::TransactionContext::Mempool, + "ordinary canonical upsert keeps its existing policy; archive/anchors never promote context"); +} + +#[test] +fn should_repair_existing_canonical_coin_consumers_when_alias_is_adopted_without_coin_redelivery() { + let (persister, _temp, funding, consumer) = migrated_history(); + let output = funding.transaction.output[0].clone(); + let coin = Utxo { + outpoint: OutPoint::new(funding.transaction.txid(), 0), + address: Address::from_script(&output.script_pubkey, Network::Testnet).unwrap(), + txout: output, + height: 0, + is_coinbase: false, + is_confirmed: false, + is_instantlocked: false, + is_locked: false, + is_trusted: false, + }; + persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + new_utxos: vec![coin], + ..Default::default() + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap() + .net_amount, + 0 + ); + let coins = snapshot(&persister, "core_utxos"); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &consumer.txid) + .unwrap() + .unwrap() + .net_amount, + -150_000 + ); + assert_eq!(snapshot(&persister, "core_utxos"), coins); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); +} + +#[test] +fn should_preserve_unrepresentable_mempool_accounting_during_alias_adoption() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.transaction.output[0].value = u64::MAX; + funding.output_details[0].value = u64::MAX; + funding.txid = funding.transaction.instance_hash(); + funding.net_amount = 123; + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .net_amount, + 123 + ); +} + +#[test] +fn should_not_recompute_alias_accounting_without_owned_details() { + let (persister, _temp, mut funding, _) = migrated_history(); + funding.output_details.clear(); + funding.net_amount = 123; + seed_record(&persister, wid(0x79), &funding); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .net_amount, + 123 + ); +} + +#[test] +fn should_rollback_archiving_and_anchor_changes_when_canonical_upsert_fails_then_retry() { + let (persister, _temp, funding, consumer) = migrated_history(); + let before = snapshot(&persister, "core_transactions"); + let archive = snapshot(&persister, "core_transaction_record_originals"); + { + let conn = persister.lock_conn_for_test(); + conn.execute_batch("CREATE TEMP TRIGGER reject_canonical_insert BEFORE INSERT ON core_transactions BEGIN SELECT RAISE(ABORT, 'injected canonical upsert failure'); END;").unwrap(); + } + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + assert!(persister + .store( + wid(0x79), + PlatformWalletChangeSet { + core: Some(CoreChangeSet { + records: vec![canonical], + ..Default::default() + }), + ..Default::default() + } + ) + .is_err()); + assert_eq!(snapshot(&persister, "core_transactions"), before); + assert_eq!( + snapshot(&persister, "core_transaction_record_originals"), + archive + ); + assert_consumer_bytes_and_raw_index_unchanged(&persister, &consumer); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER reject_canonical_insert;") + .unwrap(); + canonical_redelivery(&persister, &funding, false); + assert_eq!( + persister + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap() + .txid, + funding.transaction.txid() + ); +} + +#[test] +fn should_preserve_recovery_height_drift_policy_during_alias_projection() { + let (persister, temp, funding, _) = migrated_history(); + let mut canonical = funding.clone(); + canonical.txid = canonical.transaction.txid(); + seed_record(&persister, wid(0x79), &canonical); + { + let conn = persister.lock_conn_for_test(); + conn.execute( + "UPDATE core_transactions SET height = 999 WHERE wallet_id = ?1 AND txid = ?2", + params![ + wid(0x79).as_slice(), + funding.txid.as_byte_array().as_slice() + ], + ) + .unwrap(); + } + assert!(persister + .get_core_tx_record(wid(0x79), &canonical.txid) + .is_err()); + let before = snapshot(&persister, "core_transactions"); + drop(persister); + let recovery = SqlitePersister::open( + SqlitePersisterConfig::new(temp.path().join("wallet.db")) + .with_load_policy(platform_wallet_storage::LoadPolicy::Recovery), + ) + .unwrap(); + let loaded = recovery + .get_core_tx_record(wid(0x79), &funding.txid) + .unwrap() + .unwrap(); + assert_eq!(loaded.txid, canonical.txid); + assert_eq!(loaded.context, funding.context); + assert_eq!(snapshot(&recovery, "core_transactions"), before); +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 50015a539e2..b7f3112fbaf 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -571,9 +571,7 @@ fn fold_groups(records: &mut Vec) { // The funding slice is not necessarily the newest one — a // group can pair a stale `Mempool` sighting with the // confirmed snapshot of the same transaction. - if context_rank(&r.context) > context_rank(&merged.context) { - merged.context = r.context.clone(); - } + merged.context = most_advanced_transaction_context(&merged.context, &r.context).clone(); } // The base record's own index was skipped by the `i != base_pos` // guard above; drop every group member except the fold's output @@ -667,6 +665,21 @@ pub(crate) fn coalesce_account_records(records: &mut Vec) { } } +/// Select the more advanced observation of the same transaction, retaining +/// `current` on a tie. This merges metadata only; it does not verify finality or +/// resolve conflicting blocks. Callers must establish transaction identity and +/// compatible block observations before using it. +pub fn most_advanced_transaction_context<'a>( + current: &'a key_wallet::transaction_checking::TransactionContext, + incoming: &'a key_wallet::transaction_checking::TransactionContext, +) -> &'a key_wallet::transaction_checking::TransactionContext { + if context_rank(incoming) > context_rank(current) { + incoming + } else { + current + } +} + /// Rank a [`TransactionContext`](key_wallet::transaction_checking::TransactionContext) /// by how far along the confirmation lifecycle the observation is. /// Used by [`fold_same_txid_records`] so a fold never regresses a diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index 11aefd51976..358a46efd83 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -34,16 +34,17 @@ mod wallet_accounting; pub(crate) use changeset::account_address_pool_entries; pub use changeset::{ - upsert_pending_contact_crypto, AccountAddressPoolEntry, AccountRegistrationEntry, - AssetLockChangeSet, AssetLockEntry, ContactChangeSet, ContactRequestEntry, CoreChangeSet, - DpnsNameSaleStatus, DpnsNameStateChangeSet, DpnsNameStateEntry, HighestUsedIndexes, - IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, - IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, - KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, - PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, - PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey, - SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + most_advanced_transaction_context, upsert_pending_contact_crypto, AccountAddressPoolEntry, + AccountRegistrationEntry, AssetLockChangeSet, AssetLockEntry, ContactChangeSet, + ContactRequestEntry, CoreChangeSet, DpnsNameSaleStatus, DpnsNameStateChangeSet, + DpnsNameStateEntry, HighestUsedIndexes, IdentityChangeSet, IdentityEntry, + IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, InvitationChangeSet, + InvitationEntry, InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, + PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, + PendingContactCryptoOp, PlatformAddressBalanceEntry, PlatformAddressChangeSet, + PlatformWalletChangeSet, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + ProviderPlatformNodePubKey, ReceivedContactRequestKey, SentContactRequestKey, + TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; From 3e2e29dc632e931f471af26f60fd3f8e7508dbb1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:59:24 +0000 Subject: [PATCH 71/84] test(platform-wallet): cover unverified SPV lock persistence boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exercise live SPV delivery before and after its signed transaction with an in-process transport, then persist and reopen through checked replay. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- Cargo.lock | 1 + .../rs-platform-wallet-storage/Cargo.toml | 1 + .../tests/integration/main.rs | 2 + .../sqlite_unverified_lock_producer.rs | 292 ++++++++++++++++++ 4 files changed, 296 insertions(+) create mode 100644 packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs diff --git a/Cargo.lock b/Cargo.lock index 1c52bec888c..d16a795126a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5497,6 +5497,7 @@ dependencies = [ name = "platform-wallet-storage" version = "5.0.3" dependencies = [ + "async-trait", "apple-native-keyring-store", "argon2", "assert_cmd", diff --git a/packages/rs-platform-wallet-storage/Cargo.toml b/packages/rs-platform-wallet-storage/Cargo.toml index 449191b3a43..072f7b6e009 100644 --- a/packages/rs-platform-wallet-storage/Cargo.toml +++ b/packages/rs-platform-wallet-storage/Cargo.toml @@ -161,6 +161,7 @@ apple-native-keyring-store = { version = "=1.0.0", features = ["keychain"], opti windows-native-keyring-store = { version = "=1.0.0", optional = true } [dev-dependencies] +async-trait = "=0.1.89" dash-spv = { workspace = true, features = ["test-utils"] } # `test-utils` reaches `provider_key_test_wallet`, shared with # `platform-wallet`'s own `rebuild_provider_key_account` tests — see its use diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 5df1d7b35c5..a0d66d6a319 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -94,3 +94,5 @@ mod sqlite_core_rescan; mod sqlite_replay_migration; mod sqlite_shared_history_replay; + +mod sqlite_unverified_lock_producer; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs new file mode 100644 index 00000000000..67c147c8d51 --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs @@ -0,0 +1,292 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use dash_spv::error::NetworkResult; +use dash_spv::network::{Message, MessageType, NetworkEvent, NetworkManager, RequestSender}; +use dash_spv::sync::SyncEvent; +use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; +use dash_spv::{ClientConfig, DashSpvClient, EventHandler}; +use dashcore::network::message::NetworkMessage; +use dashcore::{hashes::Hash, Network, OutPoint, Txid}; +use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use platform_wallet::changeset::recorded_history::ReplayEvidence; +use platform_wallet::changeset::PlatformWalletPersistence; +use platform_wallet::key_wallet_manager::{WalletEvent, WalletManager}; +use platform_wallet::test_support::core_fixtures::{ + outside, pay, spend_of, wallet_with_receive_address, +}; +use platform_wallet::wallet::platform_wallet::PlatformWalletInfo; +use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; +use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; + +use crate::common::{ + authorize, fresh_persister, mine, persist_events, registration, secure_tempdir, +}; + +/// Shared handle to the existing in-process transport. No method opens sockets or resolves DNS. +#[derive(Clone)] +struct InjectedNetwork { + inner: Arc>, + requests: RequestSender, + events: broadcast::Sender, +} + +impl InjectedNetwork { + fn new() -> Self { + let inner = MockNetworkManager::new(); + Self { + requests: inner.request_sender(), + inner: Arc::new(Mutex::new(inner)), + events: broadcast::channel(16).0, + } + } +} + +#[async_trait::async_trait] +impl NetworkManager for InjectedNetwork { + async fn message_receiver( + &mut self, + types: &[MessageType], + ) -> mpsc::UnboundedReceiver { + self.inner.lock().await.message_receiver(types).await + } + fn request_sender(&self) -> RequestSender { + self.requests.clone() + } + async fn start(&mut self) -> NetworkResult<()> { + self.inner.lock().await.start().await + } + async fn stop(&mut self) { + self.inner.lock().await.stop().await; + } + async fn send_message(&mut self, message: NetworkMessage) -> NetworkResult<()> { + self.inner.lock().await.send_message(message).await + } + fn peer_count(&self) -> usize { + 1 + } + async fn broadcast(&self, _message: NetworkMessage) -> NetworkResult<()> { + Ok(()) + } + async fn dispatch_local(&self, message: NetworkMessage) { + self.inner.lock().await.dispatch_local(message).await; + } + async fn disconnect_peer(&self, _address: &SocketAddr, _reason: &str) -> NetworkResult<()> { + Ok(()) + } + fn subscribe_network_events(&self) -> broadcast::Receiver { + self.events.subscribe() + } +} + +#[derive(Debug)] +enum Notice { + Detected(Txid), + Locked(Txid), + Proof(Txid, bool), + Error(String), +} + +struct Observer(mpsc::UnboundedSender); +impl EventHandler for Observer { + fn on_sync_event(&self, event: &SyncEvent) { + if let SyncEvent::InstantLockReceived { + instant_lock, + validated, + } = event + { + let _ = self.0.send(Notice::Proof(instant_lock.txid, *validated)); + } + } + fn on_wallet_event(&self, event: &WalletEvent) { + let notice = match event { + WalletEvent::TransactionDetected { record, .. } => Notice::Detected(record.txid), + WalletEvent::TransactionInstantLocked { txid, .. } => Notice::Locked(*txid), + _ => return, + }; + let _ = self.0.send(notice); + } + fn on_error(&self, error: &str) { + let _ = self.0.send(Notice::Error(error.to_owned())); + } +} + +async fn receive_until( + receiver: &mut mpsc::UnboundedReceiver, + predicate: impl Fn(&Notice) -> bool, +) -> Vec { + tokio::time::timeout(Duration::from_secs(10), async { + let mut seen = Vec::new(); + loop { + let notice = receiver.recv().await.expect("SPV observer remains alive"); + if let Notice::Error(error) = ¬ice { + panic!("SPV failed: {error}"); + } + let done = predicate(¬ice); + seen.push(notice); + if done { + return seen; + } + } + }) + .await + .expect("in-process SPV must process the injected messages") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_transaction() { + for early in [false, true] { + let (persister, _directory, path) = fresh_persister(); + let persister = Arc::new(persister); + let (wallet, info, address) = wallet_with_receive_address(); + let wallet_id = wallet.wallet_id; + let funding = spend_of( + &[outside(191)], + vec![pay(50_000, &address), pay(50_000, &address)], + ); + let sends: Vec<_> = (0..2) + .map(|vout| { + authorize( + &wallet, + &info, + &address, + spend_of( + &[OutPoint::new(funding.txid(), vout)], + vec![pay(49_000, &address)], + ), + ) + }) + .collect(); + let invalid_id = sends[0].txid(); + let valid_id = sends[1].txid(); + let invalid_fixture = InstantLockFixture::new(&sends[0]).unwrap(); + let valid_fixture = InstantLockFixture::new(&sends[1]).unwrap(); + let mut invalid = invalid_fixture.instant_lock().clone(); + invalid.signature = valid_fixture.instant_lock().signature; + let mut manager = WalletManager::::new(Network::Testnet); + let mut persistence_events = manager.take_persistence_receiver().unwrap(); + let mut managed = PlatformWalletInfo::from_wallet(&wallet, 0); + managed.core_wallet = info; + persister + .store(wallet_id, registration(&wallet, None)) + .unwrap(); + manager.insert_wallet(wallet, managed).unwrap(); + let manager = Arc::new(RwLock::new(manager)); + mine(&manager, wallet_id, funding, 100).await; + persist_events(&manager, &persister, &mut persistence_events).await; + + let network = InjectedNetwork::new(); + let spv_directory = secure_tempdir().unwrap(); + let (notices, mut observed) = mpsc::unbounded_channel(); + let client = DashSpvClient::new( + ClientConfig::new(Network::Testnet) + .without_filters() + .with_storage_path(spv_directory.path()), + network.clone(), + manager.clone(), + vec![Arc::new(Observer(notices))], + ) + .await + .unwrap(); + client.run().await.unwrap(); + valid_fixture.install(&client).await.unwrap(); + invalid_fixture.install(&client).await.unwrap(); + assert!(client.verify_instant_lock(&invalid).await.is_err()); + let checked = client + .verify_instant_lock(valid_fixture.instant_lock()) + .await + .unwrap() + .unwrap(); + let evidence = ReplayEvidence { + instant_locks: [(valid_id, checked)].into(), + }; + + if early { + network + .dispatch_local(NetworkMessage::ISLock(invalid.clone())) + .await; + let seen = receive_until( + &mut observed, + |n| matches!(n, Notice::Proof(id, false) if *id == invalid_id), + ) + .await; + assert!(!seen + .iter() + .any(|n| matches!(n, Notice::Locked(id) if *id == invalid_id))); + } + for send in &sends { + network + .dispatch_local(NetworkMessage::Tx(Box::new(send.clone()))) + .await; + receive_until( + &mut observed, + |n| matches!(n, Notice::Detected(id) if *id == send.txid()), + ) + .await; + } + if !early { + network + .dispatch_local(NetworkMessage::ISLock(invalid)) + .await; + let seen = receive_until( + &mut observed, + |n| matches!(n, Notice::Proof(id, false) if *id == invalid_id), + ) + .await; + assert!(!seen + .iter() + .any(|n| matches!(n, Notice::Locked(id) if *id == invalid_id))); + } + network + .dispatch_local(NetworkMessage::ISLock(valid_fixture.instant_lock().clone())) + .await; + let seen = receive_until( + &mut observed, + |n| matches!(n, Notice::Locked(id) if *id == valid_id), + ) + .await; + assert!(!seen + .iter() + .any(|n| matches!(n, Notice::Locked(id) if *id == invalid_id))); + // Both locks traverse the same ordered IS event receiver; the valid wallet + // event is a processing barrier, rather than a sleep asserting absence. + client.stop().await; + persist_events(&manager, &persister, &mut persistence_events).await; + let raw: i64 = persister + .lock_conn_for_test() + .query_row( + "SELECT count(*) FROM core_instant_locks WHERE wallet_id=?1 AND txid=?2", + rusqlite::params![wallet_id.as_slice(), invalid_id.as_byte_array().as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + raw, 0, + "validated:false must not produce durable IS evidence" + ); + assert!(matches!( + persister + .get_core_tx_record(wallet_id, &invalid_id) + .unwrap() + .unwrap() + .context, + TransactionContext::Mempool + )); + drop((client, manager, persistence_events, persister)); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened.load_with_replay(&evidence).unwrap(); + let account = &loaded.state.wallets[&wallet_id] + .wallet_info + .accounts + .standard_bip44_accounts[&0]; + assert!(matches!( + account.transactions()[&invalid_id].context, + TransactionContext::Mempool + )); + assert!(!account.utxos[&OutPoint::new(invalid_id, 0)].is_instantlocked); + assert!(account.utxos[&OutPoint::new(valid_id, 0)].is_instantlocked); + } +} From b7bcbdc8664e5a19c2bac1581813b5d11c0f3158 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:00:45 +0000 Subject: [PATCH 72/84] test(wallet): reproduce stale consumed asset-lock spendability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin spendable coin filtering, balance preservation, and durable hold effects for a legacy consumed asset lock with a stale materialized funding coin. Both regression tests currently fail on a27c68153803. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../src/manager/history_replay.rs | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 8e6e8b30e1b..80b0105b464 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -1640,6 +1640,132 @@ mod tests { (wallet, restored, funding, records, spent) } + async fn stale_consumed_asset_lock_fixture() -> (ClientWalletStartState, OutPoint) { + use crate::wallet::asset_lock::tracked::TrackedAssetLock; + use crate::AssetLockFundingType; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + + let (wallet, mut info, funding, records, spent) = confirmed_spend_fixture().await; + let address = + Address::from_script(&funding.output[0].script_pubkey, wallet.network).unwrap(); + info.accounts + .standard_bip44_accounts + .get_mut(&0) + .unwrap() + .utxos + .insert( + spent, + Utxo::new( + spent, + funding.output[0].clone(), + address.clone(), + 100, + false, + ), + ); + let asset_lock = authorize( + &wallet, + &info, + &address, + Transaction { + version: 3, + lock_time: 0, + input: vec![dashcore::TxIn { + previous_output: spent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: dashcore::script::Builder::new() + .push_opcode(dashcore::opcodes::all::OP_RETURN) + .into_script(), + }], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![TxOut { + value: 99_000, + script_pubkey: address.script_pubkey(), + }]), + )), + }, + ); + let out_point = OutPoint::new(asset_lock.txid(), 0); + let mut history = history(vec![records[0].clone()], BTreeMap::new()); + history.transactions.push(StoredTransaction { + txid: asset_lock.txid(), + transaction: asset_lock.clone(), + context: TransactionContext::Mempool, + stored_net_amount: None, + stored_direction: None, + owned_inputs: vec![], + }); + let tracked = TrackedAssetLock { + operation_id: None, + out_point, + transaction: asset_lock, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 0, + amount: 99_000, + status: AssetLockStatus::Consumed, + proof: None, + }; + info.update_balance(); + ( + ClientWalletStartState { + wallet, + wallet_info: info, + identity_manager: Default::default(), + unused_asset_locks: [(0, [(out_point, tracked)].into())].into(), + unconfirmed_outgoing_txs: vec![], + recorded_history: history, + dashpay_backfill: Default::default(), + }, + spent, + ) + } + + #[tokio::test] + async fn should_remove_stale_consumed_asset_lock_input_from_spendable_projection() { + let (mut state, spent) = stale_consumed_asset_lock_fixture().await; + assert!(state + .wallet_info + .utxos() + .iter() + .any(|coin| coin.outpoint == spent)); + replay_wallet_history_blocking(&mut state, &ReplayEvidence::default()).unwrap(); + let account = &state.wallet_info.accounts.standard_bip44_accounts[&0]; + assert_eq!( + ( + account + .spendable_utxos(200) + .iter() + .any(|coin| coin.outpoint == spent), + state.wallet_info.balance.total() + ), + (false, 20_000), + "send funding must exclude the consumed coin while preserving the unrelated coin" + ); + assert!( + !state.wallet_info.utxos().iter().any(|coin| coin.outpoint == spent), + "a terminal asset lock consumed this restored coin; a claim alone cannot make it unspendable" + ); + assert_eq!(state.wallet_info.balance.total(), 20_000); + } + + #[tokio::test] + async fn should_persist_hold_for_stale_consumed_asset_lock_input() { + let (mut state, spent) = stale_consumed_asset_lock_fixture().await; + let outcome = + replay_wallet_history_blocking(&mut state, &ReplayEvidence::default()).unwrap(); + let effects = outcome + .storage_effects(&state.recorded_history.transactions, &[]) + .unwrap(); + assert!( + matches!(effects.claims.get(&spent), Some(ReplayClaimEffect::Hold { .. })), + "the atomic adapter must durably repair the stale spent flag before publishing the wallet" + ); + } + /// 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 From 0a7de05ce5eb3411f5b9995bc2649fd8160b0043 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:17:06 +0000 Subject: [PATCH 73/84] fix(platform-wallet): durably withhold finalized asset-lock inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove stale materialized inputs through the shared owner and project exact holds without promoting archived proofs or changing surviving provenance. Require atomic repair and reject adapters that leave the input spendable. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- Cargo.lock | 2 +- .../src/persistence/replay.rs | 157 +++++++++++++++++- .../src/sqlite/persister.rs | 5 +- .../sqlite_shared_history_replay.rs | 131 +++++++++++++++ .../src/manager/history_claims.rs | 11 ++ .../src/manager/history_replay.rs | 72 ++++++-- .../rs-platform-wallet/src/manager/load.rs | 15 +- .../AssetLockSpendVisibilityTests.swift | 82 ++++----- 8 files changed, 419 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d16a795126a..16fdeab1e54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5497,10 +5497,10 @@ dependencies = [ name = "platform-wallet-storage" version = "5.0.3" dependencies = [ - "async-trait", "apple-native-keyring-store", "argon2", "assert_cmd", + "async-trait", "chacha20poly1305", "chrono", "clap", diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs index 08668b80471..82ba9db4c8e 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -332,7 +332,10 @@ impl FFIPersister { claims = self.load_replay_claims(wallet_id, &mut wallet.recorded_history)?; let remaining = replay_wallet_history_blocking(wallet, evidence) .map_err(PersistenceError::backend)?; - if !remaining.sweeps.is_empty() || !remaining.authorization.rejected.is_empty() { + if !remaining.sweeps.is_empty() + || !remaining.authorization.rejected.is_empty() + || !remaining.withheld_asset_lock_inputs.is_empty() + { return Err(PersistenceError::backend( HistoryReplayError::UnsettledAfterReplay, )); @@ -463,10 +466,11 @@ impl Drop for ClaimRowsGuard { #[cfg(test)] mod tests { use super::*; + use crate::asset_lock_persistence::AssetLockEntryFFI; use crate::persistence::{FFIPersister, PersistenceCallbacks, PersistenceExtensionCallbacks}; use crate::types::FFINetwork; use crate::wallet_restore_types::RecordedTransactionRestoreFFI; - use crate::wallet_restore_types::WalletRestoreEntryFFI; + use crate::wallet_restore_types::{UtxoRestoreEntryFFI, WalletRestoreEntryFFI}; use dashcore::{Transaction, TxIn}; use platform_wallet::changeset::recorded_history::ReplayEvidence; use platform_wallet::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; @@ -489,6 +493,9 @@ mod tests { additional: RefCell>, account_xpub: Vec, applied_claims: RefCell>, + terminal_lock: Option, + coin: Option<(OutPoint, TxOut)>, + coin_held: Cell, } unsafe extern "C" fn scope( @@ -501,6 +508,8 @@ mod tests { host.scopes.set(host.scopes.get() + 1); host.inside.set(true); let saved = host.additional.borrow().clone(); + let saved_coin = host.coin_held.get(); + let saved_claims = host.applied_claims.borrow().clone(); if let Some(replacement) = host.replacement.borrow_mut().take() { *host.transaction.borrow_mut() = Some(replacement); } @@ -523,6 +532,8 @@ mod tests { host.inside.set(false); if first != 0 || host.mode.get() == 4 { *host.additional.borrow_mut() = saved; + host.coin_held.set(saved_coin); + *host.applied_claims.borrow_mut() = saved_claims; } match host.mode.get() { 1..=3 => 0, @@ -583,6 +594,52 @@ mod tests { &host.account_xpub, ))) }); + let coin = + host.coin + .as_ref() + .filter(|_| !host.coin_held.get()) + .map(|(outpoint, output)| { + let spec = &*account.unwrap(); + Box::into_raw(Box::new(UtxoRestoreEntryFFI { + type_tag: spec.type_tag, + standard_tag: spec.standard_tag, + account_index: spec.index, + registration_index: 0, + key_class: 0, + user_identity_id: [0; 32], + friend_identity_id: [0; 32], + prev_txid: outpoint.txid.to_byte_array(), + vout: outpoint.vout, + value_duffs: output.value, + script_pubkey: output.script_pubkey.as_bytes().as_ptr(), + script_pubkey_len: output.script_pubkey.len(), + height: 100, + is_coinbase: false, + is_confirmed: true, + is_instantlocked: false, + is_locked: false, + parent_context: 2, + parent_block_height: 100, + })) + }); + let terminal = host.terminal_lock.as_ref().map(|transaction| { + let bytes = dashcore::consensus::serialize(transaction).into_boxed_slice(); + let mut out_point = [0; 36]; + out_point[..32].copy_from_slice(transaction.txid().as_byte_array()); + out_point[32..].copy_from_slice(&1_u32.to_le_bytes()); + Box::into_raw(Box::new(AssetLockEntryFFI { + out_point, + transaction_bytes_len: bytes.len(), + transaction_bytes: Box::into_raw(bytes).cast(), + account_index: 0, + funding_type: 0, + identity_index: 0, + amount_duffs: 50_000, + status: 4, + proof_bytes: std::ptr::null(), + proof_bytes_len: 0, + })) + }); *rows = Box::into_raw(Box::new(WalletRestoreEntryFFI { wallet_id: [41; 32], accounts: account.unwrap_or(std::ptr::null_mut()), @@ -590,6 +647,10 @@ mod tests { network: FFINetwork::Testnet, recorded_transactions: history, recorded_transactions_count: history_len, + utxos: coin.unwrap_or(std::ptr::null_mut()), + utxos_count: usize::from(coin.is_some()), + tracked_asset_locks: terminal.unwrap_or(std::ptr::null_mut()), + tracked_asset_locks_count: usize::from(terminal.is_some()), ..Default::default() })); *count = 1; @@ -601,6 +662,16 @@ mod tests { if !entry.accounts.is_null() { drop(Box::from_raw(entry.accounts.cast_mut())); } + if !entry.utxos.is_null() { + drop(Box::from_raw(entry.utxos.cast_mut())); + } + if !entry.tracked_asset_locks.is_null() { + let lock = Box::from_raw(entry.tracked_asset_locks.cast_mut()); + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + lock.transaction_bytes.cast_mut(), + lock.transaction_bytes_len, + ))); + } if !entry.recorded_transactions.is_null() { let records = Box::from_raw(std::ptr::slice_from_raw_parts_mut( entry.recorded_transactions.cast_mut(), @@ -666,6 +737,9 @@ mod tests { }) .collect(); } + if host.mode.get() != 7 && host.coin.is_some() && effects.claims_count > 0 { + host.coin_held.set(true); + } if host.mode.get() == 6 { 81 } else { @@ -728,6 +802,85 @@ mod tests { assert_eq!(*host.additional.borrow(), original); } + #[test] + fn should_apply_terminal_input_hold_atomically_and_reject_a_noop_host() { + use dashcore::transaction::{ + special_transaction::asset_lock::AssetLockPayload, TransactionPayload, + }; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + for mode in [0, 6, 7] { + let wallet = key_wallet::Wallet::from_seed_bytes( + [0x51; 64], + dashcore::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(); + let mut lock = transaction(90); + lock.version = 3; + lock.special_transaction_payload = Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new( + [49_000, 50_000] + .map(|value| TxOut { + value, + script_pubkey: address.script_pubkey(), + }) + .to_vec(), + ), + )); + let spent = lock.input[0].previous_output; + let mut host = host_with_funds_account(); + host.coin = Some(( + spent, + TxOut { + value: 100_000, + script_pubkey: address.script_pubkey(), + }, + )); + host.terminal_lock = Some(lock.clone()); + host.mode.set(mode); + let result = persister(&host).load_with_replay(&ReplayEvidence::default()); + if mode == 0 { + let loaded = result.unwrap(); + assert_eq!( + loaded.state.wallets[&[41; 32]].wallet_info.balance.total(), + 0 + ); + assert!(host.coin_held.get()); + assert_eq!( + *host.applied_claims.borrow(), + vec![(spent, 2, Some(lock.txid()), None)] + ); + let again = persister(&host) + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert_eq!( + again.state.wallets[&[41; 32]].wallet_info.balance.total(), + 0 + ); + } else { + assert!( + result.is_err(), + "a failed or unapplied hold must not publish a wallet" + ); + assert!( + !host.coin_held.get(), + "transaction rollback preserves the original host state" + ); + assert!(host.applied_claims.borrow().is_empty()); + } + } + } + fn persister(host: &Host) -> FFIPersister { FFIPersister::new_with_persistence_capabilities_and_extensions( PersistenceCallbacks { diff --git a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 03f16847821..455fa6b52e0 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/persister.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs @@ -1820,7 +1820,10 @@ fn load_one_wallet( let remaining = replay_wallet_history_blocking(&mut state, evidence).map_err(replay_failure)?; state.unconfirmed_outgoing_txs = outcome.resendable(state.unconfirmed_outgoing_txs); - if !remaining.sweeps.is_empty() || !remaining.authorization.rejected.is_empty() { + if !remaining.sweeps.is_empty() + || !remaining.authorization.rejected.is_empty() + || !remaining.withheld_asset_lock_inputs.is_empty() + { return Err(PersistenceError::backend( HistoryReplayError::UnsettledAfterReplay, )); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs index d955edb2375..8d03daa9b68 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -260,3 +260,134 @@ async fn should_verify_tracked_funding_before_atomic_history_settlement() { ); } } + +#[tokio::test] +async fn should_atomically_withhold_final_asset_lock_input_at_nonzero_vout_and_reopen() { + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::transaction::TransactionPayload; + use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; + + for status in [AssetLockStatus::ChainLocked, AssetLockStatus::Consumed] { + let (mut wallet, mut info, address) = wallet_with_receive_address(); + let funding = spend_of( + &[outside(197)], + vec![pay(20_000, &address), pay(100_000, &address)], + ); + let records = records_of(&mut info, &mut wallet, &funding, block(100)).await; + let spent = OutPoint::new(funding.txid(), 1); + let available = OutPoint::new(funding.txid(), 0); + let mut transaction = spend_of( + &[spent], + vec![dashcore::TxOut { + value: 99_000, + script_pubkey: dashcore::script::Builder::new() + .push_opcode(dashcore::opcodes::all::OP_RETURN) + .into_script(), + }], + ); + transaction.version = 3; + transaction.special_transaction_payload = Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload::new(vec![pay(99_000, &address)]), + )); + let transaction = authorize(&wallet, &info, &address, transaction); + let out_point = OutPoint::new(transaction.txid(), 0); + let (persister, _directory, path) = fresh_persister(); + let mut changes = registration( + &wallet, + Some(CoreChangeSet { + records, + new_utxos: vec![ + stored_utxo(&funding, 0, address.clone()), + stored_utxo(&funding, 1, address), + ], + ..Default::default() + }), + ); + changes.asset_locks = Some(AssetLockChangeSet { + asset_locks: [( + out_point, + AssetLockEntry { + out_point, + transaction: transaction.clone(), + account_index: 0, + funding_type: AssetLockFundingType::IdentityTopUp, + identity_index: 0, + amount_duffs: 99_000, + status, + proof: None, + operation_id: Some([7; 16]), + }, + )] + .into(), + ..Default::default() + }); + persister.store(wallet.wallet_id, changes).unwrap(); + let encoded = blob::encode_outpoint(&spent).unwrap(); + // Simulate the legacy host projection that left a finalized input unspent. + persister.lock_conn_for_test().execute( + "UPDATE core_utxos SET spent=0, spent_in_txid=NULL, winner_mined_height=NULL WHERE wallet_id=?1 AND outpoint=?2", + rusqlite::params![wallet.wallet_id.as_slice(), &encoded], + ).unwrap(); + persister.lock_conn_for_test().execute_batch( + "CREATE TEMP TRIGGER reject_final_hold BEFORE UPDATE OF spent ON core_utxos WHEN NEW.spent=1 BEGIN SELECT RAISE(ABORT, 'injected final hold failure'); END;" + ).unwrap(); + let error = persister + .load_with_replay(&ReplayEvidence::default()) + .unwrap_err(); + assert!(format!("{error:?}").contains("injected final hold failure")); + let read_claim = |store: &SqlitePersister| { + store.lock_conn_for_test().query_row( + "SELECT spent, spent_in_txid, winner_mined_height FROM core_utxos WHERE wallet_id=?1 AND outpoint=?2", + rusqlite::params![wallet.wallet_id.as_slice(), &encoded], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>>(1)?, row.get::<_, Option>(2)?)), + ).unwrap() + }; + assert_eq!(read_claim(&persister), (0, None, None)); + persister + .lock_conn_for_test() + .execute_batch("DROP TRIGGER reject_final_hold") + .unwrap(); + let loaded = persister + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + let restored = &loaded.state.wallets[&wallet.wallet_id].wallet_info; + let account = &restored.accounts.standard_bip44_accounts[&0]; + assert_eq!(restored.balance.total(), 20_000); + assert!(account.utxos.contains_key(&available)); + assert!(!account + .spendable_utxos(200) + .iter() + .any(|coin| coin.outpoint == spent)); + assert_eq!( + read_claim(&persister), + (1, Some(transaction.txid().to_byte_array().to_vec()), None) + ); + drop(persister); + let reopened = SqlitePersister::open(SqlitePersisterConfig::new(path)).unwrap(); + let loaded = reopened + .load_with_replay(&ReplayEvidence::default()) + .unwrap(); + assert_eq!( + loaded.state.wallets[&wallet.wallet_id] + .wallet_info + .balance + .total(), + 20_000 + ); + assert!(loaded.state.wallets[&wallet.wallet_id] + .wallet_info + .utxos() + .iter() + .all(|coin| coin.outpoint != spent)); + assert!( + loaded.outcomes[&wallet.wallet_id] + .withheld_asset_lock_inputs + .is_empty(), + "durable hold makes explicit retry idempotent" + ); + assert_eq!( + read_claim(&reopened), + (1, Some(transaction.txid().to_byte_array().to_vec()), None) + ); + } +} diff --git a/packages/rs-platform-wallet/src/manager/history_claims.rs b/packages/rs-platform-wallet/src/manager/history_claims.rs index 30d6640dcc8..a636468c3af 100644 --- a/packages/rs-platform-wallet/src/manager/history_claims.rs +++ b/packages/rs-platform-wallet/src/manager/history_claims.rs @@ -348,6 +348,17 @@ impl HistoryReplay { .or_insert(None); } } + for (&input, &claimant) in &self.withheld_asset_lock_inputs { + if removed_evidence.contains(&claimant) { + return Err(HistoryReplayError::UnsettledAfterReplay); + } + affected.insert(input); + survivors + .entry(input) + .or_default() + .entry(claimant) + .or_insert(None); + } let mut stored_claims: BTreeMap> = BTreeMap::new(); for claim in claims { if claim diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 80b0105b464..0299a22cd3f 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -78,6 +78,8 @@ pub struct HistoryReplay { pub defeated: HashSet, /// Conflict effects for the backend to apply in its load transaction. pub sweeps: Vec, + /// Materialized inputs withheld for final asset locks, requiring a durable hold. + pub withheld_asset_lock_inputs: BTreeMap, /// Recovery views cannot settle or resend even when proof is available. pub read_only: bool, } @@ -194,19 +196,33 @@ pub async fn replay_wallet_history( outcome.recorded_conflicts = recorded_conflicts; state.unconfirmed_outgoing_txs = outcome.resendable(std::mem::take(&mut state.unconfirmed_outgoing_txs)); - state - .wallet_info - .restore_spent_outpoints(&final_lock_input_claims( - state - .unused_asset_locks - .values() - .flat_map(BTreeMap::values) - .filter(|lock| { - let txid = lock.transaction.txid(); - !outcome.defeated.contains(&txid) - && !outcome.authorization.rejected.contains(&txid) - }), - )); + let final_claims = final_lock_input_claims( + state + .unused_asset_locks + .values() + .flat_map(BTreeMap::values) + .filter(|lock| { + let txid = lock.transaction.txid(); + !outcome.defeated.contains(&txid) && !outcome.authorization.rejected.contains(&txid) + }), + ); + let claimants: BTreeMap<_, _> = final_claims + .iter() + .filter_map(|(input, claimant)| claimant.map(|claimant| (*input, claimant))) + .collect(); + // Claims prevent future credits, but spendability checks the materialized UTXO set. + for account in state.wallet_info.accounts.all_funding_accounts_mut() { + account.utxos.retain(|input, _| { + if let Some(claimant) = claimants.get(input) { + outcome.withheld_asset_lock_inputs.insert(*input, *claimant); + false + } else { + true + } + }); + } + state.wallet_info.restore_spent_outpoints(&final_claims); + state.wallet_info.update_balance(); Ok(outcome) } @@ -825,6 +841,7 @@ pub async fn replay_recorded_history_with_evidence( retired: swept, defeated, sweeps, + withheld_asset_lock_inputs: BTreeMap::new(), read_only: false, }) } @@ -1766,6 +1783,35 @@ mod tests { ); } + #[tokio::test] + async fn should_preserve_existing_claim_provenance_when_withholding_final_asset_lock_input() { + use dashcore::hashes::Hash; + for claimant in [None, Some(Txid::from_byte_array([71; 32]))] { + for mined_height in [None, Some(123)] { + let (mut state, spent) = stale_consumed_asset_lock_fixture().await; + let outcome = + replay_wallet_history_blocking(&mut state, &ReplayEvidence::default()).unwrap(); + let existing = StoredReplayClaim { + outpoint: spent, + claimant, + mined_height, + }; + let effects = outcome + .storage_effects(&state.recorded_history.transactions, &[existing]) + .unwrap(); + assert_eq!( + effects.claims[&spent], + ReplayClaimEffect::Hold { + claimant, + mined_height + } + ); + assert!(effects.removed_records.is_empty()); + assert!(effects.removed_evidence.is_empty()); + } + } + } + /// 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 diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index f2cf8fdb0a5..902188f500e 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -209,6 +209,7 @@ impl PlatformWalletManager

{ Ok(outcome) if outcome.sweeps.is_empty() && outcome.defeated.is_empty() + && outcome.withheld_asset_lock_inputs.is_empty() && outcome.authorization.rejected.is_empty() => { outcome @@ -1548,11 +1549,23 @@ mod idempotent_load_tests { ) )); } else { - result.unwrap(); + let outcome = result.unwrap(); + assert!(!outcome.withheld_asset_lock_inputs.is_empty()); assert!( wallet.recorded_history.transactions.is_empty(), "consumed history stays archival" ); + drop(loaded); + let id = persister.wallet.compute_wallet_id(); + let manager = PlatformWalletManager::new( + Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(persister), + Arc::new(NoopTestEventHandler), + ); + let error = manager.load_from_persistor().await.unwrap_err(); + assert!(format!("{error:?}").contains("AtomicPersistenceRequired")); + assert!(manager.get_wallet(&id).await.is_none()); + assert!(manager.list_wallet_ids_blocking().is_empty()); } } } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index 7aaafce9db8..64312f1bd84 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -12,13 +12,9 @@ import DashSDKFFI // funding TXOs it consumed stay `isSpent == false` forever. Every // relaunch then hands those consumed outputs back to Rust as spendable. // -// Two repairs cover it — the callback-time reconcile in -// `persistAssetLocks`, and the load-time guard in `loadWalletList` for -// wallets whose lock reached the terminal `Consumed` status before that -// reconcile existed and so has no future callback to fire. Both depend -// on a SwiftData read, and both must fail CLOSED: the whole point is to -// withhold outputs that are gone, so an unreadable table may never be -// read as "nothing to withhold." +// Callback-time reconciliation and scoped Rust replay both preserve spent +// inputs. The raw load only marshals rows; owner decisions reach Swift as +// exact claim effects inside the replay transaction. // // Both directions are pinned here. The readable behaviour goes through a // live store; the fail-closed behaviour goes through the handler's @@ -216,47 +212,57 @@ final class AssetLockSpendVisibilityTests: XCTestCase { return try XCTUnwrap(try context.fetch(descriptor).first).isSpent } - // MARK: Load-time guard + // MARK: Raw snapshot and owner-directed repair - /// `Consumed` is terminal — the lock never upserts again — so the - /// callback-time reconcile has no future event to repair this with, - /// and every relaunch would re-inflate the balance with an output - /// that is provably gone. The load guard must both exclude it from - /// the restore and heal the row in place. - func testLoadExcludesAndHealsATxoConsumedByAFinalizedAssetLock() throws { - let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) + /// The Rust owner tests choose this hold from the terminal lifecycle; + /// this host test checks its exact transactional application. + private func applyOwnerHold(outpoint: Data) throws { + var claim = ReplayClaimEffectFFI() + withUnsafeMutableBytes(of: &claim.txid) { $0.copyBytes(from: outpoint.prefix(32)) } + claim.vout = outpoint.suffix(4).enumerated().reduce(UInt32(0)) { + $0 | (UInt32($1.element) << (8 * $1.offset)) + } + claim.action = 2 + claim.has_claimant = true + withUnsafeMutableBytes(of: &claim.claimant) { $0.copyBytes(from: lockTxid) } + XCTAssertEqual(handler.replayWallet(walletId: walletId) { + do { + try withUnsafePointer(to: &claim) { pointer in + var effects = HistoryReplayEffectsFFI() + effects.claims = pointer + effects.claims_count = 1 + try handler.applyReplayEffects(walletId: walletId, effects: effects) + } + return 0 + } catch { + XCTFail("Unexpected owner-effect failure: \(error)") + return 1 + } + }, 0) + } - // Without a finalized lock row this IS the phantom UTXO. + // TODO(macOS-validation): run raw snapshot and scoped hold tests on an Apple SDK runner. + func testRawLoadDefersConsumedAssetLockRepairToScopedOwnerEffects() throws { + let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) XCTAssertEqual(try restoredUtxoCount(), 1) XCTAssertFalse(try txoIsSpent(outpoint: outpoint)) - try insertConsumedAssetLock(into: container, vout: 0) - XCTAssertEqual( - try restoredUtxoCount(), 0, - "the finalized lock's funding output must not rehydrate as spendable" - ) - XCTAssertTrue( - try txoIsSpent(outpoint: outpoint), - "and the stale flag must be healed in place" - ) + XCTAssertEqual(try restoredUtxoCount(), 1, "raw load must preserve the authoritative snapshot") + XCTAssertFalse(try txoIsSpent(outpoint: outpoint), "reading must not commit a repair") + try applyOwnerHold(outpoint: outpoint) + XCTAssertEqual(try restoredUtxoCount(), 0, "scoped owner repair must exclude the consumed coin") + XCTAssertTrue(try txoIsSpent(outpoint: outpoint), "the hold must survive the next raw snapshot") } - /// DIP-0027 lets one funding transaction carry several credit - /// outputs, and Rust persists each tracked lock under its own - /// credit-output index, so a valid lock row can be keyed `:1` - /// with no `:0` row anywhere. Finality belongs to the - /// transaction, so the guard keys on the funding txid alone. - func testLoadExcludesATxoConsumedByALockPersistedAtANonZeroVout() throws { + /// A tracked credit-output index does not change the funding inputs held by Rust. + func testScopedOwnerHoldExcludesInputOfLockAtANonZeroCreditVout() throws { let outpoint = try seedFundingTxoSpentByAMempoolAssetLock(into: container) - XCTAssertEqual(try restoredUtxoCount(), 1) - try insertConsumedAssetLock(into: container, vout: 1) - - XCTAssertEqual( - try restoredUtxoCount(), 0, - "finality belongs to the funding transaction, not to credit output 0" - ) + XCTAssertEqual(try restoredUtxoCount(), 1) + XCTAssertFalse(try txoIsSpent(outpoint: outpoint)) + try applyOwnerHold(outpoint: outpoint) + XCTAssertEqual(try restoredUtxoCount(), 0) XCTAssertTrue(try txoIsSpent(outpoint: outpoint)) } From 9e06baeda0fd6bba70f1b65d9cd9f52a848250bc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:20:12 +0000 Subject: [PATCH 74/84] test(wallet-storage): require verified evidence for restored InstantSend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep raw restoration unauthenticated and leave positive upgrades to the shared checked replay tests. Record the unmerged compatibility dependency gate. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- Cargo.toml | 1 + .../src/sqlite/rehydrate.rs | 22 +++++++------------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index aeac18f79f4..737fa939103 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ grovedb-storage = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f5 grovedb-version = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } grovedb-epoch-based-storage-flags = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "9b19035f51905b156e8a628c10b71b85797ffa21" } +# TODO(upstream-merge): verify this compat revision is reachable from merged rust-dashcore dev; PR #1336 alone does not prove its ancestry. dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } dash-network-seeds = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5e7982b3b7a3b5ab3999cd0d83235c705abcd3cb" } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs index 096361ae236..f09262fdfaf 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/rehydrate.rs @@ -3033,15 +3033,10 @@ mod tests { ); } - /// A restored UTXO whose transaction carried an InstantSend lock must come - /// back instant-locked, not wait for the next sync to re-learn it. The - /// persisted `core_instant_locks` rows arrive in - /// `CoreChangeSet::instant_locks_for_non_final_records`, and replaying them - /// has to happen AFTER the UTXO restore — `mark_instant_send_utxos` marks - /// the UTXOs it can find, so calling it first would insert the txid and - /// mark nothing. + /// Raw restoration preserves the coin but cannot authenticate saved lock bytes. + /// Verified positive upgrades are exercised through the shared replay owner. #[test] - fn rehydration_restores_instant_send_locks_onto_restored_utxos() { + fn should_not_restore_instant_send_finality_from_unverified_lock_rows() { use dashcore::blockdata::transaction::txout::TxOut; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::{OutPoint, Txid}; @@ -3088,8 +3083,7 @@ mod tests { let txid = Txid::from([0x5Au8; 32]); let outpoint = OutPoint { txid, vout: 0 }; - // `is_instantlocked: false` is the persisted shape: the flag is not a - // stored column, it is re-derived from the `core_instant_locks` row. + // The persisted coin starts without authenticated InstantSend evidence. let utxo = Utxo { outpoint, txout: TxOut { @@ -3135,8 +3129,8 @@ mod tests { .unwrap(); assert!( - wallet_info.instant_send_locks().contains(&txid), - "the persisted InstantSend lock must be replayed onto the wallet" + !wallet_info.instant_send_locks().contains(&txid), + "raw persisted bytes cannot establish InstantSend finality" ); let restored = wallet_info .accounts @@ -3145,8 +3139,8 @@ mod tests { .and_then(|a| a.utxos.get(&outpoint)) .expect("the restored UTXO must be present on the BIP44 account"); assert!( - restored.is_instantlocked, - "the restored UTXO must carry instant-locked status, not wait for the next sync" + !restored.is_instantlocked, + "the shared replay needs matching verified evidence before upgrading the coin" ); } } From 0061164e6ebd6a00195b40fb666df758dff2ff83 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:25:57 +0000 Subject: [PATCH 75/84] test(swift-sdk): account for history read before asset-lock failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve the failure result assertions while matching the raw snapshot's wallet, UTXO, transaction history, then asset-lock read sequence. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: Claude GPT-6 --- .../SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift index 64312f1bd84..eda7af61608 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockSpendVisibilityTests.swift @@ -374,8 +374,8 @@ final class AssetLockSpendVisibilityTests: XCTestCase { XCTAssertEqual( injector.observedReads, - ["PersistentWallet", "PersistentTxo", "PersistentAssetLock"], - "the wallet and unspent-TXO reads must have been served — only the lock read failed" + ["PersistentWallet", "PersistentTxo", "PersistentTransaction", "PersistentAssetLock"], + "wallet, unspent-TXO and history reads must succeed before the injected lock read failure" ) XCTAssertTrue( errored, From 268a84fee2154d9dc719b639ac2bac7f8ea316aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:41:50 +0000 Subject: [PATCH 76/84] fix(platform-wallet-storage): drop Hash trait imports unused after the rust-dashcore bump The rust-dashcore update inherited from v5.1-dev makes the byte-array constructors and accessors inherent on the hash newtypes, so these `dashcore::hashes::Hash` imports fail clippy with -D warnings. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/core_history_aliases.rs | 1 - .../tests/integration/sqlite_history_aliases.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs index 520b268dd99..d11e3103933 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_history_aliases.rs @@ -6,7 +6,6 @@ use std::collections::BTreeSet; -use dashcore::hashes::Hash; use dashcore::transaction::special_transaction::TransactionPayload; use dashcore::{OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs index d06ac9c0009..a449924dab8 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -4,7 +4,6 @@ //! signature preservation is tested independently of chain/quorum validity. use crate::common::{ensure_wallet_meta, fresh_persister, wid}; -use dashcore::hashes::Hash; use dashcore::sighash::SighashCache; use dashcore::{consensus, Address, Network, OutPoint, Txid}; use dpp::secp256k1::{ecdsa, ecdsa::Signature, Message, PublicKey}; From 28a665097c6d74d0d2cdc76fce88d9756114b280 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 11:16:47 +0000 Subject: [PATCH 77/84] fix(platform-wallet): follow the rust-dashcore hash API in transaction accounting rust-dashcore c9aeac0 (inherited from v5.1-dev) drops the `Hash` trait constructor on hash newtypes: build the P2PKH pubkey hash via `PubkeyHash::from_raw_hash(hash160::Hash::hash(..))` and remove the `dashcore::hashes::Hash` imports that are now unused. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 2 -- .../rs-platform-wallet/src/changeset/wallet_accounting.rs | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 5fc22f71d5c..f406884378a 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -1875,7 +1875,6 @@ impl FFIPersister { /// caller applies its own policy to the outcome. fn read_core_tx(&self, wallet_id: WalletId, txid: &dashcore::Txid) -> CoreTxRead { use dashcore::consensus::Decodable; - use dashcore::hashes::Hash; let Some(get_cb) = self.callbacks.on_get_core_tx_record_fn else { return CoreTxRead::Missing; @@ -10135,7 +10134,6 @@ mod tests { /// slot still succeeds. #[test] fn store_delivers_transaction_accounting_after_the_changeset_skipping_detailless_records() { - use dashcore::hashes::Hash as _; use key_wallet::account::{AccountType, StandardAccountType}; use key_wallet::managed_account::transaction_record::{ OutputDetail, OutputRole, TransactionDirection, TransactionRecord, diff --git a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs index 9a8b660c02f..3299385ddf5 100644 --- a/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs +++ b/packages/rs-platform-wallet/src/changeset/wallet_accounting.rs @@ -22,7 +22,7 @@ use std::collections::{BTreeMap, BTreeSet}; use dashcore::blockdata::script::Instruction; -use dashcore::hashes::Hash; +use dashcore::hashes::hash160; use dashcore::{Address, OutPoint, PubkeyHash, ScriptBuf, Transaction, TxIn, Txid}; use key_wallet::account::AccountType; use key_wallet::managed_account::managed_account_ref::ManagedAccountRef; @@ -299,7 +299,8 @@ fn p2pkh_redeemed_script(input: &TxIn) -> Option { return None; }; let pubkey = pubkey.as_bytes(); - matches!(pubkey.len(), 33 | 65).then(|| ScriptBuf::new_p2pkh(&PubkeyHash::hash(pubkey))) + matches!(pubkey.len(), 33 | 65) + .then(|| ScriptBuf::new_p2pkh(&PubkeyHash::from_raw_hash(hash160::Hash::hash(pubkey)))) } /// The wallet's funds accounts, without contacts' watch-only chains. From d8bbf50d32601e124e9796d17a486c5841c7933d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:37:15 +0000 Subject: [PATCH 78/84] test(swift-sdk): assert migrations land on the current schema version The base froze App Store schema 4.0.0 and this branch adds DashSchemaV5, so the hard-coded 4.0.0 in testPublishedV3MigratesOperationIdToNil went stale. Compare with the last schema of DashMigrationPlan instead. Co-Authored-By: OpenAI Codex Co-Authored-By: Claude Opus 5.5 --- .../SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift index f1dec9f0c87..33552994ed9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/AssetLockOperationPersistenceTests.swift @@ -86,7 +86,8 @@ final class AssetLockOperationPersistenceTests: XCTestCase { XCTAssertEqual(rows.count, 1) XCTAssertNil(rows.first?.operationId) XCTAssertEqual(rows.first?.amountDuffs, 1000) - XCTAssertEqual(DashModelContainer.schema.version, Schema.Version(4, 0, 0)) + let currentSchema = try XCTUnwrap(DashMigrationPlan.schemas.last) + XCTAssertEqual(DashModelContainer.schema.version, currentSchema.versionIdentifier) } func testMalformedOperationIdFailsLoadInsteadOfBecomingAbsent() throws { From 15c33856bae3da4ce4c1601d32a2298ec57c983c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:58:54 +0000 Subject: [PATCH 79/84] fix(wallet): release persisted wallets when their manager is dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the broadcaster/runtime/event-handler cycle with weak references from balance and spend handlers to the manager-owned wallet map. Keep that owner alive in event-handler fixtures. Legacy and adopted history can reopen the same SQLite store after shutdown and drop. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: OpenAI Codex --- packages/rs-platform-wallet/src/manager/mod.rs | 2 +- .../src/wallet/core/balance_handler.rs | 17 ++++++++++++----- .../src/wallet/core/broadcast.rs | 6 ++++-- .../src/wallet/core/spend_observer.rs | 15 +++++++++++---- 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 1a309c99db8..028265f81cc 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -603,7 +603,7 @@ impl PlatformWalletManager

{ ); // Build handler list: app handler + internal handlers. - // BalanceUpdateHandler holds a clone of the wallets map (a + // BalanceUpdateHandler weakly references the wallets map (a // wait-free `ArcSwap`, separate from the wallet_manager lock) // so it can look up PlatformWallets and write to their // lock-free balance atomics from broadcast-handler context diff --git a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs index b6bda3d9645..1205332bc3f 100644 --- a/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs +++ b/packages/rs-platform-wallet/src/wallet/core/balance_handler.rs @@ -2,7 +2,7 @@ //! when an upstream `WalletEvent` carries a fresh balance snapshot. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use arc_swap::ArcSwap; use dash_spv::EventHandler; @@ -22,7 +22,7 @@ use crate::wallet::PlatformWallet; /// `WalletBalance` atomics so SwiftUI subscribers see the new totals. /// /// Registered in `PlatformWalletManager`'s handler list. The handler -/// holds an `Arc` clone of the manager's `wallets` map (a *separate* +/// holds a `Weak` reference to the manager's `wallets` map (a *separate* /// structure from the heavily-contended `wallet_manager` SPV write /// lock). SPV holds the wallet-manager write lock for the entire /// duration of block processing — looking the balance up through *that* @@ -41,12 +41,16 @@ use crate::wallet::PlatformWallet; /// exactly that snapshot when it raced a lifecycle write, leaving /// removed funds on display indefinitely. pub struct BalanceUpdateHandler { - wallets: Arc>>>, + wallets: Weak>>>, } impl BalanceUpdateHandler { + /// Observe the manager-owned map without extending its lifetime. pub fn new(wallets: Arc>>>) -> Self { - Self { wallets } + Self { + // Wallet broadcasters reach this handler through the SPV runtime. + wallets: Arc::downgrade(&wallets), + } } } @@ -94,7 +98,10 @@ impl EventHandler for BalanceUpdateHandler { // wallet immediately after publishing — `register_wallet` and // `load_from_persistor` alike — so the window costs a snapshot, not // the balance. - let wallets = self.wallets.load(); + let Some(wallets) = self.wallets.upgrade() else { + return; + }; + let wallets = wallets.load(); if let Some(pw) = wallets.get(wallet_id) { pw.balance().set( balance.confirmed(), diff --git a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs index b4f4813facf..2026dfc9eea 100644 --- a/packages/rs-platform-wallet/src/wallet/core/broadcast.rs +++ b/packages/rs-platform-wallet/src/wallet/core/broadcast.rs @@ -855,7 +855,8 @@ mod tests { "the fixture event must report at least one spend, or the test \ would pass without observing anything" ); - let handler = SpendObservationHandler::new(wallets_map(&[core])); + let map = wallets_map(&[core]); + let handler = SpendObservationHandler::new(Arc::clone(&map)); dash_spv::EventHandler::on_wallet_event(&handler, &event); } @@ -1278,7 +1279,8 @@ mod tests { ); } - let handler = SpendObservationHandler::new(wallets_map(&[&core_a, &core_b])); + let map = wallets_map(&[&core_a, &core_b]); + let handler = SpendObservationHandler::new(Arc::clone(&map)); // An event naming a wallet id registered NOWHERE: the lookup misses, // nothing panics, and neither fence moves — the fail-safe direction. diff --git a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs index db509798de1..79d435b9287 100644 --- a/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs +++ b/packages/rs-platform-wallet/src/wallet/core/spend_observer.rs @@ -8,7 +8,7 @@ //! outpoints are spent. use std::collections::BTreeMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use arc_swap::ArcSwap; use dash_spv::EventHandler; @@ -91,12 +91,16 @@ use crate::wallet::PlatformWallet; /// nothing to retry against. /// pub struct SpendObservationHandler { - wallets: Arc>>>, + wallets: Weak>>>, } impl SpendObservationHandler { + /// Observe the manager-owned map without extending its lifetime. pub fn new(wallets: Arc>>>) -> Self { - Self { wallets } + Self { + // Wallet broadcasters reach this handler through the SPV runtime. + wallets: Arc::downgrade(&wallets), + } } /// Apply `observation` to its wallet's generation. @@ -107,7 +111,10 @@ impl SpendObservationHandler { fn observe(&self, wallet_id: WalletId, outpoints: Vec) { // The wallets map, NOT the SPV-contended wallet_manager lock — see // the type docs. - if let Some(wallet) = self.wallets.load().get(&wallet_id) { + let Some(wallets) = self.wallets.upgrade() else { + return; + }; + if let Some(wallet) = wallets.load().get(&wallet_id) { wallet.generation().observe_spent(outpoints); } } From e76b94957cc3df4f11b49fc14b014d5bf6845253 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:59:53 +0000 Subject: [PATCH 80/84] fix(wallet): preserve history and surviving input claims across replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share actual survivor selection between live SQLite sweeps and replay, including held inputs awaiting funding. Do not borrow a winner's mining height for another spender's claim. Merge legacy fallback metadata into an existing canonical replay record instead of duplicating its body. Validate V021 row indexes against authoritative blobs before discarding mined history: Recovery preserves an inconsistent wallet graph and Strict rejects it. An unmined body mislabeled mined cannot be recovered by rescan. Keep the accounting rollback regression and extend the claimant regression to missing funding. Open-time version assertions follow the full runner. All three full suites, scoped all-target/all-feature clippy and fmt pass. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: OpenAI Codex --- .../src/sqlite/migrations/v021.rs | 22 ++- .../src/sqlite/schema/core_state.rs | 160 +++++++++++------- .../integration/sqlite_history_aliases.rs | 1 - .../src/manager/history_claims.rs | 34 +++- .../src/manager/history_replay.rs | 12 +- 5 files changed, 157 insertions(+), 72 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs index 6bd9a2655f2..65b939ff972 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/migrations/v021.rs @@ -1,7 +1,8 @@ //! Rewind coverage without discarding any wallet's unresolved InstantSend evidence. +use dashcore::{hashes::Hash, Txid}; use key_wallet::transaction_checking::TransactionContext; -use rusqlite::{params, Transaction}; +use rusqlite::{params, Row, Transaction}; use crate::sqlite::config::LoadPolicy; use crate::sqlite::error::WalletStorageError; @@ -62,9 +63,18 @@ fn has_record_observation( ) -> Result { scan_observations(tx, wallet_id, policy, "SELECT CASE WHEN typeof(record_blob)='blob' THEN length(record_blob) END, - CASE WHEN typeof(record_blob)='blob' AND length(record_blob)<=?2 THEN record_blob END + CASE WHEN typeof(record_blob)='blob' AND length(record_blob)<=?2 THEN record_blob END, + length(txid), CASE WHEN length(txid)=32 THEN txid END, height FROM core_transactions WHERE wallet_id=?1 AND record_blob IS NOT NULL", - |bytes| Ok(matches!(core_state::decode_transaction_record(bytes)?.context, TransactionContext::InstantSend(_)))) + |bytes, row| { + let record = core_state::decode_transaction_record(bytes)?; + blob::check_fixed_width(row.get(2)?, 32, "core_transactions.txid")?; + let txid = Txid::from_slice(&row.get::<_, Vec>(3)?)?; + let height = row.get::<_, Option>(4)? + .map(|height| i64_to_u32("core_transactions.height", height)).transpose()?; + core_state::ensure_transaction_record_matches_columns(&txid, height, &record)?; + Ok(matches!(record.context, TransactionContext::InstantSend(_))) + }) } fn has_asset_observation( @@ -76,7 +86,7 @@ fn has_asset_observation( "SELECT CASE WHEN typeof(lifecycle_blob)='blob' THEN length(lifecycle_blob) END, CASE WHEN typeof(lifecycle_blob)='blob' AND length(lifecycle_blob)<=?2 THEN lifecycle_blob END FROM asset_locks WHERE wallet_id=?1", - asset_locks::has_instant_observation) + |bytes, _| asset_locks::has_instant_observation(bytes)) } fn scan_observations( @@ -84,7 +94,7 @@ fn scan_observations( wallet_id: &[u8; 32], policy: LoadPolicy, query: &str, - decode: impl Fn(&[u8]) -> Result, + decode: impl Fn(&[u8], &Row<'_>) -> Result, ) -> Result { let mut stmt = tx.prepare(query)?; let mut rows = stmt.query(params![ @@ -96,7 +106,7 @@ fn scan_observations( let decoded = match length { Some(length) => blob::check_size(length).and_then(|()| { let bytes: Vec = row.get(1)?; - decode(&bytes) + decode(&bytes, row) }), None => Err(WalletStorageError::blob_decode( "rescan evidence must be a BLOB", diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs index acc85e9fb03..06691fca7a4 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs @@ -8,12 +8,13 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use dashcore::ephemerealdata::chain_lock::ChainLock; use dashcore::transaction::special_transaction::TransactionPayload; -use dashcore::{OutPoint, Txid}; +use dashcore::{hashes::Hash, OutPoint, Txid}; use key_wallet::managed_account::transaction_record::TransactionRecord; use key_wallet::transaction_checking::TransactionContext; use key_wallet::Utxo; use platform_wallet::changeset::changeset::UtxoCreditVerdict; use platform_wallet::changeset::CoreChangeSet; +use platform_wallet::manager::history_replay::held_sweep_claimant; use platform_wallet::wallet::platform_wallet::WalletId; use crate::sqlite::error::WalletStorageError; @@ -703,12 +704,12 @@ fn apply_sweep( // query even if nothing had marked it spent yet (upstream sweeps only // unconfirmed records, whose spends this schema does not mark). // `spent_in_txid` moves with `spent`: a released input clears back to - // NULL (nobody's claim), a held one is attributed to `superseded_by` so + // NULL (nobody's claim), a held one is attributed to its surviving spender so // the claim outlives this row's own deletion below. // A held, never-materialised claim (`is_sweep_placeholder = 1`) is re-stamped - // with the NEW winner's mined height when this sweep has one — the - // claim now belongs to that winner, and its height is what the - // collector compares against the finality boundary. An IS-locked + // with the winner's height only when that winner spends the input. + // A surviving unmined spender's hold must outlive that winner's finality. + // An IS-locked // winner (`?5` NULL) re-points the claim but keeps the existing stamp: // the earlier block-context observation stands, exactly as upstream's // `observed_spent_outpoints` entry is never retracted by an @@ -741,6 +742,9 @@ fn apply_sweep( winner_mined_height) \ VALUES (?1, ?2, 0, X'', 1, 1, ?3, ?4)", )?; + let mut spenders_stmt = tx.prepare_cached( + "SELECT txid FROM core_transaction_inputs WHERE wallet_id = ?1 AND outpoint = ?2", + )?; for input in &loser.transaction.input { let outpoint = input.previous_output; // An input funded by a transaction this same changeset also sweeps @@ -776,17 +780,33 @@ fn apply_sweep( } let key = blob::encode_outpoint(&outpoint)?; let freed = released.contains(&outpoint); - let spent_in_txid: Option<&[u8]> = if freed { + let claimant = if freed { None } else { - Some(AsRef::<[u8]>::as_ref(superseded_by)) + let spenders = spenders_stmt + .query_map(params![wallet_id.as_slice(), &key], |row| { + row.get::<_, Vec>(0) + })? + .map(|row| Ok(Txid::from_slice(&row?)?)) + .collect::, WalletStorageError>>()?; + Some(held_sweep_claimant( + *superseded_by, + spenders + .into_iter() + .filter(|spender| !swept_txids.contains(spender)), + )) }; + let spent_in_txid = claimant.as_ref().map(AsRef::<[u8]>::as_ref); + // A winner's height cannot retire a hold belonging to another spender. + let stamp = winner_mined_height + .filter(|_| claimant.as_ref() == Some(superseded_by)) + .map(i64::from); let affected = spend_stmt.execute(params![ wallet_id.as_slice(), &key[..], !freed, spent_in_txid, - winner_mined_height.map(i64::from) + stamp ])?; if affected == 0 && !freed { // A held input with no row gets a placeholder in EVERY sweep @@ -799,8 +819,8 @@ fn apply_sweep( tombstone_stmt.execute(params![ wallet_id.as_slice(), &key[..], - AsRef::<[u8]>::as_ref(superseded_by), - winner_mined_height.map(i64::from) + spent_in_txid, + stamp ])?; } } @@ -1389,7 +1409,7 @@ fn ensure_current_transaction_identity( } /// Diagnose typed txid/height columns that disagree with the authoritative blob. -fn ensure_transaction_record_matches_columns( +pub(crate) fn ensure_transaction_record_matches_columns( typed_txid: &dashcore::Txid, typed_height: Option, record: &TransactionRecord, @@ -2049,7 +2069,7 @@ mod tests { migrations::run_for_open(&mut conn, LoadPolicy::Recovery) .expect("Recovery must complete V020 past an undecodable kept record"); - assert_eq!(schema_version(&conn), 20); + assert_eq!(schema_version(&conn), migrations::max_supported_version()); } #[test] @@ -3413,55 +3433,77 @@ mod tests { #[test] fn should_hand_a_withheld_input_to_its_kept_survivor_after_migrating_to_v021() { - let wallet_id = [0xB7u8; 32]; - let mut conn = wallet_db(&wallet_id); - let coin = |marker| sample_utxo(Txid::from_byte_array([marker; 32]), 150, true); - let (first, second, shared) = (coin(0x71), coin(0x72), coin(0x73)); - // The shared coin is claimed by the first record listing it: `loser`. - let loser = spend( - &[first.outpoint, shared.outpoint], - TransactionContext::Mempool, - ); - let survivor = spend( - &[second.outpoint, shared.outpoint], - TransactionContext::Mempool, - ); - let coins = vec![first.clone(), second.clone(), shared.clone()]; - let pending = CoreChangeSet { - spent_utxos: coins.clone(), - ..round(vec![loser.clone(), survivor.clone()], coins) - }; - let tx = conn.transaction().unwrap(); - apply(&tx, &wallet_id, &pending).unwrap(); - tx.commit().unwrap(); - let claimant = |txid: &Txid| (true, Some(txid.as_byte_array().to_vec())); - assert_eq!( - spend_claim(&conn, &wallet_id, &shared.outpoint), - claimant(&loser.txid) - ); - rewind_v020(&conn); - migrations::run(&mut conn).unwrap(); + for materialized in [true, false] { + let wallet_id = [0xB7u8; 32]; + let mut conn = wallet_db(&wallet_id); + let coin = |marker| sample_utxo(Txid::from_byte_array([marker; 32]), 150, true); + let (first, second, shared) = (coin(0x71), coin(0x72), coin(0x73)); + // The shared coin is claimed by the first record listing it: `loser`. + let loser = spend( + &[first.outpoint, shared.outpoint], + TransactionContext::Mempool, + ); + let survivor = spend( + &[second.outpoint, shared.outpoint], + TransactionContext::Mempool, + ); + let coins = if materialized { + vec![first.clone(), second.clone(), shared.clone()] + } else { + vec![] + }; + let pending = CoreChangeSet { + spent_utxos: coins.clone(), + ..round(vec![loser.clone(), survivor.clone()], coins) + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &pending).unwrap(); + tx.commit().unwrap(); + let claimant = |txid: &Txid| (true, Some(txid.as_byte_array().to_vec())); + if materialized { + assert_eq!( + spend_claim(&conn, &wallet_id, &shared.outpoint), + claimant(&loser.txid) + ); + } + rewind_v020(&conn); + migrations::run(&mut conn).unwrap(); - let block = BlockInfo::new(160, BlockHash::all_zeros(), 1); - let winner = spend(&[first.outpoint], TransactionContext::InBlock(block)); - let settled = CoreChangeSet { - sweeps: vec![SweepBatch { - txids: vec![loser.txid], - superseded_by: winner.txid, - winner_mined_height: Some(160), - released_outpoints: vec![], - }], - ..round(vec![winner], vec![]) - }; - let tx = conn.transaction().unwrap(); - apply(&tx, &wallet_id, &settled).unwrap(); - tx.commit().unwrap(); + let block = BlockInfo::new(160, BlockHash::all_zeros(), 1); + let winner = spend(&[first.outpoint], TransactionContext::InBlock(block)); + let settled = CoreChangeSet { + sweeps: vec![SweepBatch { + txids: vec![loser.txid], + superseded_by: winner.txid, + winner_mined_height: Some(160), + released_outpoints: vec![], + }], + ..round(vec![winner], vec![]) + }; + let tx = conn.transaction().unwrap(); + apply(&tx, &wallet_id, &settled).unwrap(); + tx.commit().unwrap(); - assert_eq!( - spend_claim(&conn, &wallet_id, &shared.outpoint), - claimant(&survivor.txid), - "the kept survivor, not the winner, holds the withheld input" - ); + assert_eq!( + spend_claim(&conn, &wallet_id, &shared.outpoint), + claimant(&survivor.txid), + "the kept survivor, not the winner, holds the withheld input" + ); + let stamp: Option = conn + .query_row( + "SELECT winner_mined_height FROM core_utxos WHERE wallet_id=?1 AND outpoint=?2", + params![ + wallet_id.as_slice(), + blob::encode_outpoint(&shared.outpoint).unwrap() + ], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + stamp, None, + "an unmined survivor cannot inherit the winner's height" + ); + } } fn count_vm_ops(conn: &Connection) -> std::sync::Arc { use std::sync::{ diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs index a3b9b8ce658..c548d6c865b 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_history_aliases.rs @@ -965,7 +965,6 @@ async fn signed_history_load_and_reopen(adopted: bool) { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn should_load_literal_legacy_only_history_through_replay_manager_and_reopen() { - // TODO: verify alias replay and reopen under the required memory-capped runner. signed_history_load_and_reopen(false).await; } diff --git a/packages/rs-platform-wallet/src/manager/history_claims.rs b/packages/rs-platform-wallet/src/manager/history_claims.rs index a636468c3af..21a2a8b6827 100644 --- a/packages/rs-platform-wallet/src/manager/history_claims.rs +++ b/packages/rs-platform-wallet/src/manager/history_claims.rs @@ -13,6 +13,18 @@ use crate::changeset::{ use crate::wallet::asset_lock::tracked::AssetLockStatus; use crate::wallet::identity::PaymentEntry; +/// Attribute a withheld input to its surviving spender, or the external sweep winner. +pub fn held_sweep_claimant(winner: Txid, survivors: impl Iterator) -> Txid { + let mut selected = None; + for survivor in survivors { + if survivor == winner { + return winner; + } + selected = Some(selected.map_or(survivor, |known: Txid| known.min(survivor))); + } + selected.unwrap_or(winner) +} + /// One durable hold, independent of a backend's physical row representation. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StoredReplayClaim { @@ -420,14 +432,28 @@ impl HistoryReplay { pending.remove(&outpoint); sweep_held.remove(&outpoint); } else { - let stamp = sweep - .winner_mined_height - .or_else(|| pending.get(&outpoint).and_then(|claim| claim.mined_height)); + let winner = records_by_key + .get(&sweep.superseded_by) + .map_or(sweep.superseded_by, |(body, _)| *body); + let retained = survivors.get(&outpoint); + let claimant = held_sweep_claimant( + winner, + retained + .into_iter() + .flat_map(|claims| claims.keys().copied()), + ); + let stamp = if claimant == winner { + sweep + .winner_mined_height + .or_else(|| pending.get(&outpoint).and_then(|claim| claim.mined_height)) + } else { + retained.and_then(|claims| claims.get(&claimant).copied().flatten()) + }; pending.insert( outpoint, StoredReplayClaim { outpoint, - claimant: Some(sweep.superseded_by), + claimant: Some(claimant), mined_height: stamp, }, ); diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 0299a22cd3f..e4c4501ef34 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -34,7 +34,8 @@ use key_wallet::Utxo; use super::history_authorization::plan_history_authorization; pub use super::history_authorization::HistoryAuthorization; pub use super::history_claims::{ - HistoryReplayEffects, HistoryReplayOperationEffects, ReplayClaimEffect, StoredReplayClaim, + held_sweep_claimant, HistoryReplayEffects, HistoryReplayOperationEffects, ReplayClaimEffect, + StoredReplayClaim, }; use crate::changeset::changeset::SweepBatch; use crate::changeset::recorded_history::ReplayEvidence; @@ -744,7 +745,14 @@ pub async fn replay_recorded_history_with_evidence( if unsafe_amounts.contains_key(&original.txid) { sanitize_unsafe_record(&mut original); } - if let Some(replayed) = account.transactions_mut().get_mut(&original.txid) { + // The checker keys legacy bodies canonically; keep one logical record. + let canonical = original.transaction.txid(); + let replayed_key = if account.transactions().contains_key(&canonical) { + canonical + } else { + original.txid + }; + if let Some(replayed) = account.transactions_mut().get_mut(&replayed_key) { replayed.label = original.label; replayed.fee = original.fee.or(replayed.fee); } else { From 3c59183a4e62b4d119d0e0bbfe63fd1572907b7f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:59:59 +0000 Subject: [PATCH 81/84] test(wallet-storage): pin rescan boundaries and atomic load exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve old migration assertions through V020, then assert V021's intentional mined-history rescan and coverage rewind. Use a valid encoded asset-lock lifecycle because V021 inspects finality evidence. Refresh schema fingerprints for the approved unmerged additive-V020 / rescan-V021 split and its two indexes; V001-V019 are unchanged. Bound empty-wallet loads to 21 statements for intentional claim/prevout reads, retaining constant-scaling checks. Assert external writer exclusion during IMMEDIATE replay and a fresh snapshot after the writer can commit. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: OpenAI Codex --- .../integration/sqlite_load_reconstruction.rs | 47 ++++++++--- .../integration/sqlite_migration_execution.rs | 80 ++++++++++++++----- .../tests/integration/sqlite_migrations.rs | 45 ++++++++++- .../integration/sqlite_schema_pinning.rs | 4 +- .../integration/sqlite_transaction_sweeps.rs | 12 ++- 5 files changed, 147 insertions(+), 41 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs index 7a755f19f66..affb2f319cd 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_load_reconstruction.rs @@ -1541,9 +1541,10 @@ fn tc_p4_012_load_query_count_bounded() { "per-wallet statement count must be constant \ (N=1 → {count_one}, N=10 → {count_ten}, delta → {delta})" ); + // Atomic replay also reads durable claims and spent prevout material. let per_wallet = delta / 9; assert!( - (1..=20).contains(&per_wallet), + (1..=21).contains(&per_wallet), "per-wallet statement count must be small + bounded, got {per_wallet}" ); // Shared (wallet-count-independent) overhead: the `list_ids` + @@ -1825,7 +1826,7 @@ fn tc_p4_020_unignore_survives_restart_table_is_authoritative() { } #[test] -fn should_load_wallet_from_one_snapshot_during_an_external_commit() { +fn should_exclude_external_writers_while_loading_one_consistent_snapshot() { use key_wallet::account::{AccountType, StandardAccountType}; use platform_wallet::changeset::{AccountRegistrationEntry, CoreChangeSet}; use rusqlite::hooks::{AuthAction, AuthContext, Authorization}; @@ -1854,23 +1855,36 @@ fn should_load_wallet_from_one_snapshot_during_an_external_commit() { }, ) .unwrap(); - let writer = rusqlite::Connection::open(path).unwrap(); + let writer = rusqlite::Connection::open(&path).unwrap(); + writer.busy_timeout(std::time::Duration::ZERO).unwrap(); let changed = Arc::new(AtomicBool::new(false)); let changed_hook = Arc::clone(&changed); - persister.lock_conn_for_test().authorizer(Some(move |ctx: AuthContext<'_>| { - if matches!(ctx.action, AuthAction::Read { table_name: "core_sync_state", .. }) - && !changed_hook.swap(true, Ordering::SeqCst) - { - writer.execute_batch("BEGIN IMMEDIATE; UPDATE wallets SET birth_height = 10; UPDATE core_sync_state SET synced_height = 200; COMMIT;").unwrap(); - } - Authorization::Allow - })).unwrap(); + persister + .lock_conn_for_test() + .authorizer(Some(move |ctx: AuthContext<'_>| { + if matches!( + ctx.action, + AuthAction::Read { + table_name: "core_sync_state", + .. + } + ) && !changed_hook.swap(true, Ordering::SeqCst) + { + let error = writer.execute_batch("BEGIN IMMEDIATE").unwrap_err(); + assert_eq!( + error.sqlite_error_code(), + Some(rusqlite::ErrorCode::DatabaseBusy) + ); + } + Authorization::Allow + })) + .unwrap(); let state = persister.load().unwrap(); assert!( changed.load(Ordering::SeqCst), - "external commit must occur during load" + "external writer must attempt to enter during load" ); let metadata = &state.wallets[&wallet_id].wallet_info.metadata; assert_eq!(metadata.birth_height, 0); @@ -1878,4 +1892,13 @@ fn should_load_wallet_from_one_snapshot_during_an_external_commit() { metadata.synced_height, 100, "wallet metadata and checkpoint share one snapshot" ); + persister + .lock_conn_for_test() + .authorizer(None::) -> Authorization>) + .unwrap(); + let writer = rusqlite::Connection::open(&path).unwrap(); + writer.execute_batch("BEGIN IMMEDIATE; UPDATE wallets SET birth_height = 10; UPDATE core_sync_state SET synced_height = 200; COMMIT;").unwrap(); + let next = persister.load().unwrap(); + let metadata = &next.wallets[&wallet_id].wallet_info.metadata; + assert_eq!((metadata.birth_height, metadata.synced_height), (10, 200)); } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs index edffd52b99c..2cb263a3dec 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migration_execution.rs @@ -160,8 +160,8 @@ fn transaction_height_and_blob(conn: &Connection, wallet: &WalletId) -> (Option< .unwrap() } -/// Assert the post-migration store carries the full fixture data intact. -fn assert_full_data_preserved(conn: &Connection) { +/// Assert V021 rewinds mined Core data while retaining wallet and identity state. +fn assert_migrated_data(conn: &Connection) { let full = wid(FULL_WALLET); assert_eq!( schema_version(conn), @@ -187,24 +187,22 @@ fn assert_full_data_preserved(conn: &Connection) { "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", &full, ); - assert_eq!(utxos, 1, "UTXO preserved"); + assert_eq!(utxos, 0, "V021 rescans mined outputs from birth"); assert_eq!( count( conn, "SELECT COUNT(*) FROM core_transactions WHERE wallet_id = ?1", &full ), - 1 + 0, + "V021 rescans mined history with no retained unmined claims" ); - let (height, record_blob) = transaction_height_and_blob(conn, &full); - assert_eq!(height, Some(200), "transaction height preserved by V010"); - let record: key_wallet::managed_account::transaction_record::TransactionRecord = - platform_wallet_storage::sqlite::schema::blob::decode(&record_blob) - .expect("transaction record blob preserved by V010"); - assert_eq!( - record.height(), - Some(200), - "transaction record blob retains its block context" + let (synced, birth): (i64, i64) = conn.query_row( + "SELECT s.synced_height, w.birth_height FROM core_sync_state s JOIN wallets w USING(wallet_id) WHERE wallet_id=?1", + rusqlite::params![full.as_slice()], |row| Ok((row.get(0)?, row.get(1)?))).unwrap(); + assert!( + synced <= birth.saturating_sub(1).max(0), + "rescan must start at wallet birth" ); assert_eq!( count( @@ -258,17 +256,34 @@ fn v4_2_dev_database_opens_and_migrates_forward() { ); transaction_height_and_blob(&pre, &wid(FULL_WALLET)) }; - let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert_eq!(original_transaction.0, Some(200)); + let record: key_wallet::managed_account::transaction_record::TransactionRecord = + platform_wallet_storage::sqlite::schema::blob::decode(&original_transaction.1).unwrap(); + assert_eq!(record.height(), Some(200)); + // Pin preservation through V020 before the deliberate V021 rescan. { - let conn = p.lock_conn_for_test(); - assert_full_data_preserved(&conn); + let mut conn = Connection::open(&path).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); assert_eq!( transaction_height_and_blob(&conn, &wid(FULL_WALLET)), original_transaction, - "the rebuild must preserve the fixture transaction height and blob byte-for-byte" + "V010 rebuild and V020 accounting preserve this fixture byte-for-byte" + ); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id=?1", + &wid(FULL_WALLET) + ), + 1 ); } - // The full wallet reconstructs; converted pool and UTXO usage agree. + let p = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); + assert_migrated_data(&p.lock_conn_for_test()); + // The full wallet reconstructs and retained pool usage still prevents address reuse. let state = p.load().unwrap(); let full = wid(FULL_WALLET); assert!( @@ -278,7 +293,7 @@ fn v4_2_dev_database_opens_and_migrates_forward() { assert_eq!( used_set(&p, &full).len(), 1, - "migrated store falls back to the UTXO-derived used-set" + "persisted address usage survives the Core rescan" ); } @@ -332,8 +347,11 @@ fn v014_backfills_recordless_confirmed_utxo_height() { assert_eq!(schema_version(&conn), 13); } - let persister = SqlitePersister::open(SqlitePersisterConfig::new(&path)).unwrap(); - let conn = persister.lock_conn_for_test(); + let mut conn = Connection::open(&path).unwrap(); + mig::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); let (height, record_blob): (Option, Option>) = conn .query_row( "SELECT height, record_blob FROM core_transactions \ @@ -362,6 +380,24 @@ fn v014_backfills_recordless_confirmed_utxo_height() { legacy_unconfirmed_rows, 0, "legacy height zero was the unconfirmed sentinel and must not be backfilled" ); + mig::run(&mut conn).unwrap(); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id=?1", + &wallet_id + ), + 0 + ); + assert_eq!( + count( + &conn, + "SELECT COUNT(*) FROM core_transactions WHERE wallet_id=?1", + &wallet_id + ), + 0, + "V021 removes the height-only record when its funding is rescanned" + ); } /// The empty wallet inside the populated store migrates without a @@ -504,7 +540,7 @@ fn backup_restorable_and_remigration_deterministic() { } let p2 = SqlitePersister::open(SqlitePersisterConfig::new(&dest)).unwrap(); let conn = p2.lock_conn_for_test(); - assert_full_data_preserved(&conn); + assert_migrated_data(&conn); } /// The forward-version gate rejects at the newest embedded diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs index ce850eb949c..7cba67f1e4c 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_migrations.rs @@ -293,6 +293,11 @@ fn tc044_load_empty_is_empty() { /// existing install experiences. #[test] fn tc045_v004_widens_asset_lock_status_on_existing_db() { + use dashcore::{OutPoint, Transaction}; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + use platform_wallet::changeset::AssetLockEntry; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + use platform_wallet_storage::sqlite::schema::asset_locks::encode_entry_for_test; use rusqlite::params; let mut conn = rusqlite::Connection::open_in_memory().expect("open in-memory db"); @@ -312,10 +317,28 @@ fn tc045_v004_widens_asset_lock_status_on_existing_db() { ) .expect("insert wallet"); let outpoint_a = [1u8; 36]; + let lifecycle = encode_entry_for_test(&AssetLockEntry { + operation_id: None, + out_point: OutPoint::null(), + transaction: Transaction { + version: 1, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: None, + }, + account_index: 0, + funding_type: AssetLockFundingType::IdentityRegistration, + identity_index: 4, + amount_duffs: 1_000_000, + status: AssetLockStatus::ChainLocked, + proof: None, + }) + .unwrap(); conn.execute( "INSERT INTO asset_locks (wallet_id, outpoint, status, account_index, identity_index, \ - amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'chain_locked', 0, 4, 1000000, X'01')", - params![wallet_id.as_slice(), outpoint_a.as_slice()], + amount_duffs, lifecycle_blob) VALUES (?1, ?2, 'chain_locked', 0, 4, 1000000, ?3)", + params![wallet_id.as_slice(), outpoint_a.as_slice(), lifecycle], ) .expect("insert pre-upgrade asset lock"); @@ -482,8 +505,11 @@ fn tc046_v014_purges_legacy_empty_script_spent_utxos() { "expected AddressDecode from the empty script, got {err:?}" ); - // 4. Upgrade to the latest schema (applies V013's purge). - mig::run(&mut conn).expect("migrate to latest"); + // Check the purge before V021 intentionally removes recordless unspent rows. + mig::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); // 5. The poisoned row is gone... let poisoned_rows: i64 = conn @@ -541,6 +567,17 @@ fn tc046_v014_purges_legacy_empty_script_spent_utxos() { unspent_rows, 1, "an unspent row is balance state and must survive any script content" ); + mig::run(&mut conn).unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM core_utxos WHERE wallet_id = ?1", + params![untouched.as_slice()], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0, + "V021 rebuilds recordless unspent outputs through rescan" + ); } /// V014 rebuilds `core_transactions` into an FK-declaring twin and backfills diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs index 863c2e4ef71..6a0b94212cc 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_schema_pinning.rs @@ -15,13 +15,13 @@ use platform_wallet_storage::sqlite::{migrations as mig, schema::versions::Domai /// Golden `(version, name)` fingerprint of the frozen migration set. Bump /// deliberately only when adding/removing/renaming a migration file. const EXPECTED_ID_FINGERPRINT: &str = - "fdcaec1d3a0a960adbe02405601166c32487f10b5a253f175041ae99af7c5326"; + "1b88153391ba20a55dc38170584259e562262a3c86d5ecab1caa85a09c20a5d2"; /// Golden content-level fingerprint over every migration's rendered SQL. /// Bump it only when ADDING a migration file; a body change on an already /// applied migration is a defect, not a golden to refresh. const EXPECTED_SQL_FINGERPRINT: &str = - "d8fbb57cd8cbccd51c729dacd2fd3b7e72b5fe80cb38c2f603b75dc94730d4e2"; + "55689b73b7c4d1242948bda3676194879f5a17b10994aa22d7a4320c61ba8a1e"; /// The migrations merged `v4.2-dev` already ships. Refinery keys /// `refinery_schema_history` by version and validates an applied migration's diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs index 4286c18bcce..e29c88d1851 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_transaction_sweeps.rs @@ -3756,7 +3756,10 @@ fn should_preserve_mainline_sweep_holds_through_rehydration_migrations() { params![w.as_slice(), encoded], ) .unwrap(); - migrations::run(&mut conn).unwrap(); + migrations::runner() + .set_target(refinery::Target::Version(20)) + .run(&mut conn) + .unwrap(); assert_eq!( utxo_row_state(&conn, &w, &input), Some((true, true, Some(400))) @@ -3768,6 +3771,13 @@ fn should_preserve_mainline_sweep_holds_through_rehydration_migrations() { assert!(row_exists(&conn, &w, &input)); apply_heights(&mut conn, &w, 400); assert!(!row_exists(&conn, &w, &input)); + conn.execute("INSERT INTO core_utxos (wallet_id, outpoint, value, script, spent, is_sweep_placeholder, winner_mined_height) VALUES (?1, ?2, 0, X'', 1, 1, 400)", + params![w.as_slice(), blob::encode_outpoint(&input).unwrap()]).unwrap(); + migrations::run(&mut conn).unwrap(); + assert!( + !row_exists(&conn, &w, &input), + "V021 rescans mined sweep holds" + ); } #[test] From 3dfee043005b4b95f3705c19a580237dd3ec451a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:00:06 +0000 Subject: [PATCH 82/84] test(wallet): model proof discovery and atomic replay callback requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate advisory proof discovery from the cursor tests' authoritative snapshot sequence, retaining their original publication-lock assertions. Wire all atomic replay callbacks in the reconciliation capability fixture and verify each missing callback removes the capability. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent Co-Authored-By: OpenAI Codex --- .../rs-platform-wallet-ffi/src/persistence.rs | 95 ++++++++++++------- .../src/wallet/identity/network/payments.rs | 5 + 2 files changed, 64 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index c2ddf1dcf0a..dd0db761f35 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -11229,6 +11229,41 @@ mod tests { ); } + fn complete_replay_extensions() -> PersistenceExtensionCallbacks { + unsafe extern "C" fn scope( + _: *mut c_void, + _: *const u8, + _: ReplayOperationFn, + _: *mut c_void, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const ReplayClaimRestoreFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free(_: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) {} + unsafe extern "C" fn apply( + _: *mut c_void, + _: *const u8, + _: *const HistoryReplayEffectsFFI, + ) -> i32 { + 0 + } + PersistenceExtensionCallbacks { + wallet_changeset_sweeps: Some(noop_wallet_changeset_sweeps), + replay_wallet: Some(scope), + load_replay_claims: Some(load), + free_replay_claims: Some(free), + apply_replay_effects: Some(apply), + ..Default::default() + } + } + #[test] fn asset_lock_reconciliation_requires_every_callback_leg() { fn complete_callbacks() -> PersistenceCallbacks { @@ -11243,9 +11278,16 @@ mod tests { } let required = PersistenceCapabilities::ASSET_LOCK_RECONCILIATION; - assert!(declared_persister(complete_callbacks(), required) - .persistence_capabilities() - .contains(required)); + let persister = |callbacks, extensions| { + FFIPersister::new_with_persistence_capabilities_and_extensions( + callbacks, required, extensions, + ) + }; + assert!( + persister(complete_callbacks(), complete_replay_extensions()) + .persistence_capabilities() + .contains(required) + ); let mut missing_begin = complete_callbacks(); missing_begin.on_changeset_begin_fn = None; @@ -11265,7 +11307,19 @@ mod tests { missing_load, missing_load_free, ] { - assert!(!declared_persister(callbacks, required) + assert!(!persister(callbacks, complete_replay_extensions()) + .persistence_capabilities() + .contains(required)); + } + for missing in 0..4 { + let mut extensions = complete_replay_extensions(); + match missing { + 0 => extensions.replay_wallet = None, + 1 => extensions.load_replay_claims = None, + 2 => extensions.free_replay_claims = None, + _ => extensions.apply_replay_effects = None, + } + assert!(!persister(complete_callbacks(), extensions) .persistence_capabilities() .contains(required)); } @@ -11298,41 +11352,10 @@ mod tests { cb.on_persist_token_balances_fn = Some(noop_token_balances); // "Fully wired" includes the extension's sweeps slot — the legacy // vtable alone can no longer attest CORE_SWEEP_REMOVAL. - unsafe extern "C" fn scope( - _: *mut c_void, - _: *const u8, - _: ReplayOperationFn, - _: *mut c_void, - ) -> i32 { - 0 - } - unsafe extern "C" fn load( - _: *mut c_void, - _: *const u8, - _: *mut *const ReplayClaimRestoreFFI, - _: *mut usize, - ) -> i32 { - 0 - } - unsafe extern "C" fn free(_: *mut c_void, _: *const ReplayClaimRestoreFFI, _: usize) {} - unsafe extern "C" fn apply( - _: *mut c_void, - _: *const u8, - _: *const HistoryReplayEffectsFFI, - ) -> i32 { - 0 - } let capabilities = FFIPersister::new_with_persistence_capabilities_and_extensions( cb, expected, - PersistenceExtensionCallbacks { - wallet_changeset_sweeps: Some(noop_wallet_changeset_sweeps), - replay_wallet: Some(scope), - load_replay_claims: Some(load), - free_replay_claims: Some(free), - apply_replay_effects: Some(apply), - ..Default::default() - }, + complete_replay_extensions(), ) .persistence_capabilities(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 345a9d1828c..677fca90be0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -6058,6 +6058,11 @@ mod tests { } impl PlatformWalletPersistence for SequencedHost { + fn replay_lock_candidates(&self) -> Result, PersistenceError> { + // Advisory proof discovery must not advance the authoritative snapshot sequence. + self.current.replay_lock_candidates() + } + fn store_commits_inline(&self) -> bool { self.current.store_commits_inline() } From dd216528d9400bf8844809876d333f76e2ad2b95 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:31:32 +0000 Subject: [PATCH 83/84] fix(platform-wallet): adapt replay evidence to current SPV APIs Bind verified InstantLock evidence to the restoring wallet's network via the explicit matches_transaction argument. Select fixture networks explicitly, including Mainnet evidence in the cross-network rejection test. Adapt the in-process test transport to NetworkManager's shared-reference API. Await the invalid-lock progress counter instead of the removed validated:false event; retain both delivery orders, durable-state and restart assertions, and also reject verified proof events for invalid locks. Install only the valid fixture's random-key quorum cycle. Co-Authored-By: OpenAI Codex --- .../src/persistence/replay.rs | 3 +- .../tests/integration/common/mod.rs | 2 +- .../sqlite_shared_history_replay.rs | 4 +- .../sqlite_unverified_lock_producer.rs | 109 ++++++++---------- .../src/manager/history_replay.rs | 12 +- .../rs-platform-wallet/src/manager/load.rs | 24 ++-- 6 files changed, 78 insertions(+), 76 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs index 4c2fda2f2ce..5a50bfbda37 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence/replay.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence/replay.rs @@ -471,6 +471,7 @@ mod tests { use crate::types::FFINetwork; use crate::wallet_restore_types::RecordedTransactionRestoreFFI; use crate::wallet_restore_types::{UtxoRestoreEntryFFI, WalletRestoreEntryFFI}; + use dashcore::Network; use dashcore::{Transaction, TxIn}; use platform_wallet::changeset::recorded_history::ReplayEvidence; use platform_wallet::changeset::{PersistenceCapabilities, PlatformWalletPersistence}; @@ -949,7 +950,7 @@ mod tests { ) .await .unwrap(); - let fixture = InstantLockFixture::new(transaction).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, transaction).unwrap(); fixture.install(&client).await.unwrap(); let proof = client .verify_instant_lock(fixture.instant_lock()) diff --git a/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs b/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs index 408e5223ce6..87ccf6d4a32 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/common/mod.rs @@ -418,7 +418,7 @@ pub fn authorize_with_mode( /// Produce evidence through the public verifier; no synthetic token constructor. pub async fn checked_instant_lock(transaction: &Transaction) -> (InstantLock, ReplayEvidence) { - let fixture = InstantLockFixture::new(transaction).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, transaction).unwrap(); let directory = secure_tempdir().unwrap(); let client = DashSpvClient::new( ClientConfig::new(Network::Testnet) diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs index 5d88d0d6d55..993cf72b79a 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_shared_history_replay.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; use dash_spv::{ClientConfig, DashSpvClient}; -use dashcore::OutPoint; +use dashcore::{Network, OutPoint}; use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; use dpp::prelude::AssetLockProof; use key_wallet::transaction_checking::TransactionContext; @@ -42,7 +42,7 @@ async fn should_retry_sqlite_history_with_verified_lock_before_unsafe_amount_fil let mut alternative = info.clone(); records.extend(records_of(&mut info, &mut wallet, &locked, TransactionContext::Mempool).await); records.extend(records_of(&mut alternative, &mut wallet, &competitor, block(101)).await); - let fixture = InstantLockFixture::new(&locked).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, &locked).unwrap(); let (persister, _directory, path) = fresh_persister(); persister .store( diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs index 080e5d8b1d6..32770cc2930 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unverified_lock_producer.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use std::time::Duration; use dash_spv::error::NetworkResult; -use dash_spv::network::{Message, MessageType, NetworkEvent, NetworkManager, RequestSender}; -use dash_spv::sync::SyncEvent; +use dash_spv::network::{Inbound, MessageType, NetworkEvent, NetworkManager, RequestKey}; use dash_spv::test_utils::{InstantLockFixture, MockNetworkManager}; use dash_spv::{ClientConfig, DashSpvClient, EventHandler}; +use dash_spv::{SyncEvent, SyncProgress}; use dashcore::network::message::NetworkMessage; use dashcore::{Network, OutPoint, Txid}; use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; @@ -20,7 +20,7 @@ use platform_wallet::test_support::core_fixtures::{ }; use platform_wallet::wallet::platform_wallet::PlatformWalletInfo; use platform_wallet_storage::{SqlitePersister, SqlitePersisterConfig}; -use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; +use tokio::sync::{broadcast, mpsc, RwLock}; use crate::common::{ authorize, fresh_persister, mine, persist_events, registration, secure_tempdir, @@ -29,56 +29,54 @@ use crate::common::{ /// Shared handle to the existing in-process transport. No method opens sockets or resolves DNS. #[derive(Clone)] struct InjectedNetwork { - inner: Arc>, - requests: RequestSender, - events: broadcast::Sender, + inner: Arc, } impl InjectedNetwork { fn new() -> Self { - let inner = MockNetworkManager::new(); Self { - requests: inner.request_sender(), - inner: Arc::new(Mutex::new(inner)), - events: broadcast::channel(16).0, + inner: Arc::new(MockNetworkManager::new()), } } } #[async_trait::async_trait] impl NetworkManager for InjectedNetwork { - async fn message_receiver( - &mut self, - types: &[MessageType], - ) -> mpsc::UnboundedReceiver { - self.inner.lock().await.message_receiver(types).await + async fn start(&self) { + self.inner.start().await; } - fn request_sender(&self) -> RequestSender { - self.requests.clone() + async fn stop(&self) { + self.inner.stop().await; } - async fn start(&mut self) -> NetworkResult<()> { - self.inner.lock().await.start().await + async fn enqueue(&self, height: u32, message: NetworkMessage) { + self.inner.enqueue(height, message).await; } - async fn stop(&mut self) { - self.inner.lock().await.stop().await; + async fn send_to(&self, address: SocketAddr, message: NetworkMessage) -> bool { + self.inner.send_to(address, message).await } - async fn send_message(&mut self, message: NetworkMessage) -> NetworkResult<()> { - self.inner.lock().await.send_message(message).await + async fn broadcast(&self, message: NetworkMessage) -> NetworkResult<()> { + self.inner.broadcast(message).await } - fn peer_count(&self) -> usize { - 1 + async fn dispatch_local(&self, message: NetworkMessage) { + self.inner.dispatch_local(message).await; } - async fn broadcast(&self, _message: NetworkMessage) -> NetworkResult<()> { - Ok(()) + async fn release_request(&self, key: RequestKey) { + self.inner.release_request(key).await; } - async fn dispatch_local(&self, message: NetworkMessage) { - self.inner.lock().await.dispatch_local(message).await; + async fn subscribe(&self, types: &[MessageType]) -> mpsc::UnboundedReceiver { + self.inner.subscribe(types).await + } + fn events(&self) -> broadcast::Receiver { + self.inner.events() + } + fn tip(&self) -> u32 { + self.inner.tip() } - async fn disconnect_peer(&self, _address: &SocketAddr, _reason: &str) -> NetworkResult<()> { - Ok(()) + async fn connected_count(&self) -> u32 { + self.inner.connected_count().await } - fn subscribe_network_events(&self) -> broadcast::Receiver { - self.events.subscribe() + async fn ban_peer(&self, address: SocketAddr) { + self.inner.ban_peer(address).await; } } @@ -86,19 +84,23 @@ impl NetworkManager for InjectedNetwork { enum Notice { Detected(Txid), Locked(Txid), - Proof(Txid, bool), + Proof(Txid), + Rejected(u32), Error(String), } struct Observer(mpsc::UnboundedSender); impl EventHandler for Observer { fn on_sync_event(&self, event: &SyncEvent) { - if let SyncEvent::InstantLockReceived { - instant_lock, - validated, - } = event - { - let _ = self.0.send(Notice::Proof(instant_lock.txid, *validated)); + if let SyncEvent::InstantLockReceived { instant_lock } = event { + let _ = self.0.send(Notice::Proof(instant_lock.txid())); + } + } + fn on_progress(&self, progress: &SyncProgress) { + if let Ok(instantsend) = progress.instantsend() { + if instantsend.invalid() > 0 { + let _ = self.0.send(Notice::Rejected(instantsend.invalid())); + } } } fn on_wallet_event(&self, event: &WalletEvent) { @@ -162,8 +164,8 @@ async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_ .collect(); let invalid_id = sends[0].txid(); let valid_id = sends[1].txid(); - let invalid_fixture = InstantLockFixture::new(&sends[0]).unwrap(); - let valid_fixture = InstantLockFixture::new(&sends[1]).unwrap(); + let invalid_fixture = InstantLockFixture::new(Network::Testnet, &sends[0]).unwrap(); + let valid_fixture = InstantLockFixture::new(Network::Testnet, &sends[1]).unwrap(); let mut invalid = invalid_fixture.instant_lock().clone(); invalid.signature = valid_fixture.instant_lock().signature; let mut manager = WalletManager::::new(Network::Testnet); @@ -193,7 +195,6 @@ async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_ .unwrap(); client.run().await.unwrap(); valid_fixture.install(&client).await.unwrap(); - invalid_fixture.install(&client).await.unwrap(); assert!(client.verify_instant_lock(&invalid).await.is_err()); let checked = client .verify_instant_lock(valid_fixture.instant_lock()) @@ -208,14 +209,10 @@ async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_ network .dispatch_local(NetworkMessage::ISLock(invalid.clone())) .await; - let seen = receive_until( - &mut observed, - |n| matches!(n, Notice::Proof(id, false) if *id == invalid_id), - ) - .await; + let seen = receive_until(&mut observed, |n| matches!(n, Notice::Rejected(1))).await; assert!(!seen .iter() - .any(|n| matches!(n, Notice::Locked(id) if *id == invalid_id))); + .any(|n| matches!(n, Notice::Locked(id) | Notice::Proof(id) if *id == invalid_id))); } for send in &sends { network @@ -231,14 +228,10 @@ async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_ network .dispatch_local(NetworkMessage::ISLock(invalid)) .await; - let seen = receive_until( - &mut observed, - |n| matches!(n, Notice::Proof(id, false) if *id == invalid_id), - ) - .await; + let seen = receive_until(&mut observed, |n| matches!(n, Notice::Rejected(1))).await; assert!(!seen .iter() - .any(|n| matches!(n, Notice::Locked(id) if *id == invalid_id))); + .any(|n| matches!(n, Notice::Locked(id) | Notice::Proof(id) if *id == invalid_id))); } network .dispatch_local(NetworkMessage::ISLock(valid_fixture.instant_lock().clone())) @@ -250,8 +243,8 @@ async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_ .await; assert!(!seen .iter() - .any(|n| matches!(n, Notice::Locked(id) if *id == invalid_id))); - // Both locks traverse the same ordered IS event receiver; the valid wallet + .any(|n| matches!(n, Notice::Locked(id) | Notice::Proof(id) if *id == invalid_id))); + // Both locks traverse the same ordered IS message receiver; the valid wallet // event is a processing barrier, rather than a sleep asserting absence. client.stop().await; persist_events(&manager, &persister, &mut persistence_events).await; @@ -265,7 +258,7 @@ async fn should_never_persist_unverified_lock_from_live_spv_before_or_after_its_ .unwrap(); assert_eq!( raw, 0, - "validated:false must not produce durable IS evidence" + "a rejected lock must not produce durable IS evidence" ); assert!(matches!( persister diff --git a/packages/rs-platform-wallet/src/manager/history_replay.rs b/packages/rs-platform-wallet/src/manager/history_replay.rs index 8d274152ea4..cd4169abda4 100644 --- a/packages/rs-platform-wallet/src/manager/history_replay.rs +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -428,7 +428,7 @@ pub async fn replay_recorded_history_with_evidence( if let Some(proof) = evidence.instant_locks.get(&stored.txid).filter(|proof| { proof.network() == wallet_info.network() && proof.txid() == stored.txid - && proof.matches_transaction(&stored.transaction) + && proof.matches_transaction(wallet_info.network(), &stored.transaction) }) { deferred.remove(&stored.txid); history @@ -456,7 +456,7 @@ pub async fn replay_recorded_history_with_evidence( proof.network() == wallet_info.network() && proof.txid() == stored.txid && proof.instant_lock() == lock - && proof.matches_transaction(&stored.transaction) + && proof.matches_transaction(wallet_info.network(), &stored.transaction) }) { deferred.insert(stored.txid); @@ -1520,7 +1520,7 @@ mod tests { use std::sync::Arc; use tokio::sync::RwLock; - let fixture = InstantLockFixture::new(transaction).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, transaction).unwrap(); let directory = tempfile::TempDir::new().unwrap(); let client = DashSpvClient::new( ClientConfig::new(Network::Testnet) @@ -2029,7 +2029,7 @@ mod tests { let mut competitor = records[1].clone(); competitor.transaction.output[0].value -= 1; competitor.txid = competitor.transaction.txid(); - let fixture = InstantLockFixture::new(&locked.transaction).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, &locked.transaction).unwrap(); let raw = history( vec![locked.clone(), competitor], BTreeMap::from([(locked.txid, fixture.instant_lock().clone())]), @@ -2259,7 +2259,7 @@ mod tests { let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; let mut locked = records[1].clone(); locked.context = TransactionContext::Mempool; - let fixture = InstantLockFixture::new(&locked.transaction).unwrap(); + let fixture = InstantLockFixture::new(Network::Mainnet, &locked.transaction).unwrap(); fixture.install(&client).await.unwrap(); let proof = client .verify_instant_lock(fixture.instant_lock()) @@ -2296,7 +2296,7 @@ mod tests { let (mut wallet, mut info, _, records, _) = confirmed_spend_fixture().await; let locked = records[1].clone(); - let fixture = InstantLockFixture::new(&locked.transaction).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, &locked.transaction).unwrap(); let directory = tempfile::TempDir::new().unwrap(); let client = DashSpvClient::new( ClientConfig::testnet() diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 63b627521bd..f36778b7bbe 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -975,7 +975,7 @@ mod idempotent_load_tests { use std::sync::Mutex; - use dashcore::{OutPoint, Transaction, Txid}; + use dashcore::{Network, OutPoint, Transaction, Txid}; use key_wallet::account::{AccountType, StandardAccountType}; use key_wallet::managed_account::transaction_record::{ TransactionDirection, TransactionRecord, @@ -1511,7 +1511,8 @@ mod idempotent_load_tests { .with_mempool_funding(100_000) .await; let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); - let fixture = dash_spv::test_utils::InstantLockFixture::new(&spend).unwrap(); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &spend).unwrap(); let lock = TrackedAssetLock { operation_id: None, out_point: OutPoint::new(spend.txid(), 0), @@ -1642,7 +1643,8 @@ mod idempotent_load_tests { .with_mempool_funding(100_000) .await; let spend = spend_to(OutPoint::new(funding.txid(), 0), 74_000); - let fixture = dash_spv::test_utils::InstantLockFixture::new(&spend).unwrap(); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &spend).unwrap(); let persister = PendingSendPersister { wallet: ctx.wallet, managed: ctx.managed_wallet, @@ -1674,7 +1676,7 @@ mod idempotent_load_tests { .with_mempool_funding(100_000) .await; let wallet_id = ctx.wallet.compute_wallet_id(); - let fixture = InstantLockFixture::new(&funding).unwrap(); + let fixture = InstantLockFixture::new(Network::Testnet, &funding).unwrap(); let history = RecordedHistory { required_instant_locks: Default::default(), owned_prevouts: Default::default(), @@ -2043,7 +2045,9 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; - let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock) + .unwrap(); let proof = (status == AssetLockStatus::InstantSendLocked).then(|| { dpp::prelude::AssetLockProof::Instant( dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof::new( @@ -2375,7 +2379,9 @@ mod idempotent_load_tests { )]), _ => BTreeMap::new(), }; - let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock) + .unwrap(); let instant = |txid| { let mut lock = fixture.instant_lock().clone(); lock.txid = txid; @@ -2581,7 +2587,8 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; - let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock).unwrap(); let instant_lock = fixture.instant_lock().clone(); let mut wallet = ctx.wallet.clone(); let manager = make_history_manager(RecordedHistoryPersister { @@ -2682,7 +2689,8 @@ mod idempotent_load_tests { txid: asset_lock.txid(), vout: 0, }; - let fixture = dash_spv::test_utils::InstantLockFixture::new(&asset_lock).unwrap(); + let fixture = + dash_spv::test_utils::InstantLockFixture::new(Network::Testnet, &asset_lock).unwrap(); let proof = dpp::prelude::AssetLockProof::Instant( dpp::identity::state_transition::asset_lock_proof::instant::InstantAssetLockProof::new( fixture.instant_lock().clone(), From ecc4921672595f01175ded5c4d9919bc9fcd1f9c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:39:49 +0000 Subject: [PATCH 84/84] test(swift-sdk): account for the InstantLock entity in the V5 schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live V5 carries both unreleased additions: PersistentTransactionAccounting from the parent and PersistentCoreInstantLock from this branch. Expect both in the V4 → V5 entity diff, list the InstantLock model in schema-models.json, and document it in SCHEMA_RELEASES.md. Co-Authored-By: OpenAI Codex Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYijwZNbw3EjHtdMAHC --- packages/swift-sdk/SCHEMA_RELEASES.md | 11 ++++++++--- .../SwiftDashSDKTests/DashModelMigrationTests.swift | 4 ++-- .../SwiftDashSDKTests/DashReleasedSchemaTests.swift | 2 +- packages/swift-sdk/schema-models.json | 3 ++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index 03231e279c2..c23dcdb2700 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -24,9 +24,14 @@ schema. The former live V2 is accepted only when its complete graph matches frozen V3 exactly, entity hashes and checksum alike. The alias remains supported when the live graph changes; other stores labelled `2.0.0` fail closed. -V5 adds the `PersistentTransactionAccounting` entity and the -`PersistentWallet.transactionAccountingBackfilled` attribute (default `false`) -through a lightweight migration. Each accounting row, one per +V5 adds the `PersistentTransactionAccounting` and `PersistentCoreInstantLock` +entities and the `PersistentWallet.transactionAccountingBackfilled` attribute +(default `false`) through a lightweight migration. Both entities belong to the same unreleased +V5 graph; V4 remains frozen. `PersistentCoreInstantLock` stores raw consensus +bytes per `(networkRaw, walletId, txid)` for Rust's load-time SPV verification +after restore; storing the bytes does not establish finality. + +Each accounting row, one per `(walletId, txid)`, holds the net amount, direction and fee `rs-platform-wallet` emitted for that wallet. Migrated stores start with no rows; at the first load of each wallet Rust reads back the wallet's transactions that lack a row diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index 0b5d6272d1e..672e8983cd9 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -456,11 +456,11 @@ final class DashModelMigrationTests: XCTestCase { ["PersistentTrackedMasternode", "PersistentIdentityBalanceMetadata"]) } - func testV5AddsPerWalletTransactionAccountingToV4() { + func testV5AddsPerWalletTransactionAccountingAndCoreInstantLocksToV4() { XCTAssertEqual( Set(Schema(versionedSchema: DashSchemaV5.self).entities.map(\.name)) .subtracting(Schema(versionedSchema: DashSchemaV4.self).entities.map(\.name)), - ["PersistentTransactionAccounting"]) + ["PersistentTransactionAccounting", "PersistentCoreInstantLock"]) } @MainActor diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift index aa42eab571b..5cae950d672 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashReleasedSchemaTests.swift @@ -106,7 +106,7 @@ final class DashReleasedSchemaTests: XCTestCase { } } - /// V4 → V5 adds the per-wallet accounting entity: migrated rows start + /// The per-wallet accounting entity added in V5: migrated rows start /// without one and keep their stored amount, and a row written after the /// migration survives a reopen and cascades with its transaction. @MainActor diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index b712344a688..dc61fbfc8c6 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -37,7 +37,8 @@ "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift", - "PersistentTransactionAccounting": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift" + "PersistentTransactionAccounting": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTransactionAccounting.swift", + "PersistentCoreInstantLock": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentCoreInstantLock.swift" }, "value_types": [ {