diff --git a/src/app.cpp b/src/app.cpp index 02c44b3..5dc7a89 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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(); diff --git a/src/app.h b/src/app.h index efd5ceb..926f579 100644 --- a/src/app.h +++ b/src/app.h @@ -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 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. diff --git a/src/app_network.cpp b/src/app_network.cpp index f41bccf..c0de2f1 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 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 cb; }; + auto st = std::make_shared(SearchState{0, tip, std::move(cb)}); + auto step = std::make_shared>(); + *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 diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index b14a32f..20f64aa 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -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; diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 4deac9a..4c6f003 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -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(); } } diff --git a/src/ui/windows/bootstrap_download_dialog.h b/src/ui/windows/bootstrap_download_dialog.h index 0dca1bb..182bfc8 100644 --- a/src/ui/windows/bootstrap_download_dialog.h +++ b/src/ui/windows/bootstrap_download_dialog.h @@ -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; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 582faad..1fc56cf 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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";