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();

View File

@@ -305,7 +305,19 @@ public:
bool isEmbeddedDaemonRunning() const;
bool isUsingEmbeddedDaemon() const { return supportsEmbeddedDaemon() && use_embedded_daemon_; }
void setUseEmbeddedDaemon(bool use) { use_embedded_daemon_ = use && supportsEmbeddedDaemon(); }
void rescanBlockchain(); // restart daemon with -rescan flag
void rescanBlockchain(); // restart daemon with -rescan flag (full-history nodes)
// Runtime rescanblockchain RPC starting at a snapshot-available height. Unlike the
// -rescan restart, this works on bootstrapped/pruned nodes (which lack pre-snapshot
// block data), reconciling the wallet's stale spent-state without a daemon restart.
void runtimeRescan(int startHeight);
// Async binary-search probe for the lowest block height the node still has on disk.
// cb(ok, lowestHeight, fullHistory): fullHistory==true when genesis is present (a normal,
// non-bootstrapped node). Runs on the UI thread via the RPC worker callbacks.
void detectLowestAvailableBlockHeight(std::function<void(bool ok, int lowestHeight, bool fullHistory)> cb);
// Flag that a bootstrap just finished so the wallet auto-reconciles spent-state once the
// daemon is back up (consumed in update()).
void markPostBootstrapRescanPending() { post_bootstrap_rescan_pending_ = true; }
bool runtimeRescanActive() const { return runtime_rescan_active_; }
void repairWallet(); // restart daemon with -zapwallettxes=2 (wipe & rebuild wallet tx records)
void reinstallBundledDaemon(); // stop daemon, overwrite installed binary with the bundled one, restart
void deleteBlockchainData(); // stop daemon, delete chain data, restart fresh
@@ -616,6 +628,13 @@ private:
// Gates the "rescan complete" detection so a getrescaninfo poll that hits the still-running
// pre-restart daemon (which reports rescanning=false) can't fire a false "complete" instantly.
bool rescan_confirmed_active_ = false;
// A runtime rescanblockchain RPC is in flight (vs the -rescan daemon restart). While set,
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
bool runtime_rescan_active_ = false;
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
// that reconciles the preserved wallet.dat against the freshly-imported chain.
bool post_bootstrap_rescan_pending_ = false;
bool opid_poll_in_progress_ = false;
// Consecutive Core-refresh cycles where BOTH core RPCs failed → likely a dead
// connection. After kCoreFailuresBeforeDisconnect, tear down and reconnect.

View File

@@ -2499,4 +2499,110 @@ void App::resendWithFeeGapWorkaround(const std::string& from, const std::string&
});
}
// --------------------------------------------------------------------------------------------
// Bootstrapped/pruned-node rescan support
//
// A node restored from a bootstrap snapshot only has block data from the snapshot base upward;
// blocks below it are absent on disk. The startup -rescan flag rescans from genesis and fails on
// such a node ("error in HDD data"), and rescanblockchain(0) hits the same missing blocks. The
// fix is to rescan from a height the snapshot includes — found here by binary-searching for the
// lowest height whose block data the node can actually read.
// --------------------------------------------------------------------------------------------
void App::detectLowestAvailableBlockHeight(std::function<void(bool, int, bool)> cb)
{
if (!rpc_ || !rpc_->isConnected()) {
if (cb) cb(false, 0, false);
return;
}
const int tip = state_.sync.blocks;
if (tip <= 1) {
// No usable tip yet (or a brand-new chain) — treat as full history, nothing to probe.
if (cb) cb(false, 0, true);
return;
}
// Shared search window; each probe halves it. Invariant maintained: block at `hi` is readable.
struct SearchState { int lo; int hi; std::function<void(bool, int, bool)> cb; };
auto st = std::make_shared<SearchState>(SearchState{0, tip, std::move(cb)});
auto step = std::make_shared<std::function<void()>>();
*step = [this, st, step]() {
if (st->lo >= st->hi) {
const bool fullHistory = (st->lo <= 1); // genesis/early block present → not bootstrapped
if (st->cb) st->cb(true, st->lo, fullHistory);
return;
}
const int mid = st->lo + (st->hi - st->lo) / 2;
rpc_->getBlock(mid, [st, step, mid](const nlohmann::json& result, const std::string& error) {
if (error.empty() && !result.is_null()) {
st->hi = mid; // block data present → lowest available is <= mid
} else {
st->lo = mid + 1; // block data missing → lowest available is > mid
}
(*step)();
});
};
(*step)();
}
void App::runtimeRescan(int startHeight)
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (!state_.connected || !rpc_ || !rpc_->isConnected() || !worker_) {
ui::Notifications::instance().warning("Not connected to daemon");
return;
}
if (runtime_rescan_active_) return;
if (startHeight < 0) startHeight = 0;
DEBUG_LOGF("[App] Starting runtime rescanblockchain from height %d\n", startHeight);
// The rescan runs inside the daemon (holds cs_main/cs_wallet) — it does not restart. We own
// completion via this RPC's callback; rescan_confirmed_active_ keeps the -rescan-style pollers
// from misreading state, and runtime_rescan_active_ suppresses the per-second pollers that
// would otherwise pile up behind the held lock.
runtime_rescan_active_ = true;
state_.sync.rescanning = true;
rescan_confirmed_active_ = true;
state_.sync.rescan_progress = 0.0f;
state_.sync.rescan_status = "Rescanning from block " + std::to_string(startHeight) + "...";
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().info("Rescanning blockchain from block " + std::to_string(startHeight) +
" — this can take a while.");
worker_->post([this, startHeight]() -> rpc::RPCWorker::MainCb {
bool ok = false;
std::string err;
try {
rpc::RPCClient::TraceScope trace("Settings / Runtime rescan");
rpc_->call("rescanblockchain", {startHeight}); // blocks until the scan finishes
ok = true;
} catch (const std::exception& e) {
err = e.what();
}
return [this, ok, err]() {
runtime_rescan_active_ = false;
rescan_confirmed_active_ = false;
state_.sync.rescanning = false;
state_.sync.rescan_status.clear();
if (ok) {
state_.sync.rescan_progress = 1.0f;
transactions_dirty_ = true;
last_tx_block_height_ = -1;
invalidateShieldedHistoryScanProgress(true);
ui::Notifications::instance().success("Blockchain rescan complete");
refreshData();
} else {
state_.sync.rescan_progress = 0.0f;
ui::Notifications::instance().error("Rescan failed: " + err);
}
};
});
}
} // namespace dragonx

View File

@@ -774,6 +774,8 @@ void App::renderFirstRunWizard() {
auto finalProg = bootstrap_->getProgress();
if (finalProg.state == util::Bootstrap::State::Completed) {
bootstrap_.reset();
// Reconcile the preserved wallet.dat against the new chain once the daemon is up.
markPostBootstrapRescanPending();
wizard_phase_ = WizardPhase::EncryptOffer;
} else {
wizard_phase_ = WizardPhase::BootstrapFailed;

View File

@@ -148,6 +148,12 @@ struct SettingsPageState {
bool confirm_clear_ztx = false;
bool confirm_delete_blockchain = false;
bool confirm_rescan = false;
// Rescan dialog: probe the node's available block range so a bootstrapped/pruned node gets a
// runtime rescan from a snapshot-available height instead of the (failing) -rescan-from-genesis.
bool rescan_height_detecting = false;
bool rescan_height_detected = false;
bool rescan_full_history = true; // genesis present → traditional -rescan restart
int rescan_start_height = 0; // editable pre-fill for the runtime rescan
bool confirm_repair_wallet = false;
bool confirm_reinstall_daemon = false;
// Cached daemon-binary status for the "daemon binary" panel (loaded once / on Refresh,
@@ -2241,6 +2247,9 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TR("rescan"), ImVec2(0, 0), dbBtnFont)) {
s_settingsState.confirm_rescan = true;
// Re-probe the available block range each time the dialog is opened.
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = false;
}
if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) ImGui::SetTooltip("%s", TR("tt_rescan"));
ImGui::EndDisabled();
@@ -2702,8 +2711,22 @@ void RenderSettingsPage(App* app) {
}
}
// Confirm: rescan blockchain (restarts the daemon, re-scans the whole chain — long but safe)
// Confirm: rescan blockchain. On a normal (full-history) node this restarts the daemon with
// -rescan; on a bootstrapped/pruned node that would fail (pre-snapshot blocks are absent), so we
// probe the lowest available block height and run a runtime rescanblockchain from a confirmed,
// editable height instead.
if (s_settingsState.confirm_rescan) {
// Kick off the one-shot block-range probe the first frame the dialog is open.
if (!s_settingsState.rescan_height_detecting && !s_settingsState.rescan_height_detected) {
s_settingsState.rescan_height_detecting = true;
app->detectLowestAvailableBlockHeight([](bool ok, int lowest, bool fullHistory) {
s_settingsState.rescan_height_detecting = false;
s_settingsState.rescan_height_detected = true;
s_settingsState.rescan_full_history = (!ok) || fullHistory;
s_settingsState.rescan_start_height = (ok && !fullHistory) ? lowest : 0;
});
}
if (BeginOverlayDialog(TR("confirm_rescan_title"), &s_settingsState.confirm_rescan, 500.0f, 0.94f)) {
ImGui::PushFont(Type().iconLarge());
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), ICON_MD_WARNING);
@@ -2712,9 +2735,25 @@ void RenderSettingsPage(App* app) {
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", TR("warning"));
ImGui::Spacing();
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
const bool detecting = s_settingsState.rescan_height_detecting;
const bool bootstrapped = s_settingsState.rescan_height_detected && !s_settingsState.rescan_full_history;
if (detecting) {
ImGui::TextWrapped("%s", TR("rescan_detecting"));
} else if (bootstrapped) {
ImGui::TextWrapped("%s", TR("rescan_bootstrapped_msg"));
ImGui::Spacing();
ImGui::Text("%s", TR("rescan_from_height"));
ImGui::SetNextItemWidth(160.0f);
ImGui::InputInt("##rescanHeight", &s_settingsState.rescan_start_height);
if (s_settingsState.rescan_start_height < 0) s_settingsState.rescan_start_height = 0;
} else {
ImGui::TextWrapped("%s", TR("confirm_rescan_msg"));
ImGui::Spacing();
ImGui::TextColored(ImVec4(0.3f, 0.8f, 0.3f, 1.0f), "%s", TR("confirm_rescan_safe"));
}
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
@@ -2724,10 +2763,16 @@ void RenderSettingsPage(App* app) {
s_settingsState.confirm_rescan = false;
}
ImGui::SameLine();
ImGui::BeginDisabled(detecting);
if (ImGui::Button(TrId("rescan", "rescan_confirm").c_str(), ImVec2(btnW, 40))) {
app->rescanBlockchain();
if (bootstrapped) {
app->runtimeRescan(s_settingsState.rescan_start_height);
} else {
app->rescanBlockchain();
}
s_settingsState.confirm_rescan = false;
}
ImGui::EndDisabled();
EndOverlayDialog();
}
}

View File

@@ -226,6 +226,8 @@ private:
if (s_bootstrap->isDone()) {
auto finalProg = s_bootstrap->getProgress();
if (finalProg.state == util::Bootstrap::State::Completed) {
// Reconcile the preserved wallet.dat against the new chain once the daemon is back up.
s_app->markPostBootstrapRescanPending();
s_state = State::Done;
} else {
s_errorMsg = finalProg.error;

View File

@@ -418,6 +418,9 @@ void I18n::loadBuiltinEnglish()
strings_["confirm_rescan_title"] = "Rescan Blockchain";
strings_["confirm_rescan_msg"] = "This restarts the daemon and re-scans the entire blockchain for your wallet's transactions. It can take a long time and the wallet stays offline until it finishes.";
strings_["confirm_rescan_safe"] = "Your wallet.dat and blockchain data are not deleted — only re-scanned.";
strings_["rescan_detecting"] = "Checking which blocks your node has on disk…";
strings_["rescan_bootstrapped_msg"] = "Your node was bootstrapped, so blocks below the snapshot aren't on disk and a rescan from genesis would fail. Rescan from a height your snapshot includes to reconcile your wallet's spent balance. Your wallet.dat and chain data are not deleted.";
strings_["rescan_from_height"] = "Rescan from block height:";
strings_["repair_wallet"] = "Repair Wallet";
strings_["tt_repair_wallet"] = "Wipe and rebuild the wallet's transaction records from the blockchain (fixes notes that fail to send after a rescan)";
strings_["confirm_repair_wallet_title"] = "Repair Wallet";