fix(node): detect a wallet salvage at startup, not only on connect

Reported: loading a BDB-inconsistent wallet silently renamed it and created a new
one — no recovery dialog. Two causes, both fixed:

1) Detection ran only in onConnected(). The salvage happens at STARTUP, and the
   node may never connect (block-index abort, long sync, crash) — or a long sync
   trims the salvage line out of the rolling output buffer before connect. Extract
   detectWalletAutoRecovery() and run it every tryConnect() tick (every ~5s during
   startup), so the salvage is caught the instant it appears, regardless of whether
   the node connects. Also hold the crash-restart loop while a salvage is pending,
   so the wallet can't be re-salvaged/shrunk while the Rebuild/Restore dialog is up.

2) walletAutoRecovered() only matched the SUCCESSFUL-salvage strings. A
   BDB-inconsistent file makes aggressive salvage FAIL ("found no records"), which
   prints different lines. Broaden the detector to the signals that fire in every
   case: "CDBEnv::Salvage", the "Renamed <wallet> to wallet.<ts>.bak" rename, and
   "found no records in wallet" — while still not matching normal startup or a
   block-DB abort.

Adds the exact failed-salvage sequence to the detector test. Build clean, suite
green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:04:07 -05:00
parent 8ffcd9cc8c
commit 3216debc7d
5 changed files with 47 additions and 16 deletions

View File

@@ -1344,6 +1344,7 @@ private:
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex) void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper) void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)

View File

@@ -229,6 +229,24 @@ static constexpr int kDaemonWaitWarnAttempts = 4;
// Connection Management // Connection Management
// ============================================================================ // ============================================================================
// dragonxd moves wallet.dat to wallet.<ts>.bak and loads a salvaged copy whenever BDB verify fails —
// no flag, and often a false positive (stale/cross-platform env) or an inconsistent-but-readable file.
// The salvage prints to the node's captured output at STARTUP, but the node may then fail to connect
// (block-index abort, long sync, crash) so we must NOT wait for onConnected — scan the output on every
// tryConnect tick, early enough that the line hasn't been trimmed from the rolling buffer. Fires once
// per session; the dialog offers Rebuild (fix the DB) / Restore (swap the .bak back).
void App::detectWalletAutoRecovery()
{
if (wallet_auto_recovered_warned_) return;
if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return;
if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return;
wallet_auto_recovered_ = true;
wallet_auto_recovered_warned_ = true;
show_wallet_recovered_dialog_ = true;
ui::Notifications::instance().error(TR("wallet_recovered_notify"), 30.0f);
VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n");
}
void App::tryConnect() void App::tryConnect()
{ {
// Lite builds have no full node / RPC daemon, so never run the RPC connection state machine // Lite builds have no full node / RPC daemon, so never run the RPC connection state machine
@@ -236,6 +254,10 @@ void App::tryConnect()
// derived from it each frame in App::update(), which also gates the wallet UI (isConnected()). // derived from it each frame in App::update(), which also gates the wallet UI (isConnected()).
if (isLiteBuild()) return; if (isLiteBuild()) return;
// Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether
// the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight).
if (!daemon_restarting_) detectWalletAutoRecovery();
if (connection_in_progress_) return; if (connection_in_progress_) return;
// Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps // Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps
@@ -519,6 +541,10 @@ void App::tryConnect()
// Prevent infinite crash-restart loop // Prevent infinite crash-restart loop
if (block_db_reindex_available_) { if (block_db_reindex_available_) {
connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice
} else if (wallet_auto_recovered_) {
// A salvage is happening — DON'T restart into another one (each round can shrink
// the wallet further). Hold while the recovery dialog (Rebuild/Restore) is up.
connection_status_ = TR("sb_wallet_needs_recovery");
} else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { } else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) {
if (wallet_switch_pending_confirm_.load()) { if (wallet_switch_pending_confirm_.load()) {
// The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that // The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that
@@ -575,19 +601,7 @@ void App::onConnected()
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too) daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected"); connection_status_ = TR("connected");
// Detect a silent wallet AUTO-RECOVERY: dragonxd moves wallet.dat to wallet.<ts>.bak and loads a detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect
// salvaged copy whenever BDB verify fails (no flag, often a false positive from stale/cross-platform
// env state). The node comes up fine — so we only see it here, on connect — but the loaded wallet can
// be empty/incomplete, which reads as fund loss. Surface it loudly, once per session, so the user can
// restore the untouched original from the .bak. (Full-node only; lite has no embedded dragonxd.)
if (!wallet_auto_recovered_warned_ && isUsingEmbeddedDaemon() && daemon_controller_ && daemon_controller_->daemon() &&
daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) {
wallet_auto_recovered_ = true;
wallet_auto_recovered_warned_ = true;
show_wallet_recovered_dialog_ = true;
ui::Notifications::instance().error(TR("wallet_recovered_notify"), 30.0f);
VERBOSE_LOGF("[connect] Daemon auto-recovered wallet.dat (salvage) — warning the user\n");
}
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance + // Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
// address count fill in on the first address refresh (addresses aren't loaded yet here). // address count fill in on the first address refresh (addresses aren't loaded yet here).

View File

@@ -35,9 +35,18 @@ inline bool blockDbOutputLooksBroken(const std::string& out)
// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it. // node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
inline bool walletAutoRecovered(const std::string& out) inline bool walletAutoRecovered(const std::string& out)
{ {
return out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK warning // Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet.<ts>.bak"
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos // the rename-aside notice // warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos; // RECOVER_FAIL // records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
// "Renamed <wallet> to wallet.<ts>.bak" and CDBEnv::Salvage logs its own banner — those two fire the
// instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
|| out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
|| out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
|| (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
&& out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
} }
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1. // If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.

View File

@@ -1345,6 +1345,7 @@ void I18n::loadBuiltinEnglish()
strings_["sb_daemon_crashed"] = "Daemon crashed %d times"; strings_["sb_daemon_crashed"] = "Daemon crashed %d times";
strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd"; strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd";
strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required"; strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required";
strings_["sb_wallet_needs_recovery"] = "Wallet needs recovery — see the prompt";
// Persistent node-status banner (App::renderNodeStatusBanner). // Persistent node-status banner (App::renderNodeStatusBanner).
strings_["node_banner_offline_title"] = "Not connected to the DragonX node"; strings_["node_banner_offline_title"] = "Not connected to the DragonX node";
strings_["node_banner_crashed_title"] = "The node stopped unexpectedly"; strings_["node_banner_crashed_title"] = "The node stopped unexpectedly";

View File

@@ -6157,6 +6157,12 @@ void testBlockDbOutputDiagnosis()
EXPECT_TRUE(walletAutoRecovered( EXPECT_TRUE(walletAutoRecovered(
"Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ...")); "Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ..."));
EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed")); EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed"));
// The FAILED-salvage sequence a BDB-inconsistent wallet actually produces (must also be detected —
// this is the case that previously slipped through and silently emptied the wallet).
EXPECT_TRUE(walletAutoRecovered(
"Renamed wallet.dat to wallet.1786375505.bak\n"
"CDBEnv::Salvage: Database salvage found errors, all data may not be recoverable.\n"
"Salvage(aggressive) found no records in wallet.1786375505.bak.\n"));
EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n")); // normal load EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n")); // normal load
EXPECT_FALSE(walletAutoRecovered(": Error loading block database.")); // block-DB abort != salvage EXPECT_FALSE(walletAutoRecovered(": Error loading block database.")); // block-DB abort != salvage
EXPECT_FALSE(walletAutoRecovered("")); EXPECT_FALSE(walletAutoRecovered(""));