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:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user