Skip to content

Commit c24912c

Browse files
lklimekclaude
andcommitted
test(key-wallet,dash-spv): crate-level bug-pin repros for #763, #764 and CoinJoin gap-limit discovery
Port the platform#3549 e2e findings into the affected crates as deterministic, network-free tests (RED now, GREEN once fixed): - key-wallet/tests/instant_lock_context_promotion.rs — #763: TransactionRecord::update_context drops the InstantLock on IS->InBlock promotion. RED (assertion: lock gone after promotion). - key-wallet/tests/asset_lock_builder_failed_build.rs — #764: TransactionBuilder::set_funding eagerly reserves a change address, so monitor_revision advances 0->1 across a FAILED build_asset_lock, violating the documented no-consumption-on-failure contract. RED. - dash-spv/src/sync/filters/coinjoin_gap_discovery_tests.rs — CoinJoin gap-limit discovery through the real FiltersManager/BlockMatchTracker/ WalletManager pipeline: * dense same-batch shape (the e2e stall-at-59): GREEN — fixed by #820, kept as a regression guard; * gap-window output in an earlier block of the same active batch: GREEN — recovered by the #820 commit-time fixpoint rescan; * gap-window output in an already-COMMITTED batch: RED — new-script rescans never re-open committed ranges (rescan_batch only reaches active_batches; commit prunes tracker records and advances the wallet's synced_height), so the funded indices stay invisible even though they are inside the gap-limit recovery contract and watched. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BqMDY7ZZNwSvdPgBrJQQNq
1 parent b0a909c commit c24912c

4 files changed

Lines changed: 591 additions & 0 deletions

File tree

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
//! CoinJoin gap-limit discovery repros against the real filter → block →
2+
//! wallet pipeline (`FiltersManager` + `BlockMatchTracker` + a real
3+
//! `WalletManager<ManagedWalletInfo>` with a CoinJoin account).
4+
//!
5+
//! Background: a wallet with dense CoinJoin usage (dozens of consecutive
6+
//! external indices funded per block) historically stalled at
7+
//! `highest_used = 59` (initial 30-address watch window + one gap-limit
8+
//! extension) because a matched block was applied to a wallet exactly once —
9+
//! the `BlockMatchTracker` gate was keyed by `(wallet, block)`, not
10+
//! `(wallet, address)` — so outputs paying addresses derived *from that same
11+
//! block's matches* were never re-tested. PR #820 fixed the in-flight part:
12+
//! `rescan_batch` now re-queues already-processed blocks of ACTIVE batches
13+
//! against newly derived scripts, to a fixpoint.
14+
//!
15+
//! These tests pin both the fixed behaviour and the remaining hole:
16+
//!
17+
//! 1. [`coinjoin_gap_limit_dense_same_batch_recovers`] — the empirical
18+
//! stall-at-59 shape (two dense blocks in one batch). GREEN since #820;
19+
//! kept as a regression guard.
20+
//! 2. [`coinjoin_gap_limit_inversion_within_batch_recovers`] — a gap-window
21+
//! output in an EARLIER block of the SAME (still-active) batch is
22+
//! recovered by the commit-time rescan. GREEN since #820.
23+
//! 3. [`coinjoin_gap_limit_stall_across_committed_batch`] — the SAME shape
24+
//! with the earlier block in an already-COMMITTED batch is never
25+
//! recovered: rescans only reach `active_batches`, and committed batches
26+
//! are gone. RED — the residual `(wallet, block-progress)` vs
27+
//! `(wallet, address)` keying defect.
28+
//!
29+
//! Each test drives the manager exactly the way the production event loop
30+
//! does: `try_process_batch` → `BlocksNeeded` → (blocks-manager stand-in)
31+
//! `WalletInterface::process_block_for_wallets` → `BlockProcessed` (with the
32+
//! wallet's freshly derived scripts) → `handle_sync_event` → commit-time
33+
//! rescan, iterated to quiescence. Deterministic: fixed mnemonic, synthetic
34+
//! blocks, real BIP-158 filters, no network.
35+
36+
use super::*;
37+
use crate::storage::{
38+
DiskStorageManager, PersistentBlockHeaderStorage, PersistentFilterHeaderStorage,
39+
PersistentFilterStorage, StorageManager,
40+
};
41+
use dashcore::{
42+
Address, Block, BlockHash, Network, OutPoint, Transaction, TxIn, TxOut, Txid, Witness,
43+
};
44+
use key_wallet::account::ManagedAccountTrait;
45+
use key_wallet::gap_limit::DEFAULT_COINJOIN_GAP_LIMIT;
46+
use key_wallet::managed_account::address_pool::{AddressPool, AddressPoolType, KeySource};
47+
use key_wallet::wallet::initialization::WalletAccountCreationOptions;
48+
use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo;
49+
use key_wallet_manager::WalletManager;
50+
use tokio::sync::mpsc::unbounded_channel;
51+
52+
/// Deterministic test wallet seed (standard BIP-39 test vector mnemonic).
53+
const TEST_MNEMONIC: &str =
54+
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
55+
56+
/// CoinJoin-denomination-shaped output value (0.001 DASH + per-round fee).
57+
/// The matcher is purely script-based; the value is for realism only.
58+
const DENOMINATION: u64 = 100_001;
59+
60+
type CjFiltersManager = FiltersManager<
61+
PersistentBlockHeaderStorage,
62+
PersistentFilterHeaderStorage,
63+
PersistentFilterStorage,
64+
WalletManager<ManagedWalletInfo>,
65+
>;
66+
67+
/// Real wallet manager with one seed wallet (Default accounts incl. CoinJoin
68+
/// account 0, external pool pre-generated to `DEFAULT_COINJOIN_GAP_LIMIT`),
69+
/// wired into a `FiltersManager` over temp-dir storage.
70+
async fn setup() -> (CjFiltersManager, Arc<RwLock<WalletManager<ManagedWalletInfo>>>, WalletId) {
71+
let mut wm = WalletManager::<ManagedWalletInfo>::new(Network::Regtest);
72+
let wallet_id = wm
73+
.create_wallet_from_mnemonic(TEST_MNEMONIC, 0, WalletAccountCreationOptions::Default)
74+
.expect("create deterministic test wallet");
75+
let wallet = Arc::new(RwLock::new(wm));
76+
77+
let storage = DiskStorageManager::with_temp_dir().await.unwrap();
78+
let mut manager = FiltersManager::new(
79+
Arc::clone(&wallet),
80+
storage.block_headers(),
81+
storage.filter_headers(),
82+
storage.filters(),
83+
)
84+
.await;
85+
manager.set_state(SyncState::Syncing);
86+
(manager, wallet, wallet_id)
87+
}
88+
89+
/// Derive the wallet's first `count` CoinJoin-account-0 External addresses
90+
/// OFFLINE (via a standalone pool over the same xpub + base path), so test
91+
/// blocks can pay indices far beyond the wallet's live watch window without
92+
/// touching the wallet's own state.
93+
async fn coinjoin_external_addresses(
94+
wallet: &Arc<RwLock<WalletManager<ManagedWalletInfo>>>,
95+
wallet_id: &WalletId,
96+
count: u32,
97+
) -> Vec<Address> {
98+
let wm = wallet.read().await;
99+
let signing_wallet = wm.get_wallet(wallet_id).expect("wallet present");
100+
let xpub = signing_wallet
101+
.accounts
102+
.coinjoin_accounts
103+
.get(&0)
104+
.expect("CoinJoin account 0 (Default options)")
105+
.account_xpub;
106+
let info = wm.get_wallet_info(wallet_id).expect("wallet info present");
107+
let live_pool = coinjoin_external_pool(info);
108+
let mut pool = AddressPool::new(
109+
live_pool.base_path.clone(),
110+
AddressPoolType::External,
111+
count,
112+
Network::Regtest,
113+
&KeySource::Public(xpub),
114+
)
115+
.expect("derive standalone CoinJoin External pool");
116+
// `AddressPool::new` pre-generates `count` addresses; extend defensively
117+
// in case the constructor semantics change.
118+
if pool.highest_generated.map(|h| h + 1).unwrap_or(0) < count {
119+
let missing = count - pool.highest_generated.map(|h| h + 1).unwrap_or(0);
120+
pool.generate_addresses(missing, &KeySource::Public(xpub), true)
121+
.expect("extend standalone pool");
122+
}
123+
(0..count).map(|i| pool.address_at_index(i).expect("address generated")).collect()
124+
}
125+
126+
/// The wallet's LIVE CoinJoin-account-0 External pool.
127+
fn coinjoin_external_pool(info: &ManagedWalletInfo) -> &AddressPool {
128+
let account =
129+
info.accounts.coinjoin_accounts.get(&0).expect("CoinJoin account 0 (Default options)");
130+
account
131+
.managed_account_type()
132+
.address_pools()
133+
.into_iter()
134+
.find(|p| p.pool_type == AddressPoolType::External)
135+
.expect("CoinJoin External pool")
136+
}
137+
138+
/// `(highest_used, highest_generated, used_count)` of the live CoinJoin
139+
/// External pool — the discovery frontier the assertions pin.
140+
async fn coinjoin_pool_state(
141+
wallet: &Arc<RwLock<WalletManager<ManagedWalletInfo>>>,
142+
wallet_id: &WalletId,
143+
) -> (Option<u32>, Option<u32>, usize) {
144+
let wm = wallet.read().await;
145+
let info = wm.get_wallet_info(wallet_id).expect("wallet info present");
146+
let pool = coinjoin_external_pool(info);
147+
(pool.highest_used, pool.highest_generated, pool.used_indices.len())
148+
}
149+
150+
/// One block at `height` whose single transaction pays every address in
151+
/// `addresses` one denomination output. Returns the block, its real BIP-158
152+
/// filter, and the filter match key.
153+
fn block_paying(height: u32, addresses: &[Address]) -> (Block, BlockFilter, FilterMatchKey) {
154+
let mut prev_txid = [0xABu8; 32];
155+
prev_txid[..4].copy_from_slice(&height.to_le_bytes());
156+
let tx = Transaction {
157+
version: 1,
158+
lock_time: 0,
159+
// One non-null input so the tx is not coinbase-shaped.
160+
input: vec![TxIn {
161+
previous_output: OutPoint::new(Txid::from(prev_txid), 0),
162+
script_sig: ScriptBuf::new(),
163+
sequence: 0xffffffff,
164+
witness: Witness::new(),
165+
}],
166+
output: addresses
167+
.iter()
168+
.map(|a| TxOut {
169+
value: DENOMINATION,
170+
script_pubkey: a.script_pubkey(),
171+
})
172+
.collect(),
173+
special_transaction_payload: None,
174+
};
175+
let block = Block::dummy(height, vec![tx]);
176+
let filter = BlockFilter::dummy(&block);
177+
let key = FilterMatchKey::new(height, block.block_hash());
178+
(block, filter, key)
179+
}
180+
181+
/// Drive the production event loop to quiescence: consume `BlocksNeeded` by
182+
/// processing the named blocks through the real wallet (the blocks-manager's
183+
/// job), feed the resulting `BlockProcessed` events (carrying the wallet's
184+
/// freshly derived scripts) back into the filters manager, and repeat until
185+
/// no more blocks are requested. Blocks are processed in height order, like
186+
/// the real `BlocksPipeline`.
187+
async fn drive_to_quiescence(
188+
manager: &mut CjFiltersManager,
189+
wallet: &Arc<RwLock<WalletManager<ManagedWalletInfo>>>,
190+
blocks: &HashMap<BlockHash, Block>,
191+
initial_events: Vec<SyncEvent>,
192+
) {
193+
let (tx, _rx) = unbounded_channel();
194+
let requests = RequestSender::new(tx);
195+
196+
let mut events = initial_events;
197+
for _round in 0..64 {
198+
let mut pending: BTreeMap<(u32, BlockHash), BTreeSet<WalletId>> = BTreeMap::new();
199+
for event in events.drain(..) {
200+
if let SyncEvent::BlocksNeeded {
201+
blocks: needed,
202+
} = event
203+
{
204+
for (key, wallets) in needed {
205+
pending.entry((key.height(), *key.hash())).or_default().extend(wallets);
206+
}
207+
}
208+
}
209+
if pending.is_empty() {
210+
return;
211+
}
212+
213+
let mut next_events = Vec::new();
214+
for ((height, block_hash), wallets) in pending {
215+
let block = blocks.get(&block_hash).expect("BlocksNeeded for an unknown test block");
216+
let result = wallet
217+
.write()
218+
.await
219+
.process_block_for_wallets(block, block_hash, height, &wallets)
220+
.await;
221+
let confirmed_txids = result.relevant_txids().cloned().collect();
222+
let event = SyncEvent::BlockProcessed {
223+
block_hash,
224+
height,
225+
wallets,
226+
new_scripts: result.new_scripts,
227+
confirmed_txids,
228+
};
229+
next_events.extend(
230+
manager.handle_sync_event(&event, &requests).await.expect("BlockProcessed"),
231+
);
232+
}
233+
events = next_events;
234+
}
235+
panic!("drive_to_quiescence did not settle within 64 rounds — runaway rescan loop?");
236+
}
237+
238+
/// The empirical stall-at-59 shape: block A funds CoinJoin External indices
239+
/// 0..=51, the NEXT block funds 52..=139, both inside one active batch.
240+
/// Before PR #820 the `(wallet, block)` gate stopped discovery at index 59
241+
/// (29 initial + one 30-wide gap extension); the commit-time fixpoint rescan
242+
/// now climbs the whole dense range. Regression guard for #820.
243+
#[tokio::test]
244+
async fn coinjoin_gap_limit_dense_same_batch_recovers() {
245+
let (mut manager, wallet, wallet_id) = setup().await;
246+
let addresses = coinjoin_external_addresses(&wallet, &wallet_id, 140).await;
247+
248+
let (highest_used, highest_generated, _) = coinjoin_pool_state(&wallet, &wallet_id).await;
249+
assert_eq!(highest_used, None, "fresh wallet must have no used CoinJoin indices");
250+
assert_eq!(
251+
highest_generated,
252+
Some(DEFAULT_COINJOIN_GAP_LIMIT - 1),
253+
"fresh wallet must watch exactly the initial CoinJoin gap window"
254+
);
255+
256+
let (block_a, filter_a, key_a) = block_paying(10, &addresses[0..=51]);
257+
let (block_b, filter_b, key_b) = block_paying(11, &addresses[52..=139]);
258+
let blocks: HashMap<BlockHash, Block> =
259+
HashMap::from([(block_a.block_hash(), block_a), (block_b.block_hash(), block_b)]);
260+
261+
let mut batch = FiltersBatch::new(0, 99, HashMap::from([(key_a, filter_a), (key_b, filter_b)]));
262+
batch.mark_verified();
263+
manager.active_batches.insert(0, batch);
264+
manager.progress.update_stored_height(99);
265+
266+
let initial_events = manager.try_process_batch().await.unwrap();
267+
drive_to_quiescence(&mut manager, &wallet, &blocks, initial_events).await;
268+
269+
let (highest_used, _, used_count) = coinjoin_pool_state(&wallet, &wallet_id).await;
270+
assert_eq!(
271+
highest_used,
272+
Some(139),
273+
"dense same-batch CoinJoin range must be fully discovered via the commit-time \
274+
fixpoint rescan (PR #820); a stall at Some(59) means the (wallet, block) \
275+
once-per-block gate regressed. used_count={used_count}"
276+
);
277+
assert_eq!(used_count, 140, "every funded index 0..=139 must be marked used");
278+
}
279+
280+
/// Height inversion INSIDE one active batch: indices 40..=51 are funded at
281+
/// height 10, the in-window indices 0..=29 only at height 20. The initial
282+
/// scan cannot see block A (nothing watched matches it), but once block B's
283+
/// processing derives 30..=59 the commit-time rescan re-tests block A's
284+
/// filter and recovers it. GREEN since #820 — contrast for the RED
285+
/// cross-batch test below, isolating the commit boundary as the broken seam.
286+
#[tokio::test]
287+
async fn coinjoin_gap_limit_inversion_within_batch_recovers() {
288+
let (mut manager, wallet, wallet_id) = setup().await;
289+
let addresses = coinjoin_external_addresses(&wallet, &wallet_id, 60).await;
290+
291+
let (block_a, filter_a, key_a) = block_paying(10, &addresses[40..=51]);
292+
let (block_b, filter_b, key_b) = block_paying(20, &addresses[0..=29]);
293+
let blocks: HashMap<BlockHash, Block> =
294+
HashMap::from([(block_a.block_hash(), block_a), (block_b.block_hash(), block_b)]);
295+
296+
let mut batch = FiltersBatch::new(0, 99, HashMap::from([(key_a, filter_a), (key_b, filter_b)]));
297+
batch.mark_verified();
298+
manager.active_batches.insert(0, batch);
299+
manager.progress.update_stored_height(99);
300+
301+
let initial_events = manager.try_process_batch().await.unwrap();
302+
drive_to_quiescence(&mut manager, &wallet, &blocks, initial_events).await;
303+
304+
let (highest_used, _, used_count) = coinjoin_pool_state(&wallet, &wallet_id).await;
305+
assert_eq!(
306+
highest_used,
307+
Some(51),
308+
"a gap-window output in an earlier block of the SAME active batch must be \
309+
recovered by the commit-time rescan (PR #820). used_count={used_count}"
310+
);
311+
}
312+
313+
/// (RED-by-design): gap-window outputs in an already-COMMITTED batch are
314+
/// never recovered — the new-script rescan is keyed by batch/commit progress
315+
/// (`(wallet, block)` lineage), not `(wallet, address)`.
316+
///
317+
/// Same funding shape as the within-batch inversion test, but the early
318+
/// block (indices 40..=51, height 10) sits in batch 0..=99 while the
319+
/// in-window block (indices 0..=29) sits at height 110 in batch 100..=199.
320+
/// Batch 0 scans clean (nothing watched matches) and commits. Processing the
321+
/// height-110 block derives 30..=59, and those scripts DO match block 10's
322+
/// filter — but `rescan_batch` only reaches `active_batches`, and committed
323+
/// batches are gone (`try_commit_batches` removes them; the tracker prunes
324+
/// at-or-below the committed height). Indices 40..=51 — squarely inside the
325+
/// BIP-44/CoinJoin gap-limit recovery contract (40 < 29 + 1 + 30) — stay
326+
/// invisible forever, along with their funds. A fresh re-sync from genesis
327+
/// hits the same wall, so the funds are unrecoverable without a manual
328+
/// pre-derivation workaround.
329+
///
330+
/// GREEN once newly derived scripts can re-open committed ranges (track
331+
/// processed SCRIPTS per block / trigger a below-committed-height rescan for
332+
/// the owning wallet), at which point `highest_used` reaches 51.
333+
#[tokio::test]
334+
async fn coinjoin_gap_limit_stall_across_committed_batch() {
335+
let (mut manager, wallet, wallet_id) = setup().await;
336+
let addresses = coinjoin_external_addresses(&wallet, &wallet_id, 60).await;
337+
338+
let (block_a, filter_a, key_a) = block_paying(10, &addresses[40..=51]);
339+
let (block_b, filter_b, key_b) = block_paying(110, &addresses[0..=29]);
340+
let blocks: HashMap<BlockHash, Block> =
341+
HashMap::from([(block_a.block_hash(), block_a), (block_b.block_hash(), block_b)]);
342+
343+
let mut batch_0 = FiltersBatch::new(0, 99, HashMap::from([(key_a, filter_a)]));
344+
batch_0.mark_verified();
345+
manager.active_batches.insert(0, batch_0);
346+
let mut batch_1 = FiltersBatch::new(100, 199, HashMap::from([(key_b, filter_b)]));
347+
batch_1.mark_verified();
348+
manager.active_batches.insert(100, batch_1);
349+
manager.progress.update_stored_height(199);
350+
351+
let initial_events = manager.try_process_batch().await.unwrap();
352+
drive_to_quiescence(&mut manager, &wallet, &blocks, initial_events).await;
353+
354+
let (highest_used, highest_generated, used_count) =
355+
coinjoin_pool_state(&wallet, &wallet_id).await;
356+
// Sanity: the in-window block was found and the gap window extended past
357+
// index 51, so the missed indices ARE inside the watched range by now.
358+
assert!(
359+
highest_generated >= Some(51),
360+
"gap maintenance must have extended the watch window past index 51 \
361+
(got {highest_generated:?})"
362+
);
363+
assert_eq!(
364+
highest_used,
365+
Some(51),
366+
"CoinJoin External indices 40..=51 were funded at height 10 in a batch that \
367+
committed before their scripts were derived, and the new-script rescan never \
368+
looks below the committed boundary (rescan_batch only reaches active_batches; \
369+
BlockMatchTracker/commit pruning drops the range). The addresses are within \
370+
the gap-limit recovery contract and are watched now (highest_generated = \
371+
{highest_generated:?}), yet their outputs stay invisible: highest_used stalls \
372+
at {highest_used:?}, used_count={used_count}. Fix direction: key re-scan \
373+
suppression by (wallet, address/script) instead of block/commit progress, or \
374+
trigger a below-committed-height rescan for a wallet whose gap maintenance \
375+
derives scripts mid-sync."
376+
);
377+
}

dash-spv/src/sync/filters/manager.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -931,6 +931,10 @@ impl<H: BlockHeaderStorage, FH: FilterHeaderStorage, F: FilterStorage, W: Wallet
931931
f.debug_struct("FiltersManager").field("progress", &self.progress).finish()
932932
}
933933
}
934+
#[cfg(test)]
935+
#[path = "coinjoin_gap_discovery_tests.rs"]
936+
mod coinjoin_gap_discovery_tests;
937+
934938
#[cfg(test)]
935939
mod tests {
936940
use super::*;

0 commit comments

Comments
 (0)