Skip to content

Commit 2d38624

Browse files
fix(key-wallet): keep dedup working when keep_txs_in_memory is off
CodeRabbit review caught that confirm_transaction's dedup guard (self.transactions.contains_key(&txid)) is always false with the feature off, so re-events (mempool→block, IS-lock arrivals after a block) are treated as brand new every time — replaying record_transaction, re-bumping monitor_revision, and spuriously flipping state_modified. Add a `processed_txids: HashSet<Txid>` field that's always populated regardless of the feature, and use it as the dedup signal. has_transaction now reads it (so call sites still work in both configs); record_transaction inserts into it; Deserialize rebuilds it from `transactions` keys. Also adds an idempotency regression test (mempool→block on the same tx must not change utxo count or balance and must bump monitor_revision at most once) that runs in both feature configurations. Existing wallet_checker tests that simulated a missing record by removing from `transactions` directly are updated to clear `processed_txids` too (since the dedup signal now lives there). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8d4cfa7 commit 2d38624

3 files changed

Lines changed: 106 additions & 83 deletions

File tree

key-wallet/src/managed_account/mod.rs

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ pub struct ManagedCoreAccount {
6969
pub transactions: BTreeMap<Txid, TransactionRecord>,
7070
/// UTXO set for this account
7171
pub utxos: BTreeMap<OutPoint, Utxo>,
72+
/// Txids of every transaction this account has already processed.
73+
///
74+
/// Always populated regardless of `keep_txs_in_memory`, so the
75+
/// dedup guard in `confirm_transaction` works in either feature
76+
/// configuration. Rebuilt from `transactions` during deserialization
77+
/// when the feature is enabled.
78+
#[cfg_attr(feature = "serde", serde(skip_serializing))]
79+
pub(crate) processed_txids: HashSet<Txid>,
7280
/// Outpoints spent by recorded transactions.
7381
/// Rebuilt from `transactions` during deserialization.
7482
#[cfg_attr(feature = "serde", serde(skip_serializing))]
@@ -95,25 +103,20 @@ impl ManagedCoreAccount {
95103
#[cfg(feature = "keep_txs_in_memory")]
96104
transactions: BTreeMap::new(),
97105
utxos: BTreeMap::new(),
106+
processed_txids: HashSet::new(),
98107
spent_outpoints: HashSet::new(),
99108
monitor_revision: 0,
100109
}
101110
}
102111

103-
/// Returns `true` if a transaction record for `txid` is stored on this account.
112+
/// Returns `true` if this account has already processed `txid`.
104113
///
105-
/// Always returns `false` when the `keep_txs_in_memory` Cargo feature is
106-
/// disabled, since no records are kept.
114+
/// Backed by an always-present `processed_txids` set, so this works
115+
/// regardless of whether the `keep_txs_in_memory` Cargo feature is
116+
/// enabled. Use [`Self::get_transaction`] to retrieve the full record
117+
/// (only available when the feature is enabled).
107118
pub fn has_transaction(&self, txid: &Txid) -> bool {
108-
#[cfg(feature = "keep_txs_in_memory")]
109-
{
110-
self.transactions.contains_key(txid)
111-
}
112-
#[cfg(not(feature = "keep_txs_in_memory"))]
113-
{
114-
let _ = txid;
115-
false
116-
}
119+
self.processed_txids.contains(txid)
117120
}
118121

119122
/// Returns the stored transaction record for `txid`, if any.
@@ -463,49 +466,51 @@ impl ManagedCoreAccount {
463466
/// Re-process an existing transaction with updated context (e.g., mempool→block confirmation)
464467
/// and potentially new address matches from gap limit rescans.
465468
///
466-
/// When the `keep_txs_in_memory` Cargo feature is disabled, no per-tx
467-
/// records exist, so this always falls through to `record_transaction`
468-
/// and reports the call as a state change.
469+
/// Deduplication uses `processed_txids`, which is populated regardless
470+
/// of the `keep_txs_in_memory` Cargo feature. When the feature is off
471+
/// the per-tx record isn't stored, so we can't detect a confirmation
472+
/// status transition; we still refresh the UTXO state and report no
473+
/// change.
469474
pub(crate) fn confirm_transaction(
470475
&mut self,
471476
tx: &Transaction,
472477
account_match: &AccountMatch,
473478
context: TransactionContext,
474479
transaction_type: TransactionType,
475480
) -> bool {
481+
if !self.processed_txids.contains(&tx.txid()) {
482+
self.record_transaction(tx, account_match, context, transaction_type);
483+
return true;
484+
}
485+
486+
#[cfg_attr(not(feature = "keep_txs_in_memory"), allow(unused_mut))]
487+
let mut changed = false;
476488
#[cfg(feature = "keep_txs_in_memory")]
477-
{
478-
if !self.transactions.contains_key(&tx.txid()) {
479-
self.record_transaction(tx, account_match, context, transaction_type);
480-
return true;
481-
}
482-
483-
let mut changed = false;
484-
if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) {
485-
debug_assert_eq!(
486-
tx_record.transaction_type,
487-
transaction_type,
488-
"transaction_type changed between recordings for {}",
489-
tx.txid()
490-
);
491-
if tx_record.context != context {
492-
let was_confirmed = tx_record.context.confirmed();
493-
tx_record.update_context(context.clone());
494-
// Only signal a change when confirmation status actually changes,
495-
// not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock).
496-
// TODO: emit a change event for InBlock → InChainLockedBlock once chainlock
497-
// wallet transaction events are properly handled
498-
changed = !was_confirmed;
499-
}
489+
if let Some(tx_record) = self.transactions.get_mut(&tx.txid()) {
490+
debug_assert_eq!(
491+
tx_record.transaction_type,
492+
transaction_type,
493+
"transaction_type changed between recordings for {}",
494+
tx.txid()
495+
);
496+
if tx_record.context != context {
497+
let was_confirmed = tx_record.context.confirmed();
498+
tx_record.update_context(context.clone());
499+
// Only signal a change when confirmation status actually changes,
500+
// not for upgrades within the confirmed state (e.g. InBlock → InChainLockedBlock).
501+
// TODO: emit a change event for InBlock → InChainLockedBlock once chainlock
502+
// wallet transaction events are properly handled
503+
changed = !was_confirmed;
500504
}
501-
self.update_utxos(tx, account_match, context);
502-
changed
503505
}
504506
#[cfg(not(feature = "keep_txs_in_memory"))]
505507
{
506-
self.record_transaction(tx, account_match, context, transaction_type);
507-
true
508+
// No record stored — silence the unused-binding warning on
509+
// `transaction_type` and let UTXO state convey the upgrade.
510+
let _ = transaction_type;
508511
}
512+
self.update_utxos(tx, account_match, context);
513+
changed
509514
}
510515

511516
/// Record a new transaction and update UTXOs for spendable account types
@@ -610,6 +615,7 @@ impl ManagedCoreAccount {
610615
);
611616

612617
let record = tx_record.clone();
618+
self.processed_txids.insert(tx.txid());
613619
#[cfg(feature = "keep_txs_in_memory")]
614620
self.transactions.insert(tx.txid(), tx_record);
615621

@@ -1412,6 +1418,7 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount {
14121418

14131419
let helper = Helper::deserialize(deserializer)?;
14141420

1421+
let processed_txids: HashSet<Txid> = helper.transactions.keys().copied().collect();
14151422
let spent_outpoints = helper
14161423
.transactions
14171424
.values()
@@ -1428,6 +1435,7 @@ impl<'de> Deserialize<'de> for ManagedCoreAccount {
14281435
#[cfg(feature = "keep_txs_in_memory")]
14291436
transactions: helper.transactions,
14301437
utxos: helper.utxos,
1438+
processed_txids,
14311439
spent_outpoints,
14321440
monitor_revision: 0,
14331441
})

key-wallet/src/tests/keep_txs_in_memory_tests.rs

Lines changed: 50 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
//! When the feature is enabled (the default), `ManagedCoreAccount` keeps a
44
//! per-account `transactions` map populated as transactions are processed.
55
//! When the feature is disabled, the field does not exist and processing a
6-
//! transaction only updates UTXOs and balance.
6+
//! transaction only updates UTXOs and balance — but `processed_txids`
7+
//! still tracks which txids have been seen so `confirm_transaction`
8+
//! continues to deduplicate re-events.
79
810
use crate::test_utils::TestWalletContext;
9-
use crate::transaction_checking::TransactionContext;
10-
use dashcore::Transaction;
11+
use crate::transaction_checking::{BlockInfo, TransactionContext};
12+
use dashcore::{BlockHash, Transaction};
13+
use dashcore_hashes::Hash;
1114

1215
#[tokio::test]
1316
async fn record_transaction_updates_utxos_and_balance() {
@@ -45,47 +48,54 @@ async fn transactions_are_not_stored_when_feature_disabled() {
4548
let _ = ctx.check_transaction(&tx, TransactionContext::Mempool).await;
4649

4750
let account = ctx.bip44_account();
48-
assert!(!account.has_transaction(&tx.txid()));
51+
// No record is stored, so `get_transaction` returns None ...
4952
assert!(account.get_transaction(&tx.txid()).is_none());
53+
// ... but `has_transaction` still returns true so re-events dedupe.
54+
assert!(account.has_transaction(&tx.txid()));
5055
}
5156

52-
#[cfg(feature = "keep_txs_in_memory")]
53-
#[test]
54-
fn helper_methods_proxy_transactions_field() {
55-
use crate::account::{StandardAccountType, TransactionRecord};
56-
use crate::managed_account::transaction_record::TransactionDirection;
57-
use crate::managed_account::ManagedCoreAccount;
58-
use crate::transaction_checking::TransactionType;
59-
use crate::AccountType;
60-
use dashcore::TxIn;
57+
/// Re-processing the same transaction (mempool → block) must not double-count
58+
/// UTXOs or change balance, regardless of whether the `keep_txs_in_memory`
59+
/// feature is enabled. Without the always-present `processed_txids` set this
60+
/// regresses when the feature is off because `confirm_transaction`'s only
61+
/// dedup signal disappears with the `transactions` map.
62+
#[tokio::test]
63+
async fn confirm_transaction_dedupes_repeat_events() {
64+
let mut ctx = TestWalletContext::new_random();
6165

62-
let mut account = ManagedCoreAccount::dummy_bip44();
63-
let tx = Transaction {
64-
version: 1,
65-
lock_time: 0,
66-
input: vec![TxIn::default()],
67-
output: Vec::new(),
68-
special_transaction_payload: None,
69-
};
70-
let record = TransactionRecord::new(
71-
tx.clone(),
72-
AccountType::Standard {
73-
index: 0,
74-
standard_account_type: StandardAccountType::BIP44Account,
75-
},
76-
TransactionContext::Mempool,
77-
TransactionType::Standard,
78-
TransactionDirection::Incoming,
79-
Vec::new(),
80-
Vec::new(),
81-
0,
82-
);
83-
let txid = tx.txid();
66+
let tx = Transaction::dummy(&ctx.receive_address, 0..1, &[200_000]);
67+
68+
// First: mempool
69+
let r1 = ctx.check_transaction(&tx, TransactionContext::Mempool).await;
70+
assert!(r1.is_relevant);
71+
assert!(r1.is_new_transaction);
72+
let utxo_count_after_mempool = ctx.bip44_account().utxos.len();
73+
let balance_after_mempool = ctx.bip44_account().balance.spendable();
74+
let revision_after_mempool = ctx.bip44_account().monitor_revision();
8475

85-
assert!(!account.has_transaction(&txid));
86-
assert!(account.get_transaction(&txid).is_none());
76+
// Second: same tx confirmed in a block
77+
let block = TransactionContext::InBlock(BlockInfo::new(
78+
100,
79+
BlockHash::from_slice(&[7u8; 32]).unwrap(),
80+
1_700_000_000,
81+
));
82+
let r2 = ctx.check_transaction(&tx, block).await;
83+
assert!(r2.is_relevant);
84+
assert!(!r2.is_new_transaction, "block confirmation must not be flagged as a new tx");
8785

88-
account.transactions.insert(txid, record);
89-
assert!(account.has_transaction(&txid));
90-
assert!(account.get_transaction(&txid).is_some());
86+
let account = ctx.bip44_account();
87+
assert_eq!(
88+
account.utxos.len(),
89+
utxo_count_after_mempool,
90+
"UTXO count must not grow on confirmation"
91+
);
92+
assert_eq!(
93+
account.balance.spendable(),
94+
balance_after_mempool,
95+
"spendable balance must not change between mempool and block confirmation"
96+
);
97+
// monitor_revision can advance once for the block-context UTXO refresh,
98+
// but it must not balloon to N per replay.
99+
let bumped_by = account.monitor_revision().saturating_sub(revision_after_mempool);
100+
assert!(bumped_by <= 1, "monitor_revision bumped {bumped_by} times on a single confirmation");
91101
}

key-wallet/src/transaction_checking/wallet_checker.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1139,13 +1139,17 @@ mod tests {
11391139
let (mut ctx, tx) = TestWalletContext::new_random().with_mempool_funding(300_000).await;
11401140
let txid = tx.txid();
11411141

1142-
// Simulate the account missing the mempool record by removing it
1142+
// Simulate the account missing the mempool record by removing it.
1143+
// Clear `processed_txids` too — otherwise the always-present dedup
1144+
// set would keep `has_transaction` truthy and the wallet checker
1145+
// wouldn't treat this confirmation as new.
11431146
let account = ctx
11441147
.managed_wallet
11451148
.first_bip44_managed_account_mut()
11461149
.expect("Should have BIP44 account");
11471150
assert!(account.transactions.contains_key(&txid));
11481151
account.transactions.remove(&txid);
1152+
account.processed_txids.remove(&txid);
11491153
assert!(!account.transactions.contains_key(&txid));
11501154

11511155
// Now process the same tx as a block confirmation.
@@ -1195,6 +1199,7 @@ mod tests {
11951199
.first_bip44_managed_account_mut()
11961200
.expect("Should have BIP44 account");
11971201
account.transactions.remove(&txid);
1202+
account.processed_txids.remove(&txid);
11981203
account.utxos.clear();
11991204
assert!(!account.transactions.contains_key(&txid));
12001205
assert!(account.utxos.is_empty());

0 commit comments

Comments
 (0)