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
+143 -22
View File
@@ -153,6 +153,29 @@ void ExpectCacheMatchesFreshDecrypt(const SaplingNoteData& nd, const OutputDescr
EXPECT_TRUE(nd.cachedPlaintext->memo == fresh->first.memo());
}
// Sets a -arg for the scope and restores its previous value (or absence), even when an assertion returns early.
class ScopedArg {
std::string name;
bool had;
std::string old;
public:
ScopedArg(const std::string& nameIn, const std::string& value) : name(nameIn), had(mapArgs.count(nameIn) > 0) {
if (had) old = mapArgs[name];
mapArgs[name] = value;
}
~ScopedArg() {
if (had) mapArgs[name] = old;
else mapArgs.erase(name);
}
};
class ScopedCheckNoteCache {
bool old;
public:
ScopedCheckNoteCache() : old(fCheckNoteCache) { fCheckNoteCache = true; }
~ScopedCheckNoteCache() { fCheckNoteCache = old; }
};
// The fake active chain is 0..TIP_HEIGHT; STALE_HEIGHT is a side block forked off it.
constexpr int TIP_HEIGHT = 30;
constexpr int STALE_HEIGHT = 25;
@@ -653,6 +676,7 @@ TEST_F(WalletNoteCache, CachedPlaintextMatchesFreshDecrypt) {
LOCK2(cs_main, wallet.cs_wallet);
for (int pass = 0; pass < 2; pass++) {
if (pass == 1) {
// The default filters skip spent and locked notes, so ask for everything.
wallet.ClearPlaintextCache();
Filtered({}, 0, INT_MAX, false, false, false); // lazy fill of every confirmed note
}
@@ -665,7 +689,8 @@ TEST_F(WalletNoteCache, CachedPlaintextMatchesFreshDecrypt) {
checked++;
}
}
EXPECT_GE(checked, 9u) << "pass " << pass;
// Eagerly: all 11 notes. Lazily: the 9 confirmed ones (not the never-mined or stale-block note).
EXPECT_EQ(pass ? 9u : 11u, checked) << "pass " << pass;
}
}
@@ -714,7 +739,6 @@ TEST_F(WalletNoteCache, CacheNeverSerialized) {
d << empty.mapSaplingNoteData.begin()->second;
EXPECT_TRUE(c.str() == d.str()) << "SaplingNoteData, type " << type;
}
EXPECT_EQ(txid, filled.GetHash());
CDataStream ss(SER_DISK, CLIENT_VERSION);
ss << filled;
@@ -725,15 +749,15 @@ TEST_F(WalletNoteCache, CacheNeverSerialized) {
EXPECT_FALSE(static_cast<bool>(item.second.cachedPlaintext));
}
// UpdatedNoteData: a difference in the cache alone is no update (no rewrite to disk); the replace branch
// keeps a plaintext the wallet already had; and nothing is carried across a different ivk.
// UpdatedNoteData: a replay arriving without plaintext keeps the wallet's cache; the replace branch keeps a
// plaintext the wallet already had; and nothing is carried across a different ivk.
TEST_F(WalletNoteCache, UpdatedNoteDataAndTheCache) {
LOCK2(cs_main, wallet.cs_wallet);
const uint256 txid = txids[2];
CWalletTx& wtx = wallet.mapWallet.at(txid);
const SaplingOutPoint op0(txid, 0), op1(txid, 1);
// Same note data, but arriving without plaintext: unchanged, and the wallet keeps its cache.
// Same note data arriving without plaintext: no update, and the wallet keeps its cache.
CWalletTx replay = wtx;
for (auto& item : replay.mapSaplingNoteData)
item.second.cachedPlaintext = boost::none;
@@ -800,7 +824,10 @@ TEST_F(WalletNoteCache, WarmUpFillsEveryNote) {
TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) {
LOCK2(cs_main, wallet.cs_wallet);
wallet.ClearPlaintextCache();
wallet.WarmSaplingNoteCache();
auto warmup = wallet.WarmSaplingNoteCache();
EXPECT_EQ(1u, warmup.noSuchOutput);
EXPECT_EQ(2u, warmup.undecryptable);
EXPECT_EQ(warmup.notes - 3, warmup.filled);
const auto& bad = wallet.mapWallet.at(txBad).mapSaplingNoteData;
ASSERT_EQ(3u, bad.size());
for (const auto& item : bad)
@@ -825,7 +852,7 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) {
ASSERT_TRUE(wallet.AddSaplingZKey(skA, addrsA[0]));
ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), skB.DefaultAddress()));
uint64_t value = 0;
for (int t = 0; t < 30; t++) {
for (int t = 0; t < 46; t++) {
std::vector<OutputDescription> outs;
for (int i = 0; i < 10; i++)
outs.push_back(MakeOutput(i % 3 == 2 ? skB.DefaultAddress() : addrsA[i % addrsA.size()], ++value));
@@ -846,14 +873,16 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) {
return out;
};
auto eager = snapshot();
ASSERT_EQ(300u, eager.size());
ASSERT_EQ(460u, eager.size());
for (const char* threads : {"1", "4", "7"}) {
mapArgs["-witnessbuildthreads"] = threads;
// 460 notes allow up to 7 threads (at least 64 notes each); 460 % 7 != 0, so the shards are uneven.
for (int threads : {1, 4, 7}) {
ScopedArg arg("-witnessbuildthreads", std::to_string(threads));
wallet.ClearPlaintextCache();
const uint64_t base = wallet.nSaplingNoteCacheFills;
wallet.WarmSaplingNoteCache();
EXPECT_EQ(base + 300, wallet.nSaplingNoteCacheFills) << threads << " thread(s)";
EXPECT_EQ(threads, wallet.nSaplingNoteCacheFillThreads);
EXPECT_EQ(base + 460, wallet.nSaplingNoteCacheFills) << threads << " thread(s)";
auto filled = snapshot();
ASSERT_EQ(eager.size(), filled.size()) << threads << " thread(s)";
for (size_t i = 0; i < eager.size(); i++) {
@@ -864,26 +893,118 @@ TEST(WalletNoteCacheParallel, ParallelFillMatchesSerial) {
EXPECT_TRUE(eager[i].second.memo == filled[i].second.memo);
}
}
mapArgs.erase("-witnessbuildthreads");
}
// The cache is trusted as is; -checknotecache decrypts every hit again, and logs and repairs a difference.
// The cache is trusted as is; -checknotecache decrypts each note a walk considers 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
const SaplingOutPoint memoOp(txids[2], 1); // tx3 output 1: to C, value 5000
SaplingNoteData& nd = wallet.mapWallet.at(op.hash).mapSaplingNoteData.at(op);
ASSERT_TRUE(static_cast<bool>(nd.cachedPlaintext));
SaplingNoteData& memoNd = wallet.mapWallet.at(memoOp.hash).mapSaplingNoteData.at(memoOp);
ASSERT_TRUE(nd.cachedPlaintext && memoNd.cachedPlaintext);
const SaplingNote good = nd.cachedPlaintext->note;
nd.cachedPlaintext->note = SaplingNote(good.d, good.pk_d, good.value() + 1, good.r);
memoNd.cachedPlaintext->memo[0] ^= 0xff;
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";
// Entries come back in wallet (txid) order, so find them by outpoint.
auto entry = [](const std::vector<SaplingNoteEntry>& entries, const SaplingOutPoint& want) {
for (const SaplingNoteEntry& e : entries)
if (e.op == want)
return e;
ADD_FAILURE() << "no entry for " << want.hash.ToString() << ":" << want.n;
return SaplingNoteEntry();
};
auto trusted = Filtered({paC}, 1, INT_MAX, true, false, false);
ASSERT_EQ(3u, trusted.size());
EXPECT_FALSE(entry(trusted, memoOp).memo == MemoFor(5000)) << "without the check, the cache is what gets returned";
EXPECT_EQ(7001u, entry(trusted, op).note.value());
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());
std::vector<SaplingNoteEntry> checked;
{
ScopedCheckNoteCache on;
checked = Filtered({paC}, 1, INT_MAX, true, false, false);
}
ASSERT_EQ(3u, checked.size());
EXPECT_TRUE(entry(checked, memoOp).memo == MemoFor(5000));
EXPECT_EQ(7000u, entry(checked, op).note.value());
EXPECT_EQ(7000u, nd.cachedPlaintext->note.value()) << "repaired";
EXPECT_TRUE(memoNd.cachedPlaintext->memo == MemoFor(5000)) << "repaired";
}
// The cache must stay out of the "did this tx change?" decision. A rescan replays a witnessed note that was
// loaded from wallet.dat (so it has no plaintext) with FindMySaplingNotes' fresh, plaintext-carrying data:
// that is no update (no rewrite to disk; counting the cache as a difference brings back the rewrite storm
// 69bb035e7 removed), the stored witness and nullifier stay, and the stored entry gains the plaintext.
TEST_F(WalletNoteCache, ReplayWithPlaintextIsNoUpdate) {
LOCK2(cs_main, wallet.cs_wallet);
const uint256 txid = txids[2]; // tx3: three notes
CWalletTx& stored = wallet.mapWallet.at(txid);
const SaplingOutPoint op0(txid, 0);
SaplingMerkleTree tree;
tree.append(stored.vShieldedOutput[0].cm);
SaplingNoteData& nd0 = stored.mapSaplingNoteData.at(op0);
nd0.witnesses.push_front(tree.witness());
nd0.witnessHeight = 20;
nd0.nullifier = uint256S("abcd1234");
wallet.ClearPlaintextCache();
CWalletTx replay(&wallet, stored);
mapSaplingNoteData_t fresh = wallet.FindMySaplingNotes(stored).first;
replay.SetSaplingNoteData(fresh);
for (const auto& item : replay.mapSaplingNoteData)
ASSERT_TRUE(static_cast<bool>(item.second.cachedPlaintext));
ASSERT_FALSE(replay.mapSaplingNoteData == stored.mapSaplingNoteData) << "op0 differs in nullifier/height";
EXPECT_FALSE(wallet.UpdatedNoteData(replay, stored));
const SaplingNoteData& after = stored.mapSaplingNoteData.at(op0); // re-read: a replace would rebuild the map
EXPECT_EQ(1u, after.witnesses.size());
EXPECT_EQ(20, after.witnessHeight);
ASSERT_TRUE(static_cast<bool>(after.nullifier)) << "a replace dropped the witnessed note's nullifier";
EXPECT_EQ(uint256S("abcd1234"), *after.nullifier);
for (const auto& item : stored.mapSaplingNoteData)
ExpectCacheMatchesFreshDecrypt(item.second, stored.vShieldedOutput[item.first.n]);
// The note data equal outright (operator== ignores the cache): no update, plaintext kept as well.
wallet.ClearPlaintextCache();
CWalletTx same(&wallet, stored);
mapSaplingNoteData_t sameData = stored.mapSaplingNoteData;
for (auto& item : sameData)
item.second.cachedPlaintext = fresh.at(item.first).cachedPlaintext;
same.SetSaplingNoteData(sameData);
ASSERT_TRUE(same.mapSaplingNoteData == stored.mapSaplingNoteData);
EXPECT_FALSE(wallet.UpdatedNoteData(same, stored));
EXPECT_EQ(3u, wallet.CachedNotes()) << "only tx3's entries were refilled";
}
// A cold walk big enough for the parallel fill returns exactly what the 69bb035e7 loop returns.
class WalletNoteCacheMany : public WalletNoteCache {
protected:
void AddMoreTxs() override {
uint64_t value = 20000;
for (int t = 0; t < 26; t++) {
std::vector<OutputDescription> outs;
for (int i = 0; i < 10; i++)
outs.push_back(MakeOutput(i % 2 ? paA : paC, ++value));
AddTx(MakeTx(outs), 2 + t % 9);
}
}
};
TEST_F(WalletNoteCacheMany, ColdParallelFillMatchesLegacy) {
ScopedArg threads("-witnessbuildthreads", "4");
const std::set<PaymentAddress> none, onlyC = {paC};
for (int minDepth : {0, 2}) {
for (bool requireSpendingKey : {false, true}) {
{
LOCK(wallet.cs_wallet);
wallet.ClearPlaintextCache();
}
auto got = Filtered(minDepth ? onlyC : none, minDepth, INT_MAX, true, requireSpendingKey, true);
EXPECT_EQ(4, wallet.nSaplingNoteCacheFillThreads) << "the cold fill ran on 4 threads";
ExpectSameEntries(Legacy(minDepth ? onlyC : none, minDepth, INT_MAX, true, requireSpendingKey, true), got,
Describe(minDepth, INT_MAX, true, requireSpendingKey, true, minDepth ? "C" : "none"));
}
}
}
+2 -1
View File
@@ -516,7 +516,7 @@ std::string HelpMessage(HelpMessageMode mode)
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("-checknotecache", "On every balance or note-listing call, decrypt each cached note it considers again and log and repair 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"));
@@ -2713,6 +2713,7 @@ bool AppInit2(boost::thread_group& threadGroup, CScheduler& scheduler)
// Decrypt the wallet's notes into the in-memory plaintext cache now, while RPC is still in warmup
// and before the wallet notification thread (started below) can want cs_wallet. Otherwise the
// first balance call after a restart decrypts them all under cs_main.
fCheckNoteCache = GetBoolArg("-checknotecache", false);
if (GetBoolArg("-notecachewarmup", true) && !ShutdownRequested()) {
uiInterface.InitMessage(_("Decrypting wallet notes..."));
pwalletMain->WarmSaplingNoteCache();
+40 -15
View File
@@ -78,6 +78,7 @@ CBlockIndex *hush_chainactive(int32_t height);
extern int32_t HUSH_LOADINGBLOCKS;
int32_t hush_dpowconfs(int32_t height,int32_t numconfs);
bool fTxDeleteEnabled = false;
bool fCheckNoteCache = false;
bool fTxConflictDeleteEnabled = false;
int fDeleteInterval = DEFAULT_TX_DELETE_INTERVAL;
unsigned int fDeleteTransactionsAfterNBlocks = DEFAULT_TX_RETENTION_BLOCKS;
@@ -2034,6 +2035,21 @@ bool CWallet::AddToWallet(const CWalletTx& wtxIn, bool fFromLoadWallet, CWalletD
// True if both maps hold the same notes with identical persisted state (every field SaplingNoteData
// serializes). SaplingNoteData::operator== ignores the witnesses, so it cannot tell a real change
// from a redundant update.
// Copies the decrypted plaintext of each incoming note into the matching (same outpoint and ivk) entry that
// lacks one. UpdatedNoteData uses it when it keeps the wallet's entries: a replay or rescan has just
// decrypted the notes (FindMySaplingNotes), and entries loaded from wallet.dat have no plaintext. The cache
// is in memory only and outside every comparison, so this never makes a tx look changed.
static void KeepNotePlaintexts(const mapSaplingNoteData_t& incoming, mapSaplingNoteData_t& wallet)
{
for (const mapSaplingNoteData_t::value_type& in : incoming) {
if (!in.second.cachedPlaintext)
continue;
mapSaplingNoteData_t::iterator it = wallet.find(in.first);
if (it != wallet.end() && !it->second.cachedPlaintext && it->second.ivk == in.second.ivk)
it->second.cachedPlaintext = in.second.cachedPlaintext;
}
}
static bool SaplingNoteDataIdentical(const mapSaplingNoteData_t& a, const mapSaplingNoteData_t& b)
{
if (a.size() != b.size())
@@ -2053,8 +2069,10 @@ static bool SaplingNoteDataIdentical(const mapSaplingNoteData_t& a, const mapSap
bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSameBlock)
{
bool unchangedSaplingFlag = (wtxIn.mapSaplingNoteData.empty() || wtxIn.mapSaplingNoteData == wtx.mapSaplingNoteData);
if (unchangedSaplingFlag)
if (unchangedSaplingFlag) {
KeepNotePlaintexts(wtxIn.mapSaplingNoteData, wtx.mapSaplingNoteData);
return false;
}
auto tmp = wtxIn.mapSaplingNoteData;
// Ensure we keep any cached witnesses we may already have
@@ -2093,6 +2111,7 @@ bool CWallet::UpdatedNoteData(const CWalletTx& wtxIn, CWalletTx& wtx, bool fSame
if (SaplingNoteDataIdentical(tmp, wtx.mapSaplingNoteData)) {
for (mapSaplingNoteData_t::value_type& item : wtx.mapSaplingNoteData)
item.second.witnessRootValidated = false;
KeepNotePlaintexts(tmp, wtx.mapSaplingNoteData);
return false;
}
@@ -5782,49 +5801,55 @@ unsigned int CWallet::FillSaplingNoteCache(const std::vector<SaplingNoteToDecryp
for (unsigned int f : filled)
nFilled += f;
nSaplingNoteCacheFills += nFilled;
nSaplingNoteCacheFillThreads = nPar;
LogPrint("bench", "%s: decrypted %u of %u notes using %d thread(s)\n", __func__, nFilled, (unsigned int)notes.size(), nPar);
return nFilled;
}
// wallet.dat doesn't store the plaintext, so without this the first balance call after a restart decrypts
// the whole wallet under cs_main (~0.5ms a note). init runs it before RPC leaves warmup and before the
// wallet notification thread starts, so nothing waits on cs_wallet meanwhile. Unlike GetFilteredNotes it
// never asserts, so a corrupt note can't stop the node from starting (or from serving the key-export RPCs
// allowed during warmup); GetFilteredNotes still asserts on such a note, as before.
void CWallet::WarmSaplingNoteCache()
// wallet notification thread starts; only the few RPCs allowed during warmup (key export, address
// listing) can wait on cs_wallet meanwhile. Unlike GetFilteredNotes it never asserts, so a corrupt note
// can't stop the node from starting (or from serving those RPCs); GetFilteredNotes still asserts on such
// a note, as before.
CWallet::SaplingNoteCacheWarmup CWallet::WarmSaplingNoteCache()
{
const std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
LOCK(cs_wallet);
SaplingNoteCacheWarmup result;
std::vector<SaplingNoteToDecrypt> uncached;
unsigned int nNotes = 0, nBad = 0;
for (auto& p : mapWallet) {
CWalletTx& wtx = p.second;
for (auto& item : wtx.mapSaplingNoteData) {
nNotes++;
result.notes++;
if (item.second.cachedPlaintext)
continue;
if (item.first.n >= wtx.vShieldedOutput.size()) {
LogPrint("zrpcunsafe", "%s: note %s:%u has no such output\n", __func__, item.first.hash.ToString(), item.first.n);
nBad++;
result.noSuchOutput++;
continue;
}
uncached.push_back(SaplingNoteToDecrypt { &item.second, &wtx.vShieldedOutput[item.first.n] });
}
}
const unsigned int nFilled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached);
result.filled = uncached.empty() ? 0 : FillSaplingNoteCache(uncached);
for (const SaplingNoteToDecrypt& n : uncached) {
if (!n.nd->cachedPlaintext) {
LogPrint("zrpcunsafe", "%s: a note of output %s does not decrypt under its ivk\n", __func__, n.output->cm.ToString());
nBad++;
result.undecryptable++;
}
}
const unsigned int nBad = result.noSuchOutput + result.undecryptable;
const int64_t nMs = std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now() - start).count();
LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, nFilled, nNotes, nMs,
LogPrintf("%s: decrypted %u of %u Sapling notes in %dms%s\n", __func__, result.filled, result.notes, nMs,
nBad ? strprintf("; %u could not be decrypted (-debug=zrpcunsafe lists them)", nBad) : "");
return result;
}
// -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.
// -checknotecache: decrypts a cached note again, and logs and repairs any difference. GetFilteredNotes runs
// it on every note that passes its tx, spent and locked filters, before the address filter, so a corrupt
// cached address can't wrongly drop a note unchecked. A diagnostic for the cache: it costs the full
// decryption the cache 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);
@@ -5966,12 +5991,12 @@ 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);
if (filterAddresses.empty())
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)
if (fCheckNoteCache)
CheckSaplingNoteCache(*c.op, *c.nd, *c.output);
const SaplingNoteCache& plaintext = *c.nd->cachedPlaintext;
const libzcash::SaplingPaymentAddress pa(plaintext.note.d, plaintext.note.pk_d);
+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,