wallet/init: decrypt every wallet note into the plaintext cache at startup

The plaintext cache is in memory only, so a restart empties it. Without
a warm-up, the first balance call after a restart decrypts the whole
wallet under LOCK2(cs_main, cs_wallet): ~6s for a 12k-note wallet. That
is the observed failure: restart, the GUI connects, and the node looks
stuck.

CWallet::WarmSaplingNoteCache decrypts every note whose plaintext isn't
cached, spent ones included, because z_listreceivedbyaddress
(ignoreSpent=false) is called once per address by the GUI's history
scan.

init runs it at the end of the wallet block, just before
SetBroadcastTransactions. At that point RPC is still in warmup (it
shows "Decrypting wallet notes...") and the wallet notification
thread, whose startup witness rebuild can hold cs_wallet for a long
time, has not started yet. It takes cs_wallet only. -notecachewarmup=0
(debug help) skips it, which leaves the lazy fill in GetFilteredNotes
to do the work.

Unlike GetFilteredNotes, the warm-up never asserts. A note that doesn't
decrypt under its ivk, or names an output its tx lacks, is counted in
the summary line and listed under -debug=zrpcunsafe. A corrupt note
therefore can't stop the node from starting, or from serving the
key-export RPCs allowed during warmup; GetFilteredNotes still asserts
on it, as before.

Tests:
- WarmUpFillsEveryNote: spent, locked, unconfirmed and stale-block
  notes included; walks afterwards decrypt nothing.
- WarmUpSkipsBadNotes: undecryptable and out-of-range notes are skipped
  without aborting.

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 b1e3c5230a
commit 83a14a7bfb
4 changed files with 96 additions and 1 deletions
+46 -1
View File
@@ -467,7 +467,7 @@ TEST_F(WalletNoteCache, HeightFromDepth) {
// Two notes recorded under A's viewing key that it can't actually decrypt (real outputs to someone
// else): output 0 is spent by a mined tx, output 1 is locked. If the walk decrypted either one, the
// decrypt assert would abort.
// decrypt assert would abort. A third, locked too, names an output the tx doesn't have.
class WalletNoteCacheUndecryptable : public WalletNoteCache {
protected:
uint256 txBad;
@@ -482,12 +482,14 @@ protected:
CWalletTx wtx(&wallet, tx);
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf);
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = SaplingNoteData(ivkA);
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 7)] = SaplingNoteData(ivkA);
EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr));
depthOf[txBad] = 4;
txids.push_back(txBad);
AddTx(MakeTx({}, {nf}), 4);
wallet.LockNote(SaplingOutPoint(txBad, 1));
wallet.LockNote(SaplingOutPoint(txBad, 7));
}
};
@@ -770,3 +772,46 @@ TEST_F(WalletNoteCache, SpendabilityNotCached) {
EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, true, false).size());
EXPECT_EQ(fills, wallet.nSaplingNoteCacheFills);
}
// The startup warm-up decrypts every note the wallet holds: spent, locked, unconfirmed and stale ones too
// (z_listreceivedbyaddress asks for spent notes), after which a walk decrypts nothing.
TEST_F(WalletNoteCache, WarmUpFillsEveryNote) {
LOCK2(cs_main, wallet.cs_wallet);
size_t total = 0;
for (const auto& p : wallet.mapWallet)
total += p.second.mapSaplingNoteData.size();
ASSERT_EQ(11u, total); // tx1 2, tx2 1, tx3 3, tx4 1, unmined 1, stale 1, tx6 1, tx7 1
wallet.ClearPlaintextCache();
const uint64_t base = wallet.nSaplingNoteCacheFills;
wallet.WarmSaplingNoteCache();
EXPECT_EQ(total, wallet.CachedNotes());
EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills);
wallet.WarmSaplingNoteCache();
EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "nothing left to do";
Filtered({}, 0, INT_MAX, false, false, false);
Filtered({paC}, 11, INT_MAX, true, true, true);
EXPECT_EQ(base + total, wallet.nSaplingNoteCacheFills) << "walks find everything cached";
}
// The warm-up never aborts: notes that don't decrypt, or name an output the tx doesn't have, are left
// unfilled and everything else is filled.
TEST_F(WalletNoteCacheUndecryptable, WarmUpSkipsBadNotes) {
LOCK2(cs_main, wallet.cs_wallet);
wallet.ClearPlaintextCache();
wallet.WarmSaplingNoteCache();
const auto& bad = wallet.mapWallet.at(txBad).mapSaplingNoteData;
ASSERT_EQ(3u, bad.size());
for (const auto& item : bad)
EXPECT_FALSE(static_cast<bool>(item.second.cachedPlaintext)) << item.first.n;
size_t cached = 0, total = 0;
for (const auto& p : wallet.mapWallet) {
for (const auto& item : p.second.mapSaplingNoteData) {
total++;
cached += item.second.cachedPlaintext ? 1 : 0;
}
}
EXPECT_EQ(total - 3, cached);
EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "the bad notes are spent or locked";
}