wallet: filter notes before decrypting them in GetFilteredNotes

GetFilteredNotes copied every CWalletTx and every SaplingNoteData (witness
list included) by value, and trial-decrypted every note before it applied
the address, spent and lock filters. It sits under z_listunspent,
z_gettotalbalance, z_getbalance, z_listreceivedbyaddress, z_mergetoaddress
and sweep/consolidation, all with cs_main and cs_wallet held, so on a wallet
with thousands of notes it is most of the time those RPCs block the
block-connect thread.

- Read wallet txs and note data by const reference, and skip txs with no
  Sapling notes before the finality, maturity and depth checks.
- Check IsSaplingSpent before decrypting, so spent notes are not decrypted
  when ignoreSpent is set.
- Address pre-filter: when 1-16 filter addresses all have a recorded ivk that
  is canonical (below 2^251) and actually generates that address, a note
  whose canonical ivk is none of them cannot pay a filter address and is
  skipped without decrypting. z_importviewingkey stores (ivk, address) pairs
  unchecked, hence the verification; an aliased ivk + r_J would pass it,
  hence the canonical check. Any other case takes the old path.
- SaplingNotePlaintext::decrypt_with_pk_d returns the pk_d that decryption
  already computes, so the note's address and SaplingNote are built without
  two more scalar multiplications. decrypt() is now a wrapper around it.

RPC output is unchanged. The one behaviour difference: a spent note that
cannot be decrypted no longer trips assert(maybe_pt) when ignoreSpent is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 13:31:01 -05:00
co-authored by Claude Opus 5.5
parent c1040028e4
commit 106fc1ceac
3 changed files with 117 additions and 20 deletions
+93 -20
View File
@@ -5629,6 +5629,26 @@ void CWallet::GetFilteredNotes(
GetFilteredNotes(saplingEntries, filterAddresses, minDepth, INT_MAX, ignoreSpent, requireSpendingKey);
}
namespace {
// GetFilteredNotes' decrypt-free address pre-filter is only built for at most this many
// filter addresses. Verifying each one costs a Jubjub scalar multiplication, which buys
// nothing when the filter is "every address in the wallet" (z_listunspent, z_getbalances).
// Setting it to 0 disables the pre-filter entirely.
const size_t GETFILTEREDNOTES_PREFILTER_MAX_ADDRESSES = 16;
// True if ivk < 2^251. A genuine Sapling ivk is CRH_ivk output with its top 5 bits cleared
// (librustzcash_crh_ivk), so it is below 2^251 < r_J (the Jubjub subgroup order). Below r_J,
// pk_d = [ivk] g_d determines ivk uniquely. librustzcash_ivk_to_pkd multiplies by the raw
// 256-bit value without reducing it, so a value >= r_J aliases ivk - r_J. Byte 31 of the
// little-endian representation is the most significant byte.
bool IsCanonicalSaplingIvk(const libzcash::SaplingIncomingViewingKey& ivk)
{
return (ivk.begin()[31] & 0xF8) == 0;
}
} // namespace
/**
* Find notes in the wallet filtered by payment addresses, min depth, max depth,
* if the note is spent, if a spending key is required, and if the notes are locked.
@@ -5647,8 +5667,47 @@ void CWallet::GetFilteredNotes(
LogPrintf("%s ignoreLocked=%d\n", __func__, ignoreLocked);
for (auto & p : mapWallet) {
CWalletTx wtx = p.second;
// Decrypt-free address pre-filter. A note matches a Sapling filter address a only if
// nd.ivk.address(a.d) == a. If a's recorded ivk r is canonical and provably generates a
// (r.address(a.d) == a), then any other canonical ivk cannot generate a, so a note whose
// canonical nd.ivk is not one of these r can be skipped without decrypting it. Recorded
// pairs are not trusted as-is (z_importviewingkey stores any (ivk, zaddr) pair the user
// supplies), so each one is verified. If any Sapling filter address has no recorded ivk,
// a non-canonical one, or one that does not generate it, the pre-filter is off and every
// note takes the old decrypt path. InvalidEncoding entries never equal a note's address.
std::set<libzcash::SaplingIncomingViewingKey> prefilterIvks;
bool usePrefilter = !filterAddresses.empty() &&
filterAddresses.size() <= GETFILTEREDNOTES_PREFILTER_MAX_ADDRESSES;
if (usePrefilter) {
for (const libzcash::PaymentAddress& fa : filterAddresses) {
const libzcash::SaplingPaymentAddress* sa = boost::get<libzcash::SaplingPaymentAddress>(&fa);
if (sa == nullptr) {
continue;
}
libzcash::SaplingIncomingViewingKey rivk;
if (!GetSaplingIncomingViewingKey(*sa, rivk) || !IsCanonicalSaplingIvk(rivk)) {
usePrefilter = false;
break;
}
auto derived = rivk.address(sa->d);
if (!derived || !(derived.get() == *sa)) {
usePrefilter = false;
break;
}
prefilterIvks.insert(rivk);
}
}
for (const auto & p : mapWallet) {
// A reference, not a copy: copying a CWalletTx deep-copies every note's witness list.
// Nothing in this loop mutates mapWallet or any CWalletTx, and cs_main + cs_wallet are
// held for the whole function, so the reference stays valid.
const CWalletTx& wtx = p.second;
// A tx without Sapling note data can never yield an entry (the note loop below runs
// zero times), so skip it before the finality, maturity and depth work.
if (wtx.mapSaplingNoteData.empty())
continue;
// Filter the transactions before checking for notes
if (!CheckFinalTx(wtx) || wtx.GetBlocksToMaturity() > 0)
@@ -5671,31 +5730,43 @@ void CWallet::GetFilteredNotes(
}
}
for (auto & pair : wtx.mapSaplingNoteData) {
SaplingOutPoint op = pair.first;
SaplingNoteData nd = pair.second;
for (const auto & pair : wtx.mapSaplingNoteData) {
const SaplingOutPoint& op = pair.first;
const SaplingNoteData& nd = pair.second;
auto maybe_pt = SaplingNotePlaintext::decrypt(
wtx.vShieldedOutput[op.n].encCiphertext,
nd.ivk,
wtx.vShieldedOutput[op.n].ephemeralKey,
wtx.vShieldedOutput[op.n].cm);
assert(static_cast<bool>(maybe_pt));
auto notePt = maybe_pt.get();
auto maybe_pa = nd.ivk.address(notePt.d);
assert(static_cast<bool>(maybe_pa));
auto pa = maybe_pa.get();
// skip notes which belong to a different payment address in the wallet
if (!(filterAddresses.empty() || filterAddresses.count(pa))) {
// Address pre-filter (see prefilterIvks above): a canonical ivk that is not one of
// the verified filter ivks cannot generate any filter address, so the
// filterAddresses check after decryption would reject this note anyway.
if (usePrefilter && IsCanonicalSaplingIvk(nd.ivk) && prefilterIvks.count(nd.ivk) == 0) {
continue;
}
// Spent check before trial decryption: it reads only nd.nullifier and wallet
// state (not the plaintext or the address), so spent notes are never decrypted.
if (ignoreSpent && nd.nullifier && IsSaplingSpent(*nd.nullifier)) {
continue;
}
uint256 pk_d;
auto maybe_pt = SaplingNotePlaintext::decrypt_with_pk_d(
wtx.vShieldedOutput[op.n].encCiphertext,
nd.ivk,
wtx.vShieldedOutput[op.n].ephemeralKey,
wtx.vShieldedOutput[op.n].cm,
pk_d);
assert(static_cast<bool>(maybe_pt));
auto notePt = maybe_pt.get();
// Same value as nd.ivk.address(notePt.d).get(): decryption succeeded, so
// librustzcash_ivk_to_pkd(nd.ivk, d) returned true (d is a valid diversifier, which
// is all check_diversifier tests) and wrote pk_d, the value address() recomputes.
SaplingPaymentAddress pa(notePt.d, pk_d);
// skip notes which belong to a different payment address in the wallet
if (!(filterAddresses.empty() || filterAddresses.count(pa))) {
continue;
}
// skip notes which cannot be spent
if (requireSpendingKey) {
libzcash::SaplingIncomingViewingKey ivk;
@@ -5713,7 +5784,9 @@ void CWallet::GetFilteredNotes(
continue;
}
auto note = notePt.note(nd.ivk).get();
// Same value as notePt.note(nd.ivk).get(), which is
// SaplingNote(d, nd.ivk.address(d)->pk_d, value, rcm): pa is nd.ivk.address(d).
libzcash::SaplingNote note(notePt.d, pa.pk_d, notePt.value(), notePt.rcm);
saplingEntries.push_back(SaplingNoteEntry {
op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() });
}