Skip to content

fix(dash-spv): make backward coverage durable by rewinding synced_height instead of sweeping in memory - #1002

Draft
romchornyi wants to merge 4 commits into
devfrom
fix/dash-spv-durable-backward-coverage
Draft

fix(dash-spv): make backward coverage durable by rewinding synced_height instead of sweeping in memory#1002
romchornyi wants to merge 4 commits into
devfrom
fix/dash-spv-durable-backward-coverage

Conversation

@romchornyi

@romchornyi romchornyi commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Issue being fixed or feature implemented

Since #866/#974 the filter sync covers scripts derived after a range committed by rescanning the committed range at the forward drain (rescan_committed_range). On a mixing-heavy wallet (~6.7k transactions, ~13k CoinJoin scripts derived during the scan) that is one silent multi-minute pass over ~2.3M filters matching ~41k blocks, with no persisted progress:

  • the sweep's block requests are charged to the tail batch's commit gate, so an iOS suspension a few seconds after the client reports "synced" drops the whole sweep — the user sees a synced wallet missing the newly derived scripts' transactions until a manual rescan;
  • on a relaunch with those blocks already in storage, the matched blocks drain through the SyncEvent broadcast channel faster than the monitor consumes them, spawn_broadcast_monitor hits Lagged, treats it as fatal, and the client shuts down (SyncEvent monitor lagged, missed 1462 eventsStorage shutdown completed; the host's run loop only logs it).

This is the dash-spv half of the large-wallet "sync finished but transactions are missing" reports. Companion changes: dashpay/platform#4595 (linear persistence round) and dashpay/dashwallet-ios#1112 (UI gate on the durable watermark).

Draft because it changes an invariant (synced_height may now be lowered by the sync layer) and needs the dash-spv owner's view on that before it is polished further.

What was done?

  • WalletInterface::rewind_wallet_synced_height(wallet_id, height) (key-wallet-manager): new hook that lowers one wallet's committed sync checkpoint. Emits the same SyncHeightAdvanced persistence event an advance emits, so persisters store the lowered height verbatim and the rewind survives a restart. Only lowers; a value at or above the current is ignored. Default no-op; implemented for WalletManager (process_block.rs) and MockWallet.
  • FilterSyncManager (sync/filters/manager.rs): at the forward drain, when backward scripts exist, rewind the affected wallets to earliest_required_height - 1 instead of sweeping. The existing wallet-behind path ("Wallet synced_height fell below committed_height, restarting scan") re-walks committed history in the normal 5,000-height batches, each persisting its own progress. The commit-time advance skips a wallet rewound at the same drain so the batch's own SyncHeightAdvanced does not clobber the rewind.
  • FilterSyncManager::try_process_batch holds FiltersSyncComplete while rewalk_pending() — a wallet below the committed frontier that the tick will restart the scan for (tested exactly as the tick tests it). The state stays Syncing through the re-walk and SyncComplete fires once, after it; hosts never see a "synced" cycle with a re-walk still pending.
  • WalletManager::rewind_wallet_synced_height clamps to the wallet's own birth_height - 1: the drain passes one floor for every wallet it rewinds, and a wallet added at runtime with a lower birth height must not drag an older wallet below its own start.
  • rescan_committed_range is kept but unused (#[allow(dead_code)]), with progress logging and a yield_now per batch; to be removed once the re-walk has soaked.
  • Tests: backward_coverage_rewinds_and_holds_completion_until_rewalked (dash-spv) replaces the first sweep-shaped test in coinjoin_gap_discovery_tests — the committed-batch shape now asserts the rewind, rewalk_pending(), no completion while behind, completion once caught up; two WalletManager unit tests cover lowering + event, the non-lowering no-op, and the birth-height clamp. The second sweep test (sweep coalescing) stays #[ignore]d — it measured a mechanism that no longer exists; remove with rescan_committed_range. Two dashd integration tests asserted the old monotonic synced_height (test_runtime_add_during_initial_sync, test_all_callbacks_during_sync) and now check "never below own birth height, converges to the tip" / the first completed cycle.

How Has This Been Tested?

cargo test -p dash-spv --lib (569 passed, 3 ignored) and cargo test -p key-wallet-manager --lib (66 passed) on this branch; dashd integration tests via CI.

Manual, same seed throughout, built into the iOS wallet via platform's swift-sdk:

  • iOS Simulator, fresh restore: rewind of 13,034 scripts at the drain; re-walk in 5,000-height batches with BlocksNeeded ≤ 343 per batch; reached the tip; store audited with gettxout over every unspent row matched the chain, whereas the previous build's store carried 0.128 DASH of CoinJoin outputs that are spent on-chain.
  • iPhone 13 Pro, relaunch on an existing store and a full rescan: both reached the tip with the persisted watermark at the tip, zero Lagged.
  • Simulator kill test: process killed mid re-walk (store watermark 1,780,000), relaunch resumed from there and reached the tip 3 minutes later; the store matched the chain and contained 62 previously missed CoinJoin spends, none lost.
  • Field log of the previous build (same wallet, relaunch): Committed-range rescan found 41546 additional blocksSyncEvent monitor lagged, missed 1462 events → client shutdown.

Known cost: the re-walk starts at birth height and re-delivers already-known transactions through the persistence channel, so it is slower than the targeted sweep (about +7 minutes for this wallet from a fresh restore in the simulator; more on device). Follow-ups: rewind to the lowest matched height for the new scripts; persist deltas only (rs-platform-wallet); treat Lagged as recoverable in the event monitor and restart the run loop.

Breaking Changes

None in the public API (rewind_wallet_synced_height has a default no-op). Behavioural: a wallet's synced_height is no longer monotonic across a sync cycle — it can be lowered at the forward drain and then re-advanced by the re-walk. Persisters that clamp the watermark to max would silently break the re-walk's resume; the two in-tree persisters apply it verbatim.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

…ght instead of sweeping in memory

Since #866/#974 the filter sync covers scripts derived after a range
committed by rescanning the committed range at the forward drain
(`rescan_committed_range`). On a mixing-heavy wallet (~13k CoinJoin
scripts derived during the scan) that is a single multi-minute pass over
~2.3M filters that matches tens of thousands of blocks, with no persisted
progress: the sweep's block requests are charged to the tail batch's
commit gate, and an iOS suspension a few seconds after the client reports
"synced" drops the whole sweep. The user sees a synced wallet with the
newly derived scripts' transactions missing until a manual rescan. On a
relaunch with those blocks already in storage the same pass drains the
matched blocks through the `SyncEvent` broadcast channel faster than the
monitor consumes them, the monitor hits `Lagged` and the client shuts
down.

Replace the in-memory sweep with a durable re-walk:

- `WalletInterface::rewind_wallet_synced_height(wallet_id, height)` — a
  new hook that lowers one wallet's committed sync checkpoint. It emits
  the same `SyncHeightAdvanced` persistence event an advance emits, so
  the persisters store the lowered height verbatim and the rewind
  survives a restart. Only lowers; a value at or above the current is
  ignored. Default no-op for implementations that predate backward
  coverage; implemented for `WalletManager` and the mock wallet.
- `FilterSyncManager`: at the forward drain, when there are scripts that
  were derived after their range committed, rewind the affected wallets
  to `earliest_required_height - 1` instead of sweeping. The existing
  wallet-behind path ("Wallet synced_height fell below committed_height,
  restarting scan") then re-walks committed history in the normal
  5,000-height batches, each persisting its own progress. Commit-time
  advance skips a wallet rewound at the same drain so the rewind is not
  clobbered by the batch's own `SyncHeightAdvanced`.
- `rescan_committed_range` is kept (now unused) with progress logging and
  a `yield_now` per batch; it can be removed once the re-walk has soaked.

Two sweep-shaped tests in `coinjoin_gap_discovery_tests` are `#[ignore]`d:
their harness drives the filter manager directly and never runs the
wallet-behind tick that now does the work.

Cost: the re-walk starts at the wallet's birth height and re-delivers
already-known transactions through the persistence channel, so it is
slower than the targeted sweep (about +7 minutes on a 6.7k-transaction
wallet from a fresh restore in the simulator). Rewinding to the lowest
matched height and persisting only deltas are follow-ups.

Verified with the same wallet: fresh restore, relaunch on an existing
store, and a process kill mid re-walk with relaunch — every run reached
the tip with the persisted store matching the chain, no `Lagged`.
@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@romchornyi

Copy link
Copy Markdown
Contributor Author

Local run on this branch (rebased onto dev at 93260bf):

cargo check -p dash-spv -p key-wallet-manager --tests   → Finished (41 s)
cargo test  -p dash-spv --lib -- filters                → 117 passed; 0 failed; 2 ignored

The 2 ignored are the two sweep-shaped tests in coinjoin_gap_discovery_tests mentioned in the description.

…n birth height

The forward drain rewinds every wallet with newly derived scripts to one
floor, the earliest height any wallet requires. When a wallet with a lower
birth height is added at runtime, that floor dragged an older wallet's
checkpoint below its own birth (CI: `test_runtime_add_during_initial_sync`,
W1 rewound 20999 -> 0), re-walking history the wallet cannot have touched
and, on a persisted store, reading as a reset. `WalletManager` now clamps
the rewind to `birth_height - 1` per wallet; the trait contract says so.

Two dashd integration tests asserted the old invariant that a wallet's
synced_height never decreases. It now legitimately dips at the drain and
climbs back during the re-walk:

- `tests_multi_wallet::test_runtime_add_during_initial_sync` checks that
  W1 never goes below its own birth height and still converges to the tip.
- `dash-spv-ffi tests_callback::test_all_callbacks_during_sync` waits (up
  to 60 s) for `on_synced_height_updated` to report the tip again instead
  of sampling the last value once, which could land on the rewind.
The callback test's wallet has transactions, so the scan derives scripts
and a backward-coverage re-walk follows, completing as a later cycle.
Track the cycle of the first on_sync_complete in the tracker and assert
on that; the last cycle is logged.
@codecov

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.48387% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.28%. Comparing base (93260bf) to head (8843421).

Files with missing lines Patch % Lines
dash-spv/src/sync/filters/manager.rs 80.95% 8 Missing ⚠️
key-wallet-manager/src/wallet_interface.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #1002      +/-   ##
==========================================
+ Coverage   77.10%   77.28%   +0.18%     
==========================================
  Files         329      329              
  Lines       83511    83567      +56     
==========================================
+ Hits        64394    64588     +194     
+ Misses      19117    18979     -138     
Flag Coverage Δ
core 78.25% <ø> (ø)
ffi 52.72% <ø> (+1.80%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.80% <80.95%> (-0.26%) ⬇️
wallet 79.64% <95.00%> (+0.02%) ⬆️
Files with missing lines Coverage Δ
key-wallet-manager/src/process_block.rs 93.96% <100.00%> (+0.81%) ⬆️
key-wallet-manager/src/wallet_interface.rs 9.09% <0.00%> (-0.29%) ⬇️
dash-spv/src/sync/filters/manager.rs 96.19% <80.95%> (-1.76%) ⬇️

... and 25 files with indirect coverage changes

…k is pending; cover the rewind

CI (Ubuntu ARM / ffi): `test_ffi_multiple_transactions_across_blocks`
read 24 transactions instead of 25 right after `wait_for_sync`. The
forward drain rewound the wallet and then declared the filters complete
in the same pass, so `SyncComplete` fired with the re-walk still to run;
on a slow runner the tip block's transaction landed after the test read
the count. The same ordering is what produced a spurious extra sync cycle
in `test_all_callbacks_during_sync`.

`try_process_batch` now skips `FiltersSyncComplete` while
`rewalk_pending()` — a wallet below the committed frontier that the
sync-manager tick will restart the scan for, tested exactly as the tick
tests it (lowest stale synced_height + 1, floored at birth height and
stored-header start, reaching the frontier). The state stays Syncing
through the re-walk and completion is emitted once, after it.

Coverage:
- `backward_coverage_rewinds_and_holds_completion_until_rewalked`
  replaces the first ignored sweep test: the committed-batch shape now
  asserts the rewind to birth_height - 1, `rewalk_pending()`, no
  completion while behind, and completion once the wallet has caught up.
  The second ignored test keeps its `#[ignore]` with an updated reason.
- `WalletManager::rewind_wallet_synced_height`: lowers and emits
  SyncHeightAdvanced, ignores a non-lowering value and an unknown wallet,
  clamps to the wallet's own birth_height - 1.
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