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

@@ -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;