feat(node): detect an unreadable block DB on startup and offer a one-click reindex

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-09 23:49:46 -05:00
parent f88304fed2
commit d136916e80
10 changed files with 163 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<bool> rescan_on_next_start_{false}; // -rescan flag for next start
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
std::atomic<bool> 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<std::string> override_extra_args_; // one-shot: extra args for the next start
bool skip_port_check_ = false; // isolated instance on a non-default port

View File

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

View File

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