wallet: walk GetFilteredNotes by reference, cheap filters first

GetFilteredNotes copied every CWalletTx by value before any filter ran,
then copied each SaplingNoteData by value again. Both copies deep-copy
the note's witness list (up to WITNESS_CACHE_SIZE witnesses, ~100KB per
note on a mainnet wallet): ~40us per note per pass, measured. Spent and
locked notes were also fully decrypted (~0.6ms of Jubjub arithmetic per
note) before the spent and locked checks threw them away. -deletetx is
off by default, so spent notes accumulate.

- iterate mapWallet and mapSaplingNoteData by const reference;
- skip txs with no notes of ours before CheckFinalTx;
- read GetDepthInMainChain once per tx instead of 2+N times (cs_main is
  held, so it can't change during the walk);
- run the spent and locked checks before the decryption;
- z_listreceivedbyaddress no longer copies the whole CWalletTx per
  entry to read its time, and z_sendmany's sort comparator takes its
  entries by reference.

The filters have no side effects, so the result set and its order are
unchanged; the A0 equivalence test passes unchanged. One deliberate
difference: a spent or locked note whose ciphertext no longer decrypts
under its ivk is now skipped instead of tripping the decrypt assert. The
new SpentAndLockedNotesAreNotDecrypted test pins this (it aborts on the
old ordering).

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 b9cdce8e96
commit bfc1334565
3 changed files with 67 additions and 21 deletions
+38
View File
@@ -240,9 +240,13 @@ protected:
lockedOp = SaplingOutPoint(tx2, 0);
wallet.LockNote(lockedOp);
AddMoreTxs();
Mine();
}
// Lets a test add txs before the chain is built.
virtual void AddMoreTxs() {}
void TearDown() override {
LOCK(cs_main);
chainActive.SetTip(nullptr);
@@ -362,3 +366,37 @@ TEST_F(WalletNoteCache, FixtureExercisesEveryFilter) {
TEST_F(WalletNoteCache, FilteredNotesMatchLegacy) {
ExpectEquivalent({0, 1});
}
// 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.
class WalletNoteCacheUndecryptable : public WalletNoteCache {
protected:
uint256 txBad;
void AddMoreTxs() override {
SaplingPaymentAddress foreign = TestKey(9).DefaultAddress();
CTransaction tx = MakeTx({MakeOutput(foreign, 1), MakeOutput(foreign, 2)});
txBad = tx.GetHash();
const SaplingIncomingViewingKey ivkA = TestKey(0).expsk.full_viewing_key().in_viewing_key();
const uint256 nf = uint256S("bad5");
CWalletTx wtx(&wallet, tx);
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 0)] = SaplingNoteData(ivkA, nf);
wtx.mapSaplingNoteData[SaplingOutPoint(txBad, 1)] = 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));
}
};
// Spent and locked notes are dropped before the decryption (~0.5-0.8ms per note), not after it.
TEST_F(WalletNoteCacheUndecryptable, SpentAndLockedNotesAreNotDecrypted) {
auto entries = Filtered({}, 1, INT_MAX, true, false, true);
EXPECT_EQ(7u, entries.size()) << "the fixture's 8 notes less tx2's locked one";
for (const SaplingNoteEntry& e : entries)
EXPECT_FALSE(e.op.hash == txBad);
}