Skip to content

feat(dash-spv): force resync to re-anchor a running client at a lower checkpoint#856

Open
xdustinface wants to merge 4 commits into
devfrom
feat/spv-force-resync
Open

feat(dash-spv): force resync to re-anchor a running client at a lower checkpoint#856
xdustinface wants to merge 4 commits into
devfrom
feat/spv-force-resync

Conversation

@xdustinface

@xdustinface xdustinface commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator
  • force_resync: wipes chain storage and re-syncs from the checkpoint at or below the current minimum wallet birth height, so adding a wallet with older history gets its range scanned. Wallet state (per-wallet synced heights and discovered outputs) lives outside chain storage and is preserved, and the client keeps running across the resync.
  • Detection: resync_needed reports when a wallet sits below the anchored start, resync_if_needed checks and resyncs in one call, and EventHandler::on_resync_needed fires once on the rising edge so callers can react without polling.
  • Self-arming restart: the network restart no longer needs a separate reset step, and the sync coordinator resets in place, so progress and event subscribers stay connected across the resync.
  • Tip poll: poll the block-headers tip once on the sync-completion edge so a block mined mid-sync isn't missed.
  • Tests: node-free unit tests plus dashd integration tests, including checkpoint anchoring and re-anchoring to a lower checkpoint against a real node (via a test-utils-only checkpoint override).

Summary by CodeRabbit

  • New Features

    • Added resync handling so the client can detect when a wallet needs an earlier chain anchor, notify listeners, and rebuild sync state automatically.
    • Added a way to check the client’s starting height and support custom checkpoint data in test builds.
    • Improved reconnect behavior so sync can resume cleanly after disconnects.
  • Bug Fixes

    • Prevented missed block-header updates during catch-up.
    • Preserved wallet data and sync progress across forced resyncs.

Add `force_resync`, which wipes all chain storage and re-syncs from the checkpoint at or below the current minimum wallet birth height, plus `resync_needed`, which reports when a wallet sits below the anchored header start and a resync is required to scan its history. Wallet state (per-wallet synced heights and discovered outputs) lives outside chain storage and is preserved, so only chain data is refetched: an added wallet with a lower birth height drags the anchor down and gets its range scanned, while wallets already ahead keep their progress.

Making an already-running client resync in place required teardown and rebuild across layers that were previously single-use:

- The network manager is now self-arming across a reconnect. The request processor returns its receiver to the shared slot when it shuts down, so the request channel and the sender clones managers hold are never rebuilt and stay valid, and `connect` re-arms the shutdown token that `disconnect` cancelled. A second `connect` therefore behaves like a fresh start with no separate reset step.
- `SyncCoordinator::reset` rebuilds the managers in place while keeping the progress and sync-event senders, so existing subscribers stay connected. Replacing the whole coordinator would drop those senders and silently freeze every progress and event stream.
- `force_resync` tears down and restarts the coordinator and network directly instead of via `stop`/`start`, leaving the `running` flag set. Flipping it would make the `run` monitoring loop exit for good, freezing all outward progress even though sync continues internally.

The block-headers manager also polls its tip once on the sync-completion edge: the peer only announces a new block when it connects to the tip it believes we already have, so a block that lands while we were still syncing is never pushed to us, and without the poll a freshly resynced client can miss it until the next block arrives.

Covered by a node-free unit test for the re-anchor and detection logic and a dashd integration test that resyncs a running client, mines a boundary block, and asserts the client keeps tracking the chain with wallet state preserved.
Add `resync_if_needed`, which runs `force_resync` only when `resync_needed` reports a wallet now sits below the anchored header start, returning whether a resync actually ran. A caller that just added wallets can do the whole "rescan the older history if I need to" step in one call instead of pairing the check and the action itself.

Add `EventHandler::on_resync_needed`, fired once by the run loop on the rising edge of `resync_needed` so a wallet added below the anchor is reported without the caller polling. The check runs on its own low-frequency interval, kept well above the sync tick so the extra storage lock it takes does not contend with active sync. The client only notifies: reacting is a policy decision (`force_resync` wipes and refetches chain data), so the app calls `force_resync` or `resync_if_needed` when it fits, or defers.
…gainst a real node

Add `ClientConfig::with_checkpoints` to override the network's bundled checkpoints (regtest and devnet ship none), a `DashSpvClient::start_height` accessor, and a `DashCoreNode::checkpoint_at` test helper that builds a real checkpoint from the running chain.

Regtest ships no checkpoints, so the anchor was always genesis and no integration test could exercise checkpoint anchoring against a real node, only mock unit tests. `test_checkpoint_anchoring_and_reanchor_to_lower_checkpoint` now injects checkpoints built from the running chain, anchors a client at a mid-chain checkpoint and syncs forward from it, then adds a wallet below the anchor and asserts `force_resync` re-anchors at the next checkpoint down and re-syncs. This also closes the pre-existing gap that checkpoint anchoring at startup had no real-node coverage at all.

Production networks keep their bundled checkpoints, so the config override is a test-only affordance with no behavior change.
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@xdustinface, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 55 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: df095df0-4732-47ef-b633-5e308e437c6b

📥 Commits

Reviewing files that changed from the base of the PR and between ffa8322 and 894e0e5.

📒 Files selected for processing (1)
  • dash-spv/src/client/lifecycle.rs
📝 Walkthrough

Walkthrough

Adds test-only checkpoint override support to ClientConfig, a checkpoint_manager() helper used during genesis anchoring, and new resync_needed/force_resync/resync_if_needed client APIs. Introduces an EventHandler on_resync_needed callback with periodic detection in SyncCoordinator, plus network reconnect fixes, sync-progress reseeding, and new tests.

Changes

Checkpoint Override and Resync Support

Layer / File(s) Summary
Checkpoint override config
dash-spv/src/client/config.rs
Test-only checkpoints field, with_checkpoints builder, and checkpoint_manager() helper selecting override vs bundled network checkpoints.
Client resync APIs and manager rebuild
dash-spv/src/client/core.rs, dash-spv/src/client/lifecycle.rs
start_height() accessor added; new refactored via build_managers; genesis anchoring uses config.checkpoint_manager(); resync_needed, force_resync, and resync_if_needed implemented to detect and rebuild sync state.
Resync event notification
dash-spv/src/client/event_handler.rs, dash-spv/src/client/sync_coordinator.rs
New on_resync_needed default event method; run loop adds a periodic timer detecting the rising edge of resync-needed and notifying handlers.
Network reconnect fixes
dash-spv/src/network/manager.rs, dash-spv/src/network/mod.rs
Request receiver reused on shutdown, peer count reset, shutdown token re-armed on connect, and disconnect docs updated to require startable state.
Sync progress reseeding and header follow-up
dash-spv/src/sync/sync_coordinator.rs, dash-spv/src/sync/block_headers/manager.rs
seed_initial_progress helper shared by new/reset; reset preserves subscribers via send_replace; header pipeline issues follow-up request after sync completion.
Test utilities and coverage
dash-spv/src/test_utils/node.rs, dash-spv/src/client/mod.rs, dash-spv/tests/dashd_sync/tests_multi_wallet.rs
checkpoint_at builds real checkpoints from RPC data; unit tests cover resync detection/execution and event firing; integration tests verify wallet data and checkpoint anchoring survive resync.

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

Sequence Diagram(s)

sequenceDiagram
  participant SyncCoordinator
  participant DashSpvClient
  participant NetworkManager
  participant Storage
  participant EventHandler

  loop periodic tick
    SyncCoordinator->>DashSpvClient: resync_needed()
    DashSpvClient->>Storage: get_start_height()
    Storage-->>DashSpvClient: anchored height
    DashSpvClient-->>SyncCoordinator: true/false
    alt rising edge detected
      SyncCoordinator->>EventHandler: on_resync_needed()
    end
  end

  EventHandler->>DashSpvClient: force_resync()
  DashSpvClient->>SyncCoordinator: shutdown sync tasks
  DashSpvClient->>Storage: clear chain storage
  DashSpvClient->>DashSpvClient: build_managers()
  DashSpvClient->>SyncCoordinator: reset()
  DashSpvClient->>NetworkManager: connect() (re-arm shutdown_token)
  NetworkManager-->>DashSpvClient: connected
Loading

Possibly related issues

Possibly related PRs

  • dashpay/rust-dashcore#772: Both PRs modify the run-loop logic in dash-spv/src/client/sync_coordinator.rs, one refactoring stop gating and this one adding periodic resync checks.
  • dashpay/rust-dashcore#848: This PR's genesis/checkpoint anchoring changes via config.checkpoint_manager() are tightly coupled to that PR's checkpoint-based start height/anchoring work.

Suggested labels: ready-for-review

Suggested reviewers: 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 matches the main change: adding force resync to re-anchor a running dash-spv client at a lower checkpoint.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/spv-force-resync

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
dash-spv/src/sync/block_headers/manager.rs (1)

194-219: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Move the post-sync GetHeaders poll before marking Synced dash-spv/src/sync/block_headers/manager.rs:204-217
If request_block_headers(*tip.hash()) errors here, BlockHeaderSyncComplete is dropped after the manager has already flipped to Synced, so the completion event never reaches downstream managers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/sync/block_headers/manager.rs` around lines 194 - 219, In the
block header sync completion path inside the manager logic, the final post-sync
GetHeaders poll is happening after the state is already switched to Synced and
the completion event is queued, so an error there can prevent downstream
notification. Update the synchronization flow around the pipeline completion
handling in the block headers manager so the tip poll via
request_block_headers(*tip.hash()) happens before setting SyncState::Synced and
pushing BlockHeaderSyncComplete, and keep the finalize/event emission only after
that request succeeds.
🧹 Nitpick comments (2)
dash-spv/src/network/manager.rs (2)

1426-1434: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider unit test coverage for the reconnect flow.

None of the three fixes in this diff (receiver reuse, peer-count reset, token re-arm) appear to have accompanying #[cfg(test)] coverage in this module. Given the concurrency/lifecycle subtlety here (receiver handoff, cancellation token lifetime), a focused unit test exercising shutdown()connect()shutdown() again would guard against regressions.

As per path instructions, "Implement comprehensive unit tests in-module for individual components using #[cfg(test)] and integration tests in the tests/ directory" for dash-spv/**/{src,tests}/**/*.rs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 1426 - 1434, Add in-module
#[cfg(test)] coverage in manager.rs for the reconnect lifecycle around connect()
and shutdown(). Create a focused unit test that exercises shutdown() → connect()
→ shutdown() again and verifies the receiver handoff, peer-count reset, and
CancellationToken re-arm behavior through the NetworkManager methods involved
(especially connect(), shutdown(), and start()) so regressions in the reconnect
flow are caught.

Source: Path instructions


1426-1434: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the token re-arm against a double connect().

connect() unconditionally replaces self.shutdown_token. If it were ever called twice without an intervening disconnect(), tasks spawned under the previous token (peer readers, maintenance loop, request processor) become permanently uncancellable — the field no longer references the token they're watching, so a later shutdown() can't reach them, and shutdown()'s task-drain loop would hang or leave stale tasks. All current call sites (e.g. force_resync) always pair disconnect()/connect(), so this isn't triggered today, but nothing in the type enforces that discipline for future callers.

🔧 Suggested guard
     async fn connect(&mut self) -> NetworkResult<()> {
         // Re-arm the shutdown token so a reconnect after `disconnect` (which cancelled it)
         // spawns its tasks against a live token. The request channel is not rebuilt here: the
         // request processor returns its receiver to the shared slot on shutdown, so `start`
         // simply takes it again, keeping every manager's request sender valid across
         // reconnects.
-        self.shutdown_token = CancellationToken::new();
+        if self.shutdown_token.is_cancelled() {
+            self.shutdown_token = CancellationToken::new();
+        }
         self.start().await.map_err(|e| NetworkError::ConnectionFailed(e.to_string()))
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dash-spv/src/network/manager.rs` around lines 1426 - 1434, Guard the token
re-arm in connect so it does not blindly replace self.shutdown_token on a second
call without a prior disconnect(). Update NetworkManager::connect to either
reuse the existing token when already connected or return an error/early no-op
if a live token is still in use, and keep the start() call tied to the same
CancellationToken that spawned the existing peer readers, maintenance loop, and
request processor.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dash-spv/src/client/lifecycle.rs`:
- Around line 234-278: The force_resync flow leaves the client partially torn
down if storage.clear() or Self::build_managers() fails after
shutdown/disconnect, since run state is preserved but the network is
disconnected and managers/storage are wiped. Update force_resync in lifecycle.rs
to either defer the shutdown/disconnect until after the rebuild succeeds, or
capture enough pre-resync state to restore the sync_coordinator, network, and
storage on error before returning. Use the existing force_resync,
sync_coordinator.reset, network.disconnect/connect, and Self::build_managers
paths to keep the client consistent on failure.

---

Outside diff comments:
In `@dash-spv/src/sync/block_headers/manager.rs`:
- Around line 194-219: In the block header sync completion path inside the
manager logic, the final post-sync GetHeaders poll is happening after the state
is already switched to Synced and the completion event is queued, so an error
there can prevent downstream notification. Update the synchronization flow
around the pipeline completion handling in the block headers manager so the tip
poll via request_block_headers(*tip.hash()) happens before setting
SyncState::Synced and pushing BlockHeaderSyncComplete, and keep the
finalize/event emission only after that request succeeds.

---

Nitpick comments:
In `@dash-spv/src/network/manager.rs`:
- Around line 1426-1434: Add in-module #[cfg(test)] coverage in manager.rs for
the reconnect lifecycle around connect() and shutdown(). Create a focused unit
test that exercises shutdown() → connect() → shutdown() again and verifies the
receiver handoff, peer-count reset, and CancellationToken re-arm behavior
through the NetworkManager methods involved (especially connect(), shutdown(),
and start()) so regressions in the reconnect flow are caught.
- Around line 1426-1434: Guard the token re-arm in connect so it does not
blindly replace self.shutdown_token on a second call without a prior
disconnect(). Update NetworkManager::connect to either reuse the existing token
when already connected or return an error/early no-op if a live token is still
in use, and keep the start() call tied to the same CancellationToken that
spawned the existing peer readers, maintenance loop, and request processor.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a2e3988e-21bf-49f5-a155-9ca1ce14d60e

📥 Commits

Reviewing files that changed from the base of the PR and between e4f11a1 and ffa8322.

📒 Files selected for processing (12)
  • dash-spv/src/client/config.rs
  • dash-spv/src/client/core.rs
  • dash-spv/src/client/event_handler.rs
  • dash-spv/src/client/lifecycle.rs
  • dash-spv/src/client/mod.rs
  • dash-spv/src/client/sync_coordinator.rs
  • dash-spv/src/network/manager.rs
  • dash-spv/src/network/mod.rs
  • dash-spv/src/sync/block_headers/manager.rs
  • dash-spv/src/sync/sync_coordinator.rs
  • dash-spv/src/test_utils/node.rs
  • dash-spv/tests/dashd_sync/tests_multi_wallet.rs

Comment thread dash-spv/src/client/lifecycle.rs
@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.42489% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.68%. Comparing base (e4f11a1) to head (894e0e5).
⚠️ Report is 13 commits behind head on dev.

Files with missing lines Patch % Lines
dash-spv/src/client/lifecycle.rs 94.68% 5 Missing ⚠️
dash-spv/src/client/event_handler.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev     #856      +/-   ##
==========================================
+ Coverage   73.58%   73.68%   +0.10%     
==========================================
  Files         324      324              
  Lines       73140    73397     +257     
==========================================
+ Hits        53820    54085     +265     
+ Misses      19320    19312       -8     
Flag Coverage Δ
core 77.08% <ø> (ø)
ffi 46.08% <ø> (+0.01%) ⬆️
rpc 20.00% <ø> (ø)
spv 91.14% <97.42%> (+0.09%) ⬆️
wallet 73.05% <ø> (+0.08%) ⬆️
Files with missing lines Coverage Δ
dash-spv/src/client/config.rs 95.04% <100.00%> (+0.54%) ⬆️
dash-spv/src/client/core.rs 64.58% <100.00%> (+2.36%) ⬆️
dash-spv/src/client/mod.rs 100.00% <100.00%> (ø)
dash-spv/src/client/sync_coordinator.rs 80.76% <100.00%> (+2.27%) ⬆️
dash-spv/src/network/manager.rs 71.87% <100.00%> (-0.92%) ⬇️
dash-spv/src/network/mod.rs 98.09% <ø> (ø)
dash-spv/src/sync/block_headers/manager.rs 90.80% <100.00%> (+0.62%) ⬆️
dash-spv/src/sync/sync_coordinator.rs 87.55% <100.00%> (+0.70%) ⬆️
dash-spv/src/client/event_handler.rs 93.62% <0.00%> (-0.27%) ⬇️
dash-spv/src/client/lifecycle.rs 92.33% <94.68%> (+0.10%) ⬆️

... and 14 files with indirect coverage changes

…down

If `storage.clear()` or `build_managers()` fails after `force_resync` has already torn down the coordinator and disconnected the network, the client was left with `running` set but a drained coordinator, so `run`'s `tick` reported no error and spun forever without surfacing the failure. Factor the body into `force_resync_body` and, on failure while `was_running`, drain the coordinator (also cleaning up any tasks a failed `start`/`connect` restart spawned) and flip `running` to false so `run` exits and the error surfaces. A later `force_resync` retries the rebuild cleanly.

Addresses CodeRabbit review comment on PR #856
#856 (comment)
@github-actions github-actions Bot added ready-for-review CodeRabbit has approved this PR merge-conflict The PR conflicts with the target branch. labels Jul 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR has merge conflicts with the base branch. Please rebase or merge the base branch into your branch to resolve them.

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

Labels

merge-conflict The PR conflicts with the target branch. ready-for-review CodeRabbit has approved this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant