feat(wallets): exact address/tx counts via a minimal BDB btree parser

parseWalletBtree() walks the wallet.dat Berkeley DB btree directly (no libdb, no
daemon): validate the metapage magic, follow the "main" sub-database (its pgno is
stored big-endian in the master map), traverse internal/leaf pages, and tally
records by their length-prefixed type name — transparent + shielded spendable
keys, address-book, and tx count, plus exact encryption/seed flags. Every offset
is bounds-checked, pages are deduped at push time (stack stays O(npages)), and a
visited-set + page/key caps make it safe on a corrupt/adversarial imported file;
files using DB_CHKSUM/DB_ENCRYPT (which shift the page layout) are rejected so the
byte-scan fallback runs instead.

The async probe now uses this as the primary path (exact badges + counts), falling
back to the byte-scan only when the btree can't be fully parsed. The wallets list
shows "N keys · M txs" for probed rows (labeled "keys", not "addresses", since the
count includes change keys the daemon's address list omits). Validated read-only
against real wallets (counts cross-checked; enc/seed match the byte-scan) and a
hand-built minimal btree fixture in the unit suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 13:20:35 -05:00
parent 36efe99a0f
commit 175950a2ec
4 changed files with 229 additions and 8 deletions

View File

@@ -227,10 +227,15 @@ public:
}
}
// Metadata line (size · N addresses · balance DRGX · last opened)
// Metadata line (size · N addresses/keys · M txs · balance DRGX · last opened). The cached
// wallet index gives the authoritative user-facing ADDRESS count; the btree walk gives an
// exact tx count and a spendable-KEY count (labeled "keys", not "addresses", since it counts
// change keys the daemon's address list omits — a different figure for the same wallet).
std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes);
char b[64];
char b[80];
if (meta && meta->cachedAddressCount >= 0) { snprintf(b, sizeof(b), "%s%lld %s", dot, (long long)meta->cachedAddressCount, TR("wallets_col_addresses")); ms += b; }
else if (pres.hasCounts && pres.keyCount > 0) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.keyCount, TR("wallets_col_keys")); ms += b; }
if (pres.hasCounts) { snprintf(b, sizeof(b), "%s%d %s", dot, pres.txCount, TR("wallets_col_txs")); ms += b; }
if (meta && meta->cachedBalance >= 0.0) { snprintf(b, sizeof(b), "%s%.4f %s", dot, meta->cachedBalance, DRAGONX_TICKER); ms += b; }
ms += dot;
ms += (meta && meta->lastOpenedEpoch > 0) ? util::formatTimeAgo(meta->lastOpenedEpoch) : std::string(TR("wallets_never"));
@@ -346,10 +351,13 @@ private:
// index-aligned batch: the probe thread fills it, render() reads it under the mutex, and a re-scan
// supersedes the old batch (cancel + swap) so a detached in-flight probe can't touch stale rows.
struct ProbeResult {
bool probed = false; // validated as a BDB wallet and scanned
bool complete = false; // scan covered the whole file → a MISSING marker is trustworthy
bool probed = false; // validated as a BDB wallet and scanned
bool complete = false; // scan covered the whole file → a MISSING marker is trustworthy
bool encrypted = false; // has an mkey record
bool hdSeed = false; // HD/seed wallet (else legacy)
bool hdSeed = false; // HD/seed wallet (else legacy)
bool hasCounts = false; // an exact btree walk produced the counts below
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
int txCount = 0; // wallet transaction records
};
struct ProbeBatch {
std::mutex mtx;
@@ -501,9 +509,18 @@ private:
if (batch->cancel.load()) return;
ProbeResult res;
if (budget > 0) {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
budget -= std::min(budget, pr.bytesRead);
// Primary: an exact btree walk (encryption/seed flags + address/tx counts). If it
// can't fully parse (unknown BDB variant, or a >cap file), fall back to the cheap
// byte-scan for badges only (no counts).
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
if (bt.parsed && bt.complete) {
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount };
budget -= std::min(budget, bt.bytesRead);
} else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
}
}
std::lock_guard<std::mutex> lk(batch->mtx);
if (t.second < batch->results.size()) batch->results[t.second] = res;

View File

@@ -258,6 +258,8 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_col_name"] = "Wallet";
strings_["wallets_col_size"] = "Size";
strings_["wallets_col_addresses"] = "Addresses";
strings_["wallets_col_txs"] = "txs";
strings_["wallets_col_keys"] = "keys";
strings_["wallets_col_balance"] = "Balance";
strings_["wallets_col_opened"] = "Last opened";
strings_["wallets_current"] = "current";

View File

@@ -17,6 +17,7 @@
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <fstream>
#include <string>
#include <utility>
@@ -118,5 +119,181 @@ inline WalletFileProbe probeWalletFile(const std::string& path,
return out;
}
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// Tier 2: exact record counts by actually walking the Berkeley DB btree (still no daemon, no libdb).
//
// wallet.dat is a BDB btree: a metapage (page 0) points at a root page; internal pages point at child
// pages; leaf pages hold (key,data) item pairs. We traverse from the root, visiting only reachable
// leaves (so freed/stale pages aren't counted), and tally records by the length-prefixed type name each
// key begins with. Every offset is bounds-checked against the page/file; a visited-set + page/key caps
// make it safe on a corrupt or adversarial file. On any structural surprise it returns parsed=false and
// the caller falls back to the byte-scan probe above.
struct WalletBtreeStats {
bool parsed = false; ///< the btree walked cleanly
bool complete = false; ///< the whole file was read (not cap-truncated) → counts are exact
int transparentKeys = 0; ///< key + wkey + ckey (spendable keys, incl. change; keypool "pool" recs excluded)
int shieldedKeys = 0; ///< zkey + czkey + sapzkey + csapzkey (≈ shielded addresses)
int addressBook = 0; ///< name records (labeled/received addresses)
int txCount = 0; ///< tx records (wallet transactions)
bool encrypted = false; ///< saw an mkey record
bool hdSeed = false; ///< saw hdseed/chdseed/hdchain
std::size_t bytesRead = 0; ///< bytes actually read (for budgeting)
int addresses() const { return transparentKeys + shieldedKeys; }
};
inline WalletBtreeStats parseWalletBtree(const std::string& path,
std::size_t maxBytes = 256u * 1024u * 1024u) {
WalletBtreeStats st;
std::ifstream f(path, std::ios::binary);
if (!f) return st;
std::string buf;
{
f.seekg(0, std::ios::end);
std::streamoff sz = f.tellg();
if (sz < 512) return st;
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 st;
st.bytesRead = buf.size();
st.complete = (buf.size() == static_cast<std::size_t>(sz)); // read the whole file, not cap-truncated
}
const unsigned char* B = reinterpret_cast<const unsigned char*>(buf.data());
const std::size_t N = buf.size();
// Byte order comes from the metapage magic (BDB stores fields in the creating machine's order).
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 st; // not a BDB btree
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 st; // must be a power of two
const uint32_t npages = static_cast<uint32_t>(N / pagesize);
const uint32_t root = r32(88);
if (npages == 0 || root == 0 || root >= npages) return st;
// Page-level checksums (DB_CHKSUM) or encryption (DB_ENCRYPT) shift the per-page offset index array
// (26 → 32 / 64), which our fixed-offset-26 reads don't account for — that would silently under-count
// rather than fail. DragonX wallets use neither, so bail to the byte-scan fallback if either is set
// (metapage encrypt_alg@24, metaflags@26 & DBMETA_CHKSUM 0x01).
if (B[24] != 0 || (B[26] & 0x01)) return st;
// BDB page layout: hdr[26] = {..., entries@20:u16, level@24:u8, type@25:u8}, then a u16 offset array.
// Items on a leaf are BKEYDATA {len@0:u16, type@2:u8, data@3}; on an internal page BINTERNAL {pgno@4}.
constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1;
constexpr std::size_t kMaxPagesVisited = 600000; // bounds a corrupt/huge file
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);
};
// Walk the btree rooted at `rootPg`, invoking fn(keyPtr,keyLen, dataPtr,dataLen,dataType) per leaf pair.
auto traverse = [&](uint32_t rootPg, auto&& fn) {
std::fill(visited.begin(), visited.end(), false);
std::vector<uint32_t> stack;
// Mark pages visited at PUSH time (here + at each internal child below) so every page is enqueued
// at most once. The stack then stays O(npages) — a crafted file with many internal pages all
// referencing a shared child set can't balloon it to ~npages*entries pushes (a ~0.5 GB DoS).
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; // index array must fit
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) { // items alternate (key, data)
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 type key
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);
}
}
// metapage / overflow / free pages: ignored.
}
};
// wallet.dat stores its records in a NAMED sub-database ("main"): the file's root btree is a master map
// of subdb-name -> subdb meta/root pgno. Follow each mapping to the real record btree. (A plain,
// single-database BDB file has no such mapping, so we fall back to walking the file root directly.)
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;
// The master-db value (the subdb's meta/root pgno) is stored BIG-ENDIAN regardless of the file's
// native order. Try big-endian first, then native, and accept whichever lands on a real subdb page.
const uint32_t cand[2] = {
(uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24), // big-endian
rdpgno(dp), // native
};
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) { // subdb metapage → its root is at +88
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) { // mapping points straight at the root
subRoots.push_back(pgno); break;
}
}
});
if (aborted) return st;
if (subRoots.empty()) subRoots.push_back(root); // single-database file
auto countKey = [&](const unsigned char* kp, uint32_t kl) {
const uint32_t nlen = kp[0]; // CompactSize length of the type string
if (nlen < 2 || nlen > 20 || 1u + nlen > kl) return;
const char* nm = reinterpret_cast<const char*>(kp + 1);
auto is = [&](const char* s) { return std::strlen(s) == nlen && std::memcmp(nm, s, nlen) == 0; };
if (is("key") || is("wkey") || is("ckey")) st.transparentKeys++;
else if (is("zkey") || is("czkey") || is("sapzkey") || is("csapzkey")) st.shieldedKeys++;
else if (is("name")) st.addressBook++;
else if (is("tx")) st.txCount++;
else if (is("mkey")) st.encrypted = true;
else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true;
};
for (const uint32_t sr : subRoots) {
traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char*, uint32_t, uint8_t) { countKey(kp, kl); });
if (aborted) return st;
}
st.parsed = true;
return st;
}
} // namespace util
} // namespace dragonx

View File

@@ -789,6 +789,31 @@ void testWalletFileProbe()
auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u);
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
// 7) Btree record counting (tier 2): hand-build a minimal single-database BDB btree — a 512-byte
// metapage (magic/pagesize/root) + one leaf page holding a "tx" record and a "key" record — and
// confirm parseWalletBtree walks it and tallies them. Also that a non-BDB file yields parsed=false.
{
std::string w(1024, '\0');
auto put16 = [&](std::size_t o, unsigned v) { w[o] = (char)(v & 0xff); w[o+1] = (char)((v >> 8) & 0xff); };
auto put32 = [&](std::size_t o, unsigned v) { for (int k=0;k<4;k++) w[o+k] = (char)((v >> (8*k)) & 0xff); };
put32(12, 0x00053162u); put32(20, 512); w[25] = (char)9; put32(88, 1); // page 0: metapage, root = page 1
const std::size_t P = 512; // page 1: a btree leaf
put16(P + 20, 4); w[P + 24] = (char)1; w[P + 25] = (char)5; // entries=4 (2 pairs), level 1, P_LBTREE
put16(P + 26, 500); put16(P + 28, 496); put16(P + 30, 488); put16(P + 32, 484); // index array → item offsets
auto keyItem = [&](std::size_t off, const std::string& type) { // BKEYDATA holding CompactSize(len)+type
std::string kd; kd += (char)type.size(); kd += type;
put16(P + off, (unsigned)kd.size()); w[P + off + 2] = (char)1;
for (std::size_t k = 0; k < kd.size(); ++k) w[P + off + 3 + k] = kd[k];
};
auto dataItem = [&](std::size_t off) { put16(P + off, 1); w[P + off + 2] = (char)1; w[P + off + 3] = 0; };
keyItem(500, "tx"); dataItem(496); keyItem(488, "key"); dataItem(484);
writef(dir / "btree.dat", w);
auto s = dragonx::util::parseWalletBtree((dir / "btree.dat").string());
EXPECT_TRUE(s.parsed); EXPECT_TRUE(s.complete);
EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1);
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
}
fs::remove_all(dir, ec);
}