You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
letmut 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
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:
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:
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.
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.pubasyncfnrescan_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:
Expand a wallet's monitored set atomically.
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.
Fixing import_wallet_from_extended_priv_key to accept an explicit birth_height is a separate, smaller change. It's necessary but not sufficient — see constraints (2), (3), and (4).
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:316inWalletManager::import_wallet_from_extended_priv_key: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. Theadd_wallet(line 134) and oneimport_wallet_from_bytesvariant (~line 210) accept an explicitbirth_heightparameter — but even passing0alone is not sufficient because of (2), (3), and (4).2.
FiltersManagerreads scan floor only at constructiondash-spv/src/sync/filters/manager.rs:161–165:These values are captured once when
FiltersManager::new()runs. After that, the manager's internalprocessing_height,active_batches, andprogress.committed_height()evolve based on its own state. Changing a wallet'sbirth_heightpost-construction does not propagate. No public method re-evaluates the floor.3.
filter_committed_heightis monotonic;rescan_batchonly covers active batchesWalletInterface::update_filter_committed_height(wallet_interface.rs:103–107) is strictly forward-only:There is no public setter to lower it.
FiltersManager::rescan_batchispub(super)and triggered exclusively byBlockProcessedevents carryingnew_addressesfrommaintain_gap_limitduring in-pipeline block processing — it cannot re-scan committed batches (they have been removed fromactive_batchesviaself.active_batches.remove(&batch_start)at line 499 of filters/manager.rs).4.
SegmentCache::first_valid_offsetanchorsget_start_height()near the tip after reloaddash-spv/src/storage/segments.rs:128–134inSegmentCache::load_or_new:Even if a caller fully tears down the SPV client, resets
filter_committed_heightto0, and reconstructsFiltersManagerfresh against the expanded wallet set, the freshly-loadedDiskStorageManagercomputesstart_heightfrom 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, anchoringFiltersManager'sscan_startaway from historical filters.Observed on Dash testnet (dash-evo-tool log trace 2026-04-20T12:40:11Z):
parallel header sync from 1400000 to 1461916andFilter 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.wm.update_filter_committed_height(0), and rebuilds a newSpvClientagainst the same data directory.DiskStorageManagerloads,FiltersManager::new()readswallet.earliest_required_height() = 0andwallet.filter_committed_height() = 0— correct.header_storage.get_start_height()returns 1,461,917 (=segment_id_to_start_height(28) + first_valid_offset(segment 28)wherefirst_valid_offsetreturns61,917) instead of1,400,000.FiltersManager::start_downloadcomputesscan_start = wallet_birth_height.max(header_start_height) = 0.max(1_461_917) = 1_461_917.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_offsetunder-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 belowget_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:
56bdab0ac5dfefc47e136eedeede07a83ff0f2cb753683578fd41677975f8b32in 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-readMinimum: Allow
WalletInterface::update_filter_committed_height(h)to accept values lower than currentsynced_heightfor the specific purpose of "re-scan required from here."FiltersManagerlistens to a signal (event, revision counter, explicit hook) and rewinds itsprocessing_height/committed_height/active_batchesto match, then continues scanning against the current monitored set.Option B — Public
rescan_from_height(h)onDashSpvClient/FiltersManagerA single entry point callers invoke after expanding a wallet's script set:
Option C —
WalletManager::register_addresses(wallet_id, &[Address])with rescan signalA public bulk register method that does NOT require repeated
get_receive_address(mark_used=true)calls (which have the side effect of advancinghighest_usedcosmetically in the wallet's internal tracker). Combined with a rescan signal, the caller can:Option D — Dynamic re-evaluation of
WalletInterface::earliest_required_heightMake
FiltersManagerre-queryearliest_required_heighton each tick (or in response to amonitor_revisionbump) and rewind its state when the floor drops belowprocessing_height.Why this is worth doing upstream
filter_committed_heightexplicitly 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.PersistentBlockStorage(feat: introducePersistentBlockStorage#397), persistent filter storage, themaintain_gap_limit → new_addresses → BlockProcessed → rescan_batchwiring from feat: capture new addresses frommaintain_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 aSegmentCachescan floor that honors what's actually persisted.Out of scope for this ticket
import_wallet_from_extended_priv_keyto accept an explicitbirth_heightis a separate, smaller change. It's necessary but not sufficient — see constraints (2), (3), and (4).Related
filter_committed_heightintroduction (author's TODO)new_addressesfrommaintain_gap_limitrescan_batchmechanism)WalletManagerrefactor (foundation for per-wallet state)🤖 Co-authored by Claudius the Magnificent AI Agent