fix(wallets): detect seed-phrase wallets offline via hdchain fMnemonicSeed

The wallet switcher couldn't tell a BIP39 seed-phrase wallet from a legacy /
raw-entropy HD wallet without loading it, so non-active rows fell back to bare
HD-record presence — which mislabeled every HD wallet as "Seed phrase".

The distinction is actually on disk: the daemon serializes CHDChain with an
fMnemonicSeed bool (VERSION_HD_MNEMONIC=3, byte offset 52), and the hdchain
record stays plaintext even in an encrypted wallet. Read it directly:

- wallet_file_probe.h: hdChainMnemonicFlag() decodes the flag from the hdchain
  value (1 mnemonic / 2 no-phrase / 0 undecidable); WalletBtreeStats.mnemonicSeed
  surfaces it from the tier-2 btree walk.
- wallets_dialog.h: the badge prefers runtime z_exportmnemonic for the active
  wallet, then the on-disk flag, then HD-record presence — so every row is
  classified correctly (or honestly shows "?" when the flag can't be read, e.g.
  the tier-1 byte-scan fallback).
- tests: decode-level cases (v3 set/clear, v1/v2, truncated, garbage version)
  plus an end-to-end btree walk asserting mnemonicSeed.

Offset math + badge logic adversarially verified against the daemon source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 16:24:06 -05:00
parent 5874142186
commit e6c78da062
3 changed files with 106 additions and 10 deletions

View File

@@ -288,16 +288,23 @@ public:
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
const bool bLock = pres.probed && pres.encrypted;
// Seed-phrase vs legacy: the offline probe only sees HD records (hdseed/hdchain), which
// are present in BOTH mnemonic and legacy-HD wallets — so it can't tell them apart. For
// the ACTIVE wallet the app knows the truth (z_exportmnemonic → activeWalletSeedBadge);
// prefer that and fall back to the probe only for non-active or not-yet-decided wallets.
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually
// distinguishes the two. seed uses the same 1/2/0 encoding.
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
const bool bSeed = (activeBadge == 1) || (activeBadge == 0 && pres.probed && pres.hdSeed);
const bool bLegacy = (activeBadge == 2) ||
(activeBadge == 0 && pres.probed && pres.complete && !pres.hdSeed);
// No "unknown" seed badge once the runtime status is authoritative for the active wallet.
const bool bUnknown = (activeBadge == 0) && pres.probed && !pres.complete && !pres.encrypted;
int seed = activeBadge;
if (seed == 0 && pres.probed) {
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
}
const bool bSeed = (seed == 1);
const bool bLegacy = (seed == 2);
// Undecided: probed but neither the runtime status nor the on-disk flag could classify it
// (incomplete scan, or an unreadable hdchain value) — show a question mark rather than
// mislabel. Suppressed for encrypted rows, which still carry a definitive Lock badge.
const bool bUnknown = (seed == 0) && pres.probed && !pres.encrypted;
struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
Badge bl[4]; int nb = 0;
if (bSeed) bl[nb++] = { ICON_MD_ECO, WithAlpha(Success(), 235), TR("wallets_badge_seed_short"), TR("wallets_badge_seed") };
@@ -549,6 +556,7 @@ private:
int keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
int txCount = 0; // wallet transaction records
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown
int mnemonic = 0; // hdchain fMnemonicSeed flag: 0 unknown, 1 seed-phrase, 2 no phrase
};
struct ProbeBatch {
std::mutex mtx;
@@ -775,6 +783,7 @@ private:
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, bt.createdEpoch };
res.mnemonic = bt.mnemonicSeed; // read straight off the hdchain record
budget -= std::min(budget, bt.bytesRead);
} else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));