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