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. // 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 ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
const bool bLock = pres.probed && pres.encrypted; const bool bLock = pres.probed && pres.encrypted;
// Seed-phrase vs legacy: the offline probe only sees HD records (hdseed/hdchain), which // Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
// are present in BOTH mnemonic and legacy-HD wallets — so it can't tell them apart. For // authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
// the ACTIVE wallet the app knows the truth (z_exportmnemonic → activeWalletSeedBadge); // record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
// prefer that and fall back to the probe only for non-active or not-yet-decided wallets. // 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 int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
const bool bSeed = (activeBadge == 1) || (activeBadge == 0 && pres.probed && pres.hdSeed); int seed = activeBadge;
const bool bLegacy = (activeBadge == 2) || if (seed == 0 && pres.probed) {
(activeBadge == 0 && pres.probed && pres.complete && !pres.hdSeed); if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
// No "unknown" seed badge once the runtime status is authoritative for the active wallet. else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
const bool bUnknown = (activeBadge == 0) && pres.probed && !pres.complete && !pres.encrypted; }
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; }; struct Badge { const char* glyph; ImU32 col; const char* label; const char* tip; };
Badge bl[4]; int nb = 0; 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") }; 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 keyCount = 0; // transparent + shielded spendable keys (≈ addresses, incl. change)
int txCount = 0; // wallet transaction records int txCount = 0; // wallet transaction records
long long createdEpoch = 0; // wallet birthday (earliest keymeta nCreateTime); 0 = unknown 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 { struct ProbeBatch {
std::mutex mtx; std::mutex mtx;
@@ -775,6 +783,7 @@ private:
const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile)); const auto bt = util::parseWalletBtree(t.first, std::min(budget, kPerFile));
if (bt.parsed && bt.complete) { if (bt.parsed && bt.complete) {
res = ProbeResult{ true, true, bt.encrypted, bt.hdSeed, true, bt.addresses(), bt.txCount, bt.createdEpoch }; 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); budget -= std::min(budget, bt.bytesRead);
} else { } else {
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile)); const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));

View File

@@ -138,10 +138,33 @@ struct WalletBtreeStats {
long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown
bool encrypted = false; ///< saw an mkey record bool encrypted = false; ///< saw an mkey record
bool hdSeed = false; ///< saw hdseed/chdseed/hdchain bool hdSeed = false; ///< saw hdseed/chdseed/hdchain
// BIP39 seed-phrase detection read straight off the hdchain record's fMnemonicSeed flag (which the
// daemon keeps PLAINTEXT even in an encrypted wallet — only the seed itself is crypted):
// 0 = undecidable, 1 = mnemonic seed-phrase wallet (z_exportmnemonic works), 2 = HD/legacy, no phrase.
int mnemonicSeed = 0;
std::size_t bytesRead = 0; ///< bytes actually read (for budgeting) std::size_t bytesRead = 0; ///< bytes actually read (for budgeting)
int addresses() const { return transparentKeys + shieldedKeys; } int addresses() const { return transparentKeys + shieldedKeys; }
}; };
// Recover the BIP39-mnemonic flag from a serialized CHDChain record value (the plaintext "hdchain"
// record). The daemon serializes CHDChain in Bitcoin/Zcash little-endian byte order as:
// nVersion:int32 | seedFp:32B | nCreateTime:int64 | saplingAccountCounter:uint32
// | [nVersion>=2 (VERSION_HD_TRANSPARENT)] transparentChildCounter:uint32
// | [nVersion>=3 (VERSION_HD_MNEMONIC)] fMnemonicSeed:bool(1B)
// so fMnemonicSeed sits at byte offset 52 (= 4+32+8+4+4) once nVersion>=3. Returns 1 = mnemonic,
// 2 = HD but not mnemonic (raw-entropy seed, or a pre-mnemonic v1/v2 wallet), 0 = undecidable.
inline int hdChainMnemonicFlag(const unsigned char* val, uint32_t len) {
if (!val || len < 4) return 0;
const uint32_t v = (uint32_t)val[0] | ((uint32_t)val[1] << 8) |
((uint32_t)val[2] << 16) | ((uint32_t)val[3] << 24);
const int32_t nVersion = (int32_t)v;
if (nVersion < 1 || nVersion > 100) return 0; // implausible version → treat as unreadable, not "legacy"
if (nVersion < 3) return 2; // VERSION_HD_MNEMONIC not reached → field absent → no phrase
constexpr uint32_t kMnemonicOff = 52;
if (len <= kMnemonicOff) return 0; // truncated/unexpected value → can't read the flag
return val[kMnemonicOff] ? 1 : 2;
}
inline WalletBtreeStats parseWalletBtree(const std::string& path, inline WalletBtreeStats parseWalletBtree(const std::string& path,
std::size_t maxBytes = 256u * 1024u * 1024u) { std::size_t maxBytes = 256u * 1024u * 1024u) {
WalletBtreeStats st; WalletBtreeStats st;
@@ -287,7 +310,14 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path,
else if (is("name")) st.addressBook++; else if (is("name")) st.addressBook++;
else if (is("tx")) st.txCount++; else if (is("tx")) st.txCount++;
else if (is("mkey")) st.encrypted = true; else if (is("mkey")) st.encrypted = true;
else if (is("hdseed") || is("chdseed") || is("hdchain")) st.hdSeed = true; else if (is("hdseed") || is("chdseed")) st.hdSeed = true;
else if (is("hdchain")) {
st.hdSeed = true;
// The hdchain VALUE carries fMnemonicSeed — read it to tell a BIP39 seed-phrase wallet
// apart from a legacy/raw-entropy HD wallet (bare record presence can't). First one wins.
if (st.mnemonicSeed == 0 && dtype == B_KEYDATA && dp)
st.mnemonicSeed = hdChainMnemonicFlag(dp, dl);
}
else if (is("keymeta")) { else if (is("keymeta")) {
// CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin // CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin
// serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's // serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's

View File

@@ -828,6 +828,63 @@ void testWalletFileProbe()
EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed); EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed);
} }
// 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the
// CHDChain value once nVersion>=3; v1/v2 have no such field (→ "no phrase"), and a short/garbage
// value is undecidable (0), never mislabeled "legacy".
{
using dragonx::util::hdChainMnemonicFlag;
auto chdchain = [](int32_t ver, int flag /* -1 = omit the trailing byte */) {
std::string v;
for (int k = 0; k < 4; ++k) v += (char)(((uint32_t)ver >> (8 * k)) & 0xff); // nVersion, LE
v.append(32 + 8 + 4 + 4, '\x07'); // seedFp+times+counters (48B)
if (flag >= 0) v += (char)(flag ? 1 : 0); // fMnemonicSeed @ offset 52
return v;
};
auto flagOf = [&](const std::string& v) {
return hdChainMnemonicFlag(reinterpret_cast<const unsigned char*>(v.data()), (uint32_t)v.size());
};
EXPECT_EQ(flagOf(chdchain(3, 1)), 1); // v3, fMnemonicSeed=1 → seed phrase
EXPECT_EQ(flagOf(chdchain(3, 0)), 2); // v3, fMnemonicSeed=0 → HD, no phrase
EXPECT_EQ(flagOf(chdchain(2, -1)), 2); // v2 (field not serialized) → no phrase
EXPECT_EQ(flagOf(chdchain(1, -1)), 2); // v1 → no phrase
EXPECT_EQ(flagOf(chdchain(3, -1)), 0); // v3 but value ends before offset 52 → undecidable
EXPECT_EQ(flagOf(chdchain(999, 1)), 0); // implausible version → undecidable, NOT legacy
EXPECT_EQ(hdChainMnemonicFlag(nullptr, 0), 0);
EXPECT_EQ(flagOf(std::string(3, '\0')), 0); // < 4 bytes → undecidable
}
// 9) End-to-end: a minimal btree whose single record is an hdchain with a v3 CHDChain value →
// parseWalletBtree surfaces hdSeed AND the decoded mnemonicSeed (1 for a seed-phrase wallet, 2 when
// the flag is clear). Exercises the value-reading path inside the real walk, not just the decoder.
{
auto buildHdchainWallet = [](const std::string& chdValue) {
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); // metapage → root = page 1
const std::size_t P = 512; // page 1: one (key,data) pair
put16(P+20, 2); w[P+24]=(char)1; w[P+25]=(char)5; // entries=2, level 1, P_LBTREE
put16(P+26, 490); put16(P+28, 430); // item offsets: key@490, data@430
std::string kd; kd += (char)7; kd += "hdchain"; // key BKEYDATA: CompactSize(7)+name
put16(P+490, (unsigned)kd.size()); w[P+490+2]=(char)1;
for (std::size_t k=0;k<kd.size();++k) w[P+490+3+k]=kd[k];
put16(P+430, (unsigned)chdValue.size()); w[P+430+2]=(char)1; // data BKEYDATA: the CHDChain value
for (std::size_t k=0;k<chdValue.size();++k) w[P+430+3+k]=chdValue[k];
return w;
};
std::string v3seed; // v3, fMnemonicSeed=1
for (int k=0;k<4;k++) v3seed += (char)((3u>>(8*k))&0xff);
v3seed.append(48, '\0'); v3seed += (char)1;
writef(dir / "hdchain_seed.dat", buildHdchainWallet(v3seed));
auto s1 = dragonx::util::parseWalletBtree((dir / "hdchain_seed.dat").string());
EXPECT_TRUE(s1.parsed); EXPECT_TRUE(s1.hdSeed); EXPECT_EQ(s1.mnemonicSeed, 1);
std::string v3nophrase = v3seed; v3nophrase.back() = (char)0; // fMnemonicSeed=0
writef(dir / "hdchain_legacy.dat", buildHdchainWallet(v3nophrase));
auto s2 = dragonx::util::parseWalletBtree((dir / "hdchain_legacy.dat").string());
EXPECT_TRUE(s2.parsed); EXPECT_TRUE(s2.hdSeed); EXPECT_EQ(s2.mnemonicSeed, 2);
}
fs::remove_all(dir, ec); fs::remove_all(dir, ec);
} }