Skip to content

feat(dash-spv): rewindable filter scan for mid-session wallet import #651

@lklimek

Description

@lklimek

Problem

When a wallet is imported into a running SPV client that has already synced past the wallet's first-relevant block height, historical UTXOs belonging to that wallet become permanently invisible to the SPV layer until the SPV client is fully torn down and restarted — and in some cases not even then, because a fourth constraint in the persistent-storage layer re-anchors the scan floor back at the tip after reload. There is no public API today to trigger a bounded rescan after a wallet's script set expands.

Downstream effect for dash-evo-tool: mid-session mnemonic imports discover zero balance even when on-chain UTXOs exist. Users must quit and re-launch the app for historical transactions to appear — and depending on the on-disk segment state, even that is not reliably sufficient. See the consumer issue dashpay/dash-evo-tool#829 and PR #830 for the full context — DET is implementing a programmatic SPV stop-and-restart as a workaround, which is heavy-handed and discards unrelated sync progress.

Root cause — four compounding constraints

1. Birth height is hardcoded to current sync tip on import

key-wallet-manager/src/lib.rs:316 in WalletManager::import_wallet_from_extended_priv_key:

let mut managed_info = T::from_wallet(&wallet);
managed_info.set_birth_height(self.synced_height);   // ← current sync tip

Same pattern repeats at lines 357, 405, 450. This means any wallet imported mid-session starts with birth_height = synced_height, i.e. already past anything historical. The add_wallet (line 134) and one import_wallet_from_bytes variant (~line 210) accept an explicit birth_height parameter — but even passing 0 alone is not sufficient because of (2), (3), and (4).

2. FiltersManager reads scan floor only at construction

dash-spv/src/sync/filters/manager.rs:161–165:

(wallet.earliest_required_height().await, wallet.filter_committed_height())

These values are captured once when FiltersManager::new() runs. After that, the manager's internal processing_height, active_batches, and progress.committed_height() evolve based on its own state. Changing a wallet's birth_height post-construction does not propagate. No public method re-evaluates the floor.

3. filter_committed_height is monotonic; rescan_batch only covers active batches

WalletInterface::update_filter_committed_height (wallet_interface.rs:103–107) is strictly forward-only:

fn update_filter_committed_height(&mut self, height: CoreBlockHeight) {
    if height > self.synced_height() {
        self.update_synced_height(height);
    }
}

There is no public setter to lower it. FiltersManager::rescan_batch is pub(super) and triggered exclusively by BlockProcessed events carrying new_addresses from maintain_gap_limit during in-pipeline block processing — it cannot re-scan committed batches (they have been removed from active_batches via self.active_batches.remove(&batch_start) at line 499 of filters/manager.rs).

4. SegmentCache::first_valid_offset anchors get_start_height() near the tip after reload

dash-spv/src/storage/segments.rs:128–134 in SegmentCache::load_or_new:

if let Some(segment_id) = min_seg_id {
    let segment = cache.get_segment(&segment_id).await?;
    cache.start_height = segment
        .first_valid_offset()
        .map(|offset| Self::segment_id_to_start_height(segment_id) + offset);
}

Even if a caller fully tears down the SPV client, resets filter_committed_height to 0, and reconstructs FiltersManager fresh against the expanded wallet set, the freshly-loaded DiskStorageManager computes start_height from the first non-sentinel offset in the min-id segment — and in practice that value returns near the chain tip, not the actual segment floor, anchoring FiltersManager's scan_start away from historical filters.

Observed on Dash testnet (dash-evo-tool log trace 2026-04-20T12:40:11Z):

  • Previous SPV run initialized from checkpoint height 1,400,000, then ran parallel header sync from 1400000 to 1461916 and Filter sync complete at height 1461917 — segments 28 (heights 1,400,000–1,449,999) and 29 (heights 1,450,000–1,499,999) on disk each hold tens of thousands of written headers/filters.
  • Wallet is imported. DET tears down the SPV runtime, sets wm.update_filter_committed_height(0), and rebuilds a new SpvClient against the same data directory.
  • New DiskStorageManager loads, FiltersManager::new() reads wallet.earliest_required_height() = 0 and wallet.filter_committed_height() = 0 — correct.
  • But header_storage.get_start_height() returns 1,461,917 (= segment_id_to_start_height(28) + first_valid_offset(segment 28) where first_valid_offset returns 61,917) instead of 1,400,000.
  • FiltersManager::start_download computes scan_start = wallet_birth_height.max(header_start_height) = 0.max(1_461_917) = 1_461_917.
  • Log line: Loading stored filters 1461917 to 1461917 into current batch — only a single filter at the tip is re-scanned. Historical filters (1,400,000–1,461,916), where the imported wallet's UTXOs live, are never re-matched.

Either first_valid_offset under-reports valid entries on reload, or the written-offset tracking isn't durably persisted across runs. Whichever the precise fault, the practical outcome is that any proposed fix (Option A/B/C/D below) must either bypass this floor (e.g. let callers force a scan floor below get_start_height()) or fix the underlying offset computation so the scan floor reflects where headers actually begin on disk.

Reference scenario

A testnet wallet with a multi-output transaction confirming 10 DASH outputs at BIP44 external indices 0 and 32–40 (gap of 32, well beyond the default gap limit of 30). The motivating tx: 56bdab0ac5dfefc47e136eedeede07a83ff0f2cb753683578fd41677975f8b32 in block 1,459,352 on Dash testnet. Importing this wallet into an already-synced SPV client never surfaces the UTXOs at 32–40, even after the consumer (dash-evo-tool) pre-extends the wallet's BIP44 monitored set to cover indices 32–40 before announcing the wallet to SPV. The filter batch containing block 1,459,352 was already committed; no rescan path reaches it.

Proposed API additions (any one of these would be sufficient; ideally a combination)

Option A — Rewindable filter_committed_height + dynamic scan-floor re-read

Minimum: Allow WalletInterface::update_filter_committed_height(h) to accept values lower than current synced_height for the specific purpose of "re-scan required from here." FiltersManager listens to a signal (event, revision counter, explicit hook) and rewinds its processing_height / committed_height / active_batches to match, then continues scanning against the current monitored set.

Option B — Public rescan_from_height(h) on DashSpvClient / FiltersManager

A single entry point callers invoke after expanding a wallet's script set:

/// Request that the filter scan be re-run from `height` upward for any
/// wallets whose `earliest_required_height` has changed. Persisted
/// filters/headers are reused; no network re-download unless missing.
pub async fn rescan_from_height(&self, height: CoreBlockHeight) -> Result<(), Error>;

Option C — WalletManager::register_addresses(wallet_id, &[Address]) with rescan signal

A public bulk register method that does NOT require repeated get_receive_address(mark_used=true) calls (which have the side effect of advancing highest_used cosmetically in the wallet's internal tracker). Combined with a rescan signal, the caller can:

  1. Expand a wallet's monitored set atomically.
  2. Trigger bounded rescan covering the new scripts.

Option D — Dynamic re-evaluation of WalletInterface::earliest_required_height

Make FiltersManager re-query earliest_required_height on each tick (or in response to a monitor_revision bump) and rewind its state when the floor drops below processing_height.

Why this is worth doing upstream

  • DET's current workaround is full SPV restart — and even that is not reliably sufficient because of constraint (4): the freshly-loaded storage re-anchors the scan floor at the tip, so a restart against an existing data directory can still fail to surface historical UTXOs. The only bullet-proof workaround available today is wiping the SPV data directory and re-downloading from the checkpoint, which is user-visible (minutes of re-sync) and discards all in-flight sync progress.
  • PR fix: separate filter scan height from synced height #442 author's own TODO — the PR that introduced filter_committed_height explicitly flagged the design as provisional: "It's not the best solution I think, hence I added the TODO for now. I will look into this at some point later." This issue formalizes the follow-up.
  • The infrastructure is mostly there. PersistentBlockStorage (feat: introduce PersistentBlockStorage #397), persistent filter storage, the maintain_gap_limit → new_addresses → BlockProcessed → rescan_batch wiring from feat: capture new addresses from maintain_gap_limit #287 and feat: rewrite, fix and improve the sync architecture #411 all exist. What's missing is a public entry point to say "re-scan range X..Y against the current wallet set", a rewindable committed-height invariant, and a SegmentCache scan floor that honors what's actually persisted.

Out of scope for this ticket

Related

🤖 Co-authored by Claudius the Magnificent AI Agent

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions