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:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user