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
+18
View File
@@ -855,6 +855,18 @@ public:
// Id of the in-flight autoshield op; read by the op to confirm it is still
// the current one before mutating scheduler state.
AsyncRPCOperationId saplingAutoShieldOperationId;
// Provenance of this wallet's HD seed, recorded once in wallet.dat the
// first time a build that knows about it opens the wallet. Features that
// move funds into addresses only the seed can re-derive must not turn
// themselves ON by default unless the user can actually restore that seed.
enum HDSeedOrigin {
HDSEED_ORIGIN_UNRECORDED = 0, // no record in wallet.dat yet
HDSEED_ORIGIN_CREATED = 1, // minted onto a brand-new empty wallet
HDSEED_ORIGIN_RESTORED = 2, // user supplied -mnemonic / -hdseed
HDSEED_ORIGIN_RETROFIT = 3, // minted onto a pre-existing seedless wallet
HDSEED_ORIGIN_UNKNOWN = 4, // seed predates this record
};
int hdSeedOrigin = HDSEED_ORIGIN_UNRECORDED;
void ClearNoteWitnessCache();
@@ -1373,6 +1385,12 @@ public:
void SetHDChain(const CHDChain& chain, bool memonly);
const CHDChain& GetHDChain() const { return hdChain; }
/* Record (in memory and in wallet.dat) how this wallet's HD seed came to
exist. Best-effort: a failed write is logged, not fatal — the next start
simply re-classifies, and re-classification always errs toward
HDSEED_ORIGIN_UNKNOWN, which is the conservative answer. */
void SetHDSeedOrigin(int origin);
/* Set the current HD seed, without saving it to disk (used by LoadWallet) */
bool LoadHDSeed(const HDSeed& key);