wallet: store the expanded BIP39 seed for mnemonic wallets

Mnemonic wallets stored the 32-byte BIP39 entropy as the HD seed and relied on
CHDChain.fMnemonicSeed to tell the deriver to expand it first. That flag is
version-gated in the CHDChain serialisation, so a binary that predates it reads
the record, never consumes the trailing byte, and derives from the raw entropy --
a different key tree, silently, with no error.

Store the expanded 64-byte BIP39 seed instead, with fMnemonic = false, and keep
the entropy in its own display-only record. Derivation then reads the stored
bytes directly on every binary, old or new, so key trees are identical and no
CHDChain version bump or minversion fence is needed. The wallet format stays
readable by earlier releases rather than becoming one-way.

Addresses are unchanged: the previous format expanded the entropy on every read
and fed the same 64 bytes to Master(). This is also the form the tree already
round-trips through -- z_exportwallet dumps the expanded seed, and restoring that
hex via -hdseed installs it with fMnemonic = false.

Write order is load-bearing: seed first, entropy second. A crash between them
leaves a wallet with a seed and no phrase, which is merely inconvenient. The
reverse would leave an entropy record with no seed, and the next start would mint
a different seed while the wallet still held a phrase for the old one.

-usemnemonic still defaults to false; only explicit opt-in and -mnemonic restores
take this path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 06:39:38 +02:00
parent 2d7dd90c55
commit 20b2cbe830
2 changed files with 146 additions and 6 deletions

View File

@@ -139,3 +139,103 @@ TEST(mnemonic_compat, RawEntropyDiffersFromMnemonicSeed)
const std::string entropyT = DeriveTAddrFromSeedBytes(zeros); // wrong (32-byte)
EXPECT_NE(seedT, entropyT);
}
// New storage form: the HD seed IS the expanded 64-byte BIP39 seed, the chain is
// NOT flagged mnemonic, and the phrase comes from the separate entropy record.
TEST(mnemonic_compat, ExpandedSeedIsStoredDirectly)
{
SelectParams(CBaseChainParams::MAIN);
CWallet wallet;
ASSERT_TRUE(wallet.SetHDSeedFromMnemonic(ABANDON_ART));
// Stored bytes == the 64-byte BIP39 seed, fed to derivation unchanged.
HDSeed stored;
ASSERT_TRUE(wallet.GetHDSeed(stored));
auto raw = stored.RawSeed();
EXPECT_EQ(raw.size(), (size_t)64);
EXPECT_EQ(HexStr(raw.begin(), raw.end()), std::string(SEED64_HEX));
// No CHDChain version bump / no mnemonic flag: an older binary reads this
// wallet and derives the same tree.
EXPECT_FALSE(wallet.GetHDChain().fMnemonicSeed);
EXPECT_LT(wallet.GetHDChain().nVersion, CHDChain::VERSION_HD_MNEMONIC);
HDSeed forDerivation;
ASSERT_TRUE(wallet.GetHDSeedForDerivation(forDerivation));
EXPECT_EQ(forDerivation.RawSeed(), raw);
// The phrase is still exportable, and IsMnemonicSeed() (which gates
// z_exportmnemonic) still says yes.
EXPECT_TRUE(wallet.IsMnemonicSeed());
EXPECT_TRUE(wallet.HaveMnemonicEntropy());
std::string exported;
ASSERT_TRUE(wallet.GetMnemonicPhrase(exported));
EXPECT_EQ(exported, std::string(ABANDON_ART));
}
// Backwards compatibility: a wallet in the OLD form (stored HD seed == 32-byte
// entropy, fMnemonicSeed = true) must still derive and still export its phrase.
TEST(mnemonic_compat, LegacyEntropySeedStillWorks)
{
SelectParams(CBaseChainParams::MAIN);
RawHDSeed zeros(32, 0), seed64;
ASSERT_TRUE(Bip39SeedFromEntropy(zeros, seed64));
CWallet wallet;
{
LOCK(wallet.cs_wallet);
RawHDSeed entropy(32, 0);
HDSeed legacy(entropy);
ASSERT_TRUE(wallet.InstallHDSeed(legacy, true, 1));
}
EXPECT_TRUE(wallet.GetHDChain().fMnemonicSeed);
EXPECT_FALSE(wallet.HaveMnemonicEntropy());
EXPECT_TRUE(wallet.IsMnemonicSeed());
// Still expanded on read -> same key tree as the new form.
HDSeed forDerivation;
ASSERT_TRUE(wallet.GetHDSeedForDerivation(forDerivation));
EXPECT_EQ(forDerivation.RawSeed(), seed64);
{
LOCK(wallet.cs_wallet);
EXPECT_EQ(EncodePaymentAddress(wallet.GenerateNewSaplingZKey()),
DeriveZAddrFromSeed64(seed64));
}
std::string exported;
ASSERT_TRUE(wallet.GetMnemonicPhrase(exported));
EXPECT_EQ(exported, std::string(ABANDON_ART));
}
// The entropy record refuses replacement, and a phrase that does not restore the
// installed seed is never printed.
TEST(mnemonic_compat, MnemonicEntropyGuards)
{
SelectParams(CBaseChainParams::MAIN);
RawHDSeed zeros(32, 0), ones(32, 1);
// Refuse-to-replace.
CWallet wallet;
ASSERT_TRUE(wallet.SetHDSeedFromMnemonic(ABANDON_ART));
EXPECT_FALSE(wallet.SetMnemonicEntropy(ones));
EXPECT_FALSE(wallet.SetMnemonicEntropy(RawHDSeed())); // empty is not "installed"
// Mismatched entropy -> no phrase. Install a 64-byte seed that is NOT the
// expansion of `zeros`, then attach `zeros` as entropy.
RawHDSeed otherSeed64;
ASSERT_TRUE(Bip39SeedFromEntropy(ones, otherSeed64));
CWallet mismatched;
ASSERT_TRUE(mismatched.SetHDSeedFromHex(HexStr(otherSeed64.begin(), otherSeed64.end())));
ASSERT_TRUE(mismatched.SetMnemonicEntropy(zeros));
std::string phrase;
EXPECT_FALSE(mismatched.GetMnemonicPhrase(phrase));
// Matching entropy attached to a hex-restored wallet -> phrase available.
CWallet matched;
ASSERT_TRUE(matched.SetHDSeedFromHex(std::string(SEED64_HEX)));
ASSERT_TRUE(matched.SetMnemonicEntropy(zeros));
ASSERT_TRUE(matched.GetMnemonicPhrase(phrase));
EXPECT_EQ(phrase, std::string(ABANDON_ART));
}

View File

@@ -2480,9 +2480,30 @@ void CWallet::GenerateNewSeed()
RawHDSeed entropy;
if (!GenerateMnemonicEntropy(256, entropy))
throw std::runtime_error(std::string(__func__) + ": -usemnemonic entropy generation failed");
HDSeed seed(entropy);
if (!InstallHDSeed(seed, true, nCreationTime))
// Store the EXPANDED 64-byte BIP39 seed as the HD seed, with
// fMnemonic = false. Every binary -- old or new -- then feeds the stored
// bytes straight into derivation, so the key tree is identical
// everywhere and no CHDChain version bump or minversion fence is needed.
// The 32-byte entropy is kept in a separate, display-only record purely
// so the phrase can be reprinted. Addresses are unchanged from the
// previous format, which expanded the stored entropy on every read.
RawHDSeed seed64;
if (!Bip39SeedFromEntropy(entropy, seed64))
throw std::runtime_error(std::string(__func__) + ": BIP39 seed expansion failed");
HDSeed seed(seed64);
// ORDER IS LOAD-BEARING: seed first, entropy second, never the reverse.
// A crash between the two leaves a wallet with a seed and no phrase --
// recoverable via z_exportwallet, merely inconvenient. The reverse order
// would leave entropy with no seed; the next start would mint a
// DIFFERENT seed while the wallet still held a phrase for the old one.
// (GetMnemonicPhrase cross-checks the two and would refuse to print it,
// but do not rely on that here.)
if (!InstallHDSeed(seed, false, nCreationTime))
throw std::runtime_error(std::string(__func__) + ": installing the mnemonic HD seed failed");
if (!SetMnemonicEntropy(entropy))
throw std::runtime_error(std::string(__func__) + ": storing the mnemonic entropy failed");
return;
}
@@ -2713,10 +2734,29 @@ bool CWallet::SetHDSeedFromMnemonic(const std::string& phrase)
if (!MnemonicToEntropy(phrase, entropy))
return false;
// Store the BIP39 entropy as the HDSeed (SilentDragonXLite's on-disk
// convention); the 64-byte seed is expanded from it on demand.
HDSeed seed(entropy);
return InstallHDSeed(seed, true, 1); // birthday = genesis for a restore
// Store the EXPANDED 64-byte BIP39 seed as the HD seed (fMnemonic = false);
// the entropy goes in its own record and is used only to reprint the phrase.
// Derivation therefore reads the stored bytes directly on any binary, and
// the resulting addresses are byte-identical to the previous format, which
// expanded the stored entropy on every derivation. SilentDragonXLite
// interop is unaffected: the same words still yield the same seed64.
RawHDSeed seed64;
if (!Bip39SeedFromEntropy(entropy, seed64))
return false;
HDSeed seed(seed64);
// Seed first, entropy second -- see the ordering note in GenerateNewSeed.
if (!InstallHDSeed(seed, false, 1)) // birthday = genesis for a restore
return false;
// Non-fatal on a restore, unlike GenerateNewSeed: the user already holds the
// phrase (they just typed it), the seed is installed and the wallet is fully
// functional; only z_exportmnemonic is lost.
if (!SetMnemonicEntropy(entropy)) {
LogPrintf("%s: WARNING: HD seed installed but the mnemonic entropy record could not be "
"stored; z_exportmnemonic will be unavailable on this wallet\n", __func__);
}
return true;
}
bool CWallet::GetHDSeedForDerivation(HDSeed& seedOut) const