Skip to content
Closed
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions key-wallet/src/managed_account/managed_account_ref.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -339,7 +339,7 @@ impl<'a> ManagedAccountRefMut<'a> {
account_match: &AccountMatch,
context: TransactionContext,
transaction_type: TransactionType,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
observed_spent: &impl SpendEvidence,
external_final_parents: &BTreeSet<OutPoint>,
) -> TransactionRecord {
match self {
Expand Down Expand Up @@ -395,7 +395,7 @@ impl<'a> ManagedAccountRefMut<'a> {
account_match: &AccountMatch,
context: TransactionContext,
transaction_type: TransactionType,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
observed_spent: &impl SpendEvidence,
external_final_parents: &BTreeSet<OutPoint>,
) -> Option<TransactionRecord> {
match self {
Expand Down
186 changes: 99 additions & 87 deletions key-wallet/src/managed_account/managed_core_funds_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -111,6 +112,21 @@ 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))
Expand Down Expand Up @@ -190,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)
}

Expand Down Expand Up @@ -225,7 +241,7 @@ impl ManagedCoreFundsAccount {
tx: &Transaction,
account_match: &AccountMatch,
context: TransactionContext,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
observed_spent: &impl SpendEvidence,
external_final_parents: &BTreeSet<OutPoint>,
) {
// Update UTXOs only for spendable account types
Expand Down Expand Up @@ -294,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,
Expand Down Expand Up @@ -332,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.
if observed_spent.contains_key(&outpoint) {
// 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,
Expand Down Expand Up @@ -562,90 +576,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<OutPoint> =
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<Txid> = 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<Txid>,
) -> 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<Txid, Vec<Txid>> = 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<Txid> = 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<OutPoint> = HashSet::new();
let mut changed = false;
for loser in &losers {
for loser in losers {
let removed: Vec<OutPoint> =
self.utxos.keys().filter(|outpoint| outpoint.txid == *loser).copied().collect();
for outpoint in removed {
Expand Down Expand Up @@ -691,7 +646,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,
}
}
Expand All @@ -718,7 +673,7 @@ impl ManagedCoreFundsAccount {
account_match: &AccountMatch,
context: TransactionContext,
transaction_type: TransactionType,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
observed_spent: &impl SpendEvidence,
external_final_parents: &BTreeSet<OutPoint>,
) -> Option<TransactionRecord> {
let txid = tx.txid();
Expand Down Expand Up @@ -808,7 +763,7 @@ impl ManagedCoreFundsAccount {
account_match: &AccountMatch,
context: TransactionContext,
transaction_type: TransactionType,
observed_spent: &BTreeMap<OutPoint, CoreBlockHeight>,
observed_spent: &impl SpendEvidence,
external_final_parents: &BTreeSet<OutPoint>,
) -> TransactionRecord {
let net_amount = account_match.received as i64 - account_match.sent as i64;
Expand Down Expand Up @@ -1289,6 +1244,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<Txid> {
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<Txid, Vec<Txid>> = 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,
Expand Down Expand Up @@ -1340,7 +1352,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
Expand Down
15 changes: 15 additions & 0 deletions key-wallet/src/managed_account/managed_core_keys_account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions key-wallet/src/test_utils/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions key-wallet/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading