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

@@ -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