Skip to content

feat(dash-spv): adaptive gap-limit probe escalation for BIP44 discovery - #959

Open
PastaPastaPasta wants to merge 7 commits into
devfrom
feat/adaptive-gap-probe-discovery
Open

feat(dash-spv): adaptive gap-limit probe escalation for BIP44 discovery#959
PastaPastaPasta wants to merge 7 commits into
devfrom
feat/adaptive-gap-probe-discovery

Conversation

@PastaPastaPasta

@PastaPastaPasta PastaPastaPasta commented Aug 13, 2026

Copy link
Copy Markdown
Member

The problem

BIP44 discovery derives only gap_limit (30) addresses past the highest used index. A wallet whose on-chain usage skips more than 30 consecutive indices anywhere in its history silently loses everything past that hole: discovery concludes "no more history," sync completes normally, and the wallet under-reports funds and transactions with no error.

This is not hypothetical. A real mainnet wallet (dust/spam target, 297,967 used external addresses, 890,840 transactions) has 273 unused-index runs ≥ 30 in its usage space. Discovery stops at the first one — indices 2,371–2,424 — finding 8,076 of 890,840 transactions.

Why a bigger constant is the wrong fix

We validated the mechanism by experiment against that wallet (each a full mainnet sync from a checkpoint):

gap limit result
30 (default) freezes at highest_used = 2,370 — 8,076 txs
100 freezes at highest_used = 36,342 — exactly one index short of the wallet's single length-100 hole — 95,212 txs
101 complete: highest_used = 345,256, 890,837 txs

Every fixed constant K fails identically for the wallet with a K+1 hole, silently; and raising the default taxes every normal wallet with a permanently larger scan set.

The fix: adaptive probe escalation, terminated by chain evidence

Steady state stays gap-30. When discovery reaches a genuine fixpoint (a filter batch with wallet activity is fully chased and about to commit), the wallet probes: it temporarily widens the derivation window (100 → 300 → 1,000 = MAX_GAP_LIMIT), re-matches the newly derived scripts against the batch's (and later active batches') compact filters, and

  • any hit resumes the normal crawl — the frontier moves, the steady gap re-anchors, and the probe level resets;
  • no hit at rung 1,000 is terminal for that stall: the batch commits, and "done" now means "no usage within 1,000 indices of the frontier anywhere in the scanned chain" — an explicit, bounded claim decided by the chain itself, instead of a silent heuristic.

Normal wallets pay one cheap probe ladder at the true end of their history. Gap-riddled wallets self-heal at every hole without anyone choosing a number for them.

Builds on two earlier correctness fixes to the batch-commit flow included in this branch (full-set verification rescan before commit; commit held while any active batch has blocks in flight), which the probe ladder requires to define a sound fixpoint.

Validation

End-to-end (the acceptance test): full mainnet sync of the 890k-tx wallet with default gap-30 + this feature: highest_used = 345,256 (the wallet's true maximum used index), 890,837 transactions — byte-identical outcome to the gap-101 control run — crossing all 273 holes via 279 probe events, terminating on an explicit rung-1,000 probe. ~52 min wall vs ~12 min for the (overfitted) fixed-gap control; the overhead is bounded probe rescans, paid only at genuine stalls.

Tests (deterministic, real FiltersManager + WalletManager over synthetic chains):

  • dust_restore_crosses_gap_hole_of_54 — production's first hole shape, crossed at rung 100 with default gap
  • dust_restore_crosses_gap_hole_of_150_via_escalation — requires rung 300
  • dust_restore_terminates_after_last_payment — completion, exact final frontier, bounded pool growth (≤ max_used + 1,000 + slack)
  • pre-existing in-order + shuffled dust restores unchanged
  • cargo test -p dash-spv --lib --all-features: 551 passed; key-wallet / key-wallet-manager suites green; clippy -D warnings clean

Notes

  • Probe widening leaves the derived tail in the pool (pools only grow); bounded by MAX_GAP_LIMIT, documented.
  • Probe levels are in-memory scan state; a restart re-walks derive-nothing rungs cheaply.
  • Fixed during integration: collected-script draining previously only serviced the lowest batch, which could stall a chase running in a later active batch.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Wallet synchronization now adaptively discovers addresses beyond the usual gap limit.
    • Newly detected wallet activity can trigger additional address discovery during scans and rescans.
  • Bug Fixes
    • Improved detection of matching wallet filters across multiple scan batches.
    • More reliable synchronization when new addresses or confirmed transactions appear during processing.
    • Improved restoration and detection of wallets with previously missed activity, including dust-related cases.

PastaPastaPasta and others added 7 commits August 12, 2026 21:03
…re commit

A filter batch used to commit once the gap-limit chase's latest wave derived no new scripts. That signal is wrong twice over: scripts derived from blocks owned by other batches never enter this batch's collected set, and a wave whose visible transactions all pay already-derived indices (backfill - inevitable when dust waves are mined out of derivation order) derives nothing even though the batch still holds blocks paying indices past the window. Committing on it ends the chase early, and committed batches are never rescanned, so every transaction above the window at commit time is permanently lost and discovery flatlines: later activity pays underived addresses, filters stop matching, and the pool never extends again.

Observed in production on a mainnet wallet with 345,261 sequentially used addresses: discovery froze at address index 2,400 after ~500 blocks of activity and silently missed 880k+ transactions while sync ran to tip.

The manager now keeps a monotone script-derivation generation, bumped whenever block processing derives new scripts - including for blocks whose owning batch is already gone, whose scripts were previously dropped without ever being matched. Each batch records the generation it was last matched against the wallets' full script sets (initial scan or verification). At commit time, a batch whose recorded generation is stale is re-matched against the full current sets and may not complete its rescan while that verification still finds blocks. Quiet syncs never pay for this: with no derivations the generations match and the verification is skipped.

The regression test drives the real FiltersManager + WalletManager over a synthetic dust restore whose payments land out of derivation order across a batch boundary; without this fix it loses 36 of 3,000 transactions, with it discovery completes (as does the in-order control).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n flight

A block in flight for a later batch can still derive new scripts when it lands, and those scripts can match an earlier batch's filters. Sealing the earlier batch during that window raced the delivery - the same knowledge-behind-the-watermark loss the verification rescan prevents, through a narrower window. Commits now wait for global block quiescence, so a seal implies no undelivered derivations exist anywhere and the verification fixpoint argument has no gaps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…overy

BIP44 discovery derives only gap_limit (default 30) addresses past the highest used index, so a run of unused indices longer than the gap limit stalls discovery silently. A real mainnet wallet has 273 such runs of 30 or more (the longest exactly 100); plain gap-limit discovery freezes at the first one and severs the rest of the wallet's history. Raising the constant is the wrong fix: any fixed K is arbitrary, costs every normal wallet, and still fails for the first run longer than K.

This adds the wallet half of adaptive gap-probe escalation: AddressPool::probe_gap_limit derives the window a temporarily widened gap limit would require and then restores the steady-state limit without un-deriving (pools only grow; the probed tail stays monitored). ManagedAccountCollection::probe_extend_gap_with fans that out over every funds-bearing account's pools using the same key-source lookup the wallet checker uses for gap maintenance, and WalletInterface::probe_extend_gap (default: empty no-op) exposes it per wallet so a filter-sync driver can probe an escalating window against the chain's own compact filters and resume the chase past any hole it confirms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ommit

The commit-time verification rescan proves a batch has been matched against every script derived so far - but the derived set itself only reaches gap_limit past each pool's usage frontier. A run of unused indices longer than the gap limit therefore stalls discovery with every gate looking clean: the batch commits, committed batches are never rescanned, and all history past the hole is silently lost. The production wallet this was found on has 273 unused-index runs of 30 or more (the longest exactly 100) in 345k used addresses; gap-30 discovery froze at the first one and missed 890k of its transactions, and experiment confirmed gap limit 100 still freezes at the length-100 gap while 101 discovers everything. A bigger fixed gap is the wrong fix: arbitrary, paid by every wallet, and still beaten by the next longer run.

Instead the steady state stays gap-30 and the stall itself triggers a bounded probe against the chain's own filters. When a batch with wallet activity reaches its commit fixpoint (verification clean, no blocks in flight anywhere), the manager walks a per-wallet probe ladder of 100/300/1000: derive the widened window through the new WalletInterface::probe_extend_gap seam (which restores the steady-state gap before returning), re-match the freshly derived scripts against this batch and every later scanned batch, and resume the normal chase on any hit. A rung that derives nothing escalates immediately, ladder progress is monotone per wallet and resumes where it left off, and real progress (a confirmed transaction or a new derivation for the wallet) resets it - a moving frontier makes the next stall a new stall. Only when the maximum rung (MAX_GAP_LIMIT, the widest window a pool can derive) finds nothing does the batch commit: termination is now a positive verdict from the chain rather than a guess.

Commit-time collected-script rescans now also drain every scanned batch rather than only the lowest: a chase running inside a later batch used to stall with fresh scripts sitting unrescanned while an earlier batch was considered for commit, and the probe would fire on a lull that was never a wallet fixpoint.

The dust-restore harness gains configurable unused-index holes and verifies the ladder end to end: a hole of 54 (production's first gap) crossed by the first rung, a hole of 150 requiring escalation, and a two-hole terminal run proving the sync completes, the frontier lands exactly on the last used index, and pool growth stays bounded by frontier + MAX_GAP_LIMIT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Debug-build siphash is roughly 50x slower than optimized, and BIP158 compact-filter matching hashes every query element against every filter, so the dust-restore and gap-probe regression tests spent most of their wall time inside it. dashcore_hashes is a rarely edited leaf crate: opting it into opt-level 2 for dev costs one slightly longer cold build and speeds every filter- and hash-heavy test severalfold.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eness

Review follow-up: note that probe_levels is in-memory scan state (a restart cheaply re-walks derive-nothing rungs), and that the confirmed-transaction reset intentionally covers every wallet the block was processed for because BlockProcessed does not attribute confirmed txids per wallet - over-resetting is the safe direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simplification pass over the probe-escalation code, no behavior change: drain collected scripts in one iter_mut pass instead of collect-then-relookup, hoist the batch's full-match generation into a shared binding, fold the committing batch and later scanned batches into one rescan-target list, write each wallet's probe level once after the rung loop instead of at three exit points, and import the types probe_extend_gap_with names instead of spelling full paths. All five dust-restore tests re-run green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ec13b87-9c51-4a78-a537-15895812a045

📥 Commits

Reviewing files that changed from the base of the PR and between 173ffac and 7bcac9b.

📒 Files selected for processing (8)
  • Cargo.toml
  • dash-spv/src/sync/filters/batch.rs
  • dash-spv/src/sync/filters/manager.rs
  • dash-spv/src/sync/filters/sync_manager.rs
  • key-wallet-manager/src/process_block.rs
  • key-wallet-manager/src/wallet_interface.rs
  • key-wallet/src/managed_account/address_pool.rs
  • key-wallet/src/managed_account/managed_account_collection.rs

📝 Walkthrough

Walkthrough

The change adds adaptive wallet gap probing to compact-filter synchronization. Wallet address pools derive wider windows on demand. Filter batches track matched wallets and script generations. Batch commits rescan and verify scripts before using an escalating probe ladder.

Changes

Adaptive wallet gap probing

Layer / File(s) Summary
Wallet gap probe implementation
key-wallet-manager/..., key-wallet/src/managed_account/...
The wallet interfaces expose probe_extend_gap. Address pools temporarily widen their limits, retain derived addresses, restore steady-state limits, and update monitor revisions. Tests cover widening, escalation, restoration, clamping, and unknown wallets.
Batch match and generation tracking
dash-spv/src/sync/filters/batch.rs, dash-spv/src/sync/filters/sync_manager.rs
FiltersBatch records matched wallets and full-match script generations. Block processing increments script generations and resets affected probe levels.
Adaptive filter commit verification
dash-spv/src/sync/filters/manager.rs, Cargo.toml
Batch commits wait for block quiescence, rescan scripts, verify generations, and probe matched wallets with windows of 100, 300, and 1000. Regression tests cover discovery, exhaustion, transaction ordering, and address-pool growth. Development builds optimize dashcore_hashes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: ⚪ Minimal · up to 7bcac

The PR adds adaptive gap-limit probing to improve wallet address discovery; no actionable merge-blocking risk remains at the current head.

Possibly related issues

Possibly related PRs

Suggested labels: ready-for-review

Suggested reviewers: xdustinface, zocolini

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the PR's main change: adaptive gap-limit probe escalation for BIP44 discovery in dash-spv.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/adaptive-gap-probe-discovery

Comment @coderabbitai help to get the list of available commands.

@HashEngineering

Copy link
Copy Markdown
Contributor

#846 acceptance tests pass against this branch — including the cross-commit repro

Author of #866 here. I ran the #846 acceptance suite (the deterministic repros ported from repro/pr3549-rdc, updated to be gap-relative after #868) against this branch:

test dev this PR
coinjoin_gap_limit_dense_same_batch_recovers
coinjoin_gap_limit_inversion_within_batch_recovers
coinjoin_gap_limit_stall_across_committed_batch (#846) highest_used=Some(29) Some(121)

The cross-commit case passes because the probe fires before the batch commits: the inverted indices sit within the first probe rung of the frontier, so the early block is caught while its batch is still active — prevention where #866 does after-the-fact recovery. Nice property: it also means the "#851 skips recovered fully-spent txs from history" interaction (reported on #851) largely doesn't arise here, since blocks are found before their spends are processed rather than re-applied afterwards.

Two follow-ups:

  1. Offer: the acceptance-test file (coinjoin_gap_discovery_tests.rs, self-contained, no fixtures) is the only regression gate pinning the dash-spv: gap-limit scripts derived after a batch commits never re-open the committed range — wallet funds stay invisible (Found-034) #846 cross-commit shape — happy to PR it against this branch or for it to be cherry-picked from fix(dash-spv): rescan committed filter ranges for newly derived scripts #866, so the shape stays pinned whichever way dash-spv: gap-limit scripts derived after a batch commits never re-open the committed range — wallet funds stay invisible (Found-034) #846 is closed.
  2. For the record: the one dash-spv: gap-limit scripts derived after a batch commits never re-open the committed range — wallet funds stay invisible (Found-034) #846 shape this design doesn't cover is an inversion landing more than MAX_GAP_LIMIT (1,000) indices past the frontier at the earlier batch's commit — but that's outside the BIP44 recovery contract and beyond what dashj finds at any practical gap, so I don't consider it blocking.

If this merges, I'll close #866 in its favor — the unbounded committed-range sweep can be revisited as a follow-up if a real wallet ever demonstrates the beyond-rung-1,000 shape.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants