diff --git a/src/Makefile.am b/src/Makefile.am index 86d199a52..1ba627e21 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -243,6 +243,7 @@ BITCOIN_CORE_H = \ wallet/asyncrpcoperation_mergetoaddress.h \ wallet/asyncrpcoperation_saplingconsolidation.h \ wallet/asyncrpcoperation_sweep.h \ + wallet/asyncrpcoperation_autoshieldcoinbase.h \ wallet/asyncrpcoperation_sendmany.h \ wallet/asyncrpcoperation_shieldcoinbase.h \ wallet/crypter.h \ @@ -321,6 +322,7 @@ libbitcoin_wallet_a_SOURCES = \ wallet/asyncrpcoperation_mergetoaddress.cpp \ wallet/asyncrpcoperation_saplingconsolidation.cpp \ wallet/asyncrpcoperation_sweep.cpp \ + wallet/asyncrpcoperation_autoshieldcoinbase.cpp \ wallet/asyncrpcoperation_sendmany.cpp \ wallet/asyncrpcoperation_shieldcoinbase.cpp \ wallet/crypter.cpp \ diff --git a/src/gtest/test_mnemonic_compat.cpp b/src/gtest/test_mnemonic_compat.cpp index cb623c921..ce7468e57 100644 --- a/src/gtest/test_mnemonic_compat.cpp +++ b/src/gtest/test_mnemonic_compat.cpp @@ -139,3 +139,103 @@ TEST(mnemonic_compat, RawEntropyDiffersFromMnemonicSeed) 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)); +} diff --git a/src/init.cpp b/src/init.cpp index a359c7200..2aa86dc46 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -471,7 +471,7 @@ std::string HelpMessage(HelpMessageMode mode) strUsage += HelpMessageOpt("-hdtransparent", strprintf(_("Derive transparent addresses from the HD seed so they can be recovered from it (default: %u)"), 1)); strUsage += HelpMessageOpt("-hdseed=", _("Restore a fresh/empty wallet from a 32- or 64-byte HD seed hex (the value shown in z_exportwallet's '# HDSeed=' line). WARNING: exposes the seed to your shell history and process list.")); strUsage += HelpMessageOpt("-mnemonic=", _("Restore/create a fresh/empty wallet from a BIP39 seed phrase, compatible with SilentDragonXLite (English, no passphrase; cross-wallet restore parity is mainnet-only -- testnet/regtest derive a different HD coin_type). WARNING: exposes the phrase to your shell history and process list; prefer DRAGONX.conf with tight permissions.")); - strUsage += HelpMessageOpt("-usemnemonic", strprintf(_("Create new wallets from a fresh BIP39 seed phrase so the 24 words can be exported (z_exportmnemonic) and used in SilentDragonXLite (default: %u)"), 0)); + strUsage += HelpMessageOpt("-usemnemonic", strprintf(_("Create new wallets from a fresh BIP39 seed phrase so the 24 words can be exported (z_exportmnemonic) and used in SilentDragonXLite. Set to 0 for a raw random seed with no recovery phrase; existing wallets are never changed (default: %u)"), 1)); strUsage += HelpMessageOpt("-hdtransparentgaplimit=", strprintf(_("On -mnemonic/-hdseed restore, pre-derive this many HD transparent keys so a rescan can find coinbase paid to them (default: %u)"), 1000)); strUsage += HelpMessageOpt("-mnemonicsaplinggap=", strprintf(_("On -mnemonic/-hdseed restore, pre-derive this many shielded (Sapling) addresses so a rescan can find notes sent to them (default: %u)"), 100)); strUsage += HelpMessageOpt("-consolidation", _("Enable auto Sapling note consolidation (default: false)")); @@ -489,6 +489,12 @@ std::string HelpMessage(HelpMessageMode mode) strUsage += HelpMessageOpt("-zsweepexternal", _("Enable sweeping to an external wallet (default false)")); strUsage += HelpMessageOpt("-zsweepexclude", _("Addresses to exclude from sweeping (default none)")); + strUsage += HelpMessageOpt("-autoshield", _("Automatically shield matured coinbase (mining rewards) into a seed-derived wallet z-address (default: true for wallets created or restored by this software, false when the HD seed provenance is unknown). No-op when not mining or wallet is locked.")); + strUsage += HelpMessageOpt("-autoshieldinterval", strprintf(_("Block interval between automatic coinbase-shielding rounds (default: %i, min 5)"), 25)); + strUsage += HelpMessageOpt("-autoshieldaddress=", _("Destination Sapling z-address for auto-shielded coinbase (default: reuse or create a wallet z-address). Must be spendable by this wallet.")); + strUsage += HelpMessageOpt("-autoshieldfee", strprintf(_("Fee in puposhis for automatic coinbase-shielding transactions (default: %i)"), 10000)); + strUsage += HelpMessageOpt("-autoshieldminutxos", strprintf(_("Only auto-shield once at least this many matured coinbase UTXOs exist (default: %i)"), 1)); + strUsage += HelpMessageOpt("-deletetx", _("Enable Old Transaction Deletion")); strUsage += HelpMessageOpt("-deleteinterval", strprintf(_("Delete transaction every blocks during inital block download (default: %i)"), DEFAULT_TX_DELETE_INTERVAL)); strUsage += HelpMessageOpt("-keeptxnum", strprintf(_("Keep the last transactions (default: %i)"), DEFAULT_TX_RETENTION_LASTTX)); @@ -2301,6 +2307,23 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) if (!pwalletMain->HaveHDSeed()) { + // Does this wallet predate the seed we are about to install? If so, + // that seed cannot appear in any backup the user already holds. + // Checked BEFORE installing, and before the restore path pre-derives + // its gap of keys. + bool fWalletHadContent = false; + { + LOCK(pwalletMain->cs_wallet); + std::set setExistingKeys; + pwalletMain->GetKeys(setExistingKeys); // keystore.h:60 / crypter.h:212 + std::set setExistingZAddrs; + pwalletMain->GetSaplingPaymentAddresses(setExistingZAddrs); // keystore.h:226-238 + fWalletHadContent = !setExistingKeys.empty() || + !setExistingZAddrs.empty() || + !pwalletMain->mapWallet.empty() || // wallet.h:1041 + pwalletMain->IsCrypted(); // crypter.h:174 + } + std::string mnemonic = GetArg("-mnemonic", ""); std::string hdSeedHex = GetArg("-hdseed", ""); bool restoring = false; @@ -2332,6 +2355,19 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) pwalletMain->GenerateNewSeed(); } + pwalletMain->SetHDSeedOrigin(restoring + ? CWallet::HDSEED_ORIGIN_RESTORED + : (fWalletHadContent ? CWallet::HDSEED_ORIGIN_RETROFIT + : CWallet::HDSEED_ORIGIN_CREATED)); + if (pwalletMain->hdSeedOrigin == CWallet::HDSEED_ORIGIN_RETROFIT) + { + LogPrintf("%s: WARNING: generated a new HD seed for a wallet that already held keys or " + "transactions. This seed is in NO backup you made before now.\n", __func__); + InitWarning(_("A new HD seed was generated for this pre-existing wallet. Any backup you " + "made before now does not contain it: back the wallet up again " + "(z_exportwallet) before receiving funds to newly derived addresses.")); + } + if (restoring) { // Pre-derive keys (birthday = genesis) so the startup rescan finds @@ -2350,6 +2386,15 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) LogPrintf("%s: pre-derived %d transparent and %d sapling keys for restore rescan\n", __func__, (int)tGap, (int)zGap); } } + else if (pwalletMain->hdSeedOrigin == CWallet::HDSEED_ORIGIN_UNRECORDED) + { + // The seed was installed by a build that predates this record, so + // we cannot tell whether it was minted onto a pre-existing wallet + // (and is therefore absent from the user's older backups). Assume + // the worst; the user can still opt in explicitly. + pwalletMain->SetHDSeedOrigin(CWallet::HDSEED_ORIGIN_UNKNOWN); + LogPrintf("%s: HD seed predates seed-provenance recording; recorded origin as unknown\n", __func__); + } //Set Sapling Consolidation pwalletMain->fSaplingConsolidationEnabled = GetBoolArg("-consolidation", false); @@ -2451,6 +2496,69 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) } } + //Set Automatic Coinbase Shielding (default ON, conditional: self-guards + //on nodes where it cannot act - no owned coinbase, external mineraddress, + //or locked wallet). Closes the transparent-coinbase leak for miners. + // Default ON only when this wallet's HD seed provenance says the + // destination is genuinely recoverable. Autoshield sends mined coinbase to + // a seed-derived z-address (resolveDestination), so for a seed retrofitted + // onto a pre-existing wallet - or one predating provenance recording - we + // cannot assume the user holds it. Those wallets opt in with -autoshield=1 + // after backing the seed up. + const bool fAutoShieldSeedKnown = + (pwalletMain->hdSeedOrigin == CWallet::HDSEED_ORIGIN_CREATED || + pwalletMain->hdSeedOrigin == CWallet::HDSEED_ORIGIN_RESTORED); + pwalletMain->fAutoShieldEnabled = GetBoolArg("-autoshield", fAutoShieldSeedKnown); + if (!fAutoShieldSeedKnown && !mapArgs.count("-autoshield")) { + LogPrintf("%s: autoshield left OFF by default: HD seed origin %d is not known-recoverable. " + "Back the seed up (z_exportwallet, or z_exportmnemonic on a mnemonic wallet) and " + "pass -autoshield=1 to enable.\n", __func__, pwalletMain->hdSeedOrigin); + } + if (pwalletMain->fAutoShieldEnabled) { + int autoShieldInterval = GetArg("-autoshieldinterval", 25); + if (autoShieldInterval < 5) { + fprintf(stderr,"%s: Invalid autoshield interval of %d < 5, setting to default of 25\n", __func__, autoShieldInterval); + autoShieldInterval = 25; + } + pwalletMain->autoShieldInterval = autoShieldInterval; + pwalletMain->nextAutoShield = pwalletMain->autoShieldInterval + chainActive.Height(); + + // Validate the fee: floor it above the relay minimum and cap it to + // guard against a fat-finger (e.g. -autoshieldfee=5000000000) that + // would otherwise build an over-fee or malformed shield tx that + // fails mempool admission every round. + CAmount autoShieldFee = GetArg("-autoshieldfee", 10000); + const CAmount AUTOSHIELD_MIN_FEE = 1000; // comfortably above minRelayTxFee for a small tx + const CAmount AUTOSHIELD_MAX_FEE = 10000000; // 0.1 DRGX; no sane autoshield fee exceeds this + if (autoShieldFee < AUTOSHIELD_MIN_FEE || autoShieldFee > AUTOSHIELD_MAX_FEE) { + fprintf(stderr,"%s: -autoshieldfee=%lld out of range [%lld,%lld], using default 10000\n", + __func__, (long long)autoShieldFee, (long long)AUTOSHIELD_MIN_FEE, (long long)AUTOSHIELD_MAX_FEE); + autoShieldFee = 10000; + } + pwalletMain->autoShieldFee = autoShieldFee; + pwalletMain->autoShieldMinUtxos = GetArg("-autoshieldminutxos", 1); + if (pwalletMain->autoShieldMinUtxos < 1) { + pwalletMain->autoShieldMinUtxos = 1; + } + LogPrintf("%s: autoshield enabled, nextAutoShield=%d interval=%d\n", __func__, pwalletMain->nextAutoShield, pwalletMain->autoShieldInterval); + + //Optional explicit destination z-address. Must be a Sapling zaddr the + //wallet can spend, else the shielded coinbase would be unrecoverable. + std::string autoShieldAddress = GetArg("-autoshieldaddress", ""); + if (!autoShieldAddress.empty()) { + auto zdest = DecodePaymentAddress(autoShieldAddress); + if (!IsValidPaymentAddress(zdest) || + boost::get(&zdest) == nullptr) { + return InitError("Invalid -autoshieldaddress: must be a Sapling z-address"); + } + auto hasSpendingKey = boost::apply_visitor(HaveSpendingKeyForPaymentAddress(pwalletMain), zdest); + if (!hasSpendingKey) { + return InitError("Wallet must hold the spending key of -autoshieldaddress (else shielded coinbase would be unrecoverable)"); + } + pwalletMain->autoShieldAddress = autoShieldAddress; + } + } + //Set Transaction Deletion Options fTxDeleteEnabled = GetBoolArg("-deletetx", false); fTxConflictDeleteEnabled = GetBoolArg("-deleteconflicttx", true); diff --git a/src/keystore.cpp b/src/keystore.cpp index 0e78cbdc9..b0d30bd56 100644 --- a/src/keystore.cpp +++ b/src/keystore.cpp @@ -68,6 +68,42 @@ bool CBasicKeyStore::GetHDSeed(HDSeed& seedOut) const } } +bool CBasicKeyStore::SetMnemonicEntropy(const RawHDSeed& entropy) +{ + LOCK(cs_SpendingKeyStore); + if (entropy.empty()) { + // Never "install" nothing: HaveMnemonicEntropy() would stay false while + // the caller was told the call succeeded. + return false; + } + if (!mnemonicEntropy.empty()) { + // Same refuse-to-replace rule as SetHDSeed above, for a sharper reason: + // this is the printable form of the seed. If it could be swapped while + // hdSeed stayed put, the wallet would print a seed phrase that does not + // restore it -- strictly worse than printing none. + return false; + } + mnemonicEntropy = entropy; + return true; +} + +bool CBasicKeyStore::HaveMnemonicEntropy() const +{ + LOCK(cs_SpendingKeyStore); + return !mnemonicEntropy.empty(); +} + +bool CBasicKeyStore::GetMnemonicEntropy(RawHDSeed& entropyOut) const +{ + LOCK(cs_SpendingKeyStore); + if (mnemonicEntropy.empty()) { + return false; + } else { + entropyOut = mnemonicEntropy; + return true; + } +} + bool CBasicKeyStore::AddKeyPubKey(const CKey& key, const CPubKey &pubkey) { LOCK(cs_KeyStore); diff --git a/src/keystore.h b/src/keystore.h index 666d38984..827fc2bcd 100644 --- a/src/keystore.h +++ b/src/keystore.h @@ -117,6 +117,12 @@ class CBasicKeyStore : public CKeyStore { protected: HDSeed hdSeed; + // BIP39 entropy for a mnemonic-recoverable wallet, kept BESIDE hdSeed, never + // instead of it. hdSeed holds the bytes actually fed to derivation (the + // expanded 64-byte BIP39 seed on new wallets); this record exists only so the + // seed phrase can be reprinted. Empty on legacy and hex-restored wallets, + // which is a normal state, not an error. + RawHDSeed mnemonicEntropy; KeyMap mapKeys; ScriptMap mapScripts; WatchOnlySet setWatchOnly; @@ -129,6 +135,18 @@ public: bool SetHDSeed(const HDSeed& seed); bool HaveHDSeed() const; bool GetHDSeed(HDSeed& seedOut) const; + //! Mnemonic entropy: optional, present only on phrase-recoverable wallets. + //! Unlike the three seed accessors above -- which override pure virtuals on + //! CKeyStore (keystore.h:48-51) and therefore dispatch dynamically -- these + //! are plain non-virtual members: CKeyStore declares nothing for them and + //! nothing reaches the entropy through a base pointer. Every caller holds a + //! CWallet*, whose static type resolves to the CCryptoKeyStore overloads. + //! Do NOT add them to CKeyStore: that would force all four subclasses + //! (CBasicKeyStore, CCryptoKeyStore, CWallet, gtest's TestCCryptoKeyStore) + //! to implement them for zero call sites. + bool SetMnemonicEntropy(const RawHDSeed& entropy); + bool HaveMnemonicEntropy() const; + bool GetMnemonicEntropy(RawHDSeed& entropyOut) const; bool AddKeyPubKey(const CKey& key, const CPubKey &pubkey); bool HaveKey(const CKeyID &address) const diff --git a/src/wallet/asyncrpcoperation_autoshieldcoinbase.cpp b/src/wallet/asyncrpcoperation_autoshieldcoinbase.cpp new file mode 100644 index 000000000..aa79af438 --- /dev/null +++ b/src/wallet/asyncrpcoperation_autoshieldcoinbase.cpp @@ -0,0 +1,407 @@ +// Copyright (c) 2016-2024 The Hush developers +// Copyright (c) 2024-2026 The DragonX developers +// Distributed under the GPLv3 software license, see the accompanying +// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html +#include "asyncrpcoperation_autoshieldcoinbase.h" +#include "asyncrpcoperation_shieldcoinbase.h" // for ShieldCoinbaseUTXO +#include "consensus/upgrades.h" +#include "hush_defs.h" // ASSETCHAINS_TIMELOCKGTE +#include "init.h" +#include "key_io.h" +#include "main.h" +#include "rpc/protocol.h" +#include "sync.h" +#include "tinyformat.h" +#include "transaction_builder.h" +#include "util.h" +#include "utilmoneystr.h" +#include "wallet.h" + +// Sietch dummy zaddr generator (defined in wallet/rpcwallet.cpp) +extern std::string randomSietchZaddr(); + +// Serialized-size estimates for one spent input (kept in sync with rpcwallet.cpp) +static const size_t AUTOSHIELD_CTXIN_DUST_SIZE = 148; +static const size_t AUTOSHIELD_CTXIN_P2SH_SIZE = 400; +// Expire unmined autoshield txs after this many blocks, so a tx cannot straddle +// a network-upgrade activation. +static const int AUTOSHIELD_EXPIRY_DELTA = 15; + +AsyncRPCOperation_autoshieldcoinbase::AsyncRPCOperation_autoshieldcoinbase(int targetHeight) + : targetHeight_(targetHeight) {} + +AsyncRPCOperation_autoshieldcoinbase::~AsyncRPCOperation_autoshieldcoinbase() {} + +void AsyncRPCOperation_autoshieldcoinbase::main() { + if (isCancelled()) { + // Only the CURRENT op owns the scheduler flag; a stale/cancelled op must + // not clear it out from under a freshly-enqueued successor. + if (pwalletMain) { + LOCK(pwalletMain->cs_wallet); + if (getId() == pwalletMain->saplingAutoShieldOperationId) { + pwalletMain->fAutoShieldRunning = false; + } + } + return; + } + + set_state(OperationStatus::EXECUTING); + start_execution_clock(); + + bool success = false; + + try { + success = main_impl(); + } catch (const UniValue& objError) { + int code = find_value(objError, "code").get_int(); + std::string message = find_value(objError, "message").get_str(); + set_error_code(code); + set_error_message(message); + } catch (const runtime_error& e) { + set_error_code(-1); + set_error_message("runtime error: " + string(e.what())); + } catch (const logic_error& e) { + set_error_code(-1); + set_error_message("logic error: " + string(e.what())); + } catch (const exception& e) { + set_error_code(-1); + set_error_message("general exception: " + string(e.what())); + } catch (...) { + set_error_code(-2); + set_error_message("unknown error"); + } + + stop_execution_clock(); + + // ALWAYS advance the interval and clear the running flag, on success AND + // failure AND exception, so a failed/oversized/locked round still lets the + // next round fire. Only the CURRENT op does this bookkeeping: if a newer op + // has already superseded this one, leave its state untouched. + if (pwalletMain) { + LOCK2(cs_main, pwalletMain->cs_wallet); + if (getId() == pwalletMain->saplingAutoShieldOperationId) { + int tipHeight = (chainActive.Tip() != NULL) ? chainActive.Tip()->GetHeight() : targetHeight_; + pwalletMain->nextAutoShield = pwalletMain->autoShieldInterval + tipHeight; + pwalletMain->fAutoShieldRunning = false; + } + } + + set_state(success ? OperationStatus::SUCCESS : OperationStatus::FAILED); + setResult(); + + LogPrintf("%s: autoshield operation finished (status=%s, txs=%d, shielded=%s)\n", + getId(), getStateAsString(), numTxCreated_, FormatMoney(amountShielded_)); +} + +// Resolve the Sapling destination for auto-shielded coinbase. +// +// Recoverability is the hard requirement: coinbase we shield must land in an +// address that a bare -mnemonic/-hdseed restore of THIS wallet's seed re-derives +// on its own. A restore pre-derives exactly -mnemonicsaplinggap sapling accounts +// starting at index 0, with saplingAccountCounter reset to 0 (init.cpp:2349-2355), +// so the only self-recoverable destinations are the default addresses of +// m/32'/'/i' for i < gap. +// +// We therefore DERIVE those accounts from the seed and pick the lowest index the +// wallet already holds. Deriving is the only authoritative test. In particular +// CKeyMetadata is NOT evidence of provenance: z_importkey / z_importwallet copy +// both hdKeypath and seedFp verbatim out of the import source +// (wallet.cpp:5522-5529 <- rpcdump.cpp:511-516), so a foreign key can claim any +// keypath and any seed fingerprint. Filtering on metadata would let an imported +// key win as "account 0" and silently receive every shielded reward. +// +// Caller must hold cs_wallet and must already have checked the wallet is unlocked. +bool AsyncRPCOperation_autoshieldcoinbase::resolveDestination( + libzcash::SaplingPaymentAddress& destOut, std::string& destStrOut) { + + // 1. Explicit -autoshieldaddress override (validated as a spendable Sapling + // zaddr at init.cpp:2494-2506). This also serves as the per-process cache + // for whatever step 2/3 resolved. + if (!pwalletMain->autoShieldAddress.empty()) { + auto decoded = DecodePaymentAddress(pwalletMain->autoShieldAddress); + if (boost::get(&decoded) != nullptr) { + destOut = boost::get(decoded); + destStrOut = pwalletMain->autoShieldAddress; + return true; + } + LogPrintf("%s: configured -autoshieldaddress is not a valid Sapling address\n", getId()); + return false; + } + + // 2. Walk the restore window m/32'/coin'/[0, gap)' derived from the seed. + HDSeed seed; + if (!pwalletMain->GetHDSeedForDerivation(seed)) { + LogPrintf("%s: no HD seed available; refusing to pick an autoshield destination\n", getId()); + return false; + } + + // Mirror init.cpp:2349-2350's own clamp, and cap into the hardened index + // space so (i | ZIP32_HARDENED_KEY_LIMIT) below stays well formed. + int64_t gapArg = GetArg("-mnemonicsaplinggap", 100); + if (gapArg < 0) { + gapArg = 0; + } + if (gapArg > (int64_t)ZIP32_HARDENED_KEY_LIMIT) { + gapArg = (int64_t)ZIP32_HARDENED_KEY_LIMIT; + } + const uint32_t saplingGap = (uint32_t)gapArg; + + // Same derivation path as CWallet::GenerateNewSaplingZKey (wallet.cpp:139-152). + const uint32_t bip44CoinType = Params().BIP44CoinType(); + auto m = libzcash::SaplingExtendedSpendingKey::Master(seed); + auto m_32h = m.Derive(32 | ZIP32_HARDENED_KEY_LIMIT); + auto m_32h_cth = m_32h.Derive(bip44CoinType | ZIP32_HARDENED_KEY_LIMIT); + + for (uint32_t i = 0; i < saplingGap; i++) { + auto xsk = m_32h_cth.Derive(i | ZIP32_HARDENED_KEY_LIMIT); + auto addr = xsk.DefaultAddress(); + + // Spendable AND registered: GetSaplingExtendedSpendingKey resolves + // addr -> ivk -> fvk -> spending key (keystore.cpp:215-223), so a hit + // means the wallet both recognises notes sent to `addr` and can spend + // them. Exactly the pair of properties the shield needs. Lowest index + // wins: stable for the life of the wallet and reproducible from the seed + // alone, unlike std::set order over the random diversifier + // (zcash/Address.hpp:95-98). + libzcash::SaplingExtendedSpendingKey held; + if (pwalletMain->GetSaplingExtendedSpendingKey(addr, held)) { + destOut = addr; + destStrOut = EncodePaymentAddress(addr); + // Cache for the life of the process; step 1 short-circuits later + // rounds. Safe: we only cache post-validation. + pwalletMain->autoShieldAddress = destStrOut; + LogPrintf("%s: autoshield destination %s (seed-derived sapling account %u, gap %u)\n", + getId(), destStrOut, (unsigned)i, (unsigned)saplingGap); + return true; + } + } + + // 3. Nothing usable in the window yet: derive the next account, but only if + // GenerateNewSaplingZKey will land INSIDE the window. It does NOT derive + // at saplingAccountCounter: its do/while skips every index whose spending + // key we already hold (wallet.cpp:150-157), so a bare "counter < gap" + // test is unsound - counter 98 with gap 100 can still land on 100. + // Predict min{ i >= counter : we do not hold i } instead. + const uint32_t counter = pwalletMain->GetHDChain().saplingAccountCounter; + uint32_t predicted = saplingGap; // sentinel: "would land outside the window" + for (uint32_t i = counter; i < saplingGap; i++) { + auto xsk = m_32h_cth.Derive(i | ZIP32_HARDENED_KEY_LIMIT); + if (!pwalletMain->HaveSaplingSpendingKey(xsk.expsk.full_viewing_key())) { + predicted = i; + break; + } + } + + if (predicted == saplingGap) { + LogPrintf("%s: no free sapling account below -mnemonicsaplinggap=%u (account counter is %u). " + "A newly derived z-address would NOT be re-derived by a seed restore, so the " + "shielded coinbase could not be recovered from the seed alone. Refusing to " + "autoshield this round. Fix: point -autoshieldaddress at an existing in-gap " + "wallet z-address, or raise -mnemonicsaplinggap here AND use the same value on " + "any future restore.\n", + getId(), (unsigned)saplingGap, (unsigned)counter); + return false; + } + + if (pwalletMain->IsLocked()) { + LogPrintf("%s: wallet is locked; cannot derive an autoshield destination z-address\n", getId()); + return false; + } + + try { + auto expectedAddr = m_32h_cth.Derive(predicted | ZIP32_HARDENED_KEY_LIMIT).DefaultAddress(); + libzcash::SaplingPaymentAddress newAddr = pwalletMain->GenerateNewSaplingZKey(); + + // Post-verify rather than trust the prediction: cheap, and it closes the + // whole class of "the counter moved further than expected" bugs. + if (!(newAddr == expectedAddr)) { + LogPrintf("%s: newly derived z-address is not sapling account %u as predicted " + "(counter %u -> %u); not using it as the autoshield destination\n", + getId(), (unsigned)predicted, (unsigned)counter, + (unsigned)pwalletMain->GetHDChain().saplingAccountCounter); + return false; + } + + destOut = newAddr; + destStrOut = EncodePaymentAddress(newAddr); + pwalletMain->autoShieldAddress = destStrOut; + LogPrintf("%s: generated new autoshield destination z-address %s (seed-derived sapling account %u)\n", + getId(), destStrOut, (unsigned)predicted); + return true; + } catch (const std::exception& e) { + LogPrintf("%s: could not generate a destination z-address: %s\n", getId(), e.what()); + return false; + } +} + +bool AsyncRPCOperation_autoshieldcoinbase::main_impl() { + auto opid = getId(); + LogPrintf("%s: Beginning asyncrpcoperation_autoshieldcoinbase.\n", opid); + auto consensusParams = Params().GetConsensus(); + + int tipHeight; + { + LOCK(cs_main); + tipHeight = (chainActive.Tip() != NULL) ? chainActive.Tip()->GetHeight() : targetHeight_; + } + + // Don't create a tx that could be mined before, but expire after, a NU + // activation. Key this off tipHeight (the height we actually set the expiry + // from below), not the stale enqueue-time targetHeight_, so a queue delay + // cannot slip a straddling expiry past this guard. + auto nextActivationHeight = NextActivationHeight(tipHeight, consensusParams); + if (nextActivationHeight && tipHeight + AUTOSHIELD_EXPIRY_DELTA >= nextActivationHeight.get()) { + LogPrintf("%s: autoshield tx could expire across a NU activation. Skipping this round.\n", opid); + return true; + } + + libzcash::SaplingPaymentAddress destZaddr; + std::string destStr; + std::vector inputs; + CAmount shieldedValue = 0; + unsigned int max_tx_size = MAX_TX_SIZE_AFTER_SAPLING; + + { + LOCK2(cs_main, pwalletMain->cs_wallet); + + // Defensive: the scheduler already skips while locked, but the wallet + // could have been locked between enqueue and execution. + if (pwalletMain->IsLocked()) { + LogPrintf("%s: wallet is locked, skipping autoshield round\n", opid); + return true; + } + + if (!resolveDestination(destZaddr, destStr)) { + LogPrintf("%s: no spendable destination z-address available, skipping\n", opid); + return true; + } + + // Gather matured, spendable coinbase UTXOs, byte-capped to a single tx. + // AvailableCoins with fOnlySpendable already excludes immature coinbase + // (< COINBASE_MATURITY) and outputs we don't own, so external + // -mineraddress / pool coinbase naturally yields zero inputs. + size_t estimatedTxSize = 2000; // header + sietch outputs headroom + std::vector vecOutputs; + pwalletMain->AvailableCoins(vecOutputs, true, NULL, false, true); + for (const COutput& out : vecOutputs) { + if (!out.fSpendable || !out.tx->IsCoinBase()) { + continue; + } + CTxDestination address; + if (!ExtractDestination(out.tx->vout[out.i].scriptPubKey, address)) { + continue; + } + size_t increase = (boost::get(&address) != nullptr) + ? AUTOSHIELD_CTXIN_P2SH_SIZE : AUTOSHIELD_CTXIN_DUST_SIZE; + if (estimatedTxSize + increase >= max_tx_size) { + // Size-safe batch; the remainder is shielded next round. + LogPrintf("%s: reached per-tx size cap; deferring remaining coinbase to next round\n", opid); + break; + } + estimatedTxSize += increase; + ShieldCoinbaseUTXO utxo = { out.tx->GetHash(), out.i, + out.tx->vout[out.i].scriptPubKey, + out.tx->vout[out.i].nValue }; + inputs.push_back(utxo); + shieldedValue += out.tx->vout[out.i].nValue; + } + } + + CAmount fee = pwalletMain->autoShieldFee; + + if (inputs.size() < (size_t)pwalletMain->autoShieldMinUtxos) { + LogPrintf("%s: %d matured coinbase utxo(s) < min %d, skipping this round\n", + opid, (int)inputs.size(), pwalletMain->autoShieldMinUtxos); + return true; + } + if (shieldedValue <= fee) { + LogPrintf("%s: matured coinbase value %s <= fee %s, skipping\n", + opid, FormatMoney(shieldedValue), FormatMoney(fee)); + return true; + } + + // Common outgoing viewing key derived from the HD seed, exactly as + // z_shieldcoinbase does for t->z (keeps the note recoverable). + HDSeed seed; + if (!pwalletMain->GetHDSeedForDerivation(seed)) { + LogPrintf("%s: HD seed not available, skipping\n", opid); + return true; + } + uint256 ovk = ovkForShieldingFromTaddr(seed); + + // Build the t->z shield tx. Proof generation happens in Build() WITHOUT + // holding cs_wallet (mirrors the sweep op) so we don't stall wallet RPCs. + auto builder = TransactionBuilder(consensusParams, targetHeight_, pwalletMain); + builder.SetExpiryHeight(tipHeight + AUTOSHIELD_EXPIRY_DELTA); + builder.SetFee(fee); + + for (const auto& t : inputs) { + if (t.amount >= ASSETCHAINS_TIMELOCKGTE) { + builder.SetLockTime((uint32_t)tipHeight); + builder.AddTransparentInput(COutPoint(t.txid, t.vout), t.scriptPubKey, t.amount, 0xfffffffe); + } else { + builder.AddTransparentInput(COutPoint(t.txid, t.vout), t.scriptPubKey, t.amount); + } + } + + // All input value (less fee) goes back to our own z-address as change. + builder.SendChangeTo(destZaddr, ovk); + + // Sietch padding: mirror z_shieldcoinbase's two dummy zouts so autoshield + // txs are structurally indistinguishable from manual coinbase shields. + for (int i = 0; i < 2; i++) { + auto zdust = DecodePaymentAddress(randomSietchZaddr()); + if (IsValidPaymentAddress(zdust)) { + builder.AddSaplingOutput(ovk, boost::get(zdust), 0); + } + } + + auto maybe_tx = builder.Build(); + if (!maybe_tx) { + LogPrintf("%s: Failed to build autoshield transaction.\n", opid); + return false; + } + CTransaction tx = maybe_tx.get(); + + if (isCancelled()) { + LogPrintf("%s: Cancelled before commit.\n", opid); + return false; + } + + if (pwalletMain->CommitAutomatedTx(tx)) { + LogPrintf("%s: shielded %s coinbase (%d utxos) into %s via txid=%s\n", + opid, FormatMoney(shieldedValue - fee), (int)inputs.size(), + destStr, tx.GetHash().ToString()); + amountShielded_ += shieldedValue - fee; + shieldTxIds_.push_back(tx.GetHash().ToString()); + numTxCreated_++; + return true; + } + + LogPrintf("%s: autoshield tx FAILED in CommitTransaction, txid=%s\n", opid, tx.GetHash().ToString()); + return false; +} + +void AsyncRPCOperation_autoshieldcoinbase::setResult() { + UniValue res(UniValue::VOBJ); + res.push_back(Pair("num_tx_created", numTxCreated_)); + res.push_back(Pair("amount_shielded", FormatMoney(amountShielded_))); + UniValue txIds(UniValue::VARR); + for (const std::string& txId : shieldTxIds_) { + txIds.push_back(txId); + } + res.push_back(Pair("shield_txids", txIds)); + set_result(res); +} + +void AsyncRPCOperation_autoshieldcoinbase::cancel() { + set_state(OperationStatus::CANCELLED); +} + +UniValue AsyncRPCOperation_autoshieldcoinbase::getStatus() const { + UniValue v = AsyncRPCOperation::getStatus(); + UniValue obj = v.get_obj(); + obj.push_back(Pair("method", "autoshieldcoinbase")); + obj.push_back(Pair("target_height", targetHeight_)); + return obj; +} diff --git a/src/wallet/asyncrpcoperation_autoshieldcoinbase.h b/src/wallet/asyncrpcoperation_autoshieldcoinbase.h new file mode 100644 index 000000000..98dd5c734 --- /dev/null +++ b/src/wallet/asyncrpcoperation_autoshieldcoinbase.h @@ -0,0 +1,58 @@ +// Copyright (c) 2016-2024 The Hush developers +// Copyright (c) 2024-2026 The DragonX developers +// Distributed under the GPLv3 software license, see the accompanying +// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html +#ifndef ASYNCRPCOPERATION_AUTOSHIELDCOINBASE_H +#define ASYNCRPCOPERATION_AUTOSHIELDCOINBASE_H + +#include "amount.h" +#include "asyncrpcoperation.h" +#include "univalue.h" +#include "zcash/Address.hpp" +#include "zcash/zip32.h" + +// Default fee for automatic coinbase-shielding transactions +static const CAmount DEFAULT_AUTOSHIELD_FEE = 10000; + +// A periodic, wallet-local operation that drains matured *transparent* coinbase +// UTXOs into a wallet-owned Sapling z-address in size-bounded batches. It is the +// automatic sibling of the manual z_shieldcoinbase RPC and mirrors the dispatch +// model of AsyncRPCOperation_sweep (self-gathers on the async worker thread, +// commits via CWallet::CommitAutomatedTx). It never mints a transparent output, +// so it respects the ac_private=1 transparent-output ban, and it deliberately +// does NOT toggle mining (unlike z_shieldcoinbase) so it can run every interval +// on a mining node without thrashing the miner. +class AsyncRPCOperation_autoshieldcoinbase : public AsyncRPCOperation +{ +public: + AsyncRPCOperation_autoshieldcoinbase(int targetHeight); + virtual ~AsyncRPCOperation_autoshieldcoinbase(); + + // We don't want to be copied or moved around + AsyncRPCOperation_autoshieldcoinbase(AsyncRPCOperation_autoshieldcoinbase const&) = delete; + AsyncRPCOperation_autoshieldcoinbase(AsyncRPCOperation_autoshieldcoinbase&&) = delete; + AsyncRPCOperation_autoshieldcoinbase& operator=(AsyncRPCOperation_autoshieldcoinbase const&) = delete; + AsyncRPCOperation_autoshieldcoinbase& operator=(AsyncRPCOperation_autoshieldcoinbase&&) = delete; + + virtual void main(); + virtual void cancel(); + virtual UniValue getStatus() const; + +private: + int targetHeight_; + int numTxCreated_ = 0; + CAmount amountShielded_ = 0; + std::vector shieldTxIds_; + + bool main_impl(); + + // Resolve a spendable, wallet-owned Sapling destination: the configured + // -autoshieldaddress if set, else the first spendable z-addr the wallet + // holds, else a freshly generated one (requires an unlocked wallet). + // Returns false if none is available (e.g. locked wallet with no z-addr). + bool resolveDestination(libzcash::SaplingPaymentAddress& destOut, std::string& destStrOut); + + void setResult(); +}; + +#endif /* ASYNCRPCOPERATION_AUTOSHIELDCOINBASE_H */ diff --git a/src/wallet/asyncrpcoperation_saplingconsolidation.cpp b/src/wallet/asyncrpcoperation_saplingconsolidation.cpp index 755564492..1a6dfec7c 100644 --- a/src/wallet/asyncrpcoperation_saplingconsolidation.cpp +++ b/src/wallet/asyncrpcoperation_saplingconsolidation.cpp @@ -28,8 +28,17 @@ AsyncRPCOperation_saplingconsolidation::AsyncRPCOperation_saplingconsolidation(i AsyncRPCOperation_saplingconsolidation::~AsyncRPCOperation_saplingconsolidation() {} void AsyncRPCOperation_saplingconsolidation::main() { - if (isCancelled()) + if (isCancelled()) { + // Only the current op owns the scheduler flag; a stale/cancelled op must + // not clear it out from under a freshly-enqueued successor. + if (pwalletMain) { + LOCK(pwalletMain->cs_wallet); + if (getId() == pwalletMain->saplingConsolidationOperationId) { + pwalletMain->fConsolidationRunning = false; + } + } return; + } set_state(OperationStatus::EXECUTING); start_execution_clock(); @@ -76,6 +85,21 @@ void AsyncRPCOperation_saplingconsolidation::main() { LogPrintf("%s", s); unlock_notes(); // clean up LogPrint("zrpc", "%s: consolidation input notes unlocked\n", getId()); + + // Advance the interval and clear the running flag on EVERY terminal state + // (success, failure, exception) so consolidation runs once per interval + // instead of every block, and a failed round still lets the next one fire. + // Only the CURRENT op does this bookkeeping. This fixes the pre-existing + // wedge where nextConsolidation never advanced and fConsolidationRunning + // was never set/reset. + if (pwalletMain) { + LOCK2(cs_main, pwalletMain->cs_wallet); + if (getId() == pwalletMain->saplingConsolidationOperationId) { + int tipHeight = (chainActive.Tip() != NULL) ? chainActive.Tip()->GetHeight() : targetHeight_; + pwalletMain->nextConsolidation = pwalletMain->consolidationInterval + tipHeight; + pwalletMain->fConsolidationRunning = false; + } + } } bool AsyncRPCOperation_saplingconsolidation::main_impl() { diff --git a/src/wallet/asyncrpcoperation_sweep.cpp b/src/wallet/asyncrpcoperation_sweep.cpp index 166548a71..c9b7afaf6 100644 --- a/src/wallet/asyncrpcoperation_sweep.cpp +++ b/src/wallet/asyncrpcoperation_sweep.cpp @@ -27,8 +27,17 @@ AsyncRPCOperation_sweep::AsyncRPCOperation_sweep(int targetHeight, bool fromRpc) AsyncRPCOperation_sweep::~AsyncRPCOperation_sweep() {} void AsyncRPCOperation_sweep::main() { - if (isCancelled()) + if (isCancelled()) { + // Only the current op owns the scheduler flag; a stale/cancelled op must + // not clear it out from under a freshly-enqueued successor. + if (pwalletMain) { + LOCK(pwalletMain->cs_wallet); + if (getId() == pwalletMain->saplingSweepOperationId) { + pwalletMain->fSweepRunning = false; + } + } return; + } set_state(OperationStatus::EXECUTING); start_execution_clock(); @@ -64,6 +73,23 @@ void AsyncRPCOperation_sweep::main() { set_state(OperationStatus::FAILED); } + // Scheduler bookkeeping, done here so it runs on success AND failure AND + // exception (main_impl's terminal code is skipped when it throws). Only the + // current op mutates scheduler state. Preserves the "keep draining every + // block until swept" model: on a successful-but-incomplete round we leave + // fSweepRunning set and nextSweep unadvanced so the next block continues. + // On completion OR on failure/exception we release fSweepRunning and back + // off one interval — critically, a persistently failing sweep no longer + // leaves fSweepRunning stuck true and wedges consolidation + autoshield. + if (pwalletMain) { + LOCK2(cs_main, pwalletMain->cs_wallet); + if (getId() == pwalletMain->saplingSweepOperationId && (!success || sweepComplete_)) { + int tipHeight = (chainActive.Tip() != NULL) ? chainActive.Tip()->GetHeight() : targetHeight_; + pwalletMain->nextSweep = pwalletMain->sweepInterval + tipHeight; + pwalletMain->fSweepRunning = false; + } + } + std::string s = strprintf("%s: Sweep operation finished. (status=%s", getId(), getStateAsString()); if (success) { s += strprintf(", success)\n"); @@ -314,10 +340,11 @@ bool AsyncRPCOperation_sweep::main_impl() { } } - if (sweepComplete) { - pwalletMain->nextSweep = pwalletMain->sweepInterval + chainActive.Tip()->GetHeight(); - pwalletMain->fSweepRunning = false; - } + // Record whether the wallet is fully swept; the scheduler bookkeeping + // (advancing nextSweep / clearing fSweepRunning) is done in main() so it + // also runs on the failure/exception/cancel paths and cannot wedge the + // shared fSweepRunning flag (which now also gates consolidation + autoshield). + sweepComplete_ = sweepComplete; LogPrintf("%s: Created %d transactions with total output amount=%s, status=%d\n", getId(), numTxCreated, FormatMoney(amountSwept), (int)status); setSweepResult(numTxCreated, amountSwept, sweepTxIds); diff --git a/src/wallet/asyncrpcoperation_sweep.h b/src/wallet/asyncrpcoperation_sweep.h index 5779e4021..254b32f3e 100644 --- a/src/wallet/asyncrpcoperation_sweep.h +++ b/src/wallet/asyncrpcoperation_sweep.h @@ -34,6 +34,10 @@ public: private: int targetHeight_; bool fromRPC_; + // Set by main_impl(): true iff there was nothing left to sweep this round. + // Read by main() to decide scheduler bookkeeping. Defaults false so an + // exception (which skips main_impl's assignment) is treated as "not done". + bool sweepComplete_ = false; bool main_impl(); diff --git a/src/wallet/crypter.cpp b/src/wallet/crypter.cpp index f3674c776..fe2e9ee55 100644 --- a/src/wallet/crypter.cpp +++ b/src/wallet/crypter.cpp @@ -159,6 +159,34 @@ static bool DecryptHDSeed( return seed.Fingerprint() == seedFp; } +uint256 MnemonicEntropyFingerprint(const RawHDSeed& entropy) +{ + // The local copy is not gratuitous -- see the declaration in crypter.h. + // It is secure_allocator-backed, so it is memory_cleanse()d on destruction + // (support/allocators/secure.h:45-52). + RawHDSeed tmp(entropy); + return HDSeed(tmp).Fingerprint(); +} + +static bool DecryptMnemonicEntropy( + const CKeyingMaterial& vMasterKey, + const std::vector& vchCryptedSecret, + const uint256& entropyFp, + RawHDSeed& entropyOut) +{ + CKeyingMaterial vchSecret; + + // Use the entropy's fingerprint as IV, mirroring DecryptHDSeed above. + if (!DecryptSecret(vMasterKey, vchCryptedSecret, entropyFp, vchSecret)) + return false; + + // RawHDSeed and CKeyingMaterial are the SAME type (both are + // std::vector>), so this is a + // plain copy of the same bytes, not a reinterpretation. + entropyOut = vchSecret; + return MnemonicEntropyFingerprint(entropyOut) == entropyFp; +} + static bool DecryptKey(const CKeyingMaterial& vMasterKey, const std::vector& vchCryptedSecret, const CPubKey& vchPubKey, CKey& key) { CKeyingMaterial vchSecret; @@ -233,6 +261,19 @@ bool CCryptoKeyStore::Unlock(const CKeyingMaterial& vMasterKeyIn) keyPass = true; } } + // Deliberately NO arm here for cryptedMnemonicEntropy. This function is + // the "some keys decrypt but not all" corruption detector and a keyFail + // ends at the assert(false) below. The mnemonic entropy is an optional, + // non-spending, display-only record: legacy wallets, hex-restored + // wallets and every wallet predating this feature legitimately have a + // seed and no entropy, and a wallet whose every key decrypts while its + // entropy does not is not corrupt in any sense that should abort the + // process -- it simply cannot print its seed phrase. It is decrypted + // lazily in GetMnemonicEntropy() instead, so that case becomes a false + // return from one RPC while derivation and spending (which read the + // seed, not the entropy) carry on. Note the arm above caches nothing + // either -- `seed` is discarded; it only votes keyPass/keyFail -- so + // nothing is lost by omitting one here. CryptedKeyMap::const_iterator mi = mapCryptedKeys.begin(); for (; mi != mapCryptedKeys.end(); ++mi) { @@ -344,6 +385,82 @@ bool CCryptoKeyStore::GetHDSeed(HDSeed& seedOut) const return DecryptHDSeed(vMasterKey, cryptedHDSeed.second, cryptedHDSeed.first, seedOut); } +bool CCryptoKeyStore::SetMnemonicEntropy(const RawHDSeed& entropy) +{ + { + LOCK(cs_SpendingKeyStore); + if (!IsCrypted()) { + return CBasicKeyStore::SetMnemonicEntropy(entropy); + } + + if (IsLocked()) + return false; + + if (entropy.empty()) + return false; + + std::vector vchCryptedSecret; + // Use the entropy's fingerprint as IV + // TODO: Handle this properly when we make encryption a supported feature + auto entropyFp = MnemonicEntropyFingerprint(entropy); + // RawHDSeed IS CKeyingMaterial, so `entropy` binds directly here. + if (!EncryptSecret(vMasterKey, entropy, entropyFp, vchCryptedSecret)) + return false; + + // Virtual: this calls into CWallet to store the crypted entropy to disk. + if (!SetCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) + return false; + } + return true; +} + +bool CCryptoKeyStore::SetCryptedMnemonicEntropy( + const uint256& entropyFp, + const std::vector& vchCryptedSecret) +{ + { + LOCK(cs_SpendingKeyStore); + if (!IsCrypted()) { + return false; + } + + if (!cryptedMnemonicEntropy.first.IsNull()) { + // Don't allow existing entropy to be changed, mirroring + // SetCryptedHDSeed: a phrase that no longer matches the installed + // seed is worse than no phrase at all. + return false; + } + + cryptedMnemonicEntropy = std::make_pair(entropyFp, vchCryptedSecret); + } + return true; +} + +bool CCryptoKeyStore::HaveMnemonicEntropy() const +{ + LOCK(cs_SpendingKeyStore); + if (!IsCrypted()) + return CBasicKeyStore::HaveMnemonicEntropy(); + + return !cryptedMnemonicEntropy.second.empty(); +} + +bool CCryptoKeyStore::GetMnemonicEntropy(RawHDSeed& entropyOut) const +{ + LOCK(cs_SpendingKeyStore); + if (!IsCrypted()) + return CBasicKeyStore::GetMnemonicEntropy(entropyOut); + + if (cryptedMnemonicEntropy.second.empty()) + return false; + + // Decrypted lazily, on demand, and deliberately NOT in Unlock(): see the + // comment there for why the entropy must not vote in the keyPass/keyFail + // corruption detector. + return DecryptMnemonicEntropy(vMasterKey, cryptedMnemonicEntropy.second, + cryptedMnemonicEntropy.first, entropyOut); +} + bool CCryptoKeyStore::AddKeyPubKey(const CKey& key, const CPubKey &pubkey) { { @@ -505,6 +622,30 @@ bool CCryptoKeyStore::EncryptKeys(CKeyingMaterial& vMasterKeyIn) } hdSeed = HDSeed(); } + if (!mnemonicEntropy.empty()) { + { + std::vector vchCryptedSecret; + // Use the entropy's fingerprint as IV + // TODO: Handle this properly when we make encryption a supported feature + auto entropyFp = MnemonicEntropyFingerprint(mnemonicEntropy); + if (!EncryptSecret(vMasterKeyIn, mnemonicEntropy, entropyFp, vchCryptedSecret)) { + return false; + } + // Virtual: calls into CWallet to store the crypted entropy to disk. + if (!SetCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) { + return false; + } + } + // Drop the plaintext. swap() rather than `= RawHDSeed()`: assigning a + // shorter vector destroys the elements but KEEPS the capacity, so the + // old bytes would linger in the locked buffer. swap() hands the buffer + // to a temporary whose destructor deallocates it, and + // secure_allocator::deallocate memory_cleanse()s + // (support/allocators/secure.h:45-52). The `hdSeed = HDSeed();` above + // has the same weakness but cannot be fixed here: HDSeed's raw vector + // is private with no swap accessor (zip32.h:23-33). + RawHDSeed().swap(mnemonicEntropy); + } BOOST_FOREACH(KeyMap::value_type& mKey, mapKeys) { const CKey &key = mKey.second; diff --git a/src/wallet/crypter.h b/src/wallet/crypter.h index cb459fe07..d72a8a1b6 100644 --- a/src/wallet/crypter.h +++ b/src/wallet/crypter.h @@ -138,6 +138,21 @@ public: } }; +/** Keystore which keeps the private keys encrypted. + * It derives from the basic key store, which is used if no encryption is active. + */ +//! Fingerprint of a BIP39 entropy blob, computed exactly as HDSeed::Fingerprint +//! does (BLAKE2b, ZCASH_HD_SEED_FP_PERSONAL). It is an IV / integrity tag and a +//! wallet.dat record key -- never a key-derivation input. Declared here rather +//! than duplicated because three call sites must produce identical bytes: +//! CCryptoKeyStore::SetMnemonicEntropy, CCryptoKeyStore::EncryptKeys, and +//! CWallet::SetMnemonicEntropy (which keys the plaintext record with it). +//! +//! It takes a copy internally on purpose: HDSeed's constructor takes a NON-const +//! RawHDSeed& (zip32.h:28), so HDSeed(entropy).Fingerprint() does not compile +//! against a const reference or a member read from a const method. +uint256 MnemonicEntropyFingerprint(const RawHDSeed& entropy); + /** Keystore which keeps the private keys encrypted. * It derives from the basic key store, which is used if no encryption is active. */ @@ -145,6 +160,10 @@ class CCryptoKeyStore : public CBasicKeyStore { private: std::pair> cryptedHDSeed; + // Encrypted mnemonic entropy, shaped exactly like cryptedHDSeed above: + // .first is the entropy's fingerprint (AES IV + integrity tag on decrypt), + // .second is the ciphertext. + std::pair> cryptedMnemonicEntropy; CryptedKeyMap mapCryptedKeys; //CryptedSproutSpendingKeyMap mapCryptedSproutSpendingKeys; CryptedSaplingSpendingKeyMap mapCryptedSaplingSpendingKeys; @@ -194,6 +213,14 @@ public: bool SetHDSeed(const HDSeed& seed); bool HaveHDSeed() const; bool GetHDSeed(HDSeed& seedOut) const; + //! Mnemonic entropy, mirroring the four HD-seed members above. + //! SetCryptedMnemonicEntropy MUST stay virtual for the same reason + //! SetCryptedHDSeed is: CWallet overrides it to persist the record, and + //! SetMnemonicEntropy() below reaches that override through the vtable. + virtual bool SetCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector &vchCryptedSecret); + bool SetMnemonicEntropy(const RawHDSeed& entropy); + bool HaveMnemonicEntropy() const; + bool GetMnemonicEntropy(RawHDSeed& entropyOut) const; virtual bool AddCryptedKey(const CPubKey &vchPubKey, const std::vector &vchCryptedSecret); bool AddKeyPubKey(const CKey& key, const CPubKey &pubkey); diff --git a/src/wallet/rpcdump.cpp b/src/wallet/rpcdump.cpp index 14b0dacc7..9b72f008d 100644 --- a/src/wallet/rpcdump.cpp +++ b/src/wallet/rpcdump.cpp @@ -1039,8 +1039,10 @@ UniValue z_exportmnemonic(const UniValue& params, bool fHelp, const CPubKey& myp "\nReveal the wallet's BIP39 seed phrase (24 words).\n" "The phrase is byte-compatible with SilentDragonXLite: the same words\n" "restore the same transparent and shielded addresses in either wallet.\n" - "Only works for wallets created or restored from a mnemonic (see the\n" - "-mnemonic and -usemnemonic options). Requires the wallet be unlocked.\n" + "New wallets get a seed phrase by default (-usemnemonic=0 opts out);\n" + "wallets restored with -mnemonic have one too. Wallets created before\n" + "this feature, or from a raw -hdseed, have no phrase -- use\n" + "z_exportwallet for those. Requires the wallet be unlocked.\n" "\nResult:\n" "{\n" " \"mnemonic\" : \"word1 ... word24\", (string) the BIP39 seed phrase\n" diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index c762b0b97..81df81e1e 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -40,6 +40,7 @@ #include "coins.h" #include "wallet/asyncrpcoperation_saplingconsolidation.h" #include "wallet/asyncrpcoperation_sweep.h" +#include "wallet/asyncrpcoperation_autoshieldcoinbase.h" #include #include #include @@ -555,6 +556,9 @@ void CWallet::ChainTip(const CBlockIndex *pindex, if (fSweepEnabled) { RunSaplingSweep(pindex->GetHeight()); } + if (fAutoShieldEnabled) { + RunAutoShieldCoinbase(pindex->GetHeight()); + } if (fTxDeleteEnabled) { DeleteWalletTransactions(pindex); } @@ -581,7 +585,13 @@ void CWallet::RunSaplingSweep(int blockHeight) { if (blockHeight == 0) return; - AssertLockHeld(cs_wallet); + // Take cs_wallet ourselves: ChainTip (the notify-thread caller) does NOT + // hold it here, and we mutate fSweepRunning/nextSweep/saplingSweepOperationId + // and enqueue below. Matches RunSaplingConsolidation/RunAutoShieldCoinbase. + // (The old AssertLockHeld(cs_wallet) was a no-op in release builds and thus + // masked an unsynchronized mutation.) cs_wallet is recursive, so this is + // safe even on any path that already holds it. + LOCK(cs_wallet); if (!fSweepEnabled) { return; } @@ -604,6 +614,12 @@ void CWallet::RunSaplingSweep(int blockHeight) { return; } + //Don't Run While auto-shield is running. + if (fAutoShieldRunning) { + LogPrintf("%s: not sweeping since autoshield is currently running at height=%d\n", __func__, blockHeight); + return; + } + fSweepRunning = true; std::shared_ptr q = getAsyncRPCQueue(); @@ -634,6 +650,12 @@ void CWallet::RunSaplingConsolidation(int blockHeight) { return; } + // Self-guard: an op is already in flight (nextConsolidation only advances + // when it completes). Don't cancel + re-enqueue a fresh op every block. + if (fConsolidationRunning) { + return; + } + LogPrintf("%s: consolidation enabled at blockHeight=%d fSweepRunning=%d\n", __func__, blockHeight, fSweepRunning ); if (fSweepRunning) { @@ -641,7 +663,13 @@ void CWallet::RunSaplingConsolidation(int blockHeight) { return; } + if (fAutoShieldRunning) { + LogPrintf("%s: not consolidating since autoshield is currently running at height=%d\n", __func__, blockHeight); + return; + } + LogPrintf("%s: creating consolidation operation at blockHeight=%d\n", __func__, blockHeight); + fConsolidationRunning = true; std::shared_ptr q = getAsyncRPCQueue(); std::shared_ptr lastOperation = q->getOperationForId(saplingConsolidationOperationId); if (lastOperation != nullptr) { @@ -653,6 +681,60 @@ void CWallet::RunSaplingConsolidation(int blockHeight) { q->addOperation(operation); } +// Periodically drain matured transparent coinbase into a wallet-owned Sapling +// z-address. Default-ON but conditional: this is enqueue-only (all gathering +// happens on the async worker thread inside the op, which is why we must not +// take cs_main here — ChainTip runs from the wallet-notify context). It is a +// silent no-op wherever it cannot act (locked wallet, no owned coinbase, +// external -mineraddress), so it is safe to run on every node. +void CWallet::RunAutoShieldCoinbase(int blockHeight) { + // Sapling is always active from height 1 on DragonX+HACs. + if (blockHeight == 0) + return; + + LOCK(cs_wallet); + + if (!fAutoShieldEnabled) { + return; + } + + if (nextAutoShield > blockHeight) { + return; + } + + // Self-guard: an op is already in flight (nextAutoShield only advances when + // it completes). Don't cancel + re-enqueue a fresh op every block. + if (fAutoShieldRunning) { + return; + } + + // Mutual exclusion: sweep/consolidation share the single async worker and + // cs_wallet; don't queue an autoshield in the same connected block. + if (fSweepRunning || fConsolidationRunning) { + LogPrintf("%s: not autoshielding since sweep/consolidation is running at height=%d\n", __func__, blockHeight); + return; + } + + // Silent no-op while locked: we can neither sign the shield nor derive a + // destination z-addr. Advance the interval so we don't retry every block. + if (IsLocked()) { + LogPrintf("%s: wallet locked; matured coinbase will accumulate until unlocked (height=%d)\n", __func__, blockHeight); + nextAutoShield = autoShieldInterval + blockHeight; + return; + } + + fAutoShieldRunning = true; + + std::shared_ptr q = getAsyncRPCQueue(); + std::shared_ptr lastOperation = q->getOperationForId(saplingAutoShieldOperationId); + if (lastOperation != nullptr) { + lastOperation->cancel(); + } + std::shared_ptr operation(new AsyncRPCOperation_autoshieldcoinbase(blockHeight + 5)); + saplingAutoShieldOperationId = operation->getId(); + q->addOperation(operation); +} + bool CWallet::CommitAutomatedTx(const CTransaction& tx) { CWalletTx wtx(this, tx); CReserveKey reservekey(pwalletMain); @@ -2388,30 +2470,47 @@ 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. - if (GetBoolArg("-usemnemonic", false)) { + // + // 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", true)) { 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"); + + // Store the EXPANDED 64-byte BIP39 seed as the HD seed, with + // fMnemonic = false. Every binary -- old or new -- then feeds the stored + // bytes straight into derivation, so the key tree is identical + // everywhere and no CHDChain version bump or minversion fence is needed. + // The 32-byte entropy is kept in a separate, display-only record purely + // so the phrase can be reprinted. Addresses are unchanged from the + // previous format, which expanded the stored entropy on every read. + RawHDSeed seed64; + if (!Bip39SeedFromEntropy(entropy, seed64)) + throw std::runtime_error(std::string(__func__) + ": BIP39 seed expansion failed"); + HDSeed seed(seed64); + + // ORDER IS LOAD-BEARING: seed first, entropy second, never the reverse. + // A crash between the two leaves a wallet with a seed and no phrase -- + // recoverable via z_exportwallet, merely inconvenient. The reverse order + // would leave entropy with no seed; the next start would mint a + // DIFFERENT seed while the wallet still held a phrase for the old one. + // (GetMnemonicPhrase cross-checks the two and would refuse to print it, + // but do not rely on that here.) + if (!InstallHDSeed(seed, false, nCreationTime)) + throw std::runtime_error(std::string(__func__) + ": installing the mnemonic HD seed failed"); + if (!SetMnemonicEntropy(entropy)) + throw std::runtime_error(std::string(__func__) + ": storing the mnemonic entropy 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) @@ -2445,14 +2544,38 @@ bool CWallet::SetCryptedHDSeed(const uint256& seedFp, const std::vectorWriteCryptedHDSeed(seedFp, vchCryptedSecret); - else - return CWalletDB(strWalletFile).WriteCryptedHDSeed(seedFp, vchCryptedSecret); + // Write the encrypted record, then drop the plaintext one. Both go + // through the same CWalletDB (and therefore the same transaction when + // EncryptWallet supplied pwalletdbEncryption), because CDB::Rewrite at + // the end of EncryptWallet copies every surviving record into the fresh + // file -- a leftover plaintext "hdseed" would keep the unencrypted seed + // on disk for the life of the wallet. + // + // The erase is deliberately best-effort: a hard failure here propagates + // into CCryptoKeyStore::EncryptKeys, which CWallet::EncryptWallet turns + // into assert(false) with half the keys encrypted in memory. A logged + // warning is strictly better than that. + if (pwalletdbEncryption) { + if (!pwalletdbEncryption->WriteCryptedHDSeed(seedFp, vchCryptedSecret)) + return false; + if (!pwalletdbEncryption->EraseHDSeed(seedFp)) + LogPrintf("%s: WARNING: could not erase the plaintext hdseed record; " + "the unencrypted HD seed may remain in wallet.dat\n", __func__); + return true; + } else { + CWalletDB walletdb(strWalletFile); + if (!walletdb.WriteCryptedHDSeed(seedFp, vchCryptedSecret)) + return false; + if (!walletdb.EraseHDSeed(seedFp)) + LogPrintf("%s: WARNING: could not erase the plaintext hdseed record; " + "the unencrypted HD seed may remain in wallet.dat\n", __func__); + return true; + } } return false; } + void CWallet::SetHDChain(const CHDChain& chain, bool memonly) { LOCK(cs_wallet); @@ -2462,6 +2585,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); @@ -2471,21 +2608,94 @@ bool CWallet::LoadCryptedHDSeed(const uint256& seedFp, const std::vector& vchCryptedSecret) +{ + if (!CCryptoKeyStore::SetCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) { + return false; + } + + if (!fFileBacked) { + return true; + } + + { + LOCK(cs_wallet); + // Same write-then-erase discipline as SetCryptedHDSeed: CDB::Rewrite at + // the end of EncryptWallet copies every surviving record, so a leftover + // plaintext "mnementropy" would keep the seed phrase recoverable from an + // encrypted wallet.dat. The erase is best-effort for the same reason: a + // hard failure would propagate into EncryptKeys -> assert(false). + if (pwalletdbEncryption) { + if (!pwalletdbEncryption->WriteCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) + return false; + if (!pwalletdbEncryption->EraseMnemonicEntropy(entropyFp)) + LogPrintf("%s: WARNING: could not erase the plaintext mnementropy record\n", __func__); + return true; + } else { + CWalletDB walletdb(strWalletFile); + if (!walletdb.WriteCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) + return false; + if (!walletdb.EraseMnemonicEntropy(entropyFp)) + LogPrintf("%s: WARNING: could not erase the plaintext mnementropy record\n", __func__); + return true; + } + } + return false; +} + +bool CWallet::LoadMnemonicEntropy(const RawHDSeed& entropy) +{ + return CBasicKeyStore::SetMnemonicEntropy(entropy); +} + +bool CWallet::LoadCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector& vchCryptedSecret) +{ + return CCryptoKeyStore::SetCryptedMnemonicEntropy(entropyFp, vchCryptedSecret); +} bool CWallet::InstallHDSeed(const HDSeed& seed, bool fMnemonic, int64_t nCreateTime) { 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; } @@ -2524,10 +2734,29 @@ bool CWallet::SetHDSeedFromMnemonic(const std::string& phrase) if (!MnemonicToEntropy(phrase, entropy)) return false; - // Store the BIP39 entropy as the HDSeed (SilentDragonXLite's on-disk - // convention); the 64-byte seed is expanded from it on demand. - HDSeed seed(entropy); - return InstallHDSeed(seed, true, 1); // birthday = genesis for a restore + // Store the EXPANDED 64-byte BIP39 seed as the HD seed (fMnemonic = false); + // the entropy goes in its own record and is used only to reprint the phrase. + // Derivation therefore reads the stored bytes directly on any binary, and + // the resulting addresses are byte-identical to the previous format, which + // expanded the stored entropy on every derivation. SilentDragonXLite + // interop is unaffected: the same words still yield the same seed64. + RawHDSeed seed64; + if (!Bip39SeedFromEntropy(entropy, seed64)) + return false; + HDSeed seed(seed64); + + // Seed first, entropy second -- see the ordering note in GenerateNewSeed. + if (!InstallHDSeed(seed, false, 1)) // birthday = genesis for a restore + return false; + + // Non-fatal on a restore, unlike GenerateNewSeed: the user already holds the + // phrase (they just typed it), the seed is installed and the wallet is fully + // functional; only z_exportmnemonic is lost. + if (!SetMnemonicEntropy(entropy)) { + LogPrintf("%s: WARNING: HD seed installed but the mnemonic entropy record could not be " + "stored; z_exportmnemonic will be unavailable on this wallet\n", __func__); + } + return true; } bool CWallet::GetHDSeedForDerivation(HDSeed& seedOut) const @@ -2552,6 +2781,36 @@ bool CWallet::GetHDSeedForDerivation(HDSeed& seedOut) const bool CWallet::GetMnemonicPhrase(std::string& phraseOut) const { + // Preferred form: the HD seed is the EXPANDED 64-byte BIP39 seed and the + // entropy sits in its own record. + RawHDSeed entropy; + if (GetMnemonicEntropy(entropy)) { // false on an encrypted+locked wallet + // NEVER hand out a phrase that does not restore THIS wallet. Prove the + // entropy expands to the exact bytes derivation consumes; if it does + // not (a torn install, a wallet.dat edited by hand, an entropy record + // paired with a different seed), refuse rather than print a phrase that + // silently restores someone else's key tree. Costs one PBKDF2 on a + // user-initiated RPC. + RawHDSeed seed64; + if (!Bip39SeedFromEntropy(entropy, seed64)) + return false; + + HDSeed derivationSeed; + if (!GetHDSeedForDerivation(derivationSeed)) + return false; + + if (derivationSeed.RawSeed() != seed64) { + LogPrintf("%s: refusing to export a seed phrase: the stored mnemonic entropy does not " + "expand to this wallet's HD seed\n", __func__); + return false; + } + + return EntropyToMnemonic(entropy, phraseOut); + } + + // Legacy form (earlier builds of this branch): the stored HD seed IS the + // 32-byte BIP39 entropy, expanded on every derivation. Consistent by + // construction, so no cross-check is possible or needed. if (!hdChain.fMnemonicSeed) return false; diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index a99c0a4ea..1b77ca291 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -784,10 +784,8 @@ private: TxNullifiers mapTxSaplingNullifiers; std::vector pendingSaplingConsolidationTxs; - AsyncRPCOperationId saplingConsolidationOperationId; std::vector pendingSaplingSweepTxs; - AsyncRPCOperationId saplingSweepOperationId; void AddToTransparentSpends(const COutPoint& outpoint, const uint256& wtxid); void AddToSaplingSpends(const uint256& nullifier, const uint256& wtxid); @@ -802,6 +800,9 @@ public: int64_t nWitnessCacheSize; bool needsRescan = false; int nextConsolidation = 0; + // Id of the in-flight consolidation op; read by the op to confirm it is + // still the current one before mutating scheduler state. + AsyncRPCOperationId saplingConsolidationOperationId; bool fSaplingConsolidationEnabled = false; bool fConsolidationRunning = false; @@ -809,6 +810,12 @@ public: bool fSweepExternalEnabled = false; bool fSweepRunning = false; + // Automatic coinbase shielding (t->z). Default ON but conditional: it is a + // silent no-op on nodes where it cannot act (no wallet, external + // -mineraddress, non-mining, or locked wallet). See RunAutoShieldCoinbase. + bool fAutoShieldEnabled = true; + bool fAutoShieldRunning = false; + std::atomic fAbortRescan{false}; // abort current rescan void AbortRescan() { fAbortRescan = true; } @@ -823,6 +830,9 @@ public: int rescanStartHeight = 0; int nextSweep = 0; + // Id of the in-flight sweep op; read by the op to confirm it is still the + // current one before mutating scheduler state. + AsyncRPCOperationId saplingSweepOperationId; int amountSwept = 0; int amountConsolidated = 0; int sweepInterval = 10; @@ -833,6 +843,31 @@ public: std::vector sweepExcludeAddresses; std::string consolidationAddress = ""; + int nextAutoShield = 0; + int autoShieldInterval = 25; + CAmount autoShieldFee = 10000; + // Minimum matured coinbase UTXOs before a round fires, to avoid per-interval + // fee churn on a single freshly-matured reward. + int autoShieldMinUtxos = 1; + // Configured destination z-addr override; also used to cache the resolved + // wallet-owned destination so we keep reusing one address. + std::string autoShieldAddress = ""; + // 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(); int64_t NullifierCount(); @@ -1224,6 +1259,7 @@ public: const CBlock *pblock, boost::optional> added); void RunSaplingConsolidation(int blockHeight); + void RunAutoShieldCoinbase(int blockHeight); bool CommitAutomatedTx(const CTransaction& tx); /** Saves witness caches and best block locator to disk. */ void SetBestChain(const CBlockLocator& loc); @@ -1309,6 +1345,14 @@ public: bool SetHDSeed(const HDSeed& seed); bool SetCryptedHDSeed(const uint256& seedFp, const std::vector &vchCryptedSecret); + /* Record this wallet's BIP39 entropy so its seed phrase can be reprinted. + Display-only: derivation never reads it (the HD seed holds the bytes that + are actually derived from). Refuses to replace an existing record. + SetCryptedMnemonicEntropy overrides the CCryptoKeyStore virtual so the + record reaches disk; SetMnemonicEntropy merely hides the base version, + which is safe because no call site holds a base pointer. */ + bool SetMnemonicEntropy(const RawHDSeed& entropy); + bool SetCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector &vchCryptedSecret); /* Restore a wallet's HD seed from a hex string (as exported in the z_exportwallet "# HDSeed=" comment): 32 bytes for a legacy raw seed, or @@ -1327,8 +1371,14 @@ public: wallet and the seed is available (unlocked). Returns false otherwise. */ bool GetMnemonicPhrase(std::string& phraseOut) const; - /* True if the HD seed was derived from a BIP39 mnemonic (stored as entropy). */ - bool IsMnemonicSeed() const { return hdChain.fMnemonicSeed; } + /* True if this wallet has a BIP39 seed phrase available. Two storage forms + qualify: + - current: the HD seed is the EXPANDED 64-byte BIP39 seed and the + entropy lives in its own record (HaveMnemonicEntropy()); + - legacy: hdChain.fMnemonicSeed, where the stored HD seed IS the + 32-byte entropy and is expanded on every derivation. + Gates z_exportmnemonic (rpcdump.cpp). */ + bool IsMnemonicSeed() const { return hdChain.fMnemonicSeed || HaveMnemonicEntropy(); } /* Return the seed to feed into HD derivation. For mnemonic wallets this expands the stored 32-byte entropy into the 64-byte BIP39 seed; for legacy @@ -1349,11 +1399,22 @@ 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); /* Set the current encrypted HD seed, without saving it to disk (used by LoadWallet) */ bool LoadCryptedHDSeed(const uint256& seedFp, const std::vector& seed); + /* Set the mnemonic entropy, without saving it to disk (used by LoadWallet) */ + bool LoadMnemonicEntropy(const RawHDSeed& entropy); + + /* Set the encrypted mnemonic entropy, without saving it to disk (used by LoadWallet) */ + bool LoadCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector& vchCryptedSecret); /* Find notes filtered by payment address, min depth, ability to spend */ void GetFilteredNotes(std::vector& saplingEntries, diff --git a/src/wallet/walletdb.cpp b/src/wallet/walletdb.cpp index 1fcb59a06..abac194ba 100644 --- a/src/wallet/walletdb.cpp +++ b/src/wallet/walletdb.cpp @@ -216,6 +216,12 @@ bool CWalletDB::WriteWitnessCacheSize(int64_t nWitnessCacheSize) return Write(std::string("witnesscachesize"), nWitnessCacheSize); } +bool CWalletDB::WriteHDSeedOrigin(int64_t nOrigin) +{ + nWalletDBUpdated++; + return Write(std::string("hdseedorigin"), nOrigin); +} + bool CWalletDB::ReadPool(int64_t nPool, CKeyPool& keypool) { return Read(std::make_pair(std::string("pool"), nPool), keypool); @@ -403,12 +409,15 @@ public: bool fAnyUnordered; int nFileVersion; vector vWalletUpgrade; + // True once a well-formed "hdchain" record has been loaded. + bool fHDChainRead; CWalletScanState() { nKeys = nCKeys = nKeyMeta = nZKeys = nCZKeys = nZKeyMeta = nSapZAddrs = 0; fIsEncrypted = false; fAnyUnordered = false; nFileVersion = 0; + fHDChainRead = false; } }; @@ -833,9 +842,56 @@ ReadKeyValue(CWallet* pwallet, CDataStream& ssKey, CDataStream& ssValue, else if (strType == "hdchain") { CHDChain chain; - ssValue >> chain; + try { + ssValue >> chain; + } catch (...) { + // Do not let this land in the "user can live with it" bucket: + // report it, and leave wss.fHDChainRead false so LoadWallet + // turns it into DB_CORRUPT when a seed is present. + strErr = "Error reading wallet database: hdchain record is corrupt"; + return false; + } + wss.fHDChainRead = true; pwallet->SetHDChain(chain, true); } + else if (strType == "hdseedorigin") + { + int64_t nOrigin = 0; + ssValue >> nOrigin; + pwallet->hdSeedOrigin = (int)nOrigin; + } + else if (strType == "mnementropy") + { + uint256 entropyFp; + RawHDSeed entropy; + ssKey >> entropyFp; + ssValue >> entropy; + + if (MnemonicEntropyFingerprint(entropy) != entropyFp) + { + strErr = "Error reading wallet database: mnemonic entropy corrupt"; + return false; + } + + if (!pwallet->LoadMnemonicEntropy(entropy)) + { + strErr = "Error reading wallet database: LoadMnemonicEntropy failed"; + return false; + } + } + else if (strType == "cmnementropy") + { + uint256 entropyFp; + vector vchCryptedSecret; + ssKey >> entropyFp; + ssValue >> vchCryptedSecret; + if (!pwallet->LoadCryptedMnemonicEntropy(entropyFp, vchCryptedSecret)) + { + strErr = "Error reading wallet database: LoadCryptedMnemonicEntropy failed"; + return false; + } + wss.fIsEncrypted = true; + } } catch (...) { return false; @@ -847,6 +903,10 @@ static bool IsKeyType(string strType) { return (strType == "key" || strType == "wkey" || strType == "hdseed" || strType == "chdseed" || + // The mnemonic entropy must survive a keys-only salvage: without it + // a recovered wallet keeps its seed (and stays fully spendable) but + // silently loses the ability to reprint its seed phrase. + strType == "mnementropy" || strType == "cmnementropy" || strType == "zkey" || strType == "czkey" || strType == "sapzkey" || strType == "csapzkey" || strType == "vkey" || @@ -947,6 +1007,21 @@ DBErrors CWalletDB::LoadWallet(CWallet* pwallet) if (fNoncriticalErrors && result == DB_LOAD_OK) result = DB_NONCRITICAL_ERROR; + // A wallet that holds an HD seed but whose hdchain record is missing or + // unreadable is NOT safe to run. hdChain would fall back to its SetNull + // defaults (walletdb.h:105-113), which (a) clears fMnemonicSeed, switching + // HD derivation from the 64-byte BIP39 seed to the raw 32-byte entropy + // (CWallet::GetHDSeedForDerivation, wallet.cpp:2615-2633) -> an entirely + // different key tree, and (b) resets saplingAccountCounter to 0, so the + // next GenerateNewSaplingZKey walks back over accounts that already exist. + // Both are silent today (a bad hdchain read is only DB_NONCRITICAL_ERROR). + // Fail loud instead of quietly deriving into the wrong tree. + if (pwallet->HaveHDSeed() && !wss.fHDChainRead) + { + LogPrintf("Error loading wallet.dat: HD seed present but the hdchain record is missing or corrupt\n"); + return DB_CORRUPT; + } + // Any wallet corruption at all: skip any rewriting or // upgrading, we don't want to make it worse. if (result != DB_LOAD_OK) @@ -1240,7 +1315,13 @@ bool CWalletDB::Recover(CDBEnv& dbenv, const std::string& filename, bool fOnlyKe fReadOK = ReadKeyValue(&dummyWallet, ssKey, ssValue, wss, strType, strErr); } - if (!IsKeyType(strType)) + // "hdchain" is not a key type, but it must survive a keys-only + // salvage: a recovered wallet that keeps its seed while losing its + // hdchain silently derives from a different key tree (fMnemonicSeed + // cleared -> raw entropy instead of the 64-byte BIP39 seed) and + // re-issues sapling accounts from 0. CWalletDB::LoadWallet now + // refuses such a wallet outright, so preserve the record here. + if (!IsKeyType(strType) && strType != "hdchain") continue; if (!fReadOK) { @@ -1290,6 +1371,34 @@ bool CWalletDB::WriteCryptedHDSeed(const uint256& seedFp, const std::vector& vchCryptedSecret) +{ + nWalletDBUpdated++; + return Write(std::make_pair(std::string("cmnementropy"), entropyFp), vchCryptedSecret); +} + +bool CWalletDB::EraseMnemonicEntropy(const uint256& entropyFp) +{ + nWalletDBUpdated++; + return Erase(std::make_pair(std::string("mnementropy"), entropyFp)); +} + bool CWalletDB::WriteHDChain(const CHDChain& chain) { nWalletDBUpdated++; diff --git a/src/wallet/walletdb.h b/src/wallet/walletdb.h index d55072271..a219ec855 100644 --- a/src/wallet/walletdb.h +++ b/src/wallet/walletdb.h @@ -191,6 +191,9 @@ public: bool WriteWitnessCacheSize(int64_t nWitnessCacheSize); + //! Record how this wallet's HD seed came to exist (CWallet::HDSeedOrigin). + bool WriteHDSeedOrigin(int64_t nOrigin); + bool ReadPool(int64_t nPool, CKeyPool& keypool); bool WritePool(int64_t nPool, const CKeyPool& keypool); bool ErasePool(int64_t nPool); @@ -219,6 +222,17 @@ public: bool WriteHDSeed(const HDSeed& seed); bool WriteCryptedHDSeed(const uint256& seedFp, const std::vector& vchCryptedSecret); + //! Remove the PLAINTEXT hdseed record. Must be called once the seed has been + //! written in encrypted form: CDB::Rewrite (invoked at the end of + //! CWallet::EncryptWallet) copies whatever records still exist into the new + //! file, so a leftover "hdseed" leaves the unencrypted seed on disk forever. + bool EraseHDSeed(const uint256& seedFp); + //! BIP39 entropy for a phrase-recoverable wallet. Display-only: derivation + //! never reads it. Record names are deliberately distinct prefixes from + //! "hdseed"/"chdseed" so they cannot collide. + bool WriteMnemonicEntropy(const uint256& entropyFp, const RawHDSeed& entropy); + bool WriteCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector& vchCryptedSecret); + bool EraseMnemonicEntropy(const uint256& entropyFp); //! write the hdchain model (external chain child index counter) bool WriteHDChain(const CHDChain& chain);