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 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 08:16:09 -05:00
co-authored by Claude Opus 5.5
parent 23877e1038
commit 190023f85f
4 changed files with 200 additions and 43 deletions
+14 -4
View File
@@ -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<SaplingNoteToDecrypt>& 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<SaplingNoteEntry>& saplingEntries,
std::string address,