wallet: harden HD seed/chain persistence and record seed provenance

Autoshield sends mined coinbase to a seed-derived z-address, so the records that
decide how the seed derives keys become fund-safety critical. Three of them were
not treated that way.

InstallHDSeed wrote the seed record before the chain record, non-transactionally.
A crash between the two left a wallet holding a seed with no hdchain: on the next
load hdChain silently reverts to defaults, clearing fMnemonicSeed -- which
switches the derivation input from the expanded BIP39 seed to the raw entropy --
and resetting saplingAccountCounter. Write the chain first; the opposite torn
state is harmless and self-heals, because HaveHDSeed() is then false and init
installs again.

The hdchain record was read as a bare deserialise inside a catch-all with strErr
never set, and it is not a key type, so a corrupt record was downgraded to a
non-critical error and the node booted into the wrong key tree. Report it, track
whether it was read, and refuse to load a wallet that holds a seed but no
readable hdchain. Also preserve hdchain through a keys-only salvage, which would
otherwise drop it and produce exactly the state we now refuse.

GenerateNewSeed silently fell back to a random seed when BIP39 generation failed,
producing a wallet that looks mnemonic-capable but whose words can never be
exported and which no seed phrase can restore. A user who asked for -usemnemonic
now gets that or a hard failure.

Finally, record how the seed came to exist -- created on an empty wallet,
restored from -mnemonic/-hdseed, retrofitted onto a pre-existing seedless wallet,
or predating this record. A retrofitted seed is in no backup the user already
holds, so a feature that moves funds into addresses only that seed can re-derive
must not enable itself there by default. Nothing consumes this yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 03:18:35 +02:00
co-authored by Claude Opus 5
parent dad162a89a
commit a494eabdce
5 changed files with 147 additions and 24 deletions
+40 -22
View File
@@ -2470,30 +2470,26 @@ void CWallet::GenerateNewSeed()
// Opt-in: create the wallet from a fresh BIP39 mnemonic so its 24 words can
// be exported (z_exportmnemonic) and used in SilentDragonXLite.
//
// NO SILENT FALLBACK. Falling back to a random seed here produced a wallet
// that looks mnemonic-capable but whose words can never be exported
// (z_exportmnemonic refuses non-mnemonic wallets, rpcdump.cpp:1031+) and
// that no seed phrase can restore. A user who asked for -usemnemonic must
// get that or a hard failure.
if (GetBoolArg("-usemnemonic", false)) {
RawHDSeed entropy;
if (GenerateMnemonicEntropy(256, entropy)) {
HDSeed seed(entropy);
if (InstallHDSeed(seed, true, nCreationTime))
return;
}
LogPrintf("%s: -usemnemonic seed generation failed, falling back to a random seed\n", __func__);
if (!GenerateMnemonicEntropy(256, entropy))
throw std::runtime_error(std::string(__func__) + ": -usemnemonic entropy generation failed");
HDSeed seed(entropy);
if (!InstallHDSeed(seed, true, nCreationTime))
throw std::runtime_error(std::string(__func__) + ": installing the mnemonic HD seed failed");
return;
}
auto seed = HDSeed::Random(HD_WALLET_SEED_LENGTH);
// If the wallet is encrypted and locked, this will fail.
if (!SetHDSeed(seed))
auto seed = HDSeed::Random(HD_WALLET_SEED_LENGTH);
if (!InstallHDSeed(seed, false, nCreationTime))
throw std::runtime_error(std::string(__func__) + ": SetHDSeed failed");
// store the key creation time together with
// the child index counter in the database
// as a hdchain object
CHDChain newHdChain;
newHdChain.nVersion = CHDChain::VERSION_HD_TRANSPARENT;
newHdChain.seedFp = seed.Fingerprint();
newHdChain.nCreateTime = nCreationTime;
SetHDChain(newHdChain, false);
}
bool CWallet::SetHDSeed(const HDSeed& seed)
@@ -2535,6 +2531,7 @@ bool CWallet::SetCryptedHDSeed(const uint256& seedFp, const std::vector<unsigned
return false;
}
void CWallet::SetHDChain(const CHDChain& chain, bool memonly)
{
LOCK(cs_wallet);
@@ -2544,6 +2541,20 @@ void CWallet::SetHDChain(const CHDChain& chain, bool memonly)
hdChain = chain;
}
void CWallet::SetHDSeedOrigin(int origin)
{
LOCK(cs_wallet);
hdSeedOrigin = origin;
// Deliberately non-fatal, unlike SetHDChain: losing this record must never
// stop a node from starting. The cost of a failed write is that the next
// start re-classifies the wallet, and re-classification of an already-seeded
// wallet yields HDSEED_ORIGIN_UNKNOWN, i.e. the safe answer.
if (fFileBacked && !CWalletDB(strWalletFile).WriteHDSeedOrigin((int64_t)origin))
LogPrintf("%s: WARNING: could not record HD seed origin %d in wallet.dat\n", __func__, origin);
}
bool CWallet::LoadHDSeed(const HDSeed& seed)
{
return CBasicKeyStore::SetHDSeed(seed);
@@ -2558,16 +2569,23 @@ bool CWallet::InstallHDSeed(const HDSeed& seed, bool fMnemonic, int64_t nCreateT
{
AssertLockHeld(cs_wallet);
if (!SetHDSeed(seed))
return false;
// Chain BEFORE seed. A crash between the two records must never leave a
// wallet that holds a seed with no hdchain: on the next load hdChain would
// silently revert to its defaults, clearing fMnemonicSeed (which switches
// the derivation input, wallet.cpp:2615-2633) and resetting
// saplingAccountCounter. The opposite torn state — chain without seed — is
// harmless and self-healing: HaveHDSeed() is false, so init installs a seed
// again and overwrites the chain.
CHDChain newHdChain;
newHdChain.nVersion = fMnemonic ? CHDChain::VERSION_HD_MNEMONIC
: CHDChain::VERSION_HD_TRANSPARENT;
newHdChain.seedFp = seed.Fingerprint();
newHdChain.nCreateTime = nCreateTime;
newHdChain.fMnemonicSeed = fMnemonic;
SetHDChain(newHdChain, false);
SetHDChain(newHdChain, false); // throws if the write fails
if (!SetHDSeed(seed))
return false;
return true;
}