feat(node): in-app "Rebuild wallet database" recovery for a BDB-inconsistent wallet

Automates the manual recovery that fixed a wallet.dat with stale Berkeley DB
extent metadata (the "main" subdb metapage records a low last_pgno while its live
data spans thousands of pages beyond it). A tolerant page-walk reads every record,
but the daemon's BDB verify rejects the file and auto-salvages it — finding nothing
and shrinking the wallet to empty on each restart (the salvage cascade that looks
like fund loss). Plain "Restore original" can't fix it (hands the same broken file
back → re-salvage); a rebuild must produce a fresh, consistent DB.

Pieces (Approach A from the design workflow — out-of-process helper keeps AGPL
Berkeley DB out of the GPLv3 GUI):
- util/wallet_file_probe.h: extractWalletBtreeRecords() — sibling to parseWalletBtree
  that collects raw (key,value) bytes (same bounds-checked, subdb-aware walk).
  Records copied verbatim → encrypted key material passes through as opaque
  ciphertext (no passphrase). Overflow-page values (only large tx history) are
  skipped + counted; a rescan rebuilds history — funds unaffected.
- tools/wallet_rebuild/main.cpp: dragonx-wallet-rebuild CLI — reads via the tolerant
  reader, writes the records into a fresh BDB "main" btree via libdb (DB_EXCL, never
  overwrites), prints a JSON summary. New BDB-guarded CMake target.
- App::rebuildWalletDatabase(): picks the largest readable wallet/.bak as source,
  stops the daemon, runs the helper, VERIFIES the output (readable BDB with keys)
  before swapping, moves the current wallet aside (kept, timestamped), installs the
  rebuilt one, clears the stale BDB env, sets -rescan, restarts. Copy/rename only —
  never deletes. Result surfaced via the existing pumpWalletRestore channel.
- Wired as the preferred action on the existing wallet-auto-recovery dialog
  (shown only when the helper is present). Full-node only; lite-safe.

Verified end-to-end against the real broken wallet: helper reads 3,808 t-keys + 1
z-key + HD seed and the daemon LOADS the rebuilt output with no salvage. Adds
extractWalletBtreeRecords coverage. Build clean, suite green (1/1).

Remaining (follow-up): release packaging — build.sh bundling the helper built
against the vendored per-platform static libdb (DRAGONX_BDB_ROOT), and a macOS
Berkeley DB port (no in-tree artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-10 11:26:55 -05:00
parent b2e037bb67
commit bc183257b7
8 changed files with 449 additions and 4 deletions

View File

@@ -59,6 +59,8 @@
#include "data/seed_migration_resume.h"
#include "util/platform.h"
#include "util/wallet_file_probe.h" // verify a salvage-backup is a real BDB before restoring it
#include "resources/embedded_resources.h" // getDaemonDirectory() — locate the wallet-rebuild helper
#include <cstdio> // popen the rebuild helper
#include "util/perf_log.h"
#include "util/i18n.h"
#include "util/secure_vault.h"
@@ -4670,7 +4672,159 @@ void App::pumpWalletRestore()
if (!done) return;
if (sev == 2) ui::Notifications::instance().error(msg, 25.0f);
else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f);
else ui::Notifications::instance().success(TR("wallet_restore_ok"), 12.0f);
else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f);
}
// Locate the bundled dragonx-wallet-rebuild helper (exe dir → daemon dir). "" if not present.
static std::string findWalletRebuildHelper()
{
namespace fs = std::filesystem;
#ifdef _WIN32
const char* exe = "dragonx-wallet-rebuild.exe";
#else
const char* exe = "dragonx-wallet-rebuild";
#endif
for (const std::string& d : { util::Platform::getExecutableDirectory(),
dragonx::resources::getDaemonDirectory() }) {
if (d.empty()) continue;
std::error_code ec;
const std::string p = d + "/" + exe;
if (fs::exists(p, ec)) return p;
}
return {};
}
bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); }
// Rebuild a BDB-inconsistent wallet into a fresh, daemon-loadable one via the offline helper (see
// tools/wallet_rebuild). This is the real fix for the salvage cascade: plain "Restore original" just
// hands the same broken file back and the daemon re-salvages it. Modeled on restoreOriginalWallet:
// stop daemon → run helper → verify → safe swap (copy/rename only, never delete) → rescan → restart.
void App::rebuildWalletDatabase()
{
if (!supportsFullNodeLifecycleActions()) {
ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build");
return;
}
if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; }
const std::string helper = findWalletRebuildHelper();
if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }
show_wallet_recovered_dialog_ = false;
{ std::lock_guard<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
daemon_restarting_ = true;
connection_status_ = TR("sb_restarting_daemon");
if (rpc_ && rpc_->isConnected()) rpc_->disconnect();
onDisconnected("Rebuilding wallet database");
ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f);
const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty())
? settings_->getActiveWalletFile() : std::string("wallet.dat");
async_tasks_.submit("Rebuild wallet database", [this, helper, activeWalletName](const util::AsyncTaskManager::Token&) {
namespace fs = std::filesystem;
std::string err, warn;
try {
const std::string datadir = util::Platform::getDragonXDataDir();
const std::string active = datadir + "/" + activeWalletName;
// 1. Rebuild SOURCE = the largest readable wallet file (the active wallet or any salvage .bak).
// Largest = most records = the original / least-salvaged (a salvaged copy is tiny).
std::string src; unsigned long long best = 0;
{
std::error_code ec;
for (const auto& e : fs::directory_iterator(datadir, ec)) {
if (ec) break;
const std::string n = e.path().filename().string();
if (n != activeWalletName && daemon::parseWalletSalvageBakTs(n) < 0) continue;
std::error_code se;
const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
const auto usz = se ? 0ull : static_cast<unsigned long long>(sz);
if (usz > best && util::probeWalletFile(e.path().string()).isBerkeleyDB) {
best = usz; src = e.path().string();
}
}
}
if (src.empty()) {
err = TR("wallet_rebuild_no_source");
} else if (!stopDaemonForWalletSwitch()) { // 2. release wallet.dat + the port
err = TR("wallet_restore_stop_failed");
} else {
std::time_t t = std::time(nullptr);
std::tm tmv{};
#ifdef _WIN32
localtime_s(&tmv, &t);
#else
localtime_r(&t, &tmv);
#endif
char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv);
const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp";
{ std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh
// 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line.
std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\"";
#ifdef _WIN32
cmd = "\"" + cmd + "\""; // cmd.exe strips the outermost quotes
FILE* fp = _popen(cmd.c_str(), "r");
#else
FILE* fp = popen(cmd.c_str(), "r");
#endif
std::string jout;
if (fp) { char b[512]; while (std::fgets(b, sizeof b, fp)) jout += b; }
#ifdef _WIN32
const int rc = fp ? _pclose(fp) : -1;
#else
const int rc = fp ? pclose(fp) : -1;
#endif
DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str());
// 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys.
const auto probe = util::parseWalletBtree(tmpOut);
if (rc != 0 || !probe.parsed || probe.addresses() == 0) {
std::error_code ec; fs::remove(tmpOut, ec);
err = TR("wallet_rebuild_failed");
} else {
// 5. Swap: set the current wallet aside (kept), install the rebuilt one, clear stale env.
std::error_code ec;
const std::string aside = active + ".prerebuild-" + std::string(ts) + ".dat";
bool moved = false;
if (fs::exists(active)) {
fs::rename(active, aside, ec);
if (ec) err = TR("wallet_restore_move_failed"); else moved = true;
}
if (err.empty()) {
fs::rename(tmpOut, active, ec);
if (ec) {
if (moved) { std::error_code e2; fs::rename(aside, active, e2); }
err = TR("wallet_rebuild_install_failed");
}
}
if (err.empty()) {
std::error_code e2;
if (fs::exists(datadir + "/database"))
fs::rename(datadir + "/database", datadir + "/database.prerebuild-" + std::string(ts) + ".bak", e2);
for (const auto& e : fs::directory_iterator(datadir, e2))
if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); }
if (daemon_controller_) daemon_controller_->setRescanOnNextStart(true);
}
}
}
if (!shutting_down_) {
if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected();
if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart");
}
} catch (const std::exception& e) {
err = std::string("Rebuild failed: ") + e.what();
} catch (...) {
err = "Rebuild failed due to an unexpected error.";
}
daemon_restarting_ = false;
std::lock_guard<std::mutex> lk(wallet_restore_mutex_);
wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0);
wallet_restore_msg_ = !err.empty() ? err : (!warn.empty() ? warn : std::string(TR("wallet_rebuild_ok")));
wallet_restore_done_ = true;
});
}
void App::pumpSeedMigration()