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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 08:16:09 -05:00
co-authored by Claude Opus 5.5
parent 43635b7fd7
commit 23877e1038
3 changed files with 50 additions and 3 deletions
+21
View File
@@ -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<bool>(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";
}
+2
View File
@@ -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"));
+27 -3
View File
@@ -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<Candidate> 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<bool>(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);