Files
dragonx/src/gtest/test_mnemonic_compat.cpp
T
DanSandClaude Opus 5 20b2cbe830 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>
2026-08-23 06:39:38 +02:00

242 lines
9.4 KiB
C++

// Copyright (c) 2016-2024 The Hush developers
// Distributed under the GPLv3 software license, see the accompanying
// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Proves that a BIP39 seed phrase produces the SAME transparent and shielded
// addresses on the DragonX full node as in SilentDragonXLite. The proof chain:
// phrase -> entropy (round-trip) -> 64-byte BIP39 seed (known-answer)
// -> z/t addresses (wallet path == direct ZIP-32/BIP44 derivation).
// The 64-byte seed is anchored to the well-known BIP39 value for the all-zero
// "abandon...art" entropy with an EMPTY passphrase, which is exactly what
// SilentDragonXLite's tiny-bip39 0.6.2 feeds into the same coin_type=141 paths.
#include <gtest/gtest.h>
#include "chainparams.h"
#include "key.h"
#include "key_io.h"
#include "util.h"
#include "wallet/mnemonic.h"
#include "wallet/wallet.h"
#include "zcash/Address.hpp"
#include "zcash/zip32.h"
// The canonical 24-word phrase for 32 bytes of all-zero entropy.
static const char* ABANDON_ART =
"abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon art";
// The standard BIP39 seed for that phrase with an EMPTY passphrase
// (PBKDF2-HMAC-SHA512, 2048 rounds, salt "mnemonic"). Matches tiny-bip39.
static const char* SEED64_HEX =
"408b285c123836004f4b8842c89324c1f01382450c0d439af345ba7fc49acf70"
"5489c6fc77dbd4e3dc1dd8cc6bc9f043db8ada1e243c4a0eafb290d399480840";
// First shielded address for a 64-byte seed: m/32'/141'/0' default address.
static std::string DeriveZAddrFromSeed64(RawHDSeed seed64)
{
HDSeed s(seed64);
auto m = libzcash::SaplingExtendedSpendingKey::Master(s);
auto xsk = m.Derive(32 | ZIP32_HARDENED_KEY_LIMIT)
.Derive(141 | ZIP32_HARDENED_KEY_LIMIT)
.Derive(0 | ZIP32_HARDENED_KEY_LIMIT);
return EncodePaymentAddress(xsk.DefaultAddress());
}
// First transparent address for a BIP32 master over `seedBytes`:
// m/44'/141'/0'/0/0.
static std::string DeriveTAddrFromSeedBytes(RawHDSeed seedBytes)
{
CExtKey master, purpose, coinType, account, external, child;
master.SetMaster(seedBytes.data(), seedBytes.size());
master.Derive(purpose, 44 | BIP32_HARDENED_KEY_LIMIT);
purpose.Derive(coinType, 141 | BIP32_HARDENED_KEY_LIMIT);
coinType.Derive(account, 0 | BIP32_HARDENED_KEY_LIMIT);
account.Derive(external, 0);
external.Derive(child, 0);
return EncodeDestination(child.key.GetPubKey().GetID());
}
// The 64-byte seed derived from the mnemonic must equal the known BIP39 value.
// This is the cross-wallet anchor: SilentDragonXLite feeds the identical seed.
TEST(mnemonic_compat, Bip39SeedKnownAnswer)
{
RawHDSeed entropy(32, 0);
RawHDSeed seed64;
ASSERT_TRUE(Bip39SeedFromEntropy(entropy, seed64));
ASSERT_EQ(seed64.size(), (size_t)64);
EXPECT_EQ(HexStr(seed64.begin(), seed64.end()), std::string(SEED64_HEX));
}
TEST(mnemonic_compat, EntropyPhraseRoundTrip)
{
RawHDSeed zeros(32, 0);
std::string phrase;
ASSERT_TRUE(EntropyToMnemonic(zeros, phrase));
EXPECT_EQ(phrase, std::string(ABANDON_ART));
EXPECT_TRUE(MnemonicIsValid(ABANDON_ART));
RawHDSeed entropy;
ASSERT_TRUE(MnemonicToEntropy(ABANDON_ART, entropy));
EXPECT_EQ(entropy.size(), (size_t)32);
EXPECT_EQ(HexStr(entropy.begin(), entropy.end()), std::string(64, '0'));
// Bad checksum / unknown words are rejected.
EXPECT_FALSE(MnemonicIsValid("abandon abandon abandon"));
EXPECT_FALSE(MnemonicIsValid("clearly not valid bip39 words at all here"));
RawHDSeed junk;
EXPECT_FALSE(MnemonicToEntropy("clearly not valid bip39 words at all here", junk));
}
// The wallet's mnemonic derivation must reproduce the exact addresses obtained
// by driving ZIP-32 / BIP44 directly from the known 64-byte seed, and must be
// deterministic across wallets.
TEST(mnemonic_compat, WalletDerivesSdxliteAddresses)
{
SelectParams(CBaseChainParams::MAIN);
RawHDSeed zeros(32, 0), seed64;
ASSERT_TRUE(Bip39SeedFromEntropy(zeros, seed64));
const std::string expZ = DeriveZAddrFromSeed64(seed64);
const std::string expT = DeriveTAddrFromSeedBytes(seed64);
EXPECT_EQ(expZ.substr(0, 2), "zs"); // sapling HRP for mainnet
CWallet wallet;
ASSERT_TRUE(wallet.SetHDSeedFromMnemonic(ABANDON_ART));
ASSERT_TRUE(wallet.IsMnemonicSeed());
{
LOCK(wallet.cs_wallet);
EXPECT_EQ(EncodePaymentAddress(wallet.GenerateNewSaplingZKey()), expZ);
EXPECT_EQ(EncodeDestination(wallet.GenerateNewKey().GetID()), expT);
}
// Same phrase, fresh wallet -> identical first addresses.
CWallet wallet2;
ASSERT_TRUE(wallet2.SetHDSeedFromMnemonic(ABANDON_ART));
{
LOCK(wallet2.cs_wallet);
EXPECT_EQ(EncodePaymentAddress(wallet2.GenerateNewSaplingZKey()), expZ);
EXPECT_EQ(EncodeDestination(wallet2.GenerateNewKey().GetID()), expT);
}
// The phrase round-trips out of the wallet.
std::string exported;
ASSERT_TRUE(wallet.GetMnemonicPhrase(exported));
EXPECT_EQ(exported, std::string(ABANDON_ART));
}
// Negative: feeding the 32-byte entropy DIRECTLY as the seed (the classic
// interop bug) must produce a different address than the 64-byte BIP39 seed.
TEST(mnemonic_compat, RawEntropyDiffersFromMnemonicSeed)
{
SelectParams(CBaseChainParams::MAIN);
RawHDSeed zeros(32, 0), seed64;
ASSERT_TRUE(Bip39SeedFromEntropy(zeros, seed64));
const std::string seedT = DeriveTAddrFromSeedBytes(seed64); // correct (64-byte)
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));
}