Add BIP39 seed phrases (SilentDragonXLite-compatible) and HD transparent keys
Derive transparent (t-addr) keys from the HD seed and add BIP39 mnemonic seed phrases that are byte-for-byte compatible with SilentDragonXLite, so the same 24 words recover the same shielded and transparent addresses in either wallet. HD transparent keys: - Derive t-keys from the seed at m/44'/coin'/0'/0/i (were random CKeys). - CHDChain gains a version-gated transparent counter; existing wallets load unchanged. GenerateNewKey routes through DeriveNewChildKey when enabled (-hdtransparent, default on). - Restore from a seed hex via -hdseed with gap-limit pre-derivation; birthday pinned to genesis so the rescan is not clipped. BIP39 seed phrases: - Wire the vendored trezor BIP39 lib (src/crypto/bip39) into the build, fix its BIP39_WORDS guard, and disable the insecure mnemonic cache. - Match SDXLite exactly: English wordlist, empty passphrase, PBKDF2 64-byte seed, coin type 141, ZIP-32 m/32'/141'/i' and BIP44 m/44'/141'/0'/0/i. Store the 32-byte entropy and expand to the 64-byte seed on demand. - Restore via -mnemonic, create via -usemnemonic, reveal via z_exportmnemonic. Verified by gtests including a known-answer BIP39 seed vector and z/t address derivation checks (src/gtest/test_hdtransparent.cpp, test_mnemonic_compat.cpp). Docs in doc/hd-transparent-keys.md and doc/seed-phrase.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
171
src/gtest/test_hdtransparent.cpp
Normal file
171
src/gtest/test_hdtransparent.cpp
Normal file
@@ -0,0 +1,171 @@
|
||||
// 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
|
||||
//
|
||||
// Tests for HD-derived transparent keys (m/44'/coin'/0'/0/i) and the
|
||||
// version-gated CHDChain serialization used to persist the transparent counter.
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "key.h"
|
||||
#include "chainparams.h"
|
||||
#include "streams.h"
|
||||
#include "uint256.h"
|
||||
#include "util.h"
|
||||
#include "version.h"
|
||||
#include "wallet/wallet.h"
|
||||
#include "wallet/walletdb.h"
|
||||
#include "zcash/zip32.h"
|
||||
|
||||
// Build an in-memory wallet with a known seed + hdChain so that the
|
||||
// HD-transparent path (IsHDTransparentEnabled) is active.
|
||||
static void LoadSeedForTest(CWallet& wallet, const HDSeed& seed)
|
||||
{
|
||||
wallet.LoadHDSeed(seed);
|
||||
CHDChain chain;
|
||||
chain.nVersion = CHDChain::VERSION_HD_TRANSPARENT;
|
||||
chain.seedFp = seed.Fingerprint();
|
||||
chain.nCreateTime = 1;
|
||||
wallet.SetHDChain(chain, true /* memonly */);
|
||||
}
|
||||
|
||||
// Same seed must reproduce the same transparent addresses in the same order:
|
||||
// this is the recovery guarantee that lets a seed-only restore find coinbase.
|
||||
TEST(hdtransparent_tests, DeterministicFromSeed)
|
||||
{
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
|
||||
RawHDSeed rawSeed(HD_WALLET_SEED_LENGTH, 0x42);
|
||||
HDSeed seed(rawSeed);
|
||||
|
||||
std::vector<CKeyID> keysA;
|
||||
{
|
||||
CWallet wallet;
|
||||
LoadSeedForTest(wallet, seed);
|
||||
ASSERT_TRUE(wallet.IsHDTransparentEnabled());
|
||||
|
||||
LOCK(wallet.cs_wallet);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
CPubKey pk = wallet.GenerateNewKey();
|
||||
keysA.push_back(pk.GetID());
|
||||
|
||||
const CKeyMetadata& md = wallet.mapKeyMetadata[pk.GetID()];
|
||||
EXPECT_EQ(md.seedFp, seed.Fingerprint());
|
||||
EXPECT_EQ(md.hdKeypath, std::string("m/44'/141'/0'/0/") + std::to_string(i));
|
||||
}
|
||||
}
|
||||
|
||||
// Fresh wallet, same seed -> identical keys.
|
||||
{
|
||||
CWallet wallet;
|
||||
LoadSeedForTest(wallet, seed);
|
||||
|
||||
LOCK(wallet.cs_wallet);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
CPubKey pk = wallet.GenerateNewKey();
|
||||
EXPECT_EQ(pk.GetID(), keysA[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pin the exact derivation path so it can never silently change.
|
||||
TEST(hdtransparent_tests, KnownDerivationPath)
|
||||
{
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
|
||||
RawHDSeed rawSeed(HD_WALLET_SEED_LENGTH, 0x42);
|
||||
HDSeed seed(rawSeed);
|
||||
|
||||
// Independently derive m/44'/141'/0'/0/0.
|
||||
RawHDSeed raw = seed.RawSeed();
|
||||
CExtKey m, purpose, coinType, account, external, child;
|
||||
m.SetMaster(raw.data(), raw.size());
|
||||
m.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);
|
||||
CKeyID expected = child.key.GetPubKey().GetID();
|
||||
|
||||
CWallet wallet;
|
||||
LoadSeedForTest(wallet, seed);
|
||||
LOCK(wallet.cs_wallet);
|
||||
CPubKey pk = wallet.GenerateNewKey();
|
||||
EXPECT_EQ(pk.GetID(), expected);
|
||||
}
|
||||
|
||||
// A pre-existing v1 CHDChain record (no transparent counter) must still
|
||||
// deserialize under v2 code, leaving transparentChildCounter at 0; and a v2
|
||||
// record must round-trip the counter.
|
||||
TEST(hdtransparent_tests, HDChainVersionCompat)
|
||||
{
|
||||
CHDChain v1;
|
||||
v1.nVersion = CHDChain::VERSION_HD_BASE; // 1: transparentChildCounter not serialized
|
||||
v1.seedFp = uint256S("0000000000000000000000000000000000000000000000000000000000000001");
|
||||
v1.nCreateTime = 12345;
|
||||
v1.saplingAccountCounter = 7;
|
||||
|
||||
CDataStream ss(SER_DISK, PROTOCOL_VERSION);
|
||||
ss << v1;
|
||||
|
||||
CHDChain out; // default-constructed: SetNull() zeroes transparentChildCounter
|
||||
ss >> out;
|
||||
EXPECT_EQ(out.nVersion, +CHDChain::VERSION_HD_BASE); // unary + -> rvalue, avoid ODR-use of static const
|
||||
EXPECT_EQ(out.seedFp, v1.seedFp);
|
||||
EXPECT_EQ(out.nCreateTime, (int64_t)12345);
|
||||
EXPECT_EQ(out.saplingAccountCounter, (uint32_t)7);
|
||||
EXPECT_EQ(out.transparentChildCounter, (uint32_t)0);
|
||||
|
||||
CHDChain v2;
|
||||
v2.nVersion = CHDChain::VERSION_HD_TRANSPARENT;
|
||||
v2.saplingAccountCounter = 3;
|
||||
v2.transparentChildCounter = 42;
|
||||
CDataStream ss2(SER_DISK, PROTOCOL_VERSION);
|
||||
ss2 << v2;
|
||||
|
||||
CHDChain out2;
|
||||
ss2 >> out2;
|
||||
EXPECT_EQ(out2.nVersion, +CHDChain::VERSION_HD_TRANSPARENT);
|
||||
EXPECT_EQ(out2.saplingAccountCounter, (uint32_t)3);
|
||||
EXPECT_EQ(out2.transparentChildCounter, (uint32_t)42);
|
||||
}
|
||||
|
||||
// Restoring from a 32-byte seed hex reproduces the same keys as the source
|
||||
// wallet, and refuses to run when a seed already exists.
|
||||
TEST(hdtransparent_tests, RestoreFromSeedHex)
|
||||
{
|
||||
SelectParams(CBaseChainParams::MAIN);
|
||||
|
||||
RawHDSeed rawSeed(HD_WALLET_SEED_LENGTH, 0x7a);
|
||||
HDSeed seed(rawSeed);
|
||||
std::string seedHex = HexStr(seed.RawSeed());
|
||||
|
||||
// Source wallet: derive some keys.
|
||||
std::vector<CKeyID> expected;
|
||||
{
|
||||
CWallet wallet;
|
||||
LoadSeedForTest(wallet, seed);
|
||||
LOCK(wallet.cs_wallet);
|
||||
for (int i = 0; i < 3; i++)
|
||||
expected.push_back(wallet.GenerateNewKey().GetID());
|
||||
}
|
||||
|
||||
// Restored wallet: inject the seed hex, pre-derive, and compare.
|
||||
{
|
||||
CWallet wallet;
|
||||
ASSERT_TRUE(wallet.SetHDSeedFromHex(seedHex));
|
||||
// Second attempt must fail: a seed already exists.
|
||||
EXPECT_FALSE(wallet.SetHDSeedFromHex(seedHex));
|
||||
|
||||
wallet.TopUpHDTransparentKeys(3, 1);
|
||||
LOCK(wallet.cs_wallet);
|
||||
for (int i = 0; i < 3; i++)
|
||||
EXPECT_TRUE(wallet.HaveKey(expected[i]));
|
||||
}
|
||||
|
||||
// Bad input is rejected.
|
||||
{
|
||||
CWallet wallet;
|
||||
EXPECT_FALSE(wallet.SetHDSeedFromHex("nothex"));
|
||||
EXPECT_FALSE(wallet.SetHDSeedFromHex("abcd")); // too short
|
||||
}
|
||||
}
|
||||
141
src/gtest/test_mnemonic_compat.cpp
Normal file
141
src/gtest/test_mnemonic_compat.cpp
Normal file
@@ -0,0 +1,141 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user