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:
@@ -1075,6 +1075,26 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
|||||||
OPTIONAL
|
OPTIONAL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
|
||||||
|
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
|
||||||
|
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
|
||||||
|
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
|
||||||
|
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
|
||||||
|
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
|
||||||
|
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
|
||||||
|
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
|
||||||
|
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
|
||||||
|
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
|
||||||
|
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
|
||||||
|
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||||
|
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
|
||||||
|
else()
|
||||||
|
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||||
|
endif()
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
13
src/app.cpp
13
src/app.cpp
@@ -4303,8 +4303,17 @@ void App::renderWalletRecoveredDialog()
|
|||||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||||
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
|
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
|
||||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||||
// Primary: one-click restore of the untouched original (stops the node, swaps the .bak back over the
|
// Preferred fix when available: REBUILD the wallet database. Plain "Restore original" hands the same
|
||||||
// salvaged copy, clears the stale BDB env, restarts). Copy/rename-only — nothing is deleted.
|
// BDB-inconsistent file back and the daemon just re-salvages it (the cascade); the rebuild produces a
|
||||||
|
// fresh, consistent copy of every key that the daemon loads cleanly. Copy/rename-only — never deletes.
|
||||||
|
if (walletRebuildAvailable()) {
|
||||||
|
if (ui::material::TactileButton(TR("wallet_recovered_rebuild"), ImVec2(280.0f * dp, 0))) {
|
||||||
|
rebuildWalletDatabase(); // clears show_wallet_recovered_dialog_
|
||||||
|
}
|
||||||
|
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||||
|
}
|
||||||
|
// Restore of the untouched original (stops the node, swaps the .bak back over the salvaged copy,
|
||||||
|
// clears the stale BDB env, restarts).
|
||||||
if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) {
|
if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) {
|
||||||
restoreOriginalWallet(); // clears show_wallet_recovered_dialog_
|
restoreOriginalWallet(); // clears show_wallet_recovered_dialog_
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1345,7 +1345,9 @@ private:
|
|||||||
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 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 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)
|
||||||
|
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
|
||||||
void processDeferredEncryption();
|
void processDeferredEncryption();
|
||||||
|
|
||||||
// Private methods - connection
|
// Private methods - connection
|
||||||
|
|||||||
@@ -59,6 +59,8 @@
|
|||||||
#include "data/seed_migration_resume.h"
|
#include "data/seed_migration_resume.h"
|
||||||
#include "util/platform.h"
|
#include "util/platform.h"
|
||||||
#include "util/wallet_file_probe.h" // verify a salvage-backup is a real BDB before restoring it
|
#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/perf_log.h"
|
||||||
#include "util/i18n.h"
|
#include "util/i18n.h"
|
||||||
#include "util/secure_vault.h"
|
#include "util/secure_vault.h"
|
||||||
@@ -4670,7 +4672,159 @@ void App::pumpWalletRestore()
|
|||||||
if (!done) return;
|
if (!done) return;
|
||||||
if (sev == 2) ui::Notifications::instance().error(msg, 25.0f);
|
if (sev == 2) ui::Notifications::instance().error(msg, 25.0f);
|
||||||
else if (sev == 1) ui::Notifications::instance().warning(msg, 20.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()
|
void App::pumpSeedMigration()
|
||||||
|
|||||||
@@ -1211,6 +1211,14 @@ void I18n::loadBuiltinEnglish()
|
|||||||
strings_["wallet_restore_move_failed"] = "Couldn't set the current wallet aside — nothing was changed.";
|
strings_["wallet_restore_move_failed"] = "Couldn't set the current wallet aside — nothing was changed.";
|
||||||
strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place.";
|
strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place.";
|
||||||
strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings.";
|
strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings.";
|
||||||
|
// One-click "Rebuild wallet database" flow (fixes a BDB-inconsistent wallet that keeps getting salvaged).
|
||||||
|
strings_["wallet_recovered_rebuild"] = "Rebuild wallet database (recommended)";
|
||||||
|
strings_["wallet_rebuild_started"] = "Rebuilding your wallet database and restarting the node…";
|
||||||
|
strings_["wallet_rebuild_ok"] = "Wallet database rebuilt — the node is loading it and rescanning for your balance.";
|
||||||
|
strings_["wallet_rebuild_no_helper"] = "The wallet-rebuild helper isn't available in this build. Use Restore, or rebuild manually.";
|
||||||
|
strings_["wallet_rebuild_no_source"] = "Couldn't find a readable wallet to rebuild. Nothing was changed.";
|
||||||
|
strings_["wallet_rebuild_failed"] = "The rebuild didn't produce a valid wallet, so nothing was changed. Your wallet is untouched.";
|
||||||
|
strings_["wallet_rebuild_install_failed"] = "Couldn't install the rebuilt wallet; your current wallet was left in place.";
|
||||||
|
|
||||||
// Receive Tab
|
// Receive Tab
|
||||||
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
strings_["receiving_addresses"] = "Your Receiving Addresses";
|
||||||
|
|||||||
@@ -339,5 +339,155 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path,
|
|||||||
return st;
|
return st;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Tier 3: collect the raw (key,value) record BYTES — the read half of the offline wallet REBUILD that
|
||||||
|
// recovers a BDB-inconsistent wallet.dat (stale extent metadata: our tolerant walk reads records the
|
||||||
|
// daemon's Berkeley DB verify rejects and auto-salvages). A helper then writes these verbatim into a
|
||||||
|
// fresh, consistent BDB so the daemon loads it cleanly. Records are copied byte-for-byte — encrypted
|
||||||
|
// key material (ckey/csapzkey/mkey) passes through as opaque ciphertext, so no passphrase is needed.
|
||||||
|
// Values that live in BDB OVERFLOW pages (only large `tx` history records) are NOT captured (skipped +
|
||||||
|
// counted); they are irrelevant to funds — a rescan rebuilds transaction history. Same bounds-checked,
|
||||||
|
// subdb-aware, visited-set-capped walk as parseWalletBtree.
|
||||||
|
struct WalletRawRecords {
|
||||||
|
bool parsed = false; ///< the btree walked cleanly
|
||||||
|
bool complete = false; ///< the whole file was read (not cap-truncated)
|
||||||
|
std::vector<std::pair<std::string, std::string>> records; ///< inline (key,value) bytes, verbatim
|
||||||
|
int keyRecords = 0; ///< fund-critical key-type records captured (key/wkey/ckey/z*/sap*/hdseed)
|
||||||
|
int skippedOverflow = 0; ///< records whose value spilled to overflow pages (tx history) — not captured
|
||||||
|
std::size_t bytesRead = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline WalletRawRecords extractWalletBtreeRecords(const std::string& path,
|
||||||
|
std::size_t maxBytes = 512u * 1024u * 1024u) {
|
||||||
|
WalletRawRecords out;
|
||||||
|
std::ifstream f(path, std::ios::binary);
|
||||||
|
if (!f) return out;
|
||||||
|
std::string buf;
|
||||||
|
{
|
||||||
|
f.seekg(0, std::ios::end);
|
||||||
|
std::streamoff sz = f.tellg();
|
||||||
|
if (sz < 512) return out;
|
||||||
|
const std::size_t want = std::min<std::size_t>(static_cast<std::size_t>(sz), maxBytes);
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
buf.resize(want);
|
||||||
|
f.read(&buf[0], static_cast<std::streamsize>(want));
|
||||||
|
buf.resize(static_cast<std::size_t>(std::max<std::streamsize>(0, f.gcount())));
|
||||||
|
if (buf.size() < 512) return out;
|
||||||
|
out.bytesRead = buf.size();
|
||||||
|
out.complete = (buf.size() == static_cast<std::size_t>(sz));
|
||||||
|
}
|
||||||
|
const unsigned char* B = reinterpret_cast<const unsigned char*>(buf.data());
|
||||||
|
const std::size_t N = buf.size();
|
||||||
|
|
||||||
|
auto rd32at = [&](std::size_t o, bool le) -> uint32_t {
|
||||||
|
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) | ((uint32_t)B[o+2]<<16) | ((uint32_t)B[o+3]<<24)
|
||||||
|
: (uint32_t)B[o+3] | ((uint32_t)B[o+2]<<8) | ((uint32_t)B[o+1]<<16) | ((uint32_t)B[o]<<24);
|
||||||
|
};
|
||||||
|
constexpr uint32_t kBtreeMagic = 0x00053162u;
|
||||||
|
bool le;
|
||||||
|
if (rd32at(12, true) == kBtreeMagic) le = true;
|
||||||
|
else if (rd32at(12, false) == kBtreeMagic) le = false;
|
||||||
|
else return out;
|
||||||
|
auto r32 = [&](std::size_t o) { return o + 4 <= N ? rd32at(o, le) : 0u; };
|
||||||
|
auto r16 = [&](std::size_t o) -> uint32_t {
|
||||||
|
if (o + 2 > N) return 0;
|
||||||
|
return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) : (uint32_t)B[o+1] | ((uint32_t)B[o]<<8);
|
||||||
|
};
|
||||||
|
const uint32_t pagesize = r32(20);
|
||||||
|
if (pagesize < 512 || pagesize > 65536 || (pagesize & (pagesize - 1)) != 0) return out;
|
||||||
|
const uint32_t npages = static_cast<uint32_t>(N / pagesize);
|
||||||
|
const uint32_t root = r32(88);
|
||||||
|
if (npages == 0 || root == 0 || root >= npages) return out;
|
||||||
|
if (B[24] != 0 || (B[26] & 0x01)) return out; // page checksum/encryption — offsets shift; bail
|
||||||
|
|
||||||
|
constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1;
|
||||||
|
constexpr std::size_t kMaxPagesVisited = 600000;
|
||||||
|
constexpr int kMaxKeys = 4000000;
|
||||||
|
std::vector<bool> visited(npages, false);
|
||||||
|
std::size_t pagesVisited = 0;
|
||||||
|
int keys = 0;
|
||||||
|
bool aborted = false;
|
||||||
|
auto rdpgno = [&](const unsigned char* p) -> uint32_t {
|
||||||
|
return le ? (uint32_t)p[0] | ((uint32_t)p[1]<<8) | ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24)
|
||||||
|
: (uint32_t)p[3] | ((uint32_t)p[2]<<8) | ((uint32_t)p[1]<<16) | ((uint32_t)p[0]<<24);
|
||||||
|
};
|
||||||
|
auto traverse = [&](uint32_t rootPg, auto&& fn) {
|
||||||
|
std::fill(visited.begin(), visited.end(), false);
|
||||||
|
std::vector<uint32_t> stack;
|
||||||
|
if (rootPg < npages && !visited[rootPg]) { visited[rootPg] = true; stack.push_back(rootPg); }
|
||||||
|
while (!stack.empty()) {
|
||||||
|
const uint32_t pg = stack.back(); stack.pop_back();
|
||||||
|
if (pg >= npages) continue;
|
||||||
|
if (++pagesVisited > kMaxPagesVisited) { aborted = true; return; }
|
||||||
|
const std::size_t base = static_cast<std::size_t>(pg) * pagesize;
|
||||||
|
if (base + 26 > N) continue;
|
||||||
|
const uint8_t type = B[base + 25];
|
||||||
|
const uint32_t entries = r16(base + 20);
|
||||||
|
if (26 + static_cast<std::size_t>(entries) * 2 > pagesize) continue;
|
||||||
|
if (type == P_IBTREE) {
|
||||||
|
for (uint32_t i = 0; i < entries; ++i) {
|
||||||
|
const uint32_t off = r16(base + 26 + i * 2);
|
||||||
|
if (off + 8 > pagesize) continue;
|
||||||
|
const uint32_t child = r32(base + off + 4);
|
||||||
|
if (child > 0 && child < npages && !visited[child]) { visited[child] = true; stack.push_back(child); }
|
||||||
|
}
|
||||||
|
} else if (type == P_LBTREE) {
|
||||||
|
for (uint32_t i = 0; i + 1 < entries; i += 2) {
|
||||||
|
if (++keys > kMaxKeys) { aborted = true; return; }
|
||||||
|
const uint32_t ko = r16(base + 26 + i * 2);
|
||||||
|
const uint32_t dO = r16(base + 26 + (i + 1) * 2);
|
||||||
|
if (ko + 3 > pagesize || dO + 3 > pagesize) continue;
|
||||||
|
if (B[base + ko + 2] != B_KEYDATA) continue; // overflow/dup key — never a record name
|
||||||
|
const uint32_t kl = r16(base + ko);
|
||||||
|
if (kl < 1 || ko + 3 + kl > pagesize) continue;
|
||||||
|
const uint8_t dtype = B[base + dO + 2];
|
||||||
|
const uint32_t dl = r16(base + dO);
|
||||||
|
const unsigned char* dp = (dO + 3 + dl <= pagesize) ? B + base + dO + 3 : nullptr;
|
||||||
|
fn(B + base + ko + 3, kl, dp, dl, dtype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Master DB: subdb-name → subdb meta/root pgno (big-endian value; native fallback), same as parseWalletBtree.
|
||||||
|
std::vector<uint32_t> subRoots;
|
||||||
|
traverse(root, [&](const unsigned char*, uint32_t, const unsigned char* dp, uint32_t dl, uint8_t dtype) {
|
||||||
|
if (dtype != B_KEYDATA || dl != 4 || !dp) return;
|
||||||
|
const uint32_t cand[2] = {
|
||||||
|
(uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24),
|
||||||
|
rdpgno(dp),
|
||||||
|
};
|
||||||
|
for (const uint32_t pgno : cand) {
|
||||||
|
if (pgno == 0 || pgno >= npages) continue;
|
||||||
|
const uint8_t pt = B[static_cast<std::size_t>(pgno) * pagesize + 25];
|
||||||
|
if (pt == P_BTREEMETA) {
|
||||||
|
const uint32_t sr = r32(static_cast<std::size_t>(pgno) * pagesize + 88);
|
||||||
|
if (sr > 0 && sr < npages) { subRoots.push_back(sr); break; }
|
||||||
|
} else if (pt == P_LBTREE || pt == P_IBTREE) { subRoots.push_back(pgno); break; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (aborted) return out;
|
||||||
|
if (subRoots.empty()) subRoots.push_back(root);
|
||||||
|
|
||||||
|
auto isKeyType = [](const char* nm, uint32_t nl) {
|
||||||
|
auto is = [&](const char* s) { return std::strlen(s) == nl && std::memcmp(nm, s, nl) == 0; };
|
||||||
|
return is("key") || is("wkey") || is("ckey") || is("zkey") || is("czkey")
|
||||||
|
|| is("sapzkey") || is("csapzkey") || is("hdseed") || is("chdseed");
|
||||||
|
};
|
||||||
|
for (const uint32_t sr : subRoots) {
|
||||||
|
traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) {
|
||||||
|
if (dt != B_KEYDATA || !dp) { out.skippedOverflow++; return; } // overflow value (tx history) — skip
|
||||||
|
out.records.emplace_back(std::string(reinterpret_cast<const char*>(kp), kl),
|
||||||
|
std::string(reinterpret_cast<const char*>(dp), dl));
|
||||||
|
const uint32_t nlen = kp[0];
|
||||||
|
if (nlen >= 2 && nlen <= 20 && 1u + nlen <= kl && isKeyType(reinterpret_cast<const char*>(kp + 1), nlen))
|
||||||
|
out.keyRecords++;
|
||||||
|
});
|
||||||
|
if (aborted) return out;
|
||||||
|
}
|
||||||
|
out.parsed = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace util
|
} // namespace util
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -845,6 +845,18 @@ void testWalletFileProbe()
|
|||||||
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
|
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
|
||||||
EXPECT_EQ(s.createdEpoch, kCreated);
|
EXPECT_EQ(s.createdEpoch, kCreated);
|
||||||
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
|
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
|
||||||
|
|
||||||
|
// Byte-collecting reader (the wallet-REBUILD read half): same walk, but collects (key,value) bytes.
|
||||||
|
auto ex = dragonx::util::extractWalletBtreeRecords((dir / "btree.dat").string());
|
||||||
|
EXPECT_TRUE(ex.parsed);
|
||||||
|
EXPECT_TRUE(ex.records.size() >= static_cast<size_t>(3)); // tx + key + keymeta captured verbatim
|
||||||
|
EXPECT_EQ(ex.keyRecords, 1); // the "key" record is fund-critical
|
||||||
|
bool foundKeyRec = false; // value copied byte-for-byte, name intact
|
||||||
|
for (const auto& kv : ex.records)
|
||||||
|
if (kv.first.size() >= 4 && (unsigned char)kv.first[0] == 3 && kv.first.compare(1, 3, "key") == 0
|
||||||
|
&& !kv.second.empty()) foundKeyRec = true;
|
||||||
|
EXPECT_TRUE(foundKeyRec);
|
||||||
|
EXPECT_FALSE(dragonx::util::extractWalletBtreeRecords((dir / "junk.dat").string()).parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
|
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
|
||||||
|
|||||||
90
tools/wallet_rebuild/main.cpp
Normal file
90
tools/wallet_rebuild/main.cpp
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
// dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
|
||||||
|
//
|
||||||
|
// Some wallet.dat files are valid Berkeley DB btrees whose EXTENT metadata is stale (the "main"
|
||||||
|
// subdatabase metapage records a low last_pgno while its live data spans thousands of pages further
|
||||||
|
// in the file). A tolerant page-walk reads every record, but the daemon's Berkeley DB `verify`
|
||||||
|
// rejects the file and auto-salvages it — which finds nothing and, on each restart, shrinks the
|
||||||
|
// wallet to empty (the "salvage cascade" that looks like fund loss). The keys are intact; only the
|
||||||
|
// DB envelope is broken.
|
||||||
|
//
|
||||||
|
// This tool fixes it the way it must be fixed — offline, on the file, before any daemon touches it:
|
||||||
|
// 1) read all (key,value) records with the tolerant walker (util/wallet_file_probe.h), then
|
||||||
|
// 2) write them VERBATIM into a fresh, consistent Berkeley DB "main" btree via real libdb put()s,
|
||||||
|
// so libdb computes correct extent/metapage bookkeeping itself (sidestepping the whole defect).
|
||||||
|
// Records are copied byte-for-byte: encrypted key material (ckey/csapzkey/mkey) passes through as
|
||||||
|
// opaque ciphertext — no passphrase, no decryption, no key material ever interpreted. Only large `tx`
|
||||||
|
// history values (which live in BDB overflow pages) are skipped; a wallet rescan rebuilds those.
|
||||||
|
//
|
||||||
|
// Usage: dragonx-wallet-rebuild <source-wallet.dat> <output-wallet.dat>
|
||||||
|
// Output: a single JSON line on stdout; exit 0 on success, non-zero on failure. Never touches the
|
||||||
|
// source (opens it read-only); refuses to overwrite an existing output (DB_EXCL).
|
||||||
|
|
||||||
|
#include "util/wallet_file_probe.h"
|
||||||
|
|
||||||
|
#include <db.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
int main(int argc, char** argv)
|
||||||
|
{
|
||||||
|
if (argc < 3) {
|
||||||
|
std::fprintf(stderr, "usage: dragonx-wallet-rebuild <source-wallet.dat> <output-wallet.dat>\n");
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
const char* src = argv[1];
|
||||||
|
const char* dst = argv[2];
|
||||||
|
|
||||||
|
// --- 1) tolerant read (no libdb; reads records the daemon's BDB can't) ---
|
||||||
|
const auto rec = dragonx::util::extractWalletBtreeRecords(src);
|
||||||
|
if (!rec.parsed) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"source is not a readable Berkeley DB btree wallet\"}\n");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (rec.keyRecords == 0) {
|
||||||
|
// Refuse to produce a keyless wallet — nothing to recover, and installing it would look like loss.
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"no key records found in source\",\"read\":%zu}\n",
|
||||||
|
rec.records.size());
|
||||||
|
return 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 2) write a fresh, consistent BDB "main" btree (what CWalletDB expects) ---
|
||||||
|
DB* db = nullptr;
|
||||||
|
int r = db_create(&db, nullptr, 0);
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"db_create: %s\"}\n", db_strerror(r));
|
||||||
|
return 5;
|
||||||
|
}
|
||||||
|
// DB_EXCL: never clobber an existing file — the caller passes a fresh path.
|
||||||
|
r = db->open(db, nullptr, dst, "main", DB_BTREE, DB_CREATE | DB_EXCL, 0600);
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"open output: %s\"}\n", db_strerror(r));
|
||||||
|
db->close(db, 0);
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
long wrote = 0;
|
||||||
|
for (const auto& kv : rec.records) {
|
||||||
|
DBT k, v;
|
||||||
|
std::memset(&k, 0, sizeof k);
|
||||||
|
std::memset(&v, 0, sizeof v);
|
||||||
|
k.data = const_cast<char*>(kv.first.data()); k.size = static_cast<u_int32_t>(kv.first.size());
|
||||||
|
v.data = const_cast<char*>(kv.second.data()); v.size = static_cast<u_int32_t>(kv.second.size());
|
||||||
|
r = db->put(db, nullptr, &k, &v, 0);
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"put failed: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote);
|
||||||
|
db->close(db, 0);
|
||||||
|
return 7;
|
||||||
|
}
|
||||||
|
++wrote;
|
||||||
|
}
|
||||||
|
r = db->close(db, 0); // close flushes correct metadata
|
||||||
|
if (r != 0) {
|
||||||
|
std::printf("{\"ok\":false,\"error\":\"close: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote);
|
||||||
|
return 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("{\"ok\":true,\"read\":%zu,\"keyRecords\":%d,\"skippedOverflow\":%d,\"wrote\":%ld,\"complete\":%s}\n",
|
||||||
|
rec.records.size(), rec.keyRecords, rec.skippedOverflow, wrote, rec.complete ? "true" : "false");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user