From 23877e1038d871c60a152a22cf6f14375a375b90 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 21:27:01 -0500 Subject: [PATCH] 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);