diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index 4a4276a..01bbfc7 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -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 lk(batch->mtx); if (t.second < batch->results.size()) batch->results[t.second] = res; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index fbda5fa..5fe334a 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/src/util/wallet_file_probe.h b/src/util/wallet_file_probe.h index 0e3a629..1ae49de 100644 --- a/src/util/wallet_file_probe.h +++ b/src/util/wallet_file_probe.h @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -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(static_cast(sz), maxBytes); + f.seekg(0, std::ios::beg); + buf.resize(want); + f.read(&buf[0], static_cast(want)); + buf.resize(static_cast(std::max(0, f.gcount()))); + if (buf.size() < 512) return st; + st.bytesRead = buf.size(); + st.complete = (buf.size() == static_cast(sz)); // read the whole file, not cap-truncated + } + const unsigned char* B = reinterpret_cast(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(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 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 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(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(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 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(pgno) * pagesize + 25]; + if (pt == P_BTREEMETA) { // subdb metapage → its root is at +88 + const uint32_t sr = r32(static_cast(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(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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 98adbb0..3aa1bff 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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); }