From dc8ca4996ad1c25225636dfda37ee164a4708a93 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 15 Oct 2026 14:33:19 -0500 Subject: [PATCH] gtest: pin GetFilteredNotes behaviour before optimizing it Adds test_wallet_notecache.cpp: a wallet with Sapling notes at several depths (spent by a mined tx, spent by an unmined tx, locked, viewing-key only, never mined, plus txs carrying no wallet notes), mined into a fake active chain. The fixture undoes everything it puts into the process-wide chain state (chainActive, mapBlockIndex, pcoinsTip) in TearDown, since all gtest suites share one process. LegacyFilteredNotes is the 69bb035e7 loop verbatim, with tx_height() passed in so the minDepth > 1 branch can be modelled on a -txindex node. FilteredNotesMatchLegacy compares the real walk against it entry by entry and in order (z_sendmany sorts the result with an unstable sort) over minDepth {0,1} x maxDepth x ignoreSpent x requireSpendingKey x ignoreLocked x address filters. FixtureExercisesEveryFilter checks that each filter really removes something, so the comparison can't pass vacuously. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 3 +- src/gtest/test_wallet_notecache.cpp | 364 ++++++++++++++++++++++++++++ 2 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/gtest/test_wallet_notecache.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 0d474b5c4..62877b5f9 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -18,7 +18,8 @@ hush_gtest_SOURCES = \ gtest/test_wallet_notedata.cpp \ gtest/test_net_closesocket.cpp \ gtest/test_fd_defense.cpp \ - gtest/test_wallet_dbenv.cpp + gtest/test_wallet_dbenv.cpp \ + gtest/test_wallet_notecache.cpp hush_gtest_CPPFLAGS = $(AM_CPPFLAGS) -DMULTICORE -fopenmp -DBINARY_OUTPUT -DCURVE_ALT_BN128 -DSTATIC $(BITCOIN_INCLUDES) hush_gtest_CXXFLAGS = $(AM_CXXFLAGS) $(PIE_FLAGS) diff --git a/src/gtest/test_wallet_notecache.cpp b/src/gtest/test_wallet_notecache.cpp new file mode 100644 index 000000000..c0006dfc8 --- /dev/null +++ b/src/gtest/test_wallet_notecache.cpp @@ -0,0 +1,364 @@ +// Copyright (c) 2016-2024 The Hush developers +// Distributed under the GPLv3 software license, see the accompanying +// file COPYING or https://www.gnu.org/licenses/gpl-3.0.en.html + +#include + +#include +#include + +#include "chain.h" +#include "coins.h" +#include "main.h" +#include "primitives/block.h" +#include "primitives/transaction.h" +#include "random.h" +#include "wallet/wallet.h" +#include "zcash/Address.hpp" +#include "zcash/Note.hpp" +#include "zcash/NoteEncryption.hpp" +#include "zcash/zip32.h" + +int32_t hush_dpowconfs(int32_t height, int32_t numconfs); + +using namespace libzcash; + +namespace { + +typedef std::function TxHeightFn; + +// CWallet::GetFilteredNotes as of 69bb035e7, verbatim except that tx_height() is passed in. This is +// the reference the optimized walk must reproduce entry for entry and in the same order (z_sendmany +// sorts the result with an unstable sort, so the order is observable). +void LegacyFilteredNotes( + CWallet& wallet, + std::vector& saplingEntries, + std::set& filterAddresses, + int minDepth, + int maxDepth, + bool ignoreSpent, + bool requireSpendingKey, + bool ignoreLocked, + const TxHeightFn& tx_height) +{ + LOCK2(cs_main, wallet.cs_wallet); + + for (auto & p : wallet.mapWallet) { + CWalletTx wtx = p.second; + + // Filter the transactions before checking for notes + if (!CheckFinalTx(wtx) || wtx.GetBlocksToMaturity() > 0) + continue; + + if (minDepth > 1) { + int nHeight = tx_height(wtx.GetHash()); + if ( nHeight == 0 ) { + continue; + } + int nDepth = wtx.GetDepthInMainChain(); + int dpowconfs = hush_dpowconfs(nHeight,nDepth); + if ( dpowconfs < minDepth || dpowconfs > maxDepth) { + continue; + } + } else { + if ( wtx.GetDepthInMainChain() < minDepth || + wtx.GetDepthInMainChain() > maxDepth) { + continue; + } + } + + for (auto & pair : wtx.mapSaplingNoteData) { + SaplingOutPoint op = pair.first; + 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(maybe_pt)); + auto notePt = maybe_pt.get(); + + auto maybe_pa = nd.ivk.address(notePt.d); + assert(static_cast(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))) { + continue; + } + + if (ignoreSpent && nd.nullifier && wallet.IsSaplingSpent(*nd.nullifier)) { + continue; + } + + // skip notes which cannot be spent + if (requireSpendingKey) { + libzcash::SaplingIncomingViewingKey ivk; + libzcash::SaplingFullViewingKey fvk; + if (!(wallet.GetSaplingIncomingViewingKey(pa, ivk) && + wallet.GetSaplingFullViewingKey(ivk, fvk) && + wallet.HaveSaplingSpendingKey(fvk))) { + continue; + } + } + + // skip locked notes + if (ignoreLocked && wallet.IsLockedNote(op)) { + continue; + } + + auto note = notePt.note(nd.ivk).get(); + saplingEntries.push_back(SaplingNoteEntry { + op, pa, note, notePt.memo(), wtx.GetDepthInMainChain() }); + } + } +} + +std::array MemoFor(uint64_t value) +{ + std::array memo; + memo.fill(0); + std::string s = "memo-" + std::to_string(value); + memcpy(memo.data(), s.data(), s.size()); + return memo; +} + +// A real encrypted Sapling output to pa. Its proof and value commitment are never checked by the +// wallet, so they are left empty / random. +OutputDescription MakeOutput(const SaplingPaymentAddress& pa, uint64_t value) +{ + SaplingNote note(pa, value); + SaplingNotePlaintext pt(note, MemoFor(value)); + auto enc = pt.encrypt(note.pk_d); + assert(static_cast(enc)); + OutputDescription od; + od.cv = GetRandHash(); + od.cm = note.cm().get(); + od.ephemeralKey = enc->second.get_epk(); + od.encCiphertext = enc->first; + return od; +} + +CTransaction MakeTx(const std::vector& outputs, const std::vector& spentNullifiers = {}) +{ + CMutableTransaction mtx; + mtx.fOverwintered = true; + mtx.nVersion = SAPLING_TX_VERSION; + mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID; + mtx.vShieldedOutput = outputs; + for (const uint256& nf : spentNullifiers) { + SpendDescription sd; + sd.nullifier = nf; + mtx.vShieldedSpend.push_back(sd); + } + return CTransaction(mtx); +} + +SaplingExtendedSpendingKey TestKey(uint32_t account) +{ + std::vector> raw(32, 0x5a); + HDSeed seed(raw); + return SaplingExtendedSpendingKey::Master(seed).Derive(account | ZIP32_HARDENED_KEY_LIMIT); +} + +std::string Describe(int minDepth, int maxDepth, bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked, const std::string& filter) +{ + return strprintf("minDepth=%d maxDepth=%d ignoreSpent=%d requireSpendingKey=%d ignoreLocked=%d filter=%s", + minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter); +} + +void ExpectSameEntries(const std::vector& want, const std::vector& got, const std::string& ctx) +{ + ASSERT_EQ(want.size(), got.size()) << ctx; + for (size_t i = 0; i < want.size(); i++) { + EXPECT_TRUE(want[i].op == got[i].op) << ctx << " entry " << i; + EXPECT_TRUE(want[i].address == got[i].address) << ctx << " entry " << i; + EXPECT_TRUE(want[i].note.d == got[i].note.d) << ctx << " entry " << i; + EXPECT_EQ(want[i].note.pk_d, got[i].note.pk_d) << ctx << " entry " << i; + EXPECT_EQ(want[i].note.r, got[i].note.r) << ctx << " entry " << i; + EXPECT_EQ(want[i].note.value(), got[i].note.value()) << ctx << " entry " << i; + EXPECT_TRUE(want[i].memo == got[i].memo) << ctx << " entry " << i; + EXPECT_EQ(want[i].confirmations, got[i].confirmations) << ctx << " entry " << i; + } +} + +} + +// A wallet with notes at several depths, spent and unspent, locked and unlocked, spendable and +// viewing-key-only, plus txs that carry no wallet notes; mined into a fake active chain. Everything it +// touches in the process-wide chain state (chainActive, mapBlockIndex, pcoinsTip) is undone in +// TearDown, because every gtest suite runs in the same process. +class WalletNoteCache : public ::testing::Test { +protected: + static const int TIP_HEIGHT = 30; + + CWallet wallet; + SaplingPaymentAddress paA, paB, paC; // A and C spendable, B viewing key only + std::map depthOf; // txid -> depth to mine it at (absent: never mined) + std::vector txids; + SaplingOutPoint lockedOp; + + CCoinsView coinsBase; + CCoinsViewCache coins{&coinsBase}; + CCoinsViewCache* savedCoinsTip = nullptr; + std::deque blocks; + std::deque index; + std::map minedHeight; // txid -> height of the block holding it + + void SetUp() override { + // tx_height()/GetTransaction dereference pcoinsTip; an empty view answers "not found". + savedCoinsTip = pcoinsTip; + pcoinsTip = &coins; + + auto skA = TestKey(0), skB = TestKey(1), skC = TestKey(2); + paA = skA.DefaultAddress(); + paB = skB.DefaultAddress(); + paC = skC.DefaultAddress(); + ASSERT_TRUE(wallet.AddSaplingZKey(skA, paA)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(skB.expsk.full_viewing_key().in_viewing_key(), paB)); + ASSERT_TRUE(wallet.AddSaplingZKey(skC, paC)); + SaplingPaymentAddress foreign = TestKey(9).DefaultAddress(); // not in the wallet + + LOCK2(cs_main, wallet.cs_wallet); + AddTx(MakeTx({MakeOutput(paA, 1000), MakeOutput(paB, 2000)}), 1); + uint256 tx2 = AddTx(MakeTx({MakeOutput(paA, 3000)}), 5); + AddTx(MakeTx({MakeOutput(paA, 4000), MakeOutput(paC, 5000), MakeOutput(paB, 6000)}), 11); + AddTx(MakeTx({MakeOutput(paC, 7000)}), 21); + AddTx(MakeTx({MakeOutput(paA, 8000)}), -1); // never mined: depth -1 + AddTx(MakeTx({MakeOutput(foreign, 50)}), 3); // no wallet notes at all + + // A note spent by a mined tx, and one whose spending tx never confirmed. + const uint256 nfSpent = uint256S("5a1d"), nfPending = uint256S("9e9d"); + uint256 tx6 = AddTx(MakeTx({MakeOutput(paA, 9000)}), 12); + uint256 tx7 = AddTx(MakeTx({MakeOutput(paC, 10000)}), 3); + wallet.mapWallet.at(tx6).mapSaplingNoteData.at(SaplingOutPoint(tx6, 0)).nullifier = nfSpent; + wallet.mapWallet.at(tx7).mapSaplingNoteData.at(SaplingOutPoint(tx7, 0)).nullifier = nfPending; + AddTx(MakeTx({}, {nfSpent}), 2); + AddTx(MakeTx({}, {nfPending}), -1); + + lockedOp = SaplingOutPoint(tx2, 0); + wallet.LockNote(lockedOp); + + Mine(); + } + + void TearDown() override { + LOCK(cs_main); + chainActive.SetTip(nullptr); + for (const CBlockIndex& bi : index) { + const uint256 hash = bi.GetBlockHash(); + mapBlockIndex.erase(hash); + } + pcoinsTip = savedCoinsTip; + } + + // Adds tx the way LoadWallet does (no wallet.dat), with the note data FindMySaplingNotes finds. + uint256 AddTx(const CTransaction& tx, int depth) { + CWalletTx wtx(&wallet, tx); + mapSaplingNoteData_t found = wallet.FindMySaplingNotes(tx).first; + wtx.SetSaplingNoteData(found); + EXPECT_TRUE(wallet.AddToWallet(wtx, true, nullptr)); + if (depth > 0) + depthOf[tx.GetHash()] = depth; + txids.push_back(tx.GetHash()); + return tx.GetHash(); + } + + // Builds an active chain 0..TIP_HEIGHT and puts each wallet tx into the block at its depth. + void Mine() { + for (int h = 0; h <= TIP_HEIGHT; h++) { + blocks.emplace_back(); + CBlock& block = blocks.back(); + block.nTime = 1700000000 + h; + block.nNonce = uint256S(strprintf("%x", h + 1)); + if (h > 0) + block.hashPrevBlock = blocks[h - 1].GetHash(); + for (const auto& d : depthOf) { + if (TIP_HEIGHT - d.second + 1 == h) + block.vtx.push_back(wallet.mapWallet.at(d.first)); + } + block.hashMerkleRoot = block.BuildMerkleTree(); + + index.emplace_back(block); + CBlockIndex& bi = index.back(); + bi.SetHeight(h); + bi.pprev = h > 0 ? &index[h - 1] : nullptr; + BlockMap::iterator it = mapBlockIndex.insert(std::make_pair(block.GetHash(), &bi)).first; + bi.phashBlock = &it->first; + } + chainActive.SetTip(&index.back()); + for (const auto& d : depthOf) { + int h = TIP_HEIGHT - d.second + 1; + wallet.mapWallet.at(d.first).SetMerkleBranch(blocks[h]); + minedHeight[d.first] = h; + } + } + + // What tx_height() returns on a -txindex node: the height of the block holding the tx, 0 if none. + TxHeightFn TxIndexHeight() const { + return [this](const uint256& txid) { + auto it = minedHeight.find(txid); + return it == minedHeight.end() ? 0 : it->second; + }; + } + + std::vector Filtered(const std::set& filter, int minDepth, int maxDepth, + bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) { + std::set f = filter; + std::vector entries; + wallet.GetFilteredNotes(entries, f, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked); + return entries; + } + + std::vector Legacy(const std::set& filter, int minDepth, int maxDepth, + bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) { + std::set f = filter; + std::vector entries; + LegacyFilteredNotes(wallet, entries, f, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, + TxIndexHeight()); + return entries; + } + + void ExpectEquivalent(const std::vector& minDepths) { + const std::vector>> filters = { + {"none", {}}, {"A", {paA}}, {"B", {paB}}, {"A+C", {paA, paC}}, + }; + for (int minDepth : minDepths) + for (int maxDepth : {INT_MAX, 11}) + for (bool ignoreSpent : {false, true}) + for (bool requireSpendingKey : {false, true}) + for (bool ignoreLocked : {false, true}) + for (const auto& filter : filters) { + std::string ctx = Describe(minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked, filter.first); + ExpectSameEntries(Legacy(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked), + Filtered(filter.second, minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked), + ctx); + } + } +}; + +// The fixture must exercise every filter, or the equivalence check below could pass vacuously. +TEST_F(WalletNoteCache, FixtureExercisesEveryFilter) { + // Mined, unspent notes: tx1 (2), tx2 (1), tx3 (3), tx4 (1), tx7 (1); tx6's note is spent. + EXPECT_EQ(8u, Filtered({}, 1, INT_MAX, true, false, false).size()); + EXPECT_EQ(9u, Filtered({}, 1, INT_MAX, false, false, false).size()) << "ignoreSpent drops tx6's note"; + EXPECT_EQ(8u, Filtered({}, 0, INT_MAX, true, false, false).size()) << "a never-mined tx is at depth -1, not 0"; + EXPECT_EQ(6u, Filtered({}, 1, INT_MAX, true, true, false).size()) << "requireSpendingKey drops B's 2 notes"; + EXPECT_EQ(7u, Filtered({}, 1, INT_MAX, true, false, true).size()) << "ignoreLocked drops tx2's note"; + EXPECT_EQ(7u, Filtered({}, 1, 11, true, false, false).size()) << "maxDepth 11 drops tx4"; + EXPECT_EQ(2u, Filtered({paB}, 1, INT_MAX, true, false, false).size()); + + auto onlyC = Filtered({paC}, 1, INT_MAX, true, false, false); + ASSERT_EQ(3u, onlyC.size()); + for (const SaplingNoteEntry& e : onlyC) { + EXPECT_TRUE(e.address == paC); + EXPECT_TRUE(e.memo == MemoFor(e.note.value())); + } +} + +// The walk returns exactly what the 69bb035e7 loop returned, in the same order, for every filter +// combination. +TEST_F(WalletNoteCache, FilteredNotesMatchLegacy) { + ExpectEquivalent({0, 1}); +}