// 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 #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)); }