From 69bb035e7e3fbca8adbd3e8da7704b43a10acfff Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 30 Sep 2026 19:05:45 -0500 Subject: [PATCH 01/23] wallet/main: break the replay -> witness-rebuild -> kill loop on large wallets A v1.3.0 node with a ~13.6k-note wallet (-dbcache=4084) looked hung for hours: every start replayed ~62k blocks from a chainstate 26 days stale, then ran a 47-60 min all-core witness rebuild holding LOCK2(cs_main, cs_wallet) (RPC answered HTTP 500 "Work queue depth exceeded", peers timed out), the GUI killed it before anything was flushed, and the next start did it all again. Even after escaping, each new block cost ~30s under LOCK2, so the node fell behind a 36s chain. D1 BuildWitnessCache: the post-rebuild latch computed witnesses.front().root() (~2.4ms) for every pending note on every block, only to feed a log line that is printed for bulk rebuilds alone. The anchor check now runs on the Phase-2 workers and only for a bulk rebuild; the per-block path just sets the latch. Phase 2 also runs in parallel whenever the job is big (notes x commitments >= 2000), not only for a bulk range, so busy blocks no longer extend every note serially. If the OS refuses a worker thread, the remaining shards run inline instead of unwinding past joinable threads (std::terminate). D2 FlushStateToDisk: the coins cache was only flushed on shutdown, gettxoutsetinfo or after 24h; on this chain it stays a few MiB, so the size triggers never fire and a node that never reached 24h uptime or a clean stop kept its chainstate (and block index) weeks behind. Flush at least hourly and every DATABASE_FLUSH_BLOCKS (5000) connected blocks -- the latter also inside one long ActivateBestChain -- and right after the startup ActivateBestChain when it moved the tip. D3 UpdatedNoteData: a replayed or rescanned tx arrives with FindMySaplingNotes data (no nullifier). Witnesses and witnessHeight were carried over but the nullifier was dropped, so VerifyAndSetInitialWitness cleared and reseeded the witnesses on the next block -- the replay above rewrote 3,750 txs and forced the full rebuild. Carry the nullifier with the witnesses, unless the tx was re-mined in a different block (its notes moved; reseed as before). When the merged data is identical, don't rewrite the tx; just clear the in-memory witnessRootValidated so the next block re-checks the kept roots once (a rescan still finds and reseeds a witness that went bad, without rebuilding the healthy ones). The carry loop no longer deep-copies every note. SetBestChain: with hourly coin flushes the wallet locator must not run ahead of the async notifier, or a crash permanently skips the blocks it had not yet delivered. Stamp the block the wallet's state reflects (pindexWalletTip, seeded in init before ThreadNotifyWallets starts -- at the startup rescan's checkpoint if that rescan was interrupted -- and advanced contiguously by ChainTip), like zcashd's ChainTipAdded. ThreadNotifyWallets no longer hands the wallet an empty block after a fatal block-read failure. Tests: new gtest test_wallet_notedata.cpp (5 tests); 23/23 gtests pass, and the two core D3 tests fail with D3 reverted. Regtest A/B against master with a 1,008-note wallet: notify-thread CPU per block 1,780ms -> 3ms; worst LOCK2 RPC stall 1.6-2.9s -> 10-15ms on ordinary blocks and 4.6-7.1s -> ~1.6s on a block carrying a 40-output self-send; a z_importkey rescan went from 35 reseeds plus a bulk rebuild (10.6s) to 0 reseeds, same balance; after kill -9 at 150 blocks a build with a 100-block flush threshold restarted at 100, master at 0. Two adversarial review rounds; every confirmed finding is addressed here. Not covered: the hourly SetBestChain/flush paths were reviewed, not exercised (GetTimeMicros ignores mocktime); no run yet against a copy of a real large wallet. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_wallet_notedata.cpp | 155 +++++++++++++++++++++++++++ src/init.cpp | 38 ++++++- src/main.cpp | 9 +- src/main.h | 11 +- src/validationinterface.cpp | 5 + src/wallet/wallet.cpp | 164 +++++++++++++++++++++++------ src/wallet/wallet.h | 8 +- 8 files changed, 353 insertions(+), 40 deletions(-) create mode 100644 src/gtest/test_wallet_notedata.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 2f3100f11..613b215be 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -14,7 +14,8 @@ hush_gtest_SOURCES = \ gtest/test_randomx_preverify.cpp \ gtest/test_hdtransparent.cpp \ gtest/test_mnemonic_compat.cpp \ - gtest/test_stratum_jobid.cpp + gtest/test_stratum_jobid.cpp \ + gtest/test_wallet_notedata.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_wallet_notedata.cpp b/src/gtest/test_wallet_notedata.cpp new file mode 100644 index 000000000..fddee8875 --- /dev/null +++ b/src/gtest/test_wallet_notedata.cpp @@ -0,0 +1,155 @@ +// 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 + +#include + +#include "primitives/transaction.h" +#include "uint256.h" +#include "wallet/wallet.h" + +namespace { + +class NoteDataWallet : public CWallet { +public: + bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock = true) { + return CWallet::UpdatedNoteData(wtxIn, wtx, fSameBlock); + } +}; + +libzcash::SaplingIncomingViewingKey TestIvk() +{ + return libzcash::SaplingIncomingViewingKey(uint256S("0101010101010101010101010101010101010101010101010101010101010101")); +} + +// A note as the wallet holds it once witnessed: nullifier set, witnesses cached at a height. +SaplingNoteData WitnessedNote(const SaplingMerkleTree& tree, int height, const uint256& nullifier) +{ + SaplingNoteData nd(TestIvk(), nullifier); + nd.witnesses.push_front(tree.witness()); + nd.witnessHeight = height; + return nd; +} + +// A note as FindMySaplingNotes returns it during a replay or rescan: no nullifier, no witnesses. +SaplingNoteData FreshNote() +{ + return SaplingNoteData(TestIvk()); +} + +} + +// A replayed or rescanned tx must keep the witnesses AND the nullifier it already had, and must not +// report an update: dropping the nullifier made the next block clear and reseed every carried witness. +TEST(WalletNoteData, ReplayKeepsNullifierAndWitnesses) { + NoteDataWallet wallet; + CWalletTx wtx(&wallet, CTransaction()); + uint256 txid = uint256S("aa"); + SaplingOutPoint op0(txid, 0); + + SaplingMerkleTree tree; + tree.append(uint256S("01")); + const uint256 nf = uint256S("f00d"); + wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 100, nf); + wtx.mapSaplingNoteData[op0].witnessRootValidated = true; + auto before = wtx.mapSaplingNoteData; + + CWalletTx replay(&wallet, CTransaction()); + replay.mapSaplingNoteData[op0] = FreshNote(); + + EXPECT_FALSE(wallet.UpdatedNoteData(replay, wtx)); + ASSERT_EQ(1u, wtx.mapSaplingNoteData.size()); + const SaplingNoteData& nd = wtx.mapSaplingNoteData.at(op0); + ASSERT_TRUE(static_cast(nd.nullifier)); + EXPECT_EQ(nf, *nd.nullifier); + EXPECT_EQ(100, nd.witnessHeight); + EXPECT_TRUE(nd.witnesses == before.at(op0).witnesses); + // ...but the kept witness is re-checked against the active chain once (in-memory flag only). + EXPECT_FALSE(nd.witnessRootValidated); +} + +// A tx re-mined in a different block (reorg) has its notes at new tree positions: the old nullifier +// must not be carried, so VerifyAndSetInitialWitness reseeds the note at its new position. +TEST(WalletNoteData, MovedTxDropsNullifier) { + NoteDataWallet wallet; + CWalletTx wtx(&wallet, CTransaction()); + SaplingOutPoint op0(uint256S("ee"), 0); + + SaplingMerkleTree tree; + tree.append(uint256S("04")); + wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 500, uint256S("cafe")); + + CWalletTx moved(&wallet, CTransaction()); + moved.mapSaplingNoteData[op0] = FreshNote(); + + EXPECT_TRUE(wallet.UpdatedNoteData(moved, wtx, false)); + const SaplingNoteData& nd = wtx.mapSaplingNoteData.at(op0); + EXPECT_FALSE(static_cast(nd.nullifier)); +} + +// When the incoming data really adds something (a note for a newly added key), the tx is updated, +// and the notes it already had keep their witnesses, witness height and nullifier together. +TEST(WalletNoteData, NewNoteUpdatesAndKeepsExistingNoteState) { + NoteDataWallet wallet; + CWalletTx wtx(&wallet, CTransaction()); + uint256 txid = uint256S("bb"); + SaplingOutPoint op0(txid, 0); + SaplingOutPoint op1(txid, 1); + + SaplingMerkleTree tree; + tree.append(uint256S("02")); + const uint256 nf = uint256S("beef"); + wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 200, nf); + + CWalletTx incoming(&wallet, CTransaction()); + incoming.mapSaplingNoteData[op0] = FreshNote(); + incoming.mapSaplingNoteData[op1] = FreshNote(); + + EXPECT_TRUE(wallet.UpdatedNoteData(incoming, wtx)); + ASSERT_EQ(2u, wtx.mapSaplingNoteData.size()); + const SaplingNoteData& nd0 = wtx.mapSaplingNoteData.at(op0); + ASSERT_TRUE(static_cast(nd0.nullifier)); + EXPECT_EQ(nf, *nd0.nullifier); + EXPECT_EQ(200, nd0.witnessHeight); + EXPECT_EQ(1u, nd0.witnesses.size()); + const SaplingNoteData& nd1 = wtx.mapSaplingNoteData.at(op1); + EXPECT_FALSE(static_cast(nd1.nullifier)); + EXPECT_TRUE(nd1.witnesses.empty()); + EXPECT_EQ(-1, nd1.witnessHeight); +} + +// Nothing is carried for a note that has no witnesses (PART B2): its height and nullifier describe +// no witness, so they must not survive onto the incoming data. +TEST(WalletNoteData, UnwitnessedNoteCarriesNothing) { + NoteDataWallet wallet; + CWalletTx wtx(&wallet, CTransaction()); + SaplingOutPoint op0(uint256S("cc"), 0); + + SaplingNoteData stale(TestIvk(), uint256S("dead")); + stale.witnessHeight = 300; + wtx.mapSaplingNoteData[op0] = stale; + + CWalletTx incoming(&wallet, CTransaction()); + incoming.mapSaplingNoteData[op0] = FreshNote(); + + EXPECT_TRUE(wallet.UpdatedNoteData(incoming, wtx)); + const SaplingNoteData& nd = wtx.mapSaplingNoteData.at(op0); + EXPECT_FALSE(static_cast(nd.nullifier)); + EXPECT_EQ(-1, nd.witnessHeight); + EXPECT_TRUE(nd.witnesses.empty()); +} + +// Incoming data with no Sapling notes never touches the wallet's note data. +TEST(WalletNoteData, EmptyIncomingIsNoUpdate) { + NoteDataWallet wallet; + CWalletTx wtx(&wallet, CTransaction()); + SaplingOutPoint op0(uint256S("dd"), 0); + SaplingMerkleTree tree; + tree.append(uint256S("03")); + wtx.mapSaplingNoteData[op0] = WitnessedNote(tree, 400, uint256S("abcd")); + + CWalletTx incoming(&wallet, CTransaction()); + EXPECT_FALSE(wallet.UpdatedNoteData(incoming, wtx)); + EXPECT_EQ(1u, wtx.mapSaplingNoteData.size()); + EXPECT_EQ(400, wtx.mapSaplingNoteData.at(op0).witnessHeight); +} diff --git a/src/init.cpp b/src/init.cpp index ce8322dc4..d04e45fc5 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -1016,7 +1016,8 @@ extern int32_t HUSH_REWIND; // --- Adaptive coins-cache sizing ------------------------------------------------------------- // The in-memory UTXO/coins cache (nCoinCacheUsage) is the biggest lever on IBD speed: a bigger -// cache means far fewer chainstate flushes to disk. We size it to use most of RAM, but a scheduled +// cache means fewer size-triggered chainstate flushes to disk (the chainstate is also flushed at +// least every DATABASE_FLUSH_INTERVAL / DATABASE_FLUSH_BLOCKS, see main.h). We size it to use most of RAM, but a scheduled // background task (AdjustCoinCacheForMemoryPressure, registered in AppInit2) shrinks the target when // free system memory runs low — e.g. the user opens other apps — and grows it back when memory frees // up, always leaving a reserve free for the rest of the system. The existing per-block flush @@ -2229,6 +2230,9 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) // ********************************************************* Step 8: load wallet #ifdef ENABLE_WALLET + // Set when the startup rescan below is interrupted: the wallet's state then ends at the scan's + // own checkpoint, not at the tip the notifier starts from. + bool fWalletScanIncomplete = false; if (fDisableWallet) { pwalletMain = NULL; LogPrintf("Wallet disabled!\n"); @@ -2666,6 +2670,7 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) } else { LogPrintf("Rescan did not complete; leaving the best-block locator at the scan's own " "checkpoint so the remaining range is rescanned on the next start\n"); + fWalletScanIncomplete = true; } // Restore wallet transaction metadata after -zapwallettxes=1 @@ -2736,6 +2741,21 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) { LOCK(cs_main); pindexLastTip = chainActive.Tip(); +#ifdef ENABLE_WALLET + // The wallet's state reflects the block the notifier starts from, unless the startup + // rescan was interrupted: then it ends at the scan's checkpoint, and the hourly + // SetBestChain must keep stamping that so the next start rescans the rest. + if (pwalletMain) { + const CBlockIndex* pindexWallet = pindexLastTip; + if (fWalletScanIncomplete) { + CWalletDB walletdb(pwalletMain->strWalletFile); + CBlockLocator locator; + if (walletdb.ReadBestBlock(locator)) + pindexWallet = FindForkInGlobalIndex(chainActive, locator); + } + pwalletMain->SetWalletTip(pindexWallet); + } +#endif } boost::function threadnotifywallets = boost::bind(&ThreadNotifyWallets, pindexLastTip); threadGroup.create_thread( @@ -2777,8 +2797,24 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) uiInterface.InitMessage(_("Activating best chain...")); // scan for better chains in the block chain database, that are not yet connected in the active best chain CValidationState state; + const CBlockIndex* pindexBeforeActivate = NULL; + { + LOCK(cs_main); + pindexBeforeActivate = chainActive.Tip(); + } if ( !ActivateBestChain(true,state)) strErrors << "Failed to connect best block"; + else { + // Persist a startup replay now: the wallet's witness rebuild runs next and can hold + // cs_main for a long time, and a node killed before any later flush would replay it all again. + bool fTipMoved; + { + LOCK(cs_main); + fTipMoved = chainActive.Tip() != pindexBeforeActivate; + } + if (fTipMoved) + FlushStateToDisk(); + } } else { LogPrintf("HUSH_REWIND < 0\n"); } diff --git a/src/main.cpp b/src/main.cpp index 6bc40c659..492d0b66b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3738,6 +3738,7 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) { static int64_t nLastWrite = 0; static int64_t nLastFlush = 0; static int64_t nLastSetChain = 0; + static int nLastFlushHeight = -1; std::set setFilesToPrune; bool fFlushForPrune = false; try { @@ -3763,6 +3764,9 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) { if (nLastSetChain == 0) { nLastSetChain = nNow; } + if (nLastFlushHeight < 0) { + nLastFlushHeight = chainActive.Height(); + } size_t cacheSize = pcoinsTip->DynamicMemoryUsage(); // The cache is large and close to the limit, but we have time now (not in the middle of a block processing). bool fCacheLarge = mode == FLUSH_STATE_PERIODIC && cacheSize * (10.0/9) > nCoinCacheUsage; @@ -3772,8 +3776,10 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) { bool fPeriodicWrite = mode == FLUSH_STATE_PERIODIC && nNow > nLastWrite + (int64_t)DATABASE_WRITE_INTERVAL * 1000000; // It's been very long since we flushed the cache. Do this infrequently, to optimize cache usage. bool fPeriodicFlush = mode == FLUSH_STATE_PERIODIC && nNow > nLastFlush + (int64_t)DATABASE_FLUSH_INTERVAL * 1000000; + // Many blocks connected since the last flush, e.g. a long replay inside one ActivateBestChain call. + bool fBlockCountFlush = (mode == FLUSH_STATE_IF_NEEDED || mode == FLUSH_STATE_PERIODIC) && chainActive.Height() - nLastFlushHeight >= DATABASE_FLUSH_BLOCKS; // Combine all conditions that result in a full cache flush. - bool fDoFullFlush = (mode == FLUSH_STATE_ALWAYS) || fCacheLarge || fCacheCritical || fPeriodicFlush || fFlushForPrune; + bool fDoFullFlush = (mode == FLUSH_STATE_ALWAYS) || fCacheLarge || fCacheCritical || fPeriodicFlush || fFlushForPrune || fBlockCountFlush; // Write blocks and block index to disk. if (fDoFullFlush || fPeriodicWrite) { // Depend on nMinDiskSpace to ensure we can write block index @@ -3827,6 +3833,7 @@ bool static FlushStateToDisk(CValidationState &state, FlushStateMode mode) { if (!pcoinsTip->Flush()) return AbortNode(state, "Failed to write to coin database"); nLastFlush = nNow; + nLastFlushHeight = chainActive.Height(); } if ((mode == FLUSH_STATE_ALWAYS || mode == FLUSH_STATE_PERIODIC) && nNow > nLastSetChain + (int64_t)DATABASE_WRITE_INTERVAL * 1000000) { // Update best block in wallet (so we can detect restored wallets). diff --git a/src/main.h b/src/main.h index 007e67891..89e962497 100644 --- a/src/main.h +++ b/src/main.h @@ -143,8 +143,15 @@ static const unsigned int MAX_LOCATOR_SZ = 101; static unsigned int BLOCK_DOWNLOAD_WINDOW = 1024; /** Time to wait (in seconds) between writing blocks/block index to disk. */ static const unsigned int DATABASE_WRITE_INTERVAL = 60 * 60; -/** Time to wait (in seconds) between flushing chainstate to disk. */ -static const unsigned int DATABASE_FLUSH_INTERVAL = 24 * 60 * 60; +/** Time to wait (in seconds) between flushing chainstate to disk. Upstream uses 24h to keep a large + * coins cache warm, but DragonX's cache stays a few MiB (private chain, few transparent outputs), so + * the size triggers never fire and this timer is all that persists the chainstate while a node runs: + * a node that never reached 24h uptime or a clean shutdown replayed weeks of blocks on every start. */ +static const unsigned int DATABASE_FLUSH_INTERVAL = 60 * 60; +/** Also flush chainstate once this many blocks were connected since the last flush. A long + * ActivateBestChain (startup replay, catching up after downtime) only flushes IF_NEEDED until it + * returns, so without this a node killed part-way through loses the whole replay. */ +static const int DATABASE_FLUSH_BLOCKS = 5000; /** Maximum length of reject messages. */ static const unsigned int MAX_REJECT_MESSAGE_LENGTH = 111; static const int64_t DEFAULT_MAX_TIP_AGE = 24 * 60 * 60; diff --git a/src/validationinterface.cpp b/src/validationinterface.cpp index e138b7322..f1018833f 100644 --- a/src/validationinterface.cpp +++ b/src/validationinterface.cpp @@ -203,6 +203,9 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip) _("Error: A fatal internal error occurred, see debug.log for details"), "", CClientUIInterface::MSG_ERROR); StartShutdown(); + // Don't hand the wallet an empty block as if it had been processed. + fDisconnectIncomplete = true; + break; } // Let wallets know transactions went from 1-confirmed to @@ -239,6 +242,8 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip) _("Error: A fatal internal error occurred, see debug.log for details"), "", CClientUIInterface::MSG_ERROR); StartShutdown(); + // Don't hand the wallet an empty block as if it had been processed. + break; } // Tell wallet about transactions that went from mempool diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 443498cba..425bda60d 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -43,6 +43,7 @@ #include "wallet/asyncrpcoperation_autoshieldcoinbase.h" #include #include +#include #include #include #include @@ -575,6 +576,23 @@ void CWallet::ChainTip(const CBlockIndex *pindex, DecrementNoteWitnesses(pindex); UpdateNullifierNoteMapForBlock(pblock); } + + // Track the block the wallet's state reflects, advancing only along blocks delivered in order: + // a gap (the startup rescan was interrupted, so the wallet sits at its checkpoint behind the + // notifier) pins the pointer, and SetBestChain keeps stamping the checkpoint. + LOCK(cs_wallet); + if (added) { + if (pindexWalletTip == nullptr || pindex->pprev == pindexWalletTip) + pindexWalletTip = pindex; + } else if (pindex == pindexWalletTip) { + pindexWalletTip = pindex->pprev; + } +} + +void CWallet::SetWalletTip(const CBlockIndex* pindex) +{ + LOCK(cs_wallet); + pindexWalletTip = pindex; } void CWallet::RunSaplingSweep(int blockHeight) { @@ -801,10 +819,22 @@ bool CWallet::CommitAutomatedTx(const CTransaction& tx) { void CWallet::SetBestChain(const CBlockLocator& loc) { + // FlushStateToDisk passes the chain tip, but ThreadNotifyWallets delivers blocks to the wallet + // asynchronously and can lag it. Stamping a locator ahead of what the wallet has processed makes + // the next start skip those blocks for good (no rescan, no notification): notes received in them + // go missing and spends in them go unseen. So stamp the block the wallet's state reflects (seeded + // by init before the notifier starts, then advanced by ChainTip). Init's own calls, made before + // it is seeded, pass the right locator themselves. + CBlockLocator locator = loc; + { + LOCK2(cs_main, cs_wallet); + if (pindexWalletTip != nullptr) + locator = chainActive.GetLocator(pindexWalletTip); + } // Default ctor => fFlushOnClose=true => ~CDB runs a full BDB txn_checkpoint over the entire // cache. Fine for the hourly/shutdown callers; ruinous inside a scan (see SetBestChainNoFlush). CWalletDB walletdb(strWalletFile); - SetBestChainINTERNAL(walletdb, loc); + SetBestChainINTERNAL(walletdb, locator); } bool CWallet::SetBestChainNoFlush(const CBlockLocator& loc) @@ -1477,17 +1507,25 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) work.reserve(pending.size()); for (const PendingNote& p : pending) work.push_back(p.nd); - // Only a substantial bulk rebuild (e.g. a one-time post-upgrade repair) is worth parallelizing - // and logging; routine 1-block tip extension runs serially to avoid per-block thread-spawn - // overhead and log spam. + // Only a substantial bulk rebuild (e.g. a one-time post-upgrade repair) is logged and has its + // anchors checked; routine 1-block tip extension stays quiet to avoid per-block log spam. const bool bulkRebuild = (rangeLen > 100); + // Parallelize whenever the extension itself is expensive, not only for a bulk range: a routine + // 1-block extension appends each of the block's commitments to every lagging note, so a large + // wallet and a busy block (13k notes x 20 commitments) is seconds of hashing under + // cs_main/cs_wallet. Spawning the threads costs ~1ms, so only small jobs stay serial. + uint64_t nCms = 0; + for (const std::vector& cms : blockCms) + nCms += cms.size(); + const bool fParallel = bulkRebuild || (uint64_t)work.size() * nCms >= 2000; + int nPar = (int)GetArg("-witnessbuildthreads", 0); if (nPar <= 0) nPar = (int)std::thread::hardware_concurrency(); if (nPar <= 0) nPar = 1; if (nPar > (int)work.size()) nPar = (int)work.size(); if (nPar < 1) nPar = 1; - if (!bulkRebuild) nPar = 1; + if (!fParallel) nPar = 1; std::atomic failed(false); std::mutex failMtx; @@ -1499,14 +1537,27 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) // clone-every-block path for A/B verification. bool fastRebuild = GetBoolArg("-witnessfastrebuild", true); + // A bulk rebuild reports how many notes failed to reach the canonical anchor. root() walks the + // full depth-32 path (~2.4ms), so the check runs here on the workers and never on the routine + // per-block path, where it used to hold cs_main/cs_wallet for ~30s per block on a ~13k-note + // wallet purely to feed a log line that path never prints. + const uint256 canonicalRoot = pindex->hashFinalSaplingRoot; + std::atomic nUnrecoverable(0); + auto checkRoot = [&](const SaplingNoteData* nd) { + if (bulkRebuild && (nd->witnesses.empty() || nd->witnesses.front().root() != canonicalRoot)) + nUnrecoverable++; + }; + auto worker = [&](int tid) { try { for (size_t k = (size_t)tid; k < work.size(); k += (size_t)nPar) { SaplingNoteData* nd = work[k]; int startH = nd->witnessHeight; int nBlocks = tipHeight - startH; - if (nBlocks <= 0) + if (nBlocks <= 0) { + checkRoot(nd); continue; + } if (!fastRebuild || nBlocks <= CACHE) { // Reference / shallow path: clone every block (preserves pre-existing older snapshots). @@ -1547,6 +1598,7 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) nd->witnesses.pop_back(); nd->witnessHeight = tipHeight; } + checkRoot(nd); } } catch (const std::exception& e) { std::lock_guard lk(failMtx); @@ -1561,9 +1613,18 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) if (nPar <= 1) { worker(0); } else { + // If the OS refuses a thread (task limit, memory), run the remaining shards here instead of + // letting the exception unwind past joinable threads (std::terminate). Each shard still runs + // exactly once, so the result is unchanged. std::vector threads; threads.reserve(nPar); - for (int t = 0; t < nPar; t++) threads.emplace_back(worker, t); + int t = 0; + try { + for (; t < nPar; t++) threads.emplace_back(worker, t); + } catch (const std::system_error& e) { + LogPrintf("%s: could only start %d of %d witness threads (%s); running the rest inline\n", __func__, t, nPar, e.what()); + } + for (int u = t; u < nPar; u++) worker(u); for (std::thread& th : threads) th.join(); } int64_t tExtend = GetTimeMicros() - e0; @@ -1578,20 +1639,16 @@ void CWallet::BuildWitnessCache(const CBlockIndex* pindex, bool witnessOnly) // is unrecoverable here: it is left flagged (the GetSaplingNoteWitnesses majority-anchor guard // skips it for spends) and reported, rather than spun on indefinitely. witnessRootValidated is // in-memory only, so a fresh validation pass still runs on each restart and after any reorg - // (DecrementNoteWitnesses clears it), keeping the heal self-correcting. + // (DecrementNoteWitnesses clears it), keeping the heal self-correcting. The unrecoverable count + // itself comes from checkRoot() on the workers, and only for a bulk rebuild. if (!work.empty()) { - const uint256& canonicalRoot = pindex->hashFinalSaplingRoot; - int nUnrecoverable = 0; - for (SaplingNoteData* nd : work) { - if (nd->witnesses.empty() || nd->witnesses.front().root() != canonicalRoot) - nUnrecoverable++; + for (SaplingNoteData* nd : work) nd->witnessRootValidated = true; - } if (bulkRebuild) { LogPrintf("%s: rebuilt %u note witness cache(s) to height %d in %ldms using %d thread(s)%s\n", __func__, (unsigned)work.size(), tipHeight, (long)((tRead + tExtend) / 1000), nPar, - nUnrecoverable - ? strprintf(" [WARNING: %d note(s) could not be rebuilt to the canonical anchor and were skipped]", nUnrecoverable).c_str() + nUnrecoverable.load() + ? strprintf(" [WARNING: %d note(s) could not be rebuilt to the canonical anchor and were skipped]", nUnrecoverable.load()).c_str() : ""); } } @@ -1913,6 +1970,8 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD bool fUpdated = false; if (!fInsertedNew) { + // Re-mined in a different block (a reorg): its notes sit at new tree positions. + bool fSameBlock = wtx.hashBlock.IsNull() || wtxIn.hashBlock.IsNull() || wtxIn.hashBlock == wtx.hashBlock; // Merge if (!wtxIn.hashBlock.IsNull() && wtxIn.hashBlock != wtx.hashBlock) { @@ -1925,7 +1984,7 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD wtx.nIndex = wtxIn.nIndex; fUpdated = true; } - if (UpdatedNoteData(wtxIn, wtx)) { + if (UpdatedNoteData(wtxIn, wtx, fSameBlock)) { fUpdated = true; } if (wtxIn.fFromMe && wtxIn.fFromMe != wtx.fFromMe) @@ -1962,29 +2021,66 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD return true; } -bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx) +// True if both maps hold the same notes with identical persisted state (every field SaplingNoteData +// serializes). SaplingNoteData::operator== ignores the witnesses, so it cannot tell a real change +// from a redundant update. +static bool SaplingNoteDataIdentical(const mapSaplingNoteData_t& a, const mapSaplingNoteData_t& b) +{ + if (a.size() != b.size()) + return false; + for (const mapSaplingNoteData_t::value_type& item : a) { + mapSaplingNoteData_t::const_iterator it = b.find(item.first); + if (it == b.end()) + return false; + const SaplingNoteData& x = item.second; + const SaplingNoteData& y = it->second; + if (!(x.ivk == y.ivk) || x.nullifier != y.nullifier || x.witnessHeight != y.witnessHeight || x.witnesses != y.witnesses) + return false; + } + return true; +} + +bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock) { bool unchangedSaplingFlag = (wtxIn.mapSaplingNoteData.empty() || wtxIn.mapSaplingNoteData == wtx.mapSaplingNoteData); - if (!unchangedSaplingFlag) { - auto tmp = wtxIn.mapSaplingNoteData; - // Ensure we keep any cached witnesses we may already have + if (unchangedSaplingFlag) + return false; - for (const std::pair nd : wtx.mapSaplingNoteData) { - if (tmp.count(nd.first) && nd.second.witnesses.size() > 0) { - tmp.at(nd.first).witnesses.assign( - nd.second.witnesses.cbegin(), nd.second.witnesses.cend()); - //PART B2: only carry over witnessHeight TOGETHER with the witnesses it describes. - //Copying it unconditionally (when witnesses are NOT copied) advances the height past - //the actual witness state for a whole tx's notes at once = the batch desync originator. - tmp.at(nd.first).witnessHeight = nd.second.witnessHeight; - } + auto tmp = wtxIn.mapSaplingNoteData; + // Ensure we keep any cached witnesses we may already have + + for (const mapSaplingNoteData_t::value_type& nd : wtx.mapSaplingNoteData) { + if (tmp.count(nd.first) && nd.second.witnesses.size() > 0) { + tmp.at(nd.first).witnesses.assign( + nd.second.witnesses.cbegin(), nd.second.witnesses.cend()); + //PART B2: only carry over witnessHeight TOGETHER with the witnesses it describes. + //Copying it unconditionally (when witnesses are NOT copied) advances the height past + //the actual witness state for a whole tx's notes at once = the batch desync originator. + tmp.at(nd.first).witnessHeight = nd.second.witnessHeight; + //The nullifier is derived from the note and its witness position, so it travels with the + //witnesses too. wtxIn comes from FindMySaplingNotes, which never sets it; dropping it made + //VerifyAndSetInitialWitness clear and reseed the witnesses just carried over, so replaying + //or rescanning already-witnessed txs forced a rebuild of every one of their notes. A tx + //re-mined in another block is the exception: its notes moved, so the old nullifier is + //wrong and dropping it still reseeds them at the new position. + if (nd.second.nullifier && fSameBlock) + tmp.at(nd.first).nullifier = nd.second.nullifier; } - - // Now copy over the updated note data - wtx.mapSaplingNoteData = tmp; } - return !unchangedSaplingFlag; + // A replayed or rescanned tx usually carries nothing new; don't rewrite it to disk. Do have + // VerifyAndSetInitialWitness re-check the kept witnesses against the active chain once (the + // flag is in memory only): the chain may have moved under them, and it keeps a rescan able to + // find and reseed a witness that went bad, without rebuilding the healthy ones. + if (SaplingNoteDataIdentical(tmp, wtx.mapSaplingNoteData)) { + for (mapSaplingNoteData_t::value_type& item : wtx.mapSaplingNoteData) + item.second.witnessRootValidated = false; + return false; + } + + // Now copy over the updated note data + wtx.mapSaplingNoteData = tmp; + return true; } /** diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 107814971..ebcb64bb1 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -965,8 +965,12 @@ private: template void SyncMetaData(std::pair::iterator, typename TxSpendMap::iterator>); + // Last block ThreadNotifyWallets delivered to ChainTip, i.e. the block the wallet's state + // reflects (guarded by cs_wallet). SetBestChain stamps this instead of the chain tip. + const CBlockIndex* pindexWalletTip = nullptr; + protected: - bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx); + bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock); void MarkAffectedTransactionsDirty(const CTransaction& tx); /* the hd chain data model (chain counters) */ @@ -1294,6 +1298,8 @@ public: bool CommitAutomatedTx(const CTransaction& tx); /** Saves witness caches and best block locator to disk. Overrides CValidationInterface. */ void SetBestChain(const CBlockLocator& loc); + // Seed the block the wallet's state reflects, before ThreadNotifyWallets starts delivering. + void SetWalletTip(const CBlockIndex* pindex); /** As SetBestChain, but for use INSIDE a long scan: opens the wallet DB with fFlushOnClose=false * so the call does not trigger a full BDB txn_checkpoint over the whole cache (see the comment * at CWallet::SetBestChain), and reports whether the write actually committed. */ From d91e069da38c2a62b15ea9c67fade7a0302967a5 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 21:23:00 -0500 Subject: [PATCH 02/23] net: stop wolfSSL's CloseSocket macro from turning P2P closes into double closes wolfssl/wolfio.h (included by in net.h) does `#ifndef CloseSocket / #define CloseSocket(s) close(s)`. Every TU that includes net.h after that point compiles CloseSocket(x) to a bare close(x). That includes all 15 calls in net.cpp, among them the ones in CloseSocketDisconnect and ~CNode. util/sock.cpp's CloseSocket(SOCKET&), the only thing that sets the handle to INVALID_SOCKET, was never called from net.cpp. This has been the case since OpenSSL was replaced by wolfSSL (Hush 9ff756590, 2020), so every DragonX release is affected. So CNode::hSocket kept its number after the socket was closed. Every P2P disconnect closed that same number again, 2 to 4 times: - CloseSocketDisconnect (net.cpp:572) runs once per disconnect trigger (ThreadSocketHandler, SocketSendData, the TLS socket handler, ThreadMessageHandler), and each run still saw a valid-looking hSocket; - ~CNode (net.cpp:2819) then closed it once more. Meanwhile the socket handler kept FD_SETting, send()ing and recv()ing on the stale number. In between, other threads get that number from the kernel's lowest-free-fd rule: - BDB reopening wallet.dat or database/log.* (ThreadFlushWalletDB, ~11/s); - fopen("rb+") of a block file for getdata/getblock; - an HTTP RPC accept. The stray close then closes it. BDB keeps doing I/O on the dead number: 'wallet.dat: write failed for page N' / 'Illegal seek' / 'BDB0060 PANIC', or silent wallet-page writes into a blocks/blk*.dat that got the number next. Its failing reads later threw out of ThreadNotifyWallets (std::terminate). A closed HTTP connection fd makes libevent re-arm and later close whatever owns that number next ('Epoll ADD ... failed'). Reproduced on isolated regtest with an LD_PRELOAD close() guard: - one disconnectnode gives exactly one EBADF double close (49 disconnects, 50 EBADF; with strace and no shim, 29 disconnects gave 30 'close(N) = -1 EBADF'); - P2P churn alone gives non-owner closes of block files and wallet.dat; - RPC-only and Tor-only loads give none. Disassembly of CloseSocketDisconnect and ~CNode shows 'call close@plt' before this change and 'call CloseSocket(unsigned int&)' after. Undefine the macro right after the wolfSSL include in net.h; wolfio.h is include-guarded, so it can't come back. net.cpp gets an #error tripwire in case it ever does. NetCloseSocket.ClosedNodeNeverClosesAReusedFdNumber fails on the old code (a closed CNode closes the fd number's new owner) and passes now. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_net_closesocket.cpp | 50 ++++++++++++++++++++++++++++++ src/net.cpp | 7 +++++ src/net.h | 8 +++++ 4 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 src/gtest/test_net_closesocket.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 613b215be..a79fec340 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -15,7 +15,8 @@ hush_gtest_SOURCES = \ gtest/test_hdtransparent.cpp \ gtest/test_mnemonic_compat.cpp \ gtest/test_stratum_jobid.cpp \ - gtest/test_wallet_notedata.cpp + gtest/test_wallet_notedata.cpp \ + gtest/test_net_closesocket.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_net_closesocket.cpp b/src/gtest/test_net_closesocket.cpp new file mode 100644 index 000000000..b6fcd76e1 --- /dev/null +++ b/src/gtest/test_net_closesocket.cpp @@ -0,0 +1,50 @@ +// 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 + +#include + +#include +#include +#include + +#include "net.h" +#include "util/sock.h" + +// Regression test for the wolfSSL CloseSocket macro (see net.h): closing a CNode's socket must invalidate +// hSocket, so neither a repeated CloseSocketDisconnect nor ~CNode can close that fd number again once another +// owner (the wallet's BDB files, a block file, an RPC socket) has been handed it. +TEST(NetCloseSocket, ClosedNodeNeverClosesAReusedFdNumber) { + int pair[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, pair)); + const int number = pair[0]; + + CNode* node = new CNode(number, CAddress(), "", true); // inbound: sends no version message + node->CloseSocketDisconnect(); + EXPECT_EQ(INVALID_SOCKET, node->hSocket) << "CloseSocketDisconnect left the closed fd number in hSocket"; + EXPECT_EQ(-1, fcntl(number, F_GETFD)) << "the node's socket wasn't closed"; + + // Another owner gets the freed number, as the kernel's lowest-free-fd rule does in a busy node. + const int other = open("/dev/null", O_RDONLY); + ASSERT_GE(other, 0); + ASSERT_EQ(number, dup2(other, number)); + + node->CloseSocketDisconnect(); // e.g. a send error after the disconnect was already handled + delete node; // ~CNode + EXPECT_NE(-1, fcntl(number, F_GETFD)) << "a closed CNode closed the fd number's new owner"; + + close(number); + close(other); + close(pair[1]); +} + +// util/sock.cpp's CloseSocket is the one that must be called: it closes once and invalidates. +TEST(NetCloseSocket, CloseSocketInvalidates) { + int pair[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, pair)); + SOCKET s = pair[0]; + EXPECT_TRUE(CloseSocket(s)); + EXPECT_EQ(INVALID_SOCKET, s); + EXPECT_FALSE(CloseSocket(s)) << "a second close of an invalidated handle must be a no-op"; + close(pair[1]); +} diff --git a/src/net.cpp b/src/net.cpp index 2aab1dfcd..363930fc9 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -45,6 +45,13 @@ #include #include #include + +// CloseSocket must be util/sock.cpp's CloseSocket(SOCKET&), which invalidates the handle it closes. If +// wolfSSL's `#define CloseSocket(s) close(s)` survived into this file (see net.h), each close here would leave +// its stale fd number behind to be closed again after another thread reused it. +#ifdef CloseSocket +#error "CloseSocket is a macro here (wolfssl/wolfio.h): it would bypass CloseSocket(SOCKET&) in util/sock.cpp" +#endif using namespace hush; extern int32_t HUSH_TESTNODE; extern uint8_t ASSETCHAINS_CLEARNET; diff --git a/src/net.h b/src/net.h index 2627e071a..7e264502f 100644 --- a/src/net.h +++ b/src/net.h @@ -57,6 +57,14 @@ #define byte unsigned char #include #undef byte +// wolfssl/wolfio.h (pulled in by ssl.h) does `#ifndef CloseSocket / #define CloseSocket(s) close(s)`. Left +// defined, it turns every CloseSocket(hSocket) after this point -- all 15 calls in net.cpp, including the ones in +// CloseSocketDisconnect and ~CNode -- into a bare close() that never sets hSocket = INVALID_SOCKET, so util/sock.cpp's +// CloseSocket(SOCKET&) is never called. Every P2P disconnect then closed its fd number again later (a repeated +// CloseSocketDisconnect, then ~CNode), by which time another thread often owned that number: wallet.dat or a +// database/log.* fd (BDB then wrote wallet pages into whatever got the number next, e.g. a blocks/*.dat file +// opened "rb+"), a block file, or an HTTP RPC socket. wolfio.h is include-guarded, so it can't come back. +#undef CloseSocket class CAddrMan; class CBlockIndex; From 9cb31326e3c4f546752f6e42dbf0eedcb0260627 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 20:44:00 -0500 Subject: [PATCH 03/23] main: open block and undo files read-only when they're opened for reading OpenDiskFile opened every block or undo file "rb+", even for its fReadOnly callers, so every reader held a writable fd. That's what let the wolfSSL CloseSocket bug corrupt blocks/*.dat. A P2P double close took the wallet's BDB fd away; the number was then handed to a block file opened for a getblock/getdata read; BDB's next wallet page write landed in it, at offset pgno*4096. On production that destroyed blocks 2,728,132-2,728,161 in blk00011.dat and block 3,356,880 in blk00020.dat. Every fReadOnly caller only reads through the handle: - ReadBlockFromDisk, UndoReadFromDisk, both GetTransaction paths and hush_blockload read via CAutoFile >>; - the reindex loop hands it to LoadExternalBlockFile, which reads via CBufferedFile; - the existence check at startup only opens it; - hush_prefetch only freads. Open those "rb". A stray write through a reused fd number then fails (EBADF) instead of silently corrupting a block file. Missing files still fail to open as before; writers keep "rb+"/"wb+". FdDefense.ReadOnlyBlockFileIsNotWritable: pwrite through a read-only block file's fd fails with EBADF, while the writable open still writes. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_fd_defense.cpp | 55 +++++++++++++++++++++++++++++++++++ src/main.cpp | 5 +++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 src/gtest/test_fd_defense.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index a79fec340..35042a0b7 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -16,7 +16,8 @@ hush_gtest_SOURCES = \ gtest/test_mnemonic_compat.cpp \ gtest/test_stratum_jobid.cpp \ gtest/test_wallet_notedata.cpp \ - gtest/test_net_closesocket.cpp + gtest/test_net_closesocket.cpp \ + gtest/test_fd_defense.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_fd_defense.cpp b/src/gtest/test_fd_defense.cpp new file mode 100644 index 000000000..20c754a2a --- /dev/null +++ b/src/gtest/test_fd_defense.cpp @@ -0,0 +1,55 @@ +// 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 + +#include + +#include +#include +#include + +#include + +#include "chainparams.h" +#include "main.h" +#include "util.h" + +// A block file opened for reading must not be writable through its fd: a stray write through a reused fd number +// (the wolfSSL CloseSocket macro let the wallet's BDB fd be closed behind its back) used to land in it. +TEST(FdDefense, ReadOnlyBlockFileIsNotWritable) { + SelectParams(CBaseChainParams::MAIN); + const boost::filesystem::path dir = boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("fddefense-%%%%%%%%"); + const bool hadDatadir = mapArgs.count("-datadir") > 0; + const std::string oldDatadir = hadDatadir ? mapArgs["-datadir"] : ""; + boost::filesystem::create_directories(dir); + mapArgs["-datadir"] = dir.string(); + ClearDatadirCache(); + + const CDiskBlockPos pos(0, 0); + const boost::filesystem::path blk = GetBlockPosFilename(pos, "blk"); + boost::filesystem::create_directories(blk.parent_path()); + { + FILE* f = fopen(blk.string().c_str(), "wb"); + ASSERT_TRUE(f != nullptr); + fputs("block bytes", f); + fclose(f); + } + + FILE* ro = OpenBlockFile(pos, true); + ASSERT_TRUE(ro != nullptr); + char c = 0; + EXPECT_EQ(1u, fread(&c, 1, 1, ro)) << "still readable"; + errno = 0; + EXPECT_EQ(-1, pwrite(fileno(ro), "X", 1, 0)) << "a write through a read-only block file's fd must fail"; + EXPECT_EQ(EBADF, errno); + fclose(ro); + + FILE* rw = OpenBlockFile(pos, false); + ASSERT_TRUE(rw != nullptr); + EXPECT_EQ(1, pwrite(fileno(rw), "b", 1, 0)) << "the writable open still writes"; + fclose(rw); + + if (hadDatadir) mapArgs["-datadir"] = oldDatadir; else mapArgs.erase("-datadir"); + ClearDatadirCache(); + boost::filesystem::remove_all(dir); +} diff --git a/src/main.cpp b/src/main.cpp index 492d0b66b..db0af063b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -5922,7 +5922,10 @@ FILE* OpenDiskFile(const CDiskBlockPos &pos, const char *prefix, bool fReadOnly) return NULL; boost::filesystem::path path = GetBlockPosFilename(pos, prefix); boost::filesystem::create_directories(path.parent_path()); - FILE* file = fopen(path.string().c_str(), "rb+"); + // Read-only opens get a read-only FILE (every fReadOnly caller only reads). With "rb+", a write through a + // reused fd number landed here: when the wolfSSL CloseSocket macro (see net.h) closed the wallet's BDB fd + // behind its back, BDB's next page write went to whatever reopened that number -- a block file opened here. + FILE* file = fopen(path.string().c_str(), fReadOnly ? "rb" : "rb+"); if (!file && !fReadOnly) file = fopen(path.string().c_str(), "wb+"); if (!file) { From 5b36d7ad245d86a53def79656b57dc75d3a48618 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 21:23:01 -0500 Subject: [PATCH 04/23] validationinterface: shut down cleanly when a wallet block notification throws ThreadNotifyWallets' block disconnect and connect loops had no try/catch (only its mempool loop does). A wallet database failure inside SyncWithWallets or ChainTip escaped the thread, and TraceThread logs and rethrows, so the node died in std::terminate with nothing flushed. One example: CWalletDB::ListAccountCreditDebit's "error scanning DB" after BDB lost its file to the wolfSSL CloseSocket bug. That is how the pool's node died on 2026-10-12 and 2026-10-16. Run each block's wallet notifications through NotifyWalletsOrShutdown. On an exception it logs the error, requests a shutdown, shows the usual fatal-error message and stops the notifier, leaving pindexLastTip at the last block the wallet fully processed. Carrying on instead would leave the wallet behind the chain, missing the block it failed on. Thread interruption still propagates. The shutdown is requested before the fatal-error message is shown. FdDefense.WalletNotifyFailureShutsDownCleanly: - a throwing notification requests a shutdown, a clean one doesn't; - a non-std exception is handled the same way; - boost::thread_interrupted still propagates. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_fd_defense.cpp | 28 +++++++++++++++ src/validationinterface.cpp | 66 +++++++++++++++++++++++++---------- src/validationinterface.h | 5 +++ 3 files changed, 81 insertions(+), 18 deletions(-) diff --git a/src/gtest/test_fd_defense.cpp b/src/gtest/test_fd_defense.cpp index 20c754a2a..c8c9cb28b 100644 --- a/src/gtest/test_fd_defense.cpp +++ b/src/gtest/test_fd_defense.cpp @@ -4,15 +4,23 @@ #include +#include #include +#include #include #include #include +#include #include "chainparams.h" +#include "init.h" #include "main.h" +#include "noui.h" #include "util.h" +#include "validationinterface.h" + +extern std::atomic fRequestShutdown; // A block file opened for reading must not be writable through its fd: a stray write through a reused fd number // (the wolfSSL CloseSocket macro let the wallet's BDB fd be closed behind its back) used to land in it. @@ -53,3 +61,23 @@ TEST(FdDefense, ReadOnlyBlockFileIsNotWritable) { ClearDatadirCache(); boost::filesystem::remove_all(dir); } + +// A wallet notification that throws (a wallet DB failure) requests a clean shutdown instead of escaping the +// notifier thread into std::terminate; a successful one doesn't; thread interruption still propagates. +TEST(FdDefense, WalletNotifyFailureShutsDownCleanly) { + noui_connect(); // the daemon connects these UI signals at startup; the error path shows a message box + fRequestShutdown = false; + EXPECT_TRUE(NotifyWalletsOrShutdown([] {})); + EXPECT_FALSE(ShutdownRequested()); + + EXPECT_FALSE(NotifyWalletsOrShutdown([] { throw std::runtime_error("CWalletDB::ListAccountCreditDebit(): error scanning DB"); })); + EXPECT_TRUE(ShutdownRequested()); + fRequestShutdown = false; + + EXPECT_FALSE(NotifyWalletsOrShutdown([] { throw 42; })); + EXPECT_TRUE(ShutdownRequested()); + fRequestShutdown = false; + + EXPECT_THROW(NotifyWalletsOrShutdown([] { throw boost::thread_interrupted(); }), boost::thread_interrupted); + EXPECT_FALSE(ShutdownRequested()); +} diff --git a/src/validationinterface.cpp b/src/validationinterface.cpp index f1018833f..4de52d6f2 100644 --- a/src/validationinterface.cpp +++ b/src/validationinterface.cpp @@ -86,6 +86,30 @@ struct CachedBlockData { pindex(pindex), oldTrees(oldTrees), txConflicted(txConflicted) {} }; +bool NotifyWalletsOrShutdown(const std::function& notify) +{ + // The wallet can throw here on a database failure, e.g. CWalletDB::ListAccountCreditDebit's "error scanning + // DB" after BDB lost its file. Escaping this thread, that std::terminate()d the node (TraceThread logs and + // rethrows) with nothing flushed. Stop cleanly instead: going on would leave the wallet behind the chain, + // missing the block it failed on. + try { + notify(); + return true; + } catch (const boost::thread_interrupted&) { + throw; + } catch (const std::exception& e) { + PrintExceptionContinue(&e, "ThreadNotifyWallets()"); + } catch (...) { + PrintExceptionContinue(NULL, "ThreadNotifyWallets()"); + } + LogPrintf("*** %s\n", "Wallet failed to process a block notification; shutting down"); + StartShutdown(); + uiInterface.ThreadSafeMessageBox( + _("Error: A fatal internal error occurred, see debug.log for details"), + "", CClientUIInterface::MSG_ERROR); + return false; +} + void ThreadNotifyWallets(CBlockIndex *pindexLastTip) { // If pindexLastTip == nullptr, the wallet is at genesis. @@ -208,13 +232,16 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip) break; } - // Let wallets know transactions went from 1-confirmed to - // 0-confirmed or conflicted: - for (const CTransaction &tx : block.vtx) { - SyncWithWallets(tx, NULL); - } - // Update cached incremental witnesses - GetMainSignals().ChainTip(pindexLastTip, &block, boost::none); + if (!NotifyWalletsOrShutdown([&] { + // Let wallets know transactions went from 1-confirmed to + // 0-confirmed or conflicted: + for (const CTransaction &tx : block.vtx) { + SyncWithWallets(tx, NULL); + } + // Update cached incremental witnesses + GetMainSignals().ChainTip(pindexLastTip, &block, boost::none); + })) + return; // On to the next block! pindexLastTip = pindexLastTip->pprev; @@ -246,17 +273,20 @@ void ThreadNotifyWallets(CBlockIndex *pindexLastTip) break; } - // Tell wallet about transactions that went from mempool - // to conflicted: - for (const CTransaction &tx : blockData.txConflicted) { - SyncWithWallets(tx, NULL); - } - // ... and about transactions that got confirmed: - for (const CTransaction &tx : block.vtx) { - SyncWithWallets(tx, &block); - } - // Update cached incremental witnesses - GetMainSignals().ChainTip(blockData.pindex, &block, blockData.oldTrees); + if (!NotifyWalletsOrShutdown([&] { + // Tell wallet about transactions that went from mempool + // to conflicted: + for (const CTransaction &tx : blockData.txConflicted) { + SyncWithWallets(tx, NULL); + } + // ... and about transactions that got confirmed: + for (const CTransaction &tx : block.vtx) { + SyncWithWallets(tx, &block); + } + // Update cached incremental witnesses + GetMainSignals().ChainTip(blockData.pindex, &block, blockData.oldTrees); + })) + return; // This block is done! pindexLastTip = blockData.pindex; diff --git a/src/validationinterface.h b/src/validationinterface.h index 3d4352a8b..98b17abdf 100644 --- a/src/validationinterface.h +++ b/src/validationinterface.h @@ -8,6 +8,7 @@ #define HUSH_VALIDATIONINTERFACE_H #include +#include #include "zcash/IncrementalMerkleTree.hpp" class CBlock; @@ -76,4 +77,8 @@ CMainSignals& GetMainSignals(); void ThreadNotifyWallets(CBlockIndex *pindexLastTip); +/** Runs one block's wallet notifications for ThreadNotifyWallets. Returns false, after logging the error and + * requesting a shutdown, if they threw (e.g. a wallet database failure); thread interruption propagates. */ +bool NotifyWalletsOrShutdown(const std::function& notify); + #endif // HUSH_VALIDATIONINTERFACE_H From 70b35ac11ab00a001b37cd7df55b405104070916 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 21:22:16 -0500 Subject: [PATCH 05/23] wallet/db: keep the BDB logs when the shutdown flush fails CDBEnv::Flush(true) removed database/ (log_archive DB_ARCH_REMOVE, then remove_all) once mapFileUseCount was empty. It did that even when the checkpoint or lsn_reset before it had failed, or the environment had panicked (BDB0060). In those states wallet.dat can be missing committed changes that only the logs hold: - a page write failed (EBADF/ESPIPE through an fd closed behind BDB's back, the wolfSSL CloseSocket bug), so the page stayed dirty in the mpool cache and the checkpoint never completed; - pages written after the last lsn_reset carry real LSNs that point into those logs. Deleting the logs turned a wallet that the next start's DB_RECOVER would repair into one that silently lost those changes. A std::terminate kept the logs; the clean shutdown that ThreadNotifyWallets now does on a wallet DB error deleted them. Remember a failed txn_checkpoint or lsn_reset (in Flush and in CheckpointLSN, which the periodic flush and backupwallet use). Before removing anything, also check the environment for a panic. get_flags reports it, and itself fails with DB_RUNRECOVERY once the environment has panicked. In any of those cases keep database/ and log why. If the kept logs are unusable, the next start already moves database/ aside and retries (CWallet::Verify). WalletDbEnv tests: - a clean shutdown still removes the logs; - a panicked environment (DB_PANIC_ENVIRONMENT) keeps them; - a failed lsn_reset keeps them. With the old db.cpp only the first passes. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_wallet_dbenv.cpp | 58 +++++++++++++++++++++++++++++++++ src/wallet/db.cpp | 41 ++++++++++++++++++----- src/wallet/db.h | 4 +++ 4 files changed, 96 insertions(+), 10 deletions(-) create mode 100644 src/gtest/test_wallet_dbenv.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 35042a0b7..0d474b5c4 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -17,7 +17,8 @@ hush_gtest_SOURCES = \ gtest/test_stratum_jobid.cpp \ gtest/test_wallet_notedata.cpp \ gtest/test_net_closesocket.cpp \ - gtest/test_fd_defense.cpp + gtest/test_fd_defense.cpp \ + gtest/test_wallet_dbenv.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_wallet_dbenv.cpp b/src/gtest/test_wallet_dbenv.cpp new file mode 100644 index 000000000..9cb0ce327 --- /dev/null +++ b/src/gtest/test_wallet_dbenv.cpp @@ -0,0 +1,58 @@ +// 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 + +#include + +#include + +#include "wallet/db.h" + +namespace { + +// A fresh environment in a temp dir with one committed record in "wallet.dat", so database/ holds a log file. +class WalletDbEnv : public ::testing::Test { +protected: + boost::filesystem::path dir; + CDBEnv env; + + void SetUp() override { + dir = boost::filesystem::temp_directory_path() / boost::filesystem::unique_path("dbenv-%%%%%%%%"); + boost::filesystem::create_directories(dir); + ASSERT_TRUE(env.Open(dir)); + Db db(env.dbenv, DB_CXX_NO_EXCEPTIONS); + ASSERT_EQ(0, db.open(NULL, "wallet.dat", "main", DB_BTREE, DB_CREATE | DB_AUTO_COMMIT, 0)); + char k[] = "key", v[] = "value"; + Dbt key(k, sizeof k), value(v, sizeof v); + ASSERT_EQ(0, db.put(NULL, &key, &value, 0)); + ASSERT_EQ(0, db.close(0)); + ASSERT_TRUE(boost::filesystem::exists(Log())); + } + void TearDown() override { + env.Close(); + boost::system::error_code ec; + boost::filesystem::remove_all(dir, ec); + } + boost::filesystem::path Log() const { return dir / "database" / "log.0000000001"; } +}; + +} // namespace + +TEST_F(WalletDbEnv, CleanShutdownRemovesTheLogs) { + env.Flush(true); + EXPECT_FALSE(boost::filesystem::exists(dir / "database")); +} + +// BDB0060 PANIC (a page write failed, e.g. through an fd closed behind BDB's back): the logs may hold committed +// changes wallet.dat never got, and the next start's DB_RECOVER needs them. +TEST_F(WalletDbEnv, PanickedEnvironmentKeepsTheLogs) { + ASSERT_EQ(0, env.dbenv->set_flags(DB_PANIC_ENVIRONMENT, 1)); + env.Flush(true); + EXPECT_TRUE(boost::filesystem::exists(Log())); +} + +TEST_F(WalletDbEnv, FailedCheckpointKeepsTheLogs) { + env.CheckpointLSN("no-such-file.dat"); // lsn_reset fails + env.Flush(true); + EXPECT_TRUE(boost::filesystem::exists(Log())); +} diff --git a/src/wallet/db.cpp b/src/wallet/db.cpp index c924628ad..5f2df38a3 100644 --- a/src/wallet/db.cpp +++ b/src/wallet/db.cpp @@ -61,6 +61,7 @@ void CDBEnv::Reset() dbenv = new DbEnv(DB_CXX_NO_EXCEPTIONS); fDbEnvInit = false; fMockDb = false; + fKeepLogs = false; } CDBEnv::CDBEnv() : dbenv(NULL) @@ -276,10 +277,12 @@ bool CDBEnv::Salvage(const std::string& strFile, bool fAggressive, std::vectortxn_checkpoint(0, 0, 0); + if (dbenv->txn_checkpoint(0, 0, 0) != 0) + fKeepLogs = true; if (fMockDb) return; - dbenv->lsn_reset(strFile.c_str(), 0); + if (dbenv->lsn_reset(strFile.c_str(), 0) != 0) + fKeepLogs = true; } @@ -498,10 +501,16 @@ void CDBEnv::Flush(bool fShutdown) // Move log data to the dat file CloseDb(strFile); LogPrint("db", "CDBEnv::Flush: %s checkpoint\n", strFile); - dbenv->txn_checkpoint(0, 0, 0); + int ret = dbenv->txn_checkpoint(0, 0, 0); + if (ret != 0) { + LogPrintf("CDBEnv::Flush: %s checkpoint failed: %s\n", strFile, DbEnv::strerror(ret)); + fKeepLogs = true; + } LogPrint("db", "CDBEnv::Flush: %s detach\n", strFile); - if (!fMockDb) - dbenv->lsn_reset(strFile.c_str(), 0); + if (!fMockDb && (ret = dbenv->lsn_reset(strFile.c_str(), 0)) != 0) { + LogPrintf("CDBEnv::Flush: %s lsn_reset failed: %s\n", strFile, DbEnv::strerror(ret)); + fKeepLogs = true; + } LogPrint("db", "CDBEnv::Flush: %s closed\n", strFile); mapFileUseCount.erase(mi++); } else @@ -511,10 +520,24 @@ void CDBEnv::Flush(bool fShutdown) if (fShutdown) { char** listp; if (mapFileUseCount.empty()) { - dbenv->log_archive(&listp, DB_ARCH_REMOVE); - Close(); - if (!fMockDb) - boost::filesystem::remove_all(boost::filesystem::path(strPath) / "database"); + // After a failed flush, or once the environment has panicked (BDB0060, e.g. after a page + // write failed), wallet.dat can be missing committed changes that only the logs hold. + // Removing them would turn a wallet the next start recovers (DB_RECOVER) into one that + // silently lost them. If the logs are unusable, the next start moves database/ aside. + // (get_flags itself fails with DB_RUNRECOVERY once the environment has panicked.) + u_int32_t envFlags = 0; + if (dbenv->get_flags(&envFlags) != 0 || (envFlags & DB_PANIC_ENVIRONMENT)) + fKeepLogs = true; + if (fKeepLogs && !fMockDb) { + LogPrintf("CDBEnv::Flush: the wallet database was not flushed cleanly; keeping %s for recovery at the next start\n", + (boost::filesystem::path(strPath) / "database").string()); + Close(); + } else { + dbenv->log_archive(&listp, DB_ARCH_REMOVE); + Close(); + if (!fMockDb) + boost::filesystem::remove_all(boost::filesystem::path(strPath) / "database"); + } } } } diff --git a/src/wallet/db.h b/src/wallet/db.h index 1e4de9d78..73ea50e43 100644 --- a/src/wallet/db.h +++ b/src/wallet/db.h @@ -28,6 +28,7 @@ #include "sync.h" #include "version.h" +#include #include #include #include @@ -53,6 +54,9 @@ class CDBEnv private: bool fDbEnvInit; bool fMockDb; + // A checkpoint or lsn_reset failed: the logs in database/ may hold the only copy of committed + // wallet changes, so the shutdown flush must leave them for DB_RECOVER at the next start. + std::atomic fKeepLogs; // Don't change into boost::filesystem::path, as that can result in // shutdown problems/crashes caused by a static initialized internal pointer. std::string strPath; From 7131b3673f1e586d5e707007744ed050bb111a46 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 21:22:16 -0500 Subject: [PATCH 06/23] qa: add fd-guard, the close() guard and regtest harness that found the double close shim/ is an LD_PRELOAD guard around close/fclose/dup2/dup3: - before each close it resolves /proc/self/fd/N; - it reports, with a backtrace, a non-owner close of wallet.dat, database/log.* or blocks/*.dat, and every close that fails with EBADF (the second half of a double close); - FDGUARD_ABORT=1 aborts on the first hit; - run_tests.sh self-tests it (51 checks). repro/ runs a shimmed dragonxd on isolated regtest. Each run gets its own unprivileged network namespace (loopback only). The loads are: - rpc: a hammer with impatient/RST/half-open clients, work-queue bursts and long-polls; - p2p: TLS peers churned by disconnectnode, ban and kill, plus a raw TCP/TLS misbehaver; - tor: a fake control port; - mine: mining with blocknotify and autoshield. tools/tally.py groups the reports by closer. tools/scan_bdb.py finds BDB pages written into block files. With the wolfSSL CloseSocket macro in effect: - p2pmin (300 s) gives one EBADF double close per disconnect; - the full load (1200 s) gives 930 EBADF closes, 186 non-owner closes (37 of wallet/log/blk files) and a 632 KB db.log with BDB0060 PANIC; - RPC-only and Tor-only loads give none. After the fix, every count is 0 and db.log stays empty. The README has the commands and the numbers. Paths come from the environment (GOLDEN, BIN, RUNS, SHIM, ASMAP and the Sapling params). Run output goes to $RUNS, outside the tree. Co-Authored-By: Claude Opus 5.5 --- qa/fd-guard/.gitignore | 4 + qa/fd-guard/README.md | 55 + qa/fd-guard/repro/common.sh | 72 ++ qa/fd-guard/repro/go.sh | 14 + qa/fd-guard/repro/run.sh | 156 +++ qa/fd-guard/repro/tools/faketor.py | 98 ++ qa/fd-guard/repro/tools/hammer.py | 796 ++++++++++++++ qa/fd-guard/repro/tools/rpcchurn.sh | 29 + qa/fd-guard/repro/tools/scan_bdb.py | 113 ++ qa/fd-guard/repro/tools/tally.py | 80 ++ qa/fd-guard/repro/tools/tcpchurn.py | 147 +++ qa/fd-guard/shim/build.sh | 15 + qa/fd-guard/shim/fdguard.c | 1500 +++++++++++++++++++++++++++ qa/fd-guard/shim/fdguard_test.c | 206 ++++ qa/fd-guard/shim/run_tests.sh | 97 ++ qa/fd-guard/shim/symbolize.py | 212 ++++ 16 files changed, 3594 insertions(+) create mode 100644 qa/fd-guard/.gitignore create mode 100644 qa/fd-guard/README.md create mode 100644 qa/fd-guard/repro/common.sh create mode 100755 qa/fd-guard/repro/go.sh create mode 100755 qa/fd-guard/repro/run.sh create mode 100755 qa/fd-guard/repro/tools/faketor.py create mode 100755 qa/fd-guard/repro/tools/hammer.py create mode 100755 qa/fd-guard/repro/tools/rpcchurn.sh create mode 100755 qa/fd-guard/repro/tools/scan_bdb.py create mode 100644 qa/fd-guard/repro/tools/tally.py create mode 100755 qa/fd-guard/repro/tools/tcpchurn.py create mode 100755 qa/fd-guard/shim/build.sh create mode 100644 qa/fd-guard/shim/fdguard.c create mode 100644 qa/fd-guard/shim/fdguard_test.c create mode 100755 qa/fd-guard/shim/run_tests.sh create mode 100755 qa/fd-guard/shim/symbolize.py diff --git a/qa/fd-guard/.gitignore b/qa/fd-guard/.gitignore new file mode 100644 index 000000000..b69e27f49 --- /dev/null +++ b/qa/fd-guard/.gitignore @@ -0,0 +1,4 @@ +shim/libfdguard.so +shim/fdguard_test +*.o +shim/t/ diff --git a/qa/fd-guard/README.md b/qa/fd-guard/README.md new file mode 100644 index 000000000..23cae777d --- /dev/null +++ b/qa/fd-guard/README.md @@ -0,0 +1,55 @@ +# fd-guard: catch stray close() of files a thread doesn't own + +This is the harness that found the wolfSSL `CloseSocket` macro bug. With that bug, P2P disconnects closed their fd number a second time, after another thread had reused it, and BDB then wrote wallet pages into `blocks/*.dat`. + +It has two parts: + +- `shim/`: an `LD_PRELOAD` guard around `close`, `fclose`, `dup2` and `dup3`. + - Before each close it resolves `/proc/self/fd/N`. + - It logs a backtrace when a non-owner closes `wallet.dat`, `database/log.*` or `blocks/*.dat`, and for every close that fails with `EBADF` (the second half of a double close). + - `FDGUARD_ABORT=1` makes the first hit abort. +- `repro/`: isolated regtest runs that put a shimmed `dragonxd` under load: + - **rpc:** impatient and RST clients, half-open connections, work-queue bursts and long-polls; + - **p2p:** TLS peers churned by `disconnectnode`/ban/kill, plus misbehaving raw TCP/TLS clients; + - **tor:** a fake control port; + - **mining:** with `blocknotify` and autoshield. + + Each run executes in its own unprivileged network namespace: loopback only, no route. + +## Requirements + +- Linux. +- `unshare -rn` (unprivileged user namespaces), `nsenter`, `ip`, python3, gcc, and `addr2line` for symbolizing. +- A `dragonxd`/`dragonx-cli` built with `-g`. Adding `-rdynamic` makes backtraces readable without `addr2line`. +- A regtest datadir with a grown wallet to copy from (`GOLDEN`). It is never modified. + +## Running + +```sh +qa/fd-guard/shim/build.sh # builds libfdguard.so +qa/fd-guard/shim/run_tests.sh # self-test: double close, foreign close of a guarded file +cd qa/fd-guard/repro +export GOLDEN=/path/to/regtest/datadir BIN=/path/to/build/src # RUNS defaults to ~/fd-guard-runs +LOADS=p2pmin,mine DISC_INT=5 GUARD=all ./go.sh p2pmin 300 # 1 peer, disconnectnode every 5 s +LOADS=rpc,p2p,tor,mine WQ=64 RT=8 GUARD=all ./go.sh full 1200 # everything, production-like work queue +python3 tools/tally.py "$RUNS//fdguard.log" # who closed what +python3 ../shim/symbolize.py --summary "$RUNS//fdguard.log" +python3 tools/scan_bdb.py "$RUNS/"/target/regtest/blocks/*.dat # BDB pages inside block files +``` + +Each run writes `run.txt` (full log plus summary), `fdguard.log` and the target's `debug.log` and `db.log` under `$RUNS//`. + +## What to expect + +Measured on a 2041-note regtest wallet, before and after the fix (`net: stop wolfSSL's CloseSocket macro ...`), under the same loads: + +| Run | Binary | EBADF closes | Non-owner closes (wallet/log/blk) | libevent `Epoll` failures | `db.log` | +|---|---|---|---|---|---| +| `p2pmin`, 300 s | before | 51 (≈ 1 per disconnect) | 1 | 0 | empty | +| | after | 0 | 0 | 0 | empty | +| `full`, 1200 s, `WQ=64 RT=8` | before | 930 | 186 (37) | 111 | 632 KB: 7500 × `BDB0060 PANIC`, `write failed for page N` | +| | after | 0 | 0 | 0 | empty | + +- RPC-only and Tor-only loads give 0 reports, even before the fix: P2P disconnects are the only origin. +- `scan_bdb.py` found no BDB pages in block files in any run. The landing of a page in a block file needs the reused number to be a writable block file at the moment BDB writes. Production did hit that (blk00011/blk00020), but it is rare. +- `SSL_write ... closing connection` lines are expected under `tcpchurn`. They are how the load triggers disconnects, not a symptom. diff --git a/qa/fd-guard/repro/common.sh b/qa/fd-guard/repro/common.sh new file mode 100644 index 000000000..89458c570 --- /dev/null +++ b/qa/fd-guard/repro/common.sh @@ -0,0 +1,72 @@ +# shellcheck shell=bash +# Shared config + helpers for the fd-double-close REPRODUCTION harness. +# Regtest only. Meant to run INSIDE an unprivileged network namespace (see ns.sh), +# so every port is private to the namespace and nothing can reach a real network. +set +e + +REPRO=${REPRO:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)} +SRCROOT=$(cd "$REPRO/../../.." && pwd) +BIN=${BIN:-$SRCROOT/src} # dir with dragonxd + dragonx-cli (build with -g -rdynamic for backtraces) +DRAGONXD=$BIN/dragonxd +CLI=$BIN/dragonx-cli +RUNS=${RUNS:-$HOME/fd-guard-runs} # run output (block files, logs): keep it out of the repo +GOLDEN=${GOLDEN:?set GOLDEN to a regtest datadir with a grown wallet (it is only ever copied)} +ASMAP=${ASMAP:-$SRCROOT/asmap.dat} +SP=${SP:-$HOME/.zcash-params/sapling-spend.params} +OP=${OP:-$HOME/.zcash-params/sapling-output.params} +SHIM=${SHIM:-$REPRO/../shim/libfdguard.so} + +RPCUSER=ab +RPCPASS=abpass +RPC_T=18901 ; P2P_T=18911 +RPC_H1=18902; P2P_H1=18912 +RPC_H2=18903; P2P_H2=18913 +TORPORT=18950 + +clin() { local dd=$1 rp=$2; shift 2; "$CLI" -regtest -datadir="$dd" -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$rp "$@"; } + +write_conf() { printf 'rpcuser=%s\nrpcpassword=%s\nrpcport=%s\n' "$RPCUSER" "$RPCPASS" "$2" > "$1/DRAGONX.conf"; } + +# prep_golden
: copy of the golden wallet datadir (2041 notes, txindex=1, height 286) +prep_golden() { + local dd=$1 + rm -rf "$dd"; cp -a "$GOLDEN" "$dd" + cp -f "$ASMAP" "$dd/asmap.dat"; cp -f "$SP" "$dd/sapling-spend.params"; cp -f "$OP" "$dd/sapling-output.params" + write_conf "$dd" "$RPC_T" + : > "$dd/regtest/debug.log"; rm -f "$dd/regtest/db.log" +} + +# prep_fresh
: empty regtest datadir (helpers) +prep_fresh() { + local dd=$1 rp=$2 + rm -rf "$dd"; mkdir -p "$dd" + cp -f "$ASMAP" "$dd/asmap.dat"; cp -f "$SP" "$dd/sapling-spend.params"; cp -f "$OP" "$dd/sapling-output.params" + write_conf "$dd" "$rp" +} + +start_helper() { #
+ local dd=$1 p2p=$2 rp=$3 lg=$4 + ( cd "$dd" && exec nice -n 10 "$DRAGONXD" -regtest -datadir="$dd" -testnode=1 -checkpoints=0 \ + -listen=1 -bind=127.0.0.1 -port=$p2p -connect=127.0.0.1:1 -addnode=127.0.0.1:$P2P_T \ + -dnsseed=0 -dns=0 -asmap="$dd/asmap.dat" -autoshield=0 \ + -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$rp -listenonion=0 -maxinboundfromip=100 \ + -debug=net -debug=tls -daemon ) >> "$lg" 2>&1 +} + +waitrpc() { #
[secs] + local dd=$1 rp=$2 n=${3:-300} i + for i in $(seq 1 "$n"); do clin "$dd" "$rp" getblockcount >/dev/null 2>&1 && return 0; sleep 1; done + return 1 +} + +stop_daemon() { #
(helpers, started with -daemon) + local dd=$1 rp=$2 pid i + pid=$(cat "$dd/regtest/hushd.pid" 2>/dev/null) + clin "$dd" "$rp" stop >/dev/null 2>&1 + for i in $(seq 1 90); do { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; } && return 0; sleep 1; done + [ -n "$pid" ] && kill -9 "$pid" 2>/dev/null +} + +MARKERS=('libevent: Epoll' 'Socket close failed' 'Bad file descriptor' 'Operation not permitted' 'Illegal seek' + 'SSL_write' 'Unable to seek' 'socket select error' 'write failed for page' 'PANIC' 'error scanning DB' + 'terminate' 'Assertion' 'EXCEPTION') diff --git a/qa/fd-guard/repro/go.sh b/qa/fd-guard/repro/go.sh new file mode 100755 index 000000000..b9cb86546 --- /dev/null +++ b/qa/fd-guard/repro/go.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# go.sh -- create a private network namespace (loopback only, no route), +# run run.sh inside it, then tear the namespace down. Env knobs pass through to run.sh. +export REPRO=${REPRO:-$(cd "$(dirname "$0")" && pwd)} +export RUNS=${RUNS:-$HOME/fd-guard-runs} +OUT=$RUNS/$1; mkdir -p "$OUT" +setsid unshare -rn sh -c "ip link set lo up && echo \$\$ > $OUT/ns.pid && exec sleep infinity" /dev/null 2>&1 & +for _ in 1 2 3 4 5 6 7 8 9 10; do [ -s "$OUT/ns.pid" ] && break; sleep 0.3; done +NSP=$(cat "$OUT/ns.pid") +cp "$REPRO/run.sh" "$OUT/run.sh.snapshot" # run a frozen copy: editing run.sh cannot disturb a live run +nsenter -t "$NSP" -U -n --preserve-credentials bash "$OUT/run.sh.snapshot" "$@" > "$OUT/go.txt" 2>&1 +rc=$? +kill "$NSP" 2>/dev/null +echo "go.sh: run.sh rc=$rc, namespace holder $NSP killed" >> "$OUT/go.txt" diff --git a/qa/fd-guard/repro/run.sh b/qa/fd-guard/repro/run.sh new file mode 100755 index 000000000..8d58b1c43 --- /dev/null +++ b/qa/fd-guard/repro/run.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# run.sh -- run INSIDE the network namespace (go.sh does that). +# Env knobs: +# LOADS=rpc,p2p,tor,mine which load generators run (default all four); p2pmin = 1 helper + disconnectnode every DISC_INT s +# WQ=4 RT=2 -rpcworkqueue / -rpcthreads of the target +# GUARD=all FDGUARD_GUARD (db,blk | all) +# SHIMON=1 run the target under LD_PRELOAD=libfdguard.so +# STRACE=0 1 = also wrap the target in strace (failed fd syscalls only) +# TRACE=0 1 = FDGUARD_TRACE_ALL (one line per open/close; big) +# MINE_INT=3 seconds between generate 1 +# HAMMER_ARGS=... override the hammer thread mix +# STOP_ON_HIT=0 1 = end the run 60 s after the first NON-OWNER db/blk close +# EXTRA=... extra target args +source "$REPRO/common.sh" +NAME=$1; DUR=${2:-600} +OUT=$RUNS/$NAME +LOADS=${LOADS:-rpc,p2p,tor,mine}; WQ=${WQ:-4}; RT=${RT:-2}; GUARD=${GUARD:-all} +SHIMON=${SHIMON:-1}; STRACE=${STRACE:-0}; TRACE=${TRACE:-0}; MINE_INT=${MINE_INT:-3} +STOP_ON_HIT=${STOP_ON_HIT:-0} +HAMMER_ARGS=${HAMMER_ARGS:---normal 8 --impatient 8 --slowfire 2 --halfopen 3 --pipeline 3 --garbage 2 --slowread 2 --longpoll 4 --burst-size 16 --burst-every 2} +has() { case ",$LOADS," in *",$1,"*) return 0;; esac; return 1; } + +mkdir -p "$OUT/logs"; rm -f "$OUT/STOP" +DD_T=$OUT/target; DD_H1=$OUT/helper1; DD_H2=$OUT/helper2 +exec > >(tee -a "$OUT/run.txt") 2>&1 +echo "=== repro run $NAME $(date -u +%FT%TZ) dur=${DUR}s loads=$LOADS wq=$WQ rt=$RT guard=$GUARD shim=$SHIMON strace=$STRACE" +echo "netns check (must be ENETUNREACH=101): $(python3 -c 'import socket;print(socket.socket().connect_ex(("1.1.1.1",80)))')" +echo "binary: $DRAGONXD build-id: $(readelf -n "$DRAGONXD" | awk '/Build ID/{print $3}')" +echo "dragonxd already running in this namespace? $(pgrep -a -x dragonxd | wc -l) (other agents' nodes live in other namespaces)" + +PIDS=() +cleanup() { + echo "--- cleanup $(date -u +%T)" + touch "$OUT/STOP" + for p in "${PIDS[@]}"; do kill "$p" 2>/dev/null; done + stop_daemon "$DD_H2" "$RPC_H2"; stop_daemon "$DD_H1" "$RPC_H1" + if [ -n "$TPID" ] && kill -0 "$TPID" 2>/dev/null; then + timeout 20 "$CLI" -regtest -datadir="$DD_T" -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$RPC_T stop >/dev/null 2>&1 + for _ in $(seq 1 60); do kill -0 "$TPID" 2>/dev/null || break; sleep 1; done + # abandoned longpoll getblocktemplate calls can hold every -rpcthreads worker, so RPC stop may never run: + # fall back to SIGTERM (same clean StartShutdown path), SIGKILL only as a last resort + kill -0 "$TPID" 2>/dev/null && { echo "RPC stop did not finish in 60 s -> SIGTERM"; kill -TERM "$TPID"; } + for _ in $(seq 1 180); do kill -0 "$TPID" 2>/dev/null || break; sleep 1; done + kill -0 "$TPID" 2>/dev/null && { echo "target did not stop -> SIGKILL"; kill -9 "$TPID"; } + fi + [ -n "$WPID" ] && wait "$WPID" 2>/dev/null; echo "target exit status: $?" + for p in "${PIDS[@]}"; do kill -9 "$p" 2>/dev/null; done + summarize +} +summarize() { + local DL=$DD_T/regtest/debug.log + echo "=== markers in target debug.log" + for m in "${MARKERS[@]}"; do printf ' [%6d] %s\n' "$(grep -c -- "$m" "$DL" 2>/dev/null)" "$m"; done + echo " db.log bytes: $(stat -c %s "$DD_T/regtest/db.log" 2>/dev/null || echo absent)" + grep -q 'Shutdown: done' "$DL" && echo " clean shutdown: yes" || echo " clean shutdown: NO" + if [ "$SHIMON" = 1 ]; then + echo "=== fdguard reports"; grep -o '=== FDGUARD #[0-9]* [A-Z-]*' "$OUT/fdguard.log" | awk '{print $4}' | sort | uniq -c + grep '^S ' "$OUT/fdguard.log" | tail -1 + python3 "$REPRO/../shim/symbolize.py" --summary "$OUT/fdguard.log" > "$OUT/fdguard.summary.txt" 2>&1 + head -60 "$OUT/fdguard.summary.txt" + fi + echo "=== BDB-page-in-blockfile detector" + python3 "$REPRO/tools/scan_bdb.py" "$DD_T"/regtest/blocks/blk*.dat "$DD_T"/regtest/blocks/rev*.dat 2>&1 | tail -15 + echo "=== end $(date -u +%FT%TZ)" +} +trap cleanup EXIT +trap 'exit 130' INT TERM + +echo "--- prep datadirs" +prep_golden "$DD_T" +prep_fresh "$DD_H1" "$RPC_H1"; prep_fresh "$DD_H2" "$RPC_H2" +cat > "$OUT/blocknotify.sh" </dev/null 2>&1 +$CLI -regtest -datadir=$DD_T -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$RPC_T getblocktemplate >/dev/null 2>&1 +echo "\$(date +%s) \$1" >> $OUT/logs/blocknotify.log +EOF +chmod +x "$OUT/blocknotify.sh" + +if has tor; then + nice -n 10 python3 "$REPRO/tools/faketor.py" --port $TORPORT > "$OUT/logs/faketor.txt" 2>&1 & PIDS+=($!) + TORARGS="-listenonion=1 -torcontrol=127.0.0.1:$TORPORT -debug=tor" +else + TORARGS="-listenonion=0" +fi +if has p2p || has p2pmin; then P2PARGS="-listen=1 -bind=127.0.0.1 -port=$P2P_T -allowlist=127.0.0.0/8 -maxinboundfromip=100" +else P2PARGS="-listen=0 -port=$P2P_T"; fi + +TARGS=(-regtest -datadir="$DD_T" -testnode=1 -checkpoints=0 $P2PARGS -connect=127.0.0.1:1 -dnsseed=0 -dns=0 + -asmap="$DD_T/asmap.dat" -rpcuser=$RPCUSER -rpcpassword=$RPCPASS -rpcport=$RPC_T + -txindex=1 -autoshield=1 -rpcworkqueue=$WQ -rpcthreads=$RT + "-blocknotify=$OUT/blocknotify.sh %s" $TORARGS -debug=net -debug=tls -printtoconsole=0 -daemon=0 $EXTRA) +echo "target args: ${TARGS[*]}" | sed "s/$RPCPASS/***/g" +ENVV=() +if [ "$SHIMON" = 1 ]; then + ENVV=(FDGUARD_LOG="$OUT/fdguard.log" FDGUARD_GUARD="$GUARD" FDGUARD_ABORT=0 FDGUARD_STATS_SEC=60 FDGUARD_MAXREPORTS=2000 LD_PRELOAD="$SHIM") + [ "$TRACE" = 1 ] && ENVV+=(FDGUARD_TRACE_ALL=1) +fi +WRAP=() +[ "$STRACE" = 1 ] && WRAP=(strace -f -tt -qq -e trace=close,openat,accept4,socket,dup2,dup3 -e status=failed -o "$OUT/strace.txt") +( cd "$DD_T" && exec env "${ENVV[@]}" nice -n 10 "${WRAP[@]}" "$DRAGONXD" "${TARGS[@]}" ) > "$OUT/logs/target_stdout.txt" 2>&1 & +WPID=$! +echo "--- target launched (wrapper pid $WPID); waiting for RPC" +waitrpc "$DD_T" "$RPC_T" 600 || { echo "target never answered RPC"; exit 1; } +TPID=$(cat "$DD_T/regtest/hushd.pid" 2>/dev/null); [ -z "$TPID" ] && TPID=$WPID +echo "target up pid=$TPID height=$(clin "$DD_T" "$RPC_T" getblockcount) $(date -u +%T)" + +T0=$(date +%s) +if has p2p; then + start_helper "$DD_H1" "$P2P_H1" "$RPC_H1" "$OUT/logs/helper1.txt" + start_helper "$DD_H2" "$P2P_H2" "$RPC_H2" "$OUT/logs/helper2.txt" + waitrpc "$DD_H1" "$RPC_H1" 120; waitrpc "$DD_H2" "$RPC_H2" 120 + nice -n 10 python3 "$REPRO/tools/tcpchurn.py" --port $P2P_T --duration "$DUR" --concurrency 8 > "$OUT/logs/tcpchurn.txt" 2>&1 & PIDS+=($!) + nice -n 10 bash "$REPRO/tools/rpcchurn.sh" "$OUT" "$DUR" > "$OUT/logs/rpcchurn.txt" 2>&1 & PIDS+=($!) +fi +if has p2pmin; then + # minimal P2P: ONE well-behaved helper peer, and an ordinary "disconnectnode" every DISC_INT s + # (the helper's -addnode re-dials the target). No raw TCP/TLS churn, no bans, no kills. + start_helper "$DD_H1" "$P2P_H1" "$RPC_H1" "$OUT/logs/helper1.txt"; waitrpc "$DD_H1" "$RPC_H1" 120 + ( k=0; while [ ! -e "$OUT/STOP" ]; do sleep "${DISC_INT:-5}"; for a in $(clin "$DD_T" "$RPC_T" getpeerinfo 2>/dev/null | python3 -c 'import json,sys; [print(p["addr"]) for p in json.load(sys.stdin)]' 2>/dev/null); do + clin "$DD_T" "$RPC_T" disconnectnode "$a" >/dev/null 2>&1 && { k=$((k+1)); echo "$(date -u +%T.%N) disconnectnode $a" >> "$OUT/logs/disconnects.log"; }; done + sleep 1; clin "$DD_H1" "$RPC_H1" addnode "127.0.0.1:$P2P_T" onetry >/dev/null 2>&1 # helper re-dials now (its -addnode retry is ~2 min) + echo "$k" > "$OUT/logs/disconnects.txt"; done ) & PIDS+=($!) +fi +if has rpc; then + MINEARG=""; has mine && MINEARG="--mine --mine-interval $MINE_INT" + nice -n 10 python3 "$REPRO/tools/hammer.py" --port $RPC_T --user $RPCUSER --password $RPCPASS --duration "$DUR" \ + $HAMMER_ARGS $MINEARG --pid "$TPID" --logfile "$OUT/logs/hammer.log" > "$OUT/logs/hammer_stdout.txt" 2>&1 & PIDS+=($!) +elif has mine; then + ( while [ ! -e "$OUT/STOP" ]; do clin "$DD_T" "$RPC_T" generate 1 >/dev/null 2>&1; sleep "$MINE_INT"; done ) & PIDS+=($!) +fi + +# ---- poll +end=$(( T0 + DUR )); n=0; hit_at=0 +while [ "$(date +%s)" -lt "$end" ] && [ ! -e "$OUT/STOP" ]; do + sleep 30; n=$((n+1)) + alive=no; kill -0 "$TPID" 2>/dev/null && alive=yes + DL=$DD_T/regtest/debug.log + h=$(clin "$DD_T" "$RPC_T" getblockcount 2>/dev/null); c=$(clin "$DD_T" "$RPC_T" getconnectioncount 2>/dev/null) + rep=$(grep -c '^=== FDGUARD' "$OUT/fdguard.log" 2>/dev/null); noc=$(grep -c 'NON-OWNER-CLOSE' "$OUT/fdguard.log" 2>/dev/null) + ebf=$(grep -c 'EBADF-CLOSE' "$OUT/fdguard.log" 2>/dev/null); epl=$(grep -c '=== FDGUARD #[0-9]* EPOLL' "$OUT/fdguard.log" 2>/dev/null) + dbnoc=$(grep -A3 'NON-OWNER-CLOSE' "$OUT/fdguard.log" 2>/dev/null | grep -c 'class=\(db\|blk\)') + printf '[poll %3d %s +%4ds] alive=%s h=%s conns=%s | fdguard reports=%s nonowner=%s(db/blk %s) ebadf=%s epoll=%s | Epoll=%s SockCloseFail=%s EBADF=%s EPERM=%s SSL_write=%s seek=%s sel=%s | db.log=%s\n' \ + $n "$(date -u +%T)" $(( $(date +%s) - T0 )) $alive "$h" "$c" "$rep" "$noc" "$dbnoc" "$ebf" "$epl" \ + "$(grep -c 'libevent: Epoll' "$DL")" "$(grep -c 'Socket close failed' "$DL")" "$(grep -c 'Bad file descriptor' "$DL")" \ + "$(grep -c 'Operation not permitted' "$DL")" "$(grep -c 'SSL_write' "$DL")" "$(grep -c 'Unable to seek' "$DL")" \ + "$(grep -c 'socket select error' "$DL")" "$(stat -c %s "$DD_T/regtest/db.log" 2>/dev/null)" | tee -a "$OUT/poll.txt" + [ "$alive" = no ] && { echo "!!! target died"; break; } + if [ "$STOP_ON_HIT" = 1 ] && [ "${dbnoc:-0}" -gt 0 ]; then + [ "$hit_at" = 0 ] && hit_at=$(date +%s) + [ $(( $(date +%s) - hit_at )) -ge 60 ] && { echo "--- stop-on-hit"; break; } + fi +done +cat "$OUT/logs/tcpchurn.txt" "$OUT/logs/rpcchurn.txt" 2>/dev/null +grep -E 'TOTAL|totals' -A12 "$OUT/logs/hammer.log" 2>/dev/null | tail -14 diff --git a/qa/fd-guard/repro/tools/faketor.py b/qa/fd-guard/repro/tools/faketor.py new file mode 100755 index 000000000..47cd0bb85 --- /dev/null +++ b/qa/fd-guard/repro/tools/faketor.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +""" +Fake Tor control port for the fd-bug harness. + +dragonxd's torcontrol.cpp opens a libevent bufferevent to -torcontrol=, +speaks the control protocol (PROTOCOLINFO / AUTHCHALLENGE / AUTHENTICATE / ADD_ONION), +and on EOF/ERROR reconnects with exponential backoff. We give it a server on a +loopback port that behaves badly in several ways so the reconnect/teardown loop +(bufferevent_free with BEV_OPT_CLOSE_ON_FREE, the event_base, the reconnect timer) +runs constantly. Each accepted connection picks a behaviour at random: + + close : accept, then immediately close (EOF right away) + rst : accept, set SO_LINGER 0, close -> sends RST + stall : accept, read nothing, hold the socket open then drop it + garbage : send non-control-protocol bytes then close + halfok : answer PROTOCOLINFO plausibly, then drop before AUTHENTICATE completes + +Loopback only. Run with --port. Ctrl-C / SIGTERM stops it. +""" +import argparse, random, socket, struct, threading, time, sys, signal + +STOP = threading.Event() + +def handle(conn, mode): + try: + conn.settimeout(5.0) + if mode == "close": + conn.close(); return + if mode == "rst": + conn.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, + struct.pack("ii", 1, 0)) + conn.close(); return + if mode == "stall": + # read whatever the controller sends but never reply; hold 2-4s + try: + conn.recv(4096) + except Exception: + pass + time.sleep(random.uniform(2.0, 4.0)) + conn.close(); return + if mode == "garbage": + try: + conn.recv(4096) + except Exception: + pass + conn.sendall(b"\x00\xff\x01garbage not a tor reply\r\n" * 4) + conn.close(); return + if mode == "halfok": + # read the PROTOCOLINFO line, give a plausible 250 reply advertising + # NULL auth, then drop the connection before AUTHENTICATE finishes. + try: + conn.recv(4096) + except Exception: + pass + conn.sendall(b"250-PROTOCOLINFO 1\r\n" + b"250-AUTH METHODS=NULL\r\n" + b'250-VERSION Tor="0.4.7.13"\r\n' + b"250 OK\r\n") + time.sleep(random.uniform(0.2, 1.0)) + conn.close(); return + except Exception: + try: conn.close() + except Exception: pass + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + + modes = ["close", "rst", "stall", "garbage", "halfok"] + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind((args.host, args.port)) + srv.listen(16) + srv.settimeout(1.0) + print(f"faketor: listening on {args.host}:{args.port}", flush=True) + + def onsig(*_): STOP.set() + signal.signal(signal.SIGTERM, onsig) + signal.signal(signal.SIGINT, onsig) + + n = 0 + while not STOP.is_set(): + try: + conn, _ = srv.accept() + except socket.timeout: + continue + except Exception: + break + mode = modes[n % len(modes)] + n += 1 + threading.Thread(target=handle, args=(conn, mode), daemon=True).start() + srv.close() + print(f"faketor: stopped after {n} connections", flush=True) + +if __name__ == "__main__": + main() diff --git a/qa/fd-guard/repro/tools/hammer.py b/qa/fd-guard/repro/tools/hammer.py new file mode 100755 index 000000000..960657d5c --- /dev/null +++ b/qa/fd-guard/repro/tools/hammer.py @@ -0,0 +1,796 @@ +#!/usr/bin/env python3 +# hammer.py -- JSON-RPC robustness/load harness for a LOCAL regtest dragonxd. +# +# Scope: connects only to the --host/--port you pass (meant to be 127.0.0.1 and a +# regtest RPC port). Its job is to reproduce a known file-descriptor bug in the +# libevent-2.1.8 HTTP RPC server (src/httpserver.cpp) by exercising the same +# request patterns the production pool box sees: many concurrent clients, clients +# that disconnect mid-request/mid-response (the explorer indexer's 120 s client +# timeout), work-queue overflow, keep-alive pipelining, and malformed input. +# stdlib only. +# +# Correctness oracle: every request carries a unique JSON-RPC id; if a reply comes +# back with a different id on the same socket, that is cross-talk (the server wrote +# one client's reply onto another client's fd) and is logged + counted. +# +# See README.txt for the exact invocation. + +import argparse +import base64 +import collections +import json +import os +import random +import socket +import struct +import threading +import time + +# ------------------------------------------------------------------ config/state + +class C: + host = "127.0.0.1" + port = 19461 + auth = "" + ctimeout = 15.0 + +CFG = C() +STOP = threading.Event() +SHARED = {"hashes": [], "lpid": None, "zaddrs": [], "rawblock": None} +SHARED_LOCK = threading.Lock() +_ID = [random.SystemRandom().randrange(1 << 40)] +_ID_LOCK = threading.Lock() +LOGLOCK = threading.Lock() +LOGF = [None] + +def next_id(): + with _ID_LOCK: + _ID[0] += 1 + return _ID[0] + +def log(msg): + line = "%s %s" % (time.strftime("%H:%M:%S"), msg) + with LOGLOCK: + print(line, flush=True) + if LOGF[0]: + LOGF[0].write(line + "\n"); LOGF[0].flush() + +# ------------------------------------------------------------------ stats + +KEYS = ["sent", "ok", "rpcerr", "wq500", "http401", "http4xx", "httpother", + "early_rst", "early_fin", "srvreset", "srveof", "ctimeout", "connfail", + "idmismatch", "malformed", "mined", "half_open", "half_srvclose"] + +class Stats: + def __init__(self): + self.lk = threading.Lock() + self.total = collections.Counter() + self.win = collections.Counter() + self.anom = [] + def inc(self, k, n=1): + with self.lk: + self.total[k] += n; self.win[k] += n + def anomaly(self, kind, detail): + with self.lk: + if len(self.anom) < 300: + self.anom.append((time.time(), kind, detail)) + log("ANOMALY %s %s" % (kind, detail)) + def swap(self): + with self.lk: + w = self.win; self.win = collections.Counter(); return w + +ST = Stats() + +# ------------------------------------------------------------------ socket helpers + +def auth_header(u, p): + return "Basic " + base64.b64encode(("%s:%s" % (u, p)).encode()).decode() + +def mkconn(timeout=None, rcvbuf=None): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + if rcvbuf: + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf) + s.settimeout(timeout if timeout is not None else CFG.ctimeout) + s.connect((CFG.host, CFG.port)) + return s + +def end_rst(s): + # linger 0 -> TCP RST on close (models a client that dies / resets abruptly) + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + except OSError: + pass + try: + s.close() + except OSError: + pass + +def end_fin(s): + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + s.close() + except OSError: + pass + +def end_early(s, how=None): + how = how or random.choice(("rst", "fin")) + if how == "rst": + end_rst(s); ST.inc("early_rst") + else: + end_fin(s); ST.inc("early_fin") + +def req_bytes(method, params, rid, keepalive=True, auth=None, uri="/", + httpmethod="POST", body=None, clen=None): + if body is None: + body = json.dumps({"jsonrpc": "1.0", "id": rid, + "method": method, "params": params}).encode() + head = [ + "%s %s HTTP/1.1" % (httpmethod, uri), + "Host: %s:%d" % (CFG.host, CFG.port), + "Authorization: %s" % (auth if auth is not None else CFG.auth), + "Content-Type: application/json", + "Content-Length: %d" % (clen if clen is not None else len(body)), + "Connection: %s" % ("keep-alive" if keepalive else "close"), + "", "", + ] + return "\r\n".join(head).encode() + body + +class Reader: + """Incremental HTTP/1.1 reply reader over one socket.""" + def __init__(self, s): + self.s = s; self.buf = b"" + def _fill(self): + d = self.s.recv(65536) + if not d: + raise EOFError() + self.buf += d + def read(self): + while b"\r\n\r\n" not in self.buf: + self._fill() + if len(self.buf) > (1 << 20): + raise ValueError("oversize header") + head, self.buf = self.buf.split(b"\r\n\r\n", 1) + lines = head.split(b"\r\n") + p = lines[0].split(b" ", 2) + if len(p) < 2 or not p[0].startswith(b"HTTP/1."): + raise ValueError("bad status line %r" % lines[0][:80]) + status = int(p[1]) + hdr = {} + for ln in lines[1:]: + if b":" in ln: + k, v = ln.split(b":", 1) + hdr[k.strip().lower()] = v.strip() + if hdr.get(b"transfer-encoding", b"").lower() == b"chunked": + body = b"" + while True: + while b"\r\n" not in self.buf: + self._fill() + ln, self.buf = self.buf.split(b"\r\n", 1) + n = int(ln.split(b";")[0], 16) + while len(self.buf) < n + 2: + self._fill() + body += self.buf[:n]; self.buf = self.buf[n + 2:] + if n == 0: + break + elif b"content-length" in hdr: + n = int(hdr[b"content-length"]) + while len(self.buf) < n: + self._fill() + body, self.buf = self.buf[:n], self.buf[n:] + else: + try: + while True: + self._fill() + except EOFError: + pass + body, self.buf = self.buf, b"" + return status, hdr, body + +def account(status, body, rid, method, expect401=False): + if status == 401: + ST.inc("http401") + if not expect401: + ST.anomaly("unexpected401", (method,)) + return None + if status == 500 and b"Work queue depth exceeded" in body: + ST.inc("wq500"); return None + j = None + if body[:1] in (b"{", b"["): + try: + j = json.loads(body) + except ValueError: + j = None + if j is None: + if 400 <= status < 500: + ST.inc("http4xx") + else: + ST.inc("httpother") + if status == 200: + ST.anomaly("non-json-200", (method, body[:120].decode("latin1"))) + return None + if isinstance(j, dict) and rid is not None and j.get("id") != rid: + ST.inc("idmismatch") + ST.anomaly("idmismatch", {"method": method, "sent": rid, "got": j.get("id"), + "body": body[:200].decode("latin1")}) + if isinstance(j, dict) and j.get("error"): + ST.inc("rpcerr") + else: + ST.inc("ok") + return j + +def call(s, rd, method, params, keepalive=True, expect401=False, auth=None): + rid = next_id() + try: + s.sendall(req_bytes(method, params, rid, keepalive=keepalive, auth=auth)) + ST.inc("sent") + status, hdr, body = rd.read() + except socket.timeout: + ST.inc("ctimeout"); return None, False + except (ConnectionResetError, BrokenPipeError): + ST.inc("srvreset"); return None, False + except EOFError: + ST.inc("srveof"); return None, False + except ValueError as e: + ST.inc("malformed"); ST.anomaly("malformed", (method, str(e))); return None, False + except OSError: + ST.inc("srvreset"); return None, False + j = account(status, body, rid, method, expect401) + alive = keepalive and hdr.get(b"connection", b"").lower() != b"close" + return j, alive + +# ------------------------------------------------------------------ menus + +def a_hash(): + with SHARED_LOCK: + return random.choice(SHARED["hashes"]) if SHARED["hashes"] else None + +def a_zaddr(): + with SHARED_LOCK: + return random.choice(SHARED["zaddrs"]) if SHARED["zaddrs"] else None + +def normal_menu(): + # Mostly fast calls (sub-ms) so a well-behaved client interleaves with mining. + # Heavy wallet calls (z_gettotalbalance ~2.3s on the golden wallet) appear but + # are rare, so they don't permanently peg the small worker pool on their own. + fast = [("getinfo", []), ("getblockcount", []), ("getbestblockhash", []), + ("getrawmempool", []), ("getmininginfo", []), ("getdifficulty", []), + ("getblockchaininfo", []), ("getpeerinfo", []), + ("getblocktemplate", []), ("getnetworkinfo", [])] + h = a_hash() + if h: + fast.append(("getblock", [h, 2])) + fast.append(("getblockheader", [h])) + # "slow" here is only mildly-larger fast calls; the genuinely heavy wallet calls + # live in the paced slow-fire class so a normal dashboard poller never pegs the + # worker pool. + slow = [("coinsupply", []), ("listtransactions", ["*", 10]), + ("listunspent", [0]), ("getblock", [a_hash() or "", 2])] + return random.choice(slow) if random.random() < 0.1 else random.choice(fast) + +def medium_menu(): + # Fast to compute (~4 ms on this node) but a non-trivial reply to write, so they + # are good targets for a mid-response disconnect. The fd-reuse race fires on the + # disconnect, not on call duration, so firing these at a HIGH rate drives the + # race hard while the worker frees in milliseconds (mining keeps its slots). + m = [("listtransactions", ["*", 10000]), ("getrawmempool", []), + ("getrawmempool", [True]), ("getblockchaininfo", []), + ("getblocktemplate", []), ("getchaintips", []), ("getinfo", []), + ("coinsupply", []), ("listunspent", [0])] + h = a_hash() + if h: + m.append(("getblock", [h, 2])) + m.append(("getblock", [h, 1])) + return random.choice(m) + +def heavy_menu(): + # Genuinely slow calls (z_gettotalbalance ~1.7 s, z_listunspent ~5.7 s on the + # golden wallet). Fired only by the small, PACED slow-fire class so a worker + # keeps running a multi-second call long after the client disconnected (the + # production indexer/coinsupply scenario that widens the fd-reuse window), + # without the fire-and-disconnect backlog pinning the whole pool. + m = [("z_gettotalbalance", [0]), ("z_listunspent", [0])] + z = a_zaddr() + if z: + m.append(("z_listreceivedbyaddress", [z, 0])) + return random.choice(m) + +# ------------------------------------------------------------------ clients + +def client_normal(cid): + while not STOP.is_set(): + keepalive = random.random() < 0.7 + try: + s = mkconn() + except OSError: + ST.inc("connfail"); time.sleep(0.05); continue + rd = Reader(s) + n = random.randint(1, 8) if keepalive else 1 + try: + for _ in range(n): + if STOP.is_set(): + break + method, params = normal_menu() + j, alive = call(s, rd, method, params, keepalive=keepalive) + if not alive: + break + finally: + (end_fin if random.random() < 0.8 else end_rst)(s) + time.sleep(random.uniform(0.01, 0.15)) + +def client_impatient(cid): + # High-rate fast-disconnect churn: send a (medium) request, then disconnect + # after 1-500 ms at a random phase. This is the main driver of the fd-reuse + # race -- a disconnect concurrent with the server writing the reply / re-arming + # the socket -- and the worker frees in ms so mining is unaffected. + while not STOP.is_set(): + method, params = medium_menu() + rid = next_id() + body = json.dumps({"jsonrpc": "1.0", "id": rid, "method": method, + "params": params}).encode() + phase = random.choice(("mid_request", "after_send", "mid_response")) + try: + s = mkconn(timeout=2.0) + except OSError: + ST.inc("connfail"); continue + try: + if phase == "mid_request": + full = req_bytes(method, params, rid, keepalive=False, body=body) + cut = random.randint(len(full) - len(body), len(full) - 1) + s.sendall(full[:cut]) + time.sleep(random.uniform(0.001, 0.5)) + end_early(s); continue + s.sendall(req_bytes(method, params, rid, keepalive=False, body=body)) + ST.inc("sent") + time.sleep(random.uniform(0.001, 0.5)) + if phase == "mid_response": + try: + s.recv(random.randint(0, 64)) # read a few bytes, then bail + except OSError: + pass + end_early(s) + except (OSError, socket.timeout): + ST.inc("srvreset") + end_rst(s) + time.sleep(random.uniform(0.05, 0.4)) + +def client_slowfire(cid, pace): + # Paced indexer/coinsupply client: fire ONE genuinely-slow request, disconnect + # after 1-500 ms (so the worker runs the multi-second call with no client left + # == the production fd-reuse window), then wait `pace` s before the next so a + # fire-and-disconnect backlog cannot pin the whole worker pool. Phase: cut the + # request mid-body, or send it whole and abort while the worker is busy, or read + # a few bytes of the reply then abort. + while not STOP.is_set(): + method, params = heavy_menu() + rid = next_id() + body = json.dumps({"jsonrpc": "1.0", "id": rid, "method": method, + "params": params}).encode() + phase = random.choice(("mid_request", "after_send", "mid_response")) + try: + s = mkconn(timeout=2.0) + except OSError: + ST.inc("connfail"); time.sleep(pace); continue + try: + if phase == "mid_request": + full = req_bytes(method, params, rid, keepalive=False, body=body) + cut = random.randint(len(full) - len(body), len(full) - 1) + s.sendall(full[:cut]) + time.sleep(random.uniform(0.001, 0.5)); end_early(s) + else: + s.sendall(req_bytes(method, params, rid, keepalive=False, body=body)) + ST.inc("sent") + time.sleep(random.uniform(0.001, 0.5)) + if phase == "mid_response": + try: + s.recv(random.randint(0, 64)) + except OSError: + pass + end_early(s) + except (OSError, socket.timeout): + ST.inc("srvreset"); end_rst(s) + time.sleep(random.uniform(pace * 0.5, pace * 1.5)) + +def client_half_open(cid): + # connect, send nothing / partial headers, trickle, then vanish + while not STOP.is_set(): + try: + s = mkconn(timeout=1.0) + except OSError: + ST.inc("connfail"); time.sleep(0.1); continue + ST.inc("half_open") + style = random.choice(("silent", "partial_headers", "full_headers_no_body")) + try: + if style == "partial_headers": + s.sendall(b"POST / HTTP/1.1\r\nHost: x\r\n") + elif style == "full_headers_no_body": + s.sendall(b"POST / HTTP/1.1\r\nHost: x\r\nAuthorization: " + + CFG.auth.encode() + b"\r\nContent-Length: 1000\r\n\r\n") + deadline = time.monotonic() + random.uniform(0.1, 20.0) + while time.monotonic() < deadline and not STOP.is_set(): + time.sleep(random.uniform(0.2, 1.5)) + try: + s.sendall(b" ") # one byte, keep it half-alive + except OSError: + ST.inc("half_srvclose"); break + try: + s.settimeout(0.01) + if s.recv(1) == b"": + ST.inc("half_srvclose"); break + except socket.timeout: + pass + except OSError: + ST.inc("half_srvclose"); break + finally: + end_early(s) + +def client_burst(cid, size, every): + while not STOP.is_set(): + batch = [] + for _ in range(size): + try: + s = mkconn(timeout=3.0) + except OSError: + ST.inc("connfail"); continue + batch.append(s) + for s in batch: + try: + method, params = medium_menu() + s.sendall(req_bytes(method, params, next_id(), keepalive=False)) + ST.inc("sent") + except OSError: + pass + # most close immediately (before the 500/reply is written); a few read it + keep = random.sample(batch, max(0, len(batch) // 4)) if batch else [] + for s in batch: + if s in keep: + continue + end_early(s) + for s in keep: + try: + rd = Reader(s); st, hd, bd = rd.read(); account(st, bd, None, "burst") + except Exception: + ST.inc("srvreset") + finally: + end_fin(s) + for _ in range(int(every * 10)): + if STOP.is_set(): + break + time.sleep(0.1) + +def client_pipeline(cid): + while not STOP.is_set(): + try: + s = mkconn() + except OSError: + ST.inc("connfail"); time.sleep(0.05); continue + n = random.randint(2, 8) + ids = [] + blob = b"" + for _ in range(n): + rid = next_id(); ids.append(rid) + method, params = normal_menu() + blob += req_bytes(method, params, rid, keepalive=True) + try: + s.sendall(blob); ST.inc("sent", n) + rd = Reader(s) + toread = random.randint(0, n) # sometimes leave replies unread, then close + for k in range(toread): + st, hd, bd = rd.read() + account(st, bd, ids[k], "pipeline") + except (OSError, EOFError, ValueError): + ST.inc("srvreset") + finally: + end_early(s) + time.sleep(random.uniform(0, 0.03)) + +def client_garbage(cid): + probes = [ + ("submitblock", lambda: ["ab" * random.randint(1, 4000)]), + ("submitblock", lambda: [os.urandom(random.randint(1, 200)).hex()]), + ("submitblock", lambda: ["zzzz"]), + ("submitblock", lambda: [""]), + ] + while not STOP.is_set(): + pick = random.random() + try: + s = mkconn(timeout=5.0) + except OSError: + ST.inc("connfail"); continue + try: + if pick < 0.5: + method, mk = random.choice(probes) + j, _ = call(s, Reader(s), method, mk(), keepalive=False) + elif pick < 0.65: + # malformed JSON body + s.sendall(req_bytes("x", [], next_id(), keepalive=False, + body=b'{"jsonrpc":"1.0","method":')) + ST.inc("sent") + try: + account(*((lambda r: (r[0], r[2]))(Reader(s).read())), rid=None, method="badjson") + except Exception: + ST.inc("srvreset") + elif pick < 0.8: + # unknown method / wrong URI + s.sendall(req_bytes("getinfo", [], next_id(), keepalive=False, uri="/nope")) + ST.inc("sent") + try: + st, hd, bd = Reader(s).read(); account(st, bd, None, "wronguri") + except Exception: + ST.inc("srvreset") + else: + # Content-Length larger than the body we send, then bail + body = b'{"id":1}' + s.sendall(req_bytes("getinfo", [], 1, keepalive=False, body=body, clen=len(body) + 5000)) + ST.inc("sent") + time.sleep(random.uniform(0.001, 0.3)) + end_early(s) + continue + except (OSError, socket.timeout): + ST.inc("srvreset") + finally: + end_early(s) + time.sleep(random.uniform(0.02, 0.2)) + +def client_slowread(cid): + # ask for a big reply with a tiny recv buffer and do not read it, stalling the + # server write side, then close abruptly + while not STOP.is_set(): + try: + s = mkconn(timeout=3.0, rcvbuf=512) + except OSError: + ST.inc("connfail"); continue + try: + s.sendall(req_bytes("listtransactions", ["*", 10000], next_id(), keepalive=False)) + ST.inc("sent") + time.sleep(random.uniform(0.05, 1.0)) + except OSError: + ST.inc("srvreset") + finally: + end_early(s) + time.sleep(random.uniform(0, 0.05)) + +def _stale_lpid(): + # A longpollid whose hash != the current tip makes getblocktemplate return at + # once (the server's wait loop condition is immediately false), so it exercises + # the long-poll request path (lpval parsing, cs_main leave/enter) and the + # mid-response disconnect WITHOUT parking a worker. On a tiny -rpcthreads pool a + # genuinely parking long-poll + RPC-driven mining self-deadlocks (both workers + # wait for a block that only a third worker could mine); that is a pool-size + # artifact, not the fd bug, so the harness avoids it by default. + with SHARED_LOCK: + lpid = SHARED["lpid"] + if lpid and len(lpid) > 64: + h = lpid[:64] + flip = "0" if h[0] != "0" else "1" + return flip + h[1:] + lpid[64:] + return ("0" * 64) + "0" + +def client_longpoll(cid): + while not STOP.is_set(): + try: + s = mkconn(timeout=10.0) + except OSError: + ST.inc("connfail"); time.sleep(0.2); continue + rd = Reader(s) + params = [{"longpollid": _stale_lpid()}] + rid = next_id() + try: + s.sendall(req_bytes("getblocktemplate", params, rid, keepalive=False)) + ST.inc("sent") + if random.random() < 0.5: + # impatient pool worker: abandon the request after 1-500 ms + time.sleep(random.uniform(0.001, 0.5)) + end_early(s); continue + st, hd, bd = rd.read() + j = account(st, bd, rid, "getblocktemplate(lp)") + if j and isinstance(j, dict) and j.get("result"): + with SHARED_LOCK: + SHARED["lpid"] = j["result"].get("longpollid") + except (OSError, socket.timeout, EOFError, ValueError): + ST.inc("srvreset") + finally: + end_fin(s) + time.sleep(random.uniform(0.02, 0.2)) + +def miner(interval): + # Mine a block every `interval` seconds. Retry through work-queue rejections so + # blocks still land under heavy load (the production box mines steadily too). + while not STOP.is_set(): + got = False + deadline = time.monotonic() + interval + 30 + while not got and not STOP.is_set() and time.monotonic() < deadline: + try: + s = mkconn(timeout=60.0) + j, _ = call(s, Reader(s), "generate", [1], keepalive=False) + end_fin(s) + if j and not j.get("error") and j.get("result"): + ST.inc("mined"); got = True + h = j["result"][0] if isinstance(j["result"], list) else None + if h: + with SHARED_LOCK: + SHARED["hashes"].append(h) + SHARED["hashes"] = SHARED["hashes"][-200:] + else: + time.sleep(0.2) # likely work-queue 500; back off and retry + except Exception: + time.sleep(0.2) + for _ in range(int(interval * 10)): + if STOP.is_set(): + break + time.sleep(0.1) + +# ------------------------------------------------------------------ bootstrap shared state + +def bootstrap(): + try: + s = mkconn(timeout=30.0); rd = Reader(s) + j, _ = call(s, rd, "getbestblockhash", [], keepalive=True) + if j and j.get("result"): + with SHARED_LOCK: + SHARED["hashes"].append(j["result"]) + j, _ = call(s, rd, "getblockcount", [], keepalive=True) + cnt = j.get("result") if j else None + if isinstance(cnt, int): + for hh in range(max(0, cnt - 30), cnt + 1): + jj, _ = call(s, rd, "getblockhash", [hh], keepalive=True) + if jj and jj.get("result"): + with SHARED_LOCK: + SHARED["hashes"].append(jj["result"]) + j, _ = call(s, rd, "listaddresses", [], keepalive=True) + j, _ = call(s, rd, "z_listaddresses", [], keepalive=True) + if j and isinstance(j.get("result"), list): + with SHARED_LOCK: + SHARED["zaddrs"] = j["result"][:16] + j, _ = call(s, rd, "getblocktemplate", [{}], keepalive=True) + if j and isinstance(j.get("result"), dict): + with SHARED_LOCK: + SHARED["lpid"] = j["result"].get("longpollid") + end_fin(s) + log("bootstrap: %d hashes, %d zaddrs, lpid=%s" % + (len(SHARED["hashes"]), len(SHARED["zaddrs"]), bool(SHARED["lpid"]))) + except Exception as e: + log("bootstrap failed: %s" % e) + +# ------------------------------------------------------------------ fd accounting + +def count_fds(pid): + if not pid: + return (None, None, None) + base = "/proc/%d/fd" % pid + try: + fds = os.listdir(base) + except OSError: + return (None, None, None) + sock = files = 0 + for fd in fds: + try: + tgt = os.readlink(os.path.join(base, fd)) + except OSError: + continue + if tgt.startswith("socket:"): + sock += 1 + elif tgt.startswith("/"): + files += 1 + return (len(fds), sock, files) + +def pid_alive(pid): + if not pid: + return True + try: + os.kill(pid, 0); return True + except OSError: + return False + +# ------------------------------------------------------------------ main + +def reporter(pid): + t0 = time.monotonic() + while not STOP.is_set(): + for _ in range(10): + if STOP.is_set(): + break + time.sleep(0.1) + w = ST.swap() + tot, sk, fl = count_fds(pid) + fdstr = "" + if tot is not None: + fdstr = " | fds=%d sock=%d file=%d" % (tot, sk, fl) + log("t=%4ds ok=%d rpcerr=%d wq500=%d 4xx=%d other=%d | " + "earlyRST=%d earlyFIN=%d srvRST=%d srvEOF=%d ctmo=%d connfail=%d | " + "idmis=%d malf=%d | mined=%d halfopen=%d%s" % ( + int(time.monotonic() - t0), + w["ok"], w["rpcerr"], w["wq500"], w["http4xx"], w["httpother"], + w["early_rst"], w["early_fin"], w["srvreset"], w["srveof"], + w["ctimeout"], w["connfail"], w["idmismatch"], w["malformed"], + w["mined"], w["half_open"], fdstr)) + if pid and not pid_alive(pid): + log("!!! NODE PROCESS %d IS GONE -- stopping harness" % pid) + STOP.set() + break + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, default=19461) + ap.add_argument("--user", default="ab") + ap.add_argument("--password", default="abpass") + ap.add_argument("--duration", type=int, default=300) + ap.add_argument("--ctimeout", type=float, default=15.0) + ap.add_argument("--normal", type=int, default=24) + ap.add_argument("--impatient", type=int, default=10) + ap.add_argument("--slowfire", type=int, default=3, + help="paced clients that fire a genuinely-slow call then disconnect") + ap.add_argument("--slowfire-pace", type=float, default=2.0) + ap.add_argument("--halfopen", type=int, default=6) + ap.add_argument("--pipeline", type=int, default=6) + ap.add_argument("--garbage", type=int, default=4) + ap.add_argument("--slowread", type=int, default=2) + ap.add_argument("--longpoll", type=int, default=6) + # burst-size must exceed (rpcthreads + rpcworkqueue) to overflow the queue even + # when calls are fast; with -rpcthreads=8 -rpcworkqueue=4 that threshold is 12. + ap.add_argument("--burst-threads", type=int, default=1) + ap.add_argument("--burst-size", type=int, default=24) + ap.add_argument("--burst-every", type=float, default=1.5) + ap.add_argument("--mine", action="store_true", help="run a miner thread") + ap.add_argument("--mine-interval", type=float, default=4.0) + ap.add_argument("--pid", type=int, default=0, help="node pid, for /proc fd + liveness") + ap.add_argument("--logfile", default="") + args = ap.parse_args() + + CFG.host = args.host; CFG.port = args.port + CFG.auth = auth_header(args.user, args.password); CFG.ctimeout = args.ctimeout + if args.logfile: + LOGF[0] = open(args.logfile, "a") + + log("hammer start host=%s port=%d duration=%ds pid=%s" % + (CFG.host, CFG.port, args.duration, args.pid or "-")) + bootstrap() + + threads = [] + def spawn(fn, n, *a): + for i in range(n): + t = threading.Thread(target=fn, args=(i,) + a, daemon=True) + t.start(); threads.append(t) + + spawn(client_normal, args.normal) + spawn(client_impatient, args.impatient) + spawn(client_slowfire, args.slowfire, args.slowfire_pace) + spawn(client_half_open, args.halfopen) + spawn(client_pipeline, args.pipeline) + spawn(client_garbage, args.garbage) + spawn(client_slowread, args.slowread) + spawn(client_longpoll, args.longpoll) + for i in range(args.burst_threads): + t = threading.Thread(target=client_burst, args=(i, args.burst_size, args.burst_every), daemon=True) + t.start(); threads.append(t) + if args.mine: + t = threading.Thread(target=miner, args=(args.mine_interval,), daemon=True) + t.start(); threads.append(t) + + rt = threading.Thread(target=reporter, args=(args.pid,), daemon=True) + rt.start() + + end = time.monotonic() + args.duration + try: + while time.monotonic() < end and not STOP.is_set(): + time.sleep(0.2) + except KeyboardInterrupt: + pass + STOP.set() + time.sleep(1.0) + + log("=== TOTALS ===") + for k in KEYS: + if ST.total[k]: + log(" %-14s %d" % (k, ST.total[k])) + log("anomalies: %d" % len(ST.anom)) + for t, kind, detail in ST.anom[:40]: + log(" %s %s %s" % (time.strftime("%H:%M:%S", time.localtime(t)), kind, detail)) + if args.pid: + log("node alive at end: %s" % pid_alive(args.pid)) + +if __name__ == "__main__": + main() diff --git a/qa/fd-guard/repro/tools/rpcchurn.sh b/qa/fd-guard/repro/tools/rpcchurn.sh new file mode 100755 index 000000000..bd02695a9 --- /dev/null +++ b/qa/fd-guard/repro/tools/rpcchurn.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# RPC-driven P2P peer churn on the target (same mix as fdbug/p2p/rpcchurn.sh): +# disconnectnode / addnode onetry / setban+clearbanned / kill -9 + restart of helper2. +# usage: rpcchurn.sh +source "$REPRO/common.sh" +OUT=$1; DUR=${2:-300} +DD_T=$OUT/target; DD_H1=$OUT/helper1; DD_H2=$OUT/helper2 +end=$(( $(date +%s) + DUR )); i=0; restarts=0 +while [ "$(date +%s)" -lt "$end" ] && [ ! -e "$OUT/STOP" ]; do + i=$((i+1)) + case $(( i % 5 )) in + 0) clin "$DD_T" "$RPC_T" disconnectnode "127.0.0.1:$P2P_H1" >/dev/null 2>&1 ;; + 1) clin "$DD_T" "$RPC_T" addnode "127.0.0.1:$P2P_H1" onetry >/dev/null 2>&1 ;; + 2) clin "$DD_T" "$RPC_T" setban "127.0.0.1" add 1 >/dev/null 2>&1; sleep 0.3 + clin "$DD_T" "$RPC_T" clearbanned >/dev/null 2>&1 + clin "$DD_H2" "$RPC_H2" addnode "127.0.0.1:$P2P_T" onetry >/dev/null 2>&1 ;; + 3) pid=$(cat "$DD_H2/regtest/hushd.pid" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + kill -9 "$pid" 2>/dev/null + for _ in $(seq 1 20); do kill -0 "$pid" 2>/dev/null || break; sleep 0.5; done + start_helper "$DD_H2" "$P2P_H2" "$RPC_H2" "$OUT/logs/helper2.txt" + waitrpc "$DD_H2" "$RPC_H2" 120 >/dev/null 2>&1 + restarts=$((restarts+1)) + fi ;; + 4) clin "$DD_T" "$RPC_T" addnode "127.0.0.1:$P2P_H2" onetry >/dev/null 2>&1 ;; + esac + sleep 0.7 +done +echo "rpcchurn: $i cycles, $restarts helper2 restarts" diff --git a/qa/fd-guard/repro/tools/scan_bdb.py b/qa/fd-guard/repro/tools/scan_bdb.py new file mode 100755 index 000000000..0118e402b --- /dev/null +++ b/qa/fd-guard/repro/tools/scan_bdb.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +""" +scan_bdb.py -- find Berkeley DB pages written into files that must not contain them +(blocks/blk*.dat, blocks/rev*.dat). Stand-in for the production incident tool. + +BDB writes page P of a database at file offset P*pagesize through pwrite(fd, ...). If +fd has been closed and its number reused by a block file, the page lands in the block +file at exactly P*pagesize. So at every pagesize-aligned offset we decode the PAGE +header (db_page.h, all little-endian on x86): + 0 u32 lsn.file 4 u32 lsn.offset 8 u32 pgno 12 u32 prev_pgno + 16 u32 next_pgno 20 u16 entries 22 u16 hf_offset + 24 u8 level 25 u8 type +and a metadata page (pgno 0 / any meta type) also carries magic at 12 and pagesize at 20. + +A hit needs ALL of: + * pgno == offset // pagesize (the self-locating property) + * type is a real BDB page type (1..16 except 0) + * level consistent with type (overflow/meta/hash: 0; btree leaf: 1; internal: 2..32) + * hf_offset <= pagesize; entries <= pagesize/2 + * for overflow (7): entries (refcount) >= 1 and 0 < hf_offset (data length) <= pagesize-26 +Strength: "STRONG" if lsn == (0,1) (pages rewritten by lsn_reset -- the production +signature) or a meta page with a valid magic, else "weak". +Also reports any aligned BDB meta magic (btree 0x053162, hash 0x061561, queue 0x042253, +heap 0x074582) even when pgno does not match. + +usage: scan_bdb.py [--pagesize 4096] [--all-offsets] files... +exit 0 = clean, 2 = hits. +""" +import argparse, struct, sys + +TYPES = {1: "duplicate", 2: "hash_unsorted", 3: "ibtree", 4: "irecno", 5: "lbtree", 6: "lrecno", + 7: "overflow", 8: "hashmeta", 9: "btreemeta", 10: "qammeta", 11: "qamdata", 12: "ldup", + 13: "hash", 14: "heapmeta", 15: "heap", 16: "iheap"} +MAGIC = {0x053162: "btree", 0x061561: "hash", 0x042253: "queue", 0x074582: "heap"} +META = {8, 9, 10, 14} + + +def level_ok(t, lvl): + if t in (5, 6, 12): # leaf pages + return lvl == 1 + if t in (3, 4): # internal pages + return 2 <= lvl <= 32 + return lvl == 0 # overflow / meta / hash / queue / heap + + +def check(buf, off, ps): + if len(buf) < 26: + return None + lf, lo, pgno, prv, nxt, ent, hfo, lvl, typ = struct.unpack_from(" ps or ent > ps // 2: + return None + if typ == 7 and not (ent >= 1 and 0 < hfo <= ps - 26): + return None + strength = "STRONG" if (lf, lo) == (0, 1) else "weak" + return (strength, f"{TYPES[typ]} lsn=({lf},{lo}) prev={prv} next={nxt} entries={ent} hf_offset={hfo} level={lvl}") + + +def scan(path, ps): + hits, magic_only = [], [] + with open(path, "rb") as f: + off = 0 + while True: + buf = f.read(ps) + if not buf: + break + r = check(buf, off, ps) + if r: + hits.append((off, off // ps) + r) + elif len(buf) >= 16: + m = struct.unpack_from(" -- attribute every fdguard report to (type, closer function, target class) +and, for EBADF/NON-OWNER reports, to the code that made the previous close of that fd number. + +The point: show that the stray closes ORIGINATE in CNode code (net.cpp CloseSocketDisconnect / +~CNode closing a number they already closed) and that libevent / BDB / CreateNodeFromAcceptedSocket / +GetTransaction only appear as victims-turned-amplifiers downstream of that. +""" +import collections, re, sys + +ORIGIN = ("_ZN5CNode21CloseSocketDisconnectEv", "_ZN5CNodeD1Ev", "_ZN5CNodeD2Ev", "_ZN11CNetCleanupD2Ev") +NAMES = {"_ZN5CNode21CloseSocketDisconnectEv": "CNode::CloseSocketDisconnect (net.cpp:572)", + "_ZN5CNodeD1Ev": "CNode::~CNode (net.cpp:2819)", + "_ZN11CNetCleanupD2Ev": "CNetCleanup::~CNetCleanup (net.cpp:2465, shutdown)", + "evhttp_connection_free": "libevent evhttp_connection_free (http.c:1244)", + "__os_closehandle": "BDB __os_closehandle", + "_Z28CreateNodeFromAcceptedSocketjbRK8CAddressS1_": "CreateNodeFromAcceptedSocket (net.cpp:1336)", + "_Z14GetTransactionRK7uint256R12CTransactionRS_b": "GetTransaction fclose (main.cpp)", + "epoll_nochangelist_add": "libevent epoll ADD", "epoll_nochangelist_del": "libevent epoll DEL/MOD"} + + +def fn(frame_line): + m = re.search(r"\+0x[0-9a-f]+ (\S+?)\+0x", frame_line) + return m.group(1) if m else "?" + + +def main(path): + reps, cur = [], None + for line in open(path, errors="replace"): + if line.startswith("=== FDGUARD #"): + cur = {"hdr": line.strip(), "lines": []} + reps.append(cur) + elif cur is not None: + if line.startswith("=== END"): + cur = None + else: + cur["lines"].append(line.rstrip("\n")) + tally = collections.Counter() + prev_tally = collections.Counter() + db_blk = [] + for r in reps: + typ = r["hdr"].split()[3] + L = r["lines"] + cls = next((re.search(r"class=(\S+)", l).group(1) for l in L if " target=" in l and "class=" in l), "?") + try: + i = next(k for k, l in enumerate(L) if l.strip() == "closer stack:") + closer = fn(L[i + 1]) + except StopIteration: + closer = "?" + prev = "-" + for k, l in enumerate(L): + if "previous successful close of this fd number" in l: + # first frame line after the target= line + for l2 in L[k + 1:k + 4]: + if l2.strip().startswith("#0"): + prev = fn(l2) + break + break + tally[(typ, NAMES.get(closer, closer), cls)] += 1 + if typ in ("EBADF-CLOSE", "NON-OWNER-CLOSE"): + prev_tally[NAMES.get(prev, prev)] += 1 + if typ == "NON-OWNER-CLOSE" and cls in ("db", "blk"): + tgt = next((l.split("target=")[1].split()[0] for l in L if " target=" in l), "?") + db_blk.append((r["hdr"].split()[2], r["hdr"].split()[4], NAMES.get(closer, closer), tgt.rsplit("/", 2)[-2:])) + print(f"{len(reps)} reports in {path}") + print("\n count type class closer") + for (typ, closer, cls), n in tally.most_common(): + print(f"{n:6d} {typ:17s} {cls:6s} {closer}") + print("\nEBADF + NON-OWNER reports by the code that made the PREVIOUS close of the same number:") + for k, n in prev_tally.most_common(): + print(f"{n:6d} {k}") + o = sum(n for (typ, c, cls), n in tally.items() if typ == "NON-OWNER-CLOSE" and cls in ("db", "blk") and c.startswith(("CNode", "CNetCleanup"))) + a = sum(n for (typ, c, cls), n in tally.items() if typ == "NON-OWNER-CLOSE" and cls in ("db", "blk")) - o + print(f"\nNON-OWNER closes of wallet/BDB-log/block files: {o + a} (by CNode code directly: {o}; by an amplifier: {a})") + for d in db_blk[:40]: + print(" ", d[0], d[1], "closer=" + d[2], "target=" + "/".join(d[3])) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/qa/fd-guard/repro/tools/tcpchurn.py b/qa/fd-guard/repro/tools/tcpchurn.py new file mode 100755 index 000000000..9c046416d --- /dev/null +++ b/qa/fd-guard/repro/tools/tcpchurn.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +""" +Raw TCP / TLS client churn against the target node's P2P port. + +dragonxd P2P is TLS 1.3 only (wolfSSL), self-signed, no cert verification. The +accept path is net.cpp CreateNodeFromAcceptedSocket -> tlsmanager.accept() +(wolfSSL_accept), and teardown is CloseSocketDisconnect / ~CNode +(wolfSSL_shutdown + wolfSSL_free + CloseSocket). This driver pokes the accept and +TLS-teardown/error paths (SSL_read/SSL_write at net.cpp ~1005 and tlsmanager.cpp +~660-680) by opening many short-lived connections that each misbehave: + + rst : connect, then abort with RST (SO_LINGER 0) before any TLS bytes + fin : connect, then close cleanly (FIN) before any TLS bytes + garbage : connect, send non-ClientHello bytes, then drop + stall : connect, send a few bytes, hold mid-handshake, drop + tlsjunk : complete a real TLS 1.3 handshake (verify off), send junk, drop + tlshalf : start TLS, send a partial ClientHello record, drop + +Loopback only, against a port this harness started. --duration seconds. +""" +import argparse, random, socket, ssl, struct, sys, time, os + +def mk(): + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.settimeout(3.0) + return s + +def rst_close(s): + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + except Exception: + pass + try: s.close() + except Exception: pass + +def do_rst(host, port): + s = mk() + try: + s.connect((host, port)); rst_close(s) + except Exception: + rst_close(s) + +def do_fin(host, port): + s = mk() + try: + s.connect((host, port)); s.close() + except Exception: + try: s.close() + except Exception: pass + +def do_garbage(host, port): + s = mk() + try: + s.connect((host, port)) + s.sendall(os.urandom(random.randint(16, 400))) + time.sleep(random.uniform(0.0, 0.3)) + except Exception: + pass + rst_close(s) + +def do_stall(host, port): + s = mk() + try: + s.connect((host, port)) + # a couple of bytes that look like the start of a TLS record, then wait + s.sendall(b"\x16\x03\x01") + time.sleep(random.uniform(0.5, 2.0)) + except Exception: + pass + rst_close(s) + +def _ctx(): + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + try: + ctx.minimum_version = ssl.TLSVersion.TLSv1_3 + except Exception: + pass + return ctx + +def do_tlsjunk(host, port): + raw = mk() + try: + raw.connect((host, port)) + tls = _ctx().wrap_socket(raw, server_hostname=None) + # handshake completed; the node now expects a P2P version message. + # send junk inside the TLS channel, then tear down. + try: + tls.sendall(os.urandom(random.randint(8, 128))) + except Exception: + pass + time.sleep(random.uniform(0.0, 0.4)) + try: + tls.close() + except Exception: + rst_close(raw) + except Exception: + rst_close(raw) + +def do_tlshalf(host, port): + s = mk() + try: + s.connect((host, port)) + # a well-formed TLS record header claiming a long handshake, but we send + # only part of it, then drop -> server sits in wolfSSL_accept WANT_READ. + body = os.urandom(40) + rec = b"\x16\x03\x03" + struct.pack(">H", 512) + body + s.sendall(rec) + time.sleep(random.uniform(0.3, 1.5)) + except Exception: + pass + rst_close(s) + +HANDLERS = [do_rst, do_fin, do_garbage, do_stall, do_tlsjunk, do_tlshalf] + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--host", default="127.0.0.1") + ap.add_argument("--port", type=int, required=True) + ap.add_argument("--duration", type=float, default=300.0) + ap.add_argument("--concurrency", type=int, default=6) + ap.add_argument("--rate", type=float, default=0.03, help="sleep between bursts") + args = ap.parse_args() + + end = time.time() + args.duration + counts = {h.__name__: 0 for h in HANDLERS} + import threading + lock = threading.Lock() + + def worker(): + while time.time() < end: + h = random.choice(HANDLERS) + h(args.host, args.port) + with lock: + counts[h.__name__] += 1 + time.sleep(random.uniform(0.0, args.rate)) + + ts = [threading.Thread(target=worker, daemon=True) for _ in range(args.concurrency)] + for t in ts: t.start() + for t in ts: t.join() + + total = sum(counts.values()) + print(f"tcpchurn: {total} connections in {args.duration:.0f}s -> {counts}", flush=True) + +if __name__ == "__main__": + main() diff --git a/qa/fd-guard/shim/build.sh b/qa/fd-guard/shim/build.sh new file mode 100755 index 000000000..b3f9659e5 --- /dev/null +++ b/qa/fd-guard/shim/build.sh @@ -0,0 +1,15 @@ +#!/bin/sh +# Build the fdguard LD_PRELOAD shim and its self-test program. +# ./build.sh -> libfdguard.so, fdguard_test +set -e +cd "$(dirname "$0")" +CC=${CC:-gcc} +$CC -O2 -g -fPIC -shared -Wall -Wextra -Wno-unused-parameter -fvisibility=default \ + -o libfdguard.so.tmp fdguard.c -ldl -lpthread +# rename, never overwrite in place: a running process may have the old one mapped +mv -f libfdguard.so.tmp libfdguard.so +# the test program keeps its symbols (no -rdynamic on purpose: like dragonxd, +# owner detection must work from .symtab alone) and frame pointers for clarity +$CC -O1 -g -fno-omit-frame-pointer -fno-inline -Wall -o fdguard_test.tmp fdguard_test.c -lpthread +mv -f fdguard_test.tmp fdguard_test +echo "built: $(pwd)/libfdguard.so $(pwd)/fdguard_test" diff --git a/qa/fd-guard/shim/fdguard.c b/qa/fd-guard/shim/fdguard.c new file mode 100644 index 000000000..dc56e23a8 --- /dev/null +++ b/qa/fd-guard/shim/fdguard.c @@ -0,0 +1,1500 @@ +/* + * fdguard.c -- LD_PRELOAD file-descriptor ownership guard. + * + * Catches code that close()s / fclose()s / dup2()s over a file descriptor + * it does not own (the "stale fd number" bug), and the second half of a + * double close (close() returning EBADF). + * + * How it works + * - close, __close, fclose, dup2, dup3, closefrom, close_range, freopen are + * interposed (dlsym(RTLD_NEXT)). Before the real call the target of + * /proc/self/fd/N is read and classified (db, blk, reg, dev, sock, pipe, + * anon). + * - open/openat/creat/fopen/fdopen/socket/accept/pipe/socketpair/eventfd/ + * epoll_create/dup are interposed too, so every fd has an "open record": + * who created it (kind, FILE*, thread, time, stack, "domain"). + * - A "domain" is a named set of function-name patterns (bdb = Berkeley DB + * internals, event = libevent, p2p = net/netbase/sock/TLSManager, ...). + * Function names come from the executable's own ELF .symtab (loaded at + * start-up), so this works on a PIE binary built without -rdynamic. + * - Ownership rules for a close of a guarded fd (first match wins): + * stdio fd was created by fopen/fdopen: only fclose/freopen of that + * same FILE* may close it. A raw close() under a live FILE, + * or an fclose of another (stale) FILE, is a non-owner close. + * domain fd created by raw open/socket/accept: the closer's first + * FDGUARD_DEPTH executable frames must share a domain with + * the opener's, or (common-caller) share a function within + * the first FDGUARD_CCDEPTH executable frames (open+close in + * the same function, e.g. boost::filesystem::copy_file). + * fallback no valid open record: db files need a bdb frame, blk + * files need fclose() from a blk-domain frame; other classes + * are "unknown" (counted, never reported as hits). + * - Every non-owner close of a guarded fd is reported with full stacks: + * the closer, the current open record and the previous close of the + * same fd number. Every close()/fclose() that fails with EBADF is + * reported with the previous (successful) close of that number -- that + * is the first half of the double close. epoll_ctl() failing with + * EBADF/EPERM (libevent "Epoll ADD ... failed") is reported the same way. + * + * Hot path: no malloc, no stdio; per-fd records live in a static table; + * output is built in a stack buffer and written with write(2) to a log fd + * that is moved high (>= 900) so the shim does not perturb fd numbering. + * A thread-local flag prevents recursion (and signal-handler re-entry). + * + * Environment (all optional) + * FDGUARD_LOG=path log file ("%p" -> pid). Default /tmp/fdguard.%p.log + * "stderr" = fd 2. + * FDGUARD_EXE=a,b only activate in processes whose exe basename is in + * the list (default "dragonxd"; "*" = every process). + * Children (blocknotify shells) stay passive. + * FDGUARD_GUARD=list classes to judge: db,blk,reg,dev,sock,pipe,anon,all + * (default "db,blk"). + * FDGUARD_ABORT=1 abort() after reporting the first non-owner close + * =2 ... or the first EBADF / epoll failure as well. + * FDGUARD_DENY=1 do NOT perform a non-owner close of a db/blk fd + * (return 0). Test aid only. + * FDGUARD_TRACE_ALL=1 one compact line per open/close. + * FDGUARD_HISTORY=0 don't capture stacks for non-guarded opens/closes + * (lower overhead; less context in EBADF reports). + * FDGUARD_DEPTH=n exe frames used for the closer/opener domain (4). + * FDGUARD_CCDEPTH=n exe frames used for the common-caller rule (3). + * FDGUARD_DB_PATHS=a|b substrings classifying a target as db + * (default "/wallet.dat|/database/log.|/__db."). + * FDGUARD_BLK_PATHS=a|b default "/blocks/blk|/blocks/rev". + * FDGUARD_DOMAINS=d=p,p;d2=p extra domains / extra patterns for existing + * ones ("^x" = prefix, otherwise substring). + * FDGUARD_SYMFILE=path read symbols from this ELF instead of + * /proc/self/exe (for a stripped production binary, + * point it at the unstripped copy of the same build). + * FDGUARD_MAXREPORTS=n cap per report type (default 500). + * FDGUARD_STATS_SEC=n periodic stats line every n s (default 60, 0=off). + * FDGUARD_EPOLL=0 don't report epoll_ctl failures. + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define FDG_NFD 8192 /* per-fd records kept for fds below this */ +#define FDG_NF 20 /* frames stored per open/close record */ +#define FDG_NFULL 64 /* frames captured for a report */ +#define FDG_TGT 120 /* stored target string length */ +#define FDG_MAXDOM 24 +#define FDG_MAXPAT 512 +#define FDG_LOGFD_MIN 900 + +#define TLS __thread __attribute__((tls_model("initial-exec"))) + +/* ------------------------------------------------------------------ kinds */ +enum { + K_NONE = 0, K_OPEN, K_FOPEN, K_FDOPEN, K_SOCKET, K_ACCEPT, K_PIPE, + K_SOCKPAIR, K_EVENTFD, K_EPOLL, K_DUP, K_DUPTO, + K_CLOSE, K___CLOSE, K_FCLOSE, K_DUP2, K_DUP3, K_CLOSEFROM, K_CLOSERANGE, + K_FREOPEN, K_MAX +}; +static const char *const kname[K_MAX] = { + "none", "open", "fopen", "fdopen", "socket", "accept", "pipe", + "socketpair", "eventfd", "epoll_create", "dup", "dup2/3-target", + "close", "__close", "fclose", "dup2", "dup3", "closefrom", "close_range", + "freopen" +}; + +enum { C_NONE = 0, C_DB, C_BLK, C_REG, C_DEV, C_SOCK, C_PIPE, C_ANON, C_OTHER, C_MAX }; +static const char *const cname[C_MAX] = { + "none", "db", "blk", "reg", "dev", "sock", "pipe", "anon", "other" +}; + +/* ---------------------------------------------------------------- records */ +struct orec { + uint8_t kind, cls; + uint16_t nfr; + uint32_t dom; + int32_t tid; + int32_t ret, err; + uint64_t ts; /* CLOCK_REALTIME ns */ + uint64_t dev, ino; /* identity of the open file (fstat) */ + void *fp; /* FILE* that owns the fd (stdio) */ + char tname[16]; + char tgt[FDG_TGT]; + void *fr[FDG_NF]; +}; +struct fdent { + atomic_int lk; + uint32_t gen; + struct orec open; /* current owner (kind==K_NONE: unknown) */ + struct orec lclose; /* last successful close of this fd number */ + struct orec lclose_open; /* the open record that lclose ended */ +}; +static struct fdent g_fd[FDG_NFD]; + +/* ---------------------------------------------------------------- symbols */ +struct sym { uintptr_t lo, hi; uint32_t name; uint32_t dom; }; + +struct domain { char name[16]; int npat; const char *pat[48]; uint32_t nsyms; }; + +static struct { + int active, inited; + int logfd; + int guard_mask; + int abort_mode, deny, trace_all, history, epoll; + int depth, ccdepth; + int maxreports; + int stats_sec; + char db_paths[512], blk_paths[512]; + const char *dbp[16]; int ndbp; + const char *blkp[16]; int nblkp; + char exe_path[512]; + uintptr_t exe_bias, exe_lo, exe_hi; /* runtime text range of exe */ + uintptr_t shim_lo, shim_hi; + struct sym *syms; size_t nsyms; + char *strs; + struct domain dom[FDG_MAXDOM]; int ndom; + char patbuf[8192]; size_t patlen; + char build_id[64]; /* of the symbol file */ + char exe_build_id[64]; /* of the running executable */ + int nosyms_reason; + pid_t pid; +} g = { .logfd = -1 }; + +/* domain bit numbers, fixed for the built-ins (see init_domains) */ +enum { D_BDB = 0, D_EVENT, D_BLK, D_LEVELDB, D_BOOSTFS, D_BOOSTIPC, D_RUST, + D_P2P, D_CURL, D_WOLFSSL, D_TOR, D_HTTP, D_DBWRAP, D_NBUILTIN }; + +/* ---------------------------------------------------------------- counters */ +static struct { + atomic_ulong n_close, n_fclose, n_dup2, n_range, n_open, n_epollfail; + atomic_ulong n_guarded, n_owner, n_hits, n_unknown, n_ebadf, n_denied; + atomic_ulong n_closeneg; + atomic_ulong ns_close, ns_fclose, ns_open; /* shim time, excl. real */ + atomic_ulong rep_hit, rep_ebadf, rep_epoll; + atomic_ulong seq; + atomic_ulong last_stats; + atomic_ulong rule_cnt[16]; +} st; +static const char *const rule_names[16] = { + "stdio:same-FILE", "domain", "common-caller", "fallback:bdb", + "fallback:blk-fclose", "", "", "", "", "", "", "", "", "", "", "" +}; + +static pthread_mutex_t g_replock = PTHREAD_MUTEX_INITIALIZER; +static TLS int t_in; +static TLS pid_t t_tid; +static TLS char t_tname[16]; +static TLS uint64_t t_tname_ns; + +/* ---------------------------------------------------------------- reals */ +static int (*r_close)(int); +static int (*r___close)(int); +static int (*r_fclose)(FILE *); +static int (*r_dup)(int); +static int (*r_dup2)(int, int); +static int (*r_dup3)(int, int, int); +static void (*r_closefrom)(int); +static int (*r_close_range)(unsigned, unsigned, int); +static int (*r_open)(const char *, int, ...); +static int (*r_open64)(const char *, int, ...); +static int (*r_openat)(int, const char *, int, ...); +static int (*r_openat64)(int, const char *, int, ...); +static int (*r___open_2)(const char *, int); +static int (*r___open64_2)(const char *, int); +static int (*r___openat_2)(int, const char *, int); +static int (*r___openat64_2)(int, const char *, int); +static int (*r_creat)(const char *, mode_t); +static int (*r_creat64)(const char *, mode_t); +static FILE *(*r_fopen)(const char *, const char *); +static FILE *(*r_fopen64)(const char *, const char *); +static FILE *(*r_freopen)(const char *, const char *, FILE *); +static FILE *(*r_freopen64)(const char *, const char *, FILE *); +static FILE *(*r_fdopen)(int, const char *); +static int (*r_socket)(int, int, int); +static int (*r_accept)(int, __SOCKADDR_ARG, socklen_t *); +static int (*r_accept4)(int, __SOCKADDR_ARG, socklen_t *, int); +static int (*r_socketpair)(int, int, int, int *); +static int (*r_pipe)(int *); +static int (*r_pipe2)(int *, int); +static int (*r_eventfd)(unsigned, int); +static int (*r_epoll_create)(int); +static int (*r_epoll_create1)(int); +static int (*r_epoll_ctl)(int, int, int, struct epoll_event *); + +#define RESOLVE(n) do { if (!r_##n) r_##n = dlsym(RTLD_NEXT, #n); } while (0) +static void resolve_all(void) +{ + RESOLVE(close); RESOLVE(__close); RESOLVE(fclose); RESOLVE(dup); + RESOLVE(dup2); RESOLVE(dup3); RESOLVE(closefrom); RESOLVE(close_range); + RESOLVE(open); RESOLVE(open64); RESOLVE(openat); RESOLVE(openat64); + RESOLVE(__open_2); RESOLVE(__open64_2); RESOLVE(__openat_2); + RESOLVE(__openat64_2); RESOLVE(creat); RESOLVE(creat64); + RESOLVE(fopen); RESOLVE(fopen64); RESOLVE(freopen); RESOLVE(freopen64); + RESOLVE(fdopen); RESOLVE(socket); RESOLVE(accept); RESOLVE(accept4); + RESOLVE(socketpair); RESOLVE(pipe); RESOLVE(pipe2); RESOLVE(eventfd); + RESOLVE(epoll_create); RESOLVE(epoll_create1); RESOLVE(epoll_ctl); +} + +static int sys_close(int fd) { return (int)syscall(SYS_close, fd); } + +/* ---------------------------------------------------------------- time */ +static uint64_t mono_ns(void) +{ + struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} +static uint64_t real_ns(void) +{ + struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); + return (uint64_t)ts.tv_sec * 1000000000ull + (uint64_t)ts.tv_nsec; +} +static pid_t gettid_(void) { return (pid_t)syscall(SYS_gettid); } + +/* ---------------------------------------------------------------- output */ +struct lb { int fd; size_t n; char b[6144]; }; +static void lb_flush(struct lb *l) +{ + size_t off = 0; + while (off < l->n) { + ssize_t w = write(l->fd, l->b + off, l->n - off); + if (w < 0) { if (errno == EINTR) continue; break; } + off += (size_t)w; + } + l->n = 0; +} +static void lb_putn(struct lb *l, const char *s, size_t n) +{ + while (n) { + size_t room = sizeof(l->b) - l->n; + if (room == 0) { lb_flush(l); room = sizeof(l->b); } + size_t k = n < room ? n : room; + memcpy(l->b + l->n, s, k); l->n += k; s += k; n -= k; + } +} +static void lb_s(struct lb *l, const char *s) { if (!s) s = "(null)"; lb_putn(l, s, strlen(s)); } +static void lb_c(struct lb *l, char c) { lb_putn(l, &c, 1); } +static void lb_u(struct lb *l, unsigned long long v) +{ + char t[24]; int i = 24; + do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v); + lb_putn(l, t + i, (size_t)(24 - i)); +} +static void lb_d(struct lb *l, long long v) { if (v < 0) { lb_c(l, '-'); lb_u(l, (unsigned long long)(-v)); } else lb_u(l, (unsigned long long)v); } +static void lb_x(struct lb *l, unsigned long long v) +{ + static const char hx[] = "0123456789abcdef"; + char t[20]; int i = 20; + do { t[--i] = hx[v & 15]; v >>= 4; } while (v); + lb_putn(l, "0x", 2); lb_putn(l, t + i, (size_t)(20 - i)); +} +static void lb_u0(struct lb *l, unsigned v, int w) +{ + char t[12]; int i = 12; + do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v); + while (12 - i < w) t[--i] = '0'; + lb_putn(l, t + i, (size_t)(12 - i)); +} +/* UTC ISO-8601 without libc time functions (no locks, no TZ) */ +static void lb_ts(struct lb *l, uint64_t ns) +{ + if (!ns) { lb_s(l, "-"); return; } + int64_t s = (int64_t)(ns / 1000000000ull); + unsigned us = (unsigned)((ns % 1000000000ull) / 1000); + int64_t days = s / 86400, rem = s % 86400; + int64_t z = days + 719468, era = (z >= 0 ? z : z - 146096) / 146097; + unsigned doe = (unsigned)(z - era * 146097); + unsigned yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + int64_t y = (int64_t)yoe + era * 400; + unsigned doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + unsigned mp = (5 * doy + 2) / 153, d = doy - (153 * mp + 2) / 5 + 1; + unsigned m = mp < 10 ? mp + 3 : mp - 9; + if (m <= 2) y++; + lb_u0(l, (unsigned)y, 4); lb_c(l, '-'); lb_u0(l, m, 2); lb_c(l, '-'); lb_u0(l, d, 2); + lb_c(l, 'T'); lb_u0(l, (unsigned)(rem / 3600), 2); lb_c(l, ':'); + lb_u0(l, (unsigned)(rem / 60 % 60), 2); lb_c(l, ':'); lb_u0(l, (unsigned)(rem % 60), 2); + lb_c(l, '.'); lb_u0(l, us, 6); lb_c(l, 'Z'); +} +static void lb_init(struct lb *l) { l->fd = g.logfd >= 0 ? g.logfd : 2; l->n = 0; } + +/* ---------------------------------------------------------------- misc */ +static void get_tname_raw(char out[16]) +{ + memset(out, 0, 16); + if (prctl(PR_GET_NAME, out, 0, 0, 0) != 0) out[0] = '?'; + out[15] = 0; +} +/* thread name cached per thread, refreshed at most once a second (threads + * rename themselves early, e.g. RenameThread("hush-httpworker")) */ +static void get_tname(char out[16]) +{ + uint64_t now = mono_ns(); + if (!t_tname[0] || now - t_tname_ns > 1000000000ull) { get_tname_raw(t_tname); t_tname_ns = now; } + memcpy(out, t_tname, 16); +} +static pid_t my_tid(void) { if (!t_tid) t_tid = gettid_(); return t_tid; } +static void atfork_child(void) { t_tid = 0; t_tname[0] = 0; } + +/* readlink /proc/self/fd/N into buf; returns length or -1 */ +static int fd_target(int fd, char *buf, size_t sz) +{ + char p[40] = "/proc/self/fd/"; + size_t n = 14; + char t[16]; int i = 16; + unsigned v = (unsigned)fd; + do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v); + memcpy(p + n, t + i, (size_t)(16 - i)); n += (size_t)(16 - i); p[n] = 0; + ssize_t r = readlink(p, buf, sz - 1); + if (r < 0) { buf[0] = 0; return -1; } + buf[r] = 0; + return (int)r; +} + +static int match_list(const char *s, const char *const *pats, int n) +{ + for (int i = 0; i < n; i++) if (pats[i][0] && strstr(s, pats[i])) return 1; + return 0; +} + +static int classify(const char *t, int len) +{ + if (len < 0) return C_NONE; + if (t[0] == '/') { + if (match_list(t, g.dbp, g.ndbp)) return C_DB; + if (match_list(t, g.blkp, g.nblkp)) return C_BLK; + if (!strncmp(t, "/dev/", 5)) return C_DEV; + return C_REG; + } + if (!strncmp(t, "socket:", 7)) return C_SOCK; + if (!strncmp(t, "pipe:", 5)) return C_PIPE; + if (!strncmp(t, "anon_inode:", 11)) return C_ANON; + return C_OTHER; +} + +/* + * Identify what fd currently refers to. fstat() first (cheap); sockets and + * pipes get the kernel's "socket:[ino]"/"pipe:[ino]" name synthesized; for a + * regular file the open record's name is reused when (dev,ino) match, else the + * caller's absolute path hint, else readlink(/proc/self/fd/N) (~4us on WSL2). + * Returns the target length, or -1 if fd is not open. + */ +static int fd_target(int fd, char *buf, size_t sz); +static int fd_ident(int fd, const char *hint, const struct orec *rec, char *buf, size_t sz, + uint64_t *dev, uint64_t *ino, int *cls, int *rec_match) +{ + struct stat sb; + *rec_match = 0; + if (fd < 0 || fstat(fd, &sb) != 0) { buf[0] = 0; *cls = C_NONE; *dev = *ino = 0; return -1; } + *dev = (uint64_t)sb.st_dev; *ino = (uint64_t)sb.st_ino; + int fmt = sb.st_mode & S_IFMT; + if (fmt == S_IFSOCK || fmt == S_IFIFO) { + const char *pre = fmt == S_IFSOCK ? "socket:[" : "pipe:["; + size_t n = strlen(pre); memcpy(buf, pre, n); + char t[24]; int i = 24; uint64_t v = *ino; + do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v); + memcpy(buf + n, t + i, (size_t)(24 - i)); n += (size_t)(24 - i); + buf[n++] = ']'; buf[n] = 0; + *cls = fmt == S_IFSOCK ? C_SOCK : C_PIPE; + if (rec && rec->kind != K_NONE && rec->dev == *dev && rec->ino == *ino) *rec_match = 1; + return (int)n; + } + if ((fmt == S_IFREG || fmt == S_IFDIR) && rec && rec->kind != K_NONE && rec->dev == *dev && rec->ino == *ino) { + size_t n = strlen(rec->tgt); memcpy(buf, rec->tgt, n + 1); + *cls = rec->cls; *rec_match = 1; + return (int)n; + } + int n; + if ((fmt == S_IFREG || fmt == S_IFDIR) && hint && hint[0] == '/') { + size_t l = strlen(hint); if (l >= sz) l = sz - 1; + memcpy(buf, hint, l); buf[l] = 0; n = (int)l; + } else { + n = fd_target(fd, buf, sz); + if (n < 0) { *cls = C_NONE; return -1; } + /* anon inodes share one inode: require the name to match as well */ + if (rec && rec->kind != K_NONE && rec->dev == *dev && rec->ino == *ino) { + char stt[FDG_TGT]; + size_t l = (size_t)n; + if (l < FDG_TGT) { memcpy(stt, buf, l + 1); } else { stt[0] = 0; } + if (stt[0] && !strcmp(stt, rec->tgt)) *rec_match = 1; + } + } + *cls = classify(buf, n); + return n; +} + +/* store the tail of a long target so the file name survives */ +static void store_tgt(char dst[FDG_TGT], const char *src, int len) +{ + if (len < 0) { memcpy(dst, "", 9); return; } + if (len < FDG_TGT) { memcpy(dst, src, (size_t)len + 1); return; } + dst[0] = '.'; dst[1] = '.'; dst[2] = '.'; + memcpy(dst + 3, src + len - (FDG_TGT - 4), FDG_TGT - 4); + dst[FDG_TGT - 1] = 0; +} + +/* ---------------------------------------------------------------- symbols */ +static int in_exe(uintptr_t a) { return a >= g.exe_lo && a < g.exe_hi; } +static int in_shim(uintptr_t a) { return a >= g.shim_lo && a < g.shim_hi; } + +static const struct sym *sym_lookup(uintptr_t a) +{ + if (!g.nsyms || !in_exe(a)) return NULL; + uintptr_t v = a - g.exe_bias; + size_t lo = 0, hi = g.nsyms; + while (lo < hi) { size_t m = (lo + hi) / 2; if (g.syms[m].lo <= v) lo = m + 1; else hi = m; } + /* lo = first sym with lo > v; walk back over overlapping entries */ + for (size_t i = lo; i-- > 0 && lo - i < 8;) { + if (g.syms[i].lo <= v && v < g.syms[i].hi) return &g.syms[i]; + } + return NULL; +} +static const char *sym_name(const struct sym *s) { return g.strs + s->name; } + +/* compare function names ignoring a GCC clone suffix (.cold, .part.0, ...) */ +static int same_fn(const struct sym *a, const struct sym *b) +{ + if (a == b) return 1; + const char *x = sym_name(a), *y = sym_name(b); + while (*x && *x == *y && *x != '.') { x++; y++; } + return (*x == 0 || *x == '.') && (*y == 0 || *y == '.'); +} + +/* frames are return addresses: look up addr-1 so a call at the very end of a + * function (noreturn callee) is attributed to the caller's function */ +static const struct sym *frame_sym(void *f) { return sym_lookup((uintptr_t)f - 1); } + +static uint32_t stack_dom(void *const *fr, int n, int depth) +{ + uint32_t d = 0; + for (int i = 0, k = 0; i < n && k < depth; i++) { + if (!in_exe((uintptr_t)fr[i])) continue; + const struct sym *s = frame_sym(fr[i]); + if (s) d |= s->dom; + k++; + } + return d; +} + +/* "common caller": the immediate executable caller of one side (the function + * that called open() or close()) appears within the first `depth` executable + * frames of the other side -- e.g. open+close inside one function, or a close + * helper called from the function that opened the fd. A shared *ancestor* + * further up (main, a thread loop) deliberately does not count. */ +static int common_caller(void *const *a, int na, void *const *b, int nb, int depth, const char **which) +{ + const struct sym *sa[16], *sb[16]; int ka = 0, kb = 0; + if (depth > 16) depth = 16; + if (depth < 1) return 0; + for (int i = 0; i < na && ka < depth; i++) if (in_exe((uintptr_t)a[i])) { sa[ka++] = frame_sym(a[i]); } + for (int i = 0; i < nb && kb < depth; i++) if (in_exe((uintptr_t)b[i])) { sb[kb++] = frame_sym(b[i]); } + if (ka && sa[0]) for (int j = 0; j < kb; j++) if (sb[j] && same_fn(sa[0], sb[j])) { *which = sym_name(sa[0]); return 1; } + if (kb && sb[0]) for (int i = 0; i < ka; i++) if (sa[i] && same_fn(sb[0], sa[i])) { *which = sym_name(sb[0]); return 1; } + return 0; +} + +static int capture(void **out, int max) +{ + void *tmp[FDG_NFULL + 8]; + int n = backtrace(tmp, FDG_NFULL + 8); + int i = 0; + while (i < n && in_shim((uintptr_t)tmp[i])) i++; + int k = 0; + for (; i < n && k < max; i++) out[k++] = tmp[i]; + return k; +} + +static void lb_dom(struct lb *l, uint32_t d) +{ + if (!d) { lb_s(l, "-"); return; } + int first = 1; + for (int i = 0; i < g.ndom; i++) if (d & (1u << i)) { if (!first) lb_c(l, ','); lb_s(l, g.dom[i].name); first = 0; } +} + +static void lb_frame(struct lb *l, int i, void *f) +{ + uintptr_t a = (uintptr_t)f; + lb_s(l, " #"); lb_u(l, (unsigned)i); lb_c(l, ' '); lb_x(l, a); lb_c(l, ' '); + if (in_exe(a)) { + lb_s(l, g.exe_path); lb_c(l, '+'); lb_x(l, a - g.exe_bias); + const struct sym *s = frame_sym(f); + if (s) { lb_c(l, ' '); lb_s(l, sym_name(s)); lb_c(l, '+'); lb_x(l, a - g.exe_bias - s->lo); + if (s->dom) { lb_s(l, " ["); lb_dom(l, s->dom); lb_c(l, ']'); } } + } else { + Dl_info di; struct link_map *lm = NULL; + if (dladdr1(f, &di, (void **)&lm, RTLD_DL_LINKMAP) && di.dli_fname) { + lb_s(l, di.dli_fname[0] ? di.dli_fname : "?"); lb_c(l, '+'); + lb_x(l, a - (lm ? lm->l_addr : (uintptr_t)di.dli_fbase)); + if (di.dli_sname) { lb_c(l, ' '); lb_s(l, di.dli_sname); lb_c(l, '+'); lb_x(l, a - (uintptr_t)di.dli_saddr); } + } else lb_s(l, "?+0x0"); + } + lb_c(l, '\n'); +} +static void lb_stack(struct lb *l, void *const *fr, int n) +{ + for (int i = 0; i < n; i++) lb_frame(l, i, fr[i]); + if (n == 0) lb_s(l, " (no stack recorded)\n"); +} +static void lb_rec(struct lb *l, const char *title, const struct orec *r) +{ + lb_s(l, " "); lb_s(l, title); + if (r->kind == K_NONE) { lb_s(l, ": none\n"); return; } + lb_s(l, ": "); lb_s(l, kname[r->kind]); lb_s(l, " by tid="); lb_d(l, r->tid); + lb_s(l, " ("); lb_s(l, r->tname); lb_s(l, ") at "); lb_ts(l, r->ts); + lb_s(l, " class="); lb_s(l, cname[r->cls]); lb_s(l, " dom="); lb_dom(l, r->dom); + if (r->fp) { lb_s(l, " FILE="); lb_x(l, (uintptr_t)r->fp); } + if (r->kind >= K_CLOSE) { lb_s(l, " ret="); lb_d(l, r->ret); if (r->ret < 0) { lb_s(l, " errno="); lb_d(l, r->err); } } + lb_s(l, "\n target="); lb_s(l, r->tgt); lb_c(l, '\n'); + lb_stack(l, r->fr, r->nfr); +} + +/* ---------------------------------------------------------------- fd table */ +static void ent_lock(struct fdent *e) { while (atomic_exchange_explicit(&e->lk, 1, memory_order_acquire)) { /* spin */ } } +static void ent_unlock(struct fdent *e) { atomic_store_explicit(&e->lk, 0, memory_order_release); } + +static void fill_rec(struct orec *r, int kind, int cls, const char *tgt, int tlen, void *fp, + void *const *fr, int nfr, uint32_t dom) +{ + r->kind = (uint8_t)kind; r->cls = (uint8_t)cls; r->dom = dom; r->fp = fp; + r->tid = my_tid(); r->ts = real_ns(); r->ret = 0; r->err = 0; + get_tname(r->tname); + store_tgt(r->tgt, tgt, tlen); + if (nfr > FDG_NF) nfr = FDG_NF; + r->nfr = (uint16_t)nfr; + if (nfr) memcpy(r->fr, fr, sizeof(void *) * (size_t)nfr); +} + +static int want_stack_for_class(int cls) { return g.history || (g.guard_mask & (1 << cls)); } + +static void trace_line(const char *what, int kind, int fd, int ret, int err, int cls, const char *tgt, + void *const *fr, int nfr, void *caller) +{ + struct lb l; lb_init(&l); + char tn[16]; get_tname(tn); + lb_s(&l, "T "); lb_ts(&l, real_ns()); lb_s(&l, " tid="); lb_d(&l, my_tid()); + lb_c(&l, ' '); lb_s(&l, tn); lb_c(&l, ' '); lb_s(&l, what ? what : kname[kind]); + lb_s(&l, " fd="); lb_d(&l, fd); lb_s(&l, " ret="); lb_d(&l, ret); + if (ret < 0) { lb_s(&l, " errno="); lb_d(&l, err); } + lb_s(&l, " cls="); lb_s(&l, cname[cls]); lb_s(&l, " tgt="); lb_s(&l, tgt[0] ? tgt : "-"); + /* first exe frame = the code that made the call */ + void *by = caller; + for (int i = 0; i < nfr; i++) if (in_exe((uintptr_t)fr[i])) { by = fr[i]; break; } + lb_s(&l, " by="); + const struct sym *s = by ? frame_sym(by) : NULL; + if (s) { lb_s(&l, sym_name(s)); lb_c(&l, '+'); lb_x(&l, (uintptr_t)by - g.exe_bias - s->lo); } + else if (by) { + Dl_info di; + if (dladdr(by, &di) && di.dli_sname) lb_s(&l, di.dli_sname); else lb_x(&l, (uintptr_t)by); + } else lb_s(&l, "?"); + lb_c(&l, '\n'); + lb_flush(&l); +} + +static void record_open_h(int fd, int kind, void *fp, void *caller, const char *hint) +{ + if (fd < 0) return; + atomic_fetch_add_explicit(&st.n_open, 1, memory_order_relaxed); + char tgt[512]; uint64_t dev, ino; int cls, rm; + int tl = fd_ident(fd, hint, kind == K_FDOPEN && fd < FDG_NFD ? &g_fd[fd].open : NULL, tgt, sizeof tgt, &dev, &ino, &cls, &rm); + void *fr[FDG_NFULL]; int n = 0; + if (want_stack_for_class(cls) || g.trace_all) n = capture(fr, FDG_NFULL); + uint32_t dom = stack_dom(fr, n, g.depth); + if (fd < FDG_NFD) { + struct fdent *e = &g_fd[fd]; + ent_lock(e); + if (kind == K_FDOPEN && e->open.kind != K_NONE && e->open.dev == dev && e->open.ino == ino) { + e->open.fp = fp; /* ownership moves to the FILE; keep the opener's stack */ + } else { + fill_rec(&e->open, kind, cls, tgt, tl, fp, fr, n, dom); + e->open.dev = dev; e->open.ino = ino; + e->gen++; + } + ent_unlock(e); + } + if (g.trace_all) trace_line(NULL, kind, fd, fd, 0, cls, tgt, fr, n, caller); +} + +static void record_open(int fd, int kind, void *fp, void *caller) { record_open_h(fd, kind, fp, caller, NULL); } + +/* ---------------------------------------------------------------- reports */ +static int report_allowed(atomic_ulong *ctr) +{ + return atomic_fetch_add_explicit(ctr, 1, memory_order_relaxed) < (unsigned long)g.maxreports; +} + +static void report_header(struct lb *l, const char *type, unsigned long seq) +{ + char tn[16]; get_tname(tn); + lb_s(l, "\n=== FDGUARD #"); lb_u(l, seq); lb_c(l, ' '); lb_s(l, type); lb_c(l, ' '); + lb_ts(l, real_ns()); lb_s(l, " pid="); lb_d(l, getpid()); lb_s(l, " tid="); lb_d(l, my_tid()); + lb_s(l, " thread="); lb_s(l, tn); lb_c(l, '\n'); +} + +static void report_bsfd(struct lb *l, void *const *fr, int n) +{ + lb_s(l, " backtrace_symbols_fd:\n"); lb_flush(l); + backtrace_symbols_fd((void *const *)fr, n, l->fd); +} + +struct verdict { int owner; int rule; const char *why; const char *extra; }; + +static void report_close(const char *type, int kind, int fd, FILE *fp, const char *tgt, int cls, + const struct verdict *v, uint32_t cdom, void *const *fr, int n, + const struct orec *op, const struct orec *lc, const struct orec *lco, + int ret, int err) +{ + unsigned long seq = atomic_fetch_add(&st.seq, 1) + 1; + struct lb l; lb_init(&l); + pthread_mutex_lock(&g_replock); + report_header(&l, type, seq); + lb_s(&l, " call: "); lb_s(&l, kname[kind]); lb_s(&l, "(fd="); lb_d(&l, fd); + if (fp) { lb_s(&l, ", FILE="); lb_x(&l, (uintptr_t)fp); } + lb_s(&l, ")"); + if (ret != 0 || err) { lb_s(&l, " ret="); lb_d(&l, ret); lb_s(&l, " errno="); lb_d(&l, err); lb_s(&l, " ("); { const char *en = strerrorname_np(err); lb_s(&l, en ? en : "?"); } lb_c(&l, ')'); } + lb_s(&l, "\n target="); lb_s(&l, tgt[0] ? tgt : ""); lb_s(&l, " class="); lb_s(&l, cname[cls]); + lb_s(&l, "\n closer-domains="); lb_dom(&l, cdom); + if (v) { + lb_s(&l, "\n verdict="); lb_s(&l, v->owner > 0 ? "OWNER" : v->owner == 0 ? "NON-OWNER" : "UNKNOWN"); + lb_s(&l, " reason="); lb_s(&l, v->why); if (v->extra) { lb_c(&l, ' '); lb_s(&l, v->extra); } + if (g.deny && v->owner == 0 && (cls == C_DB || cls == C_BLK)) lb_s(&l, "\n ACTION=DENIED (close not performed, returned 0)"); + } + lb_s(&l, "\n closer stack:\n"); + lb_stack(&l, fr, n); + if (op) lb_rec(&l, "current open record of this fd", op); + if (lc) lb_rec(&l, "previous successful close of this fd number", lc); + if (lco && lco->kind != K_NONE) lb_rec(&l, " ...which had been opened by", lco); + report_bsfd(&l, fr, n); + lb_s(&l, "=== END #"); lb_u(&l, seq); lb_c(&l, '\n'); + lb_flush(&l); + pthread_mutex_unlock(&g_replock); +} + +static void maybe_stats(int force); + +/* ---------------------------------------------------------------- judging */ +static void judge(int kind, FILE *fp, int cls, const struct orec *op, int op_valid, + void *const *fr, int n, uint32_t cdom, struct verdict *v) +{ + v->owner = -1; v->rule = -1; v->why = "no-rule"; v->extra = NULL; + int via_stdio = (kind == K_FCLOSE || kind == K_FREOPEN); + if (op_valid && op->fp) { + if (via_stdio && (void *)fp == op->fp) { v->owner = 1; v->rule = 0; v->why = "stdio:same-FILE"; return; } + v->owner = 0; + v->why = via_stdio ? "stdio:STALE-FILE (this FILE* does not own the fd; another FILE does)" + : "stdio:RAW-CLOSE-UNDER-LIVE-FILE (fd belongs to a FILE*)"; + return; + } + if (op_valid && via_stdio) { v->owner = 0; v->why = "stdio:STALE-FILE (fd now belongs to a raw open/socket, not to this FILE*)"; return; } + if (!g.nsyms) { v->owner = -1; v->why = "unknown (no symbols: domain rules disabled)"; return; } + if (op_valid) { + if (op->dom & cdom) { v->owner = 1; v->rule = 1; v->why = "domain"; return; } + if (op->dom) { v->owner = 0; v->why = "DOMAIN-MISMATCH (opener has a domain the closer lacks)"; return; } + const char *w = NULL; + if (common_caller(op->fr, op->nfr, fr, n, g.ccdepth, &w)) { v->owner = 1; v->rule = 2; v->why = "common-caller"; v->extra = w; return; } + if (cls == C_DB || cls == C_BLK) { v->owner = 0; v->why = "NO-COMMON-CALLER (db/blk fd opened outside bdb/stdio; closer is unrelated code)"; return; } + if (cdom) { v->owner = 0; v->why = "FOREIGN-COMPONENT (a known component closes an fd created by other code)"; return; } + v->owner = -1; v->why = "unknown (neither side in a known domain)"; + return; + } + /* no valid open record */ + if (cls == C_DB) { + if (cdom & (1u << D_BDB)) { v->owner = 1; v->rule = 3; v->why = "fallback:bdb"; return; } + v->owner = 0; v->why = "fallback:DB-FILE-CLOSED-OUTSIDE-BDB (no open record)"; return; + } + if (cls == C_BLK) { + if (via_stdio && (cdom & (1u << D_BLK))) { v->owner = 1; v->rule = 4; v->why = "fallback:blk-fclose"; return; } + v->owner = 0; v->why = via_stdio ? "fallback:BLK-FCLOSE-FROM-NON-BLK-CODE (no open record)" + : "fallback:RAW-CLOSE-OF-BLOCK-FILE (no open record)"; + return; + } + v->owner = -1; v->why = "unknown (no open record)"; +} + +/* + * Common pre-close logic. Returns 1 if the real close must be skipped + * (deny mode). Fills *snap with the open record and history for later use. + */ +struct pre { + char tgt[512]; int tlen, cls; + void *fr[FDG_NFULL]; int n; + uint32_t cdom; + int have_ent; + struct orec op, lc, lco; int op_valid; + unsigned long gen; /* g_fd[fd].gen when the close was judged */ +}; + +static int pre_close(int kind, int fd, FILE *fp, struct pre *p) +{ + p->n = 0; p->cdom = 0; p->have_ent = 0; p->op_valid = 0; + p->op.kind = K_NONE; + if (fd >= 0 && fd < FDG_NFD) { + struct fdent *e = &g_fd[fd]; + ent_lock(e); + p->op = e->open; p->lc = e->lclose; p->lco = e->lclose_open; p->gen = e->gen; + ent_unlock(e); + p->have_ent = 1; + } + uint64_t dev, ino; + p->tlen = fd_ident(fd, NULL, p->have_ent ? &p->op : NULL, p->tgt, sizeof p->tgt, &dev, &ino, &p->cls, &p->op_valid); + int guarded = (g.guard_mask & (1 << p->cls)) != 0; + /* stack: needed for judging (unless the stdio rule settles it), for + * history, for EBADF reports and for trace lines */ + int need = g.history || g.trace_all || p->tlen < 0 || + (guarded && !(p->op_valid && p->op.fp && (kind == K_FCLOSE || kind == K_FREOPEN) && p->op.fp == (void *)fp)); + if (need) { p->n = capture(p->fr, FDG_NFULL); p->cdom = stack_dom(p->fr, p->n, g.depth); } + if (!guarded || p->tlen < 0) return 0; + + atomic_fetch_add_explicit(&st.n_guarded, 1, memory_order_relaxed); + struct verdict v; + judge(kind, fp, p->cls, &p->op, p->op_valid, p->fr, p->n, p->cdom, &v); + if (v.owner > 0) { + atomic_fetch_add_explicit(&st.n_owner, 1, memory_order_relaxed); + if (v.rule >= 0) atomic_fetch_add_explicit(&st.rule_cnt[v.rule], 1, memory_order_relaxed); + return 0; + } + if (v.owner < 0) { atomic_fetch_add_explicit(&st.n_unknown, 1, memory_order_relaxed); return 0; } + atomic_fetch_add_explicit(&st.n_hits, 1, memory_order_relaxed); + int deny = g.deny && (p->cls == C_DB || p->cls == C_BLK); + if (report_allowed(&st.rep_hit)) + report_close("NON-OWNER-CLOSE", kind, fd, fp, p->tgt, p->cls, &v, p->cdom, p->fr, p->n, + p->have_ent ? &p->op : NULL, p->have_ent ? &p->lc : NULL, p->have_ent ? &p->lco : NULL, 0, 0); + if (g.abort_mode >= 1) { maybe_stats(1); abort(); } + if (deny) { atomic_fetch_add_explicit(&st.n_denied, 1, memory_order_relaxed); return 1; } + return 0; +} + +static void post_close(int kind, int fd, FILE *fp, struct pre *p, int ret, int err) +{ + (void)fp; + if (ret == 0 || (ret < 0 && err != EBADF)) { + /* success (or EINTR/EIO: on Linux the fd is released anyway) */ + if (fd >= 0 && fd < FDG_NFD && p->tlen >= 0) { + struct fdent *e = &g_fd[fd]; + ent_lock(e); + /* What this close closed is the snapshot taken before it, not g_fd[fd].open now: once the real close + * returned, another thread may already have been handed the same number and recorded its open. Only + * forget the open record if nobody did (same generation), or that thread's later close of its own + * file would be judged with no open record. */ + if (p->op_valid) e->lclose_open = p->op; else e->lclose_open.kind = K_NONE; + fill_rec(&e->lclose, kind, p->cls, p->tgt, p->tlen, fp, p->fr, p->n, p->cdom); + e->lclose.dev = p->op_valid ? p->op.dev : 0; e->lclose.ino = p->op_valid ? p->op.ino : 0; + e->lclose.ret = ret; e->lclose.err = ret ? err : 0; + if (!p->have_ent || e->gen == p->gen) { + e->open.kind = K_NONE; e->open.fp = NULL; + e->gen++; + } + ent_unlock(e); + } + } else if (ret < 0 && err == EBADF) { + atomic_fetch_add_explicit(&st.n_ebadf, 1, memory_order_relaxed); + if (p->n == 0) { p->n = capture(p->fr, FDG_NFULL); p->cdom = stack_dom(p->fr, p->n, g.depth); } + if (report_allowed(&st.rep_ebadf)) { + struct orec lc, lco; int have = 0; + if (fd >= 0 && fd < FDG_NFD) { struct fdent *e = &g_fd[fd]; ent_lock(e); lc = e->lclose; lco = e->lclose_open; ent_unlock(e); have = 1; } + report_close("EBADF-CLOSE (second half of a double close?)", kind, fd, fp, p->tgt, p->cls, NULL, + p->cdom, p->fr, p->n, NULL, have ? &lc : NULL, have ? &lco : NULL, ret, err); + } + if (g.abort_mode >= 2) { maybe_stats(1); abort(); } + } + if (g.trace_all) trace_line(NULL, kind, fd, ret, err, p->cls, p->tgt, p->fr, p->n, NULL); +} + +/* ---------------------------------------------------------------- stats */ +static void maybe_stats(int force) +{ + if (g.logfd < 0) return; + uint64_t now = mono_ns(); + if (!force) { + if (!g.stats_sec) return; + unsigned long last = atomic_load_explicit(&st.last_stats, memory_order_relaxed); + if (now - last < (uint64_t)g.stats_sec * 1000000000ull) return; + if (!atomic_compare_exchange_strong(&st.last_stats, &last, now)) return; + } + struct lb l; lb_init(&l); + unsigned long nc = st.n_close, nf = st.n_fclose, no = st.n_open; + lb_s(&l, "S "); lb_ts(&l, real_ns()); lb_s(&l, force ? " final" : " periodic"); + lb_s(&l, " close="); lb_u(&l, nc); lb_s(&l, " fclose="); lb_u(&l, nf); + lb_s(&l, " dup2/3="); lb_u(&l, st.n_dup2); lb_s(&l, " range="); lb_u(&l, st.n_range); + lb_s(&l, " opens="); lb_u(&l, no); lb_s(&l, " guarded="); lb_u(&l, st.n_guarded); + lb_s(&l, " owner-ok="); lb_u(&l, st.n_owner); lb_s(&l, " unknown="); lb_u(&l, st.n_unknown); + lb_s(&l, " HITS="); lb_u(&l, st.n_hits); lb_s(&l, " EBADF="); lb_u(&l, st.n_ebadf); + lb_s(&l, " epollfail="); lb_u(&l, st.n_epollfail); lb_s(&l, " denied="); lb_u(&l, st.n_denied); + lb_s(&l, " close(-1)="); lb_u(&l, st.n_closeneg); + lb_s(&l, " rules["); + for (int i = 0; i < 5; i++) { if (i) lb_c(&l, ' '); lb_s(&l, rule_names[i]); lb_c(&l, '='); lb_u(&l, st.rule_cnt[i]); } + lb_s(&l, "] shim-ns/call close="); lb_u(&l, nc ? st.ns_close / nc : 0); + lb_s(&l, " fclose="); lb_u(&l, nf ? st.ns_fclose / nf : 0); + lb_s(&l, " open="); lb_u(&l, no ? st.ns_open / no : 0); + lb_s(&l, " shim-total-ms="); lb_u(&l, (st.ns_close + st.ns_fclose + st.ns_open) / 1000000ul); + lb_c(&l, '\n'); + lb_flush(&l); +} + +/* ---------------------------------------------------------------- close family */ +static int guarded_close(int kind, int fd, int (*realfn)(int)) +{ + if (!realfn) realfn = sys_close; + if (!g.active || t_in) return realfn(fd); + if (fd < 0) { atomic_fetch_add_explicit(&st.n_closeneg, 1, memory_order_relaxed); return realfn(fd); } + if (fd == g.logfd) { + /* someone closes the shim's own (high-numbered) log fd: a stale close */ + t_in = 1; + struct pre p; + p.n = capture(p.fr, FDG_NFULL); p.cdom = stack_dom(p.fr, p.n, g.depth); + struct verdict v = { 0, -1, "CLOSE-OF-FDGUARD-LOG-FD (refused)", NULL }; + atomic_fetch_add_explicit(&st.n_hits, 1, memory_order_relaxed); + if (report_allowed(&st.rep_hit)) + report_close("NON-OWNER-CLOSE", kind, fd, NULL, "fdguard log", C_OTHER, &v, p.cdom, p.fr, p.n, NULL, NULL, NULL, 0, 0); + t_in = 0; + return 0; + } + t_in = 1; + int saved = errno; + uint64_t t0 = mono_ns(); + atomic_fetch_add_explicit(&st.n_close, 1, memory_order_relaxed); + struct pre p; + int skip = pre_close(kind, fd, NULL, &p); + uint64_t t1 = mono_ns(); + int ret, err; + errno = saved; + if (skip) { ret = 0; err = 0; } + else { ret = realfn(fd); err = ret < 0 ? errno : 0; } + uint64_t t2 = mono_ns(); + post_close(kind, fd, NULL, &p, ret, err); + maybe_stats(0); + atomic_fetch_add_explicit(&st.ns_close, (t1 - t0) + (mono_ns() - t2), memory_order_relaxed); + t_in = 0; + errno = ret < 0 ? err : saved; + return ret; +} + +int close(int fd) { if (!r_close) RESOLVE(close); return guarded_close(K_CLOSE, fd, r_close); } +int __close(int fd) { if (!r___close) RESOLVE(__close); return guarded_close(K___CLOSE, fd, r___close ? r___close : r_close); } + +int fclose(FILE *fp) +{ + if (!r_fclose) RESOLVE(fclose); + if (!g.active || t_in || !fp) return r_fclose(fp); + int fd = fileno(fp); + if (fd < 0) return r_fclose(fp); + t_in = 1; + int saved = errno; + uint64_t t0 = mono_ns(); + atomic_fetch_add_explicit(&st.n_fclose, 1, memory_order_relaxed); + struct pre p; + int skip = pre_close(K_FCLOSE, fd, fp, &p); + uint64_t t1 = mono_ns(); + int ret, err; + errno = 0; + if (skip) { + /* deny: the FILE is stale and its fd number belongs to someone else. + * Leak the FILE (never touch the fd) and report success. */ + ret = 0; err = 0; + } else { ret = r_fclose(fp); err = ret != 0 ? errno : 0; } + uint64_t t2 = mono_ns(); + post_close(K_FCLOSE, fd, fp, &p, ret == 0 ? 0 : -1, err); + maybe_stats(0); + atomic_fetch_add_explicit(&st.ns_fclose, (t1 - t0) + (mono_ns() - t2), memory_order_relaxed); + t_in = 0; + errno = ret != 0 ? err : saved; + return ret; +} + +static int dup_common(int kind, int oldfd, int newfd, int flags) +{ + if (kind == K_DUP2) { if (!r_dup2) RESOLVE(dup2); } else { if (!r_dup3) RESOLVE(dup3); } + if (!g.active || t_in || oldfd == newfd || newfd < 0 || newfd == g.logfd) { + if (g.active && !t_in && newfd == g.logfd && newfd >= 0) { + /* would clobber our log: move the log first */ + int nl = fcntl(g.logfd, F_DUPFD_CLOEXEC, FDG_LOGFD_MIN + 50); + if (nl >= 0) { int old = g.logfd; g.logfd = nl; (void)old; } + } + return kind == K_DUP2 ? r_dup2(oldfd, newfd) : r_dup3(oldfd, newfd, flags); + } + t_in = 1; + atomic_fetch_add_explicit(&st.n_dup2, 1, memory_order_relaxed); + struct pre p; + int was_open = fcntl(newfd, F_GETFD) != -1; + int skip = 0; + if (was_open) skip = pre_close(kind, newfd, NULL, &p); + int ret, err; + if (skip) { ret = -1; err = EBADF; } + else { ret = kind == K_DUP2 ? r_dup2(oldfd, newfd) : r_dup3(oldfd, newfd, flags); err = ret < 0 ? errno : 0; } + if (ret >= 0) { + if (was_open) post_close(kind, newfd, NULL, &p, 0, 0); + record_open(newfd, K_DUPTO, NULL, __builtin_return_address(0)); + } + t_in = 0; + errno = err; + return ret; +} +int dup2(int oldfd, int newfd) { return dup_common(K_DUP2, oldfd, newfd, 0); } +int dup3(int oldfd, int newfd, int flags) { return dup_common(K_DUP3, oldfd, newfd, flags); } + +int dup(int oldfd) +{ + if (!r_dup) RESOLVE(dup); + int fd = r_dup(oldfd); + if (fd >= 0 && g.active && !t_in) { int e = errno; t_in = 1; record_open(fd, K_DUP, NULL, __builtin_return_address(0)); t_in = 0; errno = e; } + return fd; +} + +/* close_range / closefrom: judge every open fd in the range, then do it */ +static void range_scan(int kind, unsigned first, unsigned last) +{ + int dfd = (int)syscall(SYS_openat, AT_FDCWD, "/proc/self/fd", O_RDONLY | O_DIRECTORY | O_CLOEXEC); + if (dfd < 0) return; + char buf[4096]; + for (;;) { + long nr = syscall(SYS_getdents64, dfd, buf, sizeof buf); + if (nr <= 0) break; + for (long off = 0; off < nr;) { + struct { uint64_t ino; int64_t off; unsigned short reclen; unsigned char type; char name[]; } *d = (void *)(buf + off); + off += d->reclen; + if (d->name[0] < '0' || d->name[0] > '9') continue; + unsigned fd = 0; for (const char *c = d->name; *c; c++) fd = fd * 10 + (unsigned)(*c - '0'); + if ((int)fd == dfd || fd < first || fd > last || (int)fd == g.logfd) continue; + struct pre p; + (void)pre_close(kind, (int)fd, NULL, &p); /* report only; can't deny inside a range */ + post_close(kind, (int)fd, NULL, &p, 0, 0); + } + } + sys_close(dfd); +} +static int do_close_range(unsigned first, unsigned last, int flags) +{ + if (!r_close_range) RESOLVE(close_range); + if (r_close_range) return r_close_range(first, last, flags); + long r = syscall(436 /* SYS_close_range */, first, last, flags); + if (r < 0 && errno == ENOSYS && !(flags & 4 /* CLOSE_RANGE_CLOEXEC */)) { + for (unsigned f = first; f <= last && f < 65536; f++) sys_close((int)f); + return 0; + } + return (int)r; +} +/* keep the log fd alive across a range close */ +static int range_split(unsigned first, unsigned last, int flags) +{ + unsigned lf = g.logfd >= 0 ? (unsigned)g.logfd : ~0u; + if (lf == ~0u || lf < first || lf > last) return do_close_range(first, last, flags); + int r = 0; + if (lf > first) r |= do_close_range(first, lf - 1, flags); + if (lf < last) r |= do_close_range(lf + 1, last, flags); + return r; +} +int close_range(unsigned first, unsigned last, int flags) +{ + if (!g.active || t_in) return do_close_range(first, last, flags); + if (flags & 4 /* CLOSE_RANGE_CLOEXEC */) return do_close_range(first, last, flags); + t_in = 1; + atomic_fetch_add_explicit(&st.n_range, 1, memory_order_relaxed); + range_scan(K_CLOSERANGE, first, last); + int r = range_split(first, last, flags); + t_in = 0; + return r; +} +void closefrom(int lowfd) +{ + if (lowfd < 0) lowfd = 0; + if (!g.active || t_in) { if (!r_closefrom) RESOLVE(closefrom); if (r_closefrom) r_closefrom(lowfd); else do_close_range((unsigned)lowfd, ~0u, 0); return; } + t_in = 1; + atomic_fetch_add_explicit(&st.n_range, 1, memory_order_relaxed); + range_scan(K_CLOSEFROM, (unsigned)lowfd, ~0u); + range_split((unsigned)lowfd, ~0u, 0); + t_in = 0; +} + +FILE *freopen(const char *path, const char *mode, FILE *fp) +{ + if (!r_freopen) RESOLVE(freopen); + if (!g.active || t_in || !fp) return r_freopen(path, mode, fp); + int fd = fileno(fp); + t_in = 1; + struct pre p; int had = fd >= 0; + if (had) (void)pre_close(K_FREOPEN, fd, fp, &p); + t_in = 0; + FILE *r = r_freopen(path, mode, fp); + int e = errno; + t_in = 1; + if (had) post_close(K_FREOPEN, fd, fp, &p, 0, 0); + if (r) record_open(fileno(r), K_FOPEN, r, __builtin_return_address(0)); + t_in = 0; errno = e; + return r; +} +FILE *freopen64(const char *path, const char *mode, FILE *fp) +{ + if (!r_freopen64) RESOLVE(freopen64); + if (!g.active || t_in || !fp) return r_freopen64(path, mode, fp); + int fd = fileno(fp); + t_in = 1; + struct pre p; int had = fd >= 0; + if (had) (void)pre_close(K_FREOPEN, fd, fp, &p); + t_in = 0; + FILE *r = r_freopen64(path, mode, fp); + int e = errno; + t_in = 1; + if (had) post_close(K_FREOPEN, fd, fp, &p, 0, 0); + if (r) record_open(fileno(r), K_FOPEN, r, __builtin_return_address(0)); + t_in = 0; errno = e; + return r; +} + +/* ---------------------------------------------------------------- open family */ +#define OPEN_TAIL(fd, kind, fp) OPEN_TAILH(fd, kind, fp, NULL) +#define OPEN_TAILH(fd, kind, fp, hint) \ + do { \ + if ((fd) >= 0 && g.active && !t_in) { \ + int e_ = errno; t_in = 1; uint64_t t0_ = mono_ns(); \ + record_open_h((fd), (kind), (fp), __builtin_return_address(0), (hint)); \ + atomic_fetch_add_explicit(&st.ns_open, mono_ns() - t0_, memory_order_relaxed); \ + t_in = 0; errno = e_; \ + } \ + } while (0) + +static int needs_mode(int flags) { return (flags & O_CREAT) || ((flags & O_TMPFILE) == O_TMPFILE); } + +int open(const char *path, int flags, ...) +{ + mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); } + if (!r_open) RESOLVE(open); + int fd = r_open(path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd; +} +int open64(const char *path, int flags, ...) +{ + mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); } + if (!r_open64) RESOLVE(open64); + int fd = r_open64(path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd; +} +int openat(int dfd, const char *path, int flags, ...) +{ + mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); } + if (!r_openat) RESOLVE(openat); + int fd = r_openat(dfd, path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd; +} +int openat64(int dfd, const char *path, int flags, ...) +{ + mode_t m = 0; if (needs_mode(flags)) { va_list ap; va_start(ap, flags); m = (mode_t)va_arg(ap, int); va_end(ap); } + if (!r_openat64) RESOLVE(openat64); + int fd = r_openat64(dfd, path, flags, m); OPEN_TAILH(fd, K_OPEN, NULL, path); return fd; +} +int __open_2(const char *path, int flags) { if (!r___open_2) RESOLVE(__open_2); int fd = r___open_2(path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; } +int __open64_2(const char *path, int flags) { if (!r___open64_2) RESOLVE(__open64_2); int fd = r___open64_2(path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; } +int __openat_2(int d, const char *path, int flags) { if (!r___openat_2) RESOLVE(__openat_2); int fd = r___openat_2(d, path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; } +int __openat64_2(int d, const char *path, int flags) { if (!r___openat64_2) RESOLVE(__openat64_2); int fd = r___openat64_2(d, path, flags); OPEN_TAIL(fd, K_OPEN, NULL); return fd; } +int creat(const char *path, mode_t m) { if (!r_creat) RESOLVE(creat); int fd = r_creat(path, m); OPEN_TAIL(fd, K_OPEN, NULL); return fd; } +int creat64(const char *path, mode_t m) { if (!r_creat64) RESOLVE(creat64); int fd = r_creat64(path, m); OPEN_TAIL(fd, K_OPEN, NULL); return fd; } + +FILE *fopen(const char *path, const char *mode) +{ + if (!r_fopen) RESOLVE(fopen); + FILE *f = r_fopen(path, mode); if (f) { int fd = fileno(f); OPEN_TAILH(fd, K_FOPEN, f, path); } return f; +} +FILE *fopen64(const char *path, const char *mode) +{ + if (!r_fopen64) RESOLVE(fopen64); + FILE *f = r_fopen64(path, mode); if (f) { int fd = fileno(f); OPEN_TAILH(fd, K_FOPEN, f, path); } return f; +} +FILE *fdopen(int fd, const char *mode) +{ + if (!r_fdopen) RESOLVE(fdopen); + FILE *f = r_fdopen(fd, mode); if (f) OPEN_TAIL(fd, K_FDOPEN, f); return f; +} +int socket(int d, int t, int p) { if (!r_socket) RESOLVE(socket); int fd = r_socket(d, t, p); OPEN_TAIL(fd, K_SOCKET, NULL); return fd; } +int accept(int s, __SOCKADDR_ARG a, socklen_t *l) { if (!r_accept) RESOLVE(accept); int fd = r_accept(s, a, l); OPEN_TAIL(fd, K_ACCEPT, NULL); return fd; } +int accept4(int s, __SOCKADDR_ARG a, socklen_t *l, int fl) { if (!r_accept4) RESOLVE(accept4); int fd = r_accept4(s, a, l, fl); OPEN_TAIL(fd, K_ACCEPT, NULL); return fd; } +int socketpair(int d, int t, int p, int sv[2]) +{ + if (!r_socketpair) RESOLVE(socketpair); + int r = r_socketpair(d, t, p, sv); if (r == 0) { OPEN_TAIL(sv[0], K_SOCKPAIR, NULL); OPEN_TAIL(sv[1], K_SOCKPAIR, NULL); } return r; +} +int pipe(int fds[2]) { if (!r_pipe) RESOLVE(pipe); int r = r_pipe(fds); if (r == 0) { OPEN_TAIL(fds[0], K_PIPE, NULL); OPEN_TAIL(fds[1], K_PIPE, NULL); } return r; } +int pipe2(int fds[2], int fl) { if (!r_pipe2) RESOLVE(pipe2); int r = r_pipe2(fds, fl); if (r == 0) { OPEN_TAIL(fds[0], K_PIPE, NULL); OPEN_TAIL(fds[1], K_PIPE, NULL); } return r; } +int eventfd(unsigned v, int fl) { if (!r_eventfd) RESOLVE(eventfd); int fd = r_eventfd(v, fl); OPEN_TAIL(fd, K_EVENTFD, NULL); return fd; } +int epoll_create(int n) { if (!r_epoll_create) RESOLVE(epoll_create); int fd = r_epoll_create(n); OPEN_TAIL(fd, K_EPOLL, NULL); return fd; } +int epoll_create1(int fl) { if (!r_epoll_create1) RESOLVE(epoll_create1); int fd = r_epoll_create1(fl); OPEN_TAIL(fd, K_EPOLL, NULL); return fd; } + +/* libevent's "Epoll ADD(..) on fd N failed ... Bad file descriptor / + * Operation not permitted": report with the fd's history */ +int epoll_ctl(int epfd, int eop, int fd, struct epoll_event *ev) +{ + if (!r_epoll_ctl) RESOLVE(epoll_ctl); + int r = r_epoll_ctl(epfd, eop, fd, ev); + if (r < 0 && g.active && !t_in && g.epoll && (errno == EBADF || errno == EPERM)) { + int err = errno; + t_in = 1; + atomic_fetch_add_explicit(&st.n_epollfail, 1, memory_order_relaxed); + if (report_allowed(&st.rep_epoll)) { + struct pre p; + p.tlen = fd_target(fd, p.tgt, sizeof p.tgt); p.cls = classify(p.tgt, p.tlen); /* rare path: real name */ + p.n = capture(p.fr, FDG_NFULL); p.cdom = stack_dom(p.fr, p.n, g.depth); + struct orec op, lc, lco; int have = 0; + if (fd >= 0 && fd < FDG_NFD) { struct fdent *e = &g_fd[fd]; ent_lock(e); op = e->open; lc = e->lclose; lco = e->lclose_open; ent_unlock(e); have = 1; } + char ept[160]; int el = fd_target(epfd, ept, sizeof ept); + char ex[200]; size_t xl = 0; + memcpy(ex, "epfd=", 5); xl = 5; + { const char *q = el >= 0 ? ept : ""; size_t ql = strlen(q); if (ql > sizeof ex - xl - 1) ql = sizeof ex - xl - 1; memcpy(ex + xl, q, ql); xl += ql; } + ex[xl] = 0; + struct verdict v = { -1, -1, eop == EPOLL_CTL_ADD ? "epoll_ctl(ADD)-failed" : eop == EPOLL_CTL_MOD ? "epoll_ctl(MOD)-failed" : "epoll_ctl(DEL)-failed", ex }; + report_close(err == EPERM ? "EPOLL-CTL-EPERM (fd is not pollable: a regular file?)" : "EPOLL-CTL-EBADF", + K_NONE, fd, NULL, p.tgt, p.cls, &v, p.cdom, p.fr, p.n, + have ? &op : NULL, have ? &lc : NULL, have ? &lco : NULL, r, err); + } + if (g.abort_mode >= 2) { maybe_stats(1); abort(); } + t_in = 0; + errno = err; + } + return r; +} + +/* ---------------------------------------------------------------- init */ +static int env_int(const char *n, int def) { const char *v = getenv(n); return v && *v ? atoi(v) : def; } + +static int split_into(char *buf, const char *src, size_t bufsz, char sep, const char **out, int max) +{ + size_t n = strlen(src); if (n >= bufsz) n = bufsz - 1; + memcpy(buf, src, n); buf[n] = 0; + int k = 0; char *s = buf; + while (*s && k < max) { + char *e = strchr(s, sep); + if (e) *e = 0; + if (*s) out[k++] = s; + if (!e) break; + s = e + 1; + } + return k; +} + +static const char *pat_store(const char *s, size_t n) +{ + if (g.patlen + n + 1 > sizeof g.patbuf) return ""; + char *d = g.patbuf + g.patlen; memcpy(d, s, n); d[n] = 0; g.patlen += n + 1; return d; +} + +static int dom_find_or_add(const char *name, size_t n) +{ + for (int i = 0; i < g.ndom; i++) if (strlen(g.dom[i].name) == n && !strncmp(g.dom[i].name, name, n)) return i; + if (g.ndom >= FDG_MAXDOM) return -1; + struct domain *d = &g.dom[g.ndom]; + if (n > 15) n = 15; + memcpy(d->name, name, n); d->name[n] = 0; d->npat = 0; + return g.ndom++; +} +static void dom_add_pats(int di, const char *list) /* comma separated */ +{ + if (di < 0) return; + struct domain *d = &g.dom[di]; + const char *s = list; + while (*s) { + const char *e = strchr(s, ','); size_t n = e ? (size_t)(e - s) : strlen(s); + if (n && d->npat < 48) d->pat[d->npat++] = pat_store(s, n); + if (!e) break; + s = e + 1; + } +} + +static void init_domains(void) +{ + static const struct { const char *name, *pats; } def[D_NBUILTIN] = { + [D_BDB] = { "bdb", "^__os_,^__db,^__env_,^__memp_,^__log_,^__logc_,^__fop_,^__txn_,^__rep_,^__repmgr_," + "^__bam,^__ham,^__qam,^__heap,^__blob_,^__lock_,^__mutex_,^__crdel_,^__lv_,^__seq_," + "^__ram_,^__part,^__crypto_,^__envreg_,^__cdsgroup_,^db_create,^db_env_create," + "^_ZN2Db,^_ZN5DbEnv,^_ZN3Dbc,^_ZN5DbTxn" }, + [D_EVENT] = { "event", "^event_,^evhttp_,^evutil_,^bufferevent_,^be_,^evconnlistener_,^listener_," + "^evbuffer_,^evmap_,^evsig_,^epoll_,^evthread_,^evdns_,^evrpc_,^evtag_," + "^evwatch_,^evconn,^accepted_socket_,^server_port_" }, + [D_BLK] = { "blk", "CAutoFile,CBufferedFile,FlushBlockFile,FindBlockPos,FindUndoPos,OpenDiskFile," + "OpenBlockFile,OpenUndoFile,ReadBlockFromDisk,WriteBlockToDisk,UndoWriteToDisk," + "UndoReadFromDisk,LoadExternalBlockFile,ReadFromDisk,WriteToDisk" }, + [D_LEVELDB] = { "leveldb", "7leveldb" }, + [D_BOOSTFS] = { "boostfs", "5boost10filesystem" }, + [D_BOOSTIPC] = { "boostipc", "5boost12interprocess" }, + /* Rust (librustzcash + its crates + std); "$LT$"/".." only occur in Rust legacy-mangled names */ + [D_RUST] = { "rust", "^_ZN3std,^_ZN4core,std..,^_ZN5alloc,$LT$,rustzcash,librustzcash_,zcash_,bellman," + "sapling_crypto,pairing,zip32" }, + [D_P2P] = { "p2p", "CloseSocket,ConnectSocket,AcceptConnection,BindListenPort,4hush10TLSManager," + "ThreadSocketHandler,CreateSock,4Sock,5CNode,Socks5,InterruptibleRecv,StopNode," + "SetSocketNonBlocking,ThreadOpenConnections,OpenNetworkConnection,ConnectNode" }, + [D_CURL] = { "curl", "^Curl_,^curl_" }, + [D_WOLFSSL] = { "wolfssl", "^wolfSSL,^wc_,^wolfcrypt" }, + [D_TOR] = { "tor", "TorControl,tor_control" }, + [D_HTTP] = { "http", "HTTPRequest,HTTPWorkItem,http_request_cb,http_reject_request_cb,HTTPBindAddresses," + "ThreadHTTP,StartHTTPServer,StopHTTPServer,InterruptHTTPServer,HTTPEvent" }, + [D_DBWRAP] = { "dbwrapper", "10CDBWrapper" }, + }; + for (int i = 0; i < D_NBUILTIN; i++) { int di = dom_find_or_add(def[i].name, strlen(def[i].name)); dom_add_pats(di, def[i].pats); } + const char *x = getenv("FDGUARD_DOMAINS"); /* d=p,p;d2=p */ + if (x) { + const char *s = x; + while (*s) { + const char *semi = strchr(s, ';'); size_t n = semi ? (size_t)(semi - s) : strlen(s); + const char *eq = memchr(s, '=', n); + if (eq) { + int di = dom_find_or_add(s, (size_t)(eq - s)); + char tmp[1024]; size_t pl = n - (size_t)(eq - s) - 1; if (pl >= sizeof tmp) pl = sizeof tmp - 1; + memcpy(tmp, eq + 1, pl); tmp[pl] = 0; + dom_add_pats(di, tmp); + } + if (!semi) break; + s = semi + 1; + } + } +} + +static uint32_t name_dom(const char *nm) +{ + uint32_t d = 0; + for (int i = 0; i < g.ndom; i++) { + struct domain *dm = &g.dom[i]; + for (int j = 0; j < dm->npat; j++) { + const char *p = dm->pat[j]; + if (!p[0]) continue; + if (p[0] == '^' ? !strncmp(nm, p + 1, strlen(p + 1)) : strstr(nm, p) != NULL) { d |= 1u << i; break; } + } + } + return d; +} + +static int sym_cmp(const void *a, const void *b) +{ + const struct sym *x = a, *y = b; + return x->lo < y->lo ? -1 : x->lo > y->lo ? 1 : (x->hi > y->hi ? -1 : x->hi < y->hi); +} + +static void *anon_alloc(size_t n) +{ + void *p = mmap(NULL, n ? n : 1, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + return p == MAP_FAILED ? NULL : p; +} + +static void load_symbols(const char *path) +{ + int fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_RDONLY | O_CLOEXEC); + if (fd < 0) return; + struct stat sb; + if (fstat(fd, &sb) != 0 || sb.st_size < (off_t)sizeof(Elf64_Ehdr)) { sys_close(fd); return; } + unsigned char *m = mmap(NULL, (size_t)sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0); + sys_close(fd); + if (m == MAP_FAILED) return; + Elf64_Ehdr *eh = (Elf64_Ehdr *)m; + if (memcmp(eh->e_ident, ELFMAG, SELFMAG) || eh->e_ident[EI_CLASS] != ELFCLASS64 || !eh->e_shoff) goto out; + Elf64_Shdr *sh = (Elf64_Shdr *)(m + eh->e_shoff); + int symi = -1; + for (int i = 0; i < eh->e_shnum; i++) if (sh[i].sh_type == SHT_SYMTAB) { symi = i; break; } + if (symi < 0) for (int i = 0; i < eh->e_shnum; i++) if (sh[i].sh_type == SHT_DYNSYM) { symi = i; break; } + if (symi < 0) goto out; + { + Elf64_Sym *sy = (Elf64_Sym *)(m + sh[symi].sh_offset); + size_t ns = sh[symi].sh_size / sizeof(Elf64_Sym); + const char *str = (const char *)(m + sh[sh[symi].sh_link].sh_offset); + size_t cnt = 0, sbytes = 0; + for (size_t i = 0; i < ns; i++) { + int t = ELF64_ST_TYPE(sy[i].st_info); + if ((t == STT_FUNC || t == STT_GNU_IFUNC) && sy[i].st_size && sy[i].st_shndx != SHN_UNDEF) { cnt++; sbytes += strlen(str + sy[i].st_name) + 1; } + } + g.syms = anon_alloc(cnt * sizeof(struct sym)); + g.strs = anon_alloc(sbytes + 1); + if (!g.syms || !g.strs) goto out; + size_t k = 0, so = 0; + for (size_t i = 0; i < ns; i++) { + int t = ELF64_ST_TYPE(sy[i].st_info); + if (!((t == STT_FUNC || t == STT_GNU_IFUNC) && sy[i].st_size && sy[i].st_shndx != SHN_UNDEF)) continue; + const char *nm = str + sy[i].st_name; size_t l = strlen(nm); + memcpy(g.strs + so, nm, l + 1); + g.syms[k].lo = sy[i].st_value; g.syms[k].hi = sy[i].st_value + sy[i].st_size; + g.syms[k].name = (uint32_t)so; g.syms[k].dom = name_dom(nm); + for (int d = 0; d < g.ndom; d++) if (g.syms[k].dom & (1u << d)) g.dom[d].nsyms++; + so += l + 1; k++; + } + qsort(g.syms, k, sizeof(struct sym), sym_cmp); + g.nsyms = k; + } + /* build-id */ + for (int i = 0; i < eh->e_shnum; i++) { + if (sh[i].sh_type != SHT_NOTE) continue; + unsigned char *p = m + sh[i].sh_offset, *e = p + sh[i].sh_size; + while (p + 12 <= e) { + uint32_t nsz = *(uint32_t *)p, dsz = *(uint32_t *)(p + 4), typ = *(uint32_t *)(p + 8); + unsigned char *nm = p + 12, *desc = nm + ((nsz + 3) & ~3u); + if (typ == NT_GNU_BUILD_ID && nsz == 4 && !memcmp(nm, "GNU", 4) && dsz <= 31) { + static const char hx[] = "0123456789abcdef"; + for (uint32_t j = 0; j < dsz; j++) { g.build_id[2 * j] = hx[desc[j] >> 4]; g.build_id[2 * j + 1] = hx[desc[j] & 15]; } + g.build_id[2 * dsz] = 0; + } + p = desc + ((dsz + 3) & ~3u); + } + } +out: + munmap(m, (size_t)sb.st_size); +} + +static int phdr_cb(struct dl_phdr_info *info, size_t sz, void *data) +{ + (void)sz; + uintptr_t self_bias = (uintptr_t)data; + int is_exe = (info->dlpi_name == NULL || info->dlpi_name[0] == 0) && !g.exe_hi; + int is_shim = info->dlpi_addr == self_bias && info->dlpi_name && info->dlpi_name[0]; + if (!is_exe && !is_shim) return 0; + uintptr_t lo = ~(uintptr_t)0, hi = 0; + for (int i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) *ph = &info->dlpi_phdr[i]; + if (ph->p_type != PT_LOAD) continue; + uintptr_t a = info->dlpi_addr + ph->p_vaddr, b = a + ph->p_memsz; + if (a < lo) lo = a; + if (b > hi) hi = b; + } + if (is_exe) { + g.exe_bias = info->dlpi_addr; g.exe_lo = lo; g.exe_hi = hi; + for (int i = 0; i < info->dlpi_phnum; i++) { /* build-id of the running exe */ + const ElfW(Phdr) *ph = &info->dlpi_phdr[i]; + if (ph->p_type != PT_NOTE) continue; + unsigned char *p = (unsigned char *)(info->dlpi_addr + ph->p_vaddr), *e = p + ph->p_memsz; + while (p + 12 <= e) { + uint32_t nsz = *(uint32_t *)p, dsz = *(uint32_t *)(p + 4), typ = *(uint32_t *)(p + 8); + unsigned char *nm = p + 12, *desc = nm + ((nsz + 3) & ~3u); + if (typ == NT_GNU_BUILD_ID && nsz == 4 && !memcmp(nm, "GNU", 4) && dsz <= 31) { + static const char hx[] = "0123456789abcdef"; + for (uint32_t j = 0; j < dsz; j++) { g.exe_build_id[2 * j] = hx[desc[j] >> 4]; g.exe_build_id[2 * j + 1] = hx[desc[j] & 15]; } + g.exe_build_id[2 * dsz] = 0; + } + p = desc + ((dsz + 3) & ~3u); + } + } + } + else { g.shim_lo = lo; g.shim_hi = hi; } + return 0; +} + +static void open_log(void) +{ + const char *lp = getenv("FDGUARD_LOG"); + if (lp && !strcmp(lp, "stderr")) { g.logfd = fcntl(2, F_DUPFD_CLOEXEC, FDG_LOGFD_MIN); if (g.logfd < 0) g.logfd = 2; return; } + char path[600]; const char *src = lp && *lp ? lp : "/tmp/fdguard.%p.log"; + size_t o = 0; + for (const char *s = src; *s && o < sizeof path - 24; s++) { + if (s[0] == '%' && s[1] == 'p') { + char t[16]; int i = 16; unsigned v = (unsigned)getpid(); + do { t[--i] = (char)('0' + v % 10); v /= 10; } while (v); + memcpy(path + o, t + i, (size_t)(16 - i)); o += (size_t)(16 - i); s++; + } else path[o++] = *s; + } + path[o] = 0; + int fd = (int)syscall(SYS_openat, AT_FDCWD, path, O_WRONLY | O_CREAT | O_APPEND | O_CLOEXEC, 0600); + if (fd < 0) { g.logfd = -1; return; } + struct rlimit rl; int want = FDG_LOGFD_MIN; + if (getrlimit(RLIMIT_NOFILE, &rl) == 0 && rl.rlim_cur != RLIM_INFINITY && (rlim_t)want >= rl.rlim_cur) want = (int)rl.rlim_cur - 16; + if (want < 3) want = 3; + int hfd = fcntl(fd, F_DUPFD_CLOEXEC, want); + if (hfd >= 0) { sys_close(fd); fd = hfd; } + g.logfd = fd; +} + +static void self_marker(void) {} + +__attribute__((constructor)) static void fdg_init(void) +{ + t_in = 1; + resolve_all(); + g.pid = getpid(); + char exe[512]; ssize_t el = readlink("/proc/self/exe", exe, sizeof exe - 1); + if (el < 0) el = 0; + exe[el] = 0; + memcpy(g.exe_path, exe, (size_t)el + 1); + const char *base = strrchr(exe, '/'); base = base ? base + 1 : exe; + const char *want = getenv("FDGUARD_EXE"); if (!want || !*want) want = "dragonxd"; + int match = 0; + if (!strcmp(want, "*")) match = 1; + else { + char tmp[512]; const char *lst[32]; + int k = split_into(tmp, want, sizeof tmp, ',', lst, 32); + for (int i = 0; i < k; i++) if (!strcmp(lst[i], base)) match = 1; + } + if (!match) { t_in = 0; return; } /* passive in this process */ + + const char *gv = getenv("FDGUARD_GUARD"); if (!gv || !*gv) gv = "db,blk"; + { + char tmp[256]; const char *lst[16]; + int k = split_into(tmp, gv, sizeof tmp, ',', lst, 16); + for (int i = 0; i < k; i++) { + if (!strcmp(lst[i], "all")) g.guard_mask |= ~((1 << C_NONE) | (1 << C_OTHER)) & ((1 << C_MAX) - 1); + for (int c = 1; c < C_MAX; c++) if (!strcmp(lst[i], cname[c])) g.guard_mask |= 1 << c; + } + } + g.abort_mode = env_int("FDGUARD_ABORT", 0); + g.deny = env_int("FDGUARD_DENY", 0); + g.trace_all = env_int("FDGUARD_TRACE_ALL", 0); + g.history = env_int("FDGUARD_HISTORY", 1); + g.epoll = env_int("FDGUARD_EPOLL", 1); + g.depth = env_int("FDGUARD_DEPTH", 4); + g.ccdepth = env_int("FDGUARD_CCDEPTH", 3); + g.maxreports = env_int("FDGUARD_MAXREPORTS", 500); + g.stats_sec = env_int("FDGUARD_STATS_SEC", 60); + const char *dbp = getenv("FDGUARD_DB_PATHS"); if (!dbp) dbp = "/wallet.dat|/database/log.|/__db."; + const char *blkp = getenv("FDGUARD_BLK_PATHS"); if (!blkp) blkp = "/blocks/blk|/blocks/rev"; + g.ndbp = split_into(g.db_paths, dbp, sizeof g.db_paths, '|', g.dbp, 16); + g.nblkp = split_into(g.blk_paths, blkp, sizeof g.blk_paths, '|', g.blkp, 16); + + open_log(); + Dl_info di; struct link_map *lm = NULL; + uintptr_t self_bias = 0; + if (dladdr1((void *)&self_marker, &di, (void **)&lm, RTLD_DL_LINKMAP) && lm) self_bias = lm->l_addr; + dl_iterate_phdr(phdr_cb, (void *)self_bias); + init_domains(); + const char *sf = getenv("FDGUARD_SYMFILE"); + load_symbols(sf && *sf ? sf : "/proc/self/exe"); + if (g.nsyms && g.exe_build_id[0] && g.build_id[0] && strcmp(g.exe_build_id, g.build_id)) { + g.nsyms = 0; g.nosyms_reason = 1; /* wrong symbol file: names would be garbage */ + } + { void *w[4]; (void)backtrace(w, 4); } /* warm up: loads libgcc_s now */ + atomic_store(&st.last_stats, mono_ns()); + + struct lb l; lb_init(&l); + lb_s(&l, "# fdguard start "); lb_ts(&l, real_ns()); lb_s(&l, " pid="); lb_d(&l, g.pid); + lb_s(&l, "\n# exe="); lb_s(&l, g.exe_path); lb_s(&l, " build-id="); lb_s(&l, g.build_id[0] ? g.build_id : "?"); + lb_s(&l, " bias="); lb_x(&l, g.exe_bias); lb_s(&l, " text=["); lb_x(&l, g.exe_lo); lb_c(&l, ','); lb_x(&l, g.exe_hi); + lb_s(&l, ") syms="); lb_u(&l, g.nsyms); lb_s(&l, " symfile="); lb_s(&l, sf && *sf ? sf : "/proc/self/exe"); + lb_s(&l, "\n# guard="); for (int c = 1; c < C_MAX; c++) if (g.guard_mask & (1 << c)) { lb_s(&l, cname[c]); lb_c(&l, ' '); } + lb_s(&l, "abort="); lb_d(&l, g.abort_mode); lb_s(&l, " deny="); lb_d(&l, g.deny); lb_s(&l, " trace_all="); lb_d(&l, g.trace_all); + lb_s(&l, " history="); lb_d(&l, g.history); lb_s(&l, " depth="); lb_d(&l, g.depth); lb_s(&l, " ccdepth="); lb_d(&l, g.ccdepth); + lb_s(&l, " logfd="); lb_d(&l, g.logfd); + lb_s(&l, "\n# db-paths="); lb_s(&l, dbp); lb_s(&l, " blk-paths="); lb_s(&l, blkp); + lb_s(&l, "\n# domains:"); + for (int i = 0; i < g.ndom; i++) { lb_c(&l, ' '); lb_s(&l, g.dom[i].name); lb_c(&l, '='); lb_u(&l, g.dom[i].nsyms); } + lb_c(&l, '\n'); + lb_flush(&l); + if (g.nosyms_reason == 1) { + lb_s(&l, "# WARNING: FDGUARD_SYMFILE build-id "); lb_s(&l, g.build_id); lb_s(&l, " != running exe build-id "); + lb_s(&l, g.exe_build_id); lb_s(&l, "; symbols ignored\n"); + } + if (g.nsyms == 0) { + lb_s(&l, "# WARNING: no function symbols: domain rules disabled (verdict 'unknown'); stdio FILE* ownership,\n" + "# EBADF and epoll reports still work. Point FDGUARD_SYMFILE at an unstripped copy.\n"); + } + lb_flush(&l); + pthread_atfork(NULL, NULL, atfork_child); + g.inited = 1; + g.active = 1; + t_in = 0; +} + +__attribute__((destructor)) static void fdg_fini(void) +{ + if (!g.active) return; + t_in = 1; + maybe_stats(1); + t_in = 0; +} diff --git a/qa/fd-guard/shim/fdguard_test.c b/qa/fd-guard/shim/fdguard_test.c new file mode 100644 index 000000000..19b771cc2 --- /dev/null +++ b/qa/fd-guard/shim/fdguard_test.c @@ -0,0 +1,206 @@ +/* + * fdguard_test.c -- exercises libfdguard.so. + * + * The function names imitate the real owners inside dragonxd so the shim's + * built-in domain patterns apply unchanged: + * __os_openhandle / __os_closehandle Berkeley DB os layer (domain bdb) + * OpenDiskFile / FlushBlockFile block-file code (domain blk) + * ConnectSocketDirectly / CloseSocket P2P sockets (domain p2p) + * foreign_stale_close / foreign_* somebody who holds a stale number + * + * usage: fdguard_test (see main() for the list) + */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define NI __attribute__((noinline)) +static char D[512]; +static char *P(const char *rel) { static char b[4][700]; static int i; i = (i + 1) & 3; snprintf(b[i], sizeof b[i], "%s/%s", D, rel); return b[i]; } +static void say(const char *m) { fprintf(stderr, "[test] %s\n", m); } + +/* --- "Berkeley DB" --- */ +NI int __os_openhandle(const char *path) { return open(path, O_RDWR | O_CREAT, 0600); } +NI int __os_closehandle(int fd) { return close(fd); } +/* --- "block files" --- */ +NI FILE *OpenDiskFile(const char *path) { FILE *f = fopen(path, "rb+"); if (!f) f = fopen(path, "wb+"); return f; } +NI int FlushBlockFile(FILE *f) { fflush(f); return fclose(f); } +/* --- "P2P" --- */ +NI int ConnectSocketDirectly(void) { return socket(AF_INET, SOCK_STREAM, 0); } +NI int CloseSocket(int *s) { int r = close(*s); if (r) fprintf(stderr, "[test] Socket close failed: %d. Error: %s\n", *s, strerror(errno)); return r; } +/* --- foreigners --- */ +NI int foreign_stale_close(int fd) { return close(fd); } +NI int foreign_stale_fclose(FILE *f) { return fclose(f); } +NI int foreign_dup2(int fd) { int dn = open("/dev/null", O_RDWR); int r = dup2(dn, fd); close(dn); return r; } +NI int foreign_close_range(int fd) { return close_range((unsigned)fd, (unsigned)fd, 0); } +NI void foreign_closefrom(int fd) { closefrom(fd); } +/* --- plain code that opens and closes in the same function --- */ +NI int copy_like(const char *src) { int fd = open(src, O_RDONLY); char b[16]; if (fd >= 0) { (void)!read(fd, b, sizeof b); close(fd); } return fd; } +NI int plain_open(const char *p) { return open(p, O_RDWR | O_CREAT, 0600); } +NI int plain_close(int fd) { return close(fd); } + +static int fd_valid(int fd) { return fcntl(fd, F_GETFD) != -1; } + +static int c_double(void) +{ + int fd = plain_open(P("plain.txt")); + plain_close(fd); + int r = plain_close(fd); /* EBADF */ + return r == -1 && errno == EBADF ? 0 : 1; +} +static int c_dbowner(void) { int fd = __os_openhandle(P("regtest/wallet.dat")); int r = __os_closehandle(fd); return r; } +static int c_dbforeign(void) +{ + int fd = __os_openhandle(P("regtest/wallet.dat")); + foreign_stale_close(fd); /* HIT */ + int r = __os_closehandle(fd); /* BDB's own close then fails: EBADF (unless deny) */ + (void)r; + return 0; +} +static int c_deny(void) +{ + int fd = __os_openhandle(P("regtest/wallet.dat")); + foreign_stale_close(fd); /* HIT + DENIED */ + if (!fd_valid(fd)) { say("deny FAILED: fd closed"); return 1; } + if (write(fd, "x", 1) != 1) { say("deny FAILED: write"); return 1; } + return __os_closehandle(fd); +} +static int c_blkowner(void) { FILE *f = OpenDiskFile(P("regtest/blocks/blk00000.dat")); fputs("blk", f); return FlushBlockFile(f); } +static int c_blkraw(void) +{ + FILE *f = OpenDiskFile(P("regtest/blocks/blk00001.dat")); + foreign_stale_close(fileno(f)); /* HIT: raw close under a live FILE */ + FlushBlockFile(f); /* EBADF from fclose */ + return 0; +} +static int c_stalefile(void) +{ + FILE *f = OpenDiskFile(P("regtest/blocks/blk00002.dat")); + int n = fileno(f); + foreign_stale_close(n); /* HIT #1 */ + int w = __os_openhandle(P("regtest/wallet.dat")); + if (w != n) fprintf(stderr, "[test] note: wallet got fd %d, FILE had %d\n", w, n); + FlushBlockFile(f); /* HIT #2: fclose of a stale FILE whose number is now wallet.dat */ + if (w == n) __os_closehandle(w); /* EBADF: BDB lost its fd */ + return 0; +} +static int c_reuse(void) +{ + int s = ConnectSocketDirectly(); + int keep = s; + CloseSocket(&s); /* legit */ + int w = __os_openhandle(P("regtest/wallet.dat")); /* reuses the number */ + if (w != keep) fprintf(stderr, "[test] note: wallet got fd %d, socket was %d\n", w, keep); + s = keep; + CloseSocket(&s); /* HIT: stale socket number now = wallet.dat */ + __os_closehandle(w); /* EBADF */ + return 0; +} +static int c_dup2(void) { int w = __os_openhandle(P("regtest/wallet.dat")); foreign_dup2(w); __os_closehandle(w); return 0; } +static int c_range(void) +{ + int w = __os_openhandle(P("regtest/wallet.dat")); + foreign_close_range(w); /* HIT */ + w = __os_openhandle(P("regtest/database/log.0000000001")); + int hi = fcntl(w, F_DUPFD, 600); /* move it high so closefrom only hits it */ + close(w); + foreign_closefrom(hi); /* HIT (domain mismatch: opened by test main via fcntl dup? -> fallback db) */ + return 0; +} +static int c_owner_misc(void) +{ + /* nothing here may be flagged */ + copy_like(P("regtest/wallet.dat")); /* common-caller */ + int fd = __os_openhandle(P("regtest/blocks/blk00003.dat")); + FILE *f = fdopen(fd, "rb+"); /* ownership moves to FILE */ + FlushBlockFile(f); + int s = ConnectSocketDirectly(); CloseSocket(&s); + fd = (int)syscall(SYS_openat, AT_FDCWD, P("regtest/wallet.dat"), O_RDWR); /* untracked open */ + __os_closehandle(fd); /* fallback:bdb */ + return 0; +} +static int c_fallback_hit(void) +{ + int fd = (int)syscall(SYS_openat, AT_FDCWD, P("regtest/wallet.dat"), O_RDWR); /* untracked open */ + foreign_stale_close(fd); /* HIT via fallback rule */ + return 0; +} +static int c_epoll(void) +{ + int ep = epoll_create1(0); + int fd = plain_open(P("plain2.txt")); + struct epoll_event ev = { .events = EPOLLIN }; + int r = epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); /* EPERM: regular file */ + close(fd); + r = epoll_ctl(ep, EPOLL_CTL_ADD, fd, &ev); /* EBADF */ + close(ep); + return r == -1 ? 0 : 1; +} +static int c_selfclose(void) +{ + /* close every fd from 3 to 1100 by number, like a buggy daemonizer */ + for (int i = 3; i < 1100; i++) close(i); + return 0; +} + +/* concurrency + micro-benchmark: open/close churn in many threads */ +static int nthr = 8, iters = 20000; +static void *worker(void *arg) +{ + long id = (long)arg; + char p[700]; snprintf(p, sizeof p, "%s/thr%ld.txt", D, id); + for (int i = 0; i < iters; i++) { + int s = ConnectSocketDirectly(); CloseSocket(&s); + int fd = plain_open(p); plain_close(fd); + FILE *f = fopen(p, "r"); if (f) fclose(f); + if ((i & 255) == 0) { int w = __os_openhandle(P("regtest/wallet.dat")); __os_closehandle(w); } + } + return NULL; +} +static int c_threads(void) +{ + const char *e = getenv("T_ITERS"); if (e) iters = atoi(e); + pthread_t t[64]; + struct timespec a, b; clock_gettime(CLOCK_MONOTONIC, &a); + for (long i = 0; i < nthr; i++) pthread_create(&t[i], NULL, worker, (void *)i); + for (int i = 0; i < nthr; i++) pthread_join(t[i], NULL); + clock_gettime(CLOCK_MONOTONIC, &b); + double ns = (b.tv_sec - a.tv_sec) * 1e9 + (b.tv_nsec - a.tv_nsec); + long ops = (long)nthr * iters * 3; + printf("threads=%d iters=%d ops=%ld (socket/open/fopen + close each) wall=%.3fs %.0f ns/op-pair/thread\n", + nthr, iters, ops, ns / 1e9, ns * nthr / ops); + return 0; +} + +int main(int argc, char **argv) +{ + if (argc < 3) { fprintf(stderr, "usage: %s \n", argv[0]); return 2; } + snprintf(D, sizeof D, "%s", argv[1]); + const char *c = argv[2]; + if (!strcmp(c, "double")) return c_double(); + if (!strcmp(c, "dbowner")) return c_dbowner(); + if (!strcmp(c, "dbforeign")) return c_dbforeign(); + if (!strcmp(c, "deny")) return c_deny(); + if (!strcmp(c, "blkowner")) return c_blkowner(); + if (!strcmp(c, "blkraw")) return c_blkraw(); + if (!strcmp(c, "stalefile")) return c_stalefile(); + if (!strcmp(c, "reuse")) return c_reuse(); + if (!strcmp(c, "dup2")) return c_dup2(); + if (!strcmp(c, "range")) return c_range(); + if (!strcmp(c, "ownermisc")) return c_owner_misc(); + if (!strcmp(c, "fallback")) return c_fallback_hit(); + if (!strcmp(c, "epoll")) return c_epoll(); + if (!strcmp(c, "selfclose")) return c_selfclose(); + if (!strcmp(c, "threads")) return c_threads(); + fprintf(stderr, "unknown case %s\n", c); + return 2; +} diff --git a/qa/fd-guard/shim/run_tests.sh b/qa/fd-guard/shim/run_tests.sh new file mode 100755 index 000000000..b2ffc7d95 --- /dev/null +++ b/qa/fd-guard/shim/run_tests.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# Self-test for libfdguard.so. Every case lists the report types it must +# produce (exactly, in order) -- an empty list means "no report at all". +# ./run_tests.sh (builds first) +set -u +cd "$(dirname "$0")" +./build.sh >/dev/null || { echo "build failed"; exit 1; } +SO=$PWD/libfdguard.so +T=$PWD/t +rm -rf "$T"; mkdir -p "$T/regtest/blocks" "$T/regtest/database" +pass=0; fail=0 + +run() { # run [ENV=VAL ...] -> sets RC, LOG + local c=$1; shift + LOG=$T/$c${SUFFIX:-}.log; rm -f "$LOG" + env FDGUARD_EXE=fdguard_test FDGUARD_LOG="$LOG" "$@" LD_PRELOAD="$SO" ./fdguard_test "$T" "$c" >"$T/$c${SUFFIX:-}.out" 2>&1 + RC=$? +} +types() { grep '^=== FDGUARD' "$LOG" 2>/dev/null | awk '{print $4}' | tr '\n' ' ' | sed 's/ $//'; } +reasons() { grep -o 'reason=[^ ]*' "$LOG" 2>/dev/null | sed 's/reason=//' | tr '\n' ' ' | sed 's/ $//'; } +check() { # check + if [ "$3" == "$4" ]; then pass=$((pass+1)); printf 'PASS %-12s %s\n' "$1" "$2"; + else fail=$((fail+1)); printf 'FAIL %-12s %s\n got: [%s]\n want: [%s]\n' "$1" "$2" "$3" "$4"; fi +} + +# case | expected report types | expected reasons +while IFS='|' read -r c want wantr; do + [ -z "$c" ] && continue + run "$c" + check "$c" "reports" "$(types)" "$want" + check "$c" "reasons" "$(reasons)" "$wantr" + check "$c" "exit 0" "$RC" "0" +done <<'EOF' +double|EBADF-CLOSE| +dbowner|| +dbforeign|NON-OWNER-CLOSE EBADF-CLOSE|DOMAIN-MISMATCH +blkowner|| +blkraw|NON-OWNER-CLOSE EBADF-CLOSE|stdio:RAW-CLOSE-UNDER-LIVE-FILE +stalefile|NON-OWNER-CLOSE NON-OWNER-CLOSE EBADF-CLOSE|stdio:RAW-CLOSE-UNDER-LIVE-FILE stdio:STALE-FILE +reuse|NON-OWNER-CLOSE EBADF-CLOSE|DOMAIN-MISMATCH +dup2|NON-OWNER-CLOSE|DOMAIN-MISMATCH +range|NON-OWNER-CLOSE NON-OWNER-CLOSE NON-OWNER-CLOSE|DOMAIN-MISMATCH DOMAIN-MISMATCH fallback:DB-FILE-CLOSED-OUTSIDE-BDB +ownermisc|| +fallback|NON-OWNER-CLOSE|fallback:DB-FILE-CLOSED-OUTSIDE-BDB +epoll|EPOLL-CTL-EPERM EPOLL-CTL-EBADF|epoll_ctl(ADD)-failed epoll_ctl(ADD)-failed +EOF + +# the previous close of the reused number must be the first CloseSocket (proves the double close) +run reuse +check reuse "history shows 1st CloseSocket on a socket" \ + "$(grep -A2 'previous successful close' "$LOG" | head -3 | grep -c -e 'class=sock dom=p2p' -e 'CloseSocket+')" "2" +# the stale FILE report must name wallet.dat as the victim and OpenDiskFile as the FILE's opener +run stalefile +check stalefile "victim=wallet.dat, FILE opened in OpenDiskFile" \ + "$(sed -n '/#2 NON-OWNER/,/=== END #2/p' "$LOG" | grep -m1 -c 'target=.*wallet.dat class=db') $(sed -n '/#2 NON-OWNER/,/=== END #2/p' "$LOG" | grep -A3 'which had been opened by: fopen' | grep -c 'OpenDiskFile+')" "1 1" + +# the log fd survives a close-everything loop and the attempt is reported +run selfclose +check selfclose "log-fd close refused + reported" "$(grep -c 'CLOSE-OF-FDGUARD-LOG-FD' "$LOG")" "1" +check selfclose "log still writable afterwards (final stats)" "$(grep -c '^S .* final' "$LOG")" "1" + +# abort mode: first non-owner close -> SIGABRT (rc 134), report already written +SUFFIX=_abort run dbforeign FDGUARD_ABORT=1 +check abort "rc=134 (SIGABRT)" "$RC" "134" +check abort "report written before abort" "$(types)" "NON-OWNER-CLOSE" +# abort=2 also aborts on EBADF +SUFFIX=_abort2 run double FDGUARD_ABORT=2 +check abort2 "rc=134 on EBADF" "$RC" "134" + +# deny mode: wallet fd survives the foreign close; no EBADF afterwards +SUFFIX=_deny run deny FDGUARD_DENY=1 +check deny "exit 0 (fd still valid + writable)" "$RC" "0" +check deny "reported as DENIED, no EBADF" "$(types) $(grep -c 'ACTION=DENIED' "$LOG")" "NON-OWNER-CLOSE 1" + +# extra classes: guard=all with the owner cases must still be silent +SUFFIX=_all run ownermisc FDGUARD_GUARD=all +check guard-all "ownermisc silent with FDGUARD_GUARD=all" "$(types)" "" + +# trace-all writes one T line per open/close +SUFFIX=_trace run dbowner FDGUARD_TRACE_ALL=1 +check trace "T lines for open+close" "$(grep -c '^T ' "$LOG")" "2" + +# passive in non-matching processes +LOG=$T/passive.log; env FDGUARD_EXE=dragonxd FDGUARD_LOG="$LOG" LD_PRELOAD="$SO" ./fdguard_test "$T" dbforeign >/dev/null 2>&1 +check passive "no log when exe does not match" "$( [ -e "$LOG" ] && echo exists || echo none)" "none" + +# concurrency: 8 threads x 5000 x (socket, open, fopen) churn, guard=all -> no reports +SUFFIX=_thr run threads T_ITERS=5000 FDGUARD_GUARD=all +check threads "no reports under 8-thread churn (guard=all)" "$(types)" "" +check threads "stats: HITS=0 EBADF=0" "$(grep '^S .*final' "$LOG" | grep -o 'HITS=[0-9]* EBADF=[0-9]*')" "HITS=0 EBADF=0" + +# symbolizer resolves file:line for the test binary +check symbolize "addr2line resolved CloseSocket to fdguard_test.c" \ + "$(./symbolize.py "$T/reuse.log" | grep -m1 -o 'in CloseSocket at .*fdguard_test.c:[0-9]*' | sed 's/.*fdguard_test.c:[0-9]*/ok/')" "ok" + +echo "== $pass passed, $fail failed" +[ $fail -eq 0 ] diff --git a/qa/fd-guard/shim/symbolize.py b/qa/fd-guard/shim/symbolize.py new file mode 100755 index 000000000..2ee7522bb --- /dev/null +++ b/qa/fd-guard/shim/symbolize.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +symbolize.py -- turn fdguard log frames into function + file:line. + +Every stack frame in an fdguard report looks like + #3 0x5b96b433c991 /path/to/module+0x1991 c_dbforeign+0x2d [bdb] +where "+0x1991" is the ELF virtual address inside that module (runtime address +minus the module's load bias, so PIE executables and shared libraries need no +further adjustment; for a non-PIE executable it equals the absolute address). +Frames are return addresses, so we ask addr2line about vaddr-1 (the call +instruction), with -f -C -i (function, demangle, inline chain). + +backtrace_symbols_fd lines "module(+0x1991)[0x...]" are symbolized too. + +usage: + symbolize.py [options] LOG [LOG...] annotated log on stdout + symbolize.py --summary LOG one line per distinct report +options: + --map OLD=NEW read module OLD from path NEW instead (repeatable), + e.g. --map /path/to/deployed/dragonxd=./dragonxd.unstripped + --addr2line PATH addr2line binary (default: addr2line) + --only-reports drop T/S lines (trace + stats) from the output +""" +import os +import re +import subprocess +import sys +from collections import defaultdict, OrderedDict + +FRAME_RE = re.compile(r'^(\s*#\d+ 0x[0-9a-f]+ )(\S+)\+0x([0-9a-f]+)(.*)$') +BSFD_RE = re.compile(r'^(\S+)\(\+0x([0-9a-f]+)\)\[0x[0-9a-f]+\]\s*$') +HDR_RE = re.compile(r'^=== FDGUARD #(\d+) (\S+)') + + +def parse_args(argv): + opts = {'map': {}, 'addr2line': 'addr2line', 'summary': False, 'only': False, 'logs': []} + it = iter(argv) + for a in it: + if a == '--map': + old, new = next(it).split('=', 1) + opts['map'][old] = new + elif a.startswith('--map='): + old, new = a[6:].split('=', 1) + opts['map'][old] = new + elif a == '--addr2line': + opts['addr2line'] = next(it) + elif a == '--summary': + opts['summary'] = True + elif a == '--only-reports': + opts['only'] = True + elif a in ('-h', '--help'): + print(__doc__) + sys.exit(0) + else: + opts['logs'].append(a) + if not opts['logs']: + opts['logs'] = ['-'] + return opts + + +def read_lines(paths): + out = [] + for p in paths: + f = sys.stdin if p == '-' else open(p, errors='replace') + out.extend(f.read().splitlines()) + if f is not sys.stdin: + f.close() + return out + + +def resolve_module(mod, opts, known): + if mod in opts['map']: + return opts['map'][mod] + if os.path.exists(mod): + return mod + base = os.path.basename(mod) + for k in known: + if os.path.basename(k) == base and os.path.exists(opts['map'].get(k, k)): + return opts['map'].get(k, k) + return None + + +def run_addr2line(binary, addrs, a2l): + """addrs: list of int vaddrs (already -1'd). returns {addr: [(func, loc), ...]}""" + res = {} + if not addrs: + return res + uniq = sorted(set(addrs)) + try: + p = subprocess.run([a2l, '-a', '-f', '-C', '-i', '-e', binary] + ['0x%x' % a for a in uniq], + stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, check=False) + except FileNotFoundError: + return res + cur = None + lines = p.stdout.splitlines() + i = 0 + while i < len(lines): + ln = lines[i] + if re.match(r'^0x[0-9a-f]+$', ln): + cur = int(ln, 16) + res[cur] = [] + i += 1 + continue + func = ln + loc = lines[i + 1] if i + 1 < len(lines) else '??:0' + if cur is not None: + res[cur].append((func, loc)) + i += 2 + return res + + +def main(): + opts = parse_args(sys.argv[1:]) + lines = read_lines(opts['logs']) + + if opts['summary']: + summary(lines) + return + + # pass 1: collect modules + addresses + want = defaultdict(set) + known = OrderedDict() + for ln in lines: + m = FRAME_RE.match(ln) + if m: + known[m.group(2)] = 1 + want[m.group(2)].add(max(int(m.group(3), 16) - 1, 0)) + for ln in lines: + m = BSFD_RE.match(ln) + if m: + want[m.group(1)].add(max(int(m.group(2), 16) - 1, 0)) + + table = {} + for mod, addrs in want.items(): + path = resolve_module(mod, opts, known) + if not path: + continue + table[mod] = run_addr2line(path, list(addrs), opts['addr2line']) + + def fmt(mod, vaddr): + r = table.get(mod, {}).get(vaddr) + if not r: + return [] + out = [] + for j, (func, loc) in enumerate(r): + if func == '??' and loc.startswith('??'): + continue + # addr2line -i lists the innermost (inlined) frame first + tag = 'inlined:' if j < len(r) - 1 else 'in' + out.append('%s %s at %s' % (tag, func, loc)) + return out + + w = sys.stdout.write + for ln in lines: + if opts['only'] and (ln.startswith('T ') or ln.startswith('S ')): + continue + w(ln + '\n') + m = FRAME_RE.match(ln) + if m: + v = max(int(m.group(3), 16) - 1, 0) + for s in fmt(m.group(2), v): + w(' -> ' + s + '\n') + continue + m = BSFD_RE.match(ln) + if m: + v = max(int(m.group(2), 16) - 1, 0) + for s in fmt(m.group(1), v): + w(' -> ' + s + '\n') + + +def summary(lines): + """one line per (type, reason, class, first exe closer frame)""" + groups = OrderedDict() + cur = None + in_closer = False + for ln in lines: + h = HDR_RE.match(ln) + if h: + cur = {'type': h.group(2), 'reason': '', 'cls': '', 'top': '', 'call': ''} + in_closer = False + continue + if cur is None: + continue + s = ln.strip() + if s.startswith('call:'): + cur['call'] = s.split('(')[0][6:] + elif s.startswith('target=') and ' class=' in s and not cur['cls']: + cur['cls'] = s.rsplit('class=', 1)[-1] + elif s.startswith('verdict='): + cur['reason'] = s.split('reason=', 1)[-1].split(' ')[0] + elif s == 'closer stack:': + in_closer = True + elif in_closer and FRAME_RE.match(ln): + parts = s.split(' ') + if len(parts) >= 4 and not cur['top'] and '/libc.so' not in parts[2] and '/libstdc++' not in parts[2]: + cur['top'] = parts[3] + elif in_closer and not s.startswith('#'): + in_closer = False + if s.startswith('=== END'): + k = (cur['type'], cur['call'], cur['reason'], cur['cls'], cur['top']) + groups[k] = groups.get(k, 0) + 1 + cur = None + print('%6s %-22s %-12s %-6s %-40s %s' % ('count', 'type', 'call', 'class', 'reason', 'first closer frame')) + for (t, c, r, cl, top), n in groups.items(): + print('%6d %-22s %-12s %-6s %-40s %s' % (n, t[:22], c, cl, r[:40], top)) + stats = [ln for ln in lines if ln.startswith('S ')] + if stats: + print('\nlast stats line:\n ' + stats[-1]) + + +if __name__ == '__main__': + main() From b911189ac602ad209ead758dc1814f1ececc3d67 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:03:46 -0500 Subject: [PATCH 07/23] wallet: don't witness viewing-key-only notes (fixes an abort on the next block) A note found through an incoming viewing key only (z_importviewingkey) has no full viewing key behind it. UpdateSaplingNullifierNoteMapWithTx skips such notes, so they never get a nullifier. VerifyAndSetInitialWitness treated "no nullifier" as "witness needs reseeding" on every call: it cleared the note's witness, re-read the block from disk, reseeded it, and then dereferenced the nullifier that never came. dragonxd aborted in the txnotify thread (boost::optional "is_initialized" assert) on the first block confirming a note to such an address, and again after every restart, before the block index was flushed. Such a note can't be spent, so it needs no witness. Skip it there; if its spending key is imported later, it has a full viewing key and the existing no-nullifier rule seeds it then. SaplingWitnessMinimumHeight now takes the optional nullifier. A note without one is never extended by BuildWitnessCache (its pending-note gate requires one), so it must not lower the rebuild's start height. Every call site that worked before passes a set nullifier and behaves as before. The deref came in with the 2020 witness rework (6c48397cf). The new gtest ViewingKeyOnlyNoteIsNotWitnessed fails on the old code (the note reaches the reseed path). Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notedata.cpp | 117 +++++++++++++++++++++++++++++ src/wallet/wallet.cpp | 24 ++++-- src/wallet/wallet.h | 2 +- 3 files changed, 135 insertions(+), 8 deletions(-) diff --git a/src/gtest/test_wallet_notedata.cpp b/src/gtest/test_wallet_notedata.cpp index fddee8875..bdc2ad8bb 100644 --- a/src/gtest/test_wallet_notedata.cpp +++ b/src/gtest/test_wallet_notedata.cpp @@ -4,9 +4,18 @@ #include +#include + +#include "chain.h" +#include "coins.h" +#include "main.h" +#include "primitives/block.h" #include "primitives/transaction.h" +#include "random.h" #include "uint256.h" #include "wallet/wallet.h" +#include "zcash/Note.hpp" +#include "zcash/zip32.h" namespace { @@ -15,6 +24,9 @@ public: bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock = true) { return CWallet::UpdatedNoteData(wtxIn, wtx, fSameBlock); } + int VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessOnly) { + return CWallet::VerifyAndSetInitialWitness(pindex, witnessOnly); + } }; libzcash::SaplingIncomingViewingKey TestIvk() @@ -153,3 +165,108 @@ TEST(WalletNoteData, EmptyIncomingIsNoUpdate) { EXPECT_EQ(1u, wtx.mapSaplingNoteData.size()); EXPECT_EQ(400, wtx.mapSaplingNoteData.at(op0).witnessHeight); } + +namespace { + +// A real encrypted Sapling output to pa (the wallet never checks its proof or value commitment). +OutputDescription EncryptedOutput(const libzcash::SaplingPaymentAddress& pa, uint64_t value) +{ + libzcash::SaplingNote note(pa, value); + std::array memo; + memo.fill(0); + auto enc = libzcash::SaplingNotePlaintext(note, memo).encrypt(note.pk_d); + assert(static_cast(enc)); + OutputDescription od; + od.cv = GetRandHash(); + od.cm = note.cm().get(); + od.ephemeralKey = enc->second.get_epk(); + od.encCiphertext = enc->first; + return od; +} + +// A three-block active chain (heights 0-2) whose block 1 holds the given txs, undone on destruction: +// chainActive, mapBlockIndex and pcoinsTip are process-wide and every gtest suite shares the process. +struct FakeActiveChain { + std::deque blocks; + std::deque index; + CBlockIndex* savedTip; + CCoinsView coinsBase; + CCoinsViewCache coins{&coinsBase}; + CCoinsViewCache* savedCoinsTip; + + explicit FakeActiveChain(const std::vector& inBlock1) { + LOCK(cs_main); + savedTip = chainActive.Tip(); + savedCoinsTip = pcoinsTip; + pcoinsTip = &coins; + for (int h = 0; h <= 2; h++) { + blocks.emplace_back(); + CBlock& block = blocks.back(); + block.nTime = 1700000000 + h; + block.nNonce = uint256S(strprintf("%x", 0x700 + h)); + if (h > 0) + block.hashPrevBlock = blocks[h - 1].GetHash(); + if (h == 1) + block.vtx = inBlock1; + block.hashMerkleRoot = block.BuildMerkleTree(); + index.emplace_back(block); + CBlockIndex& bi = index.back(); + bi.SetHeight(h); + bi.pprev = h > 0 ? &index[h - 1] : nullptr; + bi.phashBlock = &mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)).first->first; + } + chainActive.SetTip(&index.back()); + } + ~FakeActiveChain() { + LOCK(cs_main); + chainActive.SetTip(savedTip); + for (const CBlock& block : blocks) + mapBlockIndex.erase(block.GetHash()); + pcoinsTip = savedCoinsTip; + } +}; + +} + +// A note found with an incoming viewing key only (z_importviewingkey) can't be spent and never gets +// a nullifier, because deriving one takes the full viewing key. VerifyAndSetInitialWitness must leave +// it alone. It used to clear it and reseed its witness from the block on disk on every call, then +// abort on the missing nullifier the first time the note was confirmed. Here there are no block +// files, so the old reseed path throws. +TEST(WalletNoteData, ViewingKeyOnlyNoteIsNotWitnessed) { + NoteDataWallet wallet; + std::vector> raw(32, 0x3c); + HDSeed seed(raw); + auto sk = libzcash::SaplingExtendedSpendingKey::Master(seed); + auto pa = sk.DefaultAddress(); + + LOCK2(cs_main, wallet.cs_wallet); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(sk.expsk.full_viewing_key().in_viewing_key(), pa)); + ASSERT_FALSE(wallet.HaveSaplingFullViewingKey(sk.expsk.full_viewing_key().in_viewing_key())); + + CMutableTransaction mtx; + mtx.fOverwintered = true; + mtx.nVersion = SAPLING_TX_VERSION; + mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID; + mtx.vShieldedOutput.push_back(EncryptedOutput(pa, 5000)); + CTransaction tx(mtx); + + CWalletTx wtx(&wallet, tx); + mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first; + ASSERT_EQ(1u, found.size()) << "the viewing key finds the note"; + wtx.SetSaplingNoteData(found); + ASSERT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); + + FakeActiveChain chain({tx}); + CWalletTx& stored = wallet.mapWallet.at(tx.GetHash()); + stored.SetMerkleBranch(chain.blocks[1]); + ASSERT_EQ(2, stored.GetDepthInMainChain()); + + int minHeight = -1; + ASSERT_NO_THROW(minHeight = wallet.VerifyAndSetInitialWitness(chainActive.Tip(), false)); + EXPECT_EQ(chainActive.Height(), minHeight) << "it must not pull the witness rebuild down to its height"; + const SaplingNoteData& nd = stored.mapSaplingNoteData.at(SaplingOutPoint(tx.GetHash(), 0)); + EXPECT_TRUE(nd.witnesses.empty()); + EXPECT_FALSE(static_cast(nd.nullifier)); + EXPECT_EQ(-1, nd.witnessHeight); +} diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 425bda60d..fe8a97ecf 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -1262,9 +1262,11 @@ void ClearSingleNoteWitnessCache(NoteData* nd) nd->witnessRootValidated = false; } -int CWallet::SaplingWitnessMinimumHeight(const uint256& nullifier, int nWitnessHeight, int nMinimumHeight) +// A note without a nullifier is never extended by BuildWitnessCache (its pending-note gate requires one), +// so it doesn't pull the rebuild's start height down. +int CWallet::SaplingWitnessMinimumHeight(const boost::optional& nullifier, int nWitnessHeight, int nMinimumHeight) { - if (GetSaplingSpendDepth(nullifier) <= WITNESS_CACHE_SIZE) { + if (nullifier && GetSaplingSpendDepth(*nullifier) <= WITNESS_CACHE_SIZE) { nMinimumHeight = min(nWitnessHeight, nMinimumHeight); } return nMinimumHeight; @@ -1296,6 +1298,14 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO uint256 blockRoot; uint256 witnessRoot; + //A note found with an incoming viewing key only (z_importviewingkey) can't be spent, so it needs + //no witness, and it never gets a nullifier: deriving one takes the full viewing key, which + //UpdateSaplingNullifierNoteMapWithTx skips without. Seeding it here meant clearing and reseeding + //it from disk on every call (the no-nullifier rule below), then aborting on the missing nullifier + //the first time it was confirmed. If its spending key is imported later, the rule below seeds it. + if (!HaveSaplingFullViewingKey(nd->ivk)) + continue; + if (!nd->nullifier) ::ClearSingleNoteWitnessCache(nd); @@ -1307,7 +1317,7 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO //Skip Validation when witness root has been validated if (nd->witnessRootValidated) { - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } @@ -1319,12 +1329,12 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO CBlockIndex* whIndex = chainActive[nd->witnessHeight]; if (whIndex == NULL) { //witnessHeight strictly above the active chain (transient catch-up): cannot validate yet - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } if (nd->witnesses.front().root() == whIndex->hashFinalSaplingRoot) { nd->witnessRootValidated = true; - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } //root mismatch on the active chain -> desynced; fall through to rebuild below @@ -1336,7 +1346,7 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO blockRoot = pblockindex->hashFinalSaplingRoot; if (witnessRoot == blockRoot) { nd->witnessRootValidated = true; - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); continue; } } @@ -1388,7 +1398,7 @@ int CWallet::VerifyAndSetInitialWitness(const CBlockIndex* pindex, bool witnessO } nd->witnessHeight = pblockindex->GetHeight(); UpdateSaplingNullifierNoteMapWithTx(wtxItem.second); - nMinimumHeight = SaplingWitnessMinimumHeight(*item.second.nullifier, nd->witnessHeight, nMinimumHeight); + nMinimumHeight = SaplingWitnessMinimumHeight(item.second.nullifier, nd->witnessHeight, nMinimumHeight); } } } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index ebcb64bb1..bd3421b8f 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -895,7 +895,7 @@ public: protected: - int SaplingWitnessMinimumHeight(const uint256& nullifier, int nWitnessHeight, int nMinimumHeight); + int SaplingWitnessMinimumHeight(const boost::optional& nullifier, int nWitnessHeight, int nMinimumHeight); /** * pindex is the new tip being connected. From dc8ca4996ad1c25225636dfda37ee164a4708a93 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 14:33:19 -0500 Subject: [PATCH 08/23] gtest: pin GetFilteredNotes behaviour before optimizing it Adds test_wallet_notecache.cpp: a wallet with Sapling notes at several depths (spent by a mined tx, spent by an unmined tx, locked, viewing-key only, never mined, plus txs carrying no wallet notes), mined into a fake active chain. The fixture undoes everything it puts into the process-wide chain state (chainActive, mapBlockIndex, pcoinsTip) in TearDown, since all gtest suites share one process. LegacyFilteredNotes is the 69bb035e7 loop verbatim, with tx_height() passed in so the minDepth > 1 branch can be modelled on a -txindex node. FilteredNotesMatchLegacy compares the real walk against it entry by entry and in order (z_sendmany sorts the result with an unstable sort) over minDepth {0,1} x maxDepth x ignoreSpent x requireSpendingKey x ignoreLocked x address filters. FixtureExercisesEveryFilter checks that each filter really removes something, so the comparison can't pass vacuously. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_wallet_notecache.cpp | 364 ++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/gtest/test_wallet_notecache.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 0d474b5c4..62877b5f9 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -18,7 +18,8 @@ hush_gtest_SOURCES = \ gtest/test_wallet_notedata.cpp \ gtest/test_net_closesocket.cpp \ gtest/test_fd_defense.cpp \ - gtest/test_wallet_dbenv.cpp + gtest/test_wallet_dbenv.cpp \ + gtest/test_wallet_notecache.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp new file mode 100644 index 000000000..c0006dfc8 --- /dev/null +++ b/src/gtest/test_wallet_notecache.cpp @@ -0,0 +1,364 @@ +// 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 + +#include + +#include +#include + +#include "chain.h" +#include "coins.h" +#include "main.h" +#include "primitives/block.h" +#include "primitives/transaction.h" +#include "random.h" +#include "wallet/wallet.h" +#include "zcash/Address.hpp" +#include "zcash/Note.hpp" +#include "zcash/NoteEncryption.hpp" +#include "zcash/zip32.h" + +int32_t hush_dpowconfs(int32_t height, int32_t numconfs); + +using namespace libzcash; + +namespace { + +typedef std::function TxHeightFn; + +// CWallet::GetFilteredNotes as of 69bb035e7, verbatim except that tx_height() is passed in. This is +// the reference the optimized walk must reproduce entry for entry and in the same order (z_sendmany +// sorts the result with an unstable sort, so the order is observable). +void LegacyFilteredNotes( + CWallet& wallet, + std::vector& saplingEntries, + std::set& filterAddresses, + int minDepth, + int maxDepth, + bool ignoreSpent, + bool requireSpendingKey, + bool ignoreLocked, + const TxHeightFn& tx_height) +{ + LOCK2(cs_main, wallet.cs_wallet); + + for (auto & p : wallet.mapWallet) { + CWalletTx wtx = p.second; + + // Filter the transactions before checking for notes + if (!CheckFinalTx(wtx) || wtx.GetBlocksToMaturity() > 0) + continue; + + if (minDepth > 1) { + int nHeight = tx_height(wtx.GetHash()); + if ( nHeight == 0 ) { + continue; + } + int nDepth = wtx.GetDepthInMainChain(); + int dpowconfs = hush_dpowconfs(nHeight,nDepth); + if ( dpowconfs < minDepth || dpowconfs > maxDepth) { + continue; + } + } else { + if ( wtx.GetDepthInMainChain() < minDepth || + wtx.GetDepthInMainChain() > maxDepth) { + continue; + } + } + + for (auto & pair : wtx.mapSaplingNoteData) { + SaplingOutPoint op = pair.first; + SaplingNoteData nd = pair.second; + + auto maybe_pt = SaplingNotePlaintext::decrypt( + wtx.vShieldedOutput[op.n].encCiphertext, + nd.ivk, + wtx.vShieldedOutput[op.n].ephemeralKey, + wtx.vShieldedOutput[op.n].cm); + assert(static_cast(maybe_pt)); + auto notePt = maybe_pt.get(); + + auto maybe_pa = nd.ivk.address(notePt.d); + assert(static_cast(maybe_pa)); + auto pa = maybe_pa.get(); + + // skip notes which belong to a different payment address in the wallet + if (!(filterAddresses.empty() || filterAddresses.count(pa))) { + continue; + } + + if (ignoreSpent && nd.nullifier && wallet.IsSaplingSpent(*nd.nullifier)) { + continue; + } + + // skip notes which cannot be spent + if (requireSpendingKey) { + libzcash::SaplingIncomingViewingKey ivk; + libzcash::SaplingFullViewingKey fvk; + if (!(wallet.GetSaplingIncomingViewingKey(pa, ivk) && + wallet.GetSaplingFullViewingKey(ivk, fvk) && + wallet.HaveSaplingSpendingKey(fvk))) { + continue; + } + } + + // skip locked notes + if (ignoreLocked && wallet.IsLockedNote(op)) { + continue; + } + + auto note = notePt.note(nd.ivk).get(); + saplingEntries.push_back(SaplingNoteEntry { + op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() }); + } + } +} + +std::array MemoFor(uint64_t value) +{ + std::array memo; + memo.fill(0); + std::string s = "memo-" + std::to_string(value); + memcpy(memo.data(), s.data(), s.size()); + return memo; +} + +// A real encrypted Sapling output to pa. Its proof and value commitment are never checked by the +// wallet, so they are left empty / random. +OutputDescription MakeOutput(const SaplingPaymentAddress& pa, uint64_t value) +{ + SaplingNote note(pa, value); + SaplingNotePlaintext pt(note, MemoFor(value)); + auto enc = pt.encrypt(note.pk_d); + assert(static_cast(enc)); + OutputDescription od; + od.cv = GetRandHash(); + od.cm = note.cm().get(); + od.ephemeralKey = enc->second.get_epk(); + od.encCiphertext = enc->first; + return od; +} + +CTransaction MakeTx(const std::vector& outputs, const std::vector& spentNullifiers = {}) +{ + CMutableTransaction mtx; + mtx.fOverwintered = true; + mtx.nVersion = SAPLING_TX_VERSION; + mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID; + mtx.vShieldedOutput = outputs; + for (const uint256& nf : spentNullifiers) { + SpendDescription sd; + sd.nullifier = nf; + mtx.vShieldedSpend.push_back(sd); + } + return CTransaction(mtx); +} + +SaplingExtendedSpendingKey TestKey(uint32_t account) +{ + std::vector> raw(32, 0x5a); + HDSeed seed(raw); + return SaplingExtendedSpendingKey::Master(seed).Derive(account | ZIP32_HARDENED_KEY_LIMIT); +} + +std::string Describe(int minDepth, int maxDepth, bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked, const std::string& filter) +{ + return strprintf("minDepth=%d maxDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d filter=%s", + minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter); +} + +void ExpectSameEntries(const std::vector& want, const std::vector& got, const std::string& ctx) +{ + ASSERT_EQ(want.size(), got.size()) << ctx; + for (size_t i = 0; i < want.size(); i++) { + EXPECT_TRUE(want[i].op == got[i].op) << ctx << " entry " << i; + EXPECT_TRUE(want[i].address == got[i].address) << ctx << " entry " << i; + EXPECT_TRUE(want[i].note.d == got[i].note.d) << ctx << " entry " << i; + EXPECT_EQ(want[i].note.pk_d, got[i].note.pk_d) << ctx << " entry " << i; + EXPECT_EQ(want[i].note.r, got[i].note.r) << ctx << " entry " << i; + EXPECT_EQ(want[i].note.value(), got[i].note.value()) << ctx << " entry " << i; + EXPECT_TRUE(want[i].memo == got[i].memo) << ctx << " entry " << i; + EXPECT_EQ(want[i].confirmations, got[i].confirmations) << ctx << " entry " << i; + } +} + +} + +// A wallet with notes at several depths, spent and unspent, locked and unlocked, spendable and +// viewing-key-only, plus txs that carry no wallet notes; mined into a fake active chain. Everything it +// touches in the process-wide chain state (chainActive, mapBlockIndex, pcoinsTip) is undone in +// TearDown, because every gtest suite runs in the same process. +class WalletNoteCache : public ::testing::Test { +protected: + static const int TIP_HEIGHT = 30; + + CWallet wallet; + SaplingPaymentAddress paA, paB, paC; // A and C spendable, B viewing key only + std::map depthOf; // txid -> depth to mine it at (absent: never mined) + std::vector txids; + SaplingOutPoint lockedOp; + + CCoinsView coinsBase; + CCoinsViewCache coins{&coinsBase}; + CCoinsViewCache* savedCoinsTip = nullptr; + std::deque blocks; + std::deque index; + std::map minedHeight; // txid -> height of the block holding it + + void SetUp() override { + // tx_height()/GetTransaction dereference pcoinsTip; an empty view answers "not found". + savedCoinsTip = pcoinsTip; + pcoinsTip = &coins; + + auto skA = TestKey(0), skB = TestKey(1), skC = TestKey(2); + paA = skA.DefaultAddress(); + paB = skB.DefaultAddress(); + paC = skC.DefaultAddress(); + ASSERT_TRUE(wallet.AddSaplingZKey(skA, paA)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), paB)); + ASSERT_TRUE(wallet.AddSaplingZKey(skC, paC)); + SaplingPaymentAddress foreign = TestKey(9).DefaultAddress(); // not in the wallet + + LOCK2(cs_main, wallet.cs_wallet); + AddTx(MakeTx({MakeOutput(paA, 1000), MakeOutput(paB, 2000)}), 1); + uint256 tx2 = AddTx(MakeTx({MakeOutput(paA, 3000)}), 5); + AddTx(MakeTx({MakeOutput(paA, 4000), MakeOutput(paC, 5000), MakeOutput(paB, 6000)}), 11); + AddTx(MakeTx({MakeOutput(paC, 7000)}), 21); + AddTx(MakeTx({MakeOutput(paA, 8000)}), -1); // never mined: depth -1 + AddTx(MakeTx({MakeOutput(foreign, 50)}), 3); // no wallet notes at all + + // A note spent by a mined tx, and one whose spending tx never confirmed. + const uint256 nfSpent = uint256S("5a1d"), nfPending = uint256S("9e9d"); + uint256 tx6 = AddTx(MakeTx({MakeOutput(paA, 9000)}), 12); + uint256 tx7 = AddTx(MakeTx({MakeOutput(paC, 10000)}), 3); + wallet.mapWallet.at(tx6).mapSaplingNoteData.at(SaplingOutPoint(tx6, 0)).nullifier = nfSpent; + wallet.mapWallet.at(tx7).mapSaplingNoteData.at(SaplingOutPoint(tx7, 0)).nullifier = nfPending; + AddTx(MakeTx({}, {nfSpent}), 2); + AddTx(MakeTx({}, {nfPending}), -1); + + lockedOp = SaplingOutPoint(tx2, 0); + wallet.LockNote(lockedOp); + + Mine(); + } + + void TearDown() override { + LOCK(cs_main); + chainActive.SetTip(nullptr); + for (const CBlockIndex& bi : index) { + const uint256 hash = bi.GetBlockHash(); + mapBlockIndex.erase(hash); + } + pcoinsTip = savedCoinsTip; + } + + // Adds tx the way LoadWallet does (no wallet.dat), with the note data FindMySaplingNotes finds. + uint256 AddTx(const CTransaction& tx, int depth) { + CWalletTx wtx(&wallet, tx); + mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first; + wtx.SetSaplingNoteData(found); + EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); + if (depth > 0) + depthOf[tx.GetHash()] = depth; + txids.push_back(tx.GetHash()); + return tx.GetHash(); + } + + // Builds an active chain 0..TIP_HEIGHT and puts each wallet tx into the block at its depth. + void Mine() { + for (int h = 0; h <= TIP_HEIGHT; h++) { + blocks.emplace_back(); + CBlock& block = blocks.back(); + block.nTime = 1700000000 + h; + block.nNonce = uint256S(strprintf("%x", h + 1)); + if (h > 0) + block.hashPrevBlock = blocks[h - 1].GetHash(); + for (const auto& d : depthOf) { + if (TIP_HEIGHT - d.second + 1 == h) + block.vtx.push_back(wallet.mapWallet.at(d.first)); + } + block.hashMerkleRoot = block.BuildMerkleTree(); + + index.emplace_back(block); + CBlockIndex& bi = index.back(); + bi.SetHeight(h); + bi.pprev = h > 0 ? &index[h - 1] : nullptr; + BlockMap::iterator it = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)).first; + bi.phashBlock = &it->first; + } + chainActive.SetTip(&index.back()); + for (const auto& d : depthOf) { + int h = TIP_HEIGHT - d.second + 1; + wallet.mapWallet.at(d.first).SetMerkleBranch(blocks[h]); + minedHeight[d.first] = h; + } + } + + // What tx_height() returns on a -txindex node: the height of the block holding the tx, 0 if none. + TxHeightFn TxIndexHeight() const { + return [this](const uint256& txid) { + auto it = minedHeight.find(txid); + return it == minedHeight.end() ? 0 : it->second; + }; + } + + std::vector Filtered(const std::set& filter, int minDepth, int maxDepth, + bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) { + std::set f = filter; + std::vector entries; + wallet.GetFilteredNotes(entries, f, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked); + return entries; + } + + std::vector Legacy(const std::set& filter, int minDepth, int maxDepth, + bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) { + std::set f = filter; + std::vector entries; + LegacyFilteredNotes(wallet, entries, f, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, + TxIndexHeight()); + return entries; + } + + void ExpectEquivalent(const std::vector& minDepths) { + const std::vector>> filters = { + {"none", {}}, {"A", {paA}}, {"B", {paB}}, {"A+C", {paA, paC}}, + }; + for (int minDepth : minDepths) + for (int maxDepth : {INT_MAX, 11}) + for (bool ignoreSpent : {false, true}) + for (bool requireSpendingKey : {false, true}) + for (bool ignoreLocked : {false, true}) + for (const auto& filter : filters) { + std::string ctx = Describe(minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter.first); + ExpectSameEntries(Legacy(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked), + Filtered(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked), + ctx); + } + } +}; + +// The fixture must exercise every filter, or the equivalence check below could pass vacuously. +TEST_F(WalletNoteCache, FixtureExercisesEveryFilter) { + // Mined, unspent notes: tx1 (2), tx2 (1), tx3 (3), tx4 (1), tx7 (1); tx6's note is spent. + EXPECT_EQ(8u, Filtered({}, 1, INT_MAX, true, false, false).size()); + EXPECT_EQ(9u, Filtered({}, 1, INT_MAX, false, false, false).size()) << "ignoreSpent drops tx6's note"; + EXPECT_EQ(8u, Filtered({}, 0, INT_MAX, true, false, false).size()) << "a never-mined tx is at depth -1, not 0"; + EXPECT_EQ(6u, Filtered({}, 1, INT_MAX, true, true, false).size()) << "requireSpendingKey drops B's 2 notes"; + EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "ignoreLocked drops tx2's note"; + EXPECT_EQ(7u, Filtered({}, 1, 11, true, false, false).size()) << "maxDepth 11 drops tx4"; + EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, false, false).size()); + + auto onlyC = Filtered({paC}, 1, INT_MAX, true, false, false); + ASSERT_EQ(3u, onlyC.size()); + for (const SaplingNoteEntry& e : onlyC) { + EXPECT_TRUE(e.address == paC); + EXPECT_TRUE(e.memo == MemoFor(e.note.value())); + } +} + +// The walk returns exactly what the 69bb035e7 loop returned, in the same order, for every filter +// combination. +TEST_F(WalletNoteCache, FilteredNotesMatchLegacy) { + ExpectEquivalent({0, 1}); +} From b9cdce8e96ae8742cae9fa2dc115802e962b5489 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 14:34:52 -0500 Subject: [PATCH 09/23] wallet/rpc: time GetFilteredNotes walks and slow RPCs GetFilteredNotes logged "GetFilteredNotes ignoreLocked=N" on every call. The balance RPCs run it on every GUI poll, so that line was log spam on a fast wallet, and on a slow one the gaps between its timestamps were the only timing there was. Those gaps also count lock waits and the previous RPC's own post-processing. It now logs one summary per walk: the filters, txs that passed the tx-level checks out of the wallet total, notes examined, notes skipped as spent, entries returned, and the walk time. The summary is always written when the walk takes >= 500ms (that long under cs_main stalls block connection) and otherwise only with -debug=bench. The line has no lock-wait figure: every caller already holds LOCK2(cs_main, cs_wallet) and CCriticalSection is recursive, so the inner LOCK2 never waits. Lock waits are measured where they happen instead. CRPCTable::execute logs "RPC took ms" under the same >= 500ms / -debug=bench rule. The per-locked-note line moves to -debug=zrpcunsafe, since it logs txid prefixes. No behaviour change otherwise. Co-Authored-By: Claude Opus 5.5 --- src/rpc/server.cpp | 18 ++++++++++++++++++ src/wallet/wallet.cpp | 20 ++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/rpc/server.cpp b/src/rpc/server.cpp index 4a040b1a6..4555e3081 100644 --- a/src/rpc/server.cpp +++ b/src/rpc/server.cpp @@ -640,6 +640,23 @@ std::string JSONRPCExecBatch(const UniValue& vReq) return ret.write() + "\n"; } +namespace { +// Logs an RPC whose wall time reached 500ms, or every RPC under -debug=bench. That time includes +// waiting for cs_main / cs_wallet behind block connection or another RPC, which the callee's own +// timers can't see because they start once the locks are held. +class RPCCallTimer { + const std::string& strMethod; + const int64_t nTimeStart; +public: + explicit RPCCallTimer(const std::string& method) : strMethod(method), nTimeStart(GetTimeMicros()) {} + ~RPCCallTimer() { + const int64_t nMs = (GetTimeMicros() - nTimeStart) / 1000; + if (nMs >= 500 || LogAcceptCategory("bench")) + LogPrintf("RPC %s took %dms\n", strMethod, nMs); + } +}; +} + UniValue CRPCTable::execute(const std::string &strMethod, const UniValue ¶ms) const { // Find method @@ -674,6 +691,7 @@ UniValue CRPCTable::execute(const std::string &strMethod, const UniValue ¶ms try { // Execute + RPCCallTimer timer(strMethod); return pcmd->actor(params, false, CPubKey()); } catch (const std::exception& e) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index fe8a97ecf..20565003d 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5751,7 +5751,11 @@ void CWallet::GetFilteredNotes( { LOCK2(cs_main, cs_wallet); - LogPrintf("%s ignoreLocked=%d\n", __func__, ignoreLocked); + // Every caller already holds LOCK2, so the lock above never waits and this times the walk alone. + // Time spent waiting for the locks shows up in the RPC's own "took" line (rpc/server.cpp). + const int64_t nTimeStart = GetTimeMicros(); + const size_t nEntriesBefore = saplingEntries.size(); + unsigned int nTxs = 0, nNotes = 0, nSpent = 0; for (auto & p : mapWallet) { CWalletTx wtx = p.second; @@ -5776,10 +5780,12 @@ void CWallet::GetFilteredNotes( continue; } } + nTxs++; for (auto & pair : wtx.mapSaplingNoteData) { SaplingOutPoint op = pair.first; SaplingNoteData nd = pair.second; + nNotes++; auto maybe_pt = SaplingNotePlaintext::decrypt( wtx.vShieldedOutput[op.n].encCiphertext, @@ -5799,6 +5805,7 @@ void CWallet::GetFilteredNotes( } if (ignoreSpent && nd.nullifier && IsSaplingSpent(*nd.nullifier)) { + nSpent++; continue; } @@ -5815,7 +5822,7 @@ void CWallet::GetFilteredNotes( // skip locked notes if (ignoreLocked && IsLockedNote(op)) { - LogPrintf("%s: skipping locked note %s\n", __func__, op.hash.ToString().substr(0,10).c_str()); + LogPrint("zrpcunsafe", "%s: skipping locked note %s\n", __func__, op.hash.ToString().substr(0,10).c_str()); continue; } @@ -5824,6 +5831,15 @@ void CWallet::GetFilteredNotes( op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() }); } } + + // A slow walk holds cs_main long enough to stall block connection, so it is always logged; a + // fast one only with -debug=bench (the balance RPCs run it on every GUI poll). + const int64_t nWalkMs = (GetTimeMicros() - nTimeStart) / 1000; + if (nWalkMs >= 500 || LogAcceptCategory("bench")) { + LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d txs=%u/%u notes=%u spent=%u entries=%u walk=%dms\n", + __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nTxs, (unsigned int)mapWallet.size(), + nNotes, nSpent, (unsigned int)(saplingEntries.size() - nEntriesBefore), nWalkMs); + } } From bfc13345658665c281ef1474f6858fff7539bbf6 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 14:37:55 -0500 Subject: [PATCH 10/23] wallet: walk GetFilteredNotes by reference, cheap filters first GetFilteredNotes copied every CWalletTx by value before any filter ran, then copied each SaplingNoteData by value again. Both copies deep-copy the note's witness list (up to WITNESS_CACHE_SIZE witnesses, ~100KB per note on a mainnet wallet): ~40us per note per pass, measured. Spent and locked notes were also fully decrypted (~0.6ms of Jubjub arithmetic per note) before the spent and locked checks threw them away. -deletetx is off by default, so spent notes accumulate. - iterate mapWallet and mapSaplingNoteData by const reference; - skip txs with no notes of ours before CheckFinalTx; - read GetDepthInMainChain once per tx instead of 2+N times (cs_main is held, so it can't change during the walk); - run the spent and locked checks before the decryption; - z_listreceivedbyaddress no longer copies the whole CWalletTx per entry to read its time, and z_sendmany's sort comparator takes its entries by reference. The filters have no side effects, so the result set and its order are unchanged; the A0 equivalence test passes unchanged. One deliberate difference: a spent or locked note whose ciphertext no longer decrypts under its ivk is now skipped instead of tripping the decrypt assert. The new SpentAndLockedNotesAreNotDecrypted test pins this (it aborts on the old ordering). Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 38 ++++++++++++++++++++++++ src/wallet/rpcwallet.cpp | 4 +-- src/wallet/wallet.cpp | 46 +++++++++++++++++------------ 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index c0006dfc8..7ae9e18a8 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -240,9 +240,13 @@ protected: lockedOp = SaplingOutPoint(tx2, 0); wallet.LockNote(lockedOp); + AddMoreTxs(); Mine(); } + // Lets a test add txs before the chain is built. + virtual void AddMoreTxs() {} + void TearDown() override { LOCK(cs_main); chainActive.SetTip(nullptr); @@ -362,3 +366,37 @@ TEST_F(WalletNoteCache, FixtureExercisesEveryFilter) { TEST_F(WalletNoteCache, FilteredNotesMatchLegacy) { ExpectEquivalent({0, 1}); } + +// Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone +// else): output 0 is spent by a mined tx, output 1 is locked. If the walk decrypted either one, the +// decrypt assert would abort. +class WalletNoteCacheUndecryptable : public WalletNoteCache { +protected: + uint256 txBad; + + void AddMoreTxs() override { + SaplingPaymentAddress foreign = TestKey(9).DefaultAddress(); + CTransaction tx = MakeTx({MakeOutput(foreign, 1), MakeOutput(foreign, 2)}); + txBad = tx.GetHash(); + const SaplingIncomingViewingKey ivkA = TestKey(0).expsk.full_viewing_key().in_viewing_key(); + const uint256 nf = uint256S("bad5"); + + CWalletTx wtx(&wallet, tx); + wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf); + wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA); + EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); + depthOf[txBad] = 4; + txids.push_back(txBad); + + AddTx(MakeTx({}, {nf}), 4); + wallet.LockNote(SaplingOutPoint(txBad, 1)); + } +}; + +// Spent and locked notes are dropped before the decryption (~0.5-0.8ms per note), not after it. +TEST_F(WalletNoteCacheUndecryptable, SpentAndLockedNotesAreNotDecrypted) { + auto entries = Filtered({}, 1, INT_MAX, true, false, true); + EXPECT_EQ(7u, entries.size()) << "the fixture's 8 notes less tx2's locked one"; + for (const SaplingNoteEntry& e : entries) + EXPECT_FALSE(e.op.hash == txBad); +} diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 0ea4fa1df..22ff1ec6c 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -4662,7 +4662,7 @@ UniValue z_listreceivedbyaddress(const UniValue& params, bool fHelp, const CPubK } obj.push_back(Pair("outindex", (int)entry.op.n)); obj.push_back(Pair("rawconfirmations", entry.confirmations)); - auto wtx = pwalletMain->mapWallet.at(entry.op.hash); //.ToString()); + const CWalletTx& wtx = pwalletMain->mapWallet.at(entry.op.hash); obj.push_back(Pair("time", wtx.GetTxTime())); obj.push_back(Pair("confirmations", dpowconfs)); @@ -5376,7 +5376,7 @@ UniValue z_sendmany(const UniValue& params, bool fHelp, const CPubKey& mypk) // sort notes from largest to smallest, which means // we will spend the largest first std::sort(saplingEntries.begin(), saplingEntries.end(), - [](SaplingNoteEntry i, SaplingNoteEntry j) -> bool { + [](const SaplingNoteEntry& i, const SaplingNoteEntry& j) -> bool { return i.note.value() > j.note.value(); }); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 20565003d..482118474 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5758,35 +5758,54 @@ void CWallet::GetFilteredNotes( unsigned int nTxs = 0, nNotes = 0, nSpent = 0; for (auto & p : mapWallet) { - CWalletTx wtx = p.second; + // By reference: a copy deep-copies every note's witness list (up to WITNESS_CACHE_SIZE + // witnesses each), and it was made before any filter ran. + const CWalletTx& wtx = p.second; + + // Most wallet txs (coinbase, sends from other wallets) carry no notes of ours. + if (wtx.mapSaplingNoteData.empty()) + continue; // Filter the transactions before checking for notes if (!CheckFinalTx(wtx) || wtx.GetBlocksToMaturity() > 0) continue; + // cs_main is held, so the depth can't change during the walk. + const int nDepth = wtx.GetDepthInMainChain(); if (minDepth > 1) { int nHeight = tx_height(wtx.GetHash()); if ( nHeight == 0 ) { continue; } - int nDepth = wtx.GetDepthInMainChain(); int dpowconfs = hush_dpowconfs(nHeight,nDepth); if ( dpowconfs < minDepth || dpowconfs > maxDepth) { continue; } } else { - if ( wtx.GetDepthInMainChain() < minDepth || - wtx.GetDepthInMainChain() > maxDepth) { + if ( nDepth < minDepth || nDepth > maxDepth) { continue; } } nTxs++; - for (auto & pair : wtx.mapSaplingNoteData) { - SaplingOutPoint op = pair.first; - SaplingNoteData nd = pair.second; + for (const auto & pair : wtx.mapSaplingNoteData) { + const SaplingOutPoint& op = pair.first; + const SaplingNoteData& nd = pair.second; nNotes++; + // The cheap filters run before the decryption, which costs ~0.5-0.8ms of Jubjub + // arithmetic per note. Spent notes stay in the wallet (-deletetx is off by default). + if (ignoreSpent && nd.nullifier && IsSaplingSpent(*nd.nullifier)) { + nSpent++; + continue; + } + + // skip locked notes + if (ignoreLocked && IsLockedNote(op)) { + LogPrint("zrpcunsafe", "%s: skipping locked note %s\n", __func__, op.hash.ToString().substr(0,10).c_str()); + continue; + } + auto maybe_pt = SaplingNotePlaintext::decrypt( wtx.vShieldedOutput[op.n].encCiphertext, nd.ivk, @@ -5804,11 +5823,6 @@ void CWallet::GetFilteredNotes( continue; } - if (ignoreSpent && nd.nullifier && IsSaplingSpent(*nd.nullifier)) { - nSpent++; - continue; - } - // skip notes which cannot be spent if (requireSpendingKey) { libzcash::SaplingIncomingViewingKey ivk; @@ -5820,15 +5834,9 @@ void CWallet::GetFilteredNotes( } } - // skip locked notes - if (ignoreLocked && IsLockedNote(op)) { - LogPrint("zrpcunsafe", "%s: skipping locked note %s\n", __func__, op.hash.ToString().substr(0,10).c_str()); - continue; - } - auto note = notePt.note(nd.ivk).get(); saplingEntries.push_back(SaplingNoteEntry { - op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() }); + op, pa, note, notePt.memo(), nDepth }); } } From 94967efa7b6cfd5ba20cfa23071a18f0b3317091 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 14:42:31 -0500 Subject: [PATCH 11/23] wallet: take note heights from the wallet's depth, not tx_height() For minDepth > 1, GetFilteredNotes located each wallet tx's block with tx_height(), which calls GetTransaction and re-reads the tx from disk through the txindex. It did this once per wallet tx, transparent ones included, while holding LOCK2. z_listunspent and z_listreceivedbyaddress did it once more per returned note. Without -txindex, GetTransaction can only find a tx that still has unspent transparent outputs. tx_height() then returned 0 for every fully shielded tx, and the tx was skipped. So on a -txindex=0 node, sweep and consolidation (minDepth 11) and any minconf >= 2 call never saw those notes. The wallet already has the depth, and for an active-chain tx GetDepthInMainChain is chainActive.Height() - height + 1. The new HeightFromDepth() inverts that (0 when unconfirmed or off the active chain) and replaces tx_height() at all three sites. The height only feeds the == 0 skip and hush_dpowconfs. Both sides hold cs_main for the whole call, so the tip can't move between the walk and the RPC tail. On a -txindex node the results are identical: the txindex block and wtx.hashBlock are the same block. The extended equivalence test now also covers minDepth 2/11/12/22. On -txindex=0 nodes, fully shielded notes now show up for minDepth > 1. With -zsweep or -consolidation enabled, those notes will now be swept and consolidated, so init logs that once. MinDepthAboveOneUsesWalletHeight pins the new behaviour and shows that the old path returned nothing without a txindex. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 38 ++++++++++++++++++++++++++--- src/init.cpp | 8 ++++++ src/wallet/rpcwallet.cpp | 4 +-- src/wallet/wallet.cpp | 13 ++++++++-- src/wallet/wallet.h | 5 ++++ 5 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index 7ae9e18a8..dd54dea47 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -20,6 +20,7 @@ #include "zcash/zip32.h" int32_t hush_dpowconfs(int32_t height, int32_t numconfs); +int tx_height(const uint256 &hash); using namespace libzcash; @@ -191,7 +192,7 @@ void ExpectSameEntries(const std::vector& want, const std::vec // TearDown, because every gtest suite runs in the same process. class WalletNoteCache : public ::testing::Test { protected: - static const int TIP_HEIGHT = 30; + static constexpr int TIP_HEIGHT = 30; CWallet wallet; SaplingPaymentAddress paA, paB, paC; // A and C spendable, B viewing key only @@ -361,10 +362,39 @@ TEST_F(WalletNoteCache, FixtureExercisesEveryFilter) { } } -// The walk returns exactly what the 69bb035e7 loop returned, in the same order, for every filter -// combination. +// The walk returns exactly what the 69bb035e7 loop returned on a -txindex node, in the same order, +// for every filter combination. minDepth 11/12/22 sit on the fixture's depth-11 and depth-21 txs. TEST_F(WalletNoteCache, FilteredNotesMatchLegacy) { - ExpectEquivalent({0, 1}); + ExpectEquivalent({0, 1, 2, 11, 12, 22}); +} + +// minDepth > 1 (sweep and consolidation use 11) locates each tx's block from its depth. The old +// tx_height() re-read the tx through the txindex, and without one (as here, and on a -txindex=0 +// node) it never found a fully shielded tx, so nothing was returned at all. +TEST_F(WalletNoteCache, MinDepthAboveOneUsesWalletHeight) { + // Unspent notes at depth >= 11: tx3's 3 (depth 11) and tx4's 1 (depth 21); tx6 (12) is spent. + EXPECT_EQ(4u, Filtered({}, 11, INT_MAX, true, false, false).size()); + EXPECT_EQ(5u, Filtered({}, 11, INT_MAX, false, false, false).size()); + EXPECT_EQ(1u, Filtered({}, 12, INT_MAX, true, false, false).size()) << "depth 11 is below minDepth 12"; + EXPECT_EQ(3u, Filtered({}, 11, 11, true, false, false).size()) << "depth 21 is above maxDepth 11"; + EXPECT_EQ(0u, Filtered({}, 22, INT_MAX, false, false, false).size()); + for (const SaplingNoteEntry& e : Filtered({}, 2, INT_MAX, false, false, false)) + EXPECT_GE(e.confirmations, 2) << "unmined and shallower txs stay out"; + + std::set none; + std::vector legacy; + LegacyFilteredNotes(wallet, legacy, none, 11, INT_MAX, true, false, false, tx_height); + EXPECT_EQ(0u, legacy.size()) << "the old code found nothing here without a txindex"; +} + +TEST_F(WalletNoteCache, HeightFromDepth) { + LOCK(cs_main); + EXPECT_EQ(TIP_HEIGHT, HeightFromDepth(1)); + EXPECT_EQ(TIP_HEIGHT - 10, HeightFromDepth(11)); + EXPECT_EQ(0, HeightFromDepth(0)) << "mempool"; + EXPECT_EQ(0, HeightFromDepth(-1)) << "not in the chain or mempool"; + for (const auto& d : depthOf) + EXPECT_EQ(minedHeight.at(d.first), HeightFromDepth(wallet.mapWallet.at(d.first).GetDepthInMainChain())); } // Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone diff --git a/src/init.cpp b/src/init.cpp index d04e45fc5..2e79b57d2 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2504,6 +2504,14 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) } } + // Sweep and consolidation pick notes with GetFilteredNotes at minDepth > 1. Older versions + // located each note's block by re-reading its tx through the txindex, so on a -txindex=0 node + // every fully shielded tx was skipped and these never moved those notes. Now they will. + if (!fTxIndex && (pwalletMain->fSweepEnabled || pwalletMain->fSaplingConsolidationEnabled)) { + LogPrintf("%s: -txindex=0 with -zsweep/-consolidation: shielded notes that versions before this " + "fix never saw on such nodes are now included in sweeps and consolidations\n", __func__); + } + //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. diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 22ff1ec6c..ea129d510 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -4309,7 +4309,7 @@ UniValue z_listunspent(const UniValue& params, bool fHelp, const CPubKey& mypk) for (auto & entry : saplingEntries) { UniValue obj(UniValue::VOBJ); - int nHeight = tx_height(entry.op.hash); + int nHeight = HeightFromDepth(entry.confirmations); int dpowconfs = hush_dpowconfs(nHeight, entry.confirmations); // Only return notarized results when minconf>1 @@ -4642,7 +4642,7 @@ UniValue z_listreceivedbyaddress(const UniValue& params, bool fHelp, const CPubK if (boost::get(&zaddr) != nullptr) { for (SaplingNoteEntry & entry : saplingEntries) { UniValue obj(UniValue::VOBJ); - int nHeight = tx_height(entry.op.hash); + int nHeight = HeightFromDepth(entry.confirmations); int dpowconfs = hush_dpowconfs(nHeight, entry.confirmations); // Only return notarized results when minconf>1 if (nMinDepth > 1 && dpowconfs == 1) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 482118474..ff86165de 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -76,7 +76,6 @@ bool fPayAtLeastCustomFee = true; CBlockIndex *hush_chainactive(int32_t height); extern int32_t HUSH_LOADINGBLOCKS; int32_t hush_dpowconfs(int32_t height,int32_t numconfs); -int tx_height( const uint256 &hash ); bool fTxDeleteEnabled = false; bool fTxConflictDeleteEnabled = false; int fDeleteInterval = DEFAULT_TX_DELETE_INTERVAL; @@ -5715,6 +5714,16 @@ bool CMerkleTx::AcceptToMemoryPool(bool fLimitFree, bool fRejectAbsurdFee) return ::AcceptToMemoryPool(mempool, state, *this, fLimitFree, NULL, fRejectAbsurdFee); } +// Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through +// GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it +// never found a fully shielded tx, so those notes were silently skipped. The depth already locates +// the block: GetDepthInMainChain is chainActive.Height() - height + 1 for an active-chain tx. +int HeightFromDepth(int nDepth) +{ + AssertLockHeld(cs_main); + return nDepth > 0 ? chainActive.Height() - nDepth + 1 : 0; +} + /** * Find notes in the wallet filtered by payment address, min depth and ability to spend. * These notes are decrypted and added to the output parameter vector, outEntries. @@ -5773,7 +5782,7 @@ void CWallet::GetFilteredNotes( // cs_main is held, so the depth can't change during the walk. const int nDepth = wtx.GetDepthInMainChain(); if (minDepth > 1) { - int nHeight = tx_height(wtx.GetHash()); + int nHeight = HeightFromDepth(nDepth); if ( nHeight == 0 ) { continue; } diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index bd3421b8f..08327a9b3 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -366,6 +366,11 @@ struct SaplingNoteEntry int confirmations; }; +/** Height of the active-chain block holding a wallet tx that is nDepth deep (as GetDepthInMainChain + * returns it, and SaplingNoteEntry::confirmations holds it), or 0 when the tx is unconfirmed or not + * in the active chain. Caller holds cs_main. */ +int HeightFromDepth(int nDepth); + /** A transaction with a merkle branch linking it to the block chain. */ class CMerkleTx : public CTransaction { From 34b94e761ac254f49d9efe30681ec5eeac77c40b Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 14:45:49 -0500 Subject: [PATCH 12/23] wallet: don't copy every encrypted Sapling key on each lookup CCryptoKeyStore::HaveSaplingSpendingKey and GetSaplingSpendingKey scan mapCryptedSaplingSpendingKeys with `for (auto entry : ...)`. That copies each entry (an extended full viewing key plus its encrypted-secret vector) for every key visited, on every call. GetFilteredNotes' requireSpendingKey check makes one such call per note, and the history RPCs make one per output per address: ~22ns per key per call, measured (11us per note on a 1024-key wallet). Iterate by const reference. The equivalence test gains a locked, encrypted-keystore variant, LockedWalletMatchesLegacy, so spendability coming from the encrypted map is covered too. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 24 +++++++++++++++++++++++- src/wallet/crypter.cpp | 2 +- src/wallet/crypter.h | 2 +- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index dd54dea47..8a9357e63 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -116,6 +116,11 @@ void LegacyFilteredNotes( } } +class NoteCacheWallet : public CWallet { +public: + bool EncryptKeys(CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::EncryptKeys(vMasterKeyIn); } +}; + std::array MemoFor(uint64_t value) { std::array memo; @@ -194,7 +199,7 @@ class WalletNoteCache : public ::testing::Test { protected: static constexpr int TIP_HEIGHT = 30; - CWallet wallet; + NoteCacheWallet wallet; SaplingPaymentAddress paA, paB, paC; // A and C spendable, B viewing key only std::map depthOf; // txid -> depth to mine it at (absent: never mined) std::vector txids; @@ -430,3 +435,20 @@ TEST_F(WalletNoteCacheUndecryptable, SpentAndLockedNotesAreNotDecrypted) { for (const SaplingNoteEntry& e : entries) EXPECT_FALSE(e.op.hash == txBad); } + +// The same wallet with its keystore encrypted and locked: spendability then comes from the +// encrypted-key map (CCryptoKeyStore::HaveSaplingSpendingKey), and nothing else may change. +class WalletNoteCacheEncrypted : public WalletNoteCache { +protected: + void AddMoreTxs() override { + CKeyingMaterial masterKey(32, 0x42); + ASSERT_TRUE(wallet.EncryptKeys(masterKey)); + ASSERT_TRUE(wallet.IsCrypted()); + ASSERT_TRUE(wallet.IsLocked()); + } +}; + +TEST_F(WalletNoteCacheEncrypted, LockedWalletMatchesLegacy) { + EXPECT_EQ(6u, Filtered({}, 1, INT_MAX, true, true, false).size()) << "A's and C's keys are still known"; + ExpectEquivalent({1, 11}); +} diff --git a/src/wallet/crypter.cpp b/src/wallet/crypter.cpp index fa29dba19..336b07f3a 100644 --- a/src/wallet/crypter.cpp +++ b/src/wallet/crypter.cpp @@ -591,7 +591,7 @@ bool CCryptoKeyStore::GetSaplingSpendingKey(const libzcash::SaplingFullViewingKe if (!IsCrypted()) return CBasicKeyStore::GetSaplingSpendingKey(fvk, skOut); - for (auto entry : mapCryptedSaplingSpendingKeys) { + for (const auto& entry : mapCryptedSaplingSpendingKeys) { if (entry.first.fvk == fvk) { const std::vector &vchCryptedSecret = entry.second; return DecryptSaplingSpendingKey(vMasterKey, vchCryptedSecret, entry.first, skOut); diff --git a/src/wallet/crypter.h b/src/wallet/crypter.h index d79a1b34b..282e39a85 100644 --- a/src/wallet/crypter.h +++ b/src/wallet/crypter.h @@ -265,7 +265,7 @@ public: LOCK(cs_SpendingKeyStore); if (!IsCrypted()) return CBasicKeyStore::HaveSaplingSpendingKey(fvk); - for (auto entry : mapCryptedSaplingSpendingKeys) { + for (const auto& entry : mapCryptedSaplingSpendingKeys) { if (entry.first.fvk == fvk) { return true; } From 21586b353a4fc8213f788d7936aa9bfe16e3346e Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 16:48:44 -0500 Subject: [PATCH 13/23] wallet/rpc: monotonic timers and a quieter default slow-walk line Review follow-ups to the GetFilteredNotes / RPC timing logs: - Time with std::chrono::steady_clock instead of GetTimeMicros(), which is the system clock. A wall-clock step otherwise writes false ">= 500ms" lines unconditionally; on a WSL2 host the clock was seen stepping +3.6s every ~32s. - A long-poll getblocktemplate deliberately blocks (with cs_main released) until a new block arrives, so it is logged only under -debug=bench. - Log the method as "RPC method= took ms". A bare " rescan " token followed by "ms" matches ObsidianDragon's rescan-finished log heuristic. - The always-on slow-walk line keeps just the filters and the walk time; the wallet-size counts (txs, notes, spent, entries) are logged only with -debug=bench, so a shared debug.log doesn't carry them. - The -txindex=0 startup line names the version. Co-Authored-By: Claude Opus 5.5 --- src/init.cpp | 4 ++-- src/rpc/server.cpp | 22 +++++++++++++++------- src/wallet/wallet.cpp | 13 +++++++++---- 3 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index 2e79b57d2..42a5a6dbc 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -2508,8 +2508,8 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) // located each note's block by re-reading its tx through the txindex, so on a -txindex=0 node // every fully shielded tx was skipped and these never moved those notes. Now they will. if (!fTxIndex && (pwalletMain->fSweepEnabled || pwalletMain->fSaplingConsolidationEnabled)) { - LogPrintf("%s: -txindex=0 with -zsweep/-consolidation: shielded notes that versions before this " - "fix never saw on such nodes are now included in sweeps and consolidations\n", __func__); + LogPrintf("%s: -txindex=0 with -zsweep/-consolidation: fully shielded notes, which versions before " + "v1.3.1 never saw on such nodes, are now included in sweeps and consolidations\n", __func__); } //Set Automatic Coinbase Shielding (default ON, conditional: self-guards diff --git a/src/rpc/server.cpp b/src/rpc/server.cpp index 4555e3081..bdb95c4d4 100644 --- a/src/rpc/server.cpp +++ b/src/rpc/server.cpp @@ -26,6 +26,7 @@ #include "util.h" #include "util/strencodings.h" #include "asyncrpcqueue.h" +#include #include #include #include @@ -643,16 +644,23 @@ std::string JSONRPCExecBatch(const UniValue& vReq) namespace { // Logs an RPC whose wall time reached 500ms, or every RPC under -debug=bench. That time includes // waiting for cs_main / cs_wallet behind block connection or another RPC, which the callee's own -// timers can't see because they start once the locks are held. +// timers can't see because they start once the locks are held. A long-poll getblocktemplate waits +// for a new block with cs_main released, so it is only logged under -debug=bench. The method is +// logged as method=: a bare " " token would match the GUI's " rescan " log heuristic. class RPCCallTimer { const std::string& strMethod; - const int64_t nTimeStart; + const bool fLongPoll; + const std::chrono::steady_clock::time_point start; public: - explicit RPCCallTimer(const std::string& method) : strMethod(method), nTimeStart(GetTimeMicros()) {} + RPCCallTimer(const std::string& method, const UniValue& params) + : strMethod(method), + fLongPoll(method == "getblocktemplate" && params.size() > 0 && params[0].isObject() && + !find_value(params[0], "longpollid").isNull()), + start(std::chrono::steady_clock::now()) {} ~RPCCallTimer() { - const int64_t nMs = (GetTimeMicros() - nTimeStart) / 1000; - if (nMs >= 500 || LogAcceptCategory("bench")) - LogPrintf("RPC %s took %dms\n", strMethod, nMs); + const int64_t nMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + if ((nMs >= 500 && !fLongPoll) || LogAcceptCategory("bench")) + LogPrintf("RPC method=%s took %dms\n", strMethod, nMs); } }; } @@ -691,7 +699,7 @@ UniValue CRPCTable::execute(const std::string &strMethod, const UniValue ¶ms try { // Execute - RPCCallTimer timer(strMethod); + RPCCallTimer timer(strMethod, params); return pcmd->actor(params, false, CPubKey()); } catch (const std::exception& e) diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index ff86165de..5e4d54640 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -45,6 +45,7 @@ #include #include #include +#include #include #include #include "zcash/zip32.h" @@ -5762,7 +5763,7 @@ void CWallet::GetFilteredNotes( // Every caller already holds LOCK2, so the lock above never waits and this times the walk alone. // Time spent waiting for the locks shows up in the RPC's own "took" line (rpc/server.cpp). - const int64_t nTimeStart = GetTimeMicros(); + const std::chrono::steady_clock::time_point walkStart = std::chrono::steady_clock::now(); const size_t nEntriesBefore = saplingEntries.size(); unsigned int nTxs = 0, nNotes = 0, nSpent = 0; @@ -5850,12 +5851,16 @@ void CWallet::GetFilteredNotes( } // A slow walk holds cs_main long enough to stall block connection, so it is always logged; a - // fast one only with -debug=bench (the balance RPCs run it on every GUI poll). - const int64_t nWalkMs = (GetTimeMicros() - nTimeStart) / 1000; - if (nWalkMs >= 500 || LogAcceptCategory("bench")) { + // fast one only with -debug=bench (the balance RPCs run it on every GUI poll). The wallet-size + // counts go to the default log only with -debug=bench too. + const int64_t nWalkMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - walkStart).count(); + if (LogAcceptCategory("bench")) { LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d txs=%u/%u notes=%u spent=%u entries=%u walk=%dms\n", __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nTxs, (unsigned int)mapWallet.size(), nNotes, nSpent, (unsigned int)(saplingEntries.size() - nEntriesBefore), nWalkMs); + } else if (nWalkMs >= 500) { + LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d walk=%dms\n", + __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nWalkMs); } } From bfec5c74aeaea3181f556adfcfa8a76bda7a723d Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 16:48:44 -0500 Subject: [PATCH 14/23] gtest: notecache review fixes - Take LOCK2(cs_main, cs_wallet) before adding keys in SetUp: both AddSaplingZKey and AddSaplingIncomingViewingKey assert cs_wallet is held, which aborts a DEBUG_LOCKORDER build. - TIP_HEIGHT becomes a namespace-scope constexpr. As a static class member it was ODR-used by EXPECT_EQ without a definition, which fails to link as C++11 at -O0. - TearDown restores the previous chainActive tip and erases only the mapBlockIndex entries the fixture inserted. - Add a tx mined only in a stale side block. The legacy tx_height model returns that block's height, as a txindex can, and the tx must still be excluded at every minDepth. - The encrypted variant asserts HaveSaplingSpendingKey directly, and a new test unlocks the keystore and checks GetSaplingSpendingKey (the other loop the crypter change touched). - The legacy-loop comment now says its two LogPrintf lines are dropped. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 94 +++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 26 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index 8a9357e63..a6362d576 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -28,9 +28,10 @@ namespace { typedef std::function TxHeightFn; -// CWallet::GetFilteredNotes as of 69bb035e7, verbatim except that tx_height() is passed in. This is -// the reference the optimized walk must reproduce entry for entry and in the same order (z_sendmany -// sorts the result with an unstable sort, so the order is observable). +// CWallet::GetFilteredNotes as of 69bb035e7, verbatim except that tx_height() is passed in and its two +// LogPrintf lines are dropped. This is the reference the optimized walk must reproduce entry for +// entry and in the same order (z_sendmany sorts the result with an unstable sort, so the order is +// observable). void LegacyFilteredNotes( CWallet& wallet, std::vector& saplingEntries, @@ -119,8 +120,13 @@ void LegacyFilteredNotes( class NoteCacheWallet : public CWallet { public: bool EncryptKeys(CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::EncryptKeys(vMasterKeyIn); } + bool Unlock(const CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::Unlock(vMasterKeyIn); } }; +// The fake active chain is 0..TIP_HEIGHT; STALE_HEIGHT is a side block forked off it. +constexpr int TIP_HEIGHT = 30; +constexpr int STALE_HEIGHT = 25; + std::array MemoFor(uint64_t value) { std::array memo; @@ -192,13 +198,11 @@ void ExpectSameEntries(const std::vector& want, const std::vec } // A wallet with notes at several depths, spent and unspent, locked and unlocked, spendable and -// viewing-key-only, plus txs that carry no wallet notes; mined into a fake active chain. Everything it -// touches in the process-wide chain state (chainActive, mapBlockIndex, pcoinsTip) is undone in -// TearDown, because every gtest suite runs in the same process. +// viewing-key-only, one mined only in a stale block, plus txs that carry no wallet notes; mined into a +// fake active chain. TearDown restores the process-wide chain state it changed (the chainActive tip, +// its own mapBlockIndex entries, pcoinsTip), because every gtest suite runs in the same process. class WalletNoteCache : public ::testing::Test { protected: - static constexpr int TIP_HEIGHT = 30; - NoteCacheWallet wallet; SaplingPaymentAddress paA, paB, paC; // A and C spendable, B viewing key only std::map depthOf; // txid -> depth to mine it at (absent: never mined) @@ -210,13 +214,21 @@ protected: CCoinsViewCache* savedCoinsTip = nullptr; std::deque blocks; std::deque index; - std::map minedHeight; // txid -> height of the block holding it + std::vector insertedBlocks; // mapBlockIndex keys this fixture added + CBlockIndex* savedTip = nullptr; + std::map minedHeight; // txid -> height of the block holding it (stale ones too) + uint256 staleTx; // only ever mined in the side block at STALE_HEIGHT void SetUp() override { // tx_height()/GetTransaction dereference pcoinsTip; an empty view answers "not found". savedCoinsTip = pcoinsTip; pcoinsTip = &coins; + { + LOCK(cs_main); + savedTip = chainActive.Tip(); + } + LOCK2(cs_main, wallet.cs_wallet); auto skA = TestKey(0), skB = TestKey(1), skC = TestKey(2); paA = skA.DefaultAddress(); paB = skB.DefaultAddress(); @@ -226,13 +238,13 @@ protected: ASSERT_TRUE(wallet.AddSaplingZKey(skC, paC)); SaplingPaymentAddress foreign = TestKey(9).DefaultAddress(); // not in the wallet - LOCK2(cs_main, wallet.cs_wallet); AddTx(MakeTx({MakeOutput(paA, 1000), MakeOutput(paB, 2000)}), 1); uint256 tx2 = AddTx(MakeTx({MakeOutput(paA, 3000)}), 5); AddTx(MakeTx({MakeOutput(paA, 4000), MakeOutput(paC, 5000), MakeOutput(paB, 6000)}), 11); AddTx(MakeTx({MakeOutput(paC, 7000)}), 21); AddTx(MakeTx({MakeOutput(paA, 8000)}), -1); // never mined: depth -1 AddTx(MakeTx({MakeOutput(foreign, 50)}), 3); // no wallet notes at all + staleTx = AddTx(MakeTx({MakeOutput(paA, 11000)}), -1); // mined in a stale block only // A note spent by a mined tx, and one whose spending tx never confirmed. const uint256 nfSpent = uint256S("5a1d"), nfPending = uint256S("9e9d"); @@ -255,11 +267,9 @@ protected: void TearDown() override { LOCK(cs_main); - chainActive.SetTip(nullptr); - for (const CBlockIndex& bi : index) { - const uint256 hash = bi.GetBlockHash(); + chainActive.SetTip(savedTip); + for (const uint256& hash : insertedBlocks) mapBlockIndex.erase(hash); - } pcoinsTip = savedCoinsTip; } @@ -275,7 +285,21 @@ protected: return tx.GetHash(); } - // Builds an active chain 0..TIP_HEIGHT and puts each wallet tx into the block at its depth. + CBlockIndex& AddBlock(CBlock& block, int height, CBlockIndex* pprev) { + block.hashMerkleRoot = block.BuildMerkleTree(); + index.emplace_back(block); + CBlockIndex& bi = index.back(); + bi.SetHeight(height); + bi.pprev = pprev; + auto inserted = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)); + EXPECT_TRUE(inserted.second) << "block hash collision at height " << height; + insertedBlocks.push_back(block.GetHash()); + bi.phashBlock = &inserted.first->first; + return bi; + } + + // Builds an active chain 0..TIP_HEIGHT and puts each wallet tx into the block at its depth, plus a + // side block at STALE_HEIGHT (not on the active chain) holding staleTx. void Mine() { for (int h = 0; h <= TIP_HEIGHT; h++) { blocks.emplace_back(); @@ -288,24 +312,27 @@ protected: if (TIP_HEIGHT - d.second + 1 == h) block.vtx.push_back(wallet.mapWallet.at(d.first)); } - block.hashMerkleRoot = block.BuildMerkleTree(); - - index.emplace_back(block); - CBlockIndex& bi = index.back(); - bi.SetHeight(h); - bi.pprev = h > 0 ? &index[h - 1] : nullptr; - BlockMap::iterator it = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)).first; - bi.phashBlock = &it->first; + AddBlock(block, h, h > 0 ? &index[h - 1] : nullptr); } - chainActive.SetTip(&index.back()); + chainActive.SetTip(&index[TIP_HEIGHT]); for (const auto& d : depthOf) { int h = TIP_HEIGHT - d.second + 1; wallet.mapWallet.at(d.first).SetMerkleBranch(blocks[h]); minedHeight[d.first] = h; } + + blocks.emplace_back(); + CBlock& side = blocks.back(); + side.nTime = 1800000000; + side.hashPrevBlock = blocks[STALE_HEIGHT - 1].GetHash(); + side.vtx.push_back(wallet.mapWallet.at(staleTx)); + AddBlock(side, STALE_HEIGHT, &index[STALE_HEIGHT - 1]); + wallet.mapWallet.at(staleTx).SetMerkleBranch(side); + minedHeight[staleTx] = STALE_HEIGHT; // a txindex can still point at a stale block } - // What tx_height() returns on a -txindex node: the height of the block holding the tx, 0 if none. + // What tx_height() returns on a -txindex node: the height of the block the txindex points at + // (possibly stale), 0 if none. TxHeightFn TxIndexHeight() const { return [this](const uint256& txid) { auto it = minedHeight.find(txid); @@ -393,7 +420,8 @@ TEST_F(WalletNoteCache, MinDepthAboveOneUsesWalletHeight) { } TEST_F(WalletNoteCache, HeightFromDepth) { - LOCK(cs_main); + LOCK2(cs_main, wallet.cs_wallet); + EXPECT_EQ(-1, wallet.mapWallet.at(staleTx).GetDepthInMainChain()) << "a stale block is not the active chain"; EXPECT_EQ(TIP_HEIGHT, HeightFromDepth(1)); EXPECT_EQ(TIP_HEIGHT - 10, HeightFromDepth(11)); EXPECT_EQ(0, HeightFromDepth(0)) << "mempool"; @@ -449,6 +477,20 @@ protected: }; TEST_F(WalletNoteCacheEncrypted, LockedWalletMatchesLegacy) { + EXPECT_TRUE(wallet.HaveSaplingSpendingKey(TestKey(0).expsk.full_viewing_key())); + EXPECT_FALSE(wallet.HaveSaplingSpendingKey(TestKey(1).expsk.full_viewing_key())) << "B is viewing key only"; + EXPECT_FALSE(wallet.HaveSaplingSpendingKey(TestKey(9).expsk.full_viewing_key())); EXPECT_EQ(6u, Filtered({}, 1, INT_MAX, true, true, false).size()) << "A's and C's keys are still known"; ExpectEquivalent({1, 11}); } + +TEST_F(WalletNoteCacheEncrypted, UnlockedWalletFindsSpendingKeys) { + libzcash::SaplingExtendedSpendingKey sk; + EXPECT_FALSE(wallet.GetSaplingSpendingKey(TestKey(0).expsk.full_viewing_key(), sk)) << "locked"; + ASSERT_TRUE(wallet.Unlock(CKeyingMaterial(32, 0x42))); + ASSERT_TRUE(wallet.GetSaplingSpendingKey(TestKey(0).expsk.full_viewing_key(), sk)); + EXPECT_TRUE(sk.DefaultAddress() == paA); + ASSERT_TRUE(wallet.GetSaplingSpendingKey(TestKey(2).expsk.full_viewing_key(), sk)); + EXPECT_TRUE(sk.DefaultAddress() == paC); + EXPECT_FALSE(wallet.GetSaplingSpendingKey(TestKey(9).expsk.full_viewing_key(), sk)); +} From 7ddd03d86d48777604d5997a7352997845a99a10 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:12:39 -0500 Subject: [PATCH 15/23] zcash/wallet: decrypt a Sapling note's plaintext and note in one pass SaplingNotePlaintext::decrypt(ciphertext, ivk, epk, cmu) derives pk_d = [ivk]g_d to check the note commitment, then throws it away. The wallet then calls ivk.address(d), which derives pk_d again, and plaintext.note(ivk), which derives it a third time. Each derivation is a Jubjub scalar multiplication, ~115us, measured. Including the key agreement and the commitment check, the whole sequence is ~775us per note. decrypt_with_note() runs the same decryption and checks and returns the note built from that pk_d. ivk_to_pkd succeeding already implies a valid diversifier, which is all ivk.address() checks on top. It gets its own name rather than a decrypt() overload: an overload taking a fifth argument by non-const reference would capture calls meant for the (epk, esk, pk_d, cmu) variant. The cmu check stays. GetFilteredNotes and FindMySaplingNotes' spending-key loop use it. That saves two of the three derivations per note in every balance walk, and one per found note during scans and rescans (trial decryption of outputs that aren't ours still fails at the AEAD, as before). FindMySaplingNotes also no longer copies each OutputDescription. New tests: MatchesDecryptThenNote checks decrypt_with_note field for field against decrypt() + note(ivk), over default and diversified addresses, a wrong ivk and a wrong cmu. FindMySaplingNotes pins what discovery returns: notes per key, diversified addresses to register, foreign outputs ignored. Both pass before and after the switch. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 94 +++++++++++++++++++++++++++++ src/wallet/wallet.cpp | 24 ++++---- src/zcash/Note.cpp | 40 +++++++++++- src/zcash/Note.hpp | 11 ++++ 4 files changed, 154 insertions(+), 15 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index a6362d576..0415bc657 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -494,3 +494,97 @@ TEST_F(WalletNoteCacheEncrypted, UnlockedWalletFindsSpendingKeys) { EXPECT_TRUE(sk.DefaultAddress() == paC); EXPECT_FALSE(wallet.GetSaplingSpendingKey(TestKey(9).expsk.full_viewing_key(), sk)); } + +namespace { + +// The default address plus a few diversified addresses of the same key. +std::vector AddressesOf(const SaplingExtendedSpendingKey& sk) +{ + std::vector addrs = {sk.DefaultAddress()}; + for (unsigned char start : {7, 90, 201}) { + diversifier_index_t j; + j.begin()[0] = start; + auto found = sk.ToXFVK().Address(j); + if (found) + addrs.push_back(found->second); + } + return addrs; +} + +} + +// decrypt_with_note returns exactly decrypt() plus decrypt()->note(ivk), and fails exactly when they do. +TEST(SaplingDecryptWithNote, MatchesDecryptThenNote) { + auto sk = TestKey(0); + const SaplingIncomingViewingKey ivk = sk.expsk.full_viewing_key().in_viewing_key(); + const SaplingIncomingViewingKey otherIvk = TestKey(1).expsk.full_viewing_key().in_viewing_key(); + auto addrs = AddressesOf(sk); + ASSERT_GE(addrs.size(), 3u) << "need diversified addresses too"; + + uint64_t value = 100; + for (const SaplingPaymentAddress& pa : addrs) { + OutputDescription od = MakeOutput(pa, ++value); + auto legacyPt = SaplingNotePlaintext::decrypt(od.encCiphertext, ivk, od.ephemeralKey, od.cm); + ASSERT_TRUE(static_cast(legacyPt)); + auto legacyNote = legacyPt->note(ivk); + ASSERT_TRUE(static_cast(legacyNote)); + + auto both = SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, ivk, od.ephemeralKey, od.cm); + ASSERT_TRUE(static_cast(both)); + const SaplingNotePlaintext& pt = both->first; + const SaplingNote& note = both->second; + EXPECT_TRUE(pt.d == legacyPt->d); + EXPECT_EQ(legacyPt->rcm, pt.rcm); + EXPECT_EQ(legacyPt->value(), pt.value()); + EXPECT_TRUE(pt.memo() == legacyPt->memo()); + EXPECT_TRUE(note.d == legacyNote->d); + EXPECT_EQ(legacyNote->pk_d, note.pk_d); + EXPECT_EQ(legacyNote->r, note.r); + EXPECT_EQ(legacyNote->value(), note.value()); + EXPECT_TRUE(SaplingPaymentAddress(note.d, note.pk_d) == pa); + EXPECT_EQ(value, note.value()); + + EXPECT_FALSE(static_cast(SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, otherIvk, od.ephemeralKey, od.cm))); + uint256 badCm = od.cm; + badCm.begin()[0] ^= 1; + EXPECT_FALSE(static_cast(SaplingNotePlaintext::decrypt(od.encCiphertext, ivk, od.ephemeralKey, badCm))); + EXPECT_FALSE(static_cast(SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, ivk, od.ephemeralKey, badCm))); + } +} + +// What FindMySaplingNotes discovers: notes to a spending key's default and diversified addresses, and to +// a viewing-key-only address, each with the key that found it; a diversified address the wallet hasn't +// seen yet is reported for registration; outputs to other wallets are ignored. +TEST(WalletNoteCacheDiscovery, FindMySaplingNotes) { + NoteCacheWallet wallet; + auto skA = TestKey(0), skB = TestKey(1); + const SaplingIncomingViewingKey ivkA = skA.expsk.full_viewing_key().in_viewing_key(); + const SaplingIncomingViewingKey ivkB = skB.expsk.full_viewing_key().in_viewing_key(); + auto addrsA = AddressesOf(skA); + const SaplingPaymentAddress paB = skB.DefaultAddress(); + + LOCK2(cs_main, wallet.cs_wallet); + ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0])); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(ivkB, paB)); + + CTransaction tx = MakeTx({MakeOutput(addrsA[0], 1), MakeOutput(addrsA[1], 2), MakeOutput(paB, 3), + MakeOutput(TestKey(9).DefaultAddress(), 4), MakeOutput(addrsA[2], 5)}); + auto found = wallet.FindMySaplingNotes(tx); + const mapSaplingNoteData_t& notes = found.first; + ASSERT_EQ(4u, notes.size()); + EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 0)).ivk == ivkA); + EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 1)).ivk == ivkA); + EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 2)).ivk == ivkB); + EXPECT_EQ(0u, notes.count(SaplingOutPoint(tx.GetHash(), 3))); + EXPECT_TRUE(notes.at(SaplingOutPoint(tx.GetHash(), 4)).ivk == ivkA); + for (const auto& item : notes) { + EXPECT_FALSE(static_cast(item.second.nullifier)); + EXPECT_TRUE(item.second.witnesses.empty()); + EXPECT_EQ(-1, item.second.witnessHeight); + } + + const SaplingIncomingViewingKeyMap& toAdd = found.second; + ASSERT_EQ(2u, toAdd.size()) << "the two diversified addresses, not the registered default one"; + EXPECT_TRUE(toAdd.at(addrsA[1]) == ivkA); + EXPECT_TRUE(toAdd.at(addrsA[2]) == ivkA); +} diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 5e4d54640..9cf5161f2 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2263,15 +2263,17 @@ std::pair CWallet::FindMySap // Protocol Spec: 4.19 Block Chain Scanning (Sapling) for (uint32_t i = 0; i < nZouts; ++i) { - const OutputDescription output = tx.vShieldedOutput[i]; + const OutputDescription& output = tx.vShieldedOutput[i]; bool found = false; for (auto it = mapSaplingFullViewingKeys.begin(); it != mapSaplingFullViewingKeys.end(); ++it) { SaplingIncomingViewingKey ivk = it->first; - auto result = SaplingNotePlaintext::decrypt(output.encCiphertext, ivk, output.ephemeralKey, output.cm); + auto result = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, ivk, output.ephemeralKey, output.cm); if (result) { - auto address = ivk.address(result.get().d); - if (address && mapSaplingIncomingViewingKeys.count(address.get()) == 0) { - viewingKeysToAdd[address.get()] = ivk; + // The note's (d, pk_d) is the address it was sent to, exactly what ivk.address(d) would + // derive again: decrypt_with_note already computed pk_d from this ivk and d. + const libzcash::SaplingPaymentAddress address(result->second.d, result->second.pk_d); + if (mapSaplingIncomingViewingKeys.count(address) == 0) { + viewingKeysToAdd[address] = ivk; } // We don't cache the nullifier here as computing it requires knowledge of the note position // in the commitment tree, which can only be determined when the transaction has been mined. @@ -5816,17 +5818,16 @@ void CWallet::GetFilteredNotes( continue; } - auto maybe_pt = SaplingNotePlaintext::decrypt( + // One key agreement and one pk_d derivation; the note and its address come with it. + auto maybe_pt = SaplingNotePlaintext::decrypt_with_note( wtx.vShieldedOutput[op.n].encCiphertext, nd.ivk, wtx.vShieldedOutput[op.n].ephemeralKey, wtx.vShieldedOutput[op.n].cm); assert(static_cast(maybe_pt)); - auto notePt = maybe_pt.get(); - - auto maybe_pa = nd.ivk.address(notePt.d); - assert(static_cast(maybe_pa)); - auto pa = maybe_pa.get(); + const SaplingNotePlaintext& notePt = maybe_pt->first; + const libzcash::SaplingNote& note = maybe_pt->second; + const libzcash::SaplingPaymentAddress pa(note.d, note.pk_d); // skip notes which belong to a different payment address in the wallet if (!(filterAddresses.empty() || filterAddresses.count(pa))) { @@ -5844,7 +5845,6 @@ void CWallet::GetFilteredNotes( } } - auto note = notePt.note(nd.ivk).get(); saplingEntries.push_back(SaplingNoteEntry { op, pa, note, notePt.memo(), nDepth }); } diff --git a/src/zcash/Note.cpp b/src/zcash/Note.cpp index 7aa08536f..1bf2b746b 100644 --- a/src/zcash/Note.cpp +++ b/src/zcash/Note.cpp @@ -115,11 +115,16 @@ boost::optional SaplingOutgoingPlaintext::decrypt( } } -boost::optional SaplingNotePlaintext::decrypt( +namespace { + +// SaplingNotePlaintext::decrypt with an incoming viewing key, also handing back the pk_d = [ivk]g_d +// it derives to check cmu. +boost::optional DecryptWithIvk( const SaplingEncCiphertext &ciphertext, const uint256 &ivk, const uint256 &epk, - const uint256 &cmu + const uint256 &cmu, + uint256 &pk_d ) { auto pt = AttemptSaplingEncDecryption(ciphertext, ivk, epk); @@ -140,7 +145,6 @@ boost::optional SaplingNotePlaintext::decrypt( return boost::none; } - uint256 pk_d; if (!librustzcash_ivk_to_pkd(ivk.begin(), ret.d.data(), pk_d.begin())) { return boost::none; } @@ -164,6 +168,36 @@ boost::optional SaplingNotePlaintext::decrypt( return ret; } +} + +boost::optional SaplingNotePlaintext::decrypt( + const SaplingEncCiphertext &ciphertext, + const uint256 &ivk, + const uint256 &epk, + const uint256 &cmu +) +{ + uint256 pk_d; + return DecryptWithIvk(ciphertext, ivk, epk, cmu, pk_d); +} + +boost::optional> SaplingNotePlaintext::decrypt_with_note( + const SaplingEncCiphertext &ciphertext, + const uint256 &ivk, + const uint256 &epk, + const uint256 &cmu +) +{ + uint256 pk_d; + auto pt = DecryptWithIvk(ciphertext, ivk, epk, cmu, pk_d); + if (!pt) { + return boost::none; + } + // ivk_to_pkd succeeding implies d is a valid diversifier, which is all note(ivk) checks on top. + SaplingNote note(pt->d, pk_d, pt->value(), pt->rcm); + return std::make_pair(*pt, note); +} + boost::optional SaplingNotePlaintext::decrypt( const SaplingEncCiphertext &ciphertext, const uint256 &epk, diff --git a/src/zcash/Note.hpp b/src/zcash/Note.hpp index 47bebe740..72e7dc3f7 100644 --- a/src/zcash/Note.hpp +++ b/src/zcash/Note.hpp @@ -85,6 +85,17 @@ public: const uint256 &cmu ); + // decrypt(ciphertext, ivk, epk, cmu) that also returns the note. Building the note needs + // pk_d = [ivk]g_d, which the decryption derives anyway to check cmu; note(ivk) would derive it + // again (another Jubjub scalar multiplication). A distinct name rather than an overload, so it + // can never capture a call meant for the (epk, esk, pk_d, cmu) one. + static boost::optional> decrypt_with_note( + const SaplingEncCiphertext &ciphertext, + const uint256 &ivk, + const uint256 &epk, + const uint256 &cmu + ); + boost::optional note(const SaplingIncomingViewingKey& ivk) const; virtual ~SaplingNotePlaintext() {} From b1e3c5230a15b926c43ce5f8a21e2ce23296c15a Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:17:14 -0500 Subject: [PATCH 16/23] wallet: cache each Sapling note's decrypted plaintext in memory GetFilteredNotes, the walk behind every balance and note-listing RPC, sweep, consolidation, z_sendmany and z_mergetoaddress, decrypted every note it looked at on every call: ~0.5ms of Jubjub arithmetic per note, under LOCK2(cs_main, cs_wallet). A note's plaintext is fixed by its output (the txid commits to the ciphertext) and the ivk that decrypts it, so it never needs to be decrypted twice. SaplingNoteData gains an in-memory-only boost::optional {note, memo}; the address is the note's (d, pk_d). It follows the witnessRootValidated precedent: - never serialized, and left out of operator== and SaplingNoteDataIdentical, so filling it never makes a tx look changed and get rewritten (the replay rewrite storm 69bb035e7 removed); - FindMySaplingNotes fills it from the decryption that found the note (both the spending-key and the viewing-key-only loop); - UpdatedNoteData's replace branch keeps a plaintext the wallet already had, for the same outpoint and ivk only; - GetFilteredNotes now runs in two passes: pass 1 applies the tx, spent and locked filters and collects the notes that pass; whatever lacks a plaintext (everything after a restart, until the startup warm-up in a later commit) is decrypted once via the new FillSaplingNoteCache; pass 2 applies the address and spending-key filters and emits entries in the same order as before. Spentness, spendability, lock state and depth are still decided on every call; only the plaintext is cached. The A0 equivalence test passes unchanged (warm). New tests: - ColdCacheMatchesLegacy: the same grid with the cache dropped before every call; - CachedPlaintextMatchesFreshDecrypt: eager and lazy fills; - WarmPassDoesNotDecrypt: a fill counter shows a warm walk decrypts nothing, and spent or locked notes only when the caller asks for them; - CacheNeverSerialized: byte-identical SER_DISK/NETWORK/GETHASH output, empty after a load; - UpdatedNoteDataAndTheCache: no rewrite on a cache-only difference, carried on replace, never across another ivk; - SpendabilityNotCached; - FindMySaplingNotesFillsCache. Memory: ~616 bytes per note (the memo is 512), ~8MB for a 13k-note wallet, next to ~100KB per note of cached witnesses. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 184 +++++++++++++++++++++++++++- src/wallet/wallet.cpp | 112 +++++++++++------ src/wallet/wallet.h | 29 +++++ 3 files changed, 289 insertions(+), 36 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index 0415bc657..bc2ea9c30 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -121,8 +121,38 @@ class NoteCacheWallet : public CWallet { public: bool EncryptKeys(CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::EncryptKeys(vMasterKeyIn); } bool Unlock(const CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::Unlock(vMasterKeyIn); } + bool UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock = true) { + return CWallet::UpdatedNoteData(wtxIn, wtx, fSameBlock); + } + + // Drops every note's in-memory plaintext, as a restart does. + void ClearPlaintextCache() { + for (auto& p : mapWallet) + for (auto& item : p.second.mapSaplingNoteData) + item.second.cachedPlaintext = boost::none; + } + size_t CachedNotes() const { + size_t n = 0; + for (const auto& p : mapWallet) + for (const auto& item : p.second.mapSaplingNoteData) + n += item.second.cachedPlaintext ? 1 : 0; + return n; + } }; +void ExpectCacheMatchesFreshDecrypt(const SaplingNoteData& nd, const OutputDescription& od) +{ + ASSERT_TRUE(static_cast(nd.cachedPlaintext)); + auto fresh = SaplingNotePlaintext::decrypt_with_note(od.encCiphertext, nd.ivk, od.ephemeralKey, od.cm); + ASSERT_TRUE(static_cast(fresh)); + const SaplingNote& cached = nd.cachedPlaintext->note; + EXPECT_TRUE(cached.d == fresh->second.d); + EXPECT_EQ(fresh->second.pk_d, cached.pk_d); + EXPECT_EQ(fresh->second.r, cached.r); + EXPECT_EQ(fresh->second.value(), cached.value()); + EXPECT_TRUE(nd.cachedPlaintext->memo == fresh->first.memo()); +} + // The fake active chain is 0..TIP_HEIGHT; STALE_HEIGHT is a side block forked off it. constexpr int TIP_HEIGHT = 30; constexpr int STALE_HEIGHT = 25; @@ -357,7 +387,8 @@ protected: return entries; } - void ExpectEquivalent(const std::vector& minDepths) { + // cold: drop the plaintext cache before every call, so each one decrypts every note it returns. + void ExpectEquivalent(const std::vector& minDepths, bool cold = false) { const std::vector>> filters = { {"none", {}}, {"A", {paA}}, {"B", {paB}}, {"A+C", {paA, paC}}, }; @@ -368,6 +399,10 @@ protected: for (bool ignoreLocked : {false, true}) for (const auto& filter : filters) { std::string ctx = Describe(minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter.first); + if (cold) { + LOCK(wallet.cs_wallet); + wallet.ClearPlaintextCache(); + } ExpectSameEntries(Legacy(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked), Filtered(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked), ctx); @@ -588,3 +623,150 @@ TEST(WalletNoteCacheDiscovery, FindMySaplingNotes) { EXPECT_TRUE(toAdd.at(addrsA[1]) == ivkA); EXPECT_TRUE(toAdd.at(addrsA[2]) == ivkA); } + +// Every note FindMySaplingNotes finds comes with its plaintext, for both the spending-key loop and the +// viewing-key-only loop. +TEST(WalletNoteCacheDiscovery, FindMySaplingNotesFillsCache) { + NoteCacheWallet wallet; + auto skA = TestKey(0), skB = TestKey(1); + auto addrsA = AddressesOf(skA); + LOCK2(cs_main, wallet.cs_wallet); + ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0])); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress())); + CTransaction tx = MakeTx({MakeOutput(addrsA[0], 11), MakeOutput(addrsA[1], 12), MakeOutput(skB.DefaultAddress(), 13)}); + auto notes = wallet.FindMySaplingNotes(tx).first; + ASSERT_EQ(3u, notes.size()); + for (const auto& item : notes) + ExpectCacheMatchesFreshDecrypt(item.second, tx.vShieldedOutput[item.first.n]); +} + +// With the cache dropped before every call (a restart), the walk still returns exactly the old loop's +// entries: the lazy fill decrypts the same notes the same way. +TEST_F(WalletNoteCache, ColdCacheMatchesLegacy) { + ExpectEquivalent({0, 1, 11}, true); +} + +// The cache holds exactly what a fresh decryption gives, wherever it was filled. +TEST_F(WalletNoteCache, CachedPlaintextMatchesFreshDecrypt) { + LOCK2(cs_main, wallet.cs_wallet); + for (int pass = 0; pass < 2; pass++) { + if (pass == 1) { + wallet.ClearPlaintextCache(); + Filtered({}, 0, INT_MAX, false, false, false); // lazy fill of every confirmed note + } + size_t checked = 0; + for (const auto& p : wallet.mapWallet) { + for (const auto& item : p.second.mapSaplingNoteData) { + if (!item.second.cachedPlaintext) + continue; + ExpectCacheMatchesFreshDecrypt(item.second, p.second.vShieldedOutput[item.first.n]); + checked++; + } + } + EXPECT_GE(checked, 9u) << "pass " << pass; + } +} + +// A warm walk decrypts nothing; a cold one decrypts each note it needs exactly once, and spent notes +// only when the caller asks for them. +TEST_F(WalletNoteCache, WarmPassDoesNotDecrypt) { + LOCK2(cs_main, wallet.cs_wallet); + const uint64_t base = wallet.nSaplingNoteCacheFills; + Filtered({}, 1, INT_MAX, true, true, true); + EXPECT_EQ(base, wallet.nSaplingNoteCacheFills) << "FindMySaplingNotes already filled every note"; + + wallet.ClearPlaintextCache(); + EXPECT_EQ(0u, wallet.CachedNotes()); + Filtered({}, 1, INT_MAX, true, false, true); + // tx1 (2), tx3 (3), tx4 (1), tx7 (1): tx2's note is locked and tx6's is spent, so neither is decrypted. + EXPECT_EQ(base + 7, wallet.nSaplingNoteCacheFills); + Filtered({}, 1, INT_MAX, true, false, true); + Filtered({paA}, 0, INT_MAX, true, true, true); + EXPECT_EQ(base + 7, wallet.nSaplingNoteCacheFills) << "warm"; + + Filtered({}, 1, INT_MAX, false, false, false); + EXPECT_EQ(base + 9, wallet.nSaplingNoteCacheFills) << "the locked and the spent note, once"; + Filtered({}, 1, INT_MAX, false, false, false); + EXPECT_EQ(base + 9, wallet.nSaplingNoteCacheFills); +} + +// The cache never reaches wallet.dat, the network or a tx hash: serialization is byte-identical with it +// filled or empty, and a deserialized entry comes back empty. +TEST_F(WalletNoteCache, CacheNeverSerialized) { + LOCK2(cs_main, wallet.cs_wallet); + const uint256 txid = txids[2]; // tx3: three notes + CWalletTx filled = wallet.mapWallet.at(txid); + ASSERT_EQ(3u, filled.mapSaplingNoteData.size()); + CWalletTx empty = filled; + for (auto& item : empty.mapSaplingNoteData) { + ASSERT_TRUE(static_cast(item.second.cachedPlaintext)); + item.second.cachedPlaintext = boost::none; + } + for (int type : {SER_DISK, SER_NETWORK, SER_GETHASH}) { + CDataStream a(type, CLIENT_VERSION), b(type, CLIENT_VERSION); + a << filled; + b << empty; + EXPECT_TRUE(a.str() == b.str()) << "CWalletTx, type " << type; + CDataStream c(type, CLIENT_VERSION), d(type, CLIENT_VERSION); + c << filled.mapSaplingNoteData.begin()->second; + d << empty.mapSaplingNoteData.begin()->second; + EXPECT_TRUE(c.str() == d.str()) << "SaplingNoteData, type " << type; + } + EXPECT_EQ(txid, filled.GetHash()); + + CDataStream ss(SER_DISK, CLIENT_VERSION); + ss << filled; + CWalletTx back; + ss >> back; + ASSERT_EQ(3u, back.mapSaplingNoteData.size()); + for (const auto& item : back.mapSaplingNoteData) + EXPECT_FALSE(static_cast(item.second.cachedPlaintext)); +} + +// UpdatedNoteData: a difference in the cache alone is no update (no rewrite to disk); the replace branch +// keeps a plaintext the wallet already had; and nothing is carried across a different ivk. +TEST_F(WalletNoteCache, UpdatedNoteDataAndTheCache) { + LOCK2(cs_main, wallet.cs_wallet); + const uint256 txid = txids[2]; + CWalletTx& wtx = wallet.mapWallet.at(txid); + const SaplingOutPoint op0(txid, 0), op1(txid, 1); + + // Same note data, but arriving without plaintext: unchanged, and the wallet keeps its cache. + CWalletTx replay = wtx; + for (auto& item : replay.mapSaplingNoteData) + item.second.cachedPlaintext = boost::none; + EXPECT_FALSE(wallet.UpdatedNoteData(replay, wtx)); + EXPECT_TRUE(static_cast(wtx.mapSaplingNoteData.at(op0).cachedPlaintext)); + + // A real update (an extra note) without plaintext for the existing ones: replaced, cache carried. + CWalletTx incoming = replay; + incoming.mapSaplingNoteData.erase(op1); + wtx.mapSaplingNoteData.erase(op1); + incoming.mapSaplingNoteData[op1] = SaplingNoteData(TestKey(2).expsk.full_viewing_key().in_viewing_key()); + EXPECT_TRUE(wallet.UpdatedNoteData(incoming, wtx)); + ExpectCacheMatchesFreshDecrypt(wtx.mapSaplingNoteData.at(op0), wtx.vShieldedOutput[0]); + EXPECT_FALSE(static_cast(wtx.mapSaplingNoteData.at(op1).cachedPlaintext)) << "nothing to carry"; + + // Incoming note data under another ivk: its (absent) plaintext is not replaced by ours. + CWalletTx otherKey = wtx; + for (auto& item : otherKey.mapSaplingNoteData) { + item.second.ivk = TestKey(9).expsk.full_viewing_key().in_viewing_key(); + item.second.cachedPlaintext = boost::none; + } + EXPECT_TRUE(wallet.UpdatedNoteData(otherKey, wtx)); + for (const auto& item : wtx.mapSaplingNoteData) + EXPECT_FALSE(static_cast(item.second.cachedPlaintext)); +} + +// Spendability is decided per call, never cached: a note to a viewing-key-only address joins the +// requireSpendingKey results as soon as its spending key is added, with its plaintext already cached. +TEST_F(WalletNoteCache, SpendabilityNotCached) { + LOCK2(cs_main, wallet.cs_wallet); + auto before = Filtered({paB}, 1, INT_MAX, true, true, false); + EXPECT_EQ(0u, before.size()); + EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, false, false).size()); + const uint64_t fills = wallet.nSaplingNoteCacheFills; + ASSERT_TRUE(wallet.AddSaplingZKey(TestKey(1), paB)); + EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, true, false).size()); + EXPECT_EQ(fills, wallet.nSaplingNoteCacheFills); +} diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 9cf5161f2..dd2d91556 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -2078,6 +2078,14 @@ bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSame } } + // Keep a decrypted plaintext the wallet already holds (in memory only; a pure function of the + // outpoint and ivk) when the incoming note data lacks one. + for (const mapSaplingNoteData_t::value_type& nd : wtx.mapSaplingNoteData) { + mapSaplingNoteData_t::iterator it = tmp.find(nd.first); + if (it != tmp.end() && !it->second.cachedPlaintext && nd.second.cachedPlaintext && it->second.ivk == nd.second.ivk) + it->second.cachedPlaintext = nd.second.cachedPlaintext; + } + // A replayed or rescanned tx usually carries nothing new; don't rewrite it to disk. Do have // VerifyAndSetInitialWitness re-check the kept witnesses against the active chain once (the // flag is in memory only): the chain may have moved under them, and it keeps a rescan able to @@ -2280,6 +2288,7 @@ std::pair CWallet::FindMySap SaplingOutPoint op {hash, i}; SaplingNoteData nd; nd.ivk = ivk; + nd.cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; noteData.insert(std::make_pair(op, nd)); found = true; break; @@ -2288,7 +2297,7 @@ std::pair CWallet::FindMySap if (!found) { for (auto it = mapSaplingIncomingViewingKeys.begin(); it != mapSaplingIncomingViewingKeys.end(); ++it) { SaplingIncomingViewingKey ivk = it-> second; - auto result = SaplingNotePlaintext::decrypt(output.encCiphertext, ivk, output.ephemeralKey, output.cm); + auto result = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, ivk, output.ephemeralKey, output.cm); if (!result) { continue; } @@ -2297,6 +2306,7 @@ std::pair CWallet::FindMySap SaplingOutPoint op {hash, i}; SaplingNoteData nd; nd.ivk = ivk; + nd.cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; noteData.insert(std::make_pair(op, nd)); break; } @@ -5717,6 +5727,22 @@ bool CMerkleTx::AcceptToMemoryPool(bool fLimitFree, bool fRejectAbsurdFee) return ::AcceptToMemoryPool(mempool, state, *this, fLimitFree, NULL, fRejectAbsurdFee); } +unsigned int CWallet::FillSaplingNoteCache(const std::vector& notes) +{ + AssertLockHeld(cs_wallet); + unsigned int nFilled = 0; + for (const SaplingNoteToDecrypt& n : notes) { + auto result = SaplingNotePlaintext::decrypt_with_note( + n.output->encCiphertext, n.nd->ivk, n.output->ephemeralKey, n.output->cm); + if (result) { + n.nd->cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; + nFilled++; + } + } + nSaplingNoteCacheFills += nFilled; + return nFilled; +} + // Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through // GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it // never found a fully shielded tx, so those notes were silently skipped. The depth already locates @@ -5769,10 +5795,19 @@ void CWallet::GetFilteredNotes( const size_t nEntriesBefore = saplingEntries.size(); unsigned int nTxs = 0, nNotes = 0, nSpent = 0; + // Pass 1: the notes that pass every filter not needing the plaintext, in wallet order. + struct Candidate { + const SaplingOutPoint* op; + const SaplingNoteData* nd; + int nDepth; + }; + std::vector candidates; + std::vector uncached; for (auto & p : mapWallet) { // By reference: a copy deep-copies every note's witness list (up to WITNESS_CACHE_SIZE - // witnesses each), and it was made before any filter ran. - const CWalletTx& wtx = p.second; + // witnesses each), and it was made before any filter ran. Not const: a missing plaintext is + // cached onto the wallet's own entry. + CWalletTx& wtx = p.second; // Most wallet txs (coinbase, sends from other wallets) carry no notes of ours. if (wtx.mapSaplingNoteData.empty()) @@ -5800,13 +5835,13 @@ void CWallet::GetFilteredNotes( } nTxs++; - for (const auto & pair : wtx.mapSaplingNoteData) { + for (auto & pair : wtx.mapSaplingNoteData) { const SaplingOutPoint& op = pair.first; - const SaplingNoteData& nd = pair.second; + SaplingNoteData& nd = pair.second; nNotes++; - // The cheap filters run before the decryption, which costs ~0.5-0.8ms of Jubjub - // arithmetic per note. Spent notes stay in the wallet (-deletetx is off by default). + // The cheap filters run before any decryption, which costs ~0.5ms of Jubjub arithmetic + // per note. Spent notes stay in the wallet (-deletetx is off by default). if (ignoreSpent && nd.nullifier && IsSaplingSpent(*nd.nullifier)) { nSpent++; continue; @@ -5818,36 +5853,43 @@ void CWallet::GetFilteredNotes( continue; } - // One key agreement and one pk_d derivation; the note and its address come with it. - auto maybe_pt = SaplingNotePlaintext::decrypt_with_note( - wtx.vShieldedOutput[op.n].encCiphertext, - nd.ivk, - wtx.vShieldedOutput[op.n].ephemeralKey, - wtx.vShieldedOutput[op.n].cm); - assert(static_cast(maybe_pt)); - const SaplingNotePlaintext& notePt = maybe_pt->first; - const libzcash::SaplingNote& note = maybe_pt->second; - const libzcash::SaplingPaymentAddress pa(note.d, note.pk_d); + assert(op.n < wtx.vShieldedOutput.size()); + candidates.push_back(Candidate { &op, &nd, nDepth }); + if (!nd.cachedPlaintext) + uncached.push_back(SaplingNoteToDecrypt { &nd, &wtx.vShieldedOutput[op.n] }); + } + } - // skip notes which belong to a different payment address in the wallet - if (!(filterAddresses.empty() || filterAddresses.count(pa))) { + // Decrypt what the cache lacks: normally nothing, since FindMySaplingNotes fills new notes and the + // startup warm-up fills the ones loaded from wallet.dat. + const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); + + // Pass 2: the filters that need the note's address. + saplingEntries.reserve(saplingEntries.size() + candidates.size()); + for (const Candidate& c : candidates) { + // A wallet note that doesn't decrypt under its own ivk is corrupt; as before, that's fatal here. + assert(static_cast(c.nd->cachedPlaintext)); + const SaplingNoteCache& plaintext = *c.nd->cachedPlaintext; + const libzcash::SaplingPaymentAddress pa(plaintext.note.d, plaintext.note.pk_d); + + // skip notes which belong to a different payment address in the wallet + if (!(filterAddresses.empty() || filterAddresses.count(pa))) { + continue; + } + + // skip notes which cannot be spent + if (requireSpendingKey) { + libzcash::SaplingIncomingViewingKey ivk; + libzcash::SaplingFullViewingKey fvk; + if (!(GetSaplingIncomingViewingKey(pa, ivk) && + GetSaplingFullViewingKey(ivk, fvk) && + HaveSaplingSpendingKey(fvk))) { continue; } - - // skip notes which cannot be spent - if (requireSpendingKey) { - libzcash::SaplingIncomingViewingKey ivk; - libzcash::SaplingFullViewingKey fvk; - if (!(GetSaplingIncomingViewingKey(pa, ivk) && - GetSaplingFullViewingKey(ivk, fvk) && - HaveSaplingSpendingKey(fvk))) { - continue; - } - } - - saplingEntries.push_back(SaplingNoteEntry { - op, pa, note, notePt.memo(), nDepth }); } + + saplingEntries.push_back(SaplingNoteEntry { + *c.op, pa, plaintext.note, plaintext.memo, c.nDepth }); } // A slow walk holds cs_main long enough to stall block connection, so it is always logged; a @@ -5855,9 +5897,9 @@ void CWallet::GetFilteredNotes( // counts go to the default log only with -debug=bench too. const int64_t nWalkMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - walkStart).count(); if (LogAcceptCategory("bench")) { - LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d txs=%u/%u notes=%u spent=%u entries=%u walk=%dms\n", + LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d txs=%u/%u notes=%u spent=%u decrypted=%u entries=%u walk=%dms\n", __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nTxs, (unsigned int)mapWallet.size(), - nNotes, nSpent, (unsigned int)(saplingEntries.size() - nEntriesBefore), nWalkMs); + nNotes, nSpent, nFilled, (unsigned int)(saplingEntries.size() - nEntriesBefore), nWalkMs); } else if (nWalkMs >= 500) { LogPrintf("%s: minDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d walk=%dms\n", __func__, minDepth, ignoreSpent, requireSpendingKey, ignoreLocked, nWalkMs); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 08327a9b3..dc421fac0 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -301,6 +301,14 @@ class SproutNoteData }; +/** A Sapling note's decrypted plaintext as the wallet uses it: the note (value, rcm, and the address it + * was sent to as (d, pk_d)) and its memo. */ +struct SaplingNoteCache +{ + libzcash::SaplingNote note; + std::array memo; +}; + class SaplingNoteData { public: @@ -328,6 +336,15 @@ public: // read true and short-circuit the witness self-heal in VerifyAndSetInitialWitness. bool witnessRootValidated = false; + //In Memory Only + // The note's decrypted plaintext, so the balance and note-listing calls don't redo the Sapling + // decryption (several Jubjub scalar multiplications) for every note on every call. It is a pure + // function of (txid, output index, ivk), so nothing invalidates it. Never serialized, and left out + // of operator== and SaplingNoteDataIdentical on purpose: a filled and an unfilled entry persist the + // same state, so filling one must never make a tx look changed and get rewritten. Filled by + // FindMySaplingNotes when the note is found, otherwise by FillSaplingNoteCache. + boost::optional cachedPlaintext; + ADD_SERIALIZE_METHODS; template @@ -1469,6 +1486,18 @@ public: bool LoadCryptedMnemonicEntropy(const uint256& entropyFp, const std::vector& vchCryptedSecret); /* Find notes filtered by payment address, min depth, ability to spend */ + /** A wallet note whose decrypted plaintext is not cached yet, with the output it decrypts. */ + struct SaplingNoteToDecrypt { + SaplingNoteData* nd; + const OutputDescription* output; + }; + /** Decrypts each note into nd->cachedPlaintext and returns how many it filled. A note that doesn't + * decrypt under its ivk is left unfilled. Caller holds cs_wallet. */ + unsigned int FillSaplingNoteCache(const std::vector& notes); + /** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted). + * Guarded by cs_wallet. */ + uint64_t nSaplingNoteCacheFills = 0; + void GetFilteredNotes(std::vector& saplingEntries, std::string address, int minDepth=1, From 83a14a7bfb3857ea7c1c8a3fd0935fcacf680a90 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:21:24 -0500 Subject: [PATCH 17/23] wallet/init: decrypt every wallet note into the plaintext cache at startup The plaintext cache is in memory only, so a restart empties it. Without a warm-up, the first balance call after a restart decrypts the whole wallet under LOCK2(cs_main, cs_wallet): ~6s for a 12k-note wallet. That is the observed failure: restart, the GUI connects, and the node looks stuck. CWallet::WarmSaplingNoteCache decrypts every note whose plaintext isn't cached, spent ones included, because z_listreceivedbyaddress (ignoreSpent=false) is called once per address by the GUI's history scan. init runs it at the end of the wallet block, just before SetBroadcastTransactions. At that point RPC is still in warmup (it shows "Decrypting wallet notes...") and the wallet notification thread, whose startup witness rebuild can hold cs_wallet for a long time, has not started yet. It takes cs_wallet only. -notecachewarmup=0 (debug help) skips it, which leaves the lazy fill in GetFilteredNotes to do the work. Unlike GetFilteredNotes, the warm-up never asserts. A note that doesn't decrypt under its ivk, or names an output its tx lacks, is counted in the summary line and listed under -debug=zrpcunsafe. A corrupt note therefore can't stop the node from starting, or from serving the key-export RPCs allowed during warmup; GetFilteredNotes still asserts on it, as before. Tests: - WarmUpFillsEveryNote: spent, locked, unconfirmed and stale-block notes included; walks afterwards decrypt nothing. - WarmUpSkipsBadNotes: undecryptable and out-of-range notes are skipped without aborting. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 47 ++++++++++++++++++++++++++++- src/init.cpp | 10 ++++++ src/wallet/wallet.cpp | 37 +++++++++++++++++++++++ src/wallet/wallet.h | 3 ++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index bc2ea9c30..9782c0ddb 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -467,7 +467,7 @@ TEST_F(WalletNoteCache, HeightFromDepth) { // Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone // else): output 0 is spent by a mined tx, output 1 is locked. If the walk decrypted either one, the -// decrypt assert would abort. +// decrypt assert would abort. A third, locked too, names an output the tx doesn't have. class WalletNoteCacheUndecryptable : public WalletNoteCache { protected: uint256 txBad; @@ -482,12 +482,14 @@ protected: CWalletTx wtx(&wallet, tx); wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf); wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA); + wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 7)] = SaplingNoteData(ivkA); EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); depthOf[txBad] = 4; txids.push_back(txBad); AddTx(MakeTx({}, {nf}), 4); wallet.LockNote(SaplingOutPoint(txBad, 1)); + wallet.LockNote(SaplingOutPoint(txBad, 7)); } }; @@ -770,3 +772,46 @@ TEST_F(WalletNoteCache, SpendabilityNotCached) { EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, true, false).size()); EXPECT_EQ(fills, wallet.nSaplingNoteCacheFills); } + +// The startup warm-up decrypts every note the wallet holds: spent, locked, unconfirmed and stale ones too +// (z_listreceivedbyaddress asks for spent notes), after which a walk decrypts nothing. +TEST_F(WalletNoteCache, WarmUpFillsEveryNote) { + LOCK2(cs_main, wallet.cs_wallet); + size_t total = 0; + for (const auto& p : wallet.mapWallet) + total += p.second.mapSaplingNoteData.size(); + ASSERT_EQ(11u, total); // tx1 2, tx2 1, tx3 3, tx4 1, unmined 1, stale 1, tx6 1, tx7 1 + + wallet.ClearPlaintextCache(); + const uint64_t base = wallet.nSaplingNoteCacheFills; + wallet.WarmSaplingNoteCache(); + EXPECT_EQ(total, wallet.CachedNotes()); + EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills); + + wallet.WarmSaplingNoteCache(); + EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "nothing left to do"; + Filtered({}, 0, INT_MAX, false, false, false); + Filtered({paC}, 11, INT_MAX, true, true, true); + EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "walks find everything cached"; +} + +// The warm-up never aborts: notes that don't decrypt, or name an output the tx doesn't have, are left +// unfilled and everything else is filled. +TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) { + LOCK2(cs_main, wallet.cs_wallet); + wallet.ClearPlaintextCache(); + wallet.WarmSaplingNoteCache(); + const auto& bad = wallet.mapWallet.at(txBad).mapSaplingNoteData; + ASSERT_EQ(3u, bad.size()); + for (const auto& item : bad) + EXPECT_FALSE(static_cast(item.second.cachedPlaintext)) << item.first.n; + size_t cached = 0, total = 0; + for (const auto& p : wallet.mapWallet) { + for (const auto& item : p.second.mapSaplingNoteData) { + total++; + cached += item.second.cachedPlaintext ? 1 : 0; + } + } + EXPECT_EQ(total - 3, cached); + EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "the bad notes are spent or locked"; +} diff --git a/src/init.cpp b/src/init.cpp index 42a5a6dbc..154a9029e 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -513,6 +513,8 @@ std::string HelpMessage(HelpMessageMode mode) // If this is used incorrectly (-rescanheight too large), then the local wallet may attempt to spend funds which it does not have witness data about // which will cause a "missing inputs" error when added to the mempool. Rescanning from correct height will fix this. strUsage += HelpMessageOpt("-keepnotewitnesscache", _("Keep partial Sapling Note Witness cache. Must be used with -rescanheight to find missing cache items.")); + if (showDebug) + strUsage += HelpMessageOpt("-notecachewarmup", "Decrypt all wallet notes into memory at startup, so the first balance call after a restart doesn't (default: 1)"); strUsage += HelpMessageOpt("-rescan", _("Rescan the block chain for missing wallet transactions") + " " + _("on startup")); strUsage += HelpMessageOpt("-rescanheight", _("Rescan from specified height when rescan=1 on startup")); strUsage += HelpMessageOpt("-salvagewallet", _("Attempt to recover private keys from a corrupt wallet.dat") + " " + _("on startup")); @@ -2706,6 +2708,14 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) } } } + // Decrypt the wallet's notes into the in-memory plaintext cache now, while RPC is still in warmup + // and before the wallet notification thread (started below) can want cs_wallet. Otherwise the + // first balance call after a restart decrypts them all under cs_main. + if (GetBoolArg("-notecachewarmup", true) && !ShutdownRequested()) { + uiInterface.InitMessage(_("Decrypting wallet notes...")); + pwalletMain->WarmSaplingNoteCache(); + } + pwalletMain->SetBroadcastTransactions(GetBoolArg("-walletbroadcast", true)); } // (!fDisableWallet) #else // ENABLE_WALLET diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index dd2d91556..b6e22e492 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5743,6 +5743,43 @@ unsigned int CWallet::FillSaplingNoteCache(const std::vector uncached; + unsigned int nNotes = 0, nBad = 0; + for (auto& p : mapWallet) { + CWalletTx& wtx = p.second; + for (auto& item : wtx.mapSaplingNoteData) { + nNotes++; + if (item.second.cachedPlaintext) + continue; + if (item.first.n >= wtx.vShieldedOutput.size()) { + LogPrint("zrpcunsafe", "%s: note %s:%u has no such output\n", __func__, item.first.hash.ToString(), item.first.n); + nBad++; + continue; + } + uncached.push_back(SaplingNoteToDecrypt { &item.second, &wtx.vShieldedOutput[item.first.n] }); + } + } + const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); + for (const SaplingNoteToDecrypt& n : uncached) { + if (!n.nd->cachedPlaintext) { + LogPrint("zrpcunsafe", "%s: a note of output %s does not decrypt under its ivk\n", __func__, n.output->cm.ToString()); + nBad++; + } + } + const int64_t nMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, nFilled, nNotes, nMs, + nBad ? strprintf("; %u could not be decrypted (-debug=zrpcunsafe lists them)", nBad) : ""); +} + // Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through // GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it // never found a fully shielded tx, so those notes were silently skipped. The depth already locates diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index dc421fac0..47e39bc75 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -1497,6 +1497,9 @@ public: /** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted). * Guarded by cs_wallet. */ uint64_t nSaplingNoteCacheFills = 0; + /** Decrypts every wallet note whose plaintext isn't cached yet (all of them after wallet.dat is loaded). + * Never asserts: a note that won't decrypt is logged and left unfilled. Run at startup. */ + void WarmSaplingNoteCache(); void GetFilteredNotes(std::vector& saplingEntries, std::string address, From 43635b7fd77de09578af487d0e90ceaedc1e195f Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:23:10 -0500 Subject: [PATCH 18/23] wallet: decrypt big note-cache batches on worker threads The startup warm-up, or a lazy fill after -notecachewarmup=0, decrypts the whole wallet: ~0.5ms per note, ~6s serial for 12k notes. Each decryption is independent: it reads its own output and writes only its own note's cachedPlaintext. So FillSaplingNoteCache now splits batches of 128 or more notes round-robin across -witnessbuildthreads (default: hardware_concurrency) workers, at least 64 notes per thread. It follows BuildWitnessCache's Phase 2 pattern: - the caller keeps cs_wallet held throughout; - workers take no lock and assert none; under DEBUG_LOCKORDER a fresh thread has no lock stack; - per-shard fill counters are summed after join; - nothing escapes a worker: a throwing decryption leaves that note unfilled, like one that doesn't decrypt; - if the OS refuses a thread, the remaining shards run inline. librustzcash is already called concurrently by BuildWitnessCache's workers, and libsodium is thread-safe after sodium_init. ParallelFillMatchesSerial: 300 notes across two keys and diversified addresses decrypt to exactly the same cache with 1, 4 and 7 threads. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 51 +++++++++++++++++++++++++ src/wallet/wallet.cpp | 59 +++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 8 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index 9782c0ddb..4cada8aef 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -815,3 +815,54 @@ TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) { EXPECT_EQ(total - 3, cached); EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "the bad notes are spent or locked"; } + +// Big batches are decrypted on several threads; the result is exactly the serial one. +TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) { + NoteCacheWallet wallet; + auto skA = TestKey(0), skB = TestKey(1); + auto addrsA = AddressesOf(skA); + LOCK2(cs_main, wallet.cs_wallet); + ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0])); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress())); + uint64_t value = 0; + for (int t = 0; t < 30; t++) { + std::vector outs; + for (int i = 0; i < 10; i++) + outs.push_back(MakeOutput(i % 3 == 2 ? skB.DefaultAddress() : addrsA[i % addrsA.size()], ++value)); + CTransaction tx = MakeTx(outs); + CWalletTx wtx(&wallet, tx); + mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first; + ASSERT_EQ(10u, found.size()); + wtx.SetSaplingNoteData(found); + ASSERT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); + } + + auto snapshot = [&]() { + std::vector> out; + for (const auto& p : wallet.mapWallet) + for (const auto& item : p.second.mapSaplingNoteData) + if (item.second.cachedPlaintext) + out.push_back(std::make_pair(item.first, *item.second.cachedPlaintext)); + return out; + }; + auto eager = snapshot(); + ASSERT_EQ(300u, eager.size()); + + for (const char* threads : {"1", "4", "7"}) { + mapArgs["-witnessbuildthreads"] = threads; + wallet.ClearPlaintextCache(); + const uint64_t base = wallet.nSaplingNoteCacheFills; + wallet.WarmSaplingNoteCache(); + EXPECT_EQ(base + 300, wallet.nSaplingNoteCacheFills) << threads << " thread(s)"; + auto filled = snapshot(); + ASSERT_EQ(eager.size(), filled.size()) << threads << " thread(s)"; + for (size_t i = 0; i < eager.size(); i++) { + EXPECT_TRUE(eager[i].first == filled[i].first); + const SaplingNote& a = eager[i].second.note; + const SaplingNote& b = filled[i].second.note; + EXPECT_TRUE(a.d == b.d && a.pk_d == b.pk_d && a.r == b.r && a.value() == b.value()) << threads << " thread(s), note " << i; + EXPECT_TRUE(eager[i].second.memo == filled[i].second.memo); + } + } + mapArgs.erase("-witnessbuildthreads"); +} diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index b6e22e492..885387e9f 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5730,16 +5730,59 @@ bool CMerkleTx::AcceptToMemoryPool(bool fLimitFree, bool fRejectAbsurdFee) unsigned int CWallet::FillSaplingNoteCache(const std::vector& notes) { AssertLockHeld(cs_wallet); - unsigned int nFilled = 0; - for (const SaplingNoteToDecrypt& n : notes) { - auto result = SaplingNotePlaintext::decrypt_with_note( - n.output->encCiphertext, n.nd->ivk, n.output->ephemeralKey, n.output->cm); - if (result) { - n.nd->cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; - nFilled++; - } + + // Each decryption is independent: it reads its own output and writes only its own note's + // cachedPlaintext. So a big batch (a whole wallet after a restart, ~0.5ms a note) is split across + // threads the way BuildWitnessCache's Phase 2 is, while this thread keeps cs_wallet held. Workers + // must not lock, or even assert a lock: under DEBUG_LOCKORDER a fresh thread has no lock stack. + // Spawning costs ~1ms, so small batches stay serial. + int nPar = 1; + if (notes.size() >= 128) { + nPar = (int)GetArg("-witnessbuildthreads", 0); + if (nPar <= 0) nPar = (int)std::thread::hardware_concurrency(); + nPar = std::max(1, std::min(nPar, (int)(notes.size() / 64))); } + + std::vector filled(nPar, 0); + auto worker = [&](int tid) { + for (size_t k = (size_t)tid; k < notes.size(); k += (size_t)nPar) { + const SaplingNoteToDecrypt& n = notes[k]; + try { + auto result = SaplingNotePlaintext::decrypt_with_note( + n.output->encCiphertext, n.nd->ivk, n.output->ephemeralKey, n.output->cm); + if (result) { + n.nd->cachedPlaintext = SaplingNoteCache{ result->second, result->first.memo() }; + filled[tid]++; + } + } catch (...) { + // Left unfilled, exactly like a note that doesn't decrypt; callers handle that. Nothing may + // escape a std::thread. + } + } + }; + + if (nPar <= 1) { + worker(0); + } else { + // If the OS refuses a thread, run the remaining shards here instead of letting the exception + // unwind past joinable threads (std::terminate). Each shard still runs exactly once. + std::vector threads; + threads.reserve(nPar); + int t = 0; + try { + for (; t < nPar; t++) threads.emplace_back(worker, t); + } catch (const std::exception& e) { + LogPrintf("%s: could only start %d of %d threads (%s); decrypting the rest inline\n", __func__, t, nPar, e.what()); + } + for (int u = t; u < nPar; u++) worker(u); + for (std::thread& th : threads) th.join(); + } + + unsigned int nFilled = 0; + for (unsigned int f : filled) + nFilled += f; nSaplingNoteCacheFills += nFilled; + LogPrint("bench", "%s: decrypted %u of %u notes using %d thread(s)\n", __func__, nFilled, (unsigned int)notes.size(), nPar); return nFilled; } From 23877e1038d871c60a152a22cf6f14375a375b90 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:27:01 -0500 Subject: [PATCH 19/23] wallet: -checknotecache, a diagnostic for the note plaintext cache With -checknotecache=1 (debug help), GetFilteredNotes decrypts every note it returns again. If the cached plaintext differs from the fresh decryption, it logs the note and replaces the cache entry with the fresh value. It costs the full decryption the cache exists to avoid, so it is off by default; it is there for validating the cache on a real wallet, or for diagnosing a suspected cache problem in the field. CheckNoteCacheRepairsADifference: by default a tampered cache entry is returned as is (the cache is trusted); with the option it is repaired and the fresh value is returned. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 21 ++++++++++++++++++++ src/init.cpp | 2 ++ src/wallet/wallet.cpp | 30 ++++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index 4cada8aef..1c7cc944a 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -866,3 +866,24 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) { } mapArgs.erase("-witnessbuildthreads"); } + +// The cache is trusted as is; -checknotecache decrypts every hit again, and logs and repairs a difference. +TEST_F(WalletNoteCache, CheckNoteCacheRepairsADifference) { + LOCK2(cs_main, wallet.cs_wallet); + const SaplingOutPoint op(txids[3], 0); // tx4: one note to C, value 7000 + SaplingNoteData& nd = wallet.mapWallet.at(op.hash).mapSaplingNoteData.at(op); + ASSERT_TRUE(static_cast(nd.cachedPlaintext)); + const SaplingNote good = nd.cachedPlaintext->note; + nd.cachedPlaintext->note = SaplingNote(good.d, good.pk_d, good.value() + 1, good.r); + + auto trusted = Filtered({paC}, 21, INT_MAX, true, false, false); + ASSERT_EQ(1u, trusted.size()); + EXPECT_EQ(7001u, trusted[0].note.value()) << "without the check, the cache is what gets returned"; + + mapArgs["-checknotecache"] = "1"; + auto checked = Filtered({paC}, 21, INT_MAX, true, false, false); + mapArgs.erase("-checknotecache"); + ASSERT_EQ(1u, checked.size()); + EXPECT_EQ(7000u, checked[0].note.value()); + EXPECT_EQ(7000u, nd.cachedPlaintext->note.value()) << "repaired"; +} diff --git a/src/init.cpp b/src/init.cpp index 154a9029e..ea04c9025 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -515,6 +515,8 @@ std::string HelpMessage(HelpMessageMode mode) strUsage += HelpMessageOpt("-keepnotewitnesscache", _("Keep partial Sapling Note Witness cache. Must be used with -rescanheight to find missing cache items.")); if (showDebug) strUsage += HelpMessageOpt("-notecachewarmup", "Decrypt all wallet notes into memory at startup, so the first balance call after a restart doesn't (default: 1)"); + if (showDebug) + strUsage += HelpMessageOpt("-checknotecache", "Decrypt every cached wallet note again on each balance call and log any difference from the cache (slow; default: 0)"); strUsage += HelpMessageOpt("-rescan", _("Rescan the block chain for missing wallet transactions") + " " + _("on startup")); strUsage += HelpMessageOpt("-rescanheight", _("Rescan from specified height when rescan=1 on startup")); strUsage += HelpMessageOpt("-salvagewallet", _("Attempt to recover private keys from a corrupt wallet.dat") + " " + _("on startup")); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 885387e9f..7497f419c 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -5823,6 +5823,25 @@ void CWallet::WarmSaplingNoteCache() nBad ? strprintf("; %u could not be decrypted (-debug=zrpcunsafe lists them)", nBad) : ""); } +// -checknotecache: decrypts a cached note again, and logs and repairs any difference. A diagnostic for +// the cache; it costs the full decryption it exists to avoid. +static void CheckSaplingNoteCache(const SaplingOutPoint& op, SaplingNoteData& nd, const OutputDescription& output) +{ + auto fresh = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, nd.ivk, output.ephemeralKey, output.cm); + if (!fresh) { + LogPrintf("%s: note %s:%u has a cached plaintext but no longer decrypts\n", __func__, op.hash.ToString(), op.n); + return; + } + const SaplingNoteCache& cached = *nd.cachedPlaintext; + const libzcash::SaplingNote& note = fresh->second; + if (!(cached.note.d == note.d && cached.note.pk_d == note.pk_d && cached.note.r == note.r && + cached.note.value() == note.value() && cached.memo == fresh->first.memo())) { + LogPrintf("%s: cached plaintext of note %s:%u differs from a fresh decryption; using the fresh one\n", + __func__, op.hash.ToString(), op.n); + nd.cachedPlaintext = SaplingNoteCache{ note, fresh->first.memo() }; + } +} + // Replaces tx_height() on the wallet's note paths. tx_height() re-read the tx from disk through // GetTransaction, once per wallet tx (or per returned note) under LOCK2, and on a -txindex=0 node it // never found a fully shielded tx, so those notes were silently skipped. The depth already locates @@ -5878,7 +5897,8 @@ void CWallet::GetFilteredNotes( // Pass 1: the notes that pass every filter not needing the plaintext, in wallet order. struct Candidate { const SaplingOutPoint* op; - const SaplingNoteData* nd; + SaplingNoteData* nd; + const OutputDescription* output; int nDepth; }; std::vector candidates; @@ -5934,9 +5954,10 @@ void CWallet::GetFilteredNotes( } assert(op.n < wtx.vShieldedOutput.size()); - candidates.push_back(Candidate { &op, &nd, nDepth }); + const OutputDescription* output = &wtx.vShieldedOutput[op.n]; + candidates.push_back(Candidate { &op, &nd, output, nDepth }); if (!nd.cachedPlaintext) - uncached.push_back(SaplingNoteToDecrypt { &nd, &wtx.vShieldedOutput[op.n] }); + uncached.push_back(SaplingNoteToDecrypt { &nd, output }); } } @@ -5945,10 +5966,13 @@ void CWallet::GetFilteredNotes( const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); // Pass 2: the filters that need the note's address. + const bool fCheckCache = GetBoolArg("-checknotecache", false); saplingEntries.reserve(saplingEntries.size() + candidates.size()); for (const Candidate& c : candidates) { // A wallet note that doesn't decrypt under its own ivk is corrupt; as before, that's fatal here. assert(static_cast(c.nd->cachedPlaintext)); + if (fCheckCache) + CheckSaplingNoteCache(*c.op, *c.nd, *c.output); const SaplingNoteCache& plaintext = *c.nd->cachedPlaintext; const libzcash::SaplingPaymentAddress pa(plaintext.note.d, plaintext.note.pk_d); From 190023f85fe7cc05a48139c6920d4b9d1ad97397 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 22:56:23 -0500 Subject: [PATCH 20/23] wallet: note-cache review follow-ups - UpdatedNoteData now keeps the incoming plaintext when it keeps the wallet's own entries. When it returns "unchanged" (equal note data, or SaplingNoteDataIdentical), any entry lacking a plaintext takes the incoming one, for the same outpoint and ivk only. The startup rescan replays loaded txs with FindMySaplingNotes' freshly decrypted data; that plaintext was thrown away, so the warm-up decrypted those notes a second time. The cache is outside every comparison, so the function still returns false and nothing is rewritten. - -checknotecache is read once at init into fCheckNoteCache, instead of a mapArgs lookup on every GetFilteredNotes call. Its help text and comment now say what it checks: every note the walk considers, before the address filter, so a corrupt cached address can't drop a note unchecked. - GetFilteredNotes pre-sizes its result only when there's no address filter, so a one-address call no longer reserves room for the whole wallet. - WarmSaplingNoteCache returns its counts (notes, filled, no such output, undecryptable), and FillSaplingNoteCache records the threads it used, so tests can tell the cases apart. - WarmSaplingNoteCache's comment no longer claims nothing can wait on cs_wallet while it runs: the RPCs allowed during warmup can. Tests: - ReplayWithPlaintextIsNoUpdate pins the rule that the plaintext cache stays out of the "did this tx change?" decision. A rescan replays a witnessed note loaded without plaintext, using FindMySaplingNotes' data that carries one. That must be no update: no rewrite, and the witness and nullifier are kept. The stored entry then gains the plaintext. Checked by mutation: with cache presence added to SaplingNoteDataIdentical, this test fails and every other test still passes. - ParallelFillMatchesSerial uses 460 notes and asserts the thread count. So the 7-thread case really runs 7 threads, with uneven shards (460 % 7 != 0). Before, it was capped at 4 and repeated the 4-thread case. - ColdParallelFillMatchesLegacy: a cold GetFilteredNotes over ~270 candidates fills on 4 threads and matches the 69bb035e7 loop entry for entry. - WarmUpSkipsBadNotes asserts the warm-up's separate counts (1 no such output, 2 undecryptable), so removing the out-of-range guard is now caught. - CachedPlaintextMatchesFreshDecrypt checks exact counts. CheckNoteCacheRepairsADifference also tampers with a memo, and looks entries up by outpoint. The meaningless GetHash check is dropped. - A ScopedArg / ScopedCheckNoteCache RAII helper restores -args and fCheckNoteCache even when an assertion returns early. Co-Authored-By: Claude Opus 5.5 --- src/gtest/test_wallet_notecache.cpp | 165 ++++++++++++++++++++++++---- src/init.cpp | 3 +- src/wallet/wallet.cpp | 57 +++++++--- src/wallet/wallet.h | 18 ++- 4 files changed, 200 insertions(+), 43 deletions(-) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp index 1c7cc944a..62638c35f 100644 --- a/src/gtest/test_wallet_notecache.cpp +++ b/src/gtest/test_wallet_notecache.cpp @@ -153,6 +153,29 @@ void ExpectCacheMatchesFreshDecrypt(const SaplingNoteData& nd, const OutputDescr EXPECT_TRUE(nd.cachedPlaintext->memo == fresh->first.memo()); } +// Sets a -arg for the scope and restores its previous value (or absence), even when an assertion returns early. +class ScopedArg { + std::string name; + bool had; + std::string old; +public: + ScopedArg(const std::string& nameIn, const std::string& value) : name(nameIn), had(mapArgs.count(nameIn) > 0) { + if (had) old = mapArgs[name]; + mapArgs[name] = value; + } + ~ScopedArg() { + if (had) mapArgs[name] = old; + else mapArgs.erase(name); + } +}; + +class ScopedCheckNoteCache { + bool old; +public: + ScopedCheckNoteCache() : old(fCheckNoteCache) { fCheckNoteCache = true; } + ~ScopedCheckNoteCache() { fCheckNoteCache = old; } +}; + // The fake active chain is 0..TIP_HEIGHT; STALE_HEIGHT is a side block forked off it. constexpr int TIP_HEIGHT = 30; constexpr int STALE_HEIGHT = 25; @@ -653,6 +676,7 @@ TEST_F(WalletNoteCache, CachedPlaintextMatchesFreshDecrypt) { LOCK2(cs_main, wallet.cs_wallet); for (int pass = 0; pass < 2; pass++) { if (pass == 1) { + // The default filters skip spent and locked notes, so ask for everything. wallet.ClearPlaintextCache(); Filtered({}, 0, INT_MAX, false, false, false); // lazy fill of every confirmed note } @@ -665,7 +689,8 @@ TEST_F(WalletNoteCache, CachedPlaintextMatchesFreshDecrypt) { checked++; } } - EXPECT_GE(checked, 9u) << "pass " << pass; + // Eagerly: all 11 notes. Lazily: the 9 confirmed ones (not the never-mined or stale-block note). + EXPECT_EQ(pass ? 9u : 11u, checked) << "pass " << pass; } } @@ -714,7 +739,6 @@ TEST_F(WalletNoteCache, CacheNeverSerialized) { d << empty.mapSaplingNoteData.begin()->second; EXPECT_TRUE(c.str() == d.str()) << "SaplingNoteData, type " << type; } - EXPECT_EQ(txid, filled.GetHash()); CDataStream ss(SER_DISK, CLIENT_VERSION); ss << filled; @@ -725,15 +749,15 @@ TEST_F(WalletNoteCache, CacheNeverSerialized) { EXPECT_FALSE(static_cast(item.second.cachedPlaintext)); } -// UpdatedNoteData: a difference in the cache alone is no update (no rewrite to disk); the replace branch -// keeps a plaintext the wallet already had; and nothing is carried across a different ivk. +// UpdatedNoteData: a replay arriving without plaintext keeps the wallet's cache; the replace branch keeps a +// plaintext the wallet already had; and nothing is carried across a different ivk. TEST_F(WalletNoteCache, UpdatedNoteDataAndTheCache) { LOCK2(cs_main, wallet.cs_wallet); const uint256 txid = txids[2]; CWalletTx& wtx = wallet.mapWallet.at(txid); const SaplingOutPoint op0(txid, 0), op1(txid, 1); - // Same note data, but arriving without plaintext: unchanged, and the wallet keeps its cache. + // Same note data arriving without plaintext: no update, and the wallet keeps its cache. CWalletTx replay = wtx; for (auto& item : replay.mapSaplingNoteData) item.second.cachedPlaintext = boost::none; @@ -800,7 +824,10 @@ TEST_F(WalletNoteCache, WarmUpFillsEveryNote) { TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) { LOCK2(cs_main, wallet.cs_wallet); wallet.ClearPlaintextCache(); - wallet.WarmSaplingNoteCache(); + auto warmup = wallet.WarmSaplingNoteCache(); + EXPECT_EQ(1u, warmup.noSuchOutput); + EXPECT_EQ(2u, warmup.undecryptable); + EXPECT_EQ(warmup.notes - 3, warmup.filled); const auto& bad = wallet.mapWallet.at(txBad).mapSaplingNoteData; ASSERT_EQ(3u, bad.size()); for (const auto& item : bad) @@ -825,7 +852,7 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) { ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0])); ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress())); uint64_t value = 0; - for (int t = 0; t < 30; t++) { + for (int t = 0; t < 46; t++) { std::vector outs; for (int i = 0; i < 10; i++) outs.push_back(MakeOutput(i % 3 == 2 ? skB.DefaultAddress() : addrsA[i % addrsA.size()], ++value)); @@ -846,14 +873,16 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) { return out; }; auto eager = snapshot(); - ASSERT_EQ(300u, eager.size()); + ASSERT_EQ(460u, eager.size()); - for (const char* threads : {"1", "4", "7"}) { - mapArgs["-witnessbuildthreads"] = threads; + // 460 notes allow up to 7 threads (at least 64 notes each); 460 % 7 != 0, so the shards are uneven. + for (int threads : {1, 4, 7}) { + ScopedArg arg("-witnessbuildthreads", std::to_string(threads)); wallet.ClearPlaintextCache(); const uint64_t base = wallet.nSaplingNoteCacheFills; wallet.WarmSaplingNoteCache(); - EXPECT_EQ(base + 300, wallet.nSaplingNoteCacheFills) << threads << " thread(s)"; + EXPECT_EQ(threads, wallet.nSaplingNoteCacheFillThreads); + EXPECT_EQ(base + 460, wallet.nSaplingNoteCacheFills) << threads << " thread(s)"; auto filled = snapshot(); ASSERT_EQ(eager.size(), filled.size()) << threads << " thread(s)"; for (size_t i = 0; i < eager.size(); i++) { @@ -864,26 +893,118 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) { EXPECT_TRUE(eager[i].second.memo == filled[i].second.memo); } } - mapArgs.erase("-witnessbuildthreads"); } -// The cache is trusted as is; -checknotecache decrypts every hit again, and logs and repairs a difference. +// The cache is trusted as is; -checknotecache decrypts each note a walk considers again, and logs and +// repairs a difference. TEST_F(WalletNoteCache, CheckNoteCacheRepairsADifference) { LOCK2(cs_main, wallet.cs_wallet); const SaplingOutPoint op(txids[3], 0); // tx4: one note to C, value 7000 + const SaplingOutPoint memoOp(txids[2], 1); // tx3 output 1: to C, value 5000 SaplingNoteData& nd = wallet.mapWallet.at(op.hash).mapSaplingNoteData.at(op); - ASSERT_TRUE(static_cast(nd.cachedPlaintext)); + SaplingNoteData& memoNd = wallet.mapWallet.at(memoOp.hash).mapSaplingNoteData.at(memoOp); + ASSERT_TRUE(nd.cachedPlaintext && memoNd.cachedPlaintext); const SaplingNote good = nd.cachedPlaintext->note; nd.cachedPlaintext->note = SaplingNote(good.d, good.pk_d, good.value() + 1, good.r); + memoNd.cachedPlaintext->memo[0] ^= 0xff; - auto trusted = Filtered({paC}, 21, INT_MAX, true, false, false); - ASSERT_EQ(1u, trusted.size()); - EXPECT_EQ(7001u, trusted[0].note.value()) << "without the check, the cache is what gets returned"; + // Entries come back in wallet (txid) order, so find them by outpoint. + auto entry = [](const std::vector& entries, const SaplingOutPoint& want) { + for (const SaplingNoteEntry& e : entries) + if (e.op == want) + return e; + ADD_FAILURE() << "no entry for " << want.hash.ToString() << ":" << want.n; + return SaplingNoteEntry(); + }; + auto trusted = Filtered({paC}, 1, INT_MAX, true, false, false); + ASSERT_EQ(3u, trusted.size()); + EXPECT_FALSE(entry(trusted, memoOp).memo == MemoFor(5000)) << "without the check, the cache is what gets returned"; + EXPECT_EQ(7001u, entry(trusted, op).note.value()); - mapArgs["-checknotecache"] = "1"; - auto checked = Filtered({paC}, 21, INT_MAX, true, false, false); - mapArgs.erase("-checknotecache"); - ASSERT_EQ(1u, checked.size()); - EXPECT_EQ(7000u, checked[0].note.value()); + std::vector checked; + { + ScopedCheckNoteCache on; + checked = Filtered({paC}, 1, INT_MAX, true, false, false); + } + ASSERT_EQ(3u, checked.size()); + EXPECT_TRUE(entry(checked, memoOp).memo == MemoFor(5000)); + EXPECT_EQ(7000u, entry(checked, op).note.value()); EXPECT_EQ(7000u, nd.cachedPlaintext->note.value()) << "repaired"; + EXPECT_TRUE(memoNd.cachedPlaintext->memo == MemoFor(5000)) << "repaired"; +} + +// The cache must stay out of the "did this tx change?" decision. A rescan replays a witnessed note that was +// loaded from wallet.dat (so it has no plaintext) with FindMySaplingNotes' fresh, plaintext-carrying data: +// that is no update (no rewrite to disk; counting the cache as a difference brings back the rewrite storm +// 69bb035e7 removed), the stored witness and nullifier stay, and the stored entry gains the plaintext. +TEST_F(WalletNoteCache, ReplayWithPlaintextIsNoUpdate) { + LOCK2(cs_main, wallet.cs_wallet); + const uint256 txid = txids[2]; // tx3: three notes + CWalletTx& stored = wallet.mapWallet.at(txid); + const SaplingOutPoint op0(txid, 0); + SaplingMerkleTree tree; + tree.append(stored.vShieldedOutput[0].cm); + SaplingNoteData& nd0 = stored.mapSaplingNoteData.at(op0); + nd0.witnesses.push_front(tree.witness()); + nd0.witnessHeight = 20; + nd0.nullifier = uint256S("abcd1234"); + wallet.ClearPlaintextCache(); + + CWalletTx replay(&wallet, stored); + mapSaplingNoteData_t fresh = wallet.FindMySaplingNotes(stored).first; + replay.SetSaplingNoteData(fresh); + for (const auto& item : replay.mapSaplingNoteData) + ASSERT_TRUE(static_cast(item.second.cachedPlaintext)); + ASSERT_FALSE(replay.mapSaplingNoteData == stored.mapSaplingNoteData) << "op0 differs in nullifier/height"; + + EXPECT_FALSE(wallet.UpdatedNoteData(replay, stored)); + const SaplingNoteData& after = stored.mapSaplingNoteData.at(op0); // re-read: a replace would rebuild the map + EXPECT_EQ(1u, after.witnesses.size()); + EXPECT_EQ(20, after.witnessHeight); + ASSERT_TRUE(static_cast(after.nullifier)) << "a replace dropped the witnessed note's nullifier"; + EXPECT_EQ(uint256S("abcd1234"), *after.nullifier); + for (const auto& item : stored.mapSaplingNoteData) + ExpectCacheMatchesFreshDecrypt(item.second, stored.vShieldedOutput[item.first.n]); + + // The note data equal outright (operator== ignores the cache): no update, plaintext kept as well. + wallet.ClearPlaintextCache(); + CWalletTx same(&wallet, stored); + mapSaplingNoteData_t sameData = stored.mapSaplingNoteData; + for (auto& item : sameData) + item.second.cachedPlaintext = fresh.at(item.first).cachedPlaintext; + same.SetSaplingNoteData(sameData); + ASSERT_TRUE(same.mapSaplingNoteData == stored.mapSaplingNoteData); + EXPECT_FALSE(wallet.UpdatedNoteData(same, stored)); + EXPECT_EQ(3u, wallet.CachedNotes()) << "only tx3's entries were refilled"; +} + +// A cold walk big enough for the parallel fill returns exactly what the 69bb035e7 loop returns. +class WalletNoteCacheMany : public WalletNoteCache { +protected: + void AddMoreTxs() override { + uint64_t value = 20000; + for (int t = 0; t < 26; t++) { + std::vector outs; + for (int i = 0; i < 10; i++) + outs.push_back(MakeOutput(i % 2 ? paA : paC, ++value)); + AddTx(MakeTx(outs), 2 + t % 9); + } + } +}; + +TEST_F(WalletNoteCacheMany, ColdParallelFillMatchesLegacy) { + ScopedArg threads("-witnessbuildthreads", "4"); + const std::set none, onlyC = {paC}; + for (int minDepth : {0, 2}) { + for (bool requireSpendingKey : {false, true}) { + { + LOCK(wallet.cs_wallet); + wallet.ClearPlaintextCache(); + } + auto got = Filtered(minDepth ? onlyC : none, minDepth, INT_MAX, true, requireSpendingKey, true); + EXPECT_EQ(4, wallet.nSaplingNoteCacheFillThreads) << "the cold fill ran on 4 threads"; + ExpectSameEntries(Legacy(minDepth ? onlyC : none, minDepth, INT_MAX, true, requireSpendingKey, true), got, + Describe(minDepth, INT_MAX, true, requireSpendingKey, true, minDepth ? "C" : "none")); + } + } } diff --git a/src/init.cpp b/src/init.cpp index ea04c9025..7e296bebd 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -516,7 +516,7 @@ std::string HelpMessage(HelpMessageMode mode) if (showDebug) strUsage += HelpMessageOpt("-notecachewarmup", "Decrypt all wallet notes into memory at startup, so the first balance call after a restart doesn't (default: 1)"); if (showDebug) - strUsage += HelpMessageOpt("-checknotecache", "Decrypt every cached wallet note again on each balance call and log any difference from the cache (slow; default: 0)"); + strUsage += HelpMessageOpt("-checknotecache", "On every balance or note-listing call, decrypt each cached note it considers again and log and repair any difference from the cache (slow; default: 0)"); strUsage += HelpMessageOpt("-rescan", _("Rescan the block chain for missing wallet transactions") + " " + _("on startup")); strUsage += HelpMessageOpt("-rescanheight", _("Rescan from specified height when rescan=1 on startup")); strUsage += HelpMessageOpt("-salvagewallet", _("Attempt to recover private keys from a corrupt wallet.dat") + " " + _("on startup")); @@ -2713,6 +2713,7 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler) // Decrypt the wallet's notes into the in-memory plaintext cache now, while RPC is still in warmup // and before the wallet notification thread (started below) can want cs_wallet. Otherwise the // first balance call after a restart decrypts them all under cs_main. + fCheckNoteCache = GetBoolArg("-checknotecache", false); if (GetBoolArg("-notecachewarmup", true) && !ShutdownRequested()) { uiInterface.InitMessage(_("Decrypting wallet notes...")); pwalletMain->WarmSaplingNoteCache(); diff --git a/src/wallet/wallet.cpp b/src/wallet/wallet.cpp index 7497f419c..f3f098589 100644 --- a/src/wallet/wallet.cpp +++ b/src/wallet/wallet.cpp @@ -78,6 +78,7 @@ CBlockIndex *hush_chainactive(int32_t height); extern int32_t HUSH_LOADINGBLOCKS; int32_t hush_dpowconfs(int32_t height,int32_t numconfs); bool fTxDeleteEnabled = false; +bool fCheckNoteCache = false; bool fTxConflictDeleteEnabled = false; int fDeleteInterval = DEFAULT_TX_DELETE_INTERVAL; unsigned int fDeleteTransactionsAfterNBlocks = DEFAULT_TX_RETENTION_BLOCKS; @@ -2034,6 +2035,21 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD // True if both maps hold the same notes with identical persisted state (every field SaplingNoteData // serializes). SaplingNoteData::operator== ignores the witnesses, so it cannot tell a real change // from a redundant update. +// Copies the decrypted plaintext of each incoming note into the matching (same outpoint and ivk) entry that +// lacks one. UpdatedNoteData uses it when it keeps the wallet's entries: a replay or rescan has just +// decrypted the notes (FindMySaplingNotes), and entries loaded from wallet.dat have no plaintext. The cache +// is in memory only and outside every comparison, so this never makes a tx look changed. +static void KeepNotePlaintexts(const mapSaplingNoteData_t& incoming, mapSaplingNoteData_t& wallet) +{ + for (const mapSaplingNoteData_t::value_type& in : incoming) { + if (!in.second.cachedPlaintext) + continue; + mapSaplingNoteData_t::iterator it = wallet.find(in.first); + if (it != wallet.end() && !it->second.cachedPlaintext && it->second.ivk == in.second.ivk) + it->second.cachedPlaintext = in.second.cachedPlaintext; + } +} + static bool SaplingNoteDataIdentical(const mapSaplingNoteData_t& a, const mapSaplingNoteData_t& b) { if (a.size() != b.size()) @@ -2053,8 +2069,10 @@ static bool SaplingNoteDataIdentical(const mapSaplingNoteData_t& a, const mapSap bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock) { bool unchangedSaplingFlag = (wtxIn.mapSaplingNoteData.empty() || wtxIn.mapSaplingNoteData == wtx.mapSaplingNoteData); - if (unchangedSaplingFlag) + if (unchangedSaplingFlag) { + KeepNotePlaintexts(wtxIn.mapSaplingNoteData, wtx.mapSaplingNoteData); return false; + } auto tmp = wtxIn.mapSaplingNoteData; // Ensure we keep any cached witnesses we may already have @@ -2093,6 +2111,7 @@ bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSame if (SaplingNoteDataIdentical(tmp, wtx.mapSaplingNoteData)) { for (mapSaplingNoteData_t::value_type& item : wtx.mapSaplingNoteData) item.second.witnessRootValidated = false; + KeepNotePlaintexts(tmp, wtx.mapSaplingNoteData); return false; } @@ -5782,49 +5801,55 @@ unsigned int CWallet::FillSaplingNoteCache(const std::vector uncached; - unsigned int nNotes = 0, nBad = 0; for (auto& p : mapWallet) { CWalletTx& wtx = p.second; for (auto& item : wtx.mapSaplingNoteData) { - nNotes++; + result.notes++; if (item.second.cachedPlaintext) continue; if (item.first.n >= wtx.vShieldedOutput.size()) { LogPrint("zrpcunsafe", "%s: note %s:%u has no such output\n", __func__, item.first.hash.ToString(), item.first.n); - nBad++; + result.noSuchOutput++; continue; } uncached.push_back(SaplingNoteToDecrypt { &item.second, &wtx.vShieldedOutput[item.first.n] }); } } - const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); + result.filled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); for (const SaplingNoteToDecrypt& n : uncached) { if (!n.nd->cachedPlaintext) { LogPrint("zrpcunsafe", "%s: a note of output %s does not decrypt under its ivk\n", __func__, n.output->cm.ToString()); - nBad++; + result.undecryptable++; } } + const unsigned int nBad = result.noSuchOutput + result.undecryptable; const int64_t nMs = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); - LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, nFilled, nNotes, nMs, + LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, result.filled, result.notes, nMs, nBad ? strprintf("; %u could not be decrypted (-debug=zrpcunsafe lists them)", nBad) : ""); + return result; } -// -checknotecache: decrypts a cached note again, and logs and repairs any difference. A diagnostic for -// the cache; it costs the full decryption it exists to avoid. +// -checknotecache: decrypts a cached note again, and logs and repairs any difference. GetFilteredNotes runs +// it on every note that passes its tx, spent and locked filters, before the address filter, so a corrupt +// cached address can't wrongly drop a note unchecked. A diagnostic for the cache: it costs the full +// decryption the cache exists to avoid. static void CheckSaplingNoteCache(const SaplingOutPoint& op, SaplingNoteData& nd, const OutputDescription& output) { auto fresh = SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, nd.ivk, output.ephemeralKey, output.cm); @@ -5966,12 +5991,12 @@ void CWallet::GetFilteredNotes( const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached); // Pass 2: the filters that need the note's address. - const bool fCheckCache = GetBoolArg("-checknotecache", false); - saplingEntries.reserve(saplingEntries.size() + candidates.size()); + if (filterAddresses.empty()) + saplingEntries.reserve(saplingEntries.size() + candidates.size()); for (const Candidate& c : candidates) { // A wallet note that doesn't decrypt under its own ivk is corrupt; as before, that's fatal here. assert(static_cast(c.nd->cachedPlaintext)); - if (fCheckCache) + if (fCheckNoteCache) CheckSaplingNoteCache(*c.op, *c.nd, *c.output); const SaplingNoteCache& plaintext = *c.nd->cachedPlaintext; const libzcash::SaplingPaymentAddress pa(plaintext.note.d, plaintext.note.pk_d); diff --git a/src/wallet/wallet.h b/src/wallet/wallet.h index 47e39bc75..96bd4b631 100644 --- a/src/wallet/wallet.h +++ b/src/wallet/wallet.h @@ -62,6 +62,8 @@ extern bool bSpendZeroConfChange; extern bool fSendFreeTransactions; extern bool fPayAtLeastCustomFee; extern bool fTxDeleteEnabled; +//! -checknotecache: re-decrypt cached Sapling notes in GetFilteredNotes and repair any difference +extern bool fCheckNoteCache; extern bool fTxConflictDeleteEnabled; extern int fDeleteInterval; extern unsigned int fDeleteTransactionsAfterNBlocks; @@ -1494,12 +1496,20 @@ public: /** Decrypts each note into nd->cachedPlaintext and returns how many it filled. A note that doesn't * decrypt under its ivk is left unfilled. Caller holds cs_wallet. */ unsigned int FillSaplingNoteCache(const std::vector& notes); - /** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted). - * Guarded by cs_wallet. */ + /** Notes FillSaplingNoteCache has decrypted since startup (FindMySaplingNotes' fills not counted), and + * the threads its last call used. Guarded by cs_wallet. */ uint64_t nSaplingNoteCacheFills = 0; + int nSaplingNoteCacheFillThreads = 0; + + struct SaplingNoteCacheWarmup { + unsigned int notes = 0; //!< notes in the wallet + unsigned int filled = 0; //!< decrypted by this call + unsigned int noSuchOutput = 0; //!< naming an output their tx doesn't have + unsigned int undecryptable = 0; //!< not decrypting under their own ivk + }; /** Decrypts every wallet note whose plaintext isn't cached yet (all of them after wallet.dat is loaded). - * Never asserts: a note that won't decrypt is logged and left unfilled. Run at startup. */ - void WarmSaplingNoteCache(); + * Never asserts: a note that can't be decrypted is counted, logged and left unfilled. Run at startup. */ + SaplingNoteCacheWarmup WarmSaplingNoteCache(); void GetFilteredNotes(std::vector& saplingEntries, std::string address, From 7c3563cd5a3edc49bbe4950375c7596dadb7e15e Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 08:01:20 -0500 Subject: [PATCH 21/23] wallet/rpc: order history RPC txs by pointer, not by deep copy getalldata, z_listreceivedaddress and z_listsentbyaddress build a map of the whole wallet, keyed by nOrderPos, before any filter runs. Every insert deep-copies the CWalletTx, including each note's witness list (~100KB per note on a mainnet wallet). That is gigabytes of allocation per call on a large wallet, under LOCK2(cs_main, cs_wallet). The GUI's history scan runs z_listsentbyaddress on every launch and about once per new tip. The map now holds const CWalletTx* into mapWallet. It is still a std::map, so the first tx inserted for an nOrderPos wins, exactly as before. mapWallet can't change while the RPC holds cs_wallet, so the pointers stay valid. Output is unchanged for a consistent wallet. One corner case differs: with a nullifier map entry pointing at a note its tx lacks, the spends helper's operator[] inserts an empty note into that tx, and a later iteration now sees it where the old copies didn't. The next commit removes that insertion. Co-Authored-By: Claude Opus 5.5 --- src/wallet/rpcwallet.cpp | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index ea129d510..0bbf6b8ba 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -3032,11 +3032,13 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) } //Create Ordered List - map orderedTxs; + // Pointers into mapWallet, not copies (a CWalletTx copy deep-copies every note's witness list). + // As before, the first tx inserted for an nOrderPos wins. + map orderedTxs; for (map::iterator it = pwalletMain->mapWallet.begin(); it != pwalletMain->mapWallet.end(); ++it) { const uint256& wtxid = it->first; const CWalletTx& wtx = (*it).second; - orderedTxs.insert(std::pair(wtx.nOrderPos, wtx)); + orderedTxs.insert(std::make_pair(wtx.nOrderPos, &wtx)); unsigned int txType = 0; // 0 Unassigend @@ -3229,9 +3231,9 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) uint64_t t = GetTime(); - for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) + for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { - const CWalletTx& wtx = (*it).second; + const CWalletTx& wtx = *(*it).second; if (!CheckFinalTx(wtx)) continue; @@ -3515,17 +3517,19 @@ UniValue z_listreceivedaddress(const UniValue& params, bool fHelp,const CPubKey& throw runtime_error("Filter must be greater that 0."); //Created Ordered Transaction Map - map orderedTxs; + // Pointers into mapWallet, not copies (a CWalletTx copy deep-copies every note's witness list). + // As before, the first tx inserted for an nOrderPos wins. + map orderedTxs; for (map::iterator it = pwalletMain->mapWallet.begin(); it != pwalletMain->mapWallet.end(); ++it) { const CWalletTx& wtx = (*it).second; - orderedTxs.insert(std::pair(wtx.nOrderPos, wtx)); + orderedTxs.insert(std::make_pair(wtx.nOrderPos, &wtx)); } uint64_t t = GetTime(); //Reverse Iterate thru transactions - for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { - const CWalletTx& wtx = (*it).second; + for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { + const CWalletTx& wtx = *(*it).second; bool includeTransaction = true; //Excude transactions with less confirmations than required @@ -3698,17 +3702,19 @@ UniValue z_listsentbyaddress(const UniValue& params, bool fHelp,const CPubKey&) throw runtime_error("Filter must be greater that 0."); //Created Ordered Transaction Map - map orderedTxs; + // Pointers into mapWallet, not copies (a CWalletTx copy deep-copies every note's witness list). + // As before, the first tx inserted for an nOrderPos wins. + map orderedTxs; for (map::iterator it = pwalletMain->mapWallet.begin(); it != pwalletMain->mapWallet.end(); ++it) { const CWalletTx& wtx = (*it).second; - orderedTxs.insert(std::pair(wtx.nOrderPos, wtx)); + orderedTxs.insert(std::make_pair(wtx.nOrderPos, &wtx)); } uint64_t t = GetTime(); //Reverse Iterate thru transactions - for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { - const CWalletTx& wtx = (*it).second; + for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { + const CWalletTx& wtx = *(*it).second; if (!CheckFinalTx(wtx)) continue; From 3032eb786840a6a60b321fb0c5961f1d6c6d5212 Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 16 Oct 2026 10:14:44 -0500 Subject: [PATCH 22/23] wallet/rpc: gather Sapling keys once per history RPC, not per output per address zsWalletTxJSON (z_listsentbyaddress, z_listreceivedaddress, getalldata) runs once per wallet tx under LOCK2(cs_main, cs_wallet). For every shielded output it copied the wallet's address set, and for each address fetched the spending key, re-derived its full viewing key (an elliptic-curve operation), and trial-decrypted the output; the sends side did the same with the ovk. Every received or sent note in a tx with spends walked the whole wallet in GetNullifiersForAddresses for the change check. The spends side decrypted every spent note again and deep-copied its SaplingNoteData (witnesses included). The GUI runs z_listsentbyaddress over the whole wallet on every launch. SaplingHistoryKeys, built once per RPC call, holds each address with a usable spending key (in GetSaplingPaymentAddresses order, with its ivk and ovk as the spending key gives them, and its ivk as GetSaplingIncomingViewingKey gives it). It also gives the change check as a lookup into the wallet's (ivk, nullifier) pairs, collected on first use. The helpers keep their exact structure and quirks. Each output's JSON object and change flag still accumulate across the addresses that decrypt it. Diversified addresses sharing a key still produce one entry each. An unknown address still checks change under a zero ivk. A locked wallet still reports no shielded received or sent entries. Only the repeated work is gone: - each output is decrypted at most once per distinct key. Once it is known which ivk decrypts an output, no other ivk is tried: the cached plaintext, or a decryption under the ivk the wallet's note data records. Two canonical ivks can't both pass the note commitment check, which pins pk_d = [ivk]g_d; - outCiphertexts are decrypted once per distinct ovk; the t->z shielding ovk is derived from the seed once per tx; - spends use the cached plaintext of the spent note. Two deliberate behaviour changes. The spends helper looked notes up with operator[]: it inserted a null-outpoint entry into mapSaplingNullifiersToNotes for every other wallet's nullifier it met. After getalldata or z_listsentbyaddress had run, z_viewtransaction of any tx spending someone else's note (every incoming z->z payment) failed with map::at. And with an inconsistent nullifier map, it inserted an empty note into a wallet tx. The helpers now only read the wallet. Also, if the walletpassphrase timer relocks the wallet during the call, the whole call reports the keys as of its start; the old per-output lookups switched over partway. test_wallet_history.cpp holds 69bb035e7's helpers verbatim (renamed) and a wallet exercising every branch: - default and diversified addresses, a viewing-key-only address, and a key imported after its note arrived; - z->z sends with change, and with text, empty and binary memos; - a send nobody's ovk reads, and a t->z shielding send; - our spends, someone else's, and a mixed tx. Every tx x every return type x nine address filters gives byte-identical JSON: with the plaintext cache warm, cold, and on an encrypted wallet both locked and unlocked. Mutation-checked: dropping the ivk check from the uniqueness shortcut fails the test. DoesNotModifyTheWallet pins the read-only change (and shows that the old helpers did insert the foreign nullifier). Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_wallet_history.cpp | 816 ++++++++++++++++++++++++++++++ src/wallet/rpchushwallet.cpp | 225 +++++--- src/wallet/rpchushwallet.h | 31 +- src/wallet/rpcwallet.cpp | 9 +- 5 files changed, 1017 insertions(+), 67 deletions(-) create mode 100644 src/gtest/test_wallet_history.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 62877b5f9..eb985f328 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -19,7 +19,8 @@ hush_gtest_SOURCES = \ gtest/test_net_closesocket.cpp \ gtest/test_fd_defense.cpp \ gtest/test_wallet_dbenv.cpp \ - gtest/test_wallet_notecache.cpp + gtest/test_wallet_notecache.cpp \ + gtest/test_wallet_history.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_wallet_history.cpp b/src/gtest/test_wallet_history.cpp new file mode 100644 index 000000000..dad03389c --- /dev/null +++ b/src/gtest/test_wallet_history.cpp @@ -0,0 +1,816 @@ +// 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 + +#include + +#include +#include + +#include "chain.h" +#include "chainparams.h" +#include "coins.h" +#include "init.h" +#include "key_io.h" +#include "main.h" +#include "primitives/block.h" +#include "primitives/transaction.h" +#include "random.h" +#include "rpc/server.h" +#include "script/standard.h" +#include "utilmoneystr.h" +#include "wallet/wallet.h" +#include "wallet/rpchushwallet.h" +#include "zcash/Address.hpp" +#include "zcash/Note.hpp" +#include "zcash/NoteEncryption.hpp" +#include "zcash/zip32.h" + +using namespace std; +using namespace libzcash; + +// The history-RPC helpers (src/wallet/rpchushwallet.cpp) as of 69bb035e7, verbatim apart from the Legacy_ +// prefix and static linkage: the reference the optimized helpers must reproduce byte for byte. +static void Legacy_zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpends, CAmount& filteredSpends, const std::string& strAddress, bool filterByAddress) { + + LOCK2(cs_main, pwalletMain->cs_wallet); + + //Used to identify incomplete key sets + int vinCount = 0; + int vinSpendCount = 0; + int shieldedSpendCount = 0; + int shieldedSpendsSpentCount = 0; + + //Check address + bool isTAddress = false; + bool isZsAddress = false; + + CTxDestination tAddress = DecodeDestination(strAddress); + auto zAddress = DecodePaymentAddress(strAddress); + SaplingPaymentAddress zsAddress; + + if (filterByAddress) { + + if (IsValidDestination(tAddress)) + isTAddress = true; + + if (IsValidPaymentAddress(zAddress)) { + if (boost::get(&zAddress) != nullptr) { + zsAddress = boost::get(zAddress); + isZsAddress = true; + } + } + } + + + // Transparent Inputs belonging to the wallet + UniValue tSpends(UniValue::VARR); + if (isTAddress || !filterByAddress) { + for (int i = 0; i < wtx.vin.size(); i++) { + vinCount++; + const CTxIn& txin = wtx.vin[i]; + UniValue obj(UniValue::VOBJ); + CTxDestination address; + const CWalletTx* parent = pwalletMain->GetWalletTx(txin.prevout.hash); + if (parent != NULL) { + const CTxOut& parentOut = parent->vout[txin.prevout.n]; + ExtractDestination(parentOut.scriptPubKey, address); + if(IsMine(*pwalletMain, address)){ + vinSpendCount++; + totalSpends += CAmount(-parentOut.nValue); + obj.push_back(Pair("address",EncodeDestination(address))); + obj.push_back(Pair("scriptPubKey",HexStr(parentOut.scriptPubKey.begin(), parentOut.scriptPubKey.end()))); + obj.push_back(Pair("amount",ValueFromAmount(-parentOut.nValue))); + obj.push_back(Pair("spendTxid",parent->GetHash().ToString())); + obj.push_back(Pair("spendVout",(int)txin.prevout.n)); + CTxDestination filterAddress = DecodeDestination(strAddress); + if (address == tAddress || !filterByAddress) { + filteredSpends += CAmount(-parentOut.nValue); + tSpends.push_back(obj); + } + } + } + } + } + spends.push_back(Pair("transparentSpends",tSpends)); + + // Sapling Inputs belonging to the wallet + UniValue zsSpends(UniValue::VARR); + if (isZsAddress || !filterByAddress) { + for (int i = 0; i < wtx.vShieldedSpend.size(); i++) { + shieldedSpendCount++; + const SpendDescription& spendDesc = wtx.vShieldedSpend[i]; + UniValue obj(UniValue::VOBJ); + + SaplingOutPoint op = pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier]; + + if (pwalletMain->IsSaplingNullifierFromMe(spendDesc.nullifier)) { + const CWalletTx* parent = pwalletMain->GetWalletTx(pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier].hash); + const OutputDescription& output = parent->vShieldedOutput[op.n]; + auto nd = pwalletMain->mapWallet[pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier].hash].mapSaplingNoteData[op]; + auto pt = libzcash::SaplingNotePlaintext::decrypt(output.encCiphertext,nd.ivk,output.ephemeralKey,output.cm); + + if (pt) { + auto note = pt.get(); + auto pa = nd.ivk.address(note.d); + auto address = pa.get(); + shieldedSpendsSpentCount++; + totalSpends += CAmount(-note.value()); + obj.push_back(Pair("address",EncodePaymentAddress(address))); + obj.push_back(Pair("amount", ValueFromAmount(CAmount(-note.value())))); + obj.push_back(Pair("spendTxid",parent->GetHash().ToString())); + obj.push_back(Pair("spendshieldedOutputIndex",(int)op.n)); + if (address == zsAddress || !filterByAddress) { + filteredSpends += CAmount(-note.value()); + zsSpends.push_back(obj); + } + } + } + } + } + spends.push_back(Pair("saplingSpends",zsSpends)); + + + spends.push_back(Pair("totalSpends",ValueFromAmount(filteredSpends))); + + if (!filterByAddress) { + if (filteredSpends != 0 && (vinCount != vinSpendCount || shieldedSpendCount != shieldedSpendsSpentCount)) { + spends.push_back(Pair("missingSpendingKeys", true)); + spends.push_back(Pair("vinCount", vinCount)); + spends.push_back(Pair("vinSpendCount", vinSpendCount)); + spends.push_back(Pair("shieldedSpendCount", shieldedSpendCount)); + spends.push_back(Pair("shieldedSpendsSpentCount", shieldedSpendsSpentCount)); + } else { + spends.push_back(Pair("missingSpendingKeys", false)); + } + } +} + + +static void Legacy_zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress) { + + if(fZdebug) + fprintf(stderr,"%s: txid=%s\n", __func__, wtx.GetHash().ToString().c_str() ); + + LOCK2(cs_main, pwalletMain->cs_wallet); + + //Check address + bool isTAddress = false; + bool isZsAddress = false; + + CTxDestination tAddress = DecodeDestination(strAddress); + auto zAddress = DecodePaymentAddress(strAddress); + SaplingPaymentAddress zsAddress; + + if (filterByAddress) { + + if (IsValidDestination(tAddress)) + isTAddress = true; + + if (IsValidPaymentAddress(zAddress)) { + if (boost::get(&zAddress) != nullptr) { + zsAddress = boost::get(zAddress); + isZsAddress = true; + } + } + } + + + //Transparent Received txos belonging to the wallet + UniValue tReceived(UniValue::VARR); + if (isTAddress || !filterByAddress) { + for (int i = 0; i < wtx.vout.size(); i++) { + const CTxOut& txout = wtx.vout[i]; + UniValue obj(UniValue::VOBJ); + CTxDestination address; + ExtractDestination(txout.scriptPubKey, address); + if(IsMine(*pwalletMain, address)){ + obj.push_back(Pair("address",EncodeDestination(address))); + obj.push_back(Pair("scriptPubKey",HexStr(txout.scriptPubKey.begin(), txout.scriptPubKey.end()))); + obj.push_back(Pair("amount",ValueFromAmount(txout.nValue))); + obj.push_back(Pair("vout", i)); + if (address == tAddress || !filterByAddress) { + totalReceived += CAmount(txout.nValue); + tReceived.push_back(obj); + } + } + } + } + received.push_back(Pair("transparentReceived",tReceived)); + + + //Sapling Sends belonging to the wallet + UniValue zsReceived(UniValue::VARR); + if (isZsAddress || !filterByAddress) { + for (int i = 0; i < wtx.vShieldedOutput.size(); i++) { + const OutputDescription& outputDesc = wtx.vShieldedOutput[i]; + UniValue obj(UniValue::VOBJ); + bool changeTx = false; + //Decrypt sapling incoming commitments using IVK + std::set addresses; + pwalletMain->GetSaplingPaymentAddresses(addresses); + for (auto addr : addresses) { + libzcash::SaplingExtendedSpendingKey extsk; + if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) { + auto pt = libzcash::SaplingNotePlaintext::decrypt( + outputDesc.encCiphertext, extsk.expsk.full_viewing_key().in_viewing_key(), outputDesc.ephemeralKey, outputDesc.cm); + + if (pt) { + auto note = pt.get(); + auto pt_unwrapped = pt.get(); + auto memo = pt_unwrapped.memo(); + obj.push_back(Pair("address",EncodePaymentAddress(addr))); + obj.push_back(Pair("amount", ValueFromAmount(CAmount(note.value())))); + obj.push_back(Pair("memo", HexStr(memo))); + + if (memo[0] <= 0xf4) { + auto end = std::find_if(memo.rbegin(), memo.rend(), [](unsigned char v) { return v != 0; }); + std::string memoStr(memo.begin(), end.base()); + if (utf8::is_valid(memoStr)) { + obj.push_back(Pair("memoStr", memoStr)); + } + } + obj.push_back(Pair("shieldedOutputIndex",i)); + + //Check Change Status + if (wtx.vShieldedSpend.size()!=0) { + std::set> nullifierSet; + nullifierSet = pwalletMain->GetNullifiersForAddresses({addr}); + BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) { + if (nullifierSet.count(std::make_pair(addr, spendDesc.nullifier))) { + changeTx = true; + } + } + } + obj.push_back(Pair("change",changeTx)); + if (addr == zsAddress || !filterByAddress) { + totalReceived += CAmount(note.value()); + zsReceived.push_back(obj); + } + } + } + } + } + } + received.push_back(Pair("saplingReceived",zsReceived)); + + received.push_back(Pair("totalReceived",ValueFromAmount(totalReceived))); +} + + +static void Legacy_zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress) { + + LOCK2(cs_main, pwalletMain->cs_wallet); + + //Used to identify incomplete key sets + int shieldedOutputCount = 0; + int shieldedOutputDecryptedCount = 0; + + //Check address + bool isTAddress = false; + bool isZsAddress = false; + + CTxDestination tAddress = DecodeDestination(strAddress); + auto zAddress = DecodePaymentAddress(strAddress); + SaplingPaymentAddress zsAddress; + + if (filterByAddress) { + + if (IsValidDestination(tAddress)) + isTAddress = true; + + if (IsValidPaymentAddress(zAddress)) { + if (boost::get(&zAddress) != nullptr) { + zsAddress = boost::get(zAddress); + isZsAddress = true; + } + } + } + + //All Transparent Sends in the transaction + + UniValue tSends(UniValue::VARR); + if (isTAddress || !filterByAddress) { + for (int i = 0; i < wtx.vout.size(); i++) { + const CTxOut& txout = wtx.vout[i]; + UniValue obj(UniValue::VOBJ); + CTxDestination address; + ExtractDestination(txout.scriptPubKey, address); + obj.push_back(Pair("address",EncodeDestination(address))); + obj.push_back(Pair("scriptPubKey",HexStr(txout.scriptPubKey.begin(), txout.scriptPubKey.end()))); + obj.push_back(Pair("amount",ValueFromAmount(-txout.nValue))); + obj.push_back(Pair("vout", i)); + if (address == tAddress || !filterByAddress) { + totalSends += CAmount(-txout.nValue); + tSends.push_back(obj); + } + } + } + sends.push_back(Pair("transparentSends",tSends)); + + //All Shielded Sends in the transaction + UniValue zsSends(UniValue::VARR); + if (isZsAddress || !filterByAddress) { + for (int i = 0; i < wtx.vShieldedOutput.size(); i++) { + const OutputDescription& outputDesc = wtx.vShieldedOutput[i]; + shieldedOutputCount++; + UniValue obj(UniValue::VOBJ); + bool changeTx = false; + + //Decrypt sapling outgoing t to z transaction using HDseed + if (wtx.vShieldedSpend.size()==0) { + HDSeed seed; + if (pwalletMain->GetHDSeedForDerivation(seed)) { + auto opt = libzcash::SaplingOutgoingPlaintext::decrypt( + outputDesc.outCiphertext,ovkForShieldingFromTaddr(seed),outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey); + + if (opt) { + auto opt_unwrapped = opt.get(); + auto pt = libzcash::SaplingNotePlaintext::decrypt( + outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm); + + if (pt) { + shieldedOutputDecryptedCount++; + auto pt_unwrapped = pt.get(); + libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d); + obj.push_back(Pair("address",EncodePaymentAddress(sentAddr))); + obj.push_back(Pair("amount", ValueFromAmount(CAmount(pt_unwrapped.value())))); + obj.push_back(Pair("shieldedOutputIndex",i)); + obj.push_back(Pair("change",false)); + if (sentAddr == zsAddress || !filterByAddress) { + totalSends += CAmount(-pt_unwrapped.value()); + zsSends.push_back(obj); + } + } + } + } + + //attempt Decryption of Outgoing Sapling using wallet extended spending keys + } else { + std::set addresses; + pwalletMain->GetSaplingPaymentAddresses(addresses); + for (auto addr : addresses) { + libzcash::SaplingExtendedSpendingKey extsk; + if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) { + auto opt = libzcash::SaplingOutgoingPlaintext::decrypt( + outputDesc.outCiphertext,extsk.expsk.full_viewing_key().ovk,outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey); + + if (opt) { + auto opt_unwrapped = opt.get(); + auto pt = libzcash::SaplingNotePlaintext::decrypt( + outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm); + + if (pt) { + auto pt_unwrapped = pt.get(); + auto memo = pt_unwrapped.memo(); + shieldedOutputDecryptedCount++; + libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d); + obj.push_back(Pair("address",EncodePaymentAddress(sentAddr))); + obj.push_back(Pair("amount", ValueFromAmount(CAmount(-pt_unwrapped.value())))); + obj.push_back(Pair("memo", HexStr(memo))); + + if (memo[0] <= 0xf4) { + auto end = std::find_if(memo.rbegin(), memo.rend(), [](unsigned char v) { return v != 0; }); + std::string memoStr(memo.begin(), end.base()); + if (utf8::is_valid(memoStr)) { + obj.push_back(Pair("memoStr", memoStr)); + } + } + obj.push_back(Pair("shieldedOutputIndex",i)); + + //Check Change Status + if (wtx.vShieldedSpend.size()!=0) { + std::set> nullifierSet; + nullifierSet = pwalletMain->GetNullifiersForAddresses({sentAddr}); + BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) { + if (nullifierSet.count(std::make_pair(sentAddr, spendDesc.nullifier))) { + changeTx = true; + } + } + } + obj.push_back(Pair("change",changeTx)); + if (sentAddr == zsAddress || !filterByAddress) { + totalSends += CAmount(-pt_unwrapped.value()); + zsSends.push_back(obj); + } + } + } + } + } + } + } + } + sends.push_back(Pair("saplingSends",zsSends)); + + if (shieldedOutputCount != shieldedOutputDecryptedCount) { + sends.push_back(Pair("missingSaplingOVK", true)); + } else { + sends.push_back(Pair("missingSaplingOVK", false)); + } + + sends.push_back(Pair("totalSends",ValueFromAmount(totalSends))); +} + + +static void Legacy_zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType) { + + LOCK2(cs_main, pwalletMain->cs_wallet); + + //Track total wallet spend and received + CAmount totalSpends = 0; + CAmount filteredSpends = 0; + CAmount totalReceived = 0; + CAmount totalSends = 0; + + //Various Univalue to be added to the final transaction + UniValue spends(UniValue::VOBJ); + UniValue received(UniValue::VOBJ); + UniValue sends(UniValue::VOBJ); + UniValue tx(UniValue::VOBJ); + + //Begin Compiling the Decrypted Transaction + tx.push_back(Pair("txid", wtx.GetHash().ToString())); + + if(fZdebug) + fprintf(stderr,"%s: txid=%s\n", __func__, wtx.GetHash().ToString().c_str() ); + + if (wtx.IsCoinBase()) + { + tx.push_back(Pair("coinbase", true)); + if (wtx.GetDepthInMainChain() < 1) + tx.push_back(Pair("category", "orphan")); + else if (wtx.GetBlocksToMaturity() > 0) + tx.push_back(Pair("category", "immature")); + else + tx.push_back(Pair("category", "generate")); + } else { + tx.push_back(Pair("coinbase", false)); + tx.push_back(Pair("category", "standard")); + } + + tx.push_back(Pair("blockhash", wtx.hashBlock.GetHex())); + tx.push_back(Pair("blockindex", wtx.nIndex)); + int confirms = wtx.GetDepthInMainChain(); + if(confirms > 0) + { + tx.push_back(Pair("blocktime", mapBlockIndex[wtx.hashBlock]->GetBlockTime())); + } else { + tx.push_back(Pair("blocktime", 0)); + } + tx.push_back(Pair("expiryheight", (int64_t)wtx.nExpiryHeight)); + tx.push_back(Pair("confirmations", confirms)); + tx.push_back(Pair("time", wtx.GetTxTime())); + tx.push_back(Pair("size", static_cast(GetSerializeSize(static_cast(wtx), SER_NETWORK, PROTOCOL_VERSION)))); + + //Wallet Conflicts + UniValue conflicts(UniValue::VARR); + BOOST_FOREACH(const uint256& conflict, wtx.GetConflicts()) + conflicts.push_back(conflict.GetHex()); + tx.push_back(Pair("walletconflicts", conflicts)); + + // Return Type used to determine what is included in the transaction + // 0 Spends, Received and spends + // 1 Sends + // 2 Received + // 3 Spends + // TODO - Setup Enum, maybe... + + // Add Spends retrieved earlier + if (returnType != 2) { + Legacy_zsTxSpendsToJSON(wtx, spends, totalSpends, filteredSpends, strAddress, fBool); + if ((!fBool || filteredSpends != 0) && (returnType == 0 || returnType == 1)) { + tx.push_back(Pair("spends",spends)); + } + } + // Get Received + if (returnType == 0 || returnType == 2) { + Legacy_zsTxReceivedToJSON(wtx, received, totalReceived, strAddress, fBool); + if (!fBool || totalReceived != 0) { + tx.push_back(Pair("received",received)); + } + } + + // Get Sends + if (returnType == 0 || returnType == 3) { + //Only include sends if there are spends that belong to the wallet. + if (totalSpends != 0 || fBool) { + Legacy_zsTxSendsToJSON(wtx, sends, totalSends, strAddress, fBool); + } + if (!fBool || totalSends != 0) { + tx.push_back(Pair("sends",sends)); + } + } + + if ((returnType == 0 && (!fBool || filteredSpends != 0 || totalReceived != 0 || totalSends != 0)) + || (returnType == 1 && (!fBool || filteredSpends != 0)) + || (returnType == 2 && (!fBool || totalReceived != 0)) + || (returnType == 3 && (!fBool || totalSends != 0))) { + ret.push_back(tx); + } + +} + +namespace { + +std::array Memo(const std::string& text) +{ + std::array memo; + memo.fill(0); + if (text == "empty") // the "no memo" encoding: 0xF6 then zeros + memo[0] = 0xF6; + else if (text == "binary") // starts below 0xF5 but isn't UTF-8 + memo[0] = 0xC3, memo[1] = 0x28; + else + memcpy(memo.data(), text.data(), text.size()); + return memo; +} + +// A real encrypted Sapling output, and when ovk is given, an outCiphertext the sender can decrypt with it +// (the wallet never checks proofs or value commitments). +OutputDescription Output(const SaplingPaymentAddress& to, uint64_t value, const std::string& memo, + const boost::optional& ovk = boost::none) +{ + SaplingNote note(to, value); + auto enc = SaplingNotePlaintext(note, Memo(memo)).encrypt(note.pk_d); + assert(static_cast(enc)); + OutputDescription od; + od.cv = GetRandHash(); + od.cm = note.cm().get(); + od.ephemeralKey = enc->second.get_epk(); + od.encCiphertext = enc->first; + if (ovk) { + SaplingOutgoingPlaintext out(note.pk_d, enc->second.get_esk()); + od.outCiphertext = out.encrypt(*ovk, od.cv, od.cm, enc->second); + } else { + GetRandBytes(od.outCiphertext.begin(), od.outCiphertext.size()); + } + return od; +} + +CTransaction SaplingTx(const std::vector& outputs, const std::vector& spentNullifiers, + const std::vector& vin = {}) +{ + CMutableTransaction mtx; + mtx.fOverwintered = true; + mtx.nVersion = SAPLING_TX_VERSION; + mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID; + mtx.vShieldedOutput = outputs; + for (const uint256& nf : spentNullifiers) { + SpendDescription sd; + sd.nullifier = nf; + mtx.vShieldedSpend.push_back(sd); + } + for (const COutPoint& prev : vin) + mtx.vin.push_back(CTxIn(prev)); + return CTransaction(mtx); +} + +SaplingExtendedSpendingKey Key(uint32_t account) +{ + std::vector> raw(32, 0x6b); + HDSeed seed(raw); + return SaplingExtendedSpendingKey::Master(seed).Derive(account | ZIP32_HARDENED_KEY_LIMIT); +} + +} + +// A wallet whose txs exercise every branch of the history helpers: notes received on default and diversified +// addresses, by a viewing-key-only address and by a key imported afterwards; z->z sends with change and memos; +// a t->z shielding send; spends of our notes and of someone else's. pwalletMain points at it during the test. +class HistoryWallet : public CWallet { +public: + bool EncryptKeys(CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::EncryptKeys(vMasterKeyIn); } + bool Unlock(const CKeyingMaterial& vMasterKeyIn) { return CCryptoKeyStore::Unlock(vMasterKeyIn); } +}; + +class WalletHistory : public ::testing::Test { +protected: + HistoryWallet wallet; + CWallet* savedWallet = nullptr; + SaplingPaymentAddress paA, paA2, paB, paC, paD, paF; + CTxDestination taddr; + std::map depthOf; + std::vector txids; + CCoinsView coinsBase; + CCoinsViewCache coins{&coinsBase}; + CCoinsViewCache* savedCoinsTip = nullptr; + CBlockIndex* savedTip = nullptr; + std::deque blocks; + std::deque index; + std::vector insertedBlocks; + + void SetUp() override { + SelectParams(CBaseChainParams::MAIN); + savedWallet = pwalletMain; + pwalletMain = &wallet; + savedCoinsTip = pcoinsTip; + pcoinsTip = &coins; + LOCK2(cs_main, wallet.cs_wallet); + savedTip = chainActive.Tip(); + + std::vector> raw(32, 0x2d); + ASSERT_TRUE(wallet.SetHDSeed(HDSeed(raw))); + HDSeed seed; + ASSERT_TRUE(wallet.GetHDSeedForDerivation(seed)); + const uint256 seedOvk = ovkForShieldingFromTaddr(seed); + + auto skA = Key(0), skB = Key(1), skC = Key(2), skD = Key(3), skF = Key(9); + paA = skA.DefaultAddress(); + diversifier_index_t j; + j.begin()[0] = 50; + paA2 = skA.ToXFVK().Address(j)->second; + paB = skB.DefaultAddress(); + paC = skC.DefaultAddress(); + paD = skD.DefaultAddress(); + paF = skF.DefaultAddress(); + const uint256 ovkA = skA.expsk.full_viewing_key().ovk, ovkC = skC.expsk.full_viewing_key().ovk; + ASSERT_TRUE(wallet.AddSaplingZKey(skA, paA)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skA.expsk.full_viewing_key().in_viewing_key(), paA2)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), paB)); + ASSERT_TRUE(wallet.AddSaplingZKey(skC, paC)); + + CKey tkey; + tkey.MakeNewKey(true); + ASSERT_TRUE(wallet.AddKeyPubKey(tkey, tkey.GetPubKey())); + taddr = tkey.GetPubKey().GetID(); + + const uint256 nfA = uint256S("a1"), nfA2 = uint256S("a2"), nfC = uint256S("c1"), nfForeign = uint256S("ff"); + + // T0: transparent funds to our taddr. + CMutableTransaction t0; + t0.vin.push_back(CTxIn(COutPoint(GetRandHash(), 0))); + t0.vout.push_back(CTxOut(10 * COIN, GetScriptForDestination(taddr))); + AddTx(CTransaction(t0), 30); + // T1: received by A, A2, B (viewing key only), C and someone else; no spends. + uint256 t1 = AddTx(SaplingTx({Output(paA, 1000, "for A"), Output(paA2, 2000, "empty"), Output(paB, 3000, "for B"), + Output(paC, 4000, "binary"), Output(paF, 5000, "not ours")}, {}), + 25, {{0, nfA}, {1, nfA2}, {3, nfC}}); + (void)t1; + // T2: A sends to F with change back to A, spending A's note: outCiphertexts under A's ovk. + AddTx(SaplingTx({Output(paF, 600, "payment", ovkA), Output(paA, 399, "change", ovkA)}, {nfA}), 20); + // T3: t->z shielding of T0's output to A, outCiphertext under the seed's shielding ovk. + AddTx(SaplingTx({Output(paA, 9 * COIN, "empty", seedOvk)}, {}, {COutPoint(txids[0], 0)}), 15); + // T4: C spends its note: to A2 (another of our keys) and change to C. + AddTx(SaplingTx({Output(paA2, 1500, "to a diversified A", ovkC), Output(paC, 2499, "binary", ovkC)}, {nfC}), 10); + // T5: someone else's spend paying A. + AddTx(SaplingTx({Output(paA, 777, "from a stranger")}, {nfForeign}), 8); + // T6: received by D before D's key is in the wallet (an import without rescan), then mined. + AddTx(SaplingTx({Output(paD, 4242, "for D, found later")}, {}), 5); + // T8: spends one of our notes and one of someone else's (a coinjoin-like tx): missingSpendingKeys. + AddTx(SaplingTx({Output(paF, 100, "mixed", ovkA)}, {nfA, uint256S("fe")}), 3); + // T9: spends A2's note, but its one output's outCiphertext is under nobody's ovk we know: missingSaplingOVK. + AddTx(SaplingTx({Output(paF, 50, "unreadable send")}, {nfA2}), 2); + // T7: spends A2's note (A and A2 share A's key); never mined and not in the mempool (depth -1). + AddTx(SaplingTx({Output(paF, 2900, "", ovkA)}, {nfA2}), 0); + ASSERT_TRUE(wallet.AddSaplingZKey(skD, paD)); + + Mine(); + } + + void TearDown() override { + LOCK(cs_main); + chainActive.SetTip(savedTip); + for (const uint256& h : insertedBlocks) + mapBlockIndex.erase(h); + pcoinsTip = savedCoinsTip; + pwalletMain = savedWallet; + } + + uint256 AddTx(const CTransaction& tx, int depth, const std::map& nullifiers = {}) { + CWalletTx wtx(&wallet, tx); + mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first; + for (const auto& nf : nullifiers) + found.at(SaplingOutPoint(tx.GetHash(), nf.first)).nullifier = nf.second; + wtx.SetSaplingNoteData(found); + EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); + if (depth > 0) + depthOf[tx.GetHash()] = depth; + txids.push_back(tx.GetHash()); + return tx.GetHash(); + } + + void Mine() { + const int tip = 40; + for (int h = 0; h <= tip; h++) { + blocks.emplace_back(); + CBlock& block = blocks.back(); + block.nTime = 1700000000 + 36 * h; + block.nNonce = uint256S(strprintf("%x", 0x900 + h)); + if (h > 0) + block.hashPrevBlock = blocks[h - 1].GetHash(); + for (const auto& d : depthOf) + if (tip - d.second + 1 == h) + block.vtx.push_back(wallet.mapWallet.at(d.first)); + block.hashMerkleRoot = block.BuildMerkleTree(); + index.emplace_back(block); + CBlockIndex& bi = index.back(); + bi.SetHeight(h); + bi.pprev = h > 0 ? &index[h - 1] : nullptr; + auto inserted = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)); + ASSERT_TRUE(inserted.second); + insertedBlocks.push_back(block.GetHash()); + bi.phashBlock = &inserted.first->first; + } + chainActive.SetTip(&index[tip]); + for (const auto& d : depthOf) + wallet.mapWallet.at(d.first).SetMerkleBranch(blocks[tip - d.second + 1]); + } + + std::string NewJSON(const CWalletTx& wtx, const std::string& addr, bool fBool, int returnType) { + UniValue ret(UniValue::VARR); + zsWalletTxJSON(wtx, ret, addr, fBool, returnType, GetSaplingHistoryKeys()); + return ret.write(); + } + std::string LegacyJSON(const CWalletTx& wtx, const std::string& addr, bool fBool, int returnType) { + UniValue ret(UniValue::VARR); + Legacy_zsWalletTxJSON(wtx, ret, addr, fBool, returnType); + return ret.write(); + } + void ExpectMatchesLegacy(bool checkCoverage = true); +}; + +// Every tx, every return type, unfiltered and filtered by each kind of address: byte-identical JSON. Run with +// the wallet's plaintext cache warm (as after the startup warm-up) and cold (every output trial-decrypted). +class WalletHistoryCold : public WalletHistory {}; + +TEST_F(WalletHistoryCold, MatchesLegacy) { + LOCK2(cs_main, wallet.cs_wallet); + for (auto& p : wallet.mapWallet) + for (auto& item : p.second.mapSaplingNoteData) + item.second.cachedPlaintext = boost::none; + ExpectMatchesLegacy(); +} + +TEST_F(WalletHistory, MatchesLegacy) { + LOCK2(cs_main, wallet.cs_wallet); + ExpectMatchesLegacy(); +} + +void WalletHistory::ExpectMatchesLegacy(bool checkCoverage) { + const std::vector> filters = { + {"*", false}, {"*", true}, {EncodePaymentAddress(paA), true}, {EncodePaymentAddress(paA2), true}, + {EncodePaymentAddress(paB), true}, {EncodePaymentAddress(paC), true}, {EncodePaymentAddress(paD), true}, + {EncodePaymentAddress(paF), true}, {EncodeDestination(taddr), true}, + }; + std::string all; + for (const uint256& txid : txids) { + const CWalletTx& wtx = wallet.mapWallet.at(txid); + for (int returnType = 0; returnType <= 3; returnType++) { + for (const auto& f : filters) { + // New first: the legacy spends helper default-inserts into wallet maps. + std::string got = NewJSON(wtx, f.first, f.second, returnType); + std::string want = LegacyJSON(wtx, f.first, f.second, returnType); + EXPECT_EQ(want, got) << "tx " << txid.ToString() << " returnType " << returnType << " filter " << f.first << "/" << f.second; + all += got; + } + } + } + // The fixture really reaches the interesting branches. + if (!checkCoverage) + return; + for (const char* needle : {"\"change\":true", "\"memoStr\"", "\"saplingSpends\":[{", "\"transparentSpends\":[{", + "\"saplingSends\":[{", "\"saplingReceived\":[{", "\"missingSaplingOVK\":true", + "\"missingSpendingKeys\":true", "for D, found later"}) { + EXPECT_NE(std::string::npos, all.find(needle)) << needle; + } +} + +// The same comparison on an encrypted wallet, locked (no spending keys or HD seed available: no shielded +// received or sent entries, as before) and then unlocked. +class WalletHistoryEncrypted : public WalletHistory {}; + +TEST_F(WalletHistoryEncrypted, MatchesLegacyLockedAndUnlocked) { + LOCK2(cs_main, wallet.cs_wallet); + CKeyingMaterial masterKey(32, 0x5e); + ASSERT_TRUE(wallet.EncryptKeys(masterKey)); + ASSERT_TRUE(wallet.IsLocked()); + ASSERT_TRUE(GetSaplingHistoryKeys().spendable.empty()); + ExpectMatchesLegacy(false); + ASSERT_TRUE(wallet.Unlock(masterKey)); + ASSERT_FALSE(GetSaplingHistoryKeys().spendable.empty()); + ExpectMatchesLegacy(); +} + +// The history helpers only read the wallet. The old spends helper's operator[] lookups inserted an entry into +// mapSaplingNullifiersToNotes for every other wallet's nullifier they met, which made a later +// z_viewtransaction of that tx fail (map::at on a null outpoint). +TEST_F(WalletHistory, DoesNotModifyTheWallet) { + LOCK2(cs_main, wallet.cs_wallet); + const size_t nullifierEntries = wallet.mapSaplingNullifiersToNotes.size(); + std::map noteEntries; + for (const auto& p : wallet.mapWallet) + noteEntries[p.first] = p.second.mapSaplingNoteData.size(); + + for (const uint256& txid : txids) + for (int returnType = 0; returnType <= 3; returnType++) + NewJSON(wallet.mapWallet.at(txid), "*", false, returnType); + EXPECT_EQ(nullifierEntries, wallet.mapSaplingNullifiersToNotes.size()); + EXPECT_EQ(0u, wallet.mapSaplingNullifiersToNotes.count(uint256S("ff"))) << "someone else's spend"; + for (const auto& p : wallet.mapWallet) + EXPECT_EQ(noteEntries.at(p.first), p.second.mapSaplingNoteData.size()); + + // What the old helpers did, for the record. + for (const uint256& txid : txids) + LegacyJSON(wallet.mapWallet.at(txid), "*", false, 0); + EXPECT_EQ(1u, wallet.mapSaplingNullifiersToNotes.count(uint256S("ff"))); +} diff --git a/src/wallet/rpchushwallet.cpp b/src/wallet/rpchushwallet.cpp index 39f090ef7..f40764e7e 100644 --- a/src/wallet/rpchushwallet.cpp +++ b/src/wallet/rpchushwallet.cpp @@ -16,6 +16,109 @@ using namespace libzcash; bool EnsureWalletIsAvailable(bool avoidException); +SaplingHistoryKeys GetSaplingHistoryKeys() +{ + AssertLockHeld(pwalletMain->cs_wallet); + SaplingHistoryKeys keys; + std::set addresses; + pwalletMain->GetSaplingPaymentAddresses(addresses); + for (const auto& addr : addresses) { + libzcash::SaplingExtendedSpendingKey extsk; + if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) { + const libzcash::SaplingFullViewingKey fvk = extsk.expsk.full_viewing_key(); + SaplingHistoryKeys::SpendableAddress a; + a.addr = addr; + a.ivk = fvk.in_viewing_key(); + a.ovk = fvk.ovk; + // Left zero when the wallet doesn't know addr, exactly as GetNullifiersForAddresses does. + pwalletMain->GetSaplingIncomingViewingKey(addr, a.walletIvk); + keys.spendable.push_back(a); + } + } + return keys; +} + +bool SaplingHistoryKeys::IsNullifierOfAddress(const libzcash::SaplingIncomingViewingKey& addrIvk, const uint256& nullifier) const +{ + AssertLockHeld(pwalletMain->cs_wallet); + if (!noteNullifiers) { + noteNullifiers = std::set>(); + for (const auto& p : pwalletMain->mapWallet) { + for (const auto& item : p.second.mapSaplingNoteData) { + if (item.second.nullifier) + noteNullifiers->insert(std::make_pair(item.second.ivk, *item.second.nullifier)); + } + } + } + return noteNullifiers->count(std::make_pair(addrIvk, nullifier)) > 0; +} + +namespace { + +// One output's decryptions under incoming viewing keys, each key tried at most once. Two distinct ivks can't +// both decrypt an output: the cm check recomputes pk_d = [ivk]g_d, and ivks are canonical scalars below the +// order of g_d (ka_agree and ivk_to_pkd reject the rest), so a second one would need a NoteCommit collision. +// So once it is known which ivk decrypts the output (the wallet's note data records the one FindMySaplingNotes +// found, with its plaintext normally cached), no other ivk is tried. +class OutputDecryptions { + const OutputDescription& output; + const SaplingNoteData* nd; + std::map> tried; + + boost::optional Decrypt(const libzcash::SaplingIncomingViewingKey& ivk) const { + auto both = libzcash::SaplingNotePlaintext::decrypt_with_note(output.encCiphertext, ivk, output.ephemeralKey, output.cm); + if (!both) + return boost::none; + return SaplingNoteCache{ both->second, both->first.memo() }; + } + // The plaintext under the ivk the wallet recorded for this output, decrypting it if it isn't cached. + const boost::optional& Recorded() { + auto it = tried.find(nd->ivk); + if (it == tried.end()) + it = tried.insert(std::make_pair(nd->ivk, nd->cachedPlaintext ? nd->cachedPlaintext : Decrypt(nd->ivk))).first; + return it->second; + } +public: + OutputDecryptions(const CWalletTx& wtx, uint32_t n) : output(wtx.vShieldedOutput[n]), nd(nullptr) { + auto it = wtx.mapSaplingNoteData.find(SaplingOutPoint(wtx.GetHash(), n)); + if (it != wtx.mapSaplingNoteData.end()) + nd = &it->second; + } + const SaplingNoteCache* Get(const libzcash::SaplingIncomingViewingKey& ivk) { + auto it = tried.find(ivk); + if (it == tried.end()) { + boost::optional result; + if (nd && nd->ivk == ivk) { + result = Recorded(); + } else if (!(nd && Recorded())) { + // Not a wallet note, or one whose recorded ivk doesn't decrypt it: try this key. + result = Decrypt(ivk); + } + it = tried.insert(std::make_pair(ivk, result)).first; + } + return it->second ? &*it->second : nullptr; + } +}; + +// What an outCiphertext tells the sender holding ovk: the note's plaintext and the address it was sent to. +boost::optional> DecryptSent( + const OutputDescription& outputDesc, const uint256& ovk) +{ + auto opt = libzcash::SaplingOutgoingPlaintext::decrypt( + outputDesc.outCiphertext,ovk,outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey); + if (!opt) + return boost::none; + auto opt_unwrapped = opt.get(); + auto pt = libzcash::SaplingNotePlaintext::decrypt( + outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm); + if (!pt) + return boost::none; + auto pt_unwrapped = pt.get(); + return std::make_pair(pt_unwrapped, libzcash::SaplingPaymentAddress(pt_unwrapped.d, opt_unwrapped.pk_d)); +} + +} + void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpends, CAmount& filteredSpends, const std::string& strAddress, bool filterByAddress) { LOCK2(cs_main, pwalletMain->cs_wallet); @@ -87,18 +190,29 @@ void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpen const SpendDescription& spendDesc = wtx.vShieldedSpend[i]; UniValue obj(UniValue::VOBJ); - SaplingOutPoint op = pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier]; - + // Lookups by find(), not operator[]: that inserted an entry for every other wallet's nullifier it met + // (and could insert an empty note into a wallet tx). if (pwalletMain->IsSaplingNullifierFromMe(spendDesc.nullifier)) { - const CWalletTx* parent = pwalletMain->GetWalletTx(pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier].hash); - const OutputDescription& output = parent->vShieldedOutput[op.n]; - auto nd = pwalletMain->mapWallet[pwalletMain->mapSaplingNullifiersToNotes[spendDesc.nullifier].hash].mapSaplingNoteData[op]; - auto pt = libzcash::SaplingNotePlaintext::decrypt(output.encCiphertext,nd.ivk,output.ephemeralKey,output.cm); + const SaplingOutPoint op = pwalletMain->mapSaplingNullifiersToNotes.at(spendDesc.nullifier); + const CWalletTx* parent = pwalletMain->GetWalletTx(op.hash); + auto ndIt = parent->mapSaplingNoteData.find(op); + // The spent note's plaintext, from the wallet's cache when it has it. + boost::optional pt; + if (ndIt != parent->mapSaplingNoteData.end() && op.n < parent->vShieldedOutput.size()) { + const SaplingNoteData& nd = ndIt->second; + if (nd.cachedPlaintext) { + pt = nd.cachedPlaintext; + } else { + const OutputDescription& output = parent->vShieldedOutput[op.n]; + auto both = libzcash::SaplingNotePlaintext::decrypt_with_note(output.encCiphertext,nd.ivk,output.ephemeralKey,output.cm); + if (both) + pt = SaplingNoteCache{ both->second, both->first.memo() }; + } + } if (pt) { - auto note = pt.get(); - auto pa = nd.ivk.address(note.d); - auto address = pa.get(); + const libzcash::SaplingNote& note = pt->note; + const libzcash::SaplingPaymentAddress address(note.d, note.pk_d); shieldedSpendsSpentCount++; totalSpends += CAmount(-note.value()); obj.push_back(Pair("address",EncodePaymentAddress(address))); @@ -132,7 +246,7 @@ void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpen } -void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress) { +void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys) { if(fZdebug) fprintf(stderr,"%s: txid=%s\n", __func__, wtx.GetHash().ToString().c_str() ); @@ -188,22 +302,18 @@ void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& total UniValue zsReceived(UniValue::VARR); if (isZsAddress || !filterByAddress) { for (int i = 0; i < wtx.vShieldedOutput.size(); i++) { - const OutputDescription& outputDesc = wtx.vShieldedOutput[i]; UniValue obj(UniValue::VOBJ); bool changeTx = false; - //Decrypt sapling incoming commitments using IVK - std::set addresses; - pwalletMain->GetSaplingPaymentAddresses(addresses); - for (auto addr : addresses) { - libzcash::SaplingExtendedSpendingKey extsk; - if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) { - auto pt = libzcash::SaplingNotePlaintext::decrypt( - outputDesc.encCiphertext, extsk.expsk.full_viewing_key().in_viewing_key(), outputDesc.ephemeralKey, outputDesc.cm); + //Decrypt sapling incoming commitments using IVK: once per key, not once per address + OutputDecryptions decryptions(wtx, i); + for (const SaplingHistoryKeys::SpendableAddress& a : keys.spendable) { + const libzcash::SaplingPaymentAddress& addr = a.addr; + { + const SaplingNoteCache* pt = decryptions.Get(a.ivk); if (pt) { - auto note = pt.get(); - auto pt_unwrapped = pt.get(); - auto memo = pt_unwrapped.memo(); + const libzcash::SaplingNote& note = pt->note; + auto memo = pt->memo; obj.push_back(Pair("address",EncodePaymentAddress(addr))); obj.push_back(Pair("amount", ValueFromAmount(CAmount(note.value())))); obj.push_back(Pair("memo", HexStr(memo))); @@ -219,10 +329,8 @@ void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& total //Check Change Status if (wtx.vShieldedSpend.size()!=0) { - std::set> nullifierSet; - nullifierSet = pwalletMain->GetNullifiersForAddresses({addr}); BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) { - if (nullifierSet.count(std::make_pair(addr, spendDesc.nullifier))) { + if (keys.IsNullifierOfAddress(a.walletIvk, spendDesc.nullifier)) { changeTx = true; } } @@ -243,7 +351,7 @@ void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& total } -void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress) { +void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys) { LOCK2(cs_main, pwalletMain->cs_wallet); @@ -296,6 +404,13 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, //All Shielded Sends in the transaction UniValue zsSends(UniValue::VARR); if (isZsAddress || !filterByAddress) { + //The t to z shielding ovk comes from the HD seed: derive it once per tx, not once per output + boost::optional seedOvk; + if (wtx.vShieldedSpend.size()==0 && wtx.vShieldedOutput.size()!=0) { + HDSeed seed; + if (pwalletMain->GetHDSeedForDerivation(seed)) + seedOvk = ovkForShieldingFromTaddr(seed); + } for (int i = 0; i < wtx.vShieldedOutput.size(); i++) { const OutputDescription& outputDesc = wtx.vShieldedOutput[i]; shieldedOutputCount++; @@ -304,20 +419,14 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, //Decrypt sapling outgoing t to z transaction using HDseed if (wtx.vShieldedSpend.size()==0) { - HDSeed seed; - if (pwalletMain->GetHDSeedForDerivation(seed)) { - auto opt = libzcash::SaplingOutgoingPlaintext::decrypt( - outputDesc.outCiphertext,ovkForShieldingFromTaddr(seed),outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey); + if (seedOvk) { + { + auto sent = DecryptSent(outputDesc, *seedOvk); - if (opt) { - auto opt_unwrapped = opt.get(); - auto pt = libzcash::SaplingNotePlaintext::decrypt( - outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm); - - if (pt) { + if (sent) { shieldedOutputDecryptedCount++; - auto pt_unwrapped = pt.get(); - libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d); + const libzcash::SaplingNotePlaintext& pt_unwrapped = sent->first; + const libzcash::SaplingPaymentAddress& sentAddr = sent->second; obj.push_back(Pair("address",EncodePaymentAddress(sentAddr))); obj.push_back(Pair("amount", ValueFromAmount(CAmount(pt_unwrapped.value())))); obj.push_back(Pair("shieldedOutputIndex",i)); @@ -330,26 +439,22 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, } } - //attempt Decryption of Outgoing Sapling using wallet extended spending keys + //attempt Decryption of Outgoing Sapling using wallet extended spending keys: once per ovk, not once per address } else { - std::set addresses; - pwalletMain->GetSaplingPaymentAddresses(addresses); - for (auto addr : addresses) { - libzcash::SaplingExtendedSpendingKey extsk; - if (pwalletMain->GetSaplingExtendedSpendingKey(addr, extsk)) { - auto opt = libzcash::SaplingOutgoingPlaintext::decrypt( - outputDesc.outCiphertext,extsk.expsk.full_viewing_key().ovk,outputDesc.cv,outputDesc.cm,outputDesc.ephemeralKey); + std::map>> sentByOvk; + for (const SaplingHistoryKeys::SpendableAddress& a : keys.spendable) { + { + auto tried = sentByOvk.find(a.ovk); + if (tried == sentByOvk.end()) + tried = sentByOvk.insert(std::make_pair(a.ovk, DecryptSent(outputDesc, a.ovk))).first; + const auto& sent = tried->second; - if (opt) { - auto opt_unwrapped = opt.get(); - auto pt = libzcash::SaplingNotePlaintext::decrypt( - outputDesc.encCiphertext,outputDesc.ephemeralKey,opt_unwrapped.esk,opt_unwrapped.pk_d,outputDesc.cm); - - if (pt) { - auto pt_unwrapped = pt.get(); + { + if (sent) { + const libzcash::SaplingNotePlaintext& pt_unwrapped = sent->first; auto memo = pt_unwrapped.memo(); shieldedOutputDecryptedCount++; - libzcash::SaplingPaymentAddress sentAddr(pt_unwrapped.d, opt_unwrapped.pk_d); + const libzcash::SaplingPaymentAddress& sentAddr = sent->second; obj.push_back(Pair("address",EncodePaymentAddress(sentAddr))); obj.push_back(Pair("amount", ValueFromAmount(CAmount(-pt_unwrapped.value())))); obj.push_back(Pair("memo", HexStr(memo))); @@ -365,10 +470,10 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, //Check Change Status if (wtx.vShieldedSpend.size()!=0) { - std::set> nullifierSet; - nullifierSet = pwalletMain->GetNullifiersForAddresses({sentAddr}); + libzcash::SaplingIncomingViewingKey sentIvk; + pwalletMain->GetSaplingIncomingViewingKey(sentAddr, sentIvk); BOOST_FOREACH(const SpendDescription& spendDesc, wtx.vShieldedSpend) { - if (nullifierSet.count(std::make_pair(sentAddr, spendDesc.nullifier))) { + if (keys.IsNullifierOfAddress(sentIvk, spendDesc.nullifier)) { changeTx = true; } } @@ -397,7 +502,7 @@ void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, } -void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType) { +void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType, const SaplingHistoryKeys& keys) { LOCK2(cs_main, pwalletMain->cs_wallet); @@ -469,7 +574,7 @@ void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAd } // Get Received if (returnType == 0 || returnType == 2) { - zsTxReceivedToJSON(wtx, received, totalReceived, strAddress, fBool); + zsTxReceivedToJSON(wtx, received, totalReceived, strAddress, fBool, keys); if (!fBool || totalReceived != 0) { tx.push_back(Pair("received",received)); } @@ -479,7 +584,7 @@ void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAd if (returnType == 0 || returnType == 3) { //Only include sends if there are spends that belong to the wallet. if (totalSpends != 0 || fBool) { - zsTxSendsToJSON(wtx, sends, totalSends, strAddress, fBool); + zsTxSendsToJSON(wtx, sends, totalSends, strAddress, fBool, keys); } if (!fBool || totalSends != 0) { tx.push_back(Pair("sends",sends)); diff --git a/src/wallet/rpchushwallet.h b/src/wallet/rpchushwallet.h index 416c5b45a..f3e59dcc0 100644 --- a/src/wallet/rpchushwallet.h +++ b/src/wallet/rpchushwallet.h @@ -13,10 +13,35 @@ struct balancestruct { CAmount immature; }; +/** The wallet's Sapling keys as the history helpers use them, gathered once per RPC call. The helpers used to + * look them up for every output and every address, re-deriving each full viewing key (an elliptic-curve + * operation), and walked every note in the wallet for each change check. Build it with pwalletMain->cs_wallet + * held, and keep holding it while the helpers run: that keeps the wallet's keys and notes fixed. The one thing + * it can't stop is the walletpassphrase timer relocking the wallet mid-call; the call then reports the keys as + * they were when it started, where the old per-output lookups switched over partway. */ +struct SaplingHistoryKeys { + struct SpendableAddress { + libzcash::SaplingPaymentAddress addr; + libzcash::SaplingIncomingViewingKey ivk; //!< from its spending key: what received outputs are decrypted with + uint256 ovk; //!< from its spending key: what outCiphertexts are decrypted with + libzcash::SaplingIncomingViewingKey walletIvk; //!< GetSaplingIncomingViewingKey(addr), as the change check looked it up + }; + //! Every wallet address whose spending key is available (never while the wallet is locked), in + //! GetSaplingPaymentAddresses order. + std::vector spendable; + //! Whether GetNullifiersForAddresses({addr}) would contain (addr, nullifier), given addr's + //! GetSaplingIncomingViewingKey result (zero when the wallet doesn't know addr): nullifier belongs to a + //! wallet note received with that ivk. Walks the wallet's notes once, on first use. + bool IsNullifierOfAddress(const libzcash::SaplingIncomingViewingKey& addrIvk, const uint256& nullifier) const; +private: + mutable boost::optional>> noteNullifiers; +}; +SaplingHistoryKeys GetSaplingHistoryKeys(); + void zsTxSpendsToJSON(const CWalletTx& wtx, UniValue& spends, CAmount& totalSpends, CAmount& filteredSpends, const std::string& strAddress, bool filterByAddress); -void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress); -void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress); -void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType); +void zsTxReceivedToJSON(const CWalletTx& wtx, UniValue& received, CAmount& totalReceived, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys); +void zsTxSendsToJSON(const CWalletTx& wtx, UniValue& sends, CAmount& totalSends, const std::string& strAddress, bool filterByAddress, const SaplingHistoryKeys& keys); +void zsWalletTxJSON(const CWalletTx& wtx, UniValue& ret, const std::string strAddress, bool fBool, const int returnType, const SaplingHistoryKeys& keys); #endif //HUSH_WALLET_RPCWALLET_H diff --git a/src/wallet/rpcwallet.cpp b/src/wallet/rpcwallet.cpp index 0bbf6b8ba..893c5ce56 100644 --- a/src/wallet/rpcwallet.cpp +++ b/src/wallet/rpcwallet.cpp @@ -3230,6 +3230,7 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) } + const SaplingHistoryKeys historyKeys = GetSaplingHistoryKeys(); uint64_t t = GetTime(); for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { @@ -3250,7 +3251,7 @@ UniValue getalldata(const UniValue& params, bool fHelp,const CPubKey&) continue; } - zsWalletTxJSON(wtx, trans, "*", false, 0); + zsWalletTxJSON(wtx, trans, "*", false, 0, historyKeys); if (trans.size() >= nCount) break; } @@ -3526,6 +3527,7 @@ UniValue z_listreceivedaddress(const UniValue& params, bool fHelp,const CPubKey& } + const SaplingHistoryKeys historyKeys = GetSaplingHistoryKeys(); uint64_t t = GetTime(); //Reverse Iterate thru transactions for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { @@ -3550,7 +3552,7 @@ UniValue z_listreceivedaddress(const UniValue& params, bool fHelp,const CPubKey& } if (includeTransaction) { - zsWalletTxJSON(wtx, ret, params[0].get_str() , true, 2); + zsWalletTxJSON(wtx, ret, params[0].get_str() , true, 2, historyKeys); } if (ret.size() > nCount) break; @@ -3711,6 +3713,7 @@ UniValue z_listsentbyaddress(const UniValue& params, bool fHelp,const CPubKey&) } + const SaplingHistoryKeys historyKeys = GetSaplingHistoryKeys(); uint64_t t = GetTime(); //Reverse Iterate thru transactions for (map::reverse_iterator it = orderedTxs.rbegin(); it != orderedTxs.rend(); ++it) { @@ -3737,7 +3740,7 @@ UniValue z_listsentbyaddress(const UniValue& params, bool fHelp,const CPubKey&) if (nFilterType == 2 && wtx.GetDepthInMainChain() > nFilter) continue; - zsWalletTxJSON(wtx, ret, "*", false, 0); + zsWalletTxJSON(wtx, ret, "*", false, 0, historyKeys); if (ret.size() >= nCount) break; From fe988d6fbe92fff6cebcc6a9e801c38a9515aaac Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 27 Oct 2026 15:27:25 -0500 Subject: [PATCH 23/23] release: bump to v1.3.1 dev is 22 commits past the v1.3.0 tag and still stamped 1.3.0, so a release built from it would be indistinguishable from v1.3.0 on the wire, in getnetworkinfo and to the wallet's updater. Bump REVISION, not MINOR: the delta is bug fixes and wallet performance. It adds no subsystem, and the new RPC-visible options (-notecachewarmup, -checknotecache) are debug-help only. configure.ac, src/clientversion.h 1.3.0 -> 1.3.1 (CLIENT_VERSION 1030150) doc/man/*.1 version strings restamped contrib/debian/changelog 1.3.1 entry src/chain.h the CLIENT_VERSION comment The man page content needs no regeneration: every option in `dragonxd --help` at 3032eb786 is already in dragonxd.1, and the two new options appear only in -help-debug. No consensus change since v1.3.0, so PROTOCOL_VERSION and MIN_PEER_PROTO_VERSION are unchanged. Co-Authored-By: Claude Opus 5.5 --- configure.ac | 2 +- contrib/debian/changelog | 29 +++++++++++++++++++++++++++++ doc/man/dragonx-cli.1 | 6 +++--- doc/man/dragonx-tx.1 | 6 +++--- doc/man/dragonxd.1 | 6 +++--- src/chain.h | 2 +- src/clientversion.h | 2 +- 7 files changed, 41 insertions(+), 12 deletions(-) diff --git a/configure.ac b/configure.ac index 4aab55486..3fc8a47c7 100644 --- a/configure.ac +++ b/configure.ac @@ -3,7 +3,7 @@ AC_PREREQ([2.60]) define(_CLIENT_VERSION_MAJOR, 1) dnl Must be kept in sync with src/clientversion.h , ugh! define(_CLIENT_VERSION_MINOR, 3) -define(_CLIENT_VERSION_REVISION, 0) +define(_CLIENT_VERSION_REVISION, 1) define(_CLIENT_VERSION_BUILD, 50) define(_ZC_BUILD_VAL, m4_if(m4_eval(_CLIENT_VERSION_BUILD < 25), 1, m4_incr(_CLIENT_VERSION_BUILD), m4_eval(_CLIENT_VERSION_BUILD < 50), 1, m4_eval(_CLIENT_VERSION_BUILD - 24), m4_eval(_CLIENT_VERSION_BUILD == 50), 1, , m4_eval(_CLIENT_VERSION_BUILD - 50))) define(_CLIENT_VERSION_SUFFIX, m4_if(m4_eval(_CLIENT_VERSION_BUILD < 25), 1, _CLIENT_VERSION_REVISION-beta$1, m4_eval(_CLIENT_VERSION_BUILD < 50), 1, _CLIENT_VERSION_REVISION-rc$1, m4_eval(_CLIENT_VERSION_BUILD == 50), 1, _CLIENT_VERSION_REVISION, _CLIENT_VERSION_REVISION-$1))) diff --git a/contrib/debian/changelog b/contrib/debian/changelog index 9f8ecba11..87f33a042 100644 --- a/contrib/debian/changelog +++ b/contrib/debian/changelog @@ -1,3 +1,32 @@ +dragonx (1.3.1) stable; urgency=high + + * Fix a file-descriptor double close that could corrupt wallets and block + files and crash the node. wolfSSL's CloseSocket macro turned every P2P + disconnect into a bare close() that left the socket handle set, so the + same fd number was closed again after another thread had reused it, + often for wallet.dat, a BDB log or a block file. Present in every + release since the 2020 move to wolfSSL. + * Defense in depth: block and undo files opened for reading are now + read-only; a wallet error while processing a block now shuts the node + down cleanly instead of crashing it; the BDB logs are kept when the + shutdown flush fails, so the next start can recover the wallet. + * Fix an abort on the next block after a note arrives at an address + imported with z_importviewingkey. + * Large wallets: break the replay -> witness rebuild -> kill loop. Witness + extension is much cheaper and parallel, the chainstate is flushed at + least hourly, replayed transactions keep their nullifiers (no mass + reseed), and the wallet locator never runs ahead of what the wallet has + processed. + * Much faster balance and note RPCs on wallets with thousands of Sapling + notes, through an in-memory cache of decrypted notes (warmed at startup, + -notecachewarmup) and by avoiding deep copies; history RPCs gather + Sapling keys once per call. + * -txindex=0 nodes now include fully shielded notes in queries with + minconf >= 2, so -zsweep and -consolidation now move them. + * No consensus change; PROTOCOL_VERSION is unchanged. + + -- DragonX Developers Tue, 27 Oct 2026 12:00:00 +0000 + dragonx (1.3.0) stable; urgency=medium * RandomX stratum mining support: the daemon can serve stratum clients diff --git a/doc/man/dragonx-cli.1 b/doc/man/dragonx-cli.1 index 849ef114a..a4f8a8b5b 100644 --- a/doc/man/dragonx-cli.1 +++ b/doc/man/dragonx-cli.1 @@ -1,9 +1,9 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.1. -.TH DRAGONX-CLI "1" "August 2026" "dragonx-cli v1.3.0" "User Commands" +.TH DRAGONX-CLI "1" "October 2026" "dragonx-cli v1.3.1" "User Commands" .SH NAME -dragonx-cli \- manual page for dragonx-cli v1.3.0 +dragonx-cli \- manual page for dragonx-cli v1.3.1 .SH DESCRIPTION -DragonX RPC client version v1.3.0 +DragonX RPC client version v1.3.1 .PP In order to ensure you are adequately protecting your privacy when using DragonX, please see . diff --git a/doc/man/dragonx-tx.1 b/doc/man/dragonx-tx.1 index 126f7b444..aa7b20fe6 100644 --- a/doc/man/dragonx-tx.1 +++ b/doc/man/dragonx-tx.1 @@ -1,9 +1,9 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.1. -.TH DRAGONX-TX "1" "August 2026" "dragonx-tx v1.3.0" "User Commands" +.TH DRAGONX-TX "1" "October 2026" "dragonx-tx v1.3.1" "User Commands" .SH NAME -dragonx-tx \- manual page for dragonx-tx v1.3.0 +dragonx-tx \- manual page for dragonx-tx v1.3.1 .SH DESCRIPTION -hush\-tx utility version v1.3.0 +hush\-tx utility version v1.3.1 .SS "Usage:" .TP hush\-tx [options] [commands] diff --git a/doc/man/dragonxd.1 b/doc/man/dragonxd.1 index 9fd809079..c72058e19 100644 --- a/doc/man/dragonxd.1 +++ b/doc/man/dragonxd.1 @@ -1,9 +1,9 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.1. -.TH DRAGONXD "1" "August 2026" "dragonxd v1.3.0" "User Commands" +.TH DRAGONXD "1" "October 2026" "dragonxd v1.3.1" "User Commands" .SH NAME -dragonxd \- manual page for dragonxd v1.3.0 +dragonxd \- manual page for dragonxd v1.3.1 .SH DESCRIPTION -DragonX Daemon version v1.3.0 +DragonX Daemon version v1.3.1 .PP In order to ensure you are adequately protecting your privacy when using DragonX, please see . diff --git a/src/chain.h b/src/chain.h index aa039e83f..87edf54ad 100644 --- a/src/chain.h +++ b/src/chain.h @@ -35,7 +35,7 @@ extern bool fZindex; // These version thresholds control whether nSproutValue/nSaplingValue are // serialized in the block index. They must be <= CLIENT_VERSION or the // values will never be persisted, causing nChainSaplingValue to reset -// to 0 after node restart. DragonX CLIENT_VERSION is 1030050 (v1.3.0.50). +// to 0 after node restart. DragonX CLIENT_VERSION is 1030150 (v1.3.1.50). static const int SPROUT_VALUE_VERSION = 1000000; static const int SAPLING_VALUE_VERSION = 1000000; // Block-index records written at >= this version store nSaplingValue as a boost::optional diff --git a/src/clientversion.h b/src/clientversion.h index 734105201..8106ec01e 100644 --- a/src/clientversion.h +++ b/src/clientversion.h @@ -30,7 +30,7 @@ // Must be kept in sync with configure.ac , ugh! #define CLIENT_VERSION_MAJOR 1 #define CLIENT_VERSION_MINOR 3 -#define CLIENT_VERSION_REVISION 0 +#define CLIENT_VERSION_REVISION 1 #define CLIENT_VERSION_BUILD 50 //! Set to true for release, false for prerelease or test build