diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index ca3155c..c31bb8c 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -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)); diff --git a/src/util/wallet_file_probe.h b/src/util/wallet_file_probe.h index 24c7a37..9f3b100 100644 --- a/src/util/wallet_file_probe.h +++ b/src/util/wallet_file_probe.h @@ -138,10 +138,33 @@ struct WalletBtreeStats { long long createdEpoch = 0; ///< earliest keymeta nCreateTime (wallet birthday); 0 = unknown bool encrypted = false; ///< saw an mkey record 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) 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, std::size_t maxBytes = 256u * 1024u * 1024u) { WalletBtreeStats st; @@ -287,7 +310,14 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path, 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; + 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")) { // CKeyMetadata data = nVersion(int32) + nCreateTime(int64) + …, all little-endian (Bitcoin // serialization). The earliest non-zero nCreateTime is the wallet birthday (daemon's diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index ebc317f..1484b3d 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -828,6 +828,63 @@ void testWalletFileProbe() 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(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>(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); }