feat(node): warn when the daemon auto-recovers (salvages) wallet.dat

dragonxd auto-recovers a wallet.dat that fails BDB verification on startup — no
flag needed (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)):
it moves the original to wallet.<timestamp>.bak, salvages readable keys into a
fresh wallet.dat, and keeps running. The salvage can be incomplete (or the whole
thing a FALSE POSITIVE from stale/cross-platform BDB env state — __db.* / the
database/ dir carried between machines), so the node silently comes up on a
possibly-empty wallet. To the user that reads as fund loss, with no warning.

Detect it and warn loudly instead:
- daemon/daemon_startup_diagnosis.h: pure walletAutoRecovered() (the salvage /
  "Original wallet.dat saved as wallet.<ts>.bak" markers) + newestWalletSalvageBak()
  (picks the wallet.<unixtime>.bak the recovery just made).
- onConnected() scans the node's captured output once per session; on a match it
  shows a warning dialog + notification: the ORIGINAL is safe in wallet.<ts>.bak,
  the shown balance may be incomplete, and here are the exact steps to restore it
  (rename the .bak back + delete the stale database/ + __db.* env). One-click
  "Open data folder" jumps straight there. Full-node only; lite-safe.

Deliberately does NOT auto-swap the wallet files (untested per-platform file
manipulation on a real wallet is not worth the risk) — it informs + guides.

Adds walletAutoRecovered / newestWalletSalvageBak coverage to
testBlockDbOutputDiagnosis. Suite green (1/1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 00:30:11 -05:00
parent d136916e80
commit 3b7423f3a1
6 changed files with 111 additions and 0 deletions

View File

@@ -2144,6 +2144,7 @@ void App::render()
renderPinDialogs();
renderSwitchStopDaemonDialog();
renderBlockDbReindexDialog();
renderWalletRecoveredDialog();
// Render notifications (toast messages)
ui::Notifications::instance().render();
@@ -4281,6 +4282,36 @@ void App::renderAntivirusHelpDialog()
#endif
}
// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a
// wallet.<timestamp>.bak, but a possibly-incomplete salvaged copy is now loaded — warn loudly and point
// the user at the datadir so they can restore the original instead of mistaking it for fund loss.
void App::renderWalletRecoveredDialog()
{
if (!show_wallet_recovered_dialog_) return;
ui::material::OverlayDialogSpec ov;
ov.title = TR("wallet_recovered_title");
ov.p_open = &show_wallet_recovered_dialog_;
ov.style = ui::material::OverlayStyle::BlurFloat;
ov.cardWidth = 560.0f;
ov.idSuffix = "walletrecovered";
if (!ui::material::BeginOverlayDialog(ov)) return;
const float dp = ui::Layout::dpiScale();
ui::material::DialogWarningHeader(TR("wallet_recovered_warn"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
if (ui::material::TactileButton(TR("wallet_recovered_open_folder"), ImVec2(240.0f * dp, 0))) {
util::Platform::openFolder(util::Platform::getDragonXDataDir());
}
ImGui::SameLine();
if (ui::material::TactileButton(TR("wallet_recovered_dismiss"), ImVec2(150.0f * dp, 0))) {
show_wallet_recovered_dialog_ = false; // acknowledged; keeps the salvaged wallet loaded
}
ui::material::EndOverlayDialog();
}
// 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()

View File

@@ -900,6 +900,12 @@ private:
// 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
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
bool wallet_auto_recovered_ = false; // a salvage happened this session
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
bool show_wallet_recovered_dialog_ = false; // auto-shown warning 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.
@@ -1331,6 +1337,7 @@ private:
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 renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
void processDeferredEncryption();
// Private methods - connection

View File

@@ -572,6 +572,20 @@ void App::onConnected()
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
connection_status_ = TR("connected");
// Detect a silent wallet AUTO-RECOVERY: dragonxd moves wallet.dat to wallet.<ts>.bak and loads a
// 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 +
// address count fill in on the first address refresh (addresses aren't loaded yet here).
updateWalletIndexForActiveWallet(/*markOpened=*/true);

View File

@@ -8,6 +8,7 @@
#pragma once
#include <string>
#include <vector>
namespace dragonx {
namespace daemon {
@@ -26,5 +27,38 @@ inline bool blockDbOutputLooksBroken(const std::string& out)
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
}
// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
// 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)
{
return out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK warning
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos // the rename-aside notice
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos; // RECOVER_FAIL
}
// From a list of datadir filenames, pick the most recent daemon salvage backup — the "wallet.<unixtime>.bak"
// the auto-recovery just created (highest timestamp). Returns "" if none present. Pure, so it's testable.
inline std::string newestWalletSalvageBak(const std::vector<std::string>& filenames)
{
long long best = -1;
std::string bestName;
for (const auto& f : filenames) {
if (f.rfind("wallet.", 0) != 0) continue; // must start "wallet."
if (f.size() < 12 || f.compare(f.size() - 4, 4, ".bak") != 0) continue; // ...and end ".bak"
const std::string mid = f.substr(7, f.size() - 7 - 4); // digits between the dots
if (mid.empty() || mid.size() > 18) continue;
bool allDigits = true;
for (char c : mid) if (c < '0' || c > '9') { allDigits = false; break; }
if (!allDigits) continue;
long long ts = 0;
for (char c : mid) ts = ts * 10 + (c - '0');
if (ts > best) { best = ts; bestName = f; }
}
return bestName;
}
} // namespace daemon
} // namespace dragonx

View File

@@ -1193,6 +1193,14 @@ void I18n::loadBuiltinEnglish()
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.";
// Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy).
strings_["wallet_recovered_title"] = "Your wallet was auto-recovered";
strings_["wallet_recovered_warn"] = "The node moved your wallet aside and loaded a salvaged copy.";
strings_["wallet_recovered_body"] = "On startup the node decided your wallet.dat looked damaged and recovered it automatically. Your ORIGINAL wallet was NOT deleted — it was renamed to \"wallet.<numbers>.bak\" in your data folder, and a salvaged copy is loaded now.\n\nThe salvaged copy may be incomplete, so the balance shown here could be wrong — don't treat it as final.\n\nThis is often a false alarm caused by leftover database files (e.g. after moving the wallet between machines). To restore your original: quit the wallet, then in the data folder rename the current wallet.dat aside, rename \"wallet.<numbers>.bak\" back to \"wallet.dat\", delete the \"database\" folder and any \"__db.*\" files, and reopen.";
strings_["wallet_recovered_open_folder"] = "Open data folder";
strings_["wallet_recovered_dismiss"] = "Keep salvaged copy";
strings_["wallet_recovered_notify"] = "The node recovered your wallet and moved the original to a .bak — your shown balance may be incomplete. See the prompt to restore it.";
// Receive Tab
strings_["receiving_addresses"] = "Your Receiving Addresses";
strings_["new_z_shielded"] = "New z-Address (Shielded)";

View File

@@ -6139,6 +6139,23 @@ void testBlockDbOutputDiagnosis()
EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex
EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!"));
EXPECT_FALSE(blockDbOutputLooksBroken(""));
// Wallet auto-recovery detection: the node salvaged wallet.dat (moved the original to a .bak).
using dragonx::daemon::walletAutoRecovered;
EXPECT_TRUE(walletAutoRecovered(
"Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ..."));
EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed"));
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(""));
// Newest salvage backup picker (the "wallet.<unixtime>.bak" the recovery just made).
using dragonx::daemon::newestWalletSalvageBak;
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.1786200000.bak", "wallet.1786300000.bak", "peers.dat"}),
std::string("wallet.1786300000.bak")); // highest timestamp wins
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}),
std::string("")); // no wallet.<digits>.bak present
EXPECT_EQ(newestWalletSalvageBak({}), std::string(""));
}
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.