diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index d95a1f31db8..835a4b60f70 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. /// @@ -154,6 +160,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 @@ -1545,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); @@ -5098,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 @@ -5122,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 @@ -5190,6 +5204,114 @@ 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::deserialize; + 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 deserialize::(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, + // 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_inputs: Vec::new(), + }); + } + 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( @@ -5850,12 +5972,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, }; let platform_address_state = if per_account.is_empty() @@ -8521,6 +8648,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); @@ -8763,6 +8891,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 @@ -10921,4 +11053,343 @@ 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 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() + }; + 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 + }; + + 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 + .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_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(); + 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-platform-wallet-storage/src/sqlite/persister.rs b/packages/rs-platform-wallet-storage/src/sqlite/persister.rs index 546bc42a13c..12c380af355 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; @@ -1322,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) @@ -1850,13 +1851,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 +1872,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 4b52af8ba63..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,271 +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()); - stage_recorded_spent_inputs(wallet_info, &replay, &placed); - let mut instant_send_winners = Vec::new(); - 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.clone(), wallet, true, false) - .await; - if matches!(context, TransactionContext::InstantSend(_)) { - instant_send_winners.push((record.transaction, context)); - } - } - }) - .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; - } - // A lock's sweep only reaches conflicts already replayed; siblings replay - // by txid, so settle the ones that came after their winner here. - for (transaction, context) in &instant_send_winners { - wallet_info.sweep_conflicts(transaction, context); - } - - 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(); -} - -/// 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 -/// `input_details` are the evidence: wallet-owned by construction and repaired -/// from persisted outputs. 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: &[TransactionRecord], - placed: &HashMap, -) { - use key_wallet::managed_account::managed_account_trait::ManagedAccountTrait; - - 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.input_details { - let Some(input) = record.transaction.input.get(detail.index as usize) else { - continue; - }; - let outpoint = input.previous_output; - if placed.contains_key(&outpoint) || replayed.contains(&outpoint.txid) { - continue; - } - let Some(account) = accounts - .iter_mut() - .find(|account| account.contains_address(&detail.address)) - else { - continue; - }; - account.utxos.entry(outpoint).or_insert_with(|| { - let txout = dashcore::TxOut { - value: detail.value, - script_pubkey: detail.address.script_pubkey(), - }; - key_wallet::Utxo::new(outpoint, txout, detail.address.clone(), 0, false) - }); - } - } -} - -/// 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) @@ -3429,639 +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" - ); - } - - /// 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. - #[tokio::test] - async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { - 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 in [false, true] { - let case = format!("lock_later_txid={lock_later_txid}"); - 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 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 = InstantLock { - inputs: vec![OutPoint::new(funding.txid(), 0)], - txid: winner.txid(), - ..Default::default() - }; - let locks: BTreeMap = [(winner.txid(), lock)].into_iter().collect(); - - restore_recorded_transactions(&mut restored, &mut wallet, records, &locks); - - 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}"); - } - } - - /// 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 fafb08ff9d8..4732f24a015 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, @@ -183,10 +197,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) { @@ -240,7 +258,7 @@ impl Fixture { }, ) .unwrap(); - let (_, reloaded) = self.load(); + let (_, reloaded) = self.load().await; self.assert_spent_excluded(&reloaded); } } @@ -248,7 +266,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; } @@ -256,7 +274,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]), @@ -270,7 +288,7 @@ async fn should_keep_spent_output_excluded_after_finality_pruning() { #[tokio::test] async fn should_keep_spent_output_excluded_after_finality_pruning_with_height_only_funding() { let fixture = Fixture::with_height_only_funding(block(200)).await; - let (mut wallet, mut info) = fixture.load(); + let (mut wallet, mut info) = fixture.load().await; fixture.assert_spent_excluded(&info); info.apply_chain_lock(ChainLock { block_height: 300, @@ -285,7 +303,7 @@ async fn should_keep_spent_output_excluded_after_finality_pruning_with_height_on #[tokio::test] async fn should_keep_unconfirmed_spend_reservation_with_height_only_funding() { let fixture = Fixture::with_height_only_funding(TransactionContext::Mempool).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; fixture.assert_spent_stored(true); @@ -299,7 +317,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; } @@ -307,7 +325,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; @@ -318,7 +336,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)); @@ -329,7 +347,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; } @@ -340,7 +358,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); } @@ -348,7 +366,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 @@ -377,7 +395,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())); @@ -508,8 +526,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() @@ -638,8 +655,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 3d81e6bb810..8c172c7111f 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")] @@ -52,6 +53,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/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/changeset/recorded_history.rs b/packages/rs-platform-wallet/src/changeset/recorded_history.rs new file mode 100644 index 00000000000..710ef81c06d --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/recorded_history.rs @@ -0,0 +1,78 @@ +//! 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::{ + InputDetail, 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, + /// The wallet-owned inputs the store recorded for this transaction. + /// + /// 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. + pub owned_inputs: Vec, +} + +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), + owned_inputs: record.input_details, + 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..0ba980bceda --- /dev/null +++ b/packages/rs-platform-wallet/src/manager/history_replay.rs @@ -0,0 +1,1436 @@ +//! 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::{BTreeMap, 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::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::Utxo; + +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. +/// +/// 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( + 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 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 + // 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); + 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()) + } + (context, _) => context, + }; + let result = wallet_info + .check_core_transaction(&stored.transaction, 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 + .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)) + }); + } + 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() { + 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); + } + wallet_info.update_balance(); + replayed +} + +/// Extend `swept` with every held unconfirmed record that descends from a swept one. +/// +/// 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) + }) + .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; + } + } +} + +/// 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. +fn stage_recorded_spent_inputs( + wallet_info: &mut ManagedWalletInfo, + records: &[StoredTransaction], + placed: &HashMap, + replay_txids: &HashSet, +) { + let mut accounts = wallet_info.accounts.all_funding_accounts_mut(); + for record in records { + for detail in &record.owned_inputs { + let Some(input) = record.transaction.input.get(detail.index as usize) else { + continue; + }; + let outpoint = input.previous_output; + if placed.contains_key(&outpoint) || replay_txids.contains(&outpoint.txid) { + continue; + } + let Some(account) = accounts + .iter_mut() + .find(|account| account.contains_address(&detail.address)) + else { + continue; + }; + account.utxos.entry(outpoint).or_insert_with(|| { + let txout = TxOut { + value: detail.value, + script_pubkey: detail.address.script_pubkey(), + }; + Utxo::new(outpoint, txout, detail.address.clone(), 0, false) + }); + } + } +} + +/// 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 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. + #[tokio::test] + async fn should_rebuild_spent_mark_for_height_only_funding_from_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: Vec = records + .into_iter() + .filter(|record| record.txid != funding.txid()) + .map(StoredTransaction::from) + .collect(); + assert_eq!(spender.len(), 1); + assert_eq!(spender[0].owned_inputs.len(), 1); + let redelivery = TransactionContext::InBlock(BlockInfo::new( + 100, + BlockHash::from_byte_array([100; 32]), + 100, + )); + let replay = |transactions| RecordedHistory { + transactions, + instant_locks: BTreeMap::new(), + }; + let finalize = |info: &mut ManagedWalletInfo| { + 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()); + }; + + let mut replayed = restored.clone(); + replay_recorded_history(&mut replayed, &mut wallet, replay(spender.clone())).await; + assert!( + !replayed.accounts.standard_bip44_accounts[&0] + .utxos + .contains_key(&spent), + "the staged input must not survive the replay" + ); + assert_eq!(replayed.balance.total(), 20_000); + finalize(&mut replayed); + 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 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 + .into_iter() + .map(|stored| StoredTransaction { + 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" + ); + } + + #[tokio::test] + 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 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(txid, held.clone()); + replay_recorded_history( + &mut restored, + &mut wallet, + history(vec![held], BTreeMap::new()), + ) + .await; + 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); + } + } + + /// 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 + /// the fallback restore. Covers both sibling replay orders. + #[tokio::test] + async fn should_drop_orphaned_raw_descendant_of_swept_conflict_in_either_order() { + let (mut wallet, restored, _, records, _) = confirmed_spend_fixture().await; + 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(); + child.input_details.clear(); + let winner_with = |delta: u64| { + 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..) + .map(winner_with) + .find(|winner| (winner.txid < loser.txid) == winner_first) + .unwrap(); + 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( + &mut info, + &mut wallet, + history(vec![loser.clone(), child.clone(), winner], BTreeMap::new()), + ) + .await; + let transactions = info.accounts.standard_bip44_accounts[&0].transactions(); + assert!( + !transactions.contains_key(&loser.txid), + "winner_first={winner_first}: the loser must be swept" + ); + assert!( + !transactions.contains_key(&child.txid), + "winner_first={winner_first}: the loser's raw child must not come back" + ); + assert_eq!(info.balance.total(), 20_000, "winner_first={winner_first}"); + } + } + + /// 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); + 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, + 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" + ); + } + + /// 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. + #[tokio::test] + async fn should_sweep_conflicting_spend_for_either_sibling_replay_order() { + 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 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}"); + 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; + + 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}"); + } + } + + /// 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..d22e5737ae2 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,10 +1,13 @@ //! 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::changeset::{ + ClientStartState, ClientWalletStartState, PersistenceCapabilities, 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 +16,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; @@ -88,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. @@ -129,8 +148,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 +200,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 +720,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 +766,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 +833,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 +873,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 +1181,126 @@ 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, + owned_inputs: Vec::new(), + }; + 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 { 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(), diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 433250e87b8..a0cfbceddc7 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 c52fa78e4bc..10939b1304f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3189,6 +3189,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.trackedMasternodes | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments + | PlatformWalletPersistenceCapabilities.coreHistoryRestore ) } @@ -7135,6 +7136,39 @@ 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 { + var historyDescriptor = FetchDescriptor() + historyDescriptor.relationshipKeyPathsForPrefetching = [ + \.involvedAccounts, \.inputs, \.outputs, \.pendingInputs, + ] + let transactions = try modelFetcher.fetch( + historyDescriptor, 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 @@ -7431,6 +7465,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 @@ -8053,6 +8095,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 @@ -9232,6 +9338,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 @@ -9316,6 +9426,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,