fix(node): restore the LARGEST salvage backup, not the newest (salvage cascade)

The "Restore original wallet" action picked the newest wallet.<ts>.bak — but the
daemon auto-salvages on every failed BDB verify, and each round SHRINKS the wallet
(salvage keeps only readable records + drops the dead-page bloat). In a cascade the
newest .bak is the most-degraded (seen in the wild as "Salvage found no records")
while the original is the oldest and by far the largest.

Pick by file SIZE instead: add largestWalletSalvageBak((name,size) pairs) — the
largest wallet.<digits>.bak is the least-salvaged, i.e. the pristine original (an
emptied salvage is tiny; a real wallet is large); ties break to the newest ts.
Factor the shared parse into parseWalletSalvageBakTs(). restoreOriginalWallet()
now gathers file sizes and uses it (still verifies the pick is a valid BDB before
swapping). newestWalletSalvageBak kept for reference.

Adds a cascade regression test (a 40KB emptied newest .bak must NOT win over the
194MB original). 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:52:41 -05:00
parent 384d64ea5d
commit 975650f11b
3 changed files with 59 additions and 16 deletions

View File

@@ -4575,14 +4575,21 @@ void App::restoreOriginalWallet()
std::string err, warn;
try {
const std::string datadir = util::Platform::getDragonXDataDir();
// 1. Find the newest salvage backup (offline — no daemon needed).
std::vector<std::string> files;
// 1. Find the LARGEST salvage backup (offline — no daemon needed). Largest = least-salvaged =
// the original: a salvage cascade shrinks the wallet each round, so the newest .bak can be
// empty ("Salvage found no records") while the original is untouched and huge.
std::vector<std::pair<std::string, unsigned long long>> files;
{
std::error_code lec;
for (const auto& e : fs::directory_iterator(datadir, lec))
if (!lec) files.push_back(e.path().filename().string());
for (const auto& e : fs::directory_iterator(datadir, lec)) {
if (lec) break;
std::error_code se;
const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0;
files.emplace_back(e.path().filename().string(),
se ? 0ull : static_cast<unsigned long long>(sz));
}
}
const std::string bak = daemon::newestWalletSalvageBak(files);
const std::string bak = daemon::largestWalletSalvageBak(files);
if (bak.empty()) {
err = TR("wallet_restore_no_backup");
} else if (!util::probeWalletFile(datadir + "/" + bak).isBerkeleyDB) {

View File

@@ -8,6 +8,7 @@
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
@@ -39,26 +40,49 @@ inline bool walletAutoRecovered(const std::string& out)
|| 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.
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.
inline long long parseWalletSalvageBakTs(const std::string& name)
{
if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
if (mid.empty() || mid.size() > 18) return -1;
for (char c : mid) if (c < '0' || c > '9') return -1;
long long ts = 0;
for (char c : mid) ts = ts * 10 + (c - '0');
return ts;
}
// Most RECENT salvage backup (highest timestamp). Pure, 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');
const long long ts = parseWalletSalvageBakTs(f);
if (ts > best) { best = ts; bestName = f; }
}
return bestName;
}
// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
inline std::string largestWalletSalvageBak(const std::vector<std::pair<std::string, unsigned long long>>& files)
{
std::string bestName;
unsigned long long bestSize = 0;
long long bestTs = -1;
for (const auto& fp : files) {
const long long ts = parseWalletSalvageBakTs(fp.first);
if (ts < 0) continue;
if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
bestSize = fp.second; bestTs = ts; bestName = fp.first;
}
}
return bestName;
}
} // namespace daemon
} // namespace dragonx

View File

@@ -6156,6 +6156,18 @@ void testBlockDbOutputDiagnosis()
EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}),
std::string("")); // no wallet.<digits>.bak present
EXPECT_EQ(newestWalletSalvageBak({}), std::string(""));
// Restore must pick the LARGEST (least-salvaged) backup, NOT the newest — a salvage cascade shrinks
// the wallet each round, so the newest .bak can be an emptied 40KB copy while the original is huge.
using dragonx::daemon::largestWalletSalvageBak;
EXPECT_EQ(largestWalletSalvageBak({
{"wallet.dat", 40000ull}, // current (salvaged, tiny) — not a .bak
{"wallet.1786200000.bak", 194174976ull}, // ORIGINAL — oldest ts, biggest
{"wallet.1786340620.bak", 40960ull}, // latest salvage — newest ts, empty
{"peers.dat", 999ull}}),
std::string("wallet.1786200000.bak")); // largest wins over newest
EXPECT_EQ(largestWalletSalvageBak({{"wallet.dat", 100ull}, {"notes.txt", 5ull}}), std::string(""));
EXPECT_EQ(largestWalletSalvageBak({}), std::string(""));
}
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.