From cacc2a46240251bf471fcba38fb563f97239406d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:32:16 +0000 Subject: [PATCH 01/11] fix(key-wallet): restore persisted transaction state Co-Authored-By: Claudius the Magnificent --- .../managed_core_funds_account.rs | 12 ++ .../managed_core_keys_account.rs | 15 ++ key-wallet/src/tests/mod.rs | 2 + .../persisted_transaction_restore_tests.rs | 137 ++++++++++++++++++ .../src/wallet/managed_wallet_info/mod.rs | 42 ++++++ 5 files changed, 208 insertions(+) create mode 100644 key-wallet/src/tests/persisted_transaction_restore_tests.rs diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 491b15507..3a583a4e1 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -111,6 +111,18 @@ impl ManagedCoreFundsAccount { } } + /// Restore durable spent-output claims for this account. + /// + /// The caller must provide only claims that survived persistence conflict + /// resolution. This seeds the same set maintained by transaction + /// processing, so a later funding redelivery cannot recreate an output + /// that a live or settled transaction already consumed. Removing a + /// restored transaction through the normal abandon or conflict paths + /// releases its claims with the same rules as an in-session transaction. + pub fn restore_spent_outpoints(&mut self, outpoints: impl IntoIterator) { + self.spent_outpoints.extend(outpoints); + } + /// Create a `ManagedCoreFundsAccount` from an [`Account`](super::super::Account). pub fn from_account(account: &super::super::Account) -> Self { Self::wrap(ManagedCoreKeysAccount::from_account(account)) diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs index 1d16995e4..405e11c70 100644 --- a/key-wallet/src/managed_account/managed_core_keys_account.rs +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -89,6 +89,21 @@ impl ManagedCoreKeysAccount { } } + /// Restore one transaction record without replaying its UTXO mutations. + pub(crate) fn restore_transaction_record(&mut self, record: TransactionRecord) { + let txid = record.txid; + let finalized = record.context.is_chain_locked(); + self.transactions.insert(txid, record); + + #[cfg(not(feature = "keep-finalized-transactions"))] + if finalized { + self.drop_finalized_transaction(&txid); + } + + #[cfg(feature = "keep-finalized-transactions")] + let _ = finalized; + } + /// Drop the full record for `txid` and remember only its txid. /// /// Only defined when the `keep-finalized-transactions` Cargo feature diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index 8a91ccf52..e789735b3 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -26,6 +26,8 @@ mod observed_spent_outpoints_tests; mod performance_tests; +mod persisted_transaction_restore_tests; + mod provider_key_derivation_tests; mod special_transaction_matching_tests; diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs new file mode 100644 index 000000000..496793174 --- /dev/null +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -0,0 +1,137 @@ +//! Persistence rehydration must restore transaction lifecycle markers without +//! replaying records through UTXO mutation in an arbitrary storage order. + +use crate::account::StandardAccountType; +use crate::managed_account::managed_account_trait::ManagedAccountTrait; +use crate::managed_account::transaction_record::{ + InputDetail, TransactionDirection, TransactionRecord, +}; +use crate::test_utils::TestWalletContext; +use crate::transaction_checking::{ + BlockInfo, TransactionContext, TransactionType, WalletTransactionChecker, +}; +use crate::wallet::ManagedWalletInfo; +use crate::AccountType; +use dashcore::hashes::Hash; +use dashcore::{BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness}; + +fn bip44() -> AccountType { + AccountType::Standard { + index: 0, + standard_account_type: StandardAccountType::BIP44Account, + } +} + +fn spend(parent: OutPoint) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: parent, + script_sig: ScriptBuf::new(), + sequence: u32::MAX, + witness: Witness::new(), + }], + output: vec![TxOut { + value: 999_000, + script_pubkey: ScriptBuf::new(), + }], + special_transaction_payload: None, + } +} + +fn spending_record( + tx: Transaction, + address: dashcore::Address, + context: TransactionContext, +) -> TransactionRecord { + TransactionRecord::new( + tx, + bip44(), + context, + TransactionType::Standard, + TransactionDirection::Outgoing, + vec![InputDetail { + index: 0, + value: 1_000_000, + address, + }], + Vec::new(), + -1_000_000, + ) +} + +#[tokio::test] +async fn restored_spend_mark_blocks_funding_until_the_claim_is_released() { + let template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let claimant = spend(parent); + let claimant_txid = claimant.txid(); + let claimant_record = + spending_record(claimant, template.receive_address.clone(), TransactionContext::Mempool); + + let mut restored = template.managed_wallet.clone(); + let mut wallet = template.wallet.clone(); + let funding_context = TransactionContext::InBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([0x40; 32]), + 1_699_999_000, + )); + let unmatched = restored.restore_persisted_transactions([claimant_record]); + assert!(unmatched.is_empty()); + restored + .first_bip44_managed_account_mut() + .expect("BIP44 account") + .restore_spent_outpoints([parent]); + + restored + .check_core_transaction(&funding, funding_context.clone(), &mut wallet, true, true) + .await; + assert!( + !restored.first_bip44_managed_account().expect("BIP44 account").utxos.contains_key(&parent), + "a persisted live claim must suppress funding redelivery" + ); + + let abandoned = restored.abandon_transaction(claimant_txid); + assert!(abandoned.abandoned.contains(&claimant_txid)); + restored.check_core_transaction(&funding, funding_context, &mut wallet, true, true).await; + assert!( + restored.first_bip44_managed_account().expect("BIP44 account").utxos.contains_key(&parent), + "releasing the restored claim must allow rediscovery" + ); +} + +#[test] +fn restored_chainlocked_record_uses_finalized_compaction() { + let template = TestWalletContext::new_random(); + let parent = OutPoint { + txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), + vout: 0, + }; + let claimant = spend(parent); + let claimant_txid = claimant.txid(); + let record = spending_record( + claimant, + template.receive_address, + TransactionContext::InChainLockedBlock(BlockInfo::new( + 50, + BlockHash::from_byte_array([0x50; 32]), + 1_700_000_000, + )), + ); + + let mut restored: ManagedWalletInfo = template.managed_wallet; + assert!(restored.restore_persisted_transactions([record]).is_empty()); + assert_eq!(restored.observed_spent_outpoints().get(&parent), Some(&50)); + let account = restored.first_bip44_managed_account().expect("BIP44 account"); + assert!(account.transaction_is_finalized(&claimant_txid)); + #[cfg(not(feature = "keep-finalized-transactions"))] + assert!( + !account.transactions().contains_key(&claimant_txid), + "default retention keeps only the finalized txid" + ); +} diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index 6bfece722..dfe989c93 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -20,6 +20,7 @@ use super::balance::WalletCoreBalance; use super::metadata::WalletMetadata; use crate::account::ManagedAccountCollection; use crate::managed_account::managed_account_trait::ManagedAccountTrait; +use crate::managed_account::ManagedAccountRefMut; use crate::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::{Network, Wallet}; @@ -326,6 +327,47 @@ impl ManagedWalletInfo { &self.observed_spent_outpoints } + /// Restore persisted transaction lifecycle state without replaying UTXO + /// mutations. + /// + /// Records are installed directly into their exact account. Chainlocked + /// records follow the configured finalized-record retention policy, and + /// block-confirmed inputs rebuild the wallet-level observed-spend guard. + /// This makes restoration independent of record iteration order; the + /// persistence layer restores the authoritative UTXO set separately. + /// + /// Returns records whose account is absent from this wallet. Callers must + /// treat those as degraded state rather than silently attributing them to + /// another account. + pub fn restore_persisted_transactions( + &mut self, + records: impl IntoIterator, + ) -> Vec { + let mut unmatched = Vec::new(); + for record in records { + if let Some(block) = record.context.block_info() { + self.record_observed_spends(&record.transaction, block.height()); + } + + let account_type = record.account_type; + let account = + self.accounts.all_accounts_mut().into_iter().find(|account| { + account.managed_account_type().to_account_type() == account_type + }); + match account { + Some(ManagedAccountRefMut::Funds(account)) => { + account.keys_mut().restore_transaction_record(record) + } + Some(ManagedAccountRefMut::Keys(account)) => { + account.restore_transaction_record(record) + } + None => unmatched.push(record), + } + } + self.prune_finalized_observed_spends(); + unmatched + } + /// Record every outpoint `tx` spends into [`Self::observed_spent_outpoints`] /// at `height`. Insert-only bookkeeping — it never touches account UTXO sets, /// so it is safe to call before `record_transaction` builds a spend's From 61ac28ec957870ec453041c7b7735b2b904485da Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:39:48 +0000 Subject: [PATCH 02/11] test(key-wallet): cover restored conflict descendants Co-Authored-By: Claudius the Magnificent --- .../persisted_transaction_restore_tests.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index 496793174..d67cf89d5 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -40,6 +40,12 @@ fn spend(parent: OutPoint) -> Transaction { } } +fn competing_spend(parent: OutPoint) -> Transaction { + let mut transaction = spend(parent); + transaction.output[0].value -= 1_000; + transaction +} + fn spending_record( tx: Transaction, address: dashcore::Address, @@ -135,3 +141,43 @@ fn restored_chainlocked_record_uses_finalized_compaction() { "default retention keeps only the finalized txid" ); } + +#[test] +fn restored_unconfirmed_records_participate_in_conflict_descendant_sweeps() { + let template = TestWalletContext::new_random(); + let parent = OutPoint { + txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), + vout: 0, + }; + let root = spend(parent); + let root_txid = root.txid(); + let root_output = OutPoint { + txid: root_txid, + vout: 0, + }; + let child = spend(root_output); + let child_txid = child.txid(); + let winner = competing_spend(parent); + + let root_record = + spending_record(root, template.receive_address.clone(), TransactionContext::Mempool); + let child_record = + spending_record(child, template.receive_address, TransactionContext::Mempool); + let mut restored = template.managed_wallet; + assert!(restored.restore_persisted_transactions([root_record, child_record]).is_empty()); + restored + .first_bip44_managed_account_mut() + .expect("BIP44 account") + .restore_spent_outpoints([parent, root_output]); + + let swept = restored.sweep_conflicts( + &winner, + &TransactionContext::InBlock(BlockInfo::new( + 60, + BlockHash::from_byte_array([0x60; 32]), + 1_700_001_000, + )), + ); + assert!(swept.txids.contains(&root_txid)); + assert!(swept.txids.contains(&child_txid)); +} From e0787ee4562a2de12ba3d9f805d879390ba0f16e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:38:41 +0000 Subject: [PATCH 03/11] fix(key-wallet): reject unmatched restore side effects --- .../persisted_transaction_restore_tests.rs | 31 +++++++++++++++++++ .../src/wallet/managed_wallet_info/mod.rs | 26 +++++++++++----- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index d67cf89d5..de7cd6403 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -142,6 +142,37 @@ fn restored_chainlocked_record_uses_finalized_compaction() { ); } +#[test] +fn unmatched_record_does_not_restore_wallet_level_spend_state() { + let template = TestWalletContext::new_random(); + let parent = OutPoint { + txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), + vout: 0, + }; + let mut record = spending_record( + spend(parent), + template.receive_address, + TransactionContext::InBlock(BlockInfo::new( + 50, + BlockHash::from_byte_array([0x50; 32]), + 1_700_000_000, + )), + ); + record.account_type = AccountType::Standard { + index: 7, + standard_account_type: StandardAccountType::BIP44Account, + }; + + let mut restored = template.managed_wallet; + let unmatched = restored.restore_persisted_transactions([record]); + + assert_eq!(unmatched.len(), 1); + assert!( + !restored.observed_spent_outpoints().contains_key(&parent), + "a record rejected for missing account ownership must not mutate wallet spend state" + ); +} + #[test] fn restored_unconfirmed_records_participate_in_conflict_descendant_sweeps() { let template = TestWalletContext::new_random(); diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index dfe989c93..aa67cd10d 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -345,23 +345,33 @@ impl ManagedWalletInfo { ) -> Vec { let mut unmatched = Vec::new(); for record in records { - if let Some(block) = record.context.block_info() { - self.record_observed_spends(&record.transaction, block.height()); - } - let account_type = record.account_type; + let observed_spend = record + .context + .block_info() + .map(|block| (record.transaction.clone(), block.height())); let account = self.accounts.all_accounts_mut().into_iter().find(|account| { account.managed_account_type().to_account_type() == account_type }); - match account { + let restored = match account { Some(ManagedAccountRefMut::Funds(account)) => { - account.keys_mut().restore_transaction_record(record) + account.keys_mut().restore_transaction_record(record); + true } Some(ManagedAccountRefMut::Keys(account)) => { - account.restore_transaction_record(record) + account.restore_transaction_record(record); + true + } + None => { + unmatched.push(record); + false + } + }; + if restored { + if let Some((transaction, height)) = observed_spend { + self.record_observed_spends(&transaction, height); } - None => unmatched.push(record), } } self.prune_finalized_observed_spends(); From 75432b8a580495e3002a35b9edb75f040cc46bc2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:14:57 +0000 Subject: [PATCH 04/11] fix(key-wallet)!: restore persisted wallet lifecycle atomically --- .../managed_account/managed_account_ref.rs | 6 +- .../managed_core_funds_account.rs | 186 +++--- .../persisted_transaction_restore_tests.rs | 592 +++++++++++++++++- .../transaction_checking/wallet_checker.rs | 50 +- .../src/wallet/managed_wallet_info/helpers.rs | 41 +- .../src/wallet/managed_wallet_info/mod.rs | 60 +- .../wallet/managed_wallet_info/persistence.rs | 310 +++++++++ 7 files changed, 1076 insertions(+), 169 deletions(-) create mode 100644 key-wallet/src/wallet/managed_wallet_info/persistence.rs diff --git a/key-wallet/src/managed_account/managed_account_ref.rs b/key-wallet/src/managed_account/managed_account_ref.rs index 192a358e2..bc7d38840 100644 --- a/key-wallet/src/managed_account/managed_account_ref.rs +++ b/key-wallet/src/managed_account/managed_account_ref.rs @@ -19,9 +19,9 @@ use crate::managed_account::{ManagedCoreFundsAccount, ManagedCoreKeysAccount}; use crate::transaction_checking::account_checker::AccountMatch; use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::TransactionContext; +use crate::wallet::managed_wallet_info::persistence::SpendEvidence; use crate::Network; use dashcore::blockdata::transaction::OutPoint; -use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, ScriptBuf, Transaction, Txid}; use std::collections::{BTreeMap, BTreeSet}; @@ -339,7 +339,7 @@ impl<'a> ManagedAccountRefMut<'a> { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &BTreeMap, + observed_spent: &impl SpendEvidence, external_final_parents: &BTreeSet, ) -> TransactionRecord { match self { @@ -395,7 +395,7 @@ impl<'a> ManagedAccountRefMut<'a> { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &BTreeMap, + observed_spent: &impl SpendEvidence, external_final_parents: &BTreeSet, ) -> Option { match self { diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 3a583a4e1..296221863 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -27,6 +27,7 @@ use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::{AccountMatch, TransactionContext}; use crate::utxo::Utxo; use crate::wallet::balance::WalletCoreBalance; +use crate::wallet::managed_wallet_info::persistence::SpendEvidence; use crate::{ExtendedPubKey, Network}; use dashcore::blockdata::transaction::OutPoint; use dashcore::prelude::CoreBlockHeight; @@ -83,7 +84,7 @@ pub(crate) struct AbandonRemoval { pub records: usize, } -/// What [`ManagedCoreFundsAccount::drop_conflicted_transactions`] removed +/// What [`ManagedCoreFundsAccount::apply_conflict_set`] removed /// from one account. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct ConflictSweep { @@ -111,16 +112,19 @@ impl ManagedCoreFundsAccount { } } - /// Restore durable spent-output claims for this account. - /// - /// The caller must provide only claims that survived persistence conflict - /// resolution. This seeds the same set maintained by transaction - /// processing, so a later funding redelivery cannot recreate an output - /// that a live or settled transaction already consumed. Removing a - /// restored transaction through the normal abandon or conflict paths - /// releases its claims with the same rules as an in-session transaction. - pub fn restore_spent_outpoints(&mut self, outpoints: impl IntoIterator) { - self.spent_outpoints.extend(outpoints); + /// Restore the record and its input claims before finalized compaction. + pub(crate) fn restore_transaction_record(&mut self, record: TransactionRecord) { + if !record.transaction.is_coin_base() { + self.spent_outpoints + .extend(record.transaction.input.iter().map(|input| input.previous_output)); + } + self.keys.restore_transaction_record(record); + } + + pub(crate) fn has_persisted_funds_state(&self) -> bool { + !self.utxos.is_empty() + || !self.spent_outpoints.is_empty() + || !self.spent_before_funded.is_empty() } /// Create a `ManagedCoreFundsAccount` from an [`Account`](super::super::Account). @@ -202,7 +206,7 @@ impl ManagedCoreFundsAccount { } /// Check if an outpoint was spent by a previously recorded transaction. - fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { + pub(crate) fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { self.spent_outpoints.contains(outpoint) } @@ -237,7 +241,7 @@ impl ManagedCoreFundsAccount { tx: &Transaction, account_match: &AccountMatch, context: TransactionContext, - observed_spent: &BTreeMap, + observed_spent: &impl SpendEvidence, external_final_parents: &BTreeSet, ) { // Update UTXOs only for spendable account types @@ -306,7 +310,7 @@ impl ManagedCoreFundsAccount { && tx .input .iter() - .any(|input| observed_spent.contains_key(&input.previous_output)); + .any(|input| observed_spent.is_settled(&input.previous_output)); if doomed_by_a_settled_spend { // Deliberately before any mutation: the record built by // the caller stands, so history still shows the attempt, @@ -348,7 +352,7 @@ impl ManagedCoreFundsAccount { // earlier-processed block, so this output is genuinely spent // on-chain even though this account has never seen it before — // never insert it, so the record built below is born correct. - if observed_spent.contains_key(&outpoint) { + if observed_spent.blocks_output(&outpoint) { tracing::debug!( outpoint = %outpoint, "Skipping UTXO already observed spent in an earlier-processed block (#649)" @@ -574,90 +578,31 @@ impl ManagedCoreFundsAccount { /// not have to be wallet-relevant, so it may hold none of the loser's /// inputs anywhere the caller can see, and the loser's own record is /// already gone by the time this returns. + #[cfg(test)] pub(crate) fn drop_conflicted_transactions( &mut self, tx: &Transaction, context: &TransactionContext, ) -> ConflictSweep { - if !(context.confirmed() || matches!(context, TransactionContext::InstantSend(_))) { - return ConflictSweep::default(); - } - - let winner = tx.txid(); - let spent: BTreeSet = - tx.input.iter().map(|input| input.previous_output).collect(); - - // A finalized transaction keeps only its txid, so a chainlocked record - // can never be a loser here — and must not be, since it is settled. - let mut losers: BTreeSet = self - .keys - .transactions() - .iter() - .filter(|(txid, record)| { - // Precedence, per DIP-10: a chainlock is final over - // everything, an InstantSend lock is final against a double - // spend, and a plain block is provisional until its own - // chainlock lands. So an IS-locked record may only be evicted - // by a chainlocked arrival — a plain `InBlock` winner cannot - // overrule a lock the network already signed, and the block - // it arrived in can still reorg away. - let loser_is_locked = record.context.is_instant_send(); - **txid != winner - && !record.is_confirmed() - && (!loser_is_locked || context.is_chain_locked()) - && record - .transaction - .input - .iter() - .any(|input| spent.contains(&input.previous_output)) - }) - .map(|(txid, _)| *txid) - .collect(); + let records: Vec<_> = self.keys.transactions().values().collect(); + let losers = conflicted_transactions(&records, tx, context); + self.apply_conflict_set(tx, &losers) + } + /// Remove the wallet-wide conflict closure while retaining the winner's claims. + pub(crate) fn apply_conflict_set( + &mut self, + tx: &Transaction, + losers: &BTreeSet, + ) -> ConflictSweep { if losers.is_empty() { return ConflictSweep::default(); } - - // A loser's change may already have funded further unconfirmed - // transactions. Those can never exist either — their parent cannot — - // so leaving their outputs credited would preserve the very - // phantom-balance class this sweep exists to remove. Walk the - // unconfirmed descendant closure; confirmed records are never - // followed, since a transaction in a block spent something real, - // and neither are InstantSend-locked ones, whose lock the network - // already signed. - // - // The walk builds a parent→children index in one pass and then - // follows a queue, so each record is looked at once. Rescanning the - // whole history per generation instead is O(depth × history): a peer - // that feeds the wallet a deep chain of unconfirmed wallet-relevant - // transactions and then finalizes a replacement for the root's input - // would make the sweep quadratic in everything the wallet retained, - // while the account is held mutably and before the sweep can reach - // persistence. - let mut children: HashMap> = HashMap::new(); - for (txid, record) in self.keys.transactions() { - note_descendant_walk_visit(); - if record.is_confirmed() || record.context.is_instant_send() || *txid == winner { - continue; - } - for input in &record.transaction.input { - children.entry(input.previous_output.txid).or_default().push(*txid); - } - } - let mut queue: VecDeque = losers.iter().copied().collect(); - while let Some(parent) = queue.pop_front() { - for child in children.get(&parent).map(Vec::as_slice).unwrap_or_default() { - note_descendant_walk_visit(); - if losers.insert(*child) { - queue.push_back(*child); - } - } - } - + let winner = tx.txid(); + let spent: BTreeSet<_> = tx.input.iter().map(|input| input.previous_output).collect(); let mut freed: HashSet = HashSet::new(); let mut changed = false; - for loser in &losers { + for loser in losers { let removed: Vec = self.utxos.keys().filter(|outpoint| outpoint.txid == *loser).copied().collect(); for outpoint in removed { @@ -703,7 +648,7 @@ impl ManagedCoreFundsAccount { released.into_iter().filter(|outpoint| !losers.contains(&outpoint.txid)).collect(); released_outpoints.sort_unstable(); ConflictSweep { - txids: losers.into_iter().collect(), + txids: losers.iter().copied().collect(), released_outpoints, } } @@ -730,7 +675,7 @@ impl ManagedCoreFundsAccount { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &BTreeMap, + observed_spent: &impl SpendEvidence, external_final_parents: &BTreeSet, ) -> Option { let txid = tx.txid(); @@ -820,7 +765,7 @@ impl ManagedCoreFundsAccount { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &BTreeMap, + observed_spent: &impl SpendEvidence, external_final_parents: &BTreeSet, ) -> TransactionRecord { let net_amount = account_match.received as i64 - account_match.sent as i64; @@ -1301,6 +1246,63 @@ impl ManagedAccountTrait for ManagedCoreFundsAccount { } } +/// Find direct losers and their unconfirmed descendants across the supplied records. +pub(crate) fn conflicted_transactions( + records: &[&TransactionRecord], + tx: &Transaction, + context: &TransactionContext, +) -> BTreeSet { + if !(context.confirmed() || context.is_instant_send()) { + return BTreeSet::new(); + } + let winner = tx.txid(); + let spent: HashSet<_> = tx.input.iter().map(|input| input.previous_output).collect(); + let protected: HashSet<_> = records + .iter() + .filter(|record| { + record.is_confirmed() + || (record.context.is_instant_send() && !context.is_chain_locked()) + }) + .map(|record| record.txid) + .collect(); + let mut losers: BTreeSet<_> = records + .iter() + .filter(|record| { + record.txid != winner + && !protected.contains(&record.txid) + && record + .transaction + .input + .iter() + .any(|input| spent.contains(&input.previous_output)) + }) + .map(|record| record.txid) + .collect(); + if losers.is_empty() { + return losers; + } + let mut children: HashMap> = HashMap::new(); + for record in records { + note_descendant_walk_visit(); + if record.is_confirmed() || record.context.is_instant_send() || record.txid == winner { + continue; + } + for input in &record.transaction.input { + children.entry(input.previous_output.txid).or_default().push(record.txid); + } + } + let mut queue: VecDeque<_> = losers.iter().copied().collect(); + while let Some(parent) = queue.pop_front() { + for child in children.get(&parent).map(Vec::as_slice).unwrap_or_default() { + note_descendant_walk_visit(); + if !protected.contains(child) && losers.insert(*child) { + queue.push_back(*child); + } + } + } + losers +} + /// Rebuild the account-local `spent_outpoints` set from recorded transactions. /// /// Every input of every recorded transaction is a spend this account has seen, @@ -1352,7 +1354,7 @@ impl<'de> Deserialize<'de> for ManagedCoreFundsAccount { } /// Test-only visit counter for the descendant walk in -/// [`ManagedCoreFundsAccount::drop_conflicted_transactions`]. +/// [`ManagedCoreFundsAccount::apply_conflict_set`]. /// /// Exists so a regression test can pin the walk to a linear number of record /// visits deterministically, instead of betting on wall-clock time. Compiled diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index de7cd6403..e4313ec54 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -10,10 +10,13 @@ use crate::test_utils::TestWalletContext; use crate::transaction_checking::{ BlockInfo, TransactionContext, TransactionType, WalletTransactionChecker, }; +use crate::utxo::Utxo; +use crate::wallet::managed_wallet_info::{PersistedWalletState, RestoreError}; use crate::wallet::ManagedWalletInfo; use crate::AccountType; use dashcore::hashes::Hash; use dashcore::{BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness}; +use std::collections::BTreeMap; fn bip44() -> AccountType { AccountType::Standard { @@ -87,12 +90,13 @@ async fn restored_spend_mark_blocks_funding_until_the_claim_is_released() { BlockHash::from_byte_array([0x40; 32]), 1_699_999_000, )); - let unmatched = restored.restore_persisted_transactions([claimant_record]); - assert!(unmatched.is_empty()); restored - .first_bip44_managed_account_mut() - .expect("BIP44 account") - .restore_spent_outpoints([parent]); + .restore_persisted_state(PersistedWalletState { + transactions: vec![claimant_record], + additional_spent_outpoints: BTreeMap::from([(parent, None)]), + ..Default::default() + }) + .unwrap(); restored .check_core_transaction(&funding, funding_context.clone(), &mut wallet, true, true) @@ -131,7 +135,12 @@ fn restored_chainlocked_record_uses_finalized_compaction() { ); let mut restored: ManagedWalletInfo = template.managed_wallet; - assert!(restored.restore_persisted_transactions([record]).is_empty()); + restored + .restore_persisted_state(PersistedWalletState { + transactions: vec![record], + ..Default::default() + }) + .unwrap(); assert_eq!(restored.observed_spent_outpoints().get(&parent), Some(&50)); let account = restored.first_bip44_managed_account().expect("BIP44 account"); assert!(account.transaction_is_finalized(&claimant_txid)); @@ -164,9 +173,12 @@ fn unmatched_record_does_not_restore_wallet_level_spend_state() { }; let mut restored = template.managed_wallet; - let unmatched = restored.restore_persisted_transactions([record]); + let result = restored.restore_persisted_state(PersistedWalletState { + transactions: vec![record.clone()], + ..Default::default() + }); - assert_eq!(unmatched.len(), 1); + assert_eq!(result, Err(RestoreError::MissingAccount(record.account_type))); assert!( !restored.observed_spent_outpoints().contains_key(&parent), "a record rejected for missing account ownership must not mutate wallet spend state" @@ -195,11 +207,12 @@ fn restored_unconfirmed_records_participate_in_conflict_descendant_sweeps() { let child_record = spending_record(child, template.receive_address, TransactionContext::Mempool); let mut restored = template.managed_wallet; - assert!(restored.restore_persisted_transactions([root_record, child_record]).is_empty()); restored - .first_bip44_managed_account_mut() - .expect("BIP44 account") - .restore_spent_outpoints([parent, root_output]); + .restore_persisted_state(PersistedWalletState { + transactions: vec![child_record, root_record], + ..Default::default() + }) + .unwrap(); let swept = restored.sweep_conflicts( &winner, @@ -212,3 +225,558 @@ fn restored_unconfirmed_records_participate_in_conflict_descendant_sweeps() { assert!(swept.txids.contains(&root_txid)); assert!(swept.txids.contains(&child_txid)); } + +fn coin(tx: &Transaction, address: &dashcore::Address) -> Utxo { + Utxo::new( + OutPoint { + txid: tx.txid(), + vout: 0, + }, + tx.output[0].clone(), + address.clone(), + 40, + false, + ) +} + +#[test] +fn should_reject_entire_snapshot_before_mutating_any_account() { + let template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let valid = spending_record( + spend(parent), + template.receive_address.clone(), + TransactionContext::Mempool, + ); + let mut invalid = valid.clone(); + invalid.txid = funding.txid(); + let mut wallet = template.managed_wallet.clone(); + let state = PersistedWalletState { + transactions: vec![valid.clone(), invalid], + ..Default::default() + }; + assert_eq!( + wallet.restore_persisted_state(state), + Err(RestoreError::InvalidRecord(funding.txid())) + ); + assert!(wallet.first_bip44_managed_account().unwrap().transactions().is_empty()); + assert!(wallet.observed_spent_outpoints().is_empty()); + let mut missing = valid.clone(); + missing.account_type = AccountType::Standard { + index: 7, + standard_account_type: StandardAccountType::BIP44Account, + }; + assert_eq!( + wallet.restore_persisted_state(PersistedWalletState { + transactions: vec![valid.clone(), missing.clone()], + ..Default::default() + }), + Err(RestoreError::MissingAccount(missing.account_type)) + ); + assert!(wallet.first_bip44_managed_account().unwrap().transactions().is_empty()); + wallet + .restore_persisted_state(PersistedWalletState { + transactions: vec![valid], + ..Default::default() + }) + .unwrap(); + assert_eq!( + wallet.restore_persisted_state(PersistedWalletState::default()), + Err(RestoreError::NonEmptyWallet) + ); +} + +#[test] +fn should_reject_spent_unspent_contradiction_and_bad_coin_without_mutation() { + let template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let utxo = coin(&funding, &template.receive_address); + let record = spending_record( + spend(utxo.outpoint), + template.receive_address.clone(), + TransactionContext::Mempool, + ); + let mut wallet = template.managed_wallet; + assert_eq!( + wallet.restore_persisted_state(PersistedWalletState { + transactions: vec![record], + utxos: vec![(bip44(), utxo.clone())], + ..Default::default() + }), + Err(RestoreError::SpentUtxo(utxo.outpoint)) + ); + let mut invalid = utxo.clone(); + invalid.txout.script_pubkey = ScriptBuf::new(); + assert_eq!( + wallet.restore_persisted_state(PersistedWalletState { + utxos: vec![(bip44(), invalid)], + ..Default::default() + }), + Err(RestoreError::InvalidUtxo(utxo.outpoint)) + ); + assert!(wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); + assert!(wallet.first_bip44_managed_account().unwrap().transactions().is_empty()); +} + +#[tokio::test] +async fn should_preserve_unattributed_spend_without_inventing_block_height() { + let mut template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + template + .managed_wallet + .restore_persisted_state(PersistedWalletState { + additional_spent_outpoints: BTreeMap::from([(parent, None)]), + ..Default::default() + }) + .unwrap(); + assert!(template.managed_wallet.observed_spent_outpoints().is_empty()); + template + .managed_wallet + .check_core_transaction( + &funding, + TransactionContext::Mempool, + &mut template.wallet, + true, + true, + ) + .await; + assert!(!template + .managed_wallet + .first_bip44_managed_account() + .unwrap() + .utxos + .contains_key(&parent)); +} + +#[tokio::test] +async fn should_keep_external_block_evidence_when_abandoning_record_claim() { + let mut template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let record = spending_record( + spend(parent), + template.receive_address.clone(), + TransactionContext::Mempool, + ); + let txid = record.txid; + template + .managed_wallet + .restore_persisted_state(PersistedWalletState { + transactions: vec![record], + additional_spent_outpoints: BTreeMap::from([(parent, Some(80))]), + ..Default::default() + }) + .unwrap(); + template.managed_wallet.abandon_transaction(txid); + template + .managed_wallet + .check_core_transaction( + &funding, + TransactionContext::Mempool, + &mut template.wallet, + true, + true, + ) + .await; + assert_eq!(template.managed_wallet.observed_spent_outpoints().get(&parent), Some(&80)); + assert!(!template + .managed_wallet + .first_bip44_managed_account() + .unwrap() + .utxos + .contains_key(&parent)); +} + +#[tokio::test] +async fn should_match_uninterrupted_wallet_across_restore_abandon_and_conflict() { + let mut uninterrupted = TestWalletContext::new_random(); + let mut restored = uninterrupted.managed_wallet.clone(); + let funding = Transaction::dummy(&uninterrupted.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let funding_context = TransactionContext::InBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([0x40; 32]), + 1_699_999_000, + )); + uninterrupted + .managed_wallet + .check_core_transaction( + &funding, + funding_context.clone(), + &mut uninterrupted.wallet, + true, + true, + ) + .await; + let mut root = spend(parent); + root.output[0].script_pubkey = uninterrupted.receive_address.script_pubkey(); + uninterrupted + .managed_wallet + .check_core_transaction( + &root, + TransactionContext::Mempool, + &mut uninterrupted.wallet, + true, + true, + ) + .await; + let root_output = OutPoint { + txid: root.txid(), + vout: 0, + }; + let mut child = spend(root_output); + child.output[0].script_pubkey = uninterrupted.receive_address.script_pubkey(); + child.output[0].value = 998_000; + uninterrupted + .managed_wallet + .check_core_transaction( + &child, + TransactionContext::Mempool, + &mut uninterrupted.wallet, + true, + true, + ) + .await; + let account = uninterrupted.managed_wallet.first_bip44_managed_account().unwrap(); + let mut records: Vec<_> = account.transactions().values().cloned().collect(); + records.reverse(); + restored + .restore_persisted_state(PersistedWalletState { + transactions: records, + utxos: account.utxos.values().cloned().map(|utxo| (bip44(), utxo)).collect(), + additional_spent_outpoints: BTreeMap::from([(parent, None), (root_output, None)]), + }) + .unwrap(); + assert_funds_equal(&uninterrupted.managed_wallet, &restored); + for state in [&mut uninterrupted.managed_wallet, &mut restored] { + state + .check_core_transaction( + &funding, + funding_context.clone(), + &mut uninterrupted.wallet, + true, + true, + ) + .await; + } + assert_funds_equal(&uninterrupted.managed_wallet, &restored); + let mut restored_for_abandon = restored.clone(); + let mut live_for_abandon = uninterrupted.managed_wallet.clone(); + assert_eq!( + live_for_abandon.abandon_transaction(root.txid()).abandoned, + restored_for_abandon.abandon_transaction(root.txid()).abandoned + ); + for state in [&mut live_for_abandon, &mut restored_for_abandon] { + state + .check_core_transaction( + &funding, + funding_context.clone(), + &mut uninterrupted.wallet, + true, + true, + ) + .await; + } + assert_funds_equal(&live_for_abandon, &restored_for_abandon); + assert!(restored_for_abandon + .first_bip44_managed_account() + .unwrap() + .utxos + .contains_key(&parent)); + let winner = competing_spend(parent); + for state in [&mut uninterrupted.managed_wallet, &mut restored] { + state + .check_core_transaction( + &winner, + TransactionContext::InBlock(BlockInfo::new( + 60, + BlockHash::from_byte_array([0x60; 32]), + 1_700_000_000, + )), + &mut uninterrupted.wallet, + true, + true, + ) + .await; + } + assert_funds_equal(&uninterrupted.managed_wallet, &restored); + assert!(!restored + .first_bip44_managed_account() + .unwrap() + .transactions() + .contains_key(&child.txid())); +} + +fn assert_funds_equal(left: &ManagedWalletInfo, right: &ManagedWalletInfo) { + let left_account = left.first_bip44_managed_account().unwrap(); + let right_account = right.first_bip44_managed_account().unwrap(); + assert_eq!(left_account.utxos, right_account.utxos); + assert_eq!(left_account.balance, right_account.balance); + assert_eq!(left.balance, right.balance); + assert_eq!(left_account.tx_count(), right_account.tx_count()); + assert_eq!(left.observed_spent_outpoints(), right.observed_spent_outpoints()); +} + +#[tokio::test] +async fn should_match_finalized_wallet_after_compaction_and_funding_redelivery() { + let mut live = TestWalletContext::new_random(); + let mut restored = live.managed_wallet.clone(); + let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let finalized = TransactionContext::InChainLockedBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([0x40; 32]), + 1_699_999_000, + )); + let funding_result = live + .managed_wallet + .check_core_transaction(&funding, finalized.clone(), &mut live.wallet, true, true) + .await; + let spending = spend(parent); + let spend_result = live + .managed_wallet + .check_core_transaction(&spending, finalized.clone(), &mut live.wallet, true, true) + .await; + let records = funding_result.new_records.into_iter().chain(spend_result.new_records).collect(); + restored + .restore_persisted_state(PersistedWalletState { + transactions: records, + ..Default::default() + }) + .unwrap(); + assert_funds_equal(&live.managed_wallet, &restored); + for state in [&mut live.managed_wallet, &mut restored] { + state + .check_core_transaction(&funding, finalized.clone(), &mut live.wallet, true, true) + .await; + } + assert_funds_equal(&live.managed_wallet, &restored); + assert!(restored + .first_bip44_managed_account() + .unwrap() + .transaction_is_finalized(&spending.txid())); + assert!(restored.first_bip44_managed_account().unwrap().utxos.is_empty()); +} + +#[tokio::test] +async fn should_restore_spend_before_funding_input_recognition() { + let mut live = TestWalletContext::new_random(); + let mut restored = live.managed_wallet.clone(); + let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let spending = spend(parent); + let context = TransactionContext::InBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([0x40; 32]), + 1_699_999_000, + )); + live.managed_wallet + .check_core_transaction(&spending, context.clone(), &mut live.wallet, true, true) + .await; + let funding_result = live + .managed_wallet + .check_core_transaction(&funding, context.clone(), &mut live.wallet, true, true) + .await; + restored + .restore_persisted_state(PersistedWalletState { + transactions: funding_result.new_records, + additional_spent_outpoints: live + .managed_wallet + .observed_spent_outpoints() + .iter() + .map(|(outpoint, height)| (*outpoint, Some(*height))) + .collect(), + ..Default::default() + }) + .unwrap(); + let live_result = live + .managed_wallet + .check_core_transaction(&spending, context.clone(), &mut live.wallet, true, true) + .await; + let restored_result = + restored.check_core_transaction(&spending, context, &mut live.wallet, true, true).await; + assert_eq!(live_result.new_records.len(), 1); + assert_eq!(restored_result.new_records.len(), 1); + assert_eq!(live_result.new_records[0].net_amount, restored_result.new_records[0].net_amount); + assert_eq!( + live_result.new_records[0].input_details.len(), + restored_result.new_records[0].input_details.len() + ); + assert_funds_equal(&live.managed_wallet, &restored); +} + +#[test_case::test_case(false; "funds_record")] +#[test_case::test_case(true; "keys_record")] +#[tokio::test] +async fn should_block_cross_account_funding_with_one_persisted_spend_record_and_release_it( + keys_only: bool, +) { + use crate::wallet::managed_wallet_info::ManagedAccountOperations; + let mut template = TestWalletContext::new_random(); + let other = TestWalletContext::new_random(); + let other_type = if keys_only { + AccountType::IdentityRegistration + } else { + AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + } + }; + if !template + .managed_wallet + .accounts + .all_accounts() + .iter() + .any(|account| account.managed_account_type().to_account_type() == other_type) + { + template.managed_wallet.add_managed_account_from_xpub(other_type, other.xpub).unwrap(); + } + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let mut record = spending_record( + spend(parent), + template.receive_address.clone(), + TransactionContext::Mempool, + ); + record.account_type = other_type; + let claimant = record.txid; + template + .managed_wallet + .restore_persisted_state(PersistedWalletState { + transactions: vec![record], + additional_spent_outpoints: BTreeMap::from([(parent, None)]), + ..Default::default() + }) + .unwrap(); + let context = TransactionContext::InBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([0x40; 32]), + 1_699_999_000, + )); + template + .managed_wallet + .check_core_transaction(&funding, context.clone(), &mut template.wallet, true, true) + .await; + assert!( + !template.managed_wallet.first_bip44_managed_account().unwrap().utxos.contains_key(&parent), + "another account's claim must block the funding owner too" + ); + template.managed_wallet.abandon_transaction(claimant); + template + .managed_wallet + .check_core_transaction(&funding, context, &mut template.wallet, true, true) + .await; + assert!( + template.managed_wallet.first_bip44_managed_account().unwrap().utxos.contains_key(&parent), + "the cross-account guard must release when its record is abandoned" + ); +} + +#[tokio::test] +async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra_input() { + let mut template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000, 2_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let extra = OutPoint { + txid: funding.txid(), + vout: 1, + }; + let mut root = spend(parent); + root.input.push(TxIn { + previous_output: extra, + script_sig: ScriptBuf::new(), + sequence: u32::MAX, + witness: Witness::new(), + }); + let root_txid = root.txid(); + let mut root_record = + spending_record(root, template.receive_address.clone(), TransactionContext::Mempool); + root_record.account_type = AccountType::IdentityRegistration; + let mut child = spend(OutPoint { + txid: root_txid, + vout: 0, + }); + child.output[0].script_pubkey = template.receive_address.script_pubkey(); + let child_coin = coin(&child, &template.receive_address); + let child_txid = child.txid(); + let child_record = + spending_record(child, template.receive_address.clone(), TransactionContext::Mempool); + template + .managed_wallet + .restore_persisted_state(PersistedWalletState { + transactions: vec![root_record, child_record], + utxos: vec![(bip44(), child_coin)], + additional_spent_outpoints: BTreeMap::from([(parent, None), (extra, None)]), + }) + .unwrap(); + let context = TransactionContext::InBlock(BlockInfo::new( + 80, + BlockHash::from_byte_array([0x80; 32]), + 1_699_999_000, + )); + let result = template + .managed_wallet + .check_core_transaction( + &competing_spend(parent), + context.clone(), + &mut template.wallet, + true, + true, + ) + .await; + assert!(result.swept_transactions.contains(&root_txid)); + assert!(result.swept_transactions.contains(&child_txid)); + assert_eq!(result.released_outpoints, vec![extra]); + assert!(template.managed_wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); + template + .managed_wallet + .check_core_transaction(&funding, context, &mut template.wallet, true, true) + .await; + let coins = &template.managed_wallet.first_bip44_managed_account().unwrap().utxos; + assert!(!coins.contains_key(&parent)); + assert!(coins.contains_key(&extra)); +} + +#[test] +fn should_reject_balance_overflow_before_installing_coins() { + let template = TestWalletContext::new_random(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[u64::MAX, 1]); + let first = coin(&funding, &template.receive_address); + let mut second = first.clone(); + second.outpoint.vout = 1; + second.txout = funding.output[1].clone(); + let mut wallet = template.managed_wallet; + assert!(wallet + .restore_persisted_state(PersistedWalletState { + utxos: vec![(bip44(), first), (bip44(), second)], + ..Default::default() + }) + .is_err()); + assert!(wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); +} diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 1e91fc701..53f6e0529 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -6,12 +6,14 @@ pub(crate) use super::account_checker::TransactionCheckResult; use super::transaction_context::TransactionContext; use super::transaction_router::{AccountTypeToCheck, TransactionRouter}; +use crate::wallet::managed_wallet_info::persistence::WalletSpendEvidence; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{KeySource, Wallet}; use async_trait::async_trait; use dashcore::blockdata::transaction::Transaction; -use dashcore::{Amount, SignedAmount}; +use dashcore::{Amount, OutPoint, SignedAmount}; +use std::collections::HashSet; /// Extension trait for ManagedWalletInfo to add transaction checking capabilities #[async_trait] @@ -185,7 +187,33 @@ impl WalletTransactionChecker for ManagedWalletInfo { let external_final_parents = self.accounts.final_parents_of(tx); let txid = tx.txid(); - let is_new = !self.accounts.all_accounts().into_iter().any(|a| a.has_transaction(&txid)); + let accounts = self.accounts.all_accounts(); + let mut claimed_outputs: HashSet<_> = (0..tx.output.len()) + .map(|vout| OutPoint { + txid, + vout: vout as u32, + }) + .filter(|outpoint| { + accounts.iter().any(|account| { + account.as_funds().is_some_and(|funds| funds.is_outpoint_spent(outpoint)) + }) + }) + .collect(); + for account in &accounts { + if account.as_keys().is_some() { + claimed_outputs.extend( + account + .transactions() + .values() + .flat_map(|record| &record.transaction.input) + .map(|input| input.previous_output) + .filter(|outpoint| { + outpoint.txid == txid && (outpoint.vout as usize) < tx.output.len() + }), + ); + } + } + let is_new = !accounts.into_iter().any(|a| a.has_transaction(&txid)); result.is_new_transaction = is_new; if !is_new { @@ -237,7 +265,11 @@ impl WalletTransactionChecker for ManagedWalletInfo { &account_match, context.clone(), tx_type, - &self.observed_spent_outpoints, + &WalletSpendEvidence { + observed: &self.observed_spent_outpoints, + unattributed: &self.unattributed_spent_outpoints, + claimed: &claimed_outputs, + }, &external_final_parents, ); account.mark_utxos_instant_send(&txid); @@ -270,7 +302,11 @@ impl WalletTransactionChecker for ManagedWalletInfo { &account_match, context.clone(), tx_type, - &self.observed_spent_outpoints, + &WalletSpendEvidence { + observed: &self.observed_spent_outpoints, + unattributed: &self.unattributed_spent_outpoints, + claimed: &claimed_outputs, + }, &external_final_parents, ); result.new_records.push(record); @@ -282,7 +318,11 @@ impl WalletTransactionChecker for ManagedWalletInfo { &account_match, context.clone(), tx_type, - &self.observed_spent_outpoints, + &WalletSpendEvidence { + observed: &self.observed_spent_outpoints, + unattributed: &self.unattributed_spent_outpoints, + claimed: &claimed_outputs, + }, &external_final_parents, ) { result.state_modified = true; diff --git a/key-wallet/src/wallet/managed_wallet_info/helpers.rs b/key-wallet/src/wallet/managed_wallet_info/helpers.rs index 9e7791e97..01e6e22de 100644 --- a/key-wallet/src/wallet/managed_wallet_info/helpers.rs +++ b/key-wallet/src/wallet/managed_wallet_info/helpers.rs @@ -6,6 +6,7 @@ use crate::account::ManagedCoreFundsAccount; use crate::account::TransactionRecord; use crate::managed_account::managed_account_ref::ManagedAccountRefMut; use crate::managed_account::managed_account_trait::ManagedAccountTrait; +use crate::managed_account::managed_core_funds_account::conflicted_transactions; use crate::managed_account::managed_platform_account::ManagedPlatformAccount; use crate::managed_account::ManagedCoreKeysAccount; use crate::transaction_checking::TransactionContext; @@ -164,12 +165,42 @@ impl ManagedWalletInfo { tx: &Transaction, context: &TransactionContext, ) -> WalletConflictSweep { - let mut result = WalletConflictSweep::default(); + let records: Vec<_> = self + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values()) + .collect(); + let losers = conflicted_transactions(&records, tx, context); + let winner_inputs: HashSet<_> = + tx.input.iter().map(|input| input.previous_output).collect(); + let mut result = WalletConflictSweep { + txids: losers.iter().copied().collect(), + released_outpoints: Vec::new(), + }; for account in self.accounts.all_accounts_mut() { - if let ManagedAccountRefMut::Funds(funds) = account { - let swept = funds.drop_conflicted_transactions(tx, context); - result.txids.extend(swept.txids); - result.released_outpoints.extend(swept.released_outpoints); + match account { + ManagedAccountRefMut::Funds(funds) => { + let swept = funds.apply_conflict_set(tx, &losers); + result.released_outpoints.extend(swept.released_outpoints); + } + ManagedAccountRefMut::Keys(keys) => { + for loser in &losers { + if let Some(record) = keys.transactions_mut().remove(loser) { + result.released_outpoints.extend( + record + .transaction + .input + .iter() + .map(|input| input.previous_output) + .filter(|outpoint| { + !winner_inputs.contains(outpoint) + && !losers.contains(&outpoint.txid) + }), + ); + } + } + } } } if !result.txids.is_empty() { diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index aa67cd10d..05c4eb7d9 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -10,6 +10,8 @@ pub mod helpers; pub use helpers::AbandonOutcome; pub mod managed_account_operations; pub mod managed_accounts; +pub mod persistence; +pub use persistence::{PersistedWalletState, RestoreError}; pub mod transaction_builder; pub mod transaction_building; pub mod wallet_info_interface; @@ -20,7 +22,6 @@ use super::balance::WalletCoreBalance; use super::metadata::WalletMetadata; use crate::account::ManagedAccountCollection; use crate::managed_account::managed_account_trait::ManagedAccountTrait; -use crate::managed_account::ManagedAccountRefMut; use crate::wallet::managed_wallet_info::transaction_building::AccountTypePreference; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::{Network, Wallet}; @@ -123,6 +124,9 @@ pub struct ManagedWalletInfo { /// migration to load pre-field snapshots. #[cfg_attr(feature = "serde", serde(default, with = "observed_spent_outpoints_serde"))] pub(crate) observed_spent_outpoints: BTreeMap, + /// Durable blocked outputs whose spending transaction and height are unavailable. + #[cfg_attr(feature = "serde", serde(default))] + pub(crate) unattributed_spent_outpoints: HashSet, /// Generation counter for the wallet's account set, bumped every time an /// account is added to a live wallet (see /// [`Self::rewind_sync_checkpoint_for_new_account`]). @@ -245,6 +249,7 @@ impl ManagedWalletInfo { balance: WalletCoreBalance::default(), instant_send_locks: HashSet::new(), observed_spent_outpoints: BTreeMap::new(), + unattributed_spent_outpoints: HashSet::new(), account_generation: 0, } } @@ -261,6 +266,7 @@ impl ManagedWalletInfo { balance: WalletCoreBalance::default(), instant_send_locks: HashSet::new(), observed_spent_outpoints: BTreeMap::new(), + unattributed_spent_outpoints: HashSet::new(), account_generation: 0, } } @@ -287,6 +293,7 @@ impl ManagedWalletInfo { balance: WalletCoreBalance::default(), instant_send_locks: HashSet::new(), observed_spent_outpoints: BTreeMap::new(), + unattributed_spent_outpoints: HashSet::new(), account_generation: 0, } } @@ -327,57 +334,6 @@ impl ManagedWalletInfo { &self.observed_spent_outpoints } - /// Restore persisted transaction lifecycle state without replaying UTXO - /// mutations. - /// - /// Records are installed directly into their exact account. Chainlocked - /// records follow the configured finalized-record retention policy, and - /// block-confirmed inputs rebuild the wallet-level observed-spend guard. - /// This makes restoration independent of record iteration order; the - /// persistence layer restores the authoritative UTXO set separately. - /// - /// Returns records whose account is absent from this wallet. Callers must - /// treat those as degraded state rather than silently attributing them to - /// another account. - pub fn restore_persisted_transactions( - &mut self, - records: impl IntoIterator, - ) -> Vec { - let mut unmatched = Vec::new(); - for record in records { - let account_type = record.account_type; - let observed_spend = record - .context - .block_info() - .map(|block| (record.transaction.clone(), block.height())); - let account = - self.accounts.all_accounts_mut().into_iter().find(|account| { - account.managed_account_type().to_account_type() == account_type - }); - let restored = match account { - Some(ManagedAccountRefMut::Funds(account)) => { - account.keys_mut().restore_transaction_record(record); - true - } - Some(ManagedAccountRefMut::Keys(account)) => { - account.restore_transaction_record(record); - true - } - None => { - unmatched.push(record); - false - } - }; - if restored { - if let Some((transaction, height)) = observed_spend { - self.record_observed_spends(&transaction, height); - } - } - } - self.prune_finalized_observed_spends(); - unmatched - } - /// Record every outpoint `tx` spends into [`Self::observed_spent_outpoints`] /// at `height`. Insert-only bookkeeping — it never touches account UTXO sets, /// so it is safe to call before `record_transaction` builds a spend's diff --git a/key-wallet/src/wallet/managed_wallet_info/persistence.rs b/key-wallet/src/wallet/managed_wallet_info/persistence.rs new file mode 100644 index 000000000..6fdf026be --- /dev/null +++ b/key-wallet/src/wallet/managed_wallet_info/persistence.rs @@ -0,0 +1,310 @@ +//! Atomic installation of a persistence adapter's materialized wallet state. + +use super::wallet_info_interface::WalletInfoInterface; +use super::ManagedWalletInfo; +use crate::account::{AccountType, TransactionRecord}; +use crate::managed_account::managed_account_trait::ManagedAccountTrait; +use crate::managed_account::transaction_record::OutputRole; +use crate::managed_account::ManagedAccountRefMut; +use crate::utxo::Utxo; +use dashcore::prelude::CoreBlockHeight; +use dashcore::{OutPoint, Txid}; +use std::collections::{BTreeMap, HashSet}; +use std::fmt; + +/// Complete transaction and coin state supplied by an external persistence adapter. +/// +/// Store full records before key-wallet compacts finalized history. Records and UTXOs +/// must describe the same committed snapshot, with abandoned/conflicted records removed. +/// Account definitions, address pools and sync metadata belong to the receiving skeleton. +#[derive(Debug, Clone, Default)] +pub struct PersistedWalletState { + /// All surviving records, including full records of finalized transactions. + pub transactions: Vec, + /// The materialized unspent coins with their exact owning accounts. + pub utxos: Vec<(AccountType, Utxo)>, + /// Durable spend evidence, including outpoints whose spending record is unavailable. + /// `Some(height)` proves a block-observed spend. `None` only blocks the output; + /// claims covered by a record are derived from that record and remain releasable. + pub additional_spent_outpoints: BTreeMap>, +} + +/// A persisted snapshot could not be installed; the receiving wallet is unchanged. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RestoreError { + /// Restore requires an account skeleton with no transaction or coin state. + NonEmptyWallet, + /// The combined coin values cannot be represented by the wallet balance. + BalanceOverflow, + /// A record or coin names an account absent from the skeleton. + MissingAccount(AccountType), + /// A coin names a keys-only account. + NonFundingAccount(AccountType), + /// A record's txid or input/output metadata does not match its transaction. + InvalidRecord(Txid), + /// More than one record names the same transaction and account. + DuplicateRecord(Txid, AccountType), + /// More than one unspent coin names the same outpoint. + DuplicateUtxo(OutPoint), + /// A coin's address, script or corresponding transaction output is inconsistent. + InvalidUtxo(OutPoint), + /// A coin is simultaneously unspent and claimed spent. + SpentUtxo(OutPoint), +} + +impl fmt::Display for RestoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonEmptyWallet => { + f.write_str("persisted state requires an empty wallet skeleton") + } + Self::BalanceOverflow => { + f.write_str("persisted coin values overflow the wallet balance") + } + Self::MissingAccount(account) => write!(f, "missing persisted account {account}"), + Self::NonFundingAccount(account) => write!(f, "account {account} cannot hold coins"), + Self::InvalidRecord(txid) => write!(f, "inconsistent persisted transaction {txid}"), + Self::DuplicateRecord(txid, account) => { + write!(f, "duplicate transaction {txid} in {account}") + } + Self::DuplicateUtxo(outpoint) => write!(f, "duplicate persisted coin {outpoint}"), + Self::InvalidUtxo(outpoint) => write!(f, "inconsistent persisted coin {outpoint}"), + Self::SpentUtxo(outpoint) => write!(f, "persisted coin {outpoint} is also spent"), + } + } +} + +impl std::error::Error for RestoreError {} + +impl ManagedWalletInfo { + /// Install a complete persistence snapshot without replaying live transaction processing. + /// + /// Validates the entire snapshot before changing any state. The receiver must be a + /// fresh account skeleton; account definitions, pools and sync metadata are preserved. + /// Spend claims are derived before finalized records are compacted. Supplemental + /// evidence without a record or block height conservatively blocks funding redelivery + /// until a complete replacement snapshot can be built; it cannot be abandoned by txid. + /// Returns [`RestoreError`] for an inconsistent snapshot or nonempty receiver. + pub fn restore_persisted_state( + &mut self, + state: PersistedWalletState, + ) -> Result<(), RestoreError> { + self.validate_persisted_state(&state)?; + let record_inputs: HashSet<_> = state + .transactions + .iter() + .filter(|record| !record.transaction.is_coin_base()) + .flat_map(|record| record.transaction.input.iter().map(|input| input.previous_output)) + .collect(); + for (outpoint, height) in state.additional_spent_outpoints { + if let Some(height) = height { + self.observed_spent_outpoints.insert(outpoint, height); + } else if !record_inputs.contains(&outpoint) { + self.unattributed_spent_outpoints.insert(outpoint); + } + } + let account_claims: HashSet<_> = state + .transactions + .iter() + .filter(|record| !record.transaction.is_coin_base()) + .flat_map(|record| { + record + .transaction + .input + .iter() + .map(|input| (record.account_type, input.previous_output)) + }) + .collect(); + for record in &state.transactions { + if let Some(block) = record.context.block_info() { + for input in &record.transaction.input { + if !input.previous_output.is_null() { + self.observed_spent_outpoints + .entry(input.previous_output) + .and_modify(|height| *height = (*height).max(block.height())) + .or_insert(block.height()); + } + } + } + } + for record in state.transactions { + if record.context.is_instant_send() { + self.instant_send_locks.insert(record.txid); + } + for account in self.accounts.all_accounts_mut() { + if account.managed_account_type().to_account_type() != record.account_type { + continue; + } + match account { + ManagedAccountRefMut::Funds(account) => { + for detail in &record.output_details { + let outpoint = OutPoint { + txid: record.txid, + vout: detail.index, + }; + if matches!(detail.role, OutputRole::Received | OutputRole::Change) + && (self.observed_spent_outpoints.contains_key(&outpoint) + || self.unattributed_spent_outpoints.contains(&outpoint)) + && !account_claims.contains(&(record.account_type, outpoint)) + { + if let Some(address) = &detail.address { + account.spent_before_funded.insert( + outpoint, + Utxo::new( + outpoint, + record.transaction.output[detail.index as usize] + .clone(), + address.clone(), + record + .context + .block_info() + .map_or(0, |block| block.height()), + record.transaction.is_coin_base(), + ), + ); + } + } + } + account.restore_transaction_record(record); + } + ManagedAccountRefMut::Keys(account) => { + account.restore_transaction_record(record) + } + } + break; + } + } + for (account_type, utxo) in state.utxos { + for account in self.accounts.all_accounts_mut() { + if let ManagedAccountRefMut::Funds(account) = account { + if account.managed_account_type().to_account_type() == account_type { + account.utxos.insert(utxo.outpoint, utxo); + break; + } + } + } + } + for account in self.accounts.dashpay_external_accounts.values_mut() { + account.update_balance(self.metadata.last_processed_height); + } + self.update_balance(); + Ok(()) + } + + fn validate_persisted_state(&self, state: &PersistedWalletState) -> Result<(), RestoreError> { + let accounts: BTreeMap<_, _> = self + .accounts + .all_accounts() + .into_iter() + .map(|account| (account.managed_account_type().to_account_type(), account)) + .collect(); + if !self.observed_spent_outpoints.is_empty() + || !self.unattributed_spent_outpoints.is_empty() + || !self.instant_send_locks.is_empty() + || accounts.values().any(|account| { + account.tx_count() != 0 + || account.as_funds().is_some_and(|funds| funds.has_persisted_funds_state()) + }) + { + return Err(RestoreError::NonEmptyWallet); + } + let mut records = HashSet::new(); + let mut spent: HashSet<_> = state.additional_spent_outpoints.keys().copied().collect(); + let mut transactions = BTreeMap::new(); + for record in &state.transactions { + if !accounts.contains_key(&record.account_type) { + return Err(RestoreError::MissingAccount(record.account_type)); + } + let mut input_indices = HashSet::new(); + let mut output_indices = HashSet::new(); + if record.txid != record.transaction.txid() + || record.input_details.iter().any(|detail| { + detail.index as usize >= record.transaction.input.len() + || !input_indices.insert(detail.index) + || !detail.address.as_unchecked().is_valid_for_network(self.network) + }) + || record.output_details.iter().any(|detail| { + !output_indices.insert(detail.index) + || record.transaction.output.get(detail.index as usize).is_none_or( + |output| { + output.value != detail.value + || detail.address.as_ref().is_some_and(|address| { + address.script_pubkey() != output.script_pubkey + || !address + .as_unchecked() + .is_valid_for_network(self.network) + }) + }, + ) + }) + { + return Err(RestoreError::InvalidRecord(record.txid)); + } + if !records.insert((record.account_type, record.txid)) { + return Err(RestoreError::DuplicateRecord(record.txid, record.account_type)); + } + transactions.insert(record.txid, &record.transaction); + if !record.transaction.is_coin_base() { + spent.extend(record.transaction.input.iter().map(|input| input.previous_output)); + } + } + let mut coins = HashSet::new(); + let mut total = 0u64; + for (account_type, utxo) in &state.utxos { + total = total.checked_add(utxo.txout.value).ok_or(RestoreError::BalanceOverflow)?; + let account = + accounts.get(account_type).ok_or(RestoreError::MissingAccount(*account_type))?; + if account.as_funds().is_none() { + return Err(RestoreError::NonFundingAccount(*account_type)); + } + if !coins.insert(utxo.outpoint) { + return Err(RestoreError::DuplicateUtxo(utxo.outpoint)); + } + if spent.contains(&utxo.outpoint) { + return Err(RestoreError::SpentUtxo(utxo.outpoint)); + } + if (utxo.is_coinbase && utxo.height.checked_add(100).is_none()) + || utxo.address.script_pubkey() != utxo.txout.script_pubkey + || !utxo.address.as_unchecked().is_valid_for_network(self.network) + || transactions.get(&utxo.outpoint.txid).is_some_and(|transaction| { + transaction.output.get(utxo.outpoint.vout as usize) != Some(&utxo.txout) + }) + { + return Err(RestoreError::InvalidUtxo(utxo.outpoint)); + } + } + Ok(()) + } +} + +/// Output suppression and settled-input checks use distinct evidence. +pub(crate) trait SpendEvidence { + fn blocks_output(&self, outpoint: &OutPoint) -> bool; + fn is_settled(&self, outpoint: &OutPoint) -> bool; +} + +impl SpendEvidence for BTreeMap { + fn blocks_output(&self, outpoint: &OutPoint) -> bool { + self.contains_key(outpoint) + } + fn is_settled(&self, outpoint: &OutPoint) -> bool { + self.contains_key(outpoint) + } +} + +pub(crate) struct WalletSpendEvidence<'a> { + pub observed: &'a BTreeMap, + pub unattributed: &'a HashSet, + pub claimed: &'a HashSet, +} + +impl SpendEvidence for WalletSpendEvidence<'_> { + fn blocks_output(&self, outpoint: &OutPoint) -> bool { + self.observed.contains_key(outpoint) + || self.unattributed.contains(outpoint) + || self.claimed.contains(outpoint) + } + fn is_settled(&self, outpoint: &OutPoint) -> bool { + self.observed.contains_key(outpoint) + } +} From cdbcaa9176c4c40cb4189e73f63aadbdb9b7fa3c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:19:16 +0000 Subject: [PATCH 05/11] fix(key-wallet): retain unrecorded InstantSend winner claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep shared inputs blocked when an InstantSend winner removes a keys-only loser and no funds account retains the claim. Extra loser inputs remain releasable. Cover block and InstantSend winners without wallet records. Co-Authored-By: Codex GPT-6 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../managed_core_funds_account.rs | 8 +++---- .../persisted_transaction_restore_tests.rs | 15 +++++++++--- .../src/wallet/managed_wallet_info/helpers.rs | 23 +++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index 296221863..dadd30504 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -348,14 +348,12 @@ impl ManagedCoreFundsAccount { continue; } - // #649 spend-first ordering: the spend was observed in an - // earlier-processed block, so this output is genuinely spent - // on-chain even though this account has never seen it before — - // never insert it, so the record built below is born correct. + // Wallet evidence also covers claims held by other accounts. + // Keep the output details available for later input matching. if observed_spent.blocks_output(&outpoint) { tracing::debug!( outpoint = %outpoint, - "Skipping UTXO already observed spent in an earlier-processed block (#649)" + "Skipping output blocked by wallet spend evidence" ); self.spent_before_funded.insert( outpoint, diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index e4313ec54..383f91cf3 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -15,7 +15,7 @@ use crate::wallet::managed_wallet_info::{PersistedWalletState, RestoreError}; use crate::wallet::ManagedWalletInfo; use crate::AccountType; use dashcore::hashes::Hash; -use dashcore::{BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness}; +use dashcore::{BlockHash, InstantLock, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness}; use std::collections::BTreeMap; fn bip44() -> AccountType { @@ -695,8 +695,12 @@ async fn should_block_cross_account_funding_with_one_persisted_spend_record_and_ ); } +#[test_case::test_case(false; "block_winner")] +#[test_case::test_case(true; "instant_send_winner")] #[tokio::test] -async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra_input() { +async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra_input( + instant_send: bool, +) { let mut template = TestWalletContext::new_random(); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000, 2_000_000]); let parent = OutPoint { @@ -744,12 +748,17 @@ async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra .managed_wallet .check_core_transaction( &competing_spend(parent), - context.clone(), + if instant_send { + TransactionContext::InstantSend(InstantLock::default()) + } else { + context.clone() + }, &mut template.wallet, true, true, ) .await; + assert!(result.new_records.is_empty(), "the external winner has no retained wallet record"); assert!(result.swept_transactions.contains(&root_txid)); assert!(result.swept_transactions.contains(&child_txid)); assert_eq!(result.released_outpoints, vec![extra]); diff --git a/key-wallet/src/wallet/managed_wallet_info/helpers.rs b/key-wallet/src/wallet/managed_wallet_info/helpers.rs index 01e6e22de..418bd26d8 100644 --- a/key-wallet/src/wallet/managed_wallet_info/helpers.rs +++ b/key-wallet/src/wallet/managed_wallet_info/helpers.rs @@ -174,6 +174,17 @@ impl ManagedWalletInfo { let losers = conflicted_transactions(&records, tx, context); let winner_inputs: HashSet<_> = tx.input.iter().map(|input| input.previous_output).collect(); + let shared_inputs: HashSet<_> = if context.is_instant_send() && !losers.is_empty() { + records + .iter() + .filter(|record| losers.contains(&record.txid)) + .flat_map(|record| &record.transaction.input) + .map(|input| input.previous_output) + .filter(|outpoint| winner_inputs.contains(outpoint)) + .collect() + } else { + HashSet::new() + }; let mut result = WalletConflictSweep { txids: losers.iter().copied().collect(), released_outpoints: Vec::new(), @@ -204,6 +215,18 @@ impl ManagedWalletInfo { } } if !result.txids.is_empty() { + if context.is_instant_send() { + let accounts = self.accounts.all_accounts(); + self.unattributed_spent_outpoints.extend(shared_inputs.into_iter().filter( + |outpoint| { + !accounts.iter().any(|account| { + account + .as_funds() + .is_some_and(|funds| funds.is_outpoint_spent(outpoint)) + }) + }, + )); + } self.update_balance(); // One transaction can be recorded in several accounts, so the // per-account results overlap. From ce7c96bf933d1393deda6bdebbe3ac2ec3d6b7d2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:36:23 +0000 Subject: [PATCH 06/11] fix(key-wallet): validate restored coins and retain cross-account inputs --- .../persisted_transaction_restore_tests.rs | 144 ++++++++++++++++++ .../wallet/managed_wallet_info/persistence.rs | 7 +- 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index 383f91cf3..689da998c 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -11,6 +11,7 @@ use crate::transaction_checking::{ BlockInfo, TransactionContext, TransactionType, WalletTransactionChecker, }; use crate::utxo::Utxo; +use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::{PersistedWalletState, RestoreError}; use crate::wallet::ManagedWalletInfo; use crate::AccountType; @@ -789,3 +790,146 @@ fn should_reject_balance_overflow_before_installing_coins() { .is_err()); assert!(wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); } + +#[tokio::test] +async fn should_preserve_cross_account_input_recognition_after_restore() { + let mut template = TestWalletContext::new_random(); + let other_xpub = template.wallet.accounts.standard_bip32_accounts.get(&0).unwrap().account_xpub; + let other_address = template + .managed_wallet + .first_bip32_managed_account_mut() + .unwrap() + .next_receive_address(Some(&other_xpub), true) + .unwrap(); + let mut restored = template.managed_wallet.clone(); + let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let mut spending = spend(parent); + spending.output[0].script_pubkey = other_address.script_pubkey(); + template + .managed_wallet + .check_core_transaction( + &spending, + TransactionContext::Mempool, + &mut template.wallet, + true, + true, + ) + .await; + let context = TransactionContext::InBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([40; 32]), + 1_700_000_000, + )); + template + .managed_wallet + .check_core_transaction(&funding, context.clone(), &mut template.wallet, true, true) + .await; + let records = template + .managed_wallet + .accounts + .all_accounts() + .into_iter() + .flat_map(|account| account.transactions().values().cloned()) + .collect(); + restored + .restore_persisted_state(PersistedWalletState { + transactions: records, + utxos: template + .managed_wallet + .accounts + .all_accounts() + .into_iter() + .filter_map(|account| account.as_funds()) + .flat_map(|account| { + account + .utxos + .values() + .cloned() + .map(|utxo| (account.managed_account_type().to_account_type(), utxo)) + }) + .collect(), + additional_spent_outpoints: template + .managed_wallet + .observed_spent_outpoints() + .iter() + .map(|(outpoint, height)| (*outpoint, Some(*height))) + .chain([(parent, None)]) + .collect(), + }) + .unwrap(); + let live_result = template + .managed_wallet + .check_core_transaction(&spending, context.clone(), &mut template.wallet, true, true) + .await; + let restored_result = + restored.check_core_transaction(&spending, context, &mut template.wallet, true, true).await; + assert_eq!(live_result.new_records.len(), 1); + assert_eq!(live_result.new_records[0].account_type, bip44()); + assert_eq!(live_result.new_records[0].net_amount, -1_000_000); + assert_eq!(live_result.new_records[0].input_details.len(), 1); + assert_eq!(restored_result.new_records.len(), live_result.new_records.len()); + let debit = &restored_result.new_records[0]; + assert_eq!(debit.account_type, bip44()); + assert_eq!(debit.net_amount, -1_000_000); + assert_eq!(debit.input_details.len(), 1); + assert_eq!(debit.input_details[0].value, 1_000_000); +} + +#[test] +fn should_reject_coin_owned_by_another_wallet() { + let owner = TestWalletContext::new_random(); + let mut receiver = TestWalletContext::new_random(); + assert!(!receiver.bip44_account().contains_address(&owner.receive_address)); + let tx = Transaction::dummy(&owner.receive_address, 0..1, &[1_000_000]); + let outpoint = OutPoint { + txid: tx.txid(), + vout: 0, + }; + let mut coin = Utxo::new(outpoint, tx.output[0].clone(), owner.receive_address, 100, false); + coin.is_confirmed = true; + let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState { + utxos: vec![(bip44(), coin)], + ..Default::default() + }); + assert_eq!(result, Err(RestoreError::InvalidUtxo(outpoint))); + assert!(receiver.bip44_account().utxos.is_empty()); +} + +#[test] +fn should_reject_coinbase_flag_disagreeing_with_record() { + let mut receiver = TestWalletContext::new_random(); + receiver.managed_wallet.update_last_processed_height(100); + let mut tx = Transaction::dummy(&receiver.receive_address, 0..1, &[1_000_000]); + tx.input[0].previous_output = OutPoint::null(); + assert!(tx.is_coin_base()); + let outpoint = OutPoint { + txid: tx.txid(), + vout: 0, + }; + let mut coin = + Utxo::new(outpoint, tx.output[0].clone(), receiver.receive_address.clone(), 100, false); + coin.is_confirmed = true; + let record = TransactionRecord::new( + tx, + bip44(), + TransactionContext::InBlock(BlockInfo::new(100, BlockHash::all_zeros(), 0)), + TransactionType::Standard, + TransactionDirection::Incoming, + vec![], + vec![], + 1_000_000, + ); + let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState { + transactions: vec![record], + utxos: vec![(bip44(), coin)], + ..Default::default() + }); + assert_eq!(result, Err(RestoreError::InvalidUtxo(outpoint))); + assert!(receiver.bip44_account().utxos.is_empty()); + assert!(receiver.bip44_account().transactions().is_empty()); + assert!(receiver.managed_wallet.observed_spent_outpoints().is_empty()); +} diff --git a/key-wallet/src/wallet/managed_wallet_info/persistence.rs b/key-wallet/src/wallet/managed_wallet_info/persistence.rs index 6fdf026be..5908faecd 100644 --- a/key-wallet/src/wallet/managed_wallet_info/persistence.rs +++ b/key-wallet/src/wallet/managed_wallet_info/persistence.rs @@ -46,7 +46,7 @@ pub enum RestoreError { DuplicateRecord(Txid, AccountType), /// More than one unspent coin names the same outpoint. DuplicateUtxo(OutPoint), - /// A coin's address, script or corresponding transaction output is inconsistent. + /// A coin's ownership, script or funding transaction metadata is inconsistent. InvalidUtxo(OutPoint), /// A coin is simultaneously unspent and claimed spent. SpentUtxo(OutPoint), @@ -143,7 +143,8 @@ impl ManagedWalletInfo { vout: detail.index, }; if matches!(detail.role, OutputRole::Received | OutputRole::Change) - && (self.observed_spent_outpoints.contains_key(&outpoint) + && (record_inputs.contains(&outpoint) + || self.observed_spent_outpoints.contains_key(&outpoint) || self.unattributed_spent_outpoints.contains(&outpoint)) && !account_claims.contains(&(record.account_type, outpoint)) { @@ -264,10 +265,12 @@ impl ManagedWalletInfo { return Err(RestoreError::SpentUtxo(utxo.outpoint)); } if (utxo.is_coinbase && utxo.height.checked_add(100).is_none()) + || !account.contains_address(&utxo.address) || utxo.address.script_pubkey() != utxo.txout.script_pubkey || !utxo.address.as_unchecked().is_valid_for_network(self.network) || transactions.get(&utxo.outpoint.txid).is_some_and(|transaction| { transaction.output.get(utxo.outpoint.vout as usize) != Some(&utxo.txout) + || transaction.is_coin_base() != utxo.is_coinbase }) { return Err(RestoreError::InvalidUtxo(utxo.outpoint)); From a11c46629a338a59464e361673c21ae96db2e9b4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:17:09 +0000 Subject: [PATCH 07/11] fix(key-wallet): preserve mined context and validate restored heights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep mined transaction context on late or repeated InstantSend locks, including after restore with no remaining outputs. Reject UTXO heights contradicting full funding records before installing a snapshot. 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- .../persisted_transaction_restore_tests.rs | 89 ++++++++++++++++++- .../wallet/managed_wallet_info/persistence.rs | 14 ++- .../wallet_info_interface.rs | 7 +- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index 689da998c..0a842fc60 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -899,8 +899,9 @@ fn should_reject_coin_owned_by_another_wallet() { assert!(receiver.bip44_account().utxos.is_empty()); } -#[test] -fn should_reject_coinbase_flag_disagreeing_with_record() { +#[test_case::test_case(false; "coinbase_flag")] +#[test_case::test_case(true; "block_height")] +fn should_reject_coinbase_metadata_disagreeing_with_record(invalid_height: bool) { let mut receiver = TestWalletContext::new_random(); receiver.managed_wallet.update_last_processed_height(100); let mut tx = Transaction::dummy(&receiver.receive_address, 0..1, &[1_000_000]); @@ -911,7 +912,7 @@ fn should_reject_coinbase_flag_disagreeing_with_record() { vout: 0, }; let mut coin = - Utxo::new(outpoint, tx.output[0].clone(), receiver.receive_address.clone(), 100, false); + Utxo::new(outpoint, tx.output[0].clone(), receiver.receive_address.clone(), 100, true); coin.is_confirmed = true; let record = TransactionRecord::new( tx, @@ -923,6 +924,21 @@ fn should_reject_coinbase_flag_disagreeing_with_record() { vec![], 1_000_000, ); + let mut control = receiver.managed_wallet.clone(); + control + .restore_persisted_state(PersistedWalletState { + transactions: vec![record.clone()], + utxos: vec![(bip44(), coin.clone())], + ..Default::default() + }) + .unwrap(); + assert_eq!(control.balance().immature(), 1_000_000); + assert!(!control.first_bip44_managed_account().unwrap().utxos[&outpoint].is_spendable(100)); + if invalid_height { + coin.height = 0; + } else { + coin.is_coinbase = false; + } let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState { transactions: vec![record], utxos: vec![(bip44(), coin)], @@ -933,3 +949,70 @@ fn should_reject_coinbase_flag_disagreeing_with_record() { assert!(receiver.bip44_account().transactions().is_empty()); assert!(receiver.managed_wallet.observed_spent_outpoints().is_empty()); } + +#[test_case::test_case(false; "unspent")] +#[test_case::test_case(true; "fully_spent")] +#[tokio::test] +async fn should_preserve_restored_block_context_on_duplicate_instant_lock(fully_spent: bool) { + let mut live = TestWalletContext::new_random(); + let mut restored = live.managed_wallet.clone(); + let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); + let txid = funding.txid(); + let lock = InstantLock { + txid, + ..InstantLock::default() + }; + live.check_transaction(&funding, TransactionContext::Mempool).await; + assert!(live.managed_wallet.mark_instant_send_utxos(&txid, &lock)); + let block_info = BlockInfo::new(40, BlockHash::from_byte_array([40; 32]), 1_700_000_000); + let block = TransactionContext::InBlock(block_info); + live.check_transaction(&funding, block.clone()).await; + assert_eq!(live.transaction(&txid).context, block); + if fully_spent { + live.check_transaction( + &spend(OutPoint { + txid, + vout: 0, + }), + block.clone(), + ) + .await; + assert!(live.bip44_account().utxos.is_empty()); + } + let account = live.bip44_account(); + restored + .restore_persisted_state(PersistedWalletState { + transactions: account.transactions().values().cloned().collect(), + utxos: account.utxos.values().cloned().map(|coin| (bip44(), coin)).collect(), + additional_spent_outpoints: live + .managed_wallet + .observed_spent_outpoints() + .iter() + .map(|(outpoint, height)| (*outpoint, Some(*height))) + .collect(), + }) + .unwrap(); + assert!(!live.managed_wallet.mark_instant_send_utxos(&txid, &lock)); + restored.mark_instant_send_utxos(&txid, &lock); + assert_eq!( + restored.first_bip44_managed_account().unwrap().transactions()[&txid].context, + block + ); + assert!(!restored.mark_instant_send_utxos(&txid, &lock)); + restored.apply_chain_lock(dashcore::ChainLock { + block_height: 40, + block_hash: BlockHash::from_byte_array([40; 32]), + signature: [0u8; 96].into(), + }); + let account = restored.first_bip44_managed_account().unwrap(); + assert!(account.transaction_is_finalized(&txid)); + #[cfg(feature = "keep-finalized-transactions")] + assert_eq!( + account.transactions()[&txid].context, + TransactionContext::InChainLockedBlock(block_info) + ); + // A further delivery must also leave finalized history intact. + restored.instant_send_locks.remove(&txid); + restored.mark_instant_send_utxos(&txid, &lock); + assert!(restored.first_bip44_managed_account().unwrap().transaction_is_finalized(&txid)); +} diff --git a/key-wallet/src/wallet/managed_wallet_info/persistence.rs b/key-wallet/src/wallet/managed_wallet_info/persistence.rs index 5908faecd..ad49bcb05 100644 --- a/key-wallet/src/wallet/managed_wallet_info/persistence.rs +++ b/key-wallet/src/wallet/managed_wallet_info/persistence.rs @@ -40,7 +40,7 @@ pub enum RestoreError { MissingAccount(AccountType), /// A coin names a keys-only account. NonFundingAccount(AccountType), - /// A record's txid or input/output metadata does not match its transaction. + /// A record's txid, input/output metadata or block height is inconsistent. InvalidRecord(Txid), /// More than one record names the same transaction and account. DuplicateRecord(Txid, AccountType), @@ -212,6 +212,7 @@ impl ManagedWalletInfo { let mut records = HashSet::new(); let mut spent: HashSet<_> = state.additional_spent_outpoints.keys().copied().collect(); let mut transactions = BTreeMap::new(); + let mut funding_heights = BTreeMap::new(); for record in &state.transactions { if !accounts.contains_key(&record.account_type) { return Err(RestoreError::MissingAccount(record.account_type)); @@ -245,6 +246,14 @@ impl ManagedWalletInfo { return Err(RestoreError::DuplicateRecord(record.txid, record.account_type)); } transactions.insert(record.txid, &record.transaction); + if let Some(block) = record.context.block_info() { + if funding_heights + .insert(record.txid, block.height()) + .is_some_and(|height| height != block.height()) + { + return Err(RestoreError::InvalidRecord(record.txid)); + } + } if !record.transaction.is_coin_base() { spent.extend(record.transaction.input.iter().map(|input| input.previous_output)); } @@ -265,6 +274,9 @@ impl ManagedWalletInfo { return Err(RestoreError::SpentUtxo(utxo.outpoint)); } if (utxo.is_coinbase && utxo.height.checked_add(100).is_none()) + || funding_heights + .get(&utxo.outpoint.txid) + .is_some_and(|height| *height != utxo.height) || !account.contains_address(&utxo.address) || utxo.address.script_pubkey() != utxo.txout.script_pubkey || !utxo.address.as_unchecked().is_valid_for_network(self.network) diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 585effb53..0b6e2588f 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -574,8 +574,11 @@ impl WalletInfoInterface for ManagedWalletInfo { any_changed = true; } if let Some(record) = account.transactions_mut().get_mut(txid) { - record.update_context(TransactionContext::InstantSend(lock.clone())); - any_changed = true; + // Lock delivery must not discard mined context, including after restore. + if !record.is_confirmed() { + record.update_context(TransactionContext::InstantSend(lock.clone())); + any_changed = true; + } if locked_transaction.is_none() { locked_transaction = Some(record.transaction.clone()); } From 550c178b5f5242506f275faf7a5d541ccad41cba Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:25:09 +0000 Subject: [PATCH 08/11] fix(key-wallet): validate restored lifecycle state Reject mixed per-account transaction contexts and contradictory coin finality before changing the wallet. Invalidate monitored outpoints after restoring coins and use deterministic persistence fixtures. Co-Authored-By: Codex --- key-wallet/src/test_utils/wallet.rs | 12 ++ .../persisted_transaction_restore_tests.rs | 186 ++++++++++++++++-- .../wallet/managed_wallet_info/persistence.rs | 39 ++-- 3 files changed, 202 insertions(+), 35 deletions(-) diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 7f7859a5a..1cca5b07c 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -37,6 +37,18 @@ impl TestWalletContext { /// accounts. pub fn new_random_with_options(options: WalletAccountCreationOptions) -> Self { let wallet = Wallet::new_random(Network::Testnet, options).expect("Should create wallet"); + Self::from_wallet(wallet) + } + + /// Creates a reproducible testnet wallet from a fixed seed. + pub fn from_seed(seed: [u8; 64]) -> Self { + let wallet = + Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::Default) + .expect("Should create wallet"); + Self::from_wallet(wallet) + } + + fn from_wallet(wallet: Wallet) -> Self { let mut managed_wallet = ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index 0a842fc60..0212033cc 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -73,7 +73,7 @@ fn spending_record( #[tokio::test] async fn restored_spend_mark_blocks_funding_until_the_claim_is_released() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([1; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { txid: funding.txid(), @@ -118,7 +118,7 @@ async fn restored_spend_mark_blocks_funding_until_the_claim_is_released() { #[test] fn restored_chainlocked_record_uses_finalized_compaction() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([2; 64]); let parent = OutPoint { txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), vout: 0, @@ -154,7 +154,7 @@ fn restored_chainlocked_record_uses_finalized_compaction() { #[test] fn unmatched_record_does_not_restore_wallet_level_spend_state() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([3; 64]); let parent = OutPoint { txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), vout: 0, @@ -188,7 +188,7 @@ fn unmatched_record_does_not_restore_wallet_level_spend_state() { #[test] fn restored_unconfirmed_records_participate_in_conflict_descendant_sweeps() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([4; 64]); let parent = OutPoint { txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), vout: 0, @@ -240,9 +240,155 @@ fn coin(tx: &Transaction, address: &dashcore::Address) -> Utxo { ) } +#[test] +fn should_invalidate_monitor_after_restoring_coins() { + let mut ctx = TestWalletContext::from_seed([21; 64]); + let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); + let revision = ctx.managed_wallet.monitor_revision(); + let elements = ctx.managed_wallet.monitored_filter_elements(); + ctx.managed_wallet + .restore_persisted_state(PersistedWalletState { + utxos: vec![(bip44(), coin(&funding, &ctx.receive_address))], + ..Default::default() + }) + .unwrap(); + assert_ne!(elements, ctx.managed_wallet.monitored_filter_elements()); + assert!(ctx.managed_wallet.monitor_revision() > revision); +} + +#[test_case::test_case(0, true; "mempool_confirmed")] +#[test_case::test_case(0, false; "mempool_instantlocked")] +#[test_case::test_case(1, true; "instant_send_confirmed")] +#[test_case::test_case(1, false; "instant_send_without_lock_flag")] +#[test_case::test_case(2, true; "block_unconfirmed")] +#[test_case::test_case(3, true; "chainlock_unconfirmed")] +#[tokio::test] +async fn should_reject_inconsistent_coin_finality(context_kind: u8, flip_confirmed: bool) { + let mut live = TestWalletContext::from_seed([22; 64]); + let mut restored = live.managed_wallet.clone(); + let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); + let block = BlockInfo::new(40, BlockHash::from_byte_array([40; 32]), 1_700_000_000); + let context = match context_kind { + 0 => TransactionContext::Mempool, + 1 => TransactionContext::InstantSend(InstantLock { + txid: funding.txid(), + ..Default::default() + }), + 2 => TransactionContext::InBlock(block), + _ => TransactionContext::InChainLockedBlock(block), + }; + let result = live.check_transaction(&funding, context).await; + let mut utxo = live.first_utxo().clone(); + let snapshot = PersistedWalletState { + transactions: result.new_records, + utxos: vec![(bip44(), utxo.clone())], + ..Default::default() + }; + let mut control = restored.clone(); + control.restore_persisted_state(snapshot.clone()).unwrap(); + assert_eq!(control.balance(), live.managed_wallet.balance()); + if flip_confirmed { + utxo.is_confirmed = !utxo.is_confirmed; + } else { + utxo.is_instantlocked = !utxo.is_instantlocked; + } + let revision = restored.monitor_revision(); + let elements = restored.monitored_filter_elements(); + assert_eq!( + restored.restore_persisted_state(PersistedWalletState { + utxos: vec![(bip44(), utxo.clone())], + ..snapshot + }), + Err(RestoreError::InvalidUtxo(utxo.outpoint)) + ); + assert_eq!(restored.monitor_revision(), revision); + assert_eq!(restored.monitored_filter_elements(), elements); + assert!(restored.transaction_history().is_empty()); + assert!(restored.observed_spent_outpoints().is_empty()); + assert!(restored.instant_send_locks.is_empty()); + assert_eq!(restored.balance().total(), 0); +} + +#[test_case::test_case(0, false; "mempool_block")] +#[test_case::test_case(0, true; "block_mempool")] +#[test_case::test_case(1, false; "mempool_instant_send")] +#[test_case::test_case(1, true; "instant_send_mempool")] +#[test_case::test_case(2, false; "different_height")] +#[test_case::test_case(2, true; "different_height_reversed")] +#[test_case::test_case(3, false; "different_hash")] +#[test_case::test_case(4, false; "block_chainlock")] +#[test_case::test_case(5, false; "same_block")] +#[test_case::test_case(6, false; "optional_block_position")] +fn should_validate_lifecycle_across_accounts(context_kind: u8, reverse: bool) { + use crate::wallet::managed_wallet_info::ManagedAccountOperations; + let mut ctx = TestWalletContext::from_seed([23; 64]); + let other = TestWalletContext::from_seed([24; 64]); + let other_type = AccountType::Standard { + index: 1, + standard_account_type: StandardAccountType::BIP44Account, + }; + ctx.managed_wallet.add_managed_account_from_xpub(other_type, other.xpub).unwrap(); + let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); + let tx = spend(OutPoint { + txid: funding.txid(), + vout: 0, + }); + let block = BlockInfo::new(40, BlockHash::from_byte_array([40; 32]), 1_700_000_000); + let (first, second) = match context_kind { + 0 => (TransactionContext::Mempool, TransactionContext::InBlock(block)), + 1 => ( + TransactionContext::Mempool, + TransactionContext::InstantSend(InstantLock { + txid: tx.txid(), + ..Default::default() + }), + ), + 2 => ( + TransactionContext::InBlock(block), + TransactionContext::InBlock(BlockInfo::new(41, block.block_hash(), block.timestamp())), + ), + 3 => ( + TransactionContext::InBlock(block), + TransactionContext::InBlock(BlockInfo::new( + 40, + BlockHash::from_byte_array([41; 32]), + block.timestamp(), + )), + ), + 4 => (TransactionContext::InBlock(block), TransactionContext::InChainLockedBlock(block)), + 5 => (TransactionContext::InBlock(block), TransactionContext::InBlock(block)), + _ => ( + TransactionContext::InBlock(block), + TransactionContext::InBlock(block.with_position(2)), + ), + }; + let first_record = spending_record(tx.clone(), ctx.receive_address.clone(), first); + let mut second_record = spending_record(tx.clone(), ctx.receive_address.clone(), second); + second_record.account_type = other_type; + let mut records = vec![first_record, second_record]; + if reverse { + records.reverse(); + } + let revision = ctx.managed_wallet.monitor_revision(); + let result = ctx.managed_wallet.restore_persisted_state(PersistedWalletState { + transactions: records, + ..Default::default() + }); + if context_kind >= 5 { + assert_eq!(result, Ok(())); + assert_eq!(ctx.managed_wallet.transaction_history().len(), 2); + } else { + assert_eq!(result, Err(RestoreError::InvalidRecord(tx.txid()))); + assert!(ctx.managed_wallet.transaction_history().is_empty()); + assert!(ctx.managed_wallet.observed_spent_outpoints().is_empty()); + assert!(ctx.managed_wallet.instant_send_locks.is_empty()); + } + assert_eq!(ctx.managed_wallet.monitor_revision(), revision); +} + #[test] fn should_reject_entire_snapshot_before_mutating_any_account() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([5; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { txid: funding.txid(), @@ -293,7 +439,7 @@ fn should_reject_entire_snapshot_before_mutating_any_account() { #[test] fn should_reject_spent_unspent_contradiction_and_bad_coin_without_mutation() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([6; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); let utxo = coin(&funding, &template.receive_address); let record = spending_record( @@ -325,7 +471,7 @@ fn should_reject_spent_unspent_contradiction_and_bad_coin_without_mutation() { #[tokio::test] async fn should_preserve_unattributed_spend_without_inventing_block_height() { - let mut template = TestWalletContext::new_random(); + let mut template = TestWalletContext::from_seed([7; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { txid: funding.txid(), @@ -359,7 +505,7 @@ async fn should_preserve_unattributed_spend_without_inventing_block_height() { #[tokio::test] async fn should_keep_external_block_evidence_when_abandoning_record_claim() { - let mut template = TestWalletContext::new_random(); + let mut template = TestWalletContext::from_seed([8; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { txid: funding.txid(), @@ -401,7 +547,7 @@ async fn should_keep_external_block_evidence_when_abandoning_record_claim() { #[tokio::test] async fn should_match_uninterrupted_wallet_across_restore_abandon_and_conflict() { - let mut uninterrupted = TestWalletContext::new_random(); + let mut uninterrupted = TestWalletContext::from_seed([9; 64]); let mut restored = uninterrupted.managed_wallet.clone(); let funding = Transaction::dummy(&uninterrupted.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { @@ -534,7 +680,7 @@ fn assert_funds_equal(left: &ManagedWalletInfo, right: &ManagedWalletInfo) { #[tokio::test] async fn should_match_finalized_wallet_after_compaction_and_funding_redelivery() { - let mut live = TestWalletContext::new_random(); + let mut live = TestWalletContext::from_seed([10; 64]); let mut restored = live.managed_wallet.clone(); let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { @@ -578,7 +724,7 @@ async fn should_match_finalized_wallet_after_compaction_and_funding_redelivery() #[tokio::test] async fn should_restore_spend_before_funding_input_recognition() { - let mut live = TestWalletContext::new_random(); + let mut live = TestWalletContext::from_seed([11; 64]); let mut restored = live.managed_wallet.clone(); let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); let parent = OutPoint { @@ -633,8 +779,8 @@ async fn should_block_cross_account_funding_with_one_persisted_spend_record_and_ keys_only: bool, ) { use crate::wallet::managed_wallet_info::ManagedAccountOperations; - let mut template = TestWalletContext::new_random(); - let other = TestWalletContext::new_random(); + let mut template = TestWalletContext::from_seed([12; 64]); + let other = TestWalletContext::from_seed([13; 64]); let other_type = if keys_only { AccountType::IdentityRegistration } else { @@ -702,7 +848,7 @@ async fn should_block_cross_account_funding_with_one_persisted_spend_record_and_ async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra_input( instant_send: bool, ) { - let mut template = TestWalletContext::new_random(); + let mut template = TestWalletContext::from_seed([14; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000, 2_000_000]); let parent = OutPoint { txid: funding.txid(), @@ -775,7 +921,7 @@ async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra #[test] fn should_reject_balance_overflow_before_installing_coins() { - let template = TestWalletContext::new_random(); + let template = TestWalletContext::from_seed([15; 64]); let funding = Transaction::dummy(&template.receive_address, 0..1, &[u64::MAX, 1]); let first = coin(&funding, &template.receive_address); let mut second = first.clone(); @@ -793,7 +939,7 @@ fn should_reject_balance_overflow_before_installing_coins() { #[tokio::test] async fn should_preserve_cross_account_input_recognition_after_restore() { - let mut template = TestWalletContext::new_random(); + let mut template = TestWalletContext::from_seed([16; 64]); let other_xpub = template.wallet.accounts.standard_bip32_accounts.get(&0).unwrap().account_xpub; let other_address = template .managed_wallet @@ -881,8 +1027,8 @@ async fn should_preserve_cross_account_input_recognition_after_restore() { #[test] fn should_reject_coin_owned_by_another_wallet() { - let owner = TestWalletContext::new_random(); - let mut receiver = TestWalletContext::new_random(); + let owner = TestWalletContext::from_seed([17; 64]); + let mut receiver = TestWalletContext::from_seed([18; 64]); assert!(!receiver.bip44_account().contains_address(&owner.receive_address)); let tx = Transaction::dummy(&owner.receive_address, 0..1, &[1_000_000]); let outpoint = OutPoint { @@ -902,7 +1048,7 @@ fn should_reject_coin_owned_by_another_wallet() { #[test_case::test_case(false; "coinbase_flag")] #[test_case::test_case(true; "block_height")] fn should_reject_coinbase_metadata_disagreeing_with_record(invalid_height: bool) { - let mut receiver = TestWalletContext::new_random(); + let mut receiver = TestWalletContext::from_seed([19; 64]); receiver.managed_wallet.update_last_processed_height(100); let mut tx = Transaction::dummy(&receiver.receive_address, 0..1, &[1_000_000]); tx.input[0].previous_output = OutPoint::null(); @@ -954,7 +1100,7 @@ fn should_reject_coinbase_metadata_disagreeing_with_record(invalid_height: bool) #[test_case::test_case(true; "fully_spent")] #[tokio::test] async fn should_preserve_restored_block_context_on_duplicate_instant_lock(fully_spent: bool) { - let mut live = TestWalletContext::new_random(); + let mut live = TestWalletContext::from_seed([20; 64]); let mut restored = live.managed_wallet.clone(); let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); let txid = funding.txid(); diff --git a/key-wallet/src/wallet/managed_wallet_info/persistence.rs b/key-wallet/src/wallet/managed_wallet_info/persistence.rs index ad49bcb05..cb72c0a0c 100644 --- a/key-wallet/src/wallet/managed_wallet_info/persistence.rs +++ b/key-wallet/src/wallet/managed_wallet_info/persistence.rs @@ -16,6 +16,8 @@ use std::fmt; /// /// Store full records before key-wallet compacts finalized history. Records and UTXOs /// must describe the same committed snapshot, with abandoned/conflicted records removed. +/// Records for one txid must agree on block identity and finality. Coin confirmation +/// and unmined InstantSend flags must agree with any surviving funding record. /// Account definitions, address pools and sync metadata belong to the receiving skeleton. #[derive(Debug, Clone, Default)] pub struct PersistedWalletState { @@ -40,13 +42,13 @@ pub enum RestoreError { MissingAccount(AccountType), /// A coin names a keys-only account. NonFundingAccount(AccountType), - /// A record's txid, input/output metadata or block height is inconsistent. + /// A record's txid, input/output metadata or lifecycle context is inconsistent. InvalidRecord(Txid), /// More than one record names the same transaction and account. DuplicateRecord(Txid, AccountType), /// More than one unspent coin names the same outpoint. DuplicateUtxo(OutPoint), - /// A coin's ownership, script or funding transaction metadata is inconsistent. + /// A coin's ownership, script or funding transaction metadata/finality is inconsistent. InvalidUtxo(OutPoint), /// A coin is simultaneously unspent and claimed spent. SpentUtxo(OutPoint), @@ -179,6 +181,9 @@ impl ManagedWalletInfo { for account in self.accounts.all_accounts_mut() { if let ManagedAccountRefMut::Funds(account) = account { if account.managed_account_type().to_account_type() == account_type { + if account.utxos.is_empty() { + account.bump_monitor_revision(); + } account.utxos.insert(utxo.outpoint, utxo); break; } @@ -212,7 +217,7 @@ impl ManagedWalletInfo { let mut records = HashSet::new(); let mut spent: HashSet<_> = state.additional_spent_outpoints.keys().copied().collect(); let mut transactions = BTreeMap::new(); - let mut funding_heights = BTreeMap::new(); + let mut lifecycles = BTreeMap::new(); for record in &state.transactions { if !accounts.contains_key(&record.account_type) { return Err(RestoreError::MissingAccount(record.account_type)); @@ -245,14 +250,15 @@ impl ManagedWalletInfo { if !records.insert((record.account_type, record.txid)) { return Err(RestoreError::DuplicateRecord(record.txid, record.account_type)); } - transactions.insert(record.txid, &record.transaction); - if let Some(block) = record.context.block_info() { - if funding_heights - .insert(record.txid, block.height()) - .is_some_and(|height| height != block.height()) - { - return Err(RestoreError::InvalidRecord(record.txid)); - } + transactions.insert(record.txid, record); + // Position is optional metadata; block identity and finality must agree. + let lifecycle = ( + record.context.block_info().map(|block| (block.height(), block.block_hash())), + record.context.is_instant_send(), + record.context.is_chain_locked(), + ); + if lifecycles.insert(record.txid, lifecycle).is_some_and(|prior| prior != lifecycle) { + return Err(RestoreError::InvalidRecord(record.txid)); } if !record.transaction.is_coin_base() { spent.extend(record.transaction.input.iter().map(|input| input.previous_output)); @@ -274,15 +280,18 @@ impl ManagedWalletInfo { return Err(RestoreError::SpentUtxo(utxo.outpoint)); } if (utxo.is_coinbase && utxo.height.checked_add(100).is_none()) - || funding_heights - .get(&utxo.outpoint.txid) - .is_some_and(|height| *height != utxo.height) || !account.contains_address(&utxo.address) || utxo.address.script_pubkey() != utxo.txout.script_pubkey || !utxo.address.as_unchecked().is_valid_for_network(self.network) - || transactions.get(&utxo.outpoint.txid).is_some_and(|transaction| { + || transactions.get(&utxo.outpoint.txid).is_some_and(|record| { + let transaction = &record.transaction; transaction.output.get(utxo.outpoint.vout as usize) != Some(&utxo.txout) || transaction.is_coin_base() != utxo.is_coinbase + || record.context.block_info().is_some_and(|block| block.height() != utxo.height) + || record.is_confirmed() != utxo.is_confirmed + // A mined context does not retain earlier InstantSend evidence. + || (!record.is_confirmed() + && record.context.is_instant_send() != utxo.is_instantlocked) }) { return Err(RestoreError::InvalidUtxo(utxo.outpoint)); From 1642aae51841a7d3aeca06661d18691f45d6b227 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 17 Sep 2026 08:55:57 +0000 Subject: [PATCH 09/11] test(key-wallet): measure wallet serialization directly Time backup encoding on a deterministic populated wallet instead of random mnemonic generation. Verify restored identity and account count outside the timer, retain the 50ms ceiling, and report measured timing on failure. Co-Authored-By: Codex --- key-wallet/src/tests/performance_tests.rs | 35 ++++++++++++++--------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/key-wallet/src/tests/performance_tests.rs b/key-wallet/src/tests/performance_tests.rs index 9603bea77..ba09b7f8d 100644 --- a/key-wallet/src/tests/performance_tests.rs +++ b/key-wallet/src/tests/performance_tests.rs @@ -278,27 +278,34 @@ fn test_concurrent_derivation_performance() { } #[test] +#[cfg(feature = "bincode")] fn test_wallet_serialization_performance() { - // Serialization test would require bincode feature - // For now, just test wallet creation/destruction cycle - + let wallet = Wallet::from_seed_bytes( + [42; 64], + Network::Testnet, + crate::wallet::initialization::WalletAccountCreationOptions::Default, + ) + .unwrap(); let iterations = 100; - let mut creation_times = Vec::new(); + let mut serialization_times = Vec::new(); for _ in 0..iterations { let start = Instant::now(); - let _wallet = Wallet::new_random( - Network::Testnet, - crate::wallet::initialization::WalletAccountCreationOptions::None, - ) - .unwrap(); - creation_times.push(start.elapsed()); - } + let backup = wallet.backup().unwrap(); + serialization_times.push(start.elapsed()); - let metrics = PerformanceMetrics::from_times("Wallet Creation", creation_times); + let restored = Wallet::restore(&backup).unwrap(); + assert_eq!(restored.wallet_id, wallet.wallet_id); + assert_eq!(restored.accounts.count(), wallet.accounts.count()); + } - // Assert creation performance (relaxed for test environment) - assert!(metrics.avg_time < Duration::from_millis(50)); + let metrics = PerformanceMetrics::from_times("Wallet Serialization", serialization_times); + metrics._print_summary(); + assert!( + metrics.avg_time < Duration::from_millis(50), + "Wallet serialization too slow: avg {:?}, expected < 50ms", + metrics.avg_time + ); } #[test] From 9a8022854bbf4e2ab9bb8a77df4ba2cfecbcb45f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 17 Sep 2026 09:50:06 +0000 Subject: [PATCH 10/11] fix(wallet): preserve restored block-spend evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep restored confirmed spend evidence pinned across ChainLock and sync checkpoint pruning, including inputs of compacted keys-only records. Preserve recorded heights for settled-input checks and leave mempool-only claims releasable. Reuse the existing serialized evidence set. Co-Authored-By: OpenAI GPT-6 Astra 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 5 + .../persisted_transaction_restore_tests.rs | 138 +++++++++++++++++- .../src/wallet/managed_wallet_info/mod.rs | 25 ++-- .../wallet/managed_wallet_info/persistence.rs | 13 +- 4 files changed, 165 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd501d27d..c0a947dc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased +### Fixed + +- Preserve restored wallet block-spend evidence through ChainLock and sync + checkpoint pruning so missing transaction history cannot resurrect spent coins. + ### Changed - **Breaking:** the `bincode` feature and binary serialization dependencies now use diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs index 0212033cc..058db1cc9 100644 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ b/key-wallet/src/tests/persisted_transaction_restore_tests.rs @@ -16,7 +16,9 @@ use crate::wallet::managed_wallet_info::{PersistedWalletState, RestoreError}; use crate::wallet::ManagedWalletInfo; use crate::AccountType; use dashcore::hashes::Hash; -use dashcore::{BlockHash, InstantLock, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness}; +use dashcore::{ + BlockHash, ChainLock, InstantLock, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness, +}; use std::collections::BTreeMap; fn bip44() -> AccountType { @@ -50,6 +52,104 @@ fn competing_spend(parent: OutPoint) -> Transaction { transaction } +#[test_case::test_case(false, false; "sync_checkpoint")] +#[test_case::test_case(true, false; "chain_lock")] +#[test_case::test_case(false, true; "sync_checkpoint_after_abandon")] +#[test_case::test_case(true, true; "chain_lock_after_abandon")] +#[tokio::test] +async fn should_preserve_supplemental_block_spends_after_pruning( + chain_lock_trigger: bool, + with_record_claim: bool, +) { + let mut ctx = TestWalletContext::from_seed([21; 64]); + let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let claimant = spend(parent); + let claimant_txid = claimant.txid(); + ctx.managed_wallet.apply_chain_lock(ChainLock { + block_height: 100, + block_hash: BlockHash::from_byte_array([100; 32]), + signature: [0; 96].into(), + }); + ctx.managed_wallet.update_synced_height(100); + ctx.managed_wallet + .restore_persisted_state(PersistedWalletState { + transactions: if with_record_claim { + vec![spending_record( + claimant, + ctx.receive_address.clone(), + TransactionContext::Mempool, + )] + } else { + Vec::new() + }, + additional_spent_outpoints: BTreeMap::from([(parent, Some(80))]), + ..Default::default() + }) + .unwrap(); + + for round in 0..2 { + if chain_lock_trigger { + ctx.managed_wallet.apply_chain_lock(ChainLock { + block_height: 101 + round, + block_hash: BlockHash::from_byte_array([101; 32]), + signature: [0; 96].into(), + }); + } else { + ctx.managed_wallet.update_synced_height(100); + } + if with_record_claim { + ctx.managed_wallet.abandon_transaction(claimant_txid); + } + + ctx.managed_wallet + .check_core_transaction( + &funding, + TransactionContext::Mempool, + &mut ctx.wallet, + true, + true, + ) + .await; + assert!( + !ctx.managed_wallet.first_bip44_managed_account().unwrap().utxos.contains_key(&parent), + "pruning must not resurrect a coin protected only by restored block evidence" + ); + + let mut conflicting = competing_spend(parent); + conflicting.output[0].script_pubkey = ctx.receive_address.script_pubkey(); + ctx.managed_wallet + .check_core_transaction( + &conflicting, + TransactionContext::Mempool, + &mut ctx.wallet, + true, + true, + ) + .await; + assert!( + ctx.managed_wallet.first_bip44_managed_account().unwrap().utxos.is_empty(), + "restored block evidence must still reject outputs of a conflicting mempool spend" + ); + assert_eq!(ctx.managed_wallet.observed_spent_outpoints().get(&parent), Some(&80)); + + // Redelivery after serialization must exercise insertion, not known-record deduplication. + ctx.managed_wallet.abandon_transaction(funding.txid()); + ctx.managed_wallet.abandon_transaction(conflicting.txid()); + #[cfg(feature = "serde")] + if round == 0 { + // Address pools have non-string JSON map keys; round-trip the wallet evidence alone. + let accounts = std::mem::take(&mut ctx.managed_wallet.accounts); + let json = serde_json::to_string(&ctx.managed_wallet).unwrap(); + ctx.managed_wallet = serde_json::from_str(&json).unwrap(); + ctx.managed_wallet.accounts = accounts; + } + } +} + fn spending_record( tx: Transaction, address: dashcore::Address, @@ -71,6 +171,42 @@ fn spending_record( ) } +#[tokio::test] +async fn should_preserve_restored_keys_record_spends_after_pruning() { + let mut ctx = TestWalletContext::from_seed([22; 64]); + let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); + let parent = OutPoint { + txid: funding.txid(), + vout: 0, + }; + let mut record = spending_record( + spend(parent), + ctx.receive_address.clone(), + TransactionContext::InChainLockedBlock(BlockInfo::new( + 80, + BlockHash::from_byte_array([80; 32]), + 1_700_000_000, + )), + ); + record.account_type = AccountType::IdentityRegistration; + ctx.managed_wallet + .restore_persisted_state(PersistedWalletState { + transactions: vec![record], + ..Default::default() + }) + .unwrap(); + ctx.managed_wallet.update_synced_height(100); + ctx.managed_wallet.apply_chain_lock(ChainLock { + block_height: 100, + block_hash: BlockHash::from_byte_array([100; 32]), + signature: [0; 96].into(), + }); + ctx.managed_wallet + .check_core_transaction(&funding, TransactionContext::Mempool, &mut ctx.wallet, true, true) + .await; + assert!(ctx.managed_wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); +} + #[tokio::test] async fn restored_spend_mark_blocks_funding_until_the_claim_is_released() { let template = TestWalletContext::from_seed([1; 64]); diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index 05c4eb7d9..363bbbb5b 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -74,7 +74,7 @@ pub struct ManagedWalletInfo { /// spending transaction's classification, lets the funding-side insert be /// reconciled away whichever order the two blocks arrive in. /// - /// Membership is bounded-permanent: an entry is retained until its spend + /// Live observations are bounded-permanent: an entry is retained until its spend /// height is provably final, then evicted by /// [`Self::prune_finalized_observed_spends`]. Until then it is only ever /// added, never removed, and reorg rollback does NOT retract it — treating a @@ -85,7 +85,7 @@ pub struct ManagedWalletInfo { /// /// # Bounded permanence /// - /// An entry `(outpoint, height)` is removed only when + /// An unpinned entry `(outpoint, height)` is removed only when /// `height <= min(last_applied_chain_lock.block_height, synced_height)` — /// the finality boundary. At that boundary the spend is chain-locked (it can /// never be reorged out) and any funding transaction for the outpoint has @@ -95,17 +95,19 @@ pub struct ManagedWalletInfo { /// a coin could be re-inserted — in both `keep-finalized-transactions` /// configurations and across a reload. No other removal path may be added /// without a deliberate decision. + /// Restored block spends are pinned by `unattributed_spent_outpoints`: + /// their funding records may be absent even below the restored sync checkpoint. /// /// Eviction is event-driven (chainlock application, sync-checkpoint commit), /// never age- or recency-based: during an out-of-order rescan `synced_height` /// is low, so nothing is pruned in exactly the window where #649 ordering - /// hazards live, and the set self-repopulates on any replay since + /// hazards live, and live observations self-repopulate on any replay since /// `record_observed_spends` runs unconditionally per checked tx. A naive /// age/LRU eviction would instead evict the cold entries whose funding tx may /// still arrive out of order, reopening #649 for that coin. Steady-state size - /// is the above-boundary window only — roughly one block's inputs on a - /// healthy chain. A defensive cap on the deserialized entry count (see the - /// serde adapter) guards against a corrupted or hostile wallet file forcing + /// is the restored pins plus the above-boundary window — roughly one block's + /// inputs on a healthy chain. A defensive cap on the deserialized entry count + /// (see the serde adapter) guards against a corrupted or hostile wallet file forcing /// an unbounded allocation on load. /// /// # Persistence @@ -124,7 +126,8 @@ pub struct ManagedWalletInfo { /// migration to load pre-field snapshots. #[cfg_attr(feature = "serde", serde(default, with = "observed_spent_outpoints_serde"))] pub(crate) observed_spent_outpoints: BTreeMap, - /// Durable blocked outputs whose spending transaction and height are unavailable. + /// Durable output guards independent of releasable record claims. + /// Entries with observed heights also pin that block evidence against finality pruning. #[cfg_attr(feature = "serde", serde(default))] pub(crate) unattributed_spent_outpoints: HashSet, /// Generation counter for the wallet's account set, bumped every time an @@ -365,10 +368,10 @@ impl ManagedWalletInfo { changed } - /// Evict [`Self::observed_spent_outpoints`] entries at or below the finality + /// Evict unpinned [`Self::observed_spent_outpoints`] entries at or below the finality /// boundary `min(last_applied_chain_lock.block_height, synced_height)`. /// - /// An entry `(outpoint, height)` with `height <= boundary` is safe to + /// An unpinned entry `(outpoint, height)` with `height <= boundary` is safe to /// forget: the spend at that height is chain-locked (never reorged out) and /// any funding transaction for the outpoint has been delivered and finalized, /// so no redelivery path can re-insert the coin (dashpay/rust-dashcore#649). @@ -382,7 +385,9 @@ impl ManagedWalletInfo { return; }; let boundary = chain_lock.block_height.min(self.metadata.synced_height); - self.observed_spent_outpoints.retain(|_, height| *height > boundary); + self.observed_spent_outpoints.retain(|outpoint, height| { + *height > boundary || self.unattributed_spent_outpoints.contains(outpoint) + }); } /// Invalidate the wallet's sync certificate when an account is added. diff --git a/key-wallet/src/wallet/managed_wallet_info/persistence.rs b/key-wallet/src/wallet/managed_wallet_info/persistence.rs index cb72c0a0c..0ceda7009 100644 --- a/key-wallet/src/wallet/managed_wallet_info/persistence.rs +++ b/key-wallet/src/wallet/managed_wallet_info/persistence.rs @@ -26,8 +26,9 @@ pub struct PersistedWalletState { /// The materialized unspent coins with their exact owning accounts. pub utxos: Vec<(AccountType, Utxo)>, /// Durable spend evidence, including outpoints whose spending record is unavailable. - /// `Some(height)` proves a block-observed spend. `None` only blocks the output; - /// claims covered by a record are derived from that record and remain releasable. + /// `Some(height)` proves a block-observed spend and survives finality pruning. + /// `None` only blocks the output; claims covered by a record are derived from + /// that record and remain releasable. pub additional_spent_outpoints: BTreeMap>, } @@ -83,9 +84,9 @@ impl ManagedWalletInfo { /// /// Validates the entire snapshot before changing any state. The receiver must be a /// fresh account skeleton; account definitions, pools and sync metadata are preserved. - /// Spend claims are derived before finalized records are compacted. Supplemental - /// evidence without a record or block height conservatively blocks funding redelivery - /// until a complete replacement snapshot can be built; it cannot be abandoned by txid. + /// Spend claims are derived before finalized records are compacted. All restored + /// block evidence and heightless claims without a record conservatively block funding + /// redelivery until a complete replacement snapshot; neither is abandoned by txid. /// Returns [`RestoreError`] for an inconsistent snapshot or nonempty receiver. pub fn restore_persisted_state( &mut self, @@ -101,6 +102,7 @@ impl ManagedWalletInfo { for (outpoint, height) in state.additional_spent_outpoints { if let Some(height) = height { self.observed_spent_outpoints.insert(outpoint, height); + self.unattributed_spent_outpoints.insert(outpoint); } else if !record_inputs.contains(&outpoint) { self.unattributed_spent_outpoints.insert(outpoint); } @@ -121,6 +123,7 @@ impl ManagedWalletInfo { if let Some(block) = record.context.block_info() { for input in &record.transaction.input { if !input.previous_output.is_null() { + self.unattributed_spent_outpoints.insert(input.previous_output); self.observed_spent_outpoints .entry(input.previous_output) .and_modify(|height| *height = (*height).max(block.height())) From 253e41dbf88997070c8c3f02af103465b4ad05e9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:43:48 +0000 Subject: [PATCH 11/11] test(key-wallet): use existing full wallet snapshot serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the maintainer's proposal to persist ManagedWalletInfo through bincode serde. Seven lifecycle tests pass on the unmodified dev wallet implementation with and without finalized transaction retention. Remove the proposed restore API, serialized field and transaction-path changes. The net PR contains only snapshot tests and their dev dependency. The SQLite adapter in platform#4777 still needs to adopt this approach. Co-authored-by: Codex 🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent --- CHANGELOG.md | 5 - key-wallet/Cargo.toml | 1 + .../managed_account/managed_account_ref.rs | 6 +- .../managed_core_funds_account.rs | 186 ++- .../managed_core_keys_account.rs | 15 - key-wallet/src/test_utils/wallet.rs | 12 - .../src/tests/full_wallet_snapshot_tests.rs | 180 +++ key-wallet/src/tests/mod.rs | 5 +- key-wallet/src/tests/performance_tests.rs | 35 +- .../persisted_transaction_restore_tests.rs | 1300 ----------------- .../transaction_checking/wallet_checker.rs | 50 +- .../src/wallet/managed_wallet_info/helpers.rs | 64 +- .../src/wallet/managed_wallet_info/mod.rs | 31 +- .../wallet/managed_wallet_info/persistence.rs | 337 ----- .../wallet_info_interface.rs | 7 +- 15 files changed, 309 insertions(+), 1925 deletions(-) create mode 100644 key-wallet/src/tests/full_wallet_snapshot_tests.rs delete mode 100644 key-wallet/src/tests/persisted_transaction_restore_tests.rs delete mode 100644 key-wallet/src/wallet/managed_wallet_info/persistence.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c0a947dc4..fd501d27d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## Unreleased -### Fixed - -- Preserve restored wallet block-spend evidence through ChainLock and sync - checkpoint pruning so missing transaction history cannot resurrect spent coins. - ### Changed - **Breaking:** the `bincode` feature and binary serialization dependencies now use diff --git a/key-wallet/Cargo.toml b/key-wallet/Cargo.toml index 7342ef093..54a7ab4a7 100644 --- a/key-wallet/Cargo.toml +++ b/key-wallet/Cargo.toml @@ -58,6 +58,7 @@ tracing = "0.1" async-trait = "0.1" [dev-dependencies] +bincode = { workspace = true, features = ["serde"] } dashcore = { path="../dash", features = ["test-utils"] } hex = "0.4" key-wallet = { path = ".", features = ["test-utils", "bip38", "serde", "bincode", "eddsa", "bls"] } diff --git a/key-wallet/src/managed_account/managed_account_ref.rs b/key-wallet/src/managed_account/managed_account_ref.rs index bc7d38840..192a358e2 100644 --- a/key-wallet/src/managed_account/managed_account_ref.rs +++ b/key-wallet/src/managed_account/managed_account_ref.rs @@ -19,9 +19,9 @@ use crate::managed_account::{ManagedCoreFundsAccount, ManagedCoreKeysAccount}; use crate::transaction_checking::account_checker::AccountMatch; use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::TransactionContext; -use crate::wallet::managed_wallet_info::persistence::SpendEvidence; use crate::Network; use dashcore::blockdata::transaction::OutPoint; +use dashcore::prelude::CoreBlockHeight; use dashcore::{Address, ScriptBuf, Transaction, Txid}; use std::collections::{BTreeMap, BTreeSet}; @@ -339,7 +339,7 @@ impl<'a> ManagedAccountRefMut<'a> { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &impl SpendEvidence, + observed_spent: &BTreeMap, external_final_parents: &BTreeSet, ) -> TransactionRecord { match self { @@ -395,7 +395,7 @@ impl<'a> ManagedAccountRefMut<'a> { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &impl SpendEvidence, + observed_spent: &BTreeMap, external_final_parents: &BTreeSet, ) -> Option { match self { diff --git a/key-wallet/src/managed_account/managed_core_funds_account.rs b/key-wallet/src/managed_account/managed_core_funds_account.rs index dadd30504..491b15507 100644 --- a/key-wallet/src/managed_account/managed_core_funds_account.rs +++ b/key-wallet/src/managed_account/managed_core_funds_account.rs @@ -27,7 +27,6 @@ use crate::transaction_checking::transaction_router::TransactionType; use crate::transaction_checking::{AccountMatch, TransactionContext}; use crate::utxo::Utxo; use crate::wallet::balance::WalletCoreBalance; -use crate::wallet::managed_wallet_info::persistence::SpendEvidence; use crate::{ExtendedPubKey, Network}; use dashcore::blockdata::transaction::OutPoint; use dashcore::prelude::CoreBlockHeight; @@ -84,7 +83,7 @@ pub(crate) struct AbandonRemoval { pub records: usize, } -/// What [`ManagedCoreFundsAccount::apply_conflict_set`] removed +/// What [`ManagedCoreFundsAccount::drop_conflicted_transactions`] removed /// from one account. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct ConflictSweep { @@ -112,21 +111,6 @@ impl ManagedCoreFundsAccount { } } - /// Restore the record and its input claims before finalized compaction. - pub(crate) fn restore_transaction_record(&mut self, record: TransactionRecord) { - if !record.transaction.is_coin_base() { - self.spent_outpoints - .extend(record.transaction.input.iter().map(|input| input.previous_output)); - } - self.keys.restore_transaction_record(record); - } - - pub(crate) fn has_persisted_funds_state(&self) -> bool { - !self.utxos.is_empty() - || !self.spent_outpoints.is_empty() - || !self.spent_before_funded.is_empty() - } - /// Create a `ManagedCoreFundsAccount` from an [`Account`](super::super::Account). pub fn from_account(account: &super::super::Account) -> Self { Self::wrap(ManagedCoreKeysAccount::from_account(account)) @@ -206,7 +190,7 @@ impl ManagedCoreFundsAccount { } /// Check if an outpoint was spent by a previously recorded transaction. - pub(crate) fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { + fn is_outpoint_spent(&self, outpoint: &OutPoint) -> bool { self.spent_outpoints.contains(outpoint) } @@ -241,7 +225,7 @@ impl ManagedCoreFundsAccount { tx: &Transaction, account_match: &AccountMatch, context: TransactionContext, - observed_spent: &impl SpendEvidence, + observed_spent: &BTreeMap, external_final_parents: &BTreeSet, ) { // Update UTXOs only for spendable account types @@ -310,7 +294,7 @@ impl ManagedCoreFundsAccount { && tx .input .iter() - .any(|input| observed_spent.is_settled(&input.previous_output)); + .any(|input| observed_spent.contains_key(&input.previous_output)); if doomed_by_a_settled_spend { // Deliberately before any mutation: the record built by // the caller stands, so history still shows the attempt, @@ -348,12 +332,14 @@ impl ManagedCoreFundsAccount { continue; } - // Wallet evidence also covers claims held by other accounts. - // Keep the output details available for later input matching. - if observed_spent.blocks_output(&outpoint) { + // #649 spend-first ordering: the spend was observed in an + // earlier-processed block, so this output is genuinely spent + // on-chain even though this account has never seen it before — + // never insert it, so the record built below is born correct. + if observed_spent.contains_key(&outpoint) { tracing::debug!( outpoint = %outpoint, - "Skipping output blocked by wallet spend evidence" + "Skipping UTXO already observed spent in an earlier-processed block (#649)" ); self.spent_before_funded.insert( outpoint, @@ -576,31 +562,90 @@ impl ManagedCoreFundsAccount { /// not have to be wallet-relevant, so it may hold none of the loser's /// inputs anywhere the caller can see, and the loser's own record is /// already gone by the time this returns. - #[cfg(test)] pub(crate) fn drop_conflicted_transactions( &mut self, tx: &Transaction, context: &TransactionContext, ) -> ConflictSweep { - let records: Vec<_> = self.keys.transactions().values().collect(); - let losers = conflicted_transactions(&records, tx, context); - self.apply_conflict_set(tx, &losers) - } + if !(context.confirmed() || matches!(context, TransactionContext::InstantSend(_))) { + return ConflictSweep::default(); + } + + let winner = tx.txid(); + let spent: BTreeSet = + tx.input.iter().map(|input| input.previous_output).collect(); + + // A finalized transaction keeps only its txid, so a chainlocked record + // can never be a loser here — and must not be, since it is settled. + let mut losers: BTreeSet = self + .keys + .transactions() + .iter() + .filter(|(txid, record)| { + // Precedence, per DIP-10: a chainlock is final over + // everything, an InstantSend lock is final against a double + // spend, and a plain block is provisional until its own + // chainlock lands. So an IS-locked record may only be evicted + // by a chainlocked arrival — a plain `InBlock` winner cannot + // overrule a lock the network already signed, and the block + // it arrived in can still reorg away. + let loser_is_locked = record.context.is_instant_send(); + **txid != winner + && !record.is_confirmed() + && (!loser_is_locked || context.is_chain_locked()) + && record + .transaction + .input + .iter() + .any(|input| spent.contains(&input.previous_output)) + }) + .map(|(txid, _)| *txid) + .collect(); - /// Remove the wallet-wide conflict closure while retaining the winner's claims. - pub(crate) fn apply_conflict_set( - &mut self, - tx: &Transaction, - losers: &BTreeSet, - ) -> ConflictSweep { if losers.is_empty() { return ConflictSweep::default(); } - let winner = tx.txid(); - let spent: BTreeSet<_> = tx.input.iter().map(|input| input.previous_output).collect(); + + // A loser's change may already have funded further unconfirmed + // transactions. Those can never exist either — their parent cannot — + // so leaving their outputs credited would preserve the very + // phantom-balance class this sweep exists to remove. Walk the + // unconfirmed descendant closure; confirmed records are never + // followed, since a transaction in a block spent something real, + // and neither are InstantSend-locked ones, whose lock the network + // already signed. + // + // The walk builds a parent→children index in one pass and then + // follows a queue, so each record is looked at once. Rescanning the + // whole history per generation instead is O(depth × history): a peer + // that feeds the wallet a deep chain of unconfirmed wallet-relevant + // transactions and then finalizes a replacement for the root's input + // would make the sweep quadratic in everything the wallet retained, + // while the account is held mutably and before the sweep can reach + // persistence. + let mut children: HashMap> = HashMap::new(); + for (txid, record) in self.keys.transactions() { + note_descendant_walk_visit(); + if record.is_confirmed() || record.context.is_instant_send() || *txid == winner { + continue; + } + for input in &record.transaction.input { + children.entry(input.previous_output.txid).or_default().push(*txid); + } + } + let mut queue: VecDeque = losers.iter().copied().collect(); + while let Some(parent) = queue.pop_front() { + for child in children.get(&parent).map(Vec::as_slice).unwrap_or_default() { + note_descendant_walk_visit(); + if losers.insert(*child) { + queue.push_back(*child); + } + } + } + let mut freed: HashSet = HashSet::new(); let mut changed = false; - for loser in losers { + for loser in &losers { let removed: Vec = self.utxos.keys().filter(|outpoint| outpoint.txid == *loser).copied().collect(); for outpoint in removed { @@ -646,7 +691,7 @@ impl ManagedCoreFundsAccount { released.into_iter().filter(|outpoint| !losers.contains(&outpoint.txid)).collect(); released_outpoints.sort_unstable(); ConflictSweep { - txids: losers.iter().copied().collect(), + txids: losers.into_iter().collect(), released_outpoints, } } @@ -673,7 +718,7 @@ impl ManagedCoreFundsAccount { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &impl SpendEvidence, + observed_spent: &BTreeMap, external_final_parents: &BTreeSet, ) -> Option { let txid = tx.txid(); @@ -763,7 +808,7 @@ impl ManagedCoreFundsAccount { account_match: &AccountMatch, context: TransactionContext, transaction_type: TransactionType, - observed_spent: &impl SpendEvidence, + observed_spent: &BTreeMap, external_final_parents: &BTreeSet, ) -> TransactionRecord { let net_amount = account_match.received as i64 - account_match.sent as i64; @@ -1244,63 +1289,6 @@ impl ManagedAccountTrait for ManagedCoreFundsAccount { } } -/// Find direct losers and their unconfirmed descendants across the supplied records. -pub(crate) fn conflicted_transactions( - records: &[&TransactionRecord], - tx: &Transaction, - context: &TransactionContext, -) -> BTreeSet { - if !(context.confirmed() || context.is_instant_send()) { - return BTreeSet::new(); - } - let winner = tx.txid(); - let spent: HashSet<_> = tx.input.iter().map(|input| input.previous_output).collect(); - let protected: HashSet<_> = records - .iter() - .filter(|record| { - record.is_confirmed() - || (record.context.is_instant_send() && !context.is_chain_locked()) - }) - .map(|record| record.txid) - .collect(); - let mut losers: BTreeSet<_> = records - .iter() - .filter(|record| { - record.txid != winner - && !protected.contains(&record.txid) - && record - .transaction - .input - .iter() - .any(|input| spent.contains(&input.previous_output)) - }) - .map(|record| record.txid) - .collect(); - if losers.is_empty() { - return losers; - } - let mut children: HashMap> = HashMap::new(); - for record in records { - note_descendant_walk_visit(); - if record.is_confirmed() || record.context.is_instant_send() || record.txid == winner { - continue; - } - for input in &record.transaction.input { - children.entry(input.previous_output.txid).or_default().push(record.txid); - } - } - let mut queue: VecDeque<_> = losers.iter().copied().collect(); - while let Some(parent) = queue.pop_front() { - for child in children.get(&parent).map(Vec::as_slice).unwrap_or_default() { - note_descendant_walk_visit(); - if !protected.contains(child) && losers.insert(*child) { - queue.push_back(*child); - } - } - } - losers -} - /// Rebuild the account-local `spent_outpoints` set from recorded transactions. /// /// Every input of every recorded transaction is a spend this account has seen, @@ -1352,7 +1340,7 @@ impl<'de> Deserialize<'de> for ManagedCoreFundsAccount { } /// Test-only visit counter for the descendant walk in -/// [`ManagedCoreFundsAccount::apply_conflict_set`]. +/// [`ManagedCoreFundsAccount::drop_conflicted_transactions`]. /// /// Exists so a regression test can pin the walk to a linear number of record /// visits deterministically, instead of betting on wall-clock time. Compiled diff --git a/key-wallet/src/managed_account/managed_core_keys_account.rs b/key-wallet/src/managed_account/managed_core_keys_account.rs index 405e11c70..1d16995e4 100644 --- a/key-wallet/src/managed_account/managed_core_keys_account.rs +++ b/key-wallet/src/managed_account/managed_core_keys_account.rs @@ -89,21 +89,6 @@ impl ManagedCoreKeysAccount { } } - /// Restore one transaction record without replaying its UTXO mutations. - pub(crate) fn restore_transaction_record(&mut self, record: TransactionRecord) { - let txid = record.txid; - let finalized = record.context.is_chain_locked(); - self.transactions.insert(txid, record); - - #[cfg(not(feature = "keep-finalized-transactions"))] - if finalized { - self.drop_finalized_transaction(&txid); - } - - #[cfg(feature = "keep-finalized-transactions")] - let _ = finalized; - } - /// Drop the full record for `txid` and remember only its txid. /// /// Only defined when the `keep-finalized-transactions` Cargo feature diff --git a/key-wallet/src/test_utils/wallet.rs b/key-wallet/src/test_utils/wallet.rs index 1cca5b07c..7f7859a5a 100644 --- a/key-wallet/src/test_utils/wallet.rs +++ b/key-wallet/src/test_utils/wallet.rs @@ -37,18 +37,6 @@ impl TestWalletContext { /// accounts. pub fn new_random_with_options(options: WalletAccountCreationOptions) -> Self { let wallet = Wallet::new_random(Network::Testnet, options).expect("Should create wallet"); - Self::from_wallet(wallet) - } - - /// Creates a reproducible testnet wallet from a fixed seed. - pub fn from_seed(seed: [u8; 64]) -> Self { - let wallet = - Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::Default) - .expect("Should create wallet"); - Self::from_wallet(wallet) - } - - fn from_wallet(wallet: Wallet) -> Self { let mut managed_wallet = ManagedWalletInfo::from_wallet_with_name(&wallet, "Test".to_string(), 0); diff --git a/key-wallet/src/tests/full_wallet_snapshot_tests.rs b/key-wallet/src/tests/full_wallet_snapshot_tests.rs new file mode 100644 index 000000000..73133ed5b --- /dev/null +++ b/key-wallet/src/tests/full_wallet_snapshot_tests.rs @@ -0,0 +1,180 @@ +//! Full-wallet snapshots preserve spend state through restart and continued sync. + +use crate::managed_account::managed_account_trait::ManagedAccountTrait; +use crate::transaction_checking::{BlockInfo, TransactionContext, WalletTransactionChecker}; +use crate::wallet::initialization::WalletAccountCreationOptions; +use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use crate::wallet::ManagedWalletInfo; +use crate::Wallet; +use dashcore::hashes::Hash; +use dashcore::{ + Address, BlockHash, ChainLock, InstantLock, Network, OutPoint, Transaction, TxIn, TxOut, +}; + +fn wallet() -> (Wallet, ManagedWalletInfo, Address) { + let wallet = + Wallet::from_seed_bytes([42; 64], Network::Testnet, WalletAccountCreationOptions::Default) + .unwrap(); + let mut info = ManagedWalletInfo::from_wallet(&wallet, 0); + let xpub = wallet.accounts.standard_bip44_accounts.get(&0).unwrap().account_xpub; + let address = info + .first_bip44_managed_account_mut() + .unwrap() + .next_receive_address(Some(&xpub), true) + .unwrap(); + (wallet, info, address) +} + +fn restart(info: &ManagedWalletInfo) -> ManagedWalletInfo { + let bytes = bincode::serde::encode_to_vec(info, bincode::config::standard()).unwrap(); + let (restored, consumed) = + bincode::serde::decode_from_slice(&bytes, bincode::config::standard()).unwrap(); + assert_eq!(consumed, bytes.len()); + restored +} + +fn block(height: u32) -> TransactionContext { + TransactionContext::InBlock(BlockInfo::new(height, BlockHash::from_byte_array([7; 32]), 1000)) +} + +fn chain_lock() -> ChainLock { + ChainLock { + block_height: 200, + block_hash: BlockHash::from_byte_array([8; 32]), + signature: [0; 96].into(), + } +} + +fn spend(parent: OutPoint) -> Transaction { + Transaction { + version: 2, + lock_time: 0, + input: vec![TxIn { + previous_output: parent, + ..Default::default() + }], + output: vec![TxOut { + value: 99_000, + script_pubkey: Default::default(), + }], + special_transaction_payload: None, + } +} + +#[test_case::test_case(false, false; "pending")] +#[test_case::test_case(true, false; "mined")] +#[test_case::test_case(true, true; "chainlocked_and_pruned")] +#[tokio::test] +async fn full_snapshot_keeps_spent_funding_unavailable(mined: bool, finalized: bool) { + let (mut wallet, mut info, address) = wallet(); + let funding = Transaction::dummy(&address, 0..1, &[100_000]); + let parent = OutPoint::new(funding.txid(), 0); + let spending = spend(parent); + info.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + assert!(info.first_bip44_managed_account().unwrap().utxos.contains_key(&parent)); + let context = if mined { + block(150) + } else { + TransactionContext::Mempool + }; + info.check_core_transaction(&spending, context, &mut wallet, true, true).await; + if finalized { + info.apply_chain_lock(chain_lock()); + info.update_synced_height(200); + } + assert!(info.first_bip44_managed_account().unwrap().utxos.is_empty()); + let mut restored = restart(&info); + assert_eq!(restored.balance, info.balance); + for context in [TransactionContext::Mempool, block(120)] { + restored.check_core_transaction(&funding, context, &mut wallet, true, true).await; + assert!(restored.first_bip44_managed_account().unwrap().utxos.is_empty()); + assert_eq!(restored.balance, info.balance); + } + if !mined { + restored.abandon_transaction(spending.txid()); + restored.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + assert!(restored.first_bip44_managed_account().unwrap().utxos.contains_key(&parent)); + } +} + +#[test_case::test_case(false; "mined")] +#[test_case::test_case(true; "chainlocked")] +#[tokio::test] +async fn full_snapshot_keeps_spend_before_funding_evidence(finalized: bool) { + let (mut wallet, mut info, address) = wallet(); + let funding = Transaction::dummy(&address, 0..1, &[100_000]); + let parent = OutPoint::new(funding.txid(), 0); + let spending = spend(parent); + info.check_core_transaction(&spending, block(150), &mut wallet, true, true).await; + info.update_synced_height(100); + if finalized { + info.apply_chain_lock(chain_lock()); + } + let mut restored = restart(&info); + assert_eq!(restored.observed_spent_outpoints().get(&parent), Some(&150)); + restored.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + restored.apply_chain_lock(chain_lock()); + restored.update_synced_height(200); + restored = restart(&restored); + restored.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + assert!(restored.first_bip44_managed_account().unwrap().utxos.is_empty()); + assert_eq!(restored.balance, info.balance); +} + +#[tokio::test] +async fn full_snapshot_preserves_instant_send_until_block_confirmation() { + let (mut wallet, mut info, address) = wallet(); + let funding = Transaction::dummy(&address, 0..1, &[100_000]); + let parent = OutPoint::new(funding.txid(), 0); + let mut spending = spend(parent); + spending.output[0].script_pubkey = address.script_pubkey(); + info.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + let lock = InstantLock { + txid: spending.txid(), + ..Default::default() + }; + info.check_core_transaction( + &spending, + TransactionContext::InstantSend(lock.clone()), + &mut wallet, + true, + true, + ) + .await; + let mut restored = restart(&info); + assert_eq!(restored.balance.confirmed(), 99_000); + assert_eq!( + restored.first_bip44_managed_account().unwrap().transactions()[&spending.txid()].context, + TransactionContext::InstantSend(lock) + ); + restored.check_core_transaction(&spending, block(150), &mut wallet, true, true).await; + restored.apply_chain_lock(chain_lock()); + restored.update_synced_height(200); + restored = restart(&restored); + restored.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + assert_eq!(restored.balance.confirmed(), 99_000); + assert!(!restored.first_bip44_managed_account().unwrap().utxos.contains_key(&parent)); +} + +#[tokio::test] +async fn full_snapshot_preserves_pending_conflict_cleanup() { + let (mut wallet, mut info, address) = wallet(); + let funding = Transaction::dummy(&address, 0..1, &[100_000]); + let parent = OutPoint::new(funding.txid(), 0); + let mut pending = spend(parent); + pending.output[0].script_pubkey = address.script_pubkey(); + info.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + info.check_core_transaction(&pending, TransactionContext::Mempool, &mut wallet, true, true) + .await; + let mut restored = restart(&info); + assert_eq!(restored.balance.spendable(), 99_000); + restored.check_core_transaction(&spend(parent), block(150), &mut wallet, true, true).await; + let account = restored.first_bip44_managed_account().unwrap(); + assert!(!account.transactions().contains_key(&pending.txid())); + assert!(account.utxos.is_empty()); + assert_eq!(restored.balance.spendable(), 0); + restored = restart(&restored); + restored.check_core_transaction(&funding, block(120), &mut wallet, true, true).await; + assert!(restored.first_bip44_managed_account().unwrap().utxos.is_empty()); + assert_eq!(restored.balance.spendable(), 0); +} diff --git a/key-wallet/src/tests/mod.rs b/key-wallet/src/tests/mod.rs index e789735b3..e0c187d27 100644 --- a/key-wallet/src/tests/mod.rs +++ b/key-wallet/src/tests/mod.rs @@ -14,6 +14,9 @@ mod advanced_transaction_tests; mod backup_restore_tests; +#[cfg(feature = "bincode")] +mod full_wallet_snapshot_tests; + mod edge_case_tests; mod integration_tests; @@ -26,8 +29,6 @@ mod observed_spent_outpoints_tests; mod performance_tests; -mod persisted_transaction_restore_tests; - mod provider_key_derivation_tests; mod special_transaction_matching_tests; diff --git a/key-wallet/src/tests/performance_tests.rs b/key-wallet/src/tests/performance_tests.rs index ba09b7f8d..9603bea77 100644 --- a/key-wallet/src/tests/performance_tests.rs +++ b/key-wallet/src/tests/performance_tests.rs @@ -278,34 +278,27 @@ fn test_concurrent_derivation_performance() { } #[test] -#[cfg(feature = "bincode")] fn test_wallet_serialization_performance() { - let wallet = Wallet::from_seed_bytes( - [42; 64], - Network::Testnet, - crate::wallet::initialization::WalletAccountCreationOptions::Default, - ) - .unwrap(); + // Serialization test would require bincode feature + // For now, just test wallet creation/destruction cycle + let iterations = 100; - let mut serialization_times = Vec::new(); + let mut creation_times = Vec::new(); for _ in 0..iterations { let start = Instant::now(); - let backup = wallet.backup().unwrap(); - serialization_times.push(start.elapsed()); - - let restored = Wallet::restore(&backup).unwrap(); - assert_eq!(restored.wallet_id, wallet.wallet_id); - assert_eq!(restored.accounts.count(), wallet.accounts.count()); + let _wallet = Wallet::new_random( + Network::Testnet, + crate::wallet::initialization::WalletAccountCreationOptions::None, + ) + .unwrap(); + creation_times.push(start.elapsed()); } - let metrics = PerformanceMetrics::from_times("Wallet Serialization", serialization_times); - metrics._print_summary(); - assert!( - metrics.avg_time < Duration::from_millis(50), - "Wallet serialization too slow: avg {:?}, expected < 50ms", - metrics.avg_time - ); + let metrics = PerformanceMetrics::from_times("Wallet Creation", creation_times); + + // Assert creation performance (relaxed for test environment) + assert!(metrics.avg_time < Duration::from_millis(50)); } #[test] diff --git a/key-wallet/src/tests/persisted_transaction_restore_tests.rs b/key-wallet/src/tests/persisted_transaction_restore_tests.rs deleted file mode 100644 index 058db1cc9..000000000 --- a/key-wallet/src/tests/persisted_transaction_restore_tests.rs +++ /dev/null @@ -1,1300 +0,0 @@ -//! Persistence rehydration must restore transaction lifecycle markers without -//! replaying records through UTXO mutation in an arbitrary storage order. - -use crate::account::StandardAccountType; -use crate::managed_account::managed_account_trait::ManagedAccountTrait; -use crate::managed_account::transaction_record::{ - InputDetail, TransactionDirection, TransactionRecord, -}; -use crate::test_utils::TestWalletContext; -use crate::transaction_checking::{ - BlockInfo, TransactionContext, TransactionType, WalletTransactionChecker, -}; -use crate::utxo::Utxo; -use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use crate::wallet::managed_wallet_info::{PersistedWalletState, RestoreError}; -use crate::wallet::ManagedWalletInfo; -use crate::AccountType; -use dashcore::hashes::Hash; -use dashcore::{ - BlockHash, ChainLock, InstantLock, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Witness, -}; -use std::collections::BTreeMap; - -fn bip44() -> AccountType { - AccountType::Standard { - index: 0, - standard_account_type: StandardAccountType::BIP44Account, - } -} - -fn spend(parent: OutPoint) -> Transaction { - Transaction { - version: 2, - lock_time: 0, - input: vec![TxIn { - previous_output: parent, - script_sig: ScriptBuf::new(), - sequence: u32::MAX, - witness: Witness::new(), - }], - output: vec![TxOut { - value: 999_000, - script_pubkey: ScriptBuf::new(), - }], - special_transaction_payload: None, - } -} - -fn competing_spend(parent: OutPoint) -> Transaction { - let mut transaction = spend(parent); - transaction.output[0].value -= 1_000; - transaction -} - -#[test_case::test_case(false, false; "sync_checkpoint")] -#[test_case::test_case(true, false; "chain_lock")] -#[test_case::test_case(false, true; "sync_checkpoint_after_abandon")] -#[test_case::test_case(true, true; "chain_lock_after_abandon")] -#[tokio::test] -async fn should_preserve_supplemental_block_spends_after_pruning( - chain_lock_trigger: bool, - with_record_claim: bool, -) { - let mut ctx = TestWalletContext::from_seed([21; 64]); - let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let claimant = spend(parent); - let claimant_txid = claimant.txid(); - ctx.managed_wallet.apply_chain_lock(ChainLock { - block_height: 100, - block_hash: BlockHash::from_byte_array([100; 32]), - signature: [0; 96].into(), - }); - ctx.managed_wallet.update_synced_height(100); - ctx.managed_wallet - .restore_persisted_state(PersistedWalletState { - transactions: if with_record_claim { - vec![spending_record( - claimant, - ctx.receive_address.clone(), - TransactionContext::Mempool, - )] - } else { - Vec::new() - }, - additional_spent_outpoints: BTreeMap::from([(parent, Some(80))]), - ..Default::default() - }) - .unwrap(); - - for round in 0..2 { - if chain_lock_trigger { - ctx.managed_wallet.apply_chain_lock(ChainLock { - block_height: 101 + round, - block_hash: BlockHash::from_byte_array([101; 32]), - signature: [0; 96].into(), - }); - } else { - ctx.managed_wallet.update_synced_height(100); - } - if with_record_claim { - ctx.managed_wallet.abandon_transaction(claimant_txid); - } - - ctx.managed_wallet - .check_core_transaction( - &funding, - TransactionContext::Mempool, - &mut ctx.wallet, - true, - true, - ) - .await; - assert!( - !ctx.managed_wallet.first_bip44_managed_account().unwrap().utxos.contains_key(&parent), - "pruning must not resurrect a coin protected only by restored block evidence" - ); - - let mut conflicting = competing_spend(parent); - conflicting.output[0].script_pubkey = ctx.receive_address.script_pubkey(); - ctx.managed_wallet - .check_core_transaction( - &conflicting, - TransactionContext::Mempool, - &mut ctx.wallet, - true, - true, - ) - .await; - assert!( - ctx.managed_wallet.first_bip44_managed_account().unwrap().utxos.is_empty(), - "restored block evidence must still reject outputs of a conflicting mempool spend" - ); - assert_eq!(ctx.managed_wallet.observed_spent_outpoints().get(&parent), Some(&80)); - - // Redelivery after serialization must exercise insertion, not known-record deduplication. - ctx.managed_wallet.abandon_transaction(funding.txid()); - ctx.managed_wallet.abandon_transaction(conflicting.txid()); - #[cfg(feature = "serde")] - if round == 0 { - // Address pools have non-string JSON map keys; round-trip the wallet evidence alone. - let accounts = std::mem::take(&mut ctx.managed_wallet.accounts); - let json = serde_json::to_string(&ctx.managed_wallet).unwrap(); - ctx.managed_wallet = serde_json::from_str(&json).unwrap(); - ctx.managed_wallet.accounts = accounts; - } - } -} - -fn spending_record( - tx: Transaction, - address: dashcore::Address, - context: TransactionContext, -) -> TransactionRecord { - TransactionRecord::new( - tx, - bip44(), - context, - TransactionType::Standard, - TransactionDirection::Outgoing, - vec![InputDetail { - index: 0, - value: 1_000_000, - address, - }], - Vec::new(), - -1_000_000, - ) -} - -#[tokio::test] -async fn should_preserve_restored_keys_record_spends_after_pruning() { - let mut ctx = TestWalletContext::from_seed([22; 64]); - let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let mut record = spending_record( - spend(parent), - ctx.receive_address.clone(), - TransactionContext::InChainLockedBlock(BlockInfo::new( - 80, - BlockHash::from_byte_array([80; 32]), - 1_700_000_000, - )), - ); - record.account_type = AccountType::IdentityRegistration; - ctx.managed_wallet - .restore_persisted_state(PersistedWalletState { - transactions: vec![record], - ..Default::default() - }) - .unwrap(); - ctx.managed_wallet.update_synced_height(100); - ctx.managed_wallet.apply_chain_lock(ChainLock { - block_height: 100, - block_hash: BlockHash::from_byte_array([100; 32]), - signature: [0; 96].into(), - }); - ctx.managed_wallet - .check_core_transaction(&funding, TransactionContext::Mempool, &mut ctx.wallet, true, true) - .await; - assert!(ctx.managed_wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); -} - -#[tokio::test] -async fn restored_spend_mark_blocks_funding_until_the_claim_is_released() { - let template = TestWalletContext::from_seed([1; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let claimant = spend(parent); - let claimant_txid = claimant.txid(); - let claimant_record = - spending_record(claimant, template.receive_address.clone(), TransactionContext::Mempool); - - let mut restored = template.managed_wallet.clone(); - let mut wallet = template.wallet.clone(); - let funding_context = TransactionContext::InBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([0x40; 32]), - 1_699_999_000, - )); - restored - .restore_persisted_state(PersistedWalletState { - transactions: vec![claimant_record], - additional_spent_outpoints: BTreeMap::from([(parent, None)]), - ..Default::default() - }) - .unwrap(); - - restored - .check_core_transaction(&funding, funding_context.clone(), &mut wallet, true, true) - .await; - assert!( - !restored.first_bip44_managed_account().expect("BIP44 account").utxos.contains_key(&parent), - "a persisted live claim must suppress funding redelivery" - ); - - let abandoned = restored.abandon_transaction(claimant_txid); - assert!(abandoned.abandoned.contains(&claimant_txid)); - restored.check_core_transaction(&funding, funding_context, &mut wallet, true, true).await; - assert!( - restored.first_bip44_managed_account().expect("BIP44 account").utxos.contains_key(&parent), - "releasing the restored claim must allow rediscovery" - ); -} - -#[test] -fn restored_chainlocked_record_uses_finalized_compaction() { - let template = TestWalletContext::from_seed([2; 64]); - let parent = OutPoint { - txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), - vout: 0, - }; - let claimant = spend(parent); - let claimant_txid = claimant.txid(); - let record = spending_record( - claimant, - template.receive_address, - TransactionContext::InChainLockedBlock(BlockInfo::new( - 50, - BlockHash::from_byte_array([0x50; 32]), - 1_700_000_000, - )), - ); - - let mut restored: ManagedWalletInfo = template.managed_wallet; - restored - .restore_persisted_state(PersistedWalletState { - transactions: vec![record], - ..Default::default() - }) - .unwrap(); - assert_eq!(restored.observed_spent_outpoints().get(&parent), Some(&50)); - let account = restored.first_bip44_managed_account().expect("BIP44 account"); - assert!(account.transaction_is_finalized(&claimant_txid)); - #[cfg(not(feature = "keep-finalized-transactions"))] - assert!( - !account.transactions().contains_key(&claimant_txid), - "default retention keeps only the finalized txid" - ); -} - -#[test] -fn unmatched_record_does_not_restore_wallet_level_spend_state() { - let template = TestWalletContext::from_seed([3; 64]); - let parent = OutPoint { - txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), - vout: 0, - }; - let mut record = spending_record( - spend(parent), - template.receive_address, - TransactionContext::InBlock(BlockInfo::new( - 50, - BlockHash::from_byte_array([0x50; 32]), - 1_700_000_000, - )), - ); - record.account_type = AccountType::Standard { - index: 7, - standard_account_type: StandardAccountType::BIP44Account, - }; - - let mut restored = template.managed_wallet; - let result = restored.restore_persisted_state(PersistedWalletState { - transactions: vec![record.clone()], - ..Default::default() - }); - - assert_eq!(result, Err(RestoreError::MissingAccount(record.account_type))); - assert!( - !restored.observed_spent_outpoints().contains_key(&parent), - "a record rejected for missing account ownership must not mutate wallet spend state" - ); -} - -#[test] -fn restored_unconfirmed_records_participate_in_conflict_descendant_sweeps() { - let template = TestWalletContext::from_seed([4; 64]); - let parent = OutPoint { - txid: Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]).txid(), - vout: 0, - }; - let root = spend(parent); - let root_txid = root.txid(); - let root_output = OutPoint { - txid: root_txid, - vout: 0, - }; - let child = spend(root_output); - let child_txid = child.txid(); - let winner = competing_spend(parent); - - let root_record = - spending_record(root, template.receive_address.clone(), TransactionContext::Mempool); - let child_record = - spending_record(child, template.receive_address, TransactionContext::Mempool); - let mut restored = template.managed_wallet; - restored - .restore_persisted_state(PersistedWalletState { - transactions: vec![child_record, root_record], - ..Default::default() - }) - .unwrap(); - - let swept = restored.sweep_conflicts( - &winner, - &TransactionContext::InBlock(BlockInfo::new( - 60, - BlockHash::from_byte_array([0x60; 32]), - 1_700_001_000, - )), - ); - assert!(swept.txids.contains(&root_txid)); - assert!(swept.txids.contains(&child_txid)); -} - -fn coin(tx: &Transaction, address: &dashcore::Address) -> Utxo { - Utxo::new( - OutPoint { - txid: tx.txid(), - vout: 0, - }, - tx.output[0].clone(), - address.clone(), - 40, - false, - ) -} - -#[test] -fn should_invalidate_monitor_after_restoring_coins() { - let mut ctx = TestWalletContext::from_seed([21; 64]); - let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); - let revision = ctx.managed_wallet.monitor_revision(); - let elements = ctx.managed_wallet.monitored_filter_elements(); - ctx.managed_wallet - .restore_persisted_state(PersistedWalletState { - utxos: vec![(bip44(), coin(&funding, &ctx.receive_address))], - ..Default::default() - }) - .unwrap(); - assert_ne!(elements, ctx.managed_wallet.monitored_filter_elements()); - assert!(ctx.managed_wallet.monitor_revision() > revision); -} - -#[test_case::test_case(0, true; "mempool_confirmed")] -#[test_case::test_case(0, false; "mempool_instantlocked")] -#[test_case::test_case(1, true; "instant_send_confirmed")] -#[test_case::test_case(1, false; "instant_send_without_lock_flag")] -#[test_case::test_case(2, true; "block_unconfirmed")] -#[test_case::test_case(3, true; "chainlock_unconfirmed")] -#[tokio::test] -async fn should_reject_inconsistent_coin_finality(context_kind: u8, flip_confirmed: bool) { - let mut live = TestWalletContext::from_seed([22; 64]); - let mut restored = live.managed_wallet.clone(); - let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); - let block = BlockInfo::new(40, BlockHash::from_byte_array([40; 32]), 1_700_000_000); - let context = match context_kind { - 0 => TransactionContext::Mempool, - 1 => TransactionContext::InstantSend(InstantLock { - txid: funding.txid(), - ..Default::default() - }), - 2 => TransactionContext::InBlock(block), - _ => TransactionContext::InChainLockedBlock(block), - }; - let result = live.check_transaction(&funding, context).await; - let mut utxo = live.first_utxo().clone(); - let snapshot = PersistedWalletState { - transactions: result.new_records, - utxos: vec![(bip44(), utxo.clone())], - ..Default::default() - }; - let mut control = restored.clone(); - control.restore_persisted_state(snapshot.clone()).unwrap(); - assert_eq!(control.balance(), live.managed_wallet.balance()); - if flip_confirmed { - utxo.is_confirmed = !utxo.is_confirmed; - } else { - utxo.is_instantlocked = !utxo.is_instantlocked; - } - let revision = restored.monitor_revision(); - let elements = restored.monitored_filter_elements(); - assert_eq!( - restored.restore_persisted_state(PersistedWalletState { - utxos: vec![(bip44(), utxo.clone())], - ..snapshot - }), - Err(RestoreError::InvalidUtxo(utxo.outpoint)) - ); - assert_eq!(restored.monitor_revision(), revision); - assert_eq!(restored.monitored_filter_elements(), elements); - assert!(restored.transaction_history().is_empty()); - assert!(restored.observed_spent_outpoints().is_empty()); - assert!(restored.instant_send_locks.is_empty()); - assert_eq!(restored.balance().total(), 0); -} - -#[test_case::test_case(0, false; "mempool_block")] -#[test_case::test_case(0, true; "block_mempool")] -#[test_case::test_case(1, false; "mempool_instant_send")] -#[test_case::test_case(1, true; "instant_send_mempool")] -#[test_case::test_case(2, false; "different_height")] -#[test_case::test_case(2, true; "different_height_reversed")] -#[test_case::test_case(3, false; "different_hash")] -#[test_case::test_case(4, false; "block_chainlock")] -#[test_case::test_case(5, false; "same_block")] -#[test_case::test_case(6, false; "optional_block_position")] -fn should_validate_lifecycle_across_accounts(context_kind: u8, reverse: bool) { - use crate::wallet::managed_wallet_info::ManagedAccountOperations; - let mut ctx = TestWalletContext::from_seed([23; 64]); - let other = TestWalletContext::from_seed([24; 64]); - let other_type = AccountType::Standard { - index: 1, - standard_account_type: StandardAccountType::BIP44Account, - }; - ctx.managed_wallet.add_managed_account_from_xpub(other_type, other.xpub).unwrap(); - let funding = Transaction::dummy(&ctx.receive_address, 0..1, &[1_000_000]); - let tx = spend(OutPoint { - txid: funding.txid(), - vout: 0, - }); - let block = BlockInfo::new(40, BlockHash::from_byte_array([40; 32]), 1_700_000_000); - let (first, second) = match context_kind { - 0 => (TransactionContext::Mempool, TransactionContext::InBlock(block)), - 1 => ( - TransactionContext::Mempool, - TransactionContext::InstantSend(InstantLock { - txid: tx.txid(), - ..Default::default() - }), - ), - 2 => ( - TransactionContext::InBlock(block), - TransactionContext::InBlock(BlockInfo::new(41, block.block_hash(), block.timestamp())), - ), - 3 => ( - TransactionContext::InBlock(block), - TransactionContext::InBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([41; 32]), - block.timestamp(), - )), - ), - 4 => (TransactionContext::InBlock(block), TransactionContext::InChainLockedBlock(block)), - 5 => (TransactionContext::InBlock(block), TransactionContext::InBlock(block)), - _ => ( - TransactionContext::InBlock(block), - TransactionContext::InBlock(block.with_position(2)), - ), - }; - let first_record = spending_record(tx.clone(), ctx.receive_address.clone(), first); - let mut second_record = spending_record(tx.clone(), ctx.receive_address.clone(), second); - second_record.account_type = other_type; - let mut records = vec![first_record, second_record]; - if reverse { - records.reverse(); - } - let revision = ctx.managed_wallet.monitor_revision(); - let result = ctx.managed_wallet.restore_persisted_state(PersistedWalletState { - transactions: records, - ..Default::default() - }); - if context_kind >= 5 { - assert_eq!(result, Ok(())); - assert_eq!(ctx.managed_wallet.transaction_history().len(), 2); - } else { - assert_eq!(result, Err(RestoreError::InvalidRecord(tx.txid()))); - assert!(ctx.managed_wallet.transaction_history().is_empty()); - assert!(ctx.managed_wallet.observed_spent_outpoints().is_empty()); - assert!(ctx.managed_wallet.instant_send_locks.is_empty()); - } - assert_eq!(ctx.managed_wallet.monitor_revision(), revision); -} - -#[test] -fn should_reject_entire_snapshot_before_mutating_any_account() { - let template = TestWalletContext::from_seed([5; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let valid = spending_record( - spend(parent), - template.receive_address.clone(), - TransactionContext::Mempool, - ); - let mut invalid = valid.clone(); - invalid.txid = funding.txid(); - let mut wallet = template.managed_wallet.clone(); - let state = PersistedWalletState { - transactions: vec![valid.clone(), invalid], - ..Default::default() - }; - assert_eq!( - wallet.restore_persisted_state(state), - Err(RestoreError::InvalidRecord(funding.txid())) - ); - assert!(wallet.first_bip44_managed_account().unwrap().transactions().is_empty()); - assert!(wallet.observed_spent_outpoints().is_empty()); - let mut missing = valid.clone(); - missing.account_type = AccountType::Standard { - index: 7, - standard_account_type: StandardAccountType::BIP44Account, - }; - assert_eq!( - wallet.restore_persisted_state(PersistedWalletState { - transactions: vec![valid.clone(), missing.clone()], - ..Default::default() - }), - Err(RestoreError::MissingAccount(missing.account_type)) - ); - assert!(wallet.first_bip44_managed_account().unwrap().transactions().is_empty()); - wallet - .restore_persisted_state(PersistedWalletState { - transactions: vec![valid], - ..Default::default() - }) - .unwrap(); - assert_eq!( - wallet.restore_persisted_state(PersistedWalletState::default()), - Err(RestoreError::NonEmptyWallet) - ); -} - -#[test] -fn should_reject_spent_unspent_contradiction_and_bad_coin_without_mutation() { - let template = TestWalletContext::from_seed([6; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let utxo = coin(&funding, &template.receive_address); - let record = spending_record( - spend(utxo.outpoint), - template.receive_address.clone(), - TransactionContext::Mempool, - ); - let mut wallet = template.managed_wallet; - assert_eq!( - wallet.restore_persisted_state(PersistedWalletState { - transactions: vec![record], - utxos: vec![(bip44(), utxo.clone())], - ..Default::default() - }), - Err(RestoreError::SpentUtxo(utxo.outpoint)) - ); - let mut invalid = utxo.clone(); - invalid.txout.script_pubkey = ScriptBuf::new(); - assert_eq!( - wallet.restore_persisted_state(PersistedWalletState { - utxos: vec![(bip44(), invalid)], - ..Default::default() - }), - Err(RestoreError::InvalidUtxo(utxo.outpoint)) - ); - assert!(wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); - assert!(wallet.first_bip44_managed_account().unwrap().transactions().is_empty()); -} - -#[tokio::test] -async fn should_preserve_unattributed_spend_without_inventing_block_height() { - let mut template = TestWalletContext::from_seed([7; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - template - .managed_wallet - .restore_persisted_state(PersistedWalletState { - additional_spent_outpoints: BTreeMap::from([(parent, None)]), - ..Default::default() - }) - .unwrap(); - assert!(template.managed_wallet.observed_spent_outpoints().is_empty()); - template - .managed_wallet - .check_core_transaction( - &funding, - TransactionContext::Mempool, - &mut template.wallet, - true, - true, - ) - .await; - assert!(!template - .managed_wallet - .first_bip44_managed_account() - .unwrap() - .utxos - .contains_key(&parent)); -} - -#[tokio::test] -async fn should_keep_external_block_evidence_when_abandoning_record_claim() { - let mut template = TestWalletContext::from_seed([8; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let record = spending_record( - spend(parent), - template.receive_address.clone(), - TransactionContext::Mempool, - ); - let txid = record.txid; - template - .managed_wallet - .restore_persisted_state(PersistedWalletState { - transactions: vec![record], - additional_spent_outpoints: BTreeMap::from([(parent, Some(80))]), - ..Default::default() - }) - .unwrap(); - template.managed_wallet.abandon_transaction(txid); - template - .managed_wallet - .check_core_transaction( - &funding, - TransactionContext::Mempool, - &mut template.wallet, - true, - true, - ) - .await; - assert_eq!(template.managed_wallet.observed_spent_outpoints().get(&parent), Some(&80)); - assert!(!template - .managed_wallet - .first_bip44_managed_account() - .unwrap() - .utxos - .contains_key(&parent)); -} - -#[tokio::test] -async fn should_match_uninterrupted_wallet_across_restore_abandon_and_conflict() { - let mut uninterrupted = TestWalletContext::from_seed([9; 64]); - let mut restored = uninterrupted.managed_wallet.clone(); - let funding = Transaction::dummy(&uninterrupted.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let funding_context = TransactionContext::InBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([0x40; 32]), - 1_699_999_000, - )); - uninterrupted - .managed_wallet - .check_core_transaction( - &funding, - funding_context.clone(), - &mut uninterrupted.wallet, - true, - true, - ) - .await; - let mut root = spend(parent); - root.output[0].script_pubkey = uninterrupted.receive_address.script_pubkey(); - uninterrupted - .managed_wallet - .check_core_transaction( - &root, - TransactionContext::Mempool, - &mut uninterrupted.wallet, - true, - true, - ) - .await; - let root_output = OutPoint { - txid: root.txid(), - vout: 0, - }; - let mut child = spend(root_output); - child.output[0].script_pubkey = uninterrupted.receive_address.script_pubkey(); - child.output[0].value = 998_000; - uninterrupted - .managed_wallet - .check_core_transaction( - &child, - TransactionContext::Mempool, - &mut uninterrupted.wallet, - true, - true, - ) - .await; - let account = uninterrupted.managed_wallet.first_bip44_managed_account().unwrap(); - let mut records: Vec<_> = account.transactions().values().cloned().collect(); - records.reverse(); - restored - .restore_persisted_state(PersistedWalletState { - transactions: records, - utxos: account.utxos.values().cloned().map(|utxo| (bip44(), utxo)).collect(), - additional_spent_outpoints: BTreeMap::from([(parent, None), (root_output, None)]), - }) - .unwrap(); - assert_funds_equal(&uninterrupted.managed_wallet, &restored); - for state in [&mut uninterrupted.managed_wallet, &mut restored] { - state - .check_core_transaction( - &funding, - funding_context.clone(), - &mut uninterrupted.wallet, - true, - true, - ) - .await; - } - assert_funds_equal(&uninterrupted.managed_wallet, &restored); - let mut restored_for_abandon = restored.clone(); - let mut live_for_abandon = uninterrupted.managed_wallet.clone(); - assert_eq!( - live_for_abandon.abandon_transaction(root.txid()).abandoned, - restored_for_abandon.abandon_transaction(root.txid()).abandoned - ); - for state in [&mut live_for_abandon, &mut restored_for_abandon] { - state - .check_core_transaction( - &funding, - funding_context.clone(), - &mut uninterrupted.wallet, - true, - true, - ) - .await; - } - assert_funds_equal(&live_for_abandon, &restored_for_abandon); - assert!(restored_for_abandon - .first_bip44_managed_account() - .unwrap() - .utxos - .contains_key(&parent)); - let winner = competing_spend(parent); - for state in [&mut uninterrupted.managed_wallet, &mut restored] { - state - .check_core_transaction( - &winner, - TransactionContext::InBlock(BlockInfo::new( - 60, - BlockHash::from_byte_array([0x60; 32]), - 1_700_000_000, - )), - &mut uninterrupted.wallet, - true, - true, - ) - .await; - } - assert_funds_equal(&uninterrupted.managed_wallet, &restored); - assert!(!restored - .first_bip44_managed_account() - .unwrap() - .transactions() - .contains_key(&child.txid())); -} - -fn assert_funds_equal(left: &ManagedWalletInfo, right: &ManagedWalletInfo) { - let left_account = left.first_bip44_managed_account().unwrap(); - let right_account = right.first_bip44_managed_account().unwrap(); - assert_eq!(left_account.utxos, right_account.utxos); - assert_eq!(left_account.balance, right_account.balance); - assert_eq!(left.balance, right.balance); - assert_eq!(left_account.tx_count(), right_account.tx_count()); - assert_eq!(left.observed_spent_outpoints(), right.observed_spent_outpoints()); -} - -#[tokio::test] -async fn should_match_finalized_wallet_after_compaction_and_funding_redelivery() { - let mut live = TestWalletContext::from_seed([10; 64]); - let mut restored = live.managed_wallet.clone(); - let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let finalized = TransactionContext::InChainLockedBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([0x40; 32]), - 1_699_999_000, - )); - let funding_result = live - .managed_wallet - .check_core_transaction(&funding, finalized.clone(), &mut live.wallet, true, true) - .await; - let spending = spend(parent); - let spend_result = live - .managed_wallet - .check_core_transaction(&spending, finalized.clone(), &mut live.wallet, true, true) - .await; - let records = funding_result.new_records.into_iter().chain(spend_result.new_records).collect(); - restored - .restore_persisted_state(PersistedWalletState { - transactions: records, - ..Default::default() - }) - .unwrap(); - assert_funds_equal(&live.managed_wallet, &restored); - for state in [&mut live.managed_wallet, &mut restored] { - state - .check_core_transaction(&funding, finalized.clone(), &mut live.wallet, true, true) - .await; - } - assert_funds_equal(&live.managed_wallet, &restored); - assert!(restored - .first_bip44_managed_account() - .unwrap() - .transaction_is_finalized(&spending.txid())); - assert!(restored.first_bip44_managed_account().unwrap().utxos.is_empty()); -} - -#[tokio::test] -async fn should_restore_spend_before_funding_input_recognition() { - let mut live = TestWalletContext::from_seed([11; 64]); - let mut restored = live.managed_wallet.clone(); - let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let spending = spend(parent); - let context = TransactionContext::InBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([0x40; 32]), - 1_699_999_000, - )); - live.managed_wallet - .check_core_transaction(&spending, context.clone(), &mut live.wallet, true, true) - .await; - let funding_result = live - .managed_wallet - .check_core_transaction(&funding, context.clone(), &mut live.wallet, true, true) - .await; - restored - .restore_persisted_state(PersistedWalletState { - transactions: funding_result.new_records, - additional_spent_outpoints: live - .managed_wallet - .observed_spent_outpoints() - .iter() - .map(|(outpoint, height)| (*outpoint, Some(*height))) - .collect(), - ..Default::default() - }) - .unwrap(); - let live_result = live - .managed_wallet - .check_core_transaction(&spending, context.clone(), &mut live.wallet, true, true) - .await; - let restored_result = - restored.check_core_transaction(&spending, context, &mut live.wallet, true, true).await; - assert_eq!(live_result.new_records.len(), 1); - assert_eq!(restored_result.new_records.len(), 1); - assert_eq!(live_result.new_records[0].net_amount, restored_result.new_records[0].net_amount); - assert_eq!( - live_result.new_records[0].input_details.len(), - restored_result.new_records[0].input_details.len() - ); - assert_funds_equal(&live.managed_wallet, &restored); -} - -#[test_case::test_case(false; "funds_record")] -#[test_case::test_case(true; "keys_record")] -#[tokio::test] -async fn should_block_cross_account_funding_with_one_persisted_spend_record_and_release_it( - keys_only: bool, -) { - use crate::wallet::managed_wallet_info::ManagedAccountOperations; - let mut template = TestWalletContext::from_seed([12; 64]); - let other = TestWalletContext::from_seed([13; 64]); - let other_type = if keys_only { - AccountType::IdentityRegistration - } else { - AccountType::Standard { - index: 1, - standard_account_type: StandardAccountType::BIP44Account, - } - }; - if !template - .managed_wallet - .accounts - .all_accounts() - .iter() - .any(|account| account.managed_account_type().to_account_type() == other_type) - { - template.managed_wallet.add_managed_account_from_xpub(other_type, other.xpub).unwrap(); - } - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let mut record = spending_record( - spend(parent), - template.receive_address.clone(), - TransactionContext::Mempool, - ); - record.account_type = other_type; - let claimant = record.txid; - template - .managed_wallet - .restore_persisted_state(PersistedWalletState { - transactions: vec![record], - additional_spent_outpoints: BTreeMap::from([(parent, None)]), - ..Default::default() - }) - .unwrap(); - let context = TransactionContext::InBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([0x40; 32]), - 1_699_999_000, - )); - template - .managed_wallet - .check_core_transaction(&funding, context.clone(), &mut template.wallet, true, true) - .await; - assert!( - !template.managed_wallet.first_bip44_managed_account().unwrap().utxos.contains_key(&parent), - "another account's claim must block the funding owner too" - ); - template.managed_wallet.abandon_transaction(claimant); - template - .managed_wallet - .check_core_transaction(&funding, context, &mut template.wallet, true, true) - .await; - assert!( - template.managed_wallet.first_bip44_managed_account().unwrap().utxos.contains_key(&parent), - "the cross-account guard must release when its record is abandoned" - ); -} - -#[test_case::test_case(false; "block_winner")] -#[test_case::test_case(true; "instant_send_winner")] -#[tokio::test] -async fn should_sweep_restored_keys_loser_and_funds_descendant_and_release_extra_input( - instant_send: bool, -) { - let mut template = TestWalletContext::from_seed([14; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000, 2_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let extra = OutPoint { - txid: funding.txid(), - vout: 1, - }; - let mut root = spend(parent); - root.input.push(TxIn { - previous_output: extra, - script_sig: ScriptBuf::new(), - sequence: u32::MAX, - witness: Witness::new(), - }); - let root_txid = root.txid(); - let mut root_record = - spending_record(root, template.receive_address.clone(), TransactionContext::Mempool); - root_record.account_type = AccountType::IdentityRegistration; - let mut child = spend(OutPoint { - txid: root_txid, - vout: 0, - }); - child.output[0].script_pubkey = template.receive_address.script_pubkey(); - let child_coin = coin(&child, &template.receive_address); - let child_txid = child.txid(); - let child_record = - spending_record(child, template.receive_address.clone(), TransactionContext::Mempool); - template - .managed_wallet - .restore_persisted_state(PersistedWalletState { - transactions: vec![root_record, child_record], - utxos: vec![(bip44(), child_coin)], - additional_spent_outpoints: BTreeMap::from([(parent, None), (extra, None)]), - }) - .unwrap(); - let context = TransactionContext::InBlock(BlockInfo::new( - 80, - BlockHash::from_byte_array([0x80; 32]), - 1_699_999_000, - )); - let result = template - .managed_wallet - .check_core_transaction( - &competing_spend(parent), - if instant_send { - TransactionContext::InstantSend(InstantLock::default()) - } else { - context.clone() - }, - &mut template.wallet, - true, - true, - ) - .await; - assert!(result.new_records.is_empty(), "the external winner has no retained wallet record"); - assert!(result.swept_transactions.contains(&root_txid)); - assert!(result.swept_transactions.contains(&child_txid)); - assert_eq!(result.released_outpoints, vec![extra]); - assert!(template.managed_wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); - template - .managed_wallet - .check_core_transaction(&funding, context, &mut template.wallet, true, true) - .await; - let coins = &template.managed_wallet.first_bip44_managed_account().unwrap().utxos; - assert!(!coins.contains_key(&parent)); - assert!(coins.contains_key(&extra)); -} - -#[test] -fn should_reject_balance_overflow_before_installing_coins() { - let template = TestWalletContext::from_seed([15; 64]); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[u64::MAX, 1]); - let first = coin(&funding, &template.receive_address); - let mut second = first.clone(); - second.outpoint.vout = 1; - second.txout = funding.output[1].clone(); - let mut wallet = template.managed_wallet; - assert!(wallet - .restore_persisted_state(PersistedWalletState { - utxos: vec![(bip44(), first), (bip44(), second)], - ..Default::default() - }) - .is_err()); - assert!(wallet.first_bip44_managed_account().unwrap().utxos.is_empty()); -} - -#[tokio::test] -async fn should_preserve_cross_account_input_recognition_after_restore() { - let mut template = TestWalletContext::from_seed([16; 64]); - let other_xpub = template.wallet.accounts.standard_bip32_accounts.get(&0).unwrap().account_xpub; - let other_address = template - .managed_wallet - .first_bip32_managed_account_mut() - .unwrap() - .next_receive_address(Some(&other_xpub), true) - .unwrap(); - let mut restored = template.managed_wallet.clone(); - let funding = Transaction::dummy(&template.receive_address, 0..1, &[1_000_000]); - let parent = OutPoint { - txid: funding.txid(), - vout: 0, - }; - let mut spending = spend(parent); - spending.output[0].script_pubkey = other_address.script_pubkey(); - template - .managed_wallet - .check_core_transaction( - &spending, - TransactionContext::Mempool, - &mut template.wallet, - true, - true, - ) - .await; - let context = TransactionContext::InBlock(BlockInfo::new( - 40, - BlockHash::from_byte_array([40; 32]), - 1_700_000_000, - )); - template - .managed_wallet - .check_core_transaction(&funding, context.clone(), &mut template.wallet, true, true) - .await; - let records = template - .managed_wallet - .accounts - .all_accounts() - .into_iter() - .flat_map(|account| account.transactions().values().cloned()) - .collect(); - restored - .restore_persisted_state(PersistedWalletState { - transactions: records, - utxos: template - .managed_wallet - .accounts - .all_accounts() - .into_iter() - .filter_map(|account| account.as_funds()) - .flat_map(|account| { - account - .utxos - .values() - .cloned() - .map(|utxo| (account.managed_account_type().to_account_type(), utxo)) - }) - .collect(), - additional_spent_outpoints: template - .managed_wallet - .observed_spent_outpoints() - .iter() - .map(|(outpoint, height)| (*outpoint, Some(*height))) - .chain([(parent, None)]) - .collect(), - }) - .unwrap(); - let live_result = template - .managed_wallet - .check_core_transaction(&spending, context.clone(), &mut template.wallet, true, true) - .await; - let restored_result = - restored.check_core_transaction(&spending, context, &mut template.wallet, true, true).await; - assert_eq!(live_result.new_records.len(), 1); - assert_eq!(live_result.new_records[0].account_type, bip44()); - assert_eq!(live_result.new_records[0].net_amount, -1_000_000); - assert_eq!(live_result.new_records[0].input_details.len(), 1); - assert_eq!(restored_result.new_records.len(), live_result.new_records.len()); - let debit = &restored_result.new_records[0]; - assert_eq!(debit.account_type, bip44()); - assert_eq!(debit.net_amount, -1_000_000); - assert_eq!(debit.input_details.len(), 1); - assert_eq!(debit.input_details[0].value, 1_000_000); -} - -#[test] -fn should_reject_coin_owned_by_another_wallet() { - let owner = TestWalletContext::from_seed([17; 64]); - let mut receiver = TestWalletContext::from_seed([18; 64]); - assert!(!receiver.bip44_account().contains_address(&owner.receive_address)); - let tx = Transaction::dummy(&owner.receive_address, 0..1, &[1_000_000]); - let outpoint = OutPoint { - txid: tx.txid(), - vout: 0, - }; - let mut coin = Utxo::new(outpoint, tx.output[0].clone(), owner.receive_address, 100, false); - coin.is_confirmed = true; - let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState { - utxos: vec![(bip44(), coin)], - ..Default::default() - }); - assert_eq!(result, Err(RestoreError::InvalidUtxo(outpoint))); - assert!(receiver.bip44_account().utxos.is_empty()); -} - -#[test_case::test_case(false; "coinbase_flag")] -#[test_case::test_case(true; "block_height")] -fn should_reject_coinbase_metadata_disagreeing_with_record(invalid_height: bool) { - let mut receiver = TestWalletContext::from_seed([19; 64]); - receiver.managed_wallet.update_last_processed_height(100); - let mut tx = Transaction::dummy(&receiver.receive_address, 0..1, &[1_000_000]); - tx.input[0].previous_output = OutPoint::null(); - assert!(tx.is_coin_base()); - let outpoint = OutPoint { - txid: tx.txid(), - vout: 0, - }; - let mut coin = - Utxo::new(outpoint, tx.output[0].clone(), receiver.receive_address.clone(), 100, true); - coin.is_confirmed = true; - let record = TransactionRecord::new( - tx, - bip44(), - TransactionContext::InBlock(BlockInfo::new(100, BlockHash::all_zeros(), 0)), - TransactionType::Standard, - TransactionDirection::Incoming, - vec![], - vec![], - 1_000_000, - ); - let mut control = receiver.managed_wallet.clone(); - control - .restore_persisted_state(PersistedWalletState { - transactions: vec![record.clone()], - utxos: vec![(bip44(), coin.clone())], - ..Default::default() - }) - .unwrap(); - assert_eq!(control.balance().immature(), 1_000_000); - assert!(!control.first_bip44_managed_account().unwrap().utxos[&outpoint].is_spendable(100)); - if invalid_height { - coin.height = 0; - } else { - coin.is_coinbase = false; - } - let result = receiver.managed_wallet.restore_persisted_state(PersistedWalletState { - transactions: vec![record], - utxos: vec![(bip44(), coin)], - ..Default::default() - }); - assert_eq!(result, Err(RestoreError::InvalidUtxo(outpoint))); - assert!(receiver.bip44_account().utxos.is_empty()); - assert!(receiver.bip44_account().transactions().is_empty()); - assert!(receiver.managed_wallet.observed_spent_outpoints().is_empty()); -} - -#[test_case::test_case(false; "unspent")] -#[test_case::test_case(true; "fully_spent")] -#[tokio::test] -async fn should_preserve_restored_block_context_on_duplicate_instant_lock(fully_spent: bool) { - let mut live = TestWalletContext::from_seed([20; 64]); - let mut restored = live.managed_wallet.clone(); - let funding = Transaction::dummy(&live.receive_address, 0..1, &[1_000_000]); - let txid = funding.txid(); - let lock = InstantLock { - txid, - ..InstantLock::default() - }; - live.check_transaction(&funding, TransactionContext::Mempool).await; - assert!(live.managed_wallet.mark_instant_send_utxos(&txid, &lock)); - let block_info = BlockInfo::new(40, BlockHash::from_byte_array([40; 32]), 1_700_000_000); - let block = TransactionContext::InBlock(block_info); - live.check_transaction(&funding, block.clone()).await; - assert_eq!(live.transaction(&txid).context, block); - if fully_spent { - live.check_transaction( - &spend(OutPoint { - txid, - vout: 0, - }), - block.clone(), - ) - .await; - assert!(live.bip44_account().utxos.is_empty()); - } - let account = live.bip44_account(); - restored - .restore_persisted_state(PersistedWalletState { - transactions: account.transactions().values().cloned().collect(), - utxos: account.utxos.values().cloned().map(|coin| (bip44(), coin)).collect(), - additional_spent_outpoints: live - .managed_wallet - .observed_spent_outpoints() - .iter() - .map(|(outpoint, height)| (*outpoint, Some(*height))) - .collect(), - }) - .unwrap(); - assert!(!live.managed_wallet.mark_instant_send_utxos(&txid, &lock)); - restored.mark_instant_send_utxos(&txid, &lock); - assert_eq!( - restored.first_bip44_managed_account().unwrap().transactions()[&txid].context, - block - ); - assert!(!restored.mark_instant_send_utxos(&txid, &lock)); - restored.apply_chain_lock(dashcore::ChainLock { - block_height: 40, - block_hash: BlockHash::from_byte_array([40; 32]), - signature: [0u8; 96].into(), - }); - let account = restored.first_bip44_managed_account().unwrap(); - assert!(account.transaction_is_finalized(&txid)); - #[cfg(feature = "keep-finalized-transactions")] - assert_eq!( - account.transactions()[&txid].context, - TransactionContext::InChainLockedBlock(block_info) - ); - // A further delivery must also leave finalized history intact. - restored.instant_send_locks.remove(&txid); - restored.mark_instant_send_utxos(&txid, &lock); - assert!(restored.first_bip44_managed_account().unwrap().transaction_is_finalized(&txid)); -} diff --git a/key-wallet/src/transaction_checking/wallet_checker.rs b/key-wallet/src/transaction_checking/wallet_checker.rs index 53f6e0529..1e91fc701 100644 --- a/key-wallet/src/transaction_checking/wallet_checker.rs +++ b/key-wallet/src/transaction_checking/wallet_checker.rs @@ -6,14 +6,12 @@ pub(crate) use super::account_checker::TransactionCheckResult; use super::transaction_context::TransactionContext; use super::transaction_router::{AccountTypeToCheck, TransactionRouter}; -use crate::wallet::managed_wallet_info::persistence::WalletSpendEvidence; use crate::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; use crate::wallet::managed_wallet_info::ManagedWalletInfo; use crate::{KeySource, Wallet}; use async_trait::async_trait; use dashcore::blockdata::transaction::Transaction; -use dashcore::{Amount, OutPoint, SignedAmount}; -use std::collections::HashSet; +use dashcore::{Amount, SignedAmount}; /// Extension trait for ManagedWalletInfo to add transaction checking capabilities #[async_trait] @@ -187,33 +185,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { let external_final_parents = self.accounts.final_parents_of(tx); let txid = tx.txid(); - let accounts = self.accounts.all_accounts(); - let mut claimed_outputs: HashSet<_> = (0..tx.output.len()) - .map(|vout| OutPoint { - txid, - vout: vout as u32, - }) - .filter(|outpoint| { - accounts.iter().any(|account| { - account.as_funds().is_some_and(|funds| funds.is_outpoint_spent(outpoint)) - }) - }) - .collect(); - for account in &accounts { - if account.as_keys().is_some() { - claimed_outputs.extend( - account - .transactions() - .values() - .flat_map(|record| &record.transaction.input) - .map(|input| input.previous_output) - .filter(|outpoint| { - outpoint.txid == txid && (outpoint.vout as usize) < tx.output.len() - }), - ); - } - } - let is_new = !accounts.into_iter().any(|a| a.has_transaction(&txid)); + let is_new = !self.accounts.all_accounts().into_iter().any(|a| a.has_transaction(&txid)); result.is_new_transaction = is_new; if !is_new { @@ -265,11 +237,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { &account_match, context.clone(), tx_type, - &WalletSpendEvidence { - observed: &self.observed_spent_outpoints, - unattributed: &self.unattributed_spent_outpoints, - claimed: &claimed_outputs, - }, + &self.observed_spent_outpoints, &external_final_parents, ); account.mark_utxos_instant_send(&txid); @@ -302,11 +270,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { &account_match, context.clone(), tx_type, - &WalletSpendEvidence { - observed: &self.observed_spent_outpoints, - unattributed: &self.unattributed_spent_outpoints, - claimed: &claimed_outputs, - }, + &self.observed_spent_outpoints, &external_final_parents, ); result.new_records.push(record); @@ -318,11 +282,7 @@ impl WalletTransactionChecker for ManagedWalletInfo { &account_match, context.clone(), tx_type, - &WalletSpendEvidence { - observed: &self.observed_spent_outpoints, - unattributed: &self.unattributed_spent_outpoints, - claimed: &claimed_outputs, - }, + &self.observed_spent_outpoints, &external_final_parents, ) { result.state_modified = true; diff --git a/key-wallet/src/wallet/managed_wallet_info/helpers.rs b/key-wallet/src/wallet/managed_wallet_info/helpers.rs index 418bd26d8..9e7791e97 100644 --- a/key-wallet/src/wallet/managed_wallet_info/helpers.rs +++ b/key-wallet/src/wallet/managed_wallet_info/helpers.rs @@ -6,7 +6,6 @@ use crate::account::ManagedCoreFundsAccount; use crate::account::TransactionRecord; use crate::managed_account::managed_account_ref::ManagedAccountRefMut; use crate::managed_account::managed_account_trait::ManagedAccountTrait; -use crate::managed_account::managed_core_funds_account::conflicted_transactions; use crate::managed_account::managed_platform_account::ManagedPlatformAccount; use crate::managed_account::ManagedCoreKeysAccount; use crate::transaction_checking::TransactionContext; @@ -165,68 +164,15 @@ impl ManagedWalletInfo { tx: &Transaction, context: &TransactionContext, ) -> WalletConflictSweep { - let records: Vec<_> = self - .accounts - .all_accounts() - .into_iter() - .flat_map(|account| account.transactions().values()) - .collect(); - let losers = conflicted_transactions(&records, tx, context); - let winner_inputs: HashSet<_> = - tx.input.iter().map(|input| input.previous_output).collect(); - let shared_inputs: HashSet<_> = if context.is_instant_send() && !losers.is_empty() { - records - .iter() - .filter(|record| losers.contains(&record.txid)) - .flat_map(|record| &record.transaction.input) - .map(|input| input.previous_output) - .filter(|outpoint| winner_inputs.contains(outpoint)) - .collect() - } else { - HashSet::new() - }; - let mut result = WalletConflictSweep { - txids: losers.iter().copied().collect(), - released_outpoints: Vec::new(), - }; + let mut result = WalletConflictSweep::default(); for account in self.accounts.all_accounts_mut() { - match account { - ManagedAccountRefMut::Funds(funds) => { - let swept = funds.apply_conflict_set(tx, &losers); - result.released_outpoints.extend(swept.released_outpoints); - } - ManagedAccountRefMut::Keys(keys) => { - for loser in &losers { - if let Some(record) = keys.transactions_mut().remove(loser) { - result.released_outpoints.extend( - record - .transaction - .input - .iter() - .map(|input| input.previous_output) - .filter(|outpoint| { - !winner_inputs.contains(outpoint) - && !losers.contains(&outpoint.txid) - }), - ); - } - } - } + if let ManagedAccountRefMut::Funds(funds) = account { + let swept = funds.drop_conflicted_transactions(tx, context); + result.txids.extend(swept.txids); + result.released_outpoints.extend(swept.released_outpoints); } } if !result.txids.is_empty() { - if context.is_instant_send() { - let accounts = self.accounts.all_accounts(); - self.unattributed_spent_outpoints.extend(shared_inputs.into_iter().filter( - |outpoint| { - !accounts.iter().any(|account| { - account - .as_funds() - .is_some_and(|funds| funds.is_outpoint_spent(outpoint)) - }) - }, - )); - } self.update_balance(); // One transaction can be recorded in several accounts, so the // per-account results overlap. diff --git a/key-wallet/src/wallet/managed_wallet_info/mod.rs b/key-wallet/src/wallet/managed_wallet_info/mod.rs index 363bbbb5b..6bfece722 100644 --- a/key-wallet/src/wallet/managed_wallet_info/mod.rs +++ b/key-wallet/src/wallet/managed_wallet_info/mod.rs @@ -10,8 +10,6 @@ pub mod helpers; pub use helpers::AbandonOutcome; pub mod managed_account_operations; pub mod managed_accounts; -pub mod persistence; -pub use persistence::{PersistedWalletState, RestoreError}; pub mod transaction_builder; pub mod transaction_building; pub mod wallet_info_interface; @@ -74,7 +72,7 @@ pub struct ManagedWalletInfo { /// spending transaction's classification, lets the funding-side insert be /// reconciled away whichever order the two blocks arrive in. /// - /// Live observations are bounded-permanent: an entry is retained until its spend + /// Membership is bounded-permanent: an entry is retained until its spend /// height is provably final, then evicted by /// [`Self::prune_finalized_observed_spends`]. Until then it is only ever /// added, never removed, and reorg rollback does NOT retract it — treating a @@ -85,7 +83,7 @@ pub struct ManagedWalletInfo { /// /// # Bounded permanence /// - /// An unpinned entry `(outpoint, height)` is removed only when + /// An entry `(outpoint, height)` is removed only when /// `height <= min(last_applied_chain_lock.block_height, synced_height)` — /// the finality boundary. At that boundary the spend is chain-locked (it can /// never be reorged out) and any funding transaction for the outpoint has @@ -95,19 +93,17 @@ pub struct ManagedWalletInfo { /// a coin could be re-inserted — in both `keep-finalized-transactions` /// configurations and across a reload. No other removal path may be added /// without a deliberate decision. - /// Restored block spends are pinned by `unattributed_spent_outpoints`: - /// their funding records may be absent even below the restored sync checkpoint. /// /// Eviction is event-driven (chainlock application, sync-checkpoint commit), /// never age- or recency-based: during an out-of-order rescan `synced_height` /// is low, so nothing is pruned in exactly the window where #649 ordering - /// hazards live, and live observations self-repopulate on any replay since + /// hazards live, and the set self-repopulates on any replay since /// `record_observed_spends` runs unconditionally per checked tx. A naive /// age/LRU eviction would instead evict the cold entries whose funding tx may /// still arrive out of order, reopening #649 for that coin. Steady-state size - /// is the restored pins plus the above-boundary window — roughly one block's - /// inputs on a healthy chain. A defensive cap on the deserialized entry count - /// (see the serde adapter) guards against a corrupted or hostile wallet file forcing + /// is the above-boundary window only — roughly one block's inputs on a + /// healthy chain. A defensive cap on the deserialized entry count (see the + /// serde adapter) guards against a corrupted or hostile wallet file forcing /// an unbounded allocation on load. /// /// # Persistence @@ -126,10 +122,6 @@ pub struct ManagedWalletInfo { /// migration to load pre-field snapshots. #[cfg_attr(feature = "serde", serde(default, with = "observed_spent_outpoints_serde"))] pub(crate) observed_spent_outpoints: BTreeMap, - /// Durable output guards independent of releasable record claims. - /// Entries with observed heights also pin that block evidence against finality pruning. - #[cfg_attr(feature = "serde", serde(default))] - pub(crate) unattributed_spent_outpoints: HashSet, /// Generation counter for the wallet's account set, bumped every time an /// account is added to a live wallet (see /// [`Self::rewind_sync_checkpoint_for_new_account`]). @@ -252,7 +244,6 @@ impl ManagedWalletInfo { balance: WalletCoreBalance::default(), instant_send_locks: HashSet::new(), observed_spent_outpoints: BTreeMap::new(), - unattributed_spent_outpoints: HashSet::new(), account_generation: 0, } } @@ -269,7 +260,6 @@ impl ManagedWalletInfo { balance: WalletCoreBalance::default(), instant_send_locks: HashSet::new(), observed_spent_outpoints: BTreeMap::new(), - unattributed_spent_outpoints: HashSet::new(), account_generation: 0, } } @@ -296,7 +286,6 @@ impl ManagedWalletInfo { balance: WalletCoreBalance::default(), instant_send_locks: HashSet::new(), observed_spent_outpoints: BTreeMap::new(), - unattributed_spent_outpoints: HashSet::new(), account_generation: 0, } } @@ -368,10 +357,10 @@ impl ManagedWalletInfo { changed } - /// Evict unpinned [`Self::observed_spent_outpoints`] entries at or below the finality + /// Evict [`Self::observed_spent_outpoints`] entries at or below the finality /// boundary `min(last_applied_chain_lock.block_height, synced_height)`. /// - /// An unpinned entry `(outpoint, height)` with `height <= boundary` is safe to + /// An entry `(outpoint, height)` with `height <= boundary` is safe to /// forget: the spend at that height is chain-locked (never reorged out) and /// any funding transaction for the outpoint has been delivered and finalized, /// so no redelivery path can re-insert the coin (dashpay/rust-dashcore#649). @@ -385,9 +374,7 @@ impl ManagedWalletInfo { return; }; let boundary = chain_lock.block_height.min(self.metadata.synced_height); - self.observed_spent_outpoints.retain(|outpoint, height| { - *height > boundary || self.unattributed_spent_outpoints.contains(outpoint) - }); + self.observed_spent_outpoints.retain(|_, height| *height > boundary); } /// Invalidate the wallet's sync certificate when an account is added. diff --git a/key-wallet/src/wallet/managed_wallet_info/persistence.rs b/key-wallet/src/wallet/managed_wallet_info/persistence.rs deleted file mode 100644 index 0ceda7009..000000000 --- a/key-wallet/src/wallet/managed_wallet_info/persistence.rs +++ /dev/null @@ -1,337 +0,0 @@ -//! Atomic installation of a persistence adapter's materialized wallet state. - -use super::wallet_info_interface::WalletInfoInterface; -use super::ManagedWalletInfo; -use crate::account::{AccountType, TransactionRecord}; -use crate::managed_account::managed_account_trait::ManagedAccountTrait; -use crate::managed_account::transaction_record::OutputRole; -use crate::managed_account::ManagedAccountRefMut; -use crate::utxo::Utxo; -use dashcore::prelude::CoreBlockHeight; -use dashcore::{OutPoint, Txid}; -use std::collections::{BTreeMap, HashSet}; -use std::fmt; - -/// Complete transaction and coin state supplied by an external persistence adapter. -/// -/// Store full records before key-wallet compacts finalized history. Records and UTXOs -/// must describe the same committed snapshot, with abandoned/conflicted records removed. -/// Records for one txid must agree on block identity and finality. Coin confirmation -/// and unmined InstantSend flags must agree with any surviving funding record. -/// Account definitions, address pools and sync metadata belong to the receiving skeleton. -#[derive(Debug, Clone, Default)] -pub struct PersistedWalletState { - /// All surviving records, including full records of finalized transactions. - pub transactions: Vec, - /// The materialized unspent coins with their exact owning accounts. - pub utxos: Vec<(AccountType, Utxo)>, - /// Durable spend evidence, including outpoints whose spending record is unavailable. - /// `Some(height)` proves a block-observed spend and survives finality pruning. - /// `None` only blocks the output; claims covered by a record are derived from - /// that record and remain releasable. - pub additional_spent_outpoints: BTreeMap>, -} - -/// A persisted snapshot could not be installed; the receiving wallet is unchanged. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RestoreError { - /// Restore requires an account skeleton with no transaction or coin state. - NonEmptyWallet, - /// The combined coin values cannot be represented by the wallet balance. - BalanceOverflow, - /// A record or coin names an account absent from the skeleton. - MissingAccount(AccountType), - /// A coin names a keys-only account. - NonFundingAccount(AccountType), - /// A record's txid, input/output metadata or lifecycle context is inconsistent. - InvalidRecord(Txid), - /// More than one record names the same transaction and account. - DuplicateRecord(Txid, AccountType), - /// More than one unspent coin names the same outpoint. - DuplicateUtxo(OutPoint), - /// A coin's ownership, script or funding transaction metadata/finality is inconsistent. - InvalidUtxo(OutPoint), - /// A coin is simultaneously unspent and claimed spent. - SpentUtxo(OutPoint), -} - -impl fmt::Display for RestoreError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::NonEmptyWallet => { - f.write_str("persisted state requires an empty wallet skeleton") - } - Self::BalanceOverflow => { - f.write_str("persisted coin values overflow the wallet balance") - } - Self::MissingAccount(account) => write!(f, "missing persisted account {account}"), - Self::NonFundingAccount(account) => write!(f, "account {account} cannot hold coins"), - Self::InvalidRecord(txid) => write!(f, "inconsistent persisted transaction {txid}"), - Self::DuplicateRecord(txid, account) => { - write!(f, "duplicate transaction {txid} in {account}") - } - Self::DuplicateUtxo(outpoint) => write!(f, "duplicate persisted coin {outpoint}"), - Self::InvalidUtxo(outpoint) => write!(f, "inconsistent persisted coin {outpoint}"), - Self::SpentUtxo(outpoint) => write!(f, "persisted coin {outpoint} is also spent"), - } - } -} - -impl std::error::Error for RestoreError {} - -impl ManagedWalletInfo { - /// Install a complete persistence snapshot without replaying live transaction processing. - /// - /// Validates the entire snapshot before changing any state. The receiver must be a - /// fresh account skeleton; account definitions, pools and sync metadata are preserved. - /// Spend claims are derived before finalized records are compacted. All restored - /// block evidence and heightless claims without a record conservatively block funding - /// redelivery until a complete replacement snapshot; neither is abandoned by txid. - /// Returns [`RestoreError`] for an inconsistent snapshot or nonempty receiver. - pub fn restore_persisted_state( - &mut self, - state: PersistedWalletState, - ) -> Result<(), RestoreError> { - self.validate_persisted_state(&state)?; - let record_inputs: HashSet<_> = state - .transactions - .iter() - .filter(|record| !record.transaction.is_coin_base()) - .flat_map(|record| record.transaction.input.iter().map(|input| input.previous_output)) - .collect(); - for (outpoint, height) in state.additional_spent_outpoints { - if let Some(height) = height { - self.observed_spent_outpoints.insert(outpoint, height); - self.unattributed_spent_outpoints.insert(outpoint); - } else if !record_inputs.contains(&outpoint) { - self.unattributed_spent_outpoints.insert(outpoint); - } - } - let account_claims: HashSet<_> = state - .transactions - .iter() - .filter(|record| !record.transaction.is_coin_base()) - .flat_map(|record| { - record - .transaction - .input - .iter() - .map(|input| (record.account_type, input.previous_output)) - }) - .collect(); - for record in &state.transactions { - if let Some(block) = record.context.block_info() { - for input in &record.transaction.input { - if !input.previous_output.is_null() { - self.unattributed_spent_outpoints.insert(input.previous_output); - self.observed_spent_outpoints - .entry(input.previous_output) - .and_modify(|height| *height = (*height).max(block.height())) - .or_insert(block.height()); - } - } - } - } - for record in state.transactions { - if record.context.is_instant_send() { - self.instant_send_locks.insert(record.txid); - } - for account in self.accounts.all_accounts_mut() { - if account.managed_account_type().to_account_type() != record.account_type { - continue; - } - match account { - ManagedAccountRefMut::Funds(account) => { - for detail in &record.output_details { - let outpoint = OutPoint { - txid: record.txid, - vout: detail.index, - }; - if matches!(detail.role, OutputRole::Received | OutputRole::Change) - && (record_inputs.contains(&outpoint) - || self.observed_spent_outpoints.contains_key(&outpoint) - || self.unattributed_spent_outpoints.contains(&outpoint)) - && !account_claims.contains(&(record.account_type, outpoint)) - { - if let Some(address) = &detail.address { - account.spent_before_funded.insert( - outpoint, - Utxo::new( - outpoint, - record.transaction.output[detail.index as usize] - .clone(), - address.clone(), - record - .context - .block_info() - .map_or(0, |block| block.height()), - record.transaction.is_coin_base(), - ), - ); - } - } - } - account.restore_transaction_record(record); - } - ManagedAccountRefMut::Keys(account) => { - account.restore_transaction_record(record) - } - } - break; - } - } - for (account_type, utxo) in state.utxos { - for account in self.accounts.all_accounts_mut() { - if let ManagedAccountRefMut::Funds(account) = account { - if account.managed_account_type().to_account_type() == account_type { - if account.utxos.is_empty() { - account.bump_monitor_revision(); - } - account.utxos.insert(utxo.outpoint, utxo); - break; - } - } - } - } - for account in self.accounts.dashpay_external_accounts.values_mut() { - account.update_balance(self.metadata.last_processed_height); - } - self.update_balance(); - Ok(()) - } - - fn validate_persisted_state(&self, state: &PersistedWalletState) -> Result<(), RestoreError> { - let accounts: BTreeMap<_, _> = self - .accounts - .all_accounts() - .into_iter() - .map(|account| (account.managed_account_type().to_account_type(), account)) - .collect(); - if !self.observed_spent_outpoints.is_empty() - || !self.unattributed_spent_outpoints.is_empty() - || !self.instant_send_locks.is_empty() - || accounts.values().any(|account| { - account.tx_count() != 0 - || account.as_funds().is_some_and(|funds| funds.has_persisted_funds_state()) - }) - { - return Err(RestoreError::NonEmptyWallet); - } - let mut records = HashSet::new(); - let mut spent: HashSet<_> = state.additional_spent_outpoints.keys().copied().collect(); - let mut transactions = BTreeMap::new(); - let mut lifecycles = BTreeMap::new(); - for record in &state.transactions { - if !accounts.contains_key(&record.account_type) { - return Err(RestoreError::MissingAccount(record.account_type)); - } - let mut input_indices = HashSet::new(); - let mut output_indices = HashSet::new(); - if record.txid != record.transaction.txid() - || record.input_details.iter().any(|detail| { - detail.index as usize >= record.transaction.input.len() - || !input_indices.insert(detail.index) - || !detail.address.as_unchecked().is_valid_for_network(self.network) - }) - || record.output_details.iter().any(|detail| { - !output_indices.insert(detail.index) - || record.transaction.output.get(detail.index as usize).is_none_or( - |output| { - output.value != detail.value - || detail.address.as_ref().is_some_and(|address| { - address.script_pubkey() != output.script_pubkey - || !address - .as_unchecked() - .is_valid_for_network(self.network) - }) - }, - ) - }) - { - return Err(RestoreError::InvalidRecord(record.txid)); - } - if !records.insert((record.account_type, record.txid)) { - return Err(RestoreError::DuplicateRecord(record.txid, record.account_type)); - } - transactions.insert(record.txid, record); - // Position is optional metadata; block identity and finality must agree. - let lifecycle = ( - record.context.block_info().map(|block| (block.height(), block.block_hash())), - record.context.is_instant_send(), - record.context.is_chain_locked(), - ); - if lifecycles.insert(record.txid, lifecycle).is_some_and(|prior| prior != lifecycle) { - return Err(RestoreError::InvalidRecord(record.txid)); - } - if !record.transaction.is_coin_base() { - spent.extend(record.transaction.input.iter().map(|input| input.previous_output)); - } - } - let mut coins = HashSet::new(); - let mut total = 0u64; - for (account_type, utxo) in &state.utxos { - total = total.checked_add(utxo.txout.value).ok_or(RestoreError::BalanceOverflow)?; - let account = - accounts.get(account_type).ok_or(RestoreError::MissingAccount(*account_type))?; - if account.as_funds().is_none() { - return Err(RestoreError::NonFundingAccount(*account_type)); - } - if !coins.insert(utxo.outpoint) { - return Err(RestoreError::DuplicateUtxo(utxo.outpoint)); - } - if spent.contains(&utxo.outpoint) { - return Err(RestoreError::SpentUtxo(utxo.outpoint)); - } - if (utxo.is_coinbase && utxo.height.checked_add(100).is_none()) - || !account.contains_address(&utxo.address) - || utxo.address.script_pubkey() != utxo.txout.script_pubkey - || !utxo.address.as_unchecked().is_valid_for_network(self.network) - || transactions.get(&utxo.outpoint.txid).is_some_and(|record| { - let transaction = &record.transaction; - transaction.output.get(utxo.outpoint.vout as usize) != Some(&utxo.txout) - || transaction.is_coin_base() != utxo.is_coinbase - || record.context.block_info().is_some_and(|block| block.height() != utxo.height) - || record.is_confirmed() != utxo.is_confirmed - // A mined context does not retain earlier InstantSend evidence. - || (!record.is_confirmed() - && record.context.is_instant_send() != utxo.is_instantlocked) - }) - { - return Err(RestoreError::InvalidUtxo(utxo.outpoint)); - } - } - Ok(()) - } -} - -/// Output suppression and settled-input checks use distinct evidence. -pub(crate) trait SpendEvidence { - fn blocks_output(&self, outpoint: &OutPoint) -> bool; - fn is_settled(&self, outpoint: &OutPoint) -> bool; -} - -impl SpendEvidence for BTreeMap { - fn blocks_output(&self, outpoint: &OutPoint) -> bool { - self.contains_key(outpoint) - } - fn is_settled(&self, outpoint: &OutPoint) -> bool { - self.contains_key(outpoint) - } -} - -pub(crate) struct WalletSpendEvidence<'a> { - pub observed: &'a BTreeMap, - pub unattributed: &'a HashSet, - pub claimed: &'a HashSet, -} - -impl SpendEvidence for WalletSpendEvidence<'_> { - fn blocks_output(&self, outpoint: &OutPoint) -> bool { - self.observed.contains_key(outpoint) - || self.unattributed.contains(outpoint) - || self.claimed.contains(outpoint) - } - fn is_settled(&self, outpoint: &OutPoint) -> bool { - self.observed.contains_key(outpoint) - } -} diff --git a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs index 0b6e2588f..585effb53 100644 --- a/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs +++ b/key-wallet/src/wallet/managed_wallet_info/wallet_info_interface.rs @@ -574,11 +574,8 @@ impl WalletInfoInterface for ManagedWalletInfo { any_changed = true; } if let Some(record) = account.transactions_mut().get_mut(txid) { - // Lock delivery must not discard mined context, including after restore. - if !record.is_confirmed() { - record.update_context(TransactionContext::InstantSend(lock.clone())); - any_changed = true; - } + record.update_context(TransactionContext::InstantSend(lock.clone())); + any_changed = true; if locked_transaction.is_none() { locked_transaction = Some(record.transaction.clone()); }