feat(wallets): recursive subdir scan + wallet.dat encryption/seed badges

Search user-added wallet folders RECURSIVELY (bounded: depth/hit/visit caps,
skip_permission_denied, no symlink-follow, node-junk + node-subtree pruning);
the datadir stays top-level. r.dir is each file's real parent so Open/import
resolve subdir wallets. Auto-suffix the import destination on a name collision.

Add util/wallet_file_probe.h: read encryption/seed/shielded flags straight off
a wallet.dat WITHOUT loading it — validate the Berkeley DB btree magic, then a
bounded streaming byte-scan for the length-prefixed record markers the daemon
writes (mkey -> encrypted; hdseed/hdchain -> seed/HD; zkey/sapzkey -> shielded).
Reads no key material. The Wallets dialog shows Encrypted / Seed / Legacy badges,
plus an Unknown badge when a large file couldn't be fully scanned (so absence of
a lock never falsely reads as unencrypted). Unit-tested + validated read-only
against real wallets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:32:59 -05:00
parent 57147fd06b
commit 112c581247
4 changed files with 319 additions and 22 deletions

View File

@@ -9,7 +9,9 @@
#pragma once
#include <algorithm>
#include <cctype>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
@@ -23,6 +25,7 @@
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/text_format.h"
#include "../../util/wallet_file_probe.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../notifications.h"
#include "../layout.h"
@@ -134,7 +137,11 @@ public:
for (std::size_t i = 0; i < s_rows.size(); ++i) {
const WalletRow& r = s_rows[i];
const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName);
// The wallet index is keyed by bare filename and is only ever written for the active
// DATADIR wallet — so an external row (esp. a same-named one surfaced by the recursive
// subdir scan) must NOT borrow a datadir wallet's cached balance/addresses. Only look up
// metadata for datadir rows; external rows show size + "never opened" (accurate).
const data::WalletIndexEntry* meta = r.inDatadir ? app->walletIndex().find(r.fileName) : nullptr;
const bool isCurrent = r.inDatadir && r.fileName == active;
ImVec2 rMin = ImGui::GetCursorScreenPos();
@@ -164,9 +171,23 @@ public:
const float btnX = rMax.x - padX - btnW;
const float textR = btnX - Layout::spacingMd();
// Name (+ info icon for external files)
// Name (+ info icon for external files, + status badges from the no-daemon file probe)
const float topY = rMin.y + cardPadY;
float extra = (!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f;
// Right-aligned status badges: a lock (if encrypted) and a seed/legacy marker. Reserve their
// width up front so the filename truncates before them instead of overlapping.
const float badgeSz = metaFont->LegacySize;
// Only positive detections are definitive; a MISSING marker is trustworthy solely when the
// whole file was scanned (r.probeComplete). So: lock iff encrypted; seed iff hdSeed; "legacy"
// only when complete AND no seed; and when the scan was truncated without confirming
// encryption, an explicit "unknown" badge — so absence-of-lock never falsely reads as
// "unencrypted" on a huge wallet whose mkey record sat past the probe cap.
const bool bLock = r.probed && r.encrypted;
const bool bSeed = r.probed && r.hdSeed;
const bool bLegacy = r.probed && r.probeComplete && !r.hdSeed;
const bool bUnknown = r.probed && !r.probeComplete && !r.encrypted;
const int nBadges = (bLock ? 1 : 0) + (bSeed ? 1 : 0) + (bLegacy ? 1 : 0) + (bUnknown ? 1 : 0);
const float badgeReserve = nBadges > 0 ? nBadges * (badgeSz + Layout::spacingSm()) + Layout::spacingSm() : 0.0f;
float extra = ((!r.inDatadir) ? (metaFont->LegacySize + Layout::spacingSm()) : 0.0f) + badgeReserve;
std::string nm = fit(r.fileName, nameFont, std::max(24.0f * dp, textR - x - extra));
dl->AddText(nameFont, nameFont->LegacySize, ImVec2(x, topY), OnSurface(), nm.c_str());
if (!r.inDatadir) {
@@ -175,6 +196,20 @@ public:
if (ImGui::IsMouseHoveringRect(ip, ImVec2(ip.x + metaFont->LegacySize, ip.y + metaFont->LegacySize)))
Tooltip("%s\n%s", TR("wallets_external_tt"), r.dir.c_str());
}
if (nBadges > 0) {
float bx = textR;
auto badge = [&](const char* glyph, ImU32 col, const char* tip) {
bx -= badgeSz;
ImVec2 bp(bx, topY + (nameFont->LegacySize - badgeSz));
dl->AddText(icoFont, badgeSz, bp, col, glyph);
if (ImGui::IsMouseHoveringRect(bp, ImVec2(bp.x + badgeSz, bp.y + badgeSz))) Tooltip("%s", tip);
bx -= Layout::spacingSm();
};
if (bLock) badge(ICON_MD_LOCK, WithAlpha(Warning(), 235), TR("wallets_badge_encrypted"));
if (bSeed) badge(ICON_MD_ECO, WithAlpha(Success(), 220), TR("wallets_badge_seed"));
if (bLegacy) badge(ICON_MD_HISTORY, WithAlpha(OnSurfaceMedium(), 210), TR("wallets_badge_legacy"));
if (bUnknown) badge(ICON_MD_HELP_OUTLINE, WithAlpha(OnSurfaceMedium(), 170), TR("wallets_badge_unknown"));
}
// Metadata line (size · N addresses · balance DRGX · last opened)
std::string ms = util::Platform::formatFileSize((uint64_t)r.sizeBytes);
@@ -288,6 +323,11 @@ private:
std::string dir;
bool inDatadir = false;
long long sizeBytes = 0;
// Light facts read off the wallet.dat file WITHOUT loading it (see util/wallet_file_probe.h).
bool probed = false; // the file validated as a BDB wallet and was scanned
bool probeComplete = false; // scan covered the whole file → a MISSING marker is trustworthy
bool encrypted = false; // passphrase-encrypted (has an mkey record)
bool hdSeed = false; // HD/seed wallet (else legacy) — ties into migrate-to-seed
};
// Force a "wallet-...dat" filename (plain, no path) so a new/imported wallet shows in the
@@ -312,12 +352,21 @@ private:
// from there) under a wallet-*.dat name, then switch to it. Never overwrites an existing file.
static void importAndOpen(App* app, const WalletRow& r) {
namespace fs = std::filesystem;
std::string dest = normalizeWalletName(r.fileName);
if (dest.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
const std::string base = normalizeWalletName(r.fileName);
if (base.empty()) { Notifications::instance().warning(TR("wallets_name_invalid")); return; }
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
fs::create_directories(datadir, ec);
const std::string destPath = datadir + "/" + dest;
// The recursive subdir scan can surface several distinct wallets sharing one basename (e.g. a
// "wallet.dat" in two backup folders). Auto-suffix the datadir destination to the next free name
// instead of hard-failing, so the second one is importable too. Never overwrites an existing file.
const std::string stem = base.substr(0, base.size() - 4); // strip ".dat"
std::string dest = base;
std::string destPath = datadir + "/" + dest;
for (int n = 2; n < 1000 && fs::exists(destPath, ec); ++n) {
dest = stem + "-" + std::to_string(n) + ".dat";
destPath = datadir + "/" + dest;
}
if (fs::exists(destPath, ec)) { Notifications::instance().warning(TR("wallets_exists")); return; }
fs::copy_file(r.dir + "/" + r.fileName, destPath, ec);
if (ec) { Notifications::instance().error(TR("wallets_import_failed")); return; }
@@ -327,32 +376,98 @@ private:
}
// Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat)
// and each user-added folder (any *.dat). Exception-safe: iterates with error_codes.
// and each user-added folder (any *.dat). User-added folders are searched RECURSIVELY into
// subdirectories; the datadir stays top-level only (never descend into blocks/ chainstate/ …).
// Exception-safe: iterates with error_codes. Recursion is bounded (depth / hit / visit caps) and
// never follows directory symlinks (std default), so it can't loop or stall the UI on a huge tree.
static void scan() {
s_rows.clear();
namespace fs = std::filesystem;
auto addFrom = [](const std::string& dir, bool inDatadir) {
// Guards for recursive external-folder scans (a user could point us at a large tree / home dir).
constexpr int kMaxDepth = 8; // don't descend deeper than this below the chosen folder
constexpr size_t kMaxHits = 200; // cap total wallet files listed
constexpr int kMaxVisited = 40000; // cap total entries walked, so scan() can't stall the UI
// Shared budget for the no-daemon wallet.dat probes (encryption / seed badges) so a pile of large
// wallets can't turn scan() into a multi-hundred-MB read on the UI thread; each file is capped too.
// Cover realistic wallets fully (the active wallet can be well over 100 MB of tx history, and a
// BDB btree sorts long-keyed records — mkey/hdchain — LATE, so a small cap risks a false-negative
// badge). Per-file 256 MB + a shared budget keep even that a bounded, one-shot read on dialog open.
std::size_t probeBudget = 768u * 1024u * 1024u;
constexpr std::size_t kProbePerFile = 256u * 1024u * 1024u;
// Node/daemon .dat files that are NOT wallets. The datadir scan already restricts to a "wallet"
// prefix, but recursive external folders accept any *.dat — and a user can easily point the picker
// at a folder that IS or CONTAINS a datadir, so filter these out everywhere or the list fills with
// blk*/rev* block files, peers.dat, etc. presented as importable "wallets".
auto isNodeArtifact = [](const std::string& n) {
static const char* kExact[] = { "peers.dat", "banlist.dat", "fee_estimates.dat",
"mempool.dat", "asmap.dat", "zindex.dat" };
for (const char* e : kExact) if (n == e) return true;
return n.rfind("blk", 0) == 0 || n.rfind("rev", 0) == 0; // blk00000.dat / rev00000.dat
};
auto consider = [&](const fs::path& p, bool inDatadir) {
// String-only checks FIRST (no syscalls) — the vast majority of entries in a large tree are
// neither .dat nor wallets, so reject them before paying for a stat().
const std::string name = p.filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") return;
if (inDatadir) { if (name.rfind("wallet", 0) != 0) return; } // datadir: wallet-prefixed only
else { if (isNodeArtifact(name)) return; } // external: reject node junk
std::error_code fec;
if (!fs::is_regular_file(p, fec)) return;
WalletRow r;
r.fileName = name;
r.dir = p.parent_path().string(); // the file's ACTUAL parent (may be a subdir) — Open reads r.dir + "/" + r.fileName
r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(p, fec));
// Read encryption / seed flags straight off the file (no daemon), within the byte budget.
if (probeBudget > 0) {
const auto pr = util::probeWalletFile(p.string(), std::min(probeBudget, kProbePerFile));
r.probed = pr.isBerkeleyDB;
r.probeComplete = pr.scanComplete;
r.encrypted = pr.encrypted;
r.hdSeed = pr.hdSeed;
// Charge the budget by bytes ACTUALLY read (early-exit reads far less than a big file),
// so a few large wallets don't starve later rows of their probe.
probeBudget -= std::min(probeBudget, pr.bytesRead);
}
s_rows.push_back(std::move(r));
};
// Directory names we never descend into during a recursive external scan — node data subtrees hold
// no user wallets and would otherwise burn the visit budget (and flood the list) with block files.
auto isNodeSubtree = [](const std::string& n) {
return n == "blocks" || n == "chainstate" || n == "database" ||
n == "indexes" || n == "index";
};
auto addFrom = [&](const std::string& dir, bool inDatadir, bool recursive) {
std::error_code ec;
if (dir.empty() || !fs::is_directory(dir, ec)) return;
fs::directory_iterator it(dir, ec), end;
if (!recursive) {
fs::directory_iterator it(dir, ec), end;
for (; !ec && it != end; it.increment(ec)) consider(it->path(), inDatadir);
return;
}
// skip_permission_denied keeps a locked subdir from aborting the whole walk; the iterator
// does NOT follow directory symlinks by default, so symlink cycles can't cause infinite loops.
fs::recursive_directory_iterator it(dir, fs::directory_options::skip_permission_denied, ec), end;
int visited = 0;
for (; !ec && it != end; it.increment(ec)) {
std::error_code fec;
if (!it->is_regular_file(fec)) continue;
std::string name = it->path().filename().string();
if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue;
if (inDatadir && name.rfind("wallet", 0) != 0) continue;
WalletRow r;
r.fileName = name;
r.dir = dir;
r.inDatadir = inDatadir;
r.sizeBytes = static_cast<long long>(fs::file_size(it->path(), fec));
s_rows.push_back(std::move(r));
if (++visited > kMaxVisited) break;
std::error_code dec;
if (it->is_directory(dec)) {
// Prune: don't descend past the depth cap or into node data subtrees.
if (it.depth() >= kMaxDepth || isNodeSubtree(it->path().filename().string()))
it.disable_recursion_pending();
continue; // directories aren't wallet files
}
consider(it->path(), inDatadir);
if (s_rows.size() >= kMaxHits) break;
}
};
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true);
addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true, /*recursive=*/false);
if (s_app)
for (const auto& f : s_app->walletIndex().extraFolders())
addFrom(f, /*inDatadir=*/false);
addFrom(f, /*inDatadir=*/false, /*recursive=*/true);
}
static inline bool s_open = false;

View File

@@ -262,6 +262,10 @@ void I18n::loadBuiltinEnglish()
strings_["wallets_col_opened"] = "Last opened";
strings_["wallets_current"] = "current";
strings_["wallets_active"] = "Active";
strings_["wallets_badge_encrypted"] = "Encrypted (passphrase-protected)";
strings_["wallets_badge_seed"] = "Seed phrase wallet (HD)";
strings_["wallets_badge_legacy"] = "Legacy wallet (no seed phrase)";
strings_["wallets_badge_unknown"] = "Wallet type not fully determined (large file — open to confirm)";
strings_["wallets_open"] = "Open";
strings_["wallets_never"] = "Never";
strings_["wallets_import_hint"] = "Import to open";

View File

@@ -0,0 +1,122 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// wallet_file_probe.h — read light metadata off a wallet.dat WITHOUT loading it into the daemon.
//
// DragonX wallet.dat is a Berkeley DB (btree) key/value store. Only the private-key material is
// encrypted (ckey/czkey/csapzkey/chdseed); the record *keys* and non-secret metadata live in
// plaintext, so a handful of boolean facts can be recovered cheaply by (a) validating the BDB btree
// magic and (b) scanning the raw bytes for the length-prefixed record names the daemon writes
// (a std::string is serialized as CompactSize(len)+bytes, so e.g. an "mkey" record's key begins with
// the 5 bytes 0x04 'm' 'k' 'e' 'y'). This is a heuristic PRESENCE test — reliable for booleans, not
// for exact counts — with no libdb dependency and no daemon. It never reads or exposes key material.
#pragma once
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <fstream>
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
namespace util {
struct WalletFileProbe {
bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat)
bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted
bool hdSeed = false; ///< has hdseed/chdseed/hdchain → HD/seed wallet (else legacy)
bool hasShielded = false; ///< has zkey/czkey/sapzkey/csapzkey → holds shielded addresses
// A found marker is always definitive; a MISSING marker is only trustworthy when the scan actually
// covered the whole file. `scanComplete` is true when we reached EOF, or early-exited having found
// everything — false only when the byte cap cut the scan short. Callers should treat negative flags
// (e.g. "legacy" = !hdSeed, or "not encrypted" = !encrypted) as reliable only when scanComplete is true.
bool scanComplete = false;
std::size_t bytesRead = 0; ///< bytes actually consumed (early-exit reads far less than the file) — for budgeting
};
// Probe a wallet.dat by header-validating it as a BDB btree then byte-scanning (bounded, streaming,
// early-exit) for record markers. `maxBytes` caps how far we read so a pathologically large file can't
// stall the caller. Returns an all-false probe (isBerkeleyDB=false) for anything that isn't a readable
// BDB btree file. Safe to call on a wallet currently open by the daemon (read-only, pattern scan only).
inline WalletFileProbe probeWalletFile(const std::string& path,
std::size_t maxBytes = 96u * 1024u * 1024u) {
WalletFileProbe out;
std::ifstream f(path, std::ios::binary);
if (!f) return out;
// --- 1) Validate the Berkeley DB btree metapage magic (offset 12, either endianness). ---
unsigned char hdr[16];
f.read(reinterpret_cast<char*>(hdr), sizeof(hdr));
if (f.gcount() < static_cast<std::streamsize>(sizeof(hdr))) return out;
auto rd32 = [](const unsigned char* p, bool le) -> 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);
};
constexpr uint32_t kBtreeMagic = 0x00053162u; // DB_BTREEMAGIC
if (rd32(hdr + 12, true) != kBtreeMagic && rd32(hdr + 12, false) != kBtreeMagic) return out;
out.isBerkeleyDB = true;
// --- 2) Streaming byte-scan for length-prefixed record markers. ---
// group: 0 = encrypted, 1 = hd/seed, 2 = shielded.
const std::vector<std::pair<std::string, int>> patterns = {
{std::string("\x04", 1) + "mkey", 0}, // master key → wallet is encrypted
{std::string("\x06", 1) + "hdseed", 1}, // plaintext HD seed
{std::string("\x07", 1) + "chdseed", 1}, // encrypted HD seed
{std::string("\x07", 1) + "hdchain", 1}, // HD chain counter (present in both HD variants)
{std::string("\x04", 1) + "zkey", 2}, // sprout shielded key
{std::string("\x05", 1) + "czkey", 2}, // encrypted sprout key
{std::string("\x07", 1) + "sapzkey", 2}, // sapling shielded key
{std::string("\x08", 1) + "csapzkey", 2}, // encrypted sapling key
};
std::size_t maxPat = 0;
for (const auto& p : patterns) maxPat = std::max(maxPat, p.first.size());
const std::size_t overlap = maxPat > 0 ? maxPat - 1 : 0; // bytes carried between chunks for split matches
f.clear();
f.seekg(0, std::ios::beg);
constexpr std::size_t kChunk = 1u << 20; // 1 MiB
std::vector<char> buf(kChunk);
std::string carry;
std::size_t readTotal = 0;
bool enc = false, hd = false, sh = false;
bool eof = false, allFound = false;
while (readTotal < maxBytes) {
const std::size_t want = std::min(kChunk, maxBytes - readTotal);
f.read(buf.data(), static_cast<std::streamsize>(want));
const std::streamsize got = f.gcount();
if (got <= 0) { eof = true; break; }
readTotal += static_cast<std::size_t>(got);
std::string window;
window.reserve(carry.size() + static_cast<std::size_t>(got));
window.assign(carry);
window.append(buf.data(), static_cast<std::size_t>(got));
for (const auto& p : patterns) {
bool& flag = (p.second == 0) ? enc : (p.second == 1) ? hd : sh;
if (!flag && window.find(p.first) != std::string::npos) flag = true;
}
if (enc && hd && sh) { allFound = true; break; } // nothing more to learn
if (window.size() > overlap) carry.assign(window.data() + window.size() - overlap, overlap);
else carry.assign(window);
if (static_cast<std::size_t>(got) < want) { eof = true; break; } // reached EOF
}
out.encrypted = enc;
out.hdSeed = hd;
out.hasShielded = sh;
out.bytesRead = readTotal;
// false only if the byte cap cut a larger file short. If we stopped because readTotal hit maxBytes on
// an exact chunk boundary, ifstream never set eofbit — peek() tells us whether the file actually ended
// there (EOF → complete) or there's more beyond the cap (a real byte → truncated).
out.scanComplete = allFound || eof;
if (!out.scanComplete && f && f.peek() == std::ifstream::traits_type::eof()) out.scanComplete = true;
return out;
}
} // namespace util
} // namespace dragonx

View File

@@ -36,6 +36,7 @@
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "util/wallet_file_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
#include "wallet/lite_owned_string.h"
@@ -737,6 +738,60 @@ void testPaymentUri()
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
}
void testWalletFileProbe()
{
namespace fs = std::filesystem;
using dragonx::util::probeWalletFile;
const fs::path dir = fs::path("/tmp/dragonx") / "probe";
std::error_code ec; fs::create_directories(dir, ec);
auto writef = [](const fs::path& p, const std::string& d) {
std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size());
};
// A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here).
auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; };
// Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}.
auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; };
// 1) Non-BDB file → not recognized, no flags.
writef(dir / "junk.dat", std::string(4096, 'x'));
{ auto p = probeWalletFile((dir / "junk.dat").string());
EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); }
// 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy.
writef(dir / "legacy.dat", bdb() + std::string(2000, '\0'));
{ auto p = probeWalletFile((dir / "legacy.dat").string());
EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed);
EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); }
// 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey).
writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') +
rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd'));
{ auto p = probeWalletFile((dir / "enc.dat").string());
EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); }
// 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count.
writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0'));
{ auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); }
// 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller
// won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete.
{ std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d);
auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u);
EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete);
EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u); // budget accounting reflects a truncated read
auto full = probeWalletFile((dir / "deep.dat").string());
EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); }
// 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still
// report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated.
{ std::string d = bdb(); d.resize(1024u * 1024u, 'e'); // exactly one 1 MiB chunk, no markers
writef(dir / "exact.dat", d);
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); }
fs::remove_all(dir, ec);
}
void testAmountFormatting()
{
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
@@ -6263,6 +6318,7 @@ int main()
testHushChatOutgoing();
testHushChatTransport();
testHushChatShuffledReceive();
testWalletFileProbe();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();