From d136916e8088ffd7eab753729f52d4672673612a Mon Sep 17 00:00:00 2001 From: DanS Date: Sun, 9 Aug 2026 23:49:46 -0500 Subject: [PATCH] feat(node): detect an unreadable block DB on startup and offer a one-click reindex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a daemon update changes the block-index on-disk format (or the index is corrupt), dragonxd aborts at startup — "non-canonical optional discriminant" → "Error loading block database. Aborted." — and the wallet silently shows a zero balance. Previously the connect loop just crash-restarted into the same abort up to 3x and then reported a bare "Daemon crashed N times", with no path forward. Now: - daemon/daemon_startup_diagnosis.h: pure blockDbOutputLooksBroken() classifies the crashed node's captured console output (the fatal block-DB markers). - The connect loop detects it on the FIRST abort, STOPS crash-restarting into the same failure (each retry reloads the whole index — wasteful), and offers a fix. - A one-shot -reindex flag (EmbeddedDaemon::setReindexOnNextStart → DaemonController forwarder → args) rebuilds the block index + chainstate from the intact raw blocks; App::reindexBlockDatabase() arms it and un-gates the loop to restart. - An auto-shown dialog (renderBlockDbReindexDialog) + a notification explain the situation ("your coins are safe; the node just can't load the chain") and offer a one-click "Rebuild block database". Full-node only (gated), lite-safe. This is the exact trap behind a real "big wallet shows no funds" report: a post-format-change daemon over pre-change chaindata. Reindex also fixes a plain corrupt index. Adds testBlockDbOutputDiagnosis (the abort sequence + individual markers trip it; normal startup / wallet-corruption / asmap errors do not). Suite green (1/1). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.cpp | 52 +++++++++++++++++++++++++++ src/app.h | 7 ++++ src/app_network.cpp | 16 ++++++++- src/daemon/daemon_controller.cpp | 5 +++ src/daemon/daemon_controller.h | 1 + src/daemon/daemon_startup_diagnosis.h | 30 ++++++++++++++++ src/daemon/embedded_daemon.cpp | 8 +++++ src/daemon/embedded_daemon.h | 8 +++++ src/util/i18n.cpp | 9 +++++ tests/test_phase4.cpp | 28 +++++++++++++++ 10 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 src/daemon/daemon_startup_diagnosis.h diff --git a/src/app.cpp b/src/app.cpp index 6708461..95daaf0 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -2143,6 +2143,7 @@ void App::render() renderDecryptWalletDialog(); renderPinDialogs(); renderSwitchStopDaemonDialog(); + renderBlockDbReindexDialog(); // Render notifications (toast messages) ui::Notifications::instance().render(); @@ -4280,6 +4281,37 @@ void App::renderAntivirusHelpDialog() #endif } +// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click +// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance. +void App::renderBlockDbReindexDialog() +{ + if (!show_block_db_reindex_confirm_) return; + + ui::material::OverlayDialogSpec ov; + ov.title = TR("block_db_reindex_title"); + ov.p_open = &show_block_db_reindex_confirm_; // X / backdrop dismisses (offer remains; loop stays held) + ov.style = ui::material::OverlayStyle::BlurFloat; + ov.cardWidth = 540.0f; + ov.idSuffix = "blockdbreindex"; + if (!ui::material::BeginOverlayDialog(ov)) return; + const float dp = ui::Layout::dpiScale(); + + ui::material::DialogWarningHeader(TR("block_db_reindex_warn")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", TR("block_db_reindex_body")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) { + reindexBlockDatabase(); // clears show_block_db_reindex_confirm_ + block_db_reindex_available_ + } + ImGui::SameLine(); + if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) { + show_block_db_reindex_confirm_ = false; + // Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm) + // rather than looping into the same abort; the user can rebuild later from Settings. + } + ui::material::EndOverlayDialog(); +} + void App::renderSwitchStopDaemonDialog() { const bool confirm = show_switch_stop_daemon_confirm_; @@ -6054,4 +6086,24 @@ void App::restartDaemon() }); } +// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the +// connect loop (which detected the abort and stopped restarting). Its next attempt calls +// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from +// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed. +void App::reindexBlockDatabase() +{ + if (!supportsFullNodeLifecycleActions()) { + ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + return; + } + if (!daemon_controller_) return; + daemon_controller_->setReindexOnNextStart(true); + daemon_controller_->resetCrashCount(); // the abort no longer counts against the restart budget + show_block_db_reindex_confirm_ = false; + block_db_reindex_available_ = false; // un-gate → the connect loop restarts the node with -reindex + connection_status_ = TR("sb_starting_daemon"); + ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f); + DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n"); +} + } // namespace dragonx diff --git a/src/app.h b/src/app.h index 215491d..614b8ce 100644 --- a/src/app.h +++ b/src/app.h @@ -895,6 +895,11 @@ private: // sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true). bool show_switch_stop_daemon_confirm_ = false; std::string pending_switch_wallet_file_; + // Block-database recovery: set when the embedded node aborts because its block DB is unreadable + // (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the + // connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead. + bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop) + bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog // Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start → // reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated; // dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread. @@ -1324,6 +1329,8 @@ private: void renderPinDialogs(); void renderAntivirusHelpDialog(); void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets + void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex) + void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB void processDeferredEncryption(); // Private methods - connection diff --git a/src/app_network.cpp b/src/app_network.cpp index cf84462..c21a919 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -44,6 +44,7 @@ #include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics #include "config/version.h" #include "daemon/daemon_controller.h" +#include "daemon/daemon_startup_diagnosis.h" #include "daemon/embedded_daemon.h" #include "daemon/seed_wallet_creator.h" #include "daemon/xmrig_manager.h" @@ -501,8 +502,21 @@ void App::tryConnect() VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt); if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { + // If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata + // format mismatch after an update, or a corrupt index), crash-restarting just repeats + // the same abort — and each attempt reloads the whole index (wasteful). Detect it once + // and offer a one-click reindex instead of silently looping into a zero-balance node. + if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() && + daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) { + block_db_reindex_available_ = true; + show_block_db_reindex_confirm_ = true; + ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f); + VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt); + } // Prevent infinite crash-restart loop - if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { + if (block_db_reindex_available_) { + connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice + } else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { if (wallet_switch_pending_confirm_.load()) { // The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that // fails LATE in init, past the fast start grace) — revert to the previous diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp index 6f27c48..b357a20 100644 --- a/src/daemon/daemon_controller.cpp +++ b/src/daemon/daemon_controller.cpp @@ -126,6 +126,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled) daemon_->setSalvageOnNextStart(enabled); } +void DaemonController::setReindexOnNextStart(bool enabled) +{ + daemon_->setReindexOnNextStart(enabled); +} + bool DaemonController::zapOnNextStart() const { return daemon_->zapOnNextStart(); diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index 5f2fe25..21dc804 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -108,6 +108,7 @@ public: void setZapOnNextStart(bool enabled); bool zapOnNextStart() const; void setSalvageOnNextStart(bool enabled); + void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon, bool externalDaemonDetected, diff --git a/src/daemon/daemon_startup_diagnosis.h b/src/daemon/daemon_startup_diagnosis.h new file mode 100644 index 0000000..4c17ccb --- /dev/null +++ b/src/daemon/daemon_startup_diagnosis.h @@ -0,0 +1,30 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output, +// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds. + +#pragma once + +#include + +namespace dragonx { +namespace daemon { + +// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a +// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic +// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database +// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix +// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat). +// This is what otherwise silently presents as a wallet with zero balance — the node never starts. +inline bool blockDbOutputLooksBroken(const std::string& out) +{ + return out.find("Error loading block database") != std::string::npos + || out.find("non-canonical optional discriminant") != std::string::npos + || out.find("Aborted block database rebuild") != std::string::npos + || out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value" +} + +} // namespace daemon +} // namespace dragonx diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 2444a79..ac1b8f3 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -571,6 +571,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path) args.push_back("-rescan"); } + // -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format- + // mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair + // chain above (and implies its own wallet rescan). One-shot, consumed here. + if (reindex_on_next_start_.exchange(false)) { + DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n"); + args.push_back("-reindex"); + } + // One-shot isolated-datadir override (migrate-to-seed flow): run this start against a // throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts // revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX) diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index bce7513..c652843 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -206,6 +206,13 @@ public: void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; } bool salvageOnNextStart() const { return salvage_on_next_start_.load(); } + // -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot, + // consumed on the next start. Offered when the node aborts on an unreadable block database (a + // daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet + // rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags. + void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; } + bool reindexOnNextStart() const { return reindex_on_next_start_.load(); } + /** * @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a * different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the @@ -306,6 +313,7 @@ private: std::atomic rescan_on_next_start_{false}; // -rescan flag for next start std::atomic zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start std::atomic salvage_on_next_start_{false}; // -salvagewallet flag for next start + std::atomic reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB) std::string override_datadir_; // one-shot: -datadir for the next start std::vector override_extra_args_; // one-shot: extra args for the next start bool skip_port_check_ = false; // isolated instance on a non-default port diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 2af105f..62193fe 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1185,6 +1185,14 @@ void I18n::loadBuiltinEnglish() strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it."; strings_["switch_corrupt_repair"] = "Try to repair (salvage)"; + // Block-database recovery (offered when the node aborts on an unreadable/format-mismatched block DB). + strings_["block_db_reindex_title"] = "Rebuild block database?"; + strings_["block_db_reindex_warn"] = "The node can't read its block database."; + strings_["block_db_reindex_body"] = "This usually happens after a daemon update changes the on-disk format, or if the block index is damaged. Your wallet and coins are safe — the node just can't load the chain, so balances show as zero.\n\nRebuilding re-reads your existing block files and can take a while (it also rescans your wallet). Nothing is downloaded."; + strings_["block_db_reindex_confirm"] = "Rebuild block database"; + strings_["block_db_reindex_notify"] = "The node can't read its block database (often after a daemon update). Rebuild it to restore your balance — see the prompt, or Settings › Node."; + strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while."; + // Receive Tab strings_["receiving_addresses"] = "Your Receiving Addresses"; strings_["new_z_shielded"] = "New z-Address (Shielded)"; @@ -1309,6 +1317,7 @@ void I18n::loadBuiltinEnglish() strings_["sb_connecting_err"] = "Connecting to daemon — %s"; strings_["sb_daemon_crashed"] = "Daemon crashed %d times"; strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd"; + strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required"; // Persistent node-status banner (App::renderNodeStatusBanner). strings_["node_banner_offline_title"] = "Not connected to the DragonX node"; strings_["node_banner_crashed_title"] = "The node stopped unexpectedly"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 9624512..be6909e 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -38,6 +38,7 @@ #include "data/seed_migration_resume.h" #include "util/address_validation.h" #include "util/seed_phrase.h" +#include "daemon/daemon_startup_diagnosis.h" #include "util/amount_format.h" #include "util/payment_uri.h" #include "util/platform.h" @@ -6114,6 +6115,32 @@ void testSeedPhraseHelpers() EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos); } +// Block-DB abort detection: classify a crashed daemon's console output so the app can offer a +// one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break). +void testBlockDbOutputDiagnosis() +{ + using dragonx::daemon::blockDbOutputLooksBroken; + + // The exact abort sequence we observed on a format-mismatched datadir. + EXPECT_TRUE(blockDbOutputLooksBroken( + "Opened LevelDB successfully\n" + "GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n" + "ERROR: LoadBlockIndex() : failed to read value\n" + ": Error loading block database.\n" + "Aborted block database rebuild. Exiting.\n")); + // Each individual fatal marker also trips it (partial capture / different phrasing). + EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database.")); + EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting.")); + EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value")); + + // Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer). + EXPECT_FALSE(blockDbOutputLooksBroken( + "Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n")); + EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex + EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!")); + EXPECT_FALSE(blockDbOutputLooksBroken("")); +} + // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. void testLiteServerProbeLive() { @@ -7363,6 +7390,7 @@ int main() testAddressChecksumValidation(); testPrivateKeyImportRecognition(); testSeedPhraseHelpers(); + testBlockDbOutputDiagnosis(); testLiteServerProbeLive(); testXmrigLiveInstall(); testGeneratedResourceBehavior();