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
+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);