feat(fullnode): auto-reconcile wallet after bootstrap; runtime rescan for pruned nodes

A wallet bootstrapped from a snapshot keeps its wallet.dat but never rescans, so
its spent-state is stale and the first send tries to spend already-spent notes and
is rejected. The startup -rescan flag can't fix it either: the snapshot lacks the
pre-snapshot block history -rescan needs, so it errors. The working fix is a runtime
rescanblockchain RPC from a height the snapshot actually has.

- Add App::runtimeRescan(startHeight): runs rescanblockchain via the worker, drives
  the rescanning UI state, and owns completion via the RPC callback (getrescaninfo
  is unavailable on this daemon). Suppresses the per-second mining/rescan pollers
  and the Core/balance/tx refreshes while the daemon holds cs_main for the scan.
- Add App::detectLowestAvailableBlockHeight(): async binary search via getblock for
  the lowest height whose block data is on disk → the snapshot base, and whether the
  node still has full history.
- Auto-reconcile after bootstrap: both completion sites (wizard + Settings download
  dialog) mark a pending rescan; once the daemon is back up and the tip is known,
  detect the base and runtimeRescan() from it (or -rescan restart on a full node).
- Settings "Rescan Blockchain" now probes first: full-history nodes get the existing
  -rescan restart; bootstrapped/pruned nodes get a prompt pre-filled with the
  detected base height that runs the runtime rescan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-21 11:48:30 -05:00
parent 7df00b0909
commit a0532275dd
7 changed files with 211 additions and 11 deletions

View File

@@ -608,14 +608,16 @@ void App::update()
// Skip the mining poll while the daemon is in warmup (e.g. during -rescan). Otherwise
// getmininginfo is rejected with -28 ("Rescanning...") every second and floods the log.
// The rescan progress poll below still runs — that's how we track the warmup/rescan.
if (!state_.warming_up) {
if (!state_.warming_up && !runtime_rescan_active_) {
refreshMiningInfo();
}
// Poll getrescaninfo for rescan progress (if rescan flag is set)
// Use fast_rpc_ when available to avoid blocking on rpc_'s
// curl_mutex (which may be held by a long-running import).
if (state_.sync.rescanning && fast_worker_ && !rescan_status_poll_in_progress_) {
// Suppressed during a runtime rescanblockchain: the daemon holds cs_main for the whole
// scan, so every poll would block, and completion is signalled by the rescan RPC callback.
if (state_.sync.rescanning && !runtime_rescan_active_ && fast_worker_ && !rescan_status_poll_in_progress_) {
auto* rescanRpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
rescan_status_poll_in_progress_ = true;
fast_worker_->post([this, rescanRpc]() -> rpc::RPCWorker::MainCb {
@@ -915,15 +917,36 @@ void App::update()
});
}
// After a bootstrap the preserved wallet.dat has stale spent-state and must be reconciled
// against the freshly-imported chain, or the first send tries to spend already-spent notes and
// is rejected. The -rescan restart can't do it (the snapshot lacks pre-snapshot blocks), so we
// detect the lowest available block height and run a runtime rescanblockchain from there. Wait
// until the daemon is connected and out of warmup so the probe and rescan can actually run.
if (post_bootstrap_rescan_pending_ && rpcConnected && state_.connected &&
!state_.warming_up && !state_.isLocked() && !state_.sync.rescanning &&
!runtime_rescan_active_ && !bootstrap_downloading_ &&
state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range
post_bootstrap_rescan_pending_ = false;
ui::Notifications::instance().info("Bootstrap complete — reconciling your wallet with the new chain data.");
detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) {
if (ok && !fullHistory) {
runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base
} else {
rescanBlockchain(); // full-history node (or probe failed): -rescan restart works
}
});
}
// Per-category refresh with tab-aware intervals
// Skip when wallet is locked — same reason as above.
if (rpcConnected && !state_.isLocked()) {
const bool walletDataPage = currentPageNeedsWalletDataRefresh();
if (network_refresh_.consumeDue(RefreshTimer::Core)) {
if (!runtime_rescan_active_ && network_refresh_.consumeDue(RefreshTimer::Core)) {
refreshCoreData();
}
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28
if (!state_.warming_up) {
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28 — and during a
// runtime rescan, when the daemon holds cs_main and these calls would block.
if (!state_.warming_up && !runtime_rescan_active_) {
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) {
refreshTransactionData();