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;