|
| 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 | +} |
0 commit comments