Skip to content

Commit eb5afa2

Browse files
refactor(key-wallet): align finalization with chainlock-only semantics
Address xdustinface review feedback on PR #733: - Drop `TransactionContext::is_finalized()` (the soft IS-or-chainlock check). The wallet now treats only a chainlock as finality. - Rename `TransactionContext::is_finalized_in_block()` to `is_chain_locked()` so the predicate name describes the variant rather than overloading "finalized" — same naming style as the existing `is_instant_send()` helper. - Drop `ManagedAccountTrait::transaction_is_finalized()` (the unused soft-finality variant) and rename `transaction_is_finalized_in_block()` to `transaction_is_finalized()`. Now that there's a single finalization concept, the trait method name aligns with the feature name. - Replace runtime `if cfg!(...) { ... } else { ... }` branches in `ManagedCoreKeysAccount::has_transaction` and `transaction_is_finalized` with two `#[cfg]`-gated function bodies — one per feature configuration. Same for the chainlock-driven drop call in `confirm_transaction` / `record_transaction`: the `let drop_now = …` binding now only exists when the feature is off, removing the `#[allow(unused_variables)]` workaround. - Rewrite the doc on `has_transaction` so it's clear what it actually reports (live record OR finalized-txid marker) and how callers use it (distinguish brand-new sightings from re-processings) instead of the misleading "dedup signal in `confirm_transaction`" wording. Drop logic still triggers on the strict `is_chain_locked()` check — IS-locked-but-not-yet-chainlocked records still survive so the surrounding block-confirmation event can populate height / block hash before the chainlock catches up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 34c47eb commit eb5afa2

6 files changed

Lines changed: 67 additions & 105 deletions

File tree

key-wallet/src/managed_account/managed_account_trait.rs

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -45,33 +45,24 @@ pub trait ManagedAccountTrait {
4545
fn transactions_mut(&mut self) -> &mut BTreeMap<Txid, TransactionRecord>;
4646

4747
/// Returns `true` if this account has already processed `txid`,
48-
/// whether it's still mutable in `transactions` or has been
49-
/// finalized-and-pruned (under the default feature configuration).
50-
/// Used as the dedup signal in `confirm_transaction`.
48+
/// whether it is still represented as a full record in `transactions`
49+
/// or has been pruned (under the default feature configuration) and
50+
/// is now only retained as a finalized-txid marker. Used by callers
51+
/// that need to distinguish a brand-new sighting from a re-processing
52+
/// (mempool → block, IS-lock arrival, chainlock, …).
5153
fn has_transaction(&self, txid: &Txid) -> bool;
5254

53-
/// Returns `true` if `txid` has reached a finalized state — i.e. it
54-
/// has either an InstantSend lock or a ChainLock and is no longer
55-
/// expected to change.
56-
///
57-
/// This is the *soft* finality check (mirrors
58-
/// [`crate::transaction_checking::TransactionContext::is_finalized`]).
59-
/// Use [`Self::transaction_is_finalized_in_block`] for the stricter
60-
/// "fully confirmed in a chainlocked block" answer that drives
61-
/// memory-pruning decisions.
62-
fn transaction_is_finalized(&self, txid: &Txid) -> bool;
63-
6455
/// Returns `true` if `txid` has been mined in a block that is itself
65-
/// chainlocked — the strongest finality signal (mirrors
66-
/// [`crate::transaction_checking::TransactionContext::is_finalized_in_block`]).
56+
/// chainlocked — the only finality signal we treat as terminal
57+
/// (mirrors [`crate::transaction_checking::TransactionContext::is_chain_locked`]).
6758
///
6859
/// `InBlock` alone is not enough (the block can still be reorganized
6960
/// out), and `InstantSend` alone is not enough either (the
7061
/// surrounding block confirmation may still arrive and write the
7162
/// height / block hash before the chainlock catches up). Only
7263
/// `InChainLockedBlock` qualifies. This is the trigger for dropping
7364
/// the full record under the default feature configuration.
74-
fn transaction_is_finalized_in_block(&self, txid: &Txid) -> bool;
65+
fn transaction_is_finalized(&self, txid: &Txid) -> bool;
7566

7667
/// Return the current monitor revision.
7768
///

key-wallet/src/managed_account/managed_core_funds_account.rs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -254,9 +254,9 @@ impl ManagedCoreFundsAccount {
254254
) -> Option<TransactionRecord> {
255255
let txid = tx.txid();
256256

257-
// Already finalized in a chainlocked block: the tx is immutable —
257+
// Already finalized via a chainlock: the tx is immutable —
258258
// no record update, no UTXO refresh, no event needed.
259-
if self.keys.transaction_is_finalized_in_block(&txid) {
259+
if self.keys.transaction_is_finalized(&txid) {
260260
return None;
261261
}
262262

@@ -299,8 +299,8 @@ impl ManagedCoreFundsAccount {
299299
// enough — we keep the record so the surrounding block
300300
// confirmation can still write its height / block hash before the
301301
// chainlock catches up.
302-
#[allow(unused_variables)]
303-
let drop_now = context.is_finalized_in_block();
302+
#[cfg(not(feature = "keep-finalized-transactions"))]
303+
let drop_now = context.is_chain_locked();
304304
self.update_utxos(tx, account_match, context);
305305
#[cfg(not(feature = "keep-finalized-transactions"))]
306306
if drop_now {
@@ -419,8 +419,8 @@ impl ManagedCoreFundsAccount {
419419
// a wallet rescan from storage), drop the full record now and
420420
// keep only the txid in `finalized_txids`. No-op when the
421421
// feature is on (we want to keep the full record).
422-
#[allow(unused_variables)]
423-
let drop_now = context.is_finalized_in_block();
422+
#[cfg(not(feature = "keep-finalized-transactions"))]
423+
let drop_now = context.is_chain_locked();
424424
self.update_utxos(tx, account_match, context);
425425
#[cfg(not(feature = "keep-finalized-transactions"))]
426426
if drop_now {
@@ -672,10 +672,6 @@ impl ManagedAccountTrait for ManagedCoreFundsAccount {
672672
self.keys.transaction_is_finalized(txid)
673673
}
674674

675-
fn transaction_is_finalized_in_block(&self, txid: &Txid) -> bool {
676-
self.keys.transaction_is_finalized_in_block(txid)
677-
}
678-
679675
fn monitor_revision(&self) -> u64 {
680676
self.keys.monitor_revision()
681677
}

key-wallet/src/managed_account/managed_core_keys_account.rs

Lines changed: 28 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,9 @@ impl ManagedCoreKeysAccount {
8888
/// is OFF (the default). Called when a transaction transitions into
8989
/// `InChainLockedBlock` — the record's information is no longer
9090
/// expected to change, so we save memory by replacing it with a
91-
/// txid-only entry. [`Self::has_transaction`] keeps reporting it as
92-
/// known, and [`Self::transaction_is_finalized_in_block`] keeps
91+
/// txid-only entry. [`ManagedAccountTrait::has_transaction`] keeps
92+
/// reporting it as known, and
93+
/// [`ManagedAccountTrait::transaction_is_finalized`] keeps
9394
/// returning `true`.
9495
///
9596
/// With the feature on the full record stays in `transactions`
@@ -181,47 +182,37 @@ impl ManagedAccountTrait for ManagedCoreKeysAccount {
181182
&mut self.transactions
182183
}
183184

185+
/// With the `keep-finalized-transactions` feature ON, every record
186+
/// we have ever processed stays in `transactions` — that map is the
187+
/// authoritative dedup set.
188+
#[cfg(feature = "keep-finalized-transactions")]
184189
fn has_transaction(&self, txid: &Txid) -> bool {
185-
if self.transactions.contains_key(txid) {
186-
return true;
187-
}
188-
// Under the default feature configuration, the record may have
189-
// been pruned; the txid stays in `finalized_txids`. With the
190-
// feature on, every record stays in `transactions` so the first
191-
// check above is exhaustive.
192-
#[cfg(not(feature = "keep-finalized-transactions"))]
193-
{
194-
return self.finalized_txids.contains(txid);
195-
}
196-
#[allow(unreachable_code)]
197-
false
190+
self.transactions.contains_key(txid)
191+
}
192+
193+
/// With the feature OFF (the default), chainlocked records are
194+
/// pruned from `transactions` and only their txids are retained in
195+
/// `finalized_txids`. Both sets need to be consulted.
196+
#[cfg(not(feature = "keep-finalized-transactions"))]
197+
fn has_transaction(&self, txid: &Txid) -> bool {
198+
self.transactions.contains_key(txid) || self.finalized_txids.contains(txid)
198199
}
199200

201+
/// With the feature ON, finalized records live in `transactions`,
202+
/// so we resolve the answer purely off the live record's context.
203+
#[cfg(feature = "keep-finalized-transactions")]
200204
fn transaction_is_finalized(&self, txid: &Txid) -> bool {
201-
if let Some(r) = self.transactions.get(txid) {
202-
return r.context.is_finalized();
203-
}
204-
// Record was pruned; only chainlocked txids ever land in
205-
// `finalized_txids`, and chainlocked counts as finalized.
206-
#[cfg(not(feature = "keep-finalized-transactions"))]
207-
{
208-
return self.finalized_txids.contains(txid);
209-
}
210-
#[allow(unreachable_code)]
211-
false
205+
self.transactions.get(txid).is_some_and(|r| r.context.is_chain_locked())
212206
}
213207

214-
fn transaction_is_finalized_in_block(&self, txid: &Txid) -> bool {
215-
if let Some(r) = self.transactions.get(txid) {
216-
return r.context.is_finalized_in_block();
217-
}
218-
// Same logic — `finalized_txids` only contains chainlocked txs.
219-
#[cfg(not(feature = "keep-finalized-transactions"))]
220-
{
221-
return self.finalized_txids.contains(txid);
222-
}
223-
#[allow(unreachable_code)]
224-
false
208+
/// With the feature OFF, chainlocked records are dropped from
209+
/// `transactions` and only their txids are retained in
210+
/// `finalized_txids`. A live record can never satisfy this check
211+
/// (it would have been pruned at the chainlock event), so the only
212+
/// `true` answer comes from the txid set.
213+
#[cfg(not(feature = "keep-finalized-transactions"))]
214+
fn transaction_is_finalized(&self, txid: &Txid) -> bool {
215+
self.finalized_txids.contains(txid)
225216
}
226217

227218
fn monitor_revision(&self) -> u64 {

key-wallet/src/tests/keep_finalized_transactions_tests.rs

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
//! for dedup. IS-locked-but-not-yet-chainlocked records still live in
1111
//! the map so we don't lose the block-confirmation event when it
1212
//! arrives.
13+
//!
14+
//! "Finalized" in this crate means *chainlocked* — see
15+
//! [`crate::transaction_checking::TransactionContext::is_chain_locked`].
16+
//! IS-lock alone is **not** finality.
1317
1418
use crate::{
1519
managed_account::managed_account_trait::ManagedAccountTrait,
@@ -35,7 +39,7 @@ async fn test_chainlocked_record_kept_when_feature_on() {
3539
assert!(ctx.bip44_account().has_transaction(&txid));
3640
assert!(ctx.bip44_account().transactions().contains_key(&txid));
3741

38-
// InBlock → record still there, finalized-in-block stays false
42+
// InBlock → record still there, finalization stays false
3943
let block_hash = BlockHash::from_slice(&[7u8; 32]).expect("hash");
4044
let _ = ctx
4145
.check_transaction(
@@ -44,7 +48,7 @@ async fn test_chainlocked_record_kept_when_feature_on() {
4448
)
4549
.await;
4650
assert!(ctx.bip44_account().has_transaction(&txid));
47-
assert!(!ctx.bip44_account().transaction_is_finalized_in_block(&txid));
51+
assert!(!ctx.bip44_account().transaction_is_finalized(&txid));
4852

4953
// InChainLockedBlock → record MUST still live in the map.
5054
let _ = ctx
@@ -55,7 +59,6 @@ async fn test_chainlocked_record_kept_when_feature_on() {
5559
.await;
5660
assert!(ctx.bip44_account().has_transaction(&txid));
5761
assert!(ctx.bip44_account().transaction_is_finalized(&txid));
58-
assert!(ctx.bip44_account().transaction_is_finalized_in_block(&txid));
5962
assert!(
6063
ctx.bip44_account().transactions().contains_key(&txid),
6164
"with the feature ON the record must stay in the map after chainlock"
@@ -78,7 +81,7 @@ async fn test_chainlocked_record_dropped_when_feature_off() {
7881
assert!(ctx.bip44_account().transactions().contains_key(&txid));
7982

8083
// InChainLockedBlock → record dropped, but `has_transaction` and
81-
// `transaction_is_finalized*` still report the tx via the txid set.
84+
// `transaction_is_finalized` still report the tx via the txid set.
8285
let block_hash = BlockHash::from_slice(&[7u8; 32]).expect("hash");
8386
let _ = ctx
8487
.check_transaction(
@@ -92,14 +95,13 @@ async fn test_chainlocked_record_dropped_when_feature_off() {
9295
);
9396
assert!(ctx.bip44_account().has_transaction(&txid));
9497
assert!(ctx.bip44_account().transaction_is_finalized(&txid));
95-
assert!(ctx.bip44_account().transaction_is_finalized_in_block(&txid));
9698
}
9799

98-
/// IS-lock alone is "soft" finalized but not "finalized in block". The
99-
/// record must NOT be dropped when feature is OFF because we still need
100-
/// the in-memory record to absorb the eventual block-confirmation
101-
/// event (height / block hash). This guards against the pre-review bug
102-
/// where dropping on IS-lock lost block-confirmation tracking.
100+
/// IS-lock is **not** finalization. The record must NOT be dropped when
101+
/// the feature is OFF because we still need the in-memory record to
102+
/// absorb the eventual block-confirmation event (height / block hash).
103+
/// This guards against the pre-review bug where dropping on IS-lock
104+
/// lost block-confirmation tracking.
103105
#[cfg(not(feature = "keep-finalized-transactions"))]
104106
#[tokio::test]
105107
async fn test_islocked_record_kept_when_feature_off() {
@@ -113,12 +115,8 @@ async fn test_islocked_record_kept_when_feature_off() {
113115

114116
assert!(ctx.bip44_account().has_transaction(&txid));
115117
assert!(
116-
ctx.bip44_account().transaction_is_finalized(&txid),
117-
"IS-lock counts as soft-finalized"
118-
);
119-
assert!(
120-
!ctx.bip44_account().transaction_is_finalized_in_block(&txid),
121-
"IS-lock is not the strict block-finalization the drop check uses"
118+
!ctx.bip44_account().transaction_is_finalized(&txid),
119+
"IS-lock alone is not finalization — only a chainlock counts"
122120
);
123121
assert!(
124122
ctx.bip44_account().transactions().contains_key(&txid),
@@ -129,8 +127,8 @@ async fn test_islocked_record_kept_when_feature_off() {
129127

130128
/// IS-lock first, then a chainlocked block: the record must drop only at
131129
/// the chainlock step. We also assert that the chainlock event still
132-
/// "lands" — `transaction_is_finalized_in_block` must report `true` when
133-
/// asked via the txid set.
130+
/// "lands" — `transaction_is_finalized` must report `true` when asked
131+
/// via the txid set.
134132
#[cfg(not(feature = "keep-finalized-transactions"))]
135133
#[tokio::test]
136134
async fn test_islocked_then_chainlocked_drops_at_chainlock() {
@@ -152,5 +150,5 @@ async fn test_islocked_then_chainlocked_drops_at_chainlock() {
152150
.await;
153151
assert!(!ctx.bip44_account().transactions().contains_key(&txid), "dropped at chainlock");
154152
assert!(ctx.bip44_account().has_transaction(&txid));
155-
assert!(ctx.bip44_account().transaction_is_finalized_in_block(&txid));
153+
assert!(ctx.bip44_account().transaction_is_finalized(&txid));
156154
}

key-wallet/src/transaction_checking/transaction_context.rs

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -73,30 +73,16 @@ impl TransactionContext {
7373
matches!(self, TransactionContext::InstantSend(_))
7474
}
7575

76-
/// Returns whether the transaction is in a "finalized" state — i.e. it
77-
/// has either an InstantSend lock or a ChainLock and is no longer
78-
/// expected to change state.
79-
///
80-
/// This is the *soft* finality check. It treats both IS-lock and
81-
/// ChainLock as final. Use [`Self::is_finalized_in_block`] for the
82-
/// stricter "fully confirmed in a chainlocked block" answer that
83-
/// drives memory-pruning decisions.
84-
pub fn is_finalized(&self) -> bool {
85-
matches!(
86-
self,
87-
TransactionContext::InstantSend(_) | TransactionContext::InChainLockedBlock(_)
88-
)
89-
}
90-
9176
/// Returns whether the transaction has been mined in a block that is
92-
/// itself chainlocked — the strongest finality signal we have.
77+
/// itself chainlocked — the strongest finality signal we have, and
78+
/// the only one we treat as truly "finalized".
9379
///
9480
/// `InBlock` alone is not enough (the block can still be reorganized
95-
/// out), and `InstantSend` alone is not enough either (the surrounding
96-
/// block confirmation may still arrive and write the height /
97-
/// block hash before the chainlock catches up). Only
81+
/// out), and `InstantSend` alone is not enough either (the
82+
/// surrounding block confirmation may still arrive and write the
83+
/// height / block hash before the chainlock catches up). Only
9884
/// `InChainLockedBlock` qualifies.
99-
pub fn is_finalized_in_block(&self) -> bool {
85+
pub fn is_chain_locked(&self) -> bool {
10086
matches!(self, TransactionContext::InChainLockedBlock(_))
10187
}
10288

key-wallet/src/transaction_checking/wallet_checker.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ impl WalletTransactionChecker for ManagedWalletInfo {
8888
}
8989
// Only accept IS transitions for unconfirmed transactions.
9090
// A chainlocked tx may have had its full record dropped
91-
// under the default feature config — `transaction_is_finalized_in_block`
91+
// under the default feature config — `transaction_is_finalized`
9292
// catches that case via `finalized_txids` and the in-map
9393
// record check covers `InBlock`.
9494
let already_confirmed = result.affected_accounts.iter().any(|am| {
@@ -97,7 +97,7 @@ impl WalletTransactionChecker for ManagedWalletInfo {
9797
else {
9898
return false;
9999
};
100-
if account.transaction_is_finalized_in_block(&txid) {
100+
if account.transaction_is_finalized(&txid) {
101101
return true;
102102
}
103103
account.transactions().get(&txid).is_some_and(|r| r.is_confirmed())

0 commit comments

Comments
 (0)