From 3b7423f3a171bd623808e67595b001174fb9901b Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 10 Aug 2026 00:30:11 -0500 Subject: [PATCH] feat(node): warn when the daemon auto-recovers (salvages) wallet.dat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..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..bak" markers) + newestWalletSalvageBak() (picks the wallet..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..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) --- src/app.cpp | 31 ++++++++++++++++++++++++ src/app.h | 7 ++++++ src/app_network.cpp | 14 +++++++++++ src/daemon/daemon_startup_diagnosis.h | 34 +++++++++++++++++++++++++++ src/util/i18n.cpp | 8 +++++++ tests/test_phase4.cpp | 17 ++++++++++++++ 6 files changed, 111 insertions(+) diff --git a/src/app.cpp b/src/app.cpp index 95daaf0..d4cd032 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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..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() diff --git a/src/app.h b/src/app.h index 614b8ce..c8353b9 100644 --- a/src/app.h +++ b/src/app.h @@ -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..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 diff --git a/src/app_network.cpp b/src/app_network.cpp index c21a919..f24e766 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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..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); diff --git a/src/daemon/daemon_startup_diagnosis.h b/src/daemon/daemon_startup_diagnosis.h index 4c17ccb..38a1883 100644 --- a/src/daemon/daemon_startup_diagnosis.h +++ b/src/daemon/daemon_startup_diagnosis.h @@ -8,6 +8,7 @@ #pragma once #include +#include 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..bak" +// the auto-recovery just created (highest timestamp). Returns "" if none present. Pure, so it's testable. +inline std::string newestWalletSalvageBak(const std::vector& 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 diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 62193fe..9201bb6 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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..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..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)"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index be6909e..b890c01 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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..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..bak present + EXPECT_EQ(newestWalletSalvageBak({}), std::string("")); } // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.