From 1faa349c27bbd9455daeb9bcaba547addcbfe4fa Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 9 Oct 2026 13:31:01 -0500 Subject: [PATCH] gtest: check GetFilteredNotes and operation status against the old code - test_wallet_scan_notes.cpp compares the new GetFilteredNotes with a verbatim copy of the original over a fabricated wallet: spending keys, diversified, unrecorded, ivk-only, fvk-only, mismatched and aliased addresses; spent, unspent, locked, conflicted and mempool notes at many depths; every filter/flag combination (about 1,400 calls), plus an encrypted and locked wallet. It also checks decrypt_with_pk_d against decrypt. - test_operation_status.cpp compares z_getoperationstatus, z_getoperationresult and z_listoperationids with the original algorithm (up to 300 ops, same-second ties), shows the status calls finish while another thread holds cs_main and cs_wallet, and checks that concurrent pops hand each operation to exactly one caller. Co-Authored-By: Claude Opus 5.5 --- src/Makefile.gtest.include | 4 +- src/gtest/test_operation_status.cpp | 936 +++++++++++++++++++++++++++ src/gtest/test_wallet_scan_notes.cpp | 848 ++++++++++++++++++++++++ 3 files changed, 1787 insertions(+), 1 deletion(-) create mode 100644 src/gtest/test_operation_status.cpp create mode 100644 src/gtest/test_wallet_scan_notes.cpp diff --git a/src/Makefile.gtest.include b/src/Makefile.gtest.include index 2f3100f11..a271c83f8 100644 --- a/src/Makefile.gtest.include +++ b/src/Makefile.gtest.include @@ -14,7 +14,9 @@ hush_gtest_SOURCES = \ gtest/test_randomx_preverify.cpp \ gtest/test_hdtransparent.cpp \ gtest/test_mnemonic_compat.cpp \ - gtest/test_stratum_jobid.cpp + gtest/test_stratum_jobid.cpp \ + gtest/test_wallet_scan_notes.cpp \ + gtest/test_operation_status.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_operation_status.cpp b/src/gtest/test_operation_status.cpp new file mode 100644 index 000000000..5e50718ab --- /dev/null +++ b/src/gtest/test_operation_status.cpp @@ -0,0 +1,936 @@ +// Copyright (c) 2016-2026 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 +// +// z_getoperationstatus / z_getoperationresult / z_listoperationids after they stopped sorting +// whole UniValues, and after z_getoperationstatus and z_listoperationids stopped taking +// cs_main + cs_wallet. These tests show that: +// * both status calls return byte for byte what the original algorithm (copied below from +// c1040028e) returns, for queued, executing, successful, failed and cancelled operations, +// with and without id filters, with unknown and duplicate ids, with more than 16 +// operations and with creation_time ties. std::sort reorders equal keys once there are +// more than 16 of them, so the order of same-second operations is part of the contract; +// * z_getoperationresult removes exactly the finished operations that it reports; +// * z_getoperationstatus and z_listoperationids answer while another thread holds +// cs_main + cs_wallet, and z_getoperationresult still waits for those locks; +// * AsyncRPCQueue::popOperationForId hands an operation to exactly one of several concurrent +// callers, so concurrent removers never report the same operation twice. +// +// No worker runs on the shared async queue in hush-gtest, so every operation keeps the state the +// test gives it. Without pwalletMain the handlers throw "Method not found", and +// z_getoperationresult locks pwalletMain->cs_wallet, so each test installs an empty in-memory +// CWallet. These tests use TEST() and a TestEnv guard rather than a fixture, because a fixture +// class cannot be named OperationStatus: that name is the operation state enum in +// asyncrpcoperation.h. + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "asyncrpcoperation.h" +#include "asyncrpcqueue.h" +#include "init.h" +#include "main.h" +#include "pubkey.h" +#include "rpc/server.h" +#include "sync.h" +#include "wallet/wallet.h" + +// Defined in wallet/rpcwallet.cpp (non-static, no header). Called directly so that removers can +// run without the cs_main + cs_wallet that z_getoperationresult takes. +extern UniValue z_getoperationstatus_IMPL(const UniValue& params, bool fRemoveFinishedOperations); + +namespace { + +// Bound for every wait that must succeed, so a regression fails instead of hanging. +const std::chrono::milliseconds kGuardTimeout(5000); + +const char* const kQueueNotEmpty = "the shared async queue already holds operations from elsewhere"; +const char* const kPoolFailed = "could not create operations with controlled creation_time ties"; + +enum FakeKind { + FAKE_QUEUED = 0, + FAKE_EXECUTING, + FAKE_SUCCESS, + FAKE_FAILED, + FAKE_CANCELLED, + FAKE_KINDS +}; + +// An operation whose state is set by the test and never changes after it is queued: main() does +// nothing, and nothing runs it anyway. getStatus() appends method/params exactly the way +// z_sendmany, z_mergetoaddress and z_shieldcoinbase append their contextinfo_. +class FixedStateOperation : public AsyncRPCOperation { +public: + virtual void main() {} + + virtual UniValue getStatus() const { + UniValue v = AsyncRPCOperation::getStatus(); + if (contextinfo_.isNull()) { + return v; + } + + UniValue obj = v.get_obj(); + obj.push_back(Pair("method", "z_fixedstate")); + obj.push_back(Pair("params", contextinfo_)); + return obj; + } + + // Only called before the operation is queued, like a real operation's constructor. + void SetContext(const UniValue& context) { + contextinfo_ = context; + } + + void ForceExecuting() { + set_state(OperationStatus::EXECUTING); + } + + // Same order as the real operations: stop the clock, then publish the result and SUCCESS. + void ForceSuccess(const UniValue& result) { + start_execution_clock(); + stop_execution_clock(); + set_result(result); + set_state(OperationStatus::SUCCESS); + } + + void ForceFailed(int code, const std::string& message) { + set_error_code(code); + set_error_message(message); + set_state(OperationStatus::FAILED); + } + +private: + UniValue contextinfo_; +}; + +std::shared_ptr MakeOperation(int kind, int tag) +{ + std::shared_ptr op(new FixedStateOperation()); + if (tag % 3 == 0) { + UniValue context(UniValue::VOBJ); + context.push_back(Pair("fromaddress", "zs1fixedstate")); + context.push_back(Pair("minconf", tag)); + op->SetContext(context); + } + switch (kind) { + case FAKE_QUEUED: + break; + case FAKE_EXECUTING: + op->ForceExecuting(); + break; + case FAKE_SUCCESS: + if (tag % 2) { + UniValue result(UniValue::VOBJ); + result.push_back(Pair("txid", "txid-" + std::to_string(tag))); + op->ForceSuccess(result); + } else { + op->ForceSuccess(UniValue("done")); + } + break; + case FAKE_FAILED: + op->ForceFailed(-6, "boom " + std::to_string(tag)); + break; + case FAKE_CANCELLED: + op->cancel(); // READY -> CANCELLED, the same path shutdown and wallet.cpp use + break; + } + return op; +} + +bool IsFinished(const AsyncRPCOperation& op) +{ + return op.isSuccess() || op.isFailed() || op.isCancelled(); +} + +// The z_getoperationstatus_IMPL of c1040028e, verbatim except that the LOCK2 and the +// popOperationForId call are left out. Evaluated on an unchanged queue just before the call under +// test, it sees the same getAllOperationIds() enumeration and returns what the original returned: +// the original removing pass popped each operation only after reading it, so it reported exactly +// what this non-removing pass reports. +UniValue ReferenceOperationStatus(const UniValue& params, bool fRemoveFinishedOperations) +{ + std::set filter; + if (params.size()==1) { + UniValue ids = params[0].get_array(); + for (const UniValue & v : ids.getValues()) { + filter.insert(v.get_str()); + } + } + bool useFilter = (filter.size()>0); + + UniValue ret(UniValue::VARR); + std::shared_ptr q = getAsyncRPCQueue(); + std::vector ids = q->getAllOperationIds(); + + for (auto id : ids) { + if (useFilter && !filter.count(id)) + continue; + + std::shared_ptr operation = q->getOperationForId(id); + if (!operation) { + continue; + } + + UniValue obj = operation->getStatus(); + std::string s = obj["status"].get_str(); + if (fRemoveFinishedOperations) { + // Caller is only interested in retrieving finished results + if ("success"==s || "failed"==s || "cancelled"==s) { + ret.push_back(obj); + } + } else { + ret.push_back(obj); + } + } + + std::vector arrTmp = ret.getValues(); + + // sort results chronologically by creation_time + std::sort(arrTmp.begin(), arrTmp.end(), [](UniValue a, UniValue b) -> bool { + const int64_t t1 = find_value(a.get_obj(), "creation_time").get_int64(); + const int64_t t2 = find_value(b.get_obj(), "creation_time").get_int64(); + return t1 < t2; + }); + + ret.clear(); + ret.setArray(); + ret.push_backV(arrTmp); + + return ret; +} + +// The body of c1040028e's z_listoperationids after its argument check, verbatim without the LOCK2. +UniValue ReferenceListOperationIds(const UniValue& params) +{ + std::string filter; + bool useFilter = false; + if (params.size()==1) { + filter = params[0].get_str(); + useFilter = true; + } + + UniValue ret(UniValue::VARR); + std::shared_ptr q = getAsyncRPCQueue(); + std::vector ids = q->getAllOperationIds(); + for (auto id : ids) { + std::shared_ptr operation = q->getOperationForId(id); + if (!operation) { + continue; + } + std::string state = operation->getStateAsString(); + if (useFilter && filter.compare(state)!=0) + continue; + ret.push_back(id); + } + + return ret; +} + +// Results and errors as one comparable string. These helpers make no gtest calls, so helper +// threads can use them. +std::string CallOutcome(rpcfn_type fn, const UniValue& params) +{ + try { + return fn(params, false, CPubKey()).write(); + } catch (const UniValue& objError) { + return "rpc error: " + objError.write(); + } catch (const std::exception& e) { + return std::string("exception: ") + e.what(); + } +} + +std::string ReferenceOutcome(const UniValue& params, bool fRemoveFinishedOperations) +{ + try { + return ReferenceOperationStatus(params, fRemoveFinishedOperations).write(); + } catch (const UniValue& objError) { + return "rpc error: " + objError.write(); + } catch (const std::exception& e) { + return std::string("exception: ") + e.what(); + } +} + +std::string ReferenceListOutcome(const UniValue& params) +{ + try { + return ReferenceListOperationIds(params).write(); + } catch (const UniValue& objError) { + return "rpc error: " + objError.write(); + } catch (const std::exception& e) { + return std::string("exception: ") + e.what(); + } +} + +// Byte comparison that reports where two (possibly very long) JSON texts first differ. +::testing::AssertionResult SameText(const std::string& original, const std::string& patched) +{ + if (original == patched) + return ::testing::AssertionSuccess(); + size_t i = 0; + while (i < original.size() && i < patched.size() && original[i] == patched[i]) + i++; + const size_t from = i < 40 ? 0 : i - 40; + return ::testing::AssertionFailure() + << "outputs differ at byte " << i << " (lengths " << original.size() << " / " << patched.size() << ")" + << "\n original: ..." << original.substr(from, 120) + << "\n patched: ..." << patched.substr(from, 120); +} + +UniValue NoParams() +{ + return UniValue(UniValue::VARR); +} + +// [[id, ...]] +UniValue IdParams(const std::vector& ids) +{ + UniValue arr(UniValue::VARR); + for (size_t i = 0; i < ids.size(); i++) + arr.push_back(ids[i]); + UniValue params(UniValue::VARR); + params.push_back(arr); + return params; +} + +// The ids a params array asks for. Empty means "all", as in the handlers. +std::set RequestedIds(const UniValue& params) +{ + std::set ids; + if (params.size() == 1) { + const std::vector& v = params[0].get_array().getValues(); + for (size_t i = 0; i < v.size(); i++) + ids.insert(v[i].get_str()); + } + return ids; +} + +std::set ListedIds() +{ + UniValue listed = z_listoperationids(NoParams(), false, CPubKey()); + std::set ids; + for (size_t i = 0; i < listed.size(); i++) + ids.insert(listed[i].get_str()); + return ids; +} + +// Installs an empty in-memory wallet as pwalletMain for one test, and when it goes out of scope +// removes every operation the test added to the shared queue and restores pwalletMain. +class TestEnv { +public: + TestEnv() : queue_(getAsyncRPCQueue()), savedWallet_(pwalletMain) { + wasEmpty_ = queue_->getAllOperationIds().empty(); + pwalletMain = &wallet_; + } + + ~TestEnv() { + RemoveAll(); + pwalletMain = savedWallet_; + } + + bool QueueWasEmpty() const { return wasEmpty_; } + + const std::shared_ptr& Queue() const { return queue_; } + + // False if the queue refused an operation. + bool AddAll(const std::vector >& ops) { + for (size_t i = 0; i < ops.size(); i++) { + if (!queue_->addOperation(ops[i])) + return false; + added_.push_back(ops[i]->getId()); + } + return true; + } + + void RemoveAll() { + for (size_t i = 0; i < added_.size(); i++) + queue_->popOperationForId(added_[i]); + added_.clear(); + } + +private: + std::shared_ptr queue_; + CWallet wallet_; + CWallet* savedWallet_; + bool wasEmpty_; + std::vector added_; +}; + +// Holds cs_main and pwalletMain->cs_wallet on a helper thread, as a long wallet scan does, until +// Release() or destruction. +class WalletLockHolder { +public: + WalletLockHolder() : held_(false), release_(false), thread_(&WalletLockHolder::Run, this) {} + + ~WalletLockHolder() { + Release(); + thread_.join(); + } + + bool WaitUntilHeld(std::chrono::milliseconds timeout) { + std::unique_lock lk(m_); + return cv_.wait_for(lk, timeout, [this]() { return held_; }); + } + + void Release() { + { + std::lock_guard guard(m_); + release_ = true; + } + cv_.notify_all(); + } + +private: + void Run() { + LOCK2(cs_main, pwalletMain->cs_wallet); + std::unique_lock lk(m_); + held_ = true; + cv_.notify_all(); + cv_.wait(lk, [this]() { return release_; }); + } + + std::mutex m_; + std::condition_variable cv_; + bool held_; + bool release_; + std::thread thread_; // last, so that everything Run() touches exists before it starts +}; + +// creation_time is time(NULL) at construction and cannot be set, so ties and distinct times are +// made by creating operations inside chosen seconds. Waits in 10 ms steps for the next second. +void WaitForNextSecond() +{ + const time_t start = time(NULL); + for (int i = 0; i < 150 && time(NULL) == start; i++) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); +} + +bool AllCreatedInOneSecond(const std::vector >& ops) +{ + for (size_t i = 1; i < ops.size(); i++) { + if (ops[i]->getCreationTime() != ops[0]->getCreationTime()) + return false; + } + return true; +} + +const int kPoolGroups = 3; +const size_t kPoolGroupSize = 300; + +// kPoolGroups groups of operations in all five states. Every operation in a group has the same +// creation_time, and the groups' creation_times strictly increase. +struct OperationPool { + bool ok; + std::vector > > groups; +}; + +OperationPool BuildPool() +{ + OperationPool pool; + pool.ok = true; + for (int g = 0; g < kPoolGroups && pool.ok; g++) { + std::vector > group; + bool tied = false; + for (int attempt = 0; attempt < 3 && !tied; attempt++) { + if (g > 0 || attempt > 0) + WaitForNextSecond(); + group.clear(); + for (size_t i = 0; i < kPoolGroupSize; i++) { + group.push_back(MakeOperation(static_cast((i + 2 * g) % FAKE_KINDS), + static_cast(g * kPoolGroupSize + i))); + } + tied = AllCreatedInOneSecond(group); + } + const bool later = pool.groups.empty() || + group.front()->getCreationTime() > pool.groups.back().front()->getCreationTime(); + pool.ok = tied && later; + pool.groups.push_back(group); + } + return pool; +} + +// Built once (it waits for up to about two second boundaries) and shared by the tests that need +// it. The operations are never modified after they are built. +const OperationPool& SharedPool() +{ + static const OperationPool pool = BuildPool(); + return pool; +} + +struct Population { + std::string name; + std::vector > ops; +}; + +// For each size: "ties-K", K operations from one second, and "mixed-K", K operations spread +// round robin over three seconds (ties within each second). 16 is the largest size that +// libstdc++'s std::sort handles with insertion sort alone; from 17 on, introsort reorders equal keys. +std::vector Populations(const OperationPool& pool) +{ + static const size_t sizes[] = {0, 1, 2, 16, 17, 40, 300}; + std::vector pops; + for (size_t s = 0; s < sizeof(sizes) / sizeof(sizes[0]); s++) { + const size_t k = sizes[s]; + + Population ties; + ties.name = "ties-" + std::to_string(k); + ties.ops.assign(pool.groups[1].begin(), pool.groups[1].begin() + k); + pops.push_back(ties); + + Population mixed; + mixed.name = "mixed-" + std::to_string(k); + for (size_t i = 0; i < k; i++) + mixed.ops.push_back(pool.groups[i % kPoolGroups][i / kPoolGroups]); + pops.push_back(mixed); + } + return pops; +} + +// Arguments for z_getoperationstatus/z_getoperationresult over a population: no argument, an +// empty list (no filter), an unknown id, two ids, a duplicated id, every other id plus an +// unknown one, and every id. +std::vector StatusParamSets(const std::vector >& ops) +{ + std::vector sets; + sets.push_back(NoParams()); + sets.push_back(IdParams(std::vector())); + sets.push_back(IdParams(std::vector(1, "opid-unknown"))); + if (!ops.empty()) { + std::vector two; + two.push_back(ops.front()->getId()); + two.push_back(ops.back()->getId()); + sets.push_back(IdParams(two)); + sets.push_back(IdParams(std::vector(2, ops.front()->getId()))); + + std::vector half, all; + for (size_t i = 0; i < ops.size(); i++) { + all.push_back(ops[i]->getId()); + if (i % 2 == 0) + half.push_back(ops[i]->getId()); + } + half.push_back("opid-unknown"); + sets.push_back(IdParams(half)); + sets.push_back(IdParams(all)); + } + return sets; +} + +// Pops every id once, as fast as it can, after the start signal. +void PopEach(AsyncRPCQueue* q, const std::vector* ids, + const std::atomic* go, std::vector* out) +{ + while (!go->load()) + std::this_thread::yield(); + for (size_t i = 0; i < ids->size(); i++) { + if (q->popOperationForId((*ids)[i]) != nullptr) + out->push_back((*ids)[i]); + } +} + +// Removes finished operations until the queue is empty (or kGuardTimeout passes) and records +// every id it was handed. If direct is true it calls the IMPL without cs_main/cs_wallet, so only +// the queue's atomic pop and the IMPL's checked pop keep it apart from the other removers. +// Otherwise it calls the z_getoperationresult handler, which takes the locks. +void DrainFinished(bool direct, const std::atomic* go, std::vector* out) +{ + while (!go->load()) + std::this_thread::yield(); + const UniValue params(UniValue::VARR); + std::shared_ptr q = getAsyncRPCQueue(); + const std::chrono::steady_clock::time_point deadline = std::chrono::steady_clock::now() + kGuardTimeout; + while (std::chrono::steady_clock::now() < deadline) { + UniValue ret; + try { + ret = direct ? z_getoperationstatus_IMPL(params, true) + : z_getoperationresult(params, false, CPubKey()); + for (size_t i = 0; i < ret.size(); i++) + out->push_back(find_value(ret[i], "id").get_str()); + } catch (...) { + out->push_back(""); + return; + } + if (ret.empty() && q->getAllOperationIds().empty()) + return; + } +} + +// Polls z_getoperationstatus while the removers run. Counts results that break its contract +// (only the test's finished operations exist): an unfinished status, a repeated id, or +// creation_time out of order. +void PollStatus(const std::atomic* go, const std::atomic* stop, int* faults) +{ + while (!go->load()) + std::this_thread::yield(); + const UniValue params(UniValue::VARR); + while (!stop->load()) { + try { + UniValue ret = z_getoperationstatus(params, false, CPubKey()); + std::set ids; + int64_t last = 0; + for (size_t i = 0; i < ret.size(); i++) { + const std::string id = find_value(ret[i], "id").get_str(); + const std::string status = find_value(ret[i], "status").get_str(); + const int64_t t = find_value(ret[i], "creation_time").get_int64(); + if (!ids.insert(id).second || t < last || + (status != "success" && status != "failed" && status != "cancelled")) + (*faults)++; + last = t; + } + } catch (...) { + (*faults)++; + } + } +} + +} // namespace + +// The read path returns exactly what the original algorithm returned, including the order of +// same-second operations, and removes nothing. +TEST(OperationStatus, StatusMatchesOriginalAlgorithm) +{ + const OperationPool& pool = SharedPool(); + ASSERT_TRUE(pool.ok) << kPoolFailed; + TestEnv env; + ASSERT_TRUE(env.QueueWasEmpty()) << kQueueNotEmpty; + + const std::vector pops = Populations(pool); + for (size_t p = 0; p < pops.size(); p++) { + ASSERT_TRUE(env.AddAll(pops[p].ops)); + const std::vector paramSets = StatusParamSets(pops[p].ops); + for (size_t i = 0; i < paramSets.size(); i++) { + EXPECT_TRUE(SameText(ReferenceOutcome(paramSets[i], false), + CallOutcome(&z_getoperationstatus, paramSets[i]))) + << pops[p].name << " params=" << paramSets[i].write(); + } + EXPECT_EQ(pops[p].ops.size(), env.Queue()->getAllOperationIds().size()) << pops[p].name; + env.RemoveAll(); + } + +#if defined(__GLIBCXX__) + // Check that the comparison above can fail. With 40 operations in one second, libstdc++'s + // introsort reorders the equal keys, so the original output is not the enumeration order. + // A (creation_time, index) comparator would keep the enumeration order and fail above. + std::vector > tied(pool.groups[1].begin(), pool.groups[1].begin() + 40); + ASSERT_TRUE(env.AddAll(tied)); + const UniValue ref = ReferenceOperationStatus(NoParams(), false); + std::vector reported; + for (size_t i = 0; i < ref.size(); i++) + reported.push_back(find_value(ref[i], "id").get_str()); + const std::vector enumerated = env.Queue()->getAllOperationIds(); + EXPECT_EQ(enumerated.size(), reported.size()); + EXPECT_NE(enumerated, reported) << "40 tied operations kept their enumeration order"; + env.RemoveAll(); +#endif +} + +// The removing path reports exactly what the original reported, removes exactly the finished +// operations it was asked about, and leaves the rest visible to the other two calls. +TEST(OperationStatus, ResultMatchesOriginalAlgorithm) +{ + const OperationPool& pool = SharedPool(); + ASSERT_TRUE(pool.ok) << kPoolFailed; + TestEnv env; + ASSERT_TRUE(env.QueueWasEmpty()) << kQueueNotEmpty; + + const std::vector pops = Populations(pool); + for (size_t p = 0; p < pops.size(); p++) { + const std::vector paramSets = StatusParamSets(pops[p].ops); + for (size_t i = 0; i < paramSets.size(); i++) { + const UniValue& params = paramSets[i]; + const std::string where = pops[p].name + " params=" + params.write(); + ASSERT_TRUE(env.AddAll(pops[p].ops)); + + const std::string ref = ReferenceOutcome(params, true); + EXPECT_TRUE(SameText(ref, CallOutcome(&z_getoperationresult, params))) << where; + + const std::set requested = RequestedIds(params); + std::set remaining; + int wrong = 0; + for (size_t j = 0; j < pops[p].ops.size(); j++) { + const std::shared_ptr& op = pops[p].ops[j]; + const bool selected = requested.empty() || requested.count(op->getId()) > 0; + const bool present = env.Queue()->getOperationForId(op->getId()) != nullptr; + if (present != !(IsFinished(*op) && selected)) + wrong++; + if (present) + remaining.insert(op->getId()); + } + EXPECT_EQ(0, wrong) << where << ": operations removed or kept wrongly"; + + // Nothing is handed out twice, and the survivors are still listed and reported. + EXPECT_EQ("[]", CallOutcome(&z_getoperationresult, params)) << where; + EXPECT_EQ(remaining, ListedIds()) << where; + EXPECT_TRUE(SameText(ReferenceOutcome(NoParams(), false), + CallOutcome(&z_getoperationstatus, NoParams()))) << where; + + env.RemoveAll(); + } + } +} + +TEST(OperationStatus, ListOperationIdsMatchesOriginalAlgorithm) +{ + const OperationPool& pool = SharedPool(); + ASSERT_TRUE(pool.ok) << kPoolFailed; + TestEnv env; + ASSERT_TRUE(env.QueueWasEmpty()) << kQueueNotEmpty; + + const char* const filters[] = {NULL, "queued", "executing", "success", "failed", "cancelled", "bogus"}; + const std::vector pops = Populations(pool); + for (size_t p = 0; p < pops.size(); p++) { + ASSERT_TRUE(env.AddAll(pops[p].ops)); + for (size_t f = 0; f < sizeof(filters) / sizeof(filters[0]); f++) { + UniValue params(UniValue::VARR); + if (filters[f] != NULL) + params.push_back(filters[f]); + EXPECT_TRUE(SameText(ReferenceListOutcome(params), CallOutcome(&z_listoperationids, params))) + << pops[p].name << " params=" << params.write(); + } + env.RemoveAll(); + } +} + +// Malformed arguments fail exactly as before and remove nothing; no wallet still means +// "Method not found". +TEST(OperationStatus, ParameterErrorsUnchanged) +{ + TestEnv env; + ASSERT_TRUE(env.QueueWasEmpty()) << kQueueNotEmpty; + std::vector > ops; + for (int k = 0; k < FAKE_KINDS; k++) + ops.push_back(MakeOperation(k, k)); + ASSERT_TRUE(env.AddAll(ops)); + + UniValue notArray(UniValue::VARR); + notArray.push_back("not_an_array"); + UniValue inner(UniValue::VARR); + inner.push_back(ops[FAKE_SUCCESS]->getId()); + inner.push_back(1); + UniValue notString(UniValue::VARR); + notString.push_back(inner); + + EXPECT_EQ("exception: JSON value is not an array as expected", ReferenceOutcome(notArray, false)); + EXPECT_EQ("exception: JSON value is not a string as expected", ReferenceOutcome(notString, false)); + EXPECT_EQ(ReferenceOutcome(notArray, false), CallOutcome(&z_getoperationstatus, notArray)); + EXPECT_EQ(ReferenceOutcome(notString, false), CallOutcome(&z_getoperationstatus, notString)); + EXPECT_EQ(ReferenceOutcome(notArray, true), CallOutcome(&z_getoperationresult, notArray)); + EXPECT_EQ(ReferenceOutcome(notString, true), CallOutcome(&z_getoperationresult, notString)); + + UniValue tooMany(UniValue::VARR); + tooMany.push_back(UniValue(UniValue::VARR)); + tooMany.push_back("toomanyargs"); + EXPECT_THROW(z_getoperationstatus(tooMany, false, CPubKey()), std::runtime_error); + EXPECT_THROW(z_getoperationresult(tooMany, false, CPubKey()), std::runtime_error); + EXPECT_THROW(z_listoperationids(tooMany, false, CPubKey()), std::runtime_error); + + EXPECT_EQ(ops.size(), env.Queue()->getAllOperationIds().size()); + + CWallet* wallet = pwalletMain; + pwalletMain = NULL; + EXPECT_THROW(z_getoperationstatus(NoParams(), false, CPubKey()), UniValue); + EXPECT_THROW(z_getoperationresult(NoParams(), false, CPubKey()), UniValue); + EXPECT_THROW(z_listoperationids(NoParams(), false, CPubKey()), UniValue); + pwalletMain = wallet; +} + +// While another thread holds cs_main + cs_wallet, z_getoperationstatus (unfiltered and filtered, +// as the GUI polls it) and z_listoperationids still answer, with unchanged output, and +// z_getoperationresult waits for the locks and then answers as before. +TEST(OperationStatus, StatusDoesNotWaitForWalletLocks) +{ + TestEnv env; + ASSERT_TRUE(env.QueueWasEmpty()) << kQueueNotEmpty; + std::vector > ops; + for (int k = 0; k < FAKE_KINDS; k++) + ops.push_back(MakeOperation(k, k)); + ASSERT_TRUE(env.AddAll(ops)); + + const UniValue filtered = IdParams(std::vector(1, ops[FAKE_SUCCESS]->getId())); + const std::string refStatus = ReferenceOutcome(NoParams(), false); + const std::string refFiltered = ReferenceOutcome(filtered, false); + const std::string refList = ReferenceListOutcome(NoParams()); + const std::string refResult = ReferenceOutcome(NoParams(), true); + + // Declared before the holder: on an early return the holder is destroyed first, which + // releases the locks, and only then do the futures' destructors wait for their calls. + std::future status, statusFiltered, listed, result; + WalletLockHolder holder; + ASSERT_TRUE(holder.WaitUntilHeld(kGuardTimeout)) << "helper thread could not take cs_main + cs_wallet"; + + status = std::async(std::launch::async, CallOutcome, &z_getoperationstatus, NoParams()); + statusFiltered = std::async(std::launch::async, CallOutcome, &z_getoperationstatus, filtered); + listed = std::async(std::launch::async, CallOutcome, &z_listoperationids, NoParams()); + result = std::async(std::launch::async, CallOutcome, &z_getoperationresult, NoParams()); + + const bool statusDone = status.wait_for(kGuardTimeout) == std::future_status::ready; + const bool filteredDone = statusFiltered.wait_for(kGuardTimeout) == std::future_status::ready; + const bool listDone = listed.wait_for(kGuardTimeout) == std::future_status::ready; + // The locks are still held, so a z_getoperationresult that takes them cannot have finished. + const bool resultWaited = result.wait_for(std::chrono::milliseconds(100)) == std::future_status::timeout; + holder.Release(); + const bool resultDone = result.wait_for(kGuardTimeout) == std::future_status::ready; + + EXPECT_TRUE(statusDone) << "z_getoperationstatus waited for cs_main/cs_wallet"; + EXPECT_TRUE(filteredDone) << "filtered z_getoperationstatus waited for cs_main/cs_wallet"; + EXPECT_TRUE(listDone) << "z_listoperationids waited for cs_main/cs_wallet"; + EXPECT_TRUE(resultWaited) << "z_getoperationresult no longer waits for cs_main/cs_wallet"; + EXPECT_TRUE(resultDone) << "z_getoperationresult did not finish after the locks were released"; + if (statusDone) { + EXPECT_EQ(refStatus, status.get()); + } + if (filteredDone) { + EXPECT_EQ(refFiltered, statusFiltered.get()); + } + if (listDone) { + EXPECT_EQ(refList, listed.get()); + } + if (resultDone) { + EXPECT_EQ(refResult, result.get()); + } + + // The result call removed the three finished operations and kept the queued and executing ones. + std::set expected; + expected.insert(ops[FAKE_QUEUED]->getId()); + expected.insert(ops[FAKE_EXECUTING]->getId()); + EXPECT_EQ(expected, ListedIds()); +} + +// SC-1: popOperationForId looks up and erases under one hold of the queue lock, so when several +// threads pop the same ids each operation goes to exactly one of them. The old two-step pop +// could hand the same operation to two callers; this catches that probabilistically. +TEST(OperationStatus, PopOperationForIdHandsEachOperationToOneCaller) +{ + { + std::shared_ptr q = std::make_shared(); + std::shared_ptr op = MakeOperation(FAKE_SUCCESS, 1); + ASSERT_TRUE(q->addOperation(op)); + EXPECT_TRUE(q->popOperationForId("opid-unknown") == nullptr); + EXPECT_TRUE(q->popOperationForId(op->getId()).get() == op.get()); + EXPECT_TRUE(q->getOperationForId(op->getId()) == nullptr); + EXPECT_TRUE(q->popOperationForId(op->getId()) == nullptr); + } + + const int kThreads = 8; + const int kOps = 1000; + const int kRounds = 10; + for (int round = 0; round < kRounds; round++) { + // A private queue with no workers. + std::shared_ptr q = std::make_shared(); + std::vector ids; + for (int i = 0; i < kOps; i++) { + std::shared_ptr op(new FixedStateOperation()); + ASSERT_TRUE(q->addOperation(op)); + ids.push_back(op->getId()); + } + + std::atomic go(false); + std::vector > popped(kThreads); + std::vector threads; + for (int t = 0; t < kThreads; t++) + threads.push_back(std::thread(PopEach, q.get(), &ids, &go, &popped[t])); + go.store(true); + for (size_t t = 0; t < threads.size(); t++) + threads[t].join(); + + std::map handed; + size_t total = 0; + for (int t = 0; t < kThreads; t++) { + for (size_t i = 0; i < popped[t].size(); i++) { + handed[popped[t][i]]++; + total++; + } + } + int wrong = 0; + for (size_t i = 0; i < ids.size(); i++) { + std::map::const_iterator it = handed.find(ids[i]); + if (it == handed.end() || it->second != 1) + wrong++; + } + EXPECT_EQ(0, wrong) << "round " << round << ": operations not handed to exactly one caller"; + EXPECT_EQ(static_cast(kOps), total) << "round " << round; + EXPECT_TRUE(q->getAllOperationIds().empty()) << "round " << round; + } +} + +// Removers through the locked handler and straight through the IMPL (no wallet locks) drain the +// same finished operations while two lock-free z_getoperationstatus pollers run. Every operation +// is reported exactly once, and no poll returns a malformed or unsorted list. +TEST(OperationStatus, ConcurrentRemoversReportEachOperationOnce) +{ + TestEnv env; + ASSERT_TRUE(env.QueueWasEmpty()) << kQueueNotEmpty; + const int kOps = 200; + std::vector > ops; + std::set expected; + for (int i = 0; i < kOps; i++) { + ops.push_back(MakeOperation(FAKE_SUCCESS + i % 3, i)); // success, failed or cancelled + expected.insert(ops.back()->getId()); + } + ASSERT_TRUE(env.AddAll(ops)); + + const int kRemovers = 8; + const int kReaders = 2; + std::atomic go(false); + std::atomic stop(false); + std::vector > removed(kRemovers); + std::vector readerFaults(kReaders, 0); + std::vector removers, readers; + for (int t = 0; t < kRemovers; t++) + removers.push_back(std::thread(DrainFinished, t % 2 == 0, &go, &removed[t])); + for (int r = 0; r < kReaders; r++) + readers.push_back(std::thread(PollStatus, &go, &stop, &readerFaults[r])); + go.store(true); + for (size_t t = 0; t < removers.size(); t++) + removers[t].join(); + stop.store(true); + for (size_t r = 0; r < readers.size(); r++) + readers[r].join(); + + std::map reported; + size_t total = 0; + for (int t = 0; t < kRemovers; t++) { + for (size_t i = 0; i < removed[t].size(); i++) { + reported[removed[t][i]]++; + total++; + } + } + int duplicated = 0, unknown = 0; + for (std::map::const_iterator it = reported.begin(); it != reported.end(); ++it) { + if (it->second != 1) + duplicated++; + if (!expected.count(it->first)) + unknown++; + } + EXPECT_EQ(0, duplicated) << "an operation was reported by more than one remover"; + EXPECT_EQ(0, unknown) << "a remover reported an unknown id or threw"; + EXPECT_EQ(static_cast(kOps), reported.size()); + EXPECT_EQ(static_cast(kOps), total); + EXPECT_TRUE(env.Queue()->getAllOperationIds().empty()); + for (int r = 0; r < kReaders; r++) + EXPECT_EQ(0, readerFaults[r]) << "reader " << r; +} + +// SC-4: the const status map with .at() names every state exactly as before. +TEST(OperationStatus, StateNamesUnchanged) +{ + const char* const names[FAKE_KINDS] = {"queued", "executing", "success", "failed", "cancelled"}; + for (int k = 0; k < FAKE_KINDS; k++) { + std::shared_ptr op = MakeOperation(k, k + 1); + EXPECT_EQ(std::string(names[k]), op->getStateAsString()); + const UniValue status = op->getStatus(); + EXPECT_EQ(std::string(names[k]), find_value(status, "status").get_str()); + } +} diff --git a/src/gtest/test_wallet_scan_notes.cpp b/src/gtest/test_wallet_scan_notes.cpp new file mode 100644 index 000000000..25bafa4d2 --- /dev/null +++ b/src/gtest/test_wallet_scan_notes.cpp @@ -0,0 +1,848 @@ +// Copyright (c) 2016-2026 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 +// +// Proves that the optimised CWallet::GetFilteredNotes returns exactly what the original +// did: the same entries, in the same order, with every field equal. The optimisations +// are references instead of copies, skipping txs without note data, the spent check +// before trial decryption, the exact ivk pre-filter for address-filtered calls, and +// building the note and address from decrypt_with_pk_d. RefGetFilteredNotes below is a +// verbatim copy of the original body (c1040028e), and both run over the same hand-built +// wallet for a grid of filters and parameters. The file also tests +// SaplingNotePlaintext::decrypt_with_pk_d against decrypt(). +// +// No proofs are needed: the wallet never verifies them, so notes are encrypted by hand +// into fabricated OutputDescriptions and added with AddToWallet(wtx, true, NULL). Depth +// is faked with a mapBlockIndex chain (depth >= 1), the mempool (depth 0) or neither +// (depth -1). minDepth > 1 is not covered: that branch calls tx_height(), which reads +// the tx from disk (the regtest differential covers it). + +#include + +#include "arith_uint256.h" +#include "chain.h" +#include "chainparams.h" +#include "key_io.h" +#include "main.h" +#include "primitives/transaction.h" +#include "txmempool.h" +#include "uint256.h" +#include "util.h" +#include "wallet/crypter.h" +#include "wallet/wallet.h" +#include "zcash/Address.hpp" +#include "zcash/Note.hpp" +#include "zcash/zip32.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +// The original body calls these; wallet.cpp declares them the same way (wallet.cpp:77-78). +// Both are defined in main.cpp (via hush.h). Neither is reached here (minDepth <= 1). +int32_t hush_dpowconfs(int32_t height,int32_t numconfs); +int tx_height( const uint256 &hash ); + +/** + * Verbatim copy of the original + * CWallet::GetFilteredNotes(std::vector&, std::set&, + * int, int, bool, bool, bool) at c1040028e (wallet.cpp:5637-5721). The only changes: + * it is a free function, so wallet members are `wallet.`-qualified (all of them are + * public: cs_wallet, mapWallet, IsSaplingSpent, GetSaplingIncomingViewingKey, + * GetSaplingFullViewingKey, HaveSaplingSpendingKey, IsLockedNote), and the two LogPrintf + * calls are dropped (they do not affect the output). + */ +static void RefGetFilteredNotes( + CWallet& wallet, + std::vector& saplingEntries, + std::set& filterAddresses, + int minDepth, + int maxDepth, + bool ignoreSpent, + bool requireSpendingKey, + bool ignoreLocked) +{ + using namespace libzcash; + + 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() }); + } + } +} + +namespace { + +// Heights 0..CHAIN_LENGTH-1, so a tx in the block at height h has depth CHAIN_LENGTH - h. +const int CHAIN_LENGTH = 10; + +// The Jubjub subgroup order r_J. An ivk + r_J generates the same addresses as ivk, +// because librustzcash_ivk_to_pkd multiplies by the raw 256-bit value. +const char JUBJUB_R_HEX[] = "0e7db4ea6533afa906673b0101343b00a6682093ccc81082d0970e5ed6f72cb7"; + +// Keys and addresses of the test wallet (see WalletScanNotes::BuildWallet). +struct TestKeys { + libzcash::SaplingFullViewingKey fvk1, fvk2; + libzcash::SaplingIncomingViewingKey ivk1, ivk2, ivk3, ivk4, ivkN, aliasIvk1; + libzcash::SaplingPaymentAddress a1; // K1 default address (spending key) + libzcash::SaplingPaymentAddress d1; // K1 diversified address, recorded correctly + libzcash::SaplingPaymentAddress u1; // K1 diversified address, never recorded + libzcash::SaplingPaymentAddress x1; // K1 diversified address, recorded with K2's ivk + libzcash::SaplingPaymentAddress y1; // K1 diversified address, recorded with ivk1 + r_J + libzcash::SaplingPaymentAddress a2; // K2 default address (spending key) + libzcash::SaplingPaymentAddress a3; // K3: incoming viewing key only + libzcash::SaplingPaymentAddress a4; // K4: full viewing key only + libzcash::SaplingPaymentAddress aN; // address of a non-canonical ivk (>= 2^251) + libzcash::SaplingPaymentAddress foreign; // not in the wallet at all + std::vector empty; // spending keys that hold no notes + size_t nNotes; +}; + +// One output of a fabricated tx that the wallet owns: the address it pays, the ivk the +// wallet records for it (SaplingNoteData::ivk) and its cached nullifier, if any. +struct OwnedOutput { + libzcash::SaplingPaymentAddress addr; + libzcash::SaplingIncomingViewingKey ivk; + uint64_t value; + boost::optional nullifier; +}; + +struct NamedFilter { + std::string name; + std::set addrs; +}; + +// CCryptoKeyStore::EncryptKeys is protected, and CWallet::EncryptWallet needs a wallet file. +class EncryptableWallet : public CWallet { +public: + bool EncryptKeysForTest(CKeyingMaterial& vMasterKeyIn) { return EncryptKeys(vMasterKeyIn); } +}; + +libzcash::SaplingExtendedSpendingKey KeyFromSeedByte(unsigned char b) +{ + RawHDSeed raw(32, b); + HDSeed seed(raw); + return libzcash::SaplingExtendedSpendingKey::Master(seed); +} + +// A diversified address of xsk other than its default one: the first valid diversifier +// index at or after `start` (the default address uses the first one from 0). +libzcash::SaplingPaymentAddress DiversifiedAddress( + const libzcash::SaplingExtendedSpendingKey& xsk, unsigned char start) +{ + libzcash::diversifier_index_t j; + j.begin()[0] = start; + return xsk.ToXFVK().Address(j).get().second; +} + +// A synthetic nullifier. GetFilteredNotes only compares nullifiers, it never recomputes them. +uint256 Nf(unsigned char n) +{ + uint256 nf; + nf.begin()[0] = n; + nf.begin()[31] = 0x4e; + return nf; +} + +// A shielded output paying `value` to `addr`, encrypted the way a real sender does +// (SaplingNotePlaintext::encrypt), with a random rcm and a memo derived from the value. +// There is no proof, value commitment or outgoing ciphertext: the wallet reads only cm, +// ephemeralKey and encCiphertext. +OutputDescription MakeOutput(const libzcash::SaplingPaymentAddress& addr, uint64_t value) +{ + libzcash::SaplingNote note(addr, value); + std::array memo; + memo.fill(0); + memo[0] = value & 0xff; + memo[1] = (value >> 8) & 0xff; + libzcash::SaplingNotePlaintext pt(note, memo); + libzcash::SaplingNotePlaintextEncryptionResult res = pt.encrypt(note.pk_d).get(); + + OutputDescription od; + od.cv = uint256(); + od.cm = note.cm().get(); + od.ephemeralKey = res.second.get_epk(); + od.encCiphertext = res.first; + od.outCiphertext.fill(0); + od.zkproof.fill(0); + return od; +} + +std::set Filter(const std::vector& addrs) +{ + std::set s; + for (const libzcash::SaplingPaymentAddress& a : addrs) { + s.insert(a); + } + return s; +} + +void ExpectSameEntries(const std::vector& expected, + const std::vector& actual) +{ + ASSERT_EQ(expected.size(), actual.size()); + for (size_t i = 0; i < expected.size(); i++) { + SCOPED_TRACE(testing::Message() << "entry " << i); + const SaplingNoteEntry& e = expected[i]; + const SaplingNoteEntry& a = actual[i]; + EXPECT_EQ(e.op.hash, a.op.hash); + EXPECT_EQ(e.op.n, a.op.n); + EXPECT_TRUE(e.address == a.address); + EXPECT_TRUE(e.note.d == a.note.d); + EXPECT_EQ(e.note.pk_d, a.note.pk_d); + EXPECT_EQ(e.note.value(), a.note.value()); + EXPECT_EQ(e.note.r, a.note.r); + EXPECT_TRUE(e.memo == a.memo); + EXPECT_EQ(e.confirmations, a.confirmations); + } +} + +// Runs the original and the optimised GetFilteredNotes with the same arguments and +// expects the same entries in the same order. Returns the number of entries. +size_t ExpectMatchesReference(CWallet& wallet, NamedFilter& filter, + int minDepth, int maxDepth, + bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) +{ + SCOPED_TRACE(testing::Message() << "filter=" << filter.name + << " minDepth=" << minDepth << " maxDepth=" << maxDepth + << " ignoreSpent=" << ignoreSpent + << " requireSpendingKey=" << requireSpendingKey + << " ignoreLocked=" << ignoreLocked); + + std::vector expected; + std::vector actual; + RefGetFilteredNotes(wallet, expected, filter.addrs, + minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked); + wallet.GetFilteredNotes(actual, filter.addrs, + minDepth, maxDepth, ignoreSpent, requireSpendingKey, ignoreLocked); + ExpectSameEntries(expected, actual); + return expected.size(); +} + +// The reference's result size with maxDepth INT_MAX. +size_t RefCount(CWallet& wallet, std::set filter, int minDepth, + bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked) +{ + std::vector entries; + RefGetFilteredNotes(wallet, entries, filter, + minDepth, INT_MAX, ignoreSpent, requireSpendingKey, ignoreLocked); + return entries.size(); +} + +// Every filter shape the pre-filter distinguishes. +std::vector AllFilters(const TestKeys& k) +{ + // 16 recorded, verifiable addresses: the pre-filter is on (the cap is inclusive). + std::vector sixteen = {k.a1, k.d1, k.a2, k.a3, k.a4}; + sixteen.insert(sixteen.end(), k.empty.begin(), k.empty.begin() + 11); + // 17: the pre-filter is off only because of the cap. + std::vector seventeen = sixteen; + seventeen.push_back(k.empty[11]); + + std::set invalidOnly; + invalidOnly.insert(libzcash::InvalidEncoding()); + std::set invalidAndA2 = invalidOnly; + invalidAndA2.insert(k.a2); + + std::vector f; + f.push_back(NamedFilter{"{}", Filter({})}); + f.push_back(NamedFilter{"{a1}", Filter({k.a1})}); + f.push_back(NamedFilter{"{d1}", Filter({k.d1})}); + f.push_back(NamedFilter{"{a2}", Filter({k.a2})}); + f.push_back(NamedFilter{"{a3 ivk-only}", Filter({k.a3})}); + f.push_back(NamedFilter{"{a4 fvk-only}", Filter({k.a4})}); + f.push_back(NamedFilter{"{a1,a2,a3,a4,d1}", Filter({k.a1, k.a2, k.a3, k.a4, k.d1})}); + f.push_back(NamedFilter{"{u1 unrecorded}", Filter({k.u1})}); + f.push_back(NamedFilter{"{a1,u1 unrecorded}", Filter({k.a1, k.u1})}); + f.push_back(NamedFilter{"{x1 mismatched}", Filter({k.x1})}); + f.push_back(NamedFilter{"{x1 mismatched,a2}", Filter({k.x1, k.a2})}); + f.push_back(NamedFilter{"{y1 aliased}", Filter({k.y1})}); + f.push_back(NamedFilter{"{aN non-canonical}", Filter({k.aN})}); + f.push_back(NamedFilter{"{no notes}", Filter({k.empty[0]})}); + f.push_back(NamedFilter{"{foreign}", Filter({k.foreign})}); + f.push_back(NamedFilter{"{invalid}", invalidOnly}); + f.push_back(NamedFilter{"{invalid,a2}", invalidAndA2}); + f.push_back(NamedFilter{"16 addresses", Filter(sixteen)}); + f.push_back(NamedFilter{"17 addresses", Filter(seventeen)}); + return f; +} + +// decrypt_with_pk_d must succeed exactly when decrypt() does, and must leave pk_d_out +// alone when it fails. Returns whether decryption succeeded. +bool DecryptBothWays(const libzcash::SaplingEncCiphertext& ct, const uint256& ivk, + const uint256& epk, const uint256& cmu) +{ + uint256 sentinel; + std::fill(sentinel.begin(), sentinel.end(), 0xa5); + uint256 pk_d = sentinel; + auto expected = libzcash::SaplingNotePlaintext::decrypt(ct, ivk, epk, cmu); + auto actual = libzcash::SaplingNotePlaintext::decrypt_with_pk_d(ct, ivk, epk, cmu, pk_d); + EXPECT_EQ(static_cast(expected), static_cast(actual)); + if (!actual) { + EXPECT_EQ(pk_d, sentinel); + } + return static_cast(actual); +} + +} // namespace + +class WalletScanNotes : public ::testing::Test { +protected: + boost::filesystem::path datadir; + bool hadDatadirArg; + std::string oldDatadirArg; + bool oldPrintToDebugLog; + CBlockIndex* oldTip; + std::vector> blocks; + std::vector mempoolTxs; + + void SetUp() override + { + SelectParams(CBaseChainParams::REGTEST); + + // GetFilteredNotes logs on every call: keep that away from any real datadir and + // out of debug.log. + hadDatadirArg = mapArgs.count("-datadir") > 0; + if (hadDatadirArg) { + oldDatadirArg = mapArgs["-datadir"]; + } + datadir = boost::filesystem::temp_directory_path() / boost::filesystem::unique_path(); + boost::filesystem::create_directories(datadir); + mapArgs["-datadir"] = datadir.string(); + ClearDatadirCache(); + oldPrintToDebugLog = fPrintToDebugLog; + fPrintToDebugLog = false; + + // A fake active chain. Only what CMerkleTx::GetDepthInMainChain reads is set: the + // mapBlockIndex entry, phashBlock, the height and pprev. + LOCK(cs_main); + oldTip = chainActive.Tip(); + for (int h = 0; h < CHAIN_LENGTH; h++) { + std::unique_ptr pindex(new CBlockIndex()); + pindex->SetHeight(h); + pindex->pprev = blocks.empty() ? NULL : blocks.back().get(); + uint256 hash; + hash.begin()[0] = h; + hash.begin()[30] = 0x5c; + hash.begin()[31] = 0xa9; + auto ret = mapBlockIndex.insert(std::make_pair(hash, pindex.get())); + ASSERT_TRUE(ret.second); + pindex->phashBlock = &ret.first->first; + blocks.push_back(std::move(pindex)); + } + chainActive.SetTip(blocks.back().get()); + } + + void TearDown() override + { + { + LOCK(cs_main); + for (const CTransaction& tx : mempoolTxs) { + std::list removed; + mempool.remove(tx, removed, false); + } + mempoolTxs.clear(); + chainActive.SetTip(oldTip); + for (const std::unique_ptr& pindex : blocks) { + mapBlockIndex.erase(pindex->GetBlockHash()); + } + blocks.clear(); + } + + fPrintToDebugLog = oldPrintToDebugLog; + if (hadDatadirArg) { + mapArgs["-datadir"] = oldDatadirArg; + } else { + mapArgs.erase("-datadir"); + } + ClearDatadirCache(); + boost::system::error_code ec; + boost::filesystem::remove_all(datadir, ec); + + // The network every other test in this binary selects for itself. + SelectParams(CBaseChainParams::MAIN); + } + + // Adds a fabricated tx to the wallet the way LoadWallet does: AddToWallet(wtx, true, + // NULL) skips the database and proof checks but fills the nullifier and spend maps. + // `owned` become outputs 0..n-1 with wallet note data, `foreign` outputs follow with + // none, and `spends` are the nullifiers the tx reveals. depth >= 1 puts the tx in the + // fake chain, 0 in the mempool, -1 nowhere (like a conflicted tx). + uint256 AddTx(CWallet& wallet, int depth, + const std::vector& owned, + const std::vector& foreign, + const std::vector& spends, + bool transparentOutput = false) + { + assert(depth >= -1 && depth <= CHAIN_LENGTH); + + CMutableTransaction mtx; + mtx.fOverwintered = true; + mtx.nVersionGroupId = SAPLING_VERSION_GROUP_ID; + mtx.nVersion = SAPLING_TX_VERSION; + for (const OwnedOutput& o : owned) { + mtx.vShieldedOutput.push_back(MakeOutput(o.addr, o.value)); + } + for (const libzcash::SaplingPaymentAddress& addr : foreign) { + mtx.vShieldedOutput.push_back(MakeOutput(addr, 1)); + } + for (const uint256& nf : spends) { + SpendDescription sd; + sd.nullifier = nf; + sd.zkproof.fill(0); + sd.spendAuthSig.fill(0); + mtx.vShieldedSpend.push_back(sd); + } + if (transparentOutput) { + mtx.vout.push_back(CTxOut(1000, CScript() << OP_TRUE)); + } + CTransaction tx(mtx); + + CWalletTx wtx(&wallet, tx); + for (size_t i = 0; i < owned.size(); i++) { + SaplingNoteData nd(owned[i].ivk); + nd.nullifier = owned[i].nullifier; + wtx.mapSaplingNoteData[SaplingOutPoint(tx.GetHash(), i)] = nd; + } + if (depth >= 1) { + wtx.hashBlock = blocks[CHAIN_LENGTH - depth]->GetBlockHash(); + wtx.nIndex = 0; + // There is no real block to check a merkle branch against. + wtx.fMerkleVerified = true; + } else if (depth == 0) { + mempool.addUnchecked(tx.GetHash(), + CTxMemPoolEntry(tx, 0, GetTime(), 0.0, chainActive.Height(), true, false, SAPLING_BRANCH_ID), + false); + mempoolTxs.push_back(tx); + } + wallet.AddToWallet(wtx, true, NULL); + return tx.GetHash(); + } + + // The test wallet. Every key state, every recorded-address state and every note state + // GetFilteredNotes distinguishes, spread over several depths: + // K1 spending key: a1 (default), d1 (recorded diversified), u1 (unrecorded), + // x1 (recorded with K2's ivk, as z_importviewingkey allows), y1 (recorded with + // ivk1 + r_J, which still generates y1); + // K2 spending key: a2; K3 ivk only: a3; K4 fvk only: a4; + // ivkN: a non-canonical ivk in [2^251, r_J) and its address aN; + // 12 more spending keys with no notes; one foreign key. + // Notes are spent in a block, spent in the mempool, "spent" by a conflicted tx (so + // still unspent), unspent, without a cached nullifier although spent on chain, and + // locked. There are also txs without note data (spends only, transparent only, and + // a shielded output to a foreign address). + void BuildWallet(CWallet& wallet, TestKeys& k) + { + LOCK2(cs_main, wallet.cs_wallet); + + libzcash::SaplingExtendedSpendingKey xsk1 = KeyFromSeedByte(0x11); + libzcash::SaplingExtendedSpendingKey xsk2 = KeyFromSeedByte(0x22); + libzcash::SaplingExtendedSpendingKey xsk3 = KeyFromSeedByte(0x33); + libzcash::SaplingExtendedSpendingKey xsk4 = KeyFromSeedByte(0x44); + libzcash::SaplingExtendedSpendingKey xskN = KeyFromSeedByte(0x55); + + k.fvk1 = xsk1.expsk.full_viewing_key(); + k.fvk2 = xsk2.expsk.full_viewing_key(); + libzcash::SaplingFullViewingKey fvk4 = xsk4.expsk.full_viewing_key(); + k.ivk1 = k.fvk1.in_viewing_key(); + k.ivk2 = k.fvk2.in_viewing_key(); + k.ivk3 = xsk3.expsk.full_viewing_key().in_viewing_key(); + k.ivk4 = fvk4.in_viewing_key(); + + k.a1 = xsk1.DefaultAddress(); + k.d1 = DiversifiedAddress(xsk1, 50); + k.u1 = DiversifiedAddress(xsk1, 100); + k.x1 = DiversifiedAddress(xsk1, 150); + k.y1 = DiversifiedAddress(xsk1, 200); + std::set k1addrs = {k.a1, k.d1, k.u1, k.x1, k.y1}; + ASSERT_EQ(k1addrs.size(), (size_t)5); + k.a2 = xsk2.DefaultAddress(); + k.a3 = xsk3.DefaultAddress(); + k.a4 = xsk4.DefaultAddress(); + k.foreign = KeyFromSeedByte(0x66).DefaultAddress(); + + // Genuine ivks are canonical (top 5 bits clear, so below 2^251). + ASSERT_EQ(k.ivk1.begin()[31] & 0xF8, 0); + ASSERT_EQ(k.ivk2.begin()[31] & 0xF8, 0); + + // ivk1 + r_J generates exactly ivk1's addresses. If this ever stops holding, the + // y1 case no longer exercises the pre-filter's canonical-ivk guard. + k.aliasIvk1 = libzcash::SaplingIncomingViewingKey(ArithToUint256( + UintToArith256(k.ivk1) + UintToArith256(uint256S(JUBJUB_R_HEX)))); + ASSERT_NE(k.aliasIvk1.begin()[31] & 0xF8, 0); + auto aliasY1 = k.aliasIvk1.address(k.y1.d); + ASSERT_TRUE(static_cast(aliasY1)); + EXPECT_TRUE(aliasY1.get() == k.y1); + + // ivkN in [2^251, 2^251 + 2^248), below r_J: ka_agree accepts it, so notes to aN + // decrypt normally, but it is not canonical. + uint256 rawN = xskN.expsk.full_viewing_key().in_viewing_key(); + rawN.begin()[31] = 0x08; + k.ivkN = libzcash::SaplingIncomingViewingKey(rawN); + k.aN = k.ivkN.address(k.a1.d).get(); + + ASSERT_TRUE(wallet.AddSaplingZKey(xsk1, k.a1)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(k.ivk1, k.d1)); + ASSERT_TRUE(wallet.AddSaplingZKey(xsk2, k.a2)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(k.ivk3, k.a3)); + ASSERT_TRUE(wallet.AddSaplingFullViewingKey(fvk4, k.a4)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(k.ivk2, k.x1)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(k.aliasIvk1, k.y1)); + ASSERT_TRUE(wallet.AddSaplingIncomingViewingKey(k.ivkN, k.aN)); + for (int i = 0; i < 12; i++) { + libzcash::SaplingExtendedSpendingKey xsk = KeyFromSeedByte(0x70 + i); + libzcash::SaplingPaymentAddress addr = xsk.DefaultAddress(); + ASSERT_TRUE(wallet.AddSaplingZKey(xsk, addr)); + k.empty.push_back(addr); + } + + const boost::optional noNf; + std::vector noSpends; + std::vector noForeign; + + // Mixed ownership in one tx; Nf(2) is spent in a block. + AddTx(wallet, 5, {{k.a1, k.ivk1, 1001, Nf(1)}, + {k.a1, k.ivk1, 1002, Nf(2)}, + {k.a2, k.ivk2, 2001, Nf(3)}}, noForeign, noSpends); + // Nf(4) is spent in the mempool. The IVK-only note has no cached nullifier, but its + // on-chain nullifier Nf(5) is spent in a block: it must still count as unspent. + AddTx(wallet, 1, {{k.d1, k.ivk1, 1003, Nf(4)}, + {k.a3, k.ivk3, 3001, noNf}}, noForeign, noSpends); + // u1's note is locked; x1's note is "spent" only by a conflicted tx. + uint256 hashUX = AddTx(wallet, 3, {{k.u1, k.ivk1, 1004, Nf(6)}, + {k.x1, k.ivk1, 1005, Nf(7)}}, noForeign, noSpends); + // In the mempool (depth 0). + AddTx(wallet, 0, {{k.a1, k.ivk1, 1006, Nf(8)}, + {k.a4, k.ivk4, 4001, Nf(9)}}, noForeign, noSpends); + // Conflicted (depth -1). + AddTx(wallet, -1, {{k.a2, k.ivk2, 2002, Nf(10)}}, noForeign, noSpends); + // Deepest block; Nf(11) is spent in a block. + AddTx(wallet, 10, {{k.y1, k.ivk1, 1007, Nf(11)}, + {k.aN, k.ivkN, 5001, noNf}}, noForeign, noSpends); + // a1's note is locked; one output goes to a foreign address (no note data). + uint256 hashLocked = AddTx(wallet, 2, {{k.a1, k.ivk1, 1008, Nf(12)}, + {k.a2, k.ivk2, 2003, Nf(13)}}, + {k.foreign}, noSpends); + // Nf(14) is spent in a block. + AddTx(wallet, 4, {{k.a3, k.ivk3, 3002, noNf}, + {k.aN, k.ivkN, 5002, Nf(14)}, + {k.x1, k.ivk1, 1009, Nf(15)}}, noForeign, noSpends); + AddTx(wallet, 1, {{k.d1, k.ivk1, 1010, Nf(16)}}, noForeign, noSpends); + k.nNotes = 18; + + // Txs without note data: the spenders (in a block, in the mempool, conflicted), a + // transparent-only tx and a tx whose only output pays a foreign address. + AddTx(wallet, 2, {}, noForeign, {Nf(2), Nf(5), Nf(11), Nf(14)}); + AddTx(wallet, 0, {}, noForeign, {Nf(4)}); + AddTx(wallet, -1, {}, noForeign, {Nf(7)}); + AddTx(wallet, 3, {}, noForeign, noSpends, true); + AddTx(wallet, 1, {}, {k.foreign}, noSpends); + + wallet.LockNote(SaplingOutPoint(hashUX, 0)); + wallet.LockNote(SaplingOutPoint(hashLocked, 0)); + } +}; + +// The fixture really has what the equivalence tests rely on: every note is visible, the +// spent, depth, key and lock states are the intended ones, and the regression cases +// (unrecorded, mismatched, aliased and non-canonical addresses) hold notes, so equivalence +// on them is not vacuous. +TEST_F(WalletScanNotes, ReferenceSeesTheFixture) +{ + CWallet wallet; + TestKeys k; + ASSERT_NO_FATAL_FAILURE(BuildWallet(wallet, k)); + + std::set none; + std::vector all; + RefGetFilteredNotes(wallet, all, none, -1, INT_MAX, false, false, false); + EXPECT_EQ(all.size(), k.nNotes); + std::set depths; + for (const SaplingNoteEntry& e : all) { + depths.insert(e.confirmations); + } + EXPECT_EQ(depths, (std::set{-1, 0, 1, 2, 3, 4, 5, 10})); + + // Spent: Nf(2), Nf(11) and Nf(14) in a block, Nf(4) in the mempool. Not spent: Nf(7) + // (conflicted spender) and the IVK-only note without a cached nullifier. + EXPECT_EQ(RefCount(wallet, none, -1, true, false, false), k.nNotes - 4); + // minDepth 0 drops the conflicted tx (1 note), minDepth 1 also the mempool tx (2). + EXPECT_EQ(RefCount(wallet, none, 0, false, false, false), k.nNotes - 1); + EXPECT_EQ(RefCount(wallet, none, 1, false, false, false), k.nNotes - 3); + EXPECT_EQ(RefCount(wallet, none, -1, false, false, true), k.nNotes - 2); + // Spending key reachable through the recorded address: a1 (4), d1 (2), a2 (3), and + // x1 (2), because x1 is recorded with K2's ivk and the wallet has K2's spending key. + EXPECT_EQ(RefCount(wallet, none, -1, false, true, false), (size_t)11); + + EXPECT_EQ(RefCount(wallet, Filter({k.a1}), -1, false, false, false), (size_t)4); + EXPECT_EQ(RefCount(wallet, Filter({k.d1}), -1, false, false, false), (size_t)2); + EXPECT_EQ(RefCount(wallet, Filter({k.u1}), -1, false, false, false), (size_t)1); + EXPECT_EQ(RefCount(wallet, Filter({k.x1}), -1, false, false, false), (size_t)2); + EXPECT_EQ(RefCount(wallet, Filter({k.y1}), -1, false, false, false), (size_t)1); + EXPECT_EQ(RefCount(wallet, Filter({k.a2}), -1, false, false, false), (size_t)3); + EXPECT_EQ(RefCount(wallet, Filter({k.a3}), -1, false, false, false), (size_t)2); + EXPECT_EQ(RefCount(wallet, Filter({k.a4}), -1, false, false, false), (size_t)1); + EXPECT_EQ(RefCount(wallet, Filter({k.aN}), -1, false, false, false), (size_t)2); + EXPECT_EQ(RefCount(wallet, Filter({k.empty[0]}), -1, false, false, false), (size_t)0); + EXPECT_EQ(RefCount(wallet, Filter({k.foreign}), -1, false, false, false), (size_t)0); +} + +// Every filter shape x ignoreSpent x requireSpendingKey x ignoreLocked x minDepth {0, 1}. +TEST_F(WalletScanNotes, MatchesReferenceAcrossFilters) +{ + CWallet wallet; + TestKeys k; + ASSERT_NO_FATAL_FAILURE(BuildWallet(wallet, k)); + + std::vector filters = AllFilters(k); + size_t total = 0; + for (NamedFilter& f : filters) { + for (int minDepth : {0, 1}) { + for (bool ignoreSpent : {false, true}) { + for (bool requireSpendingKey : {false, true}) { + for (bool ignoreLocked : {false, true}) { + total += ExpectMatchesReference(wallet, f, minDepth, INT_MAX, + ignoreSpent, requireSpendingKey, ignoreLocked); + } + } + } + } + } + EXPECT_GT(total, (size_t)0); +} + +// The depth window: minDepth {-1, 0, 1} x maxDepth {0, 1, 3, INT_MAX} x the three flags, +// unfiltered and with a single (pre-filtered) address. +TEST_F(WalletScanNotes, MatchesReferenceAcrossDepths) +{ + CWallet wallet; + TestKeys k; + ASSERT_NO_FATAL_FAILURE(BuildWallet(wallet, k)); + + std::vector filters; + filters.push_back(NamedFilter{"{}", Filter({})}); + filters.push_back(NamedFilter{"{a1}", Filter({k.a1})}); + size_t total = 0; + for (NamedFilter& f : filters) { + for (int minDepth : {-1, 0, 1}) { + for (int maxDepth : {0, 1, 3, INT_MAX}) { + for (bool ignoreSpent : {false, true}) { + for (bool requireSpendingKey : {false, true}) { + for (bool ignoreLocked : {false, true}) { + total += ExpectMatchesReference(wallet, f, minDepth, maxDepth, + ignoreSpent, requireSpendingKey, ignoreLocked); + } + } + } + } + } + } + EXPECT_GT(total, (size_t)0); +} + +// The single-address string overload (z_getbalance and friends): it decodes the address +// and calls the set overload with maxDepth INT_MAX and ignoreLocked true. +TEST_F(WalletScanNotes, MatchesReferenceForAddressString) +{ + CWallet wallet; + TestKeys k; + ASSERT_NO_FATAL_FAILURE(BuildWallet(wallet, k)); + + std::vector addresses = { + "", + EncodePaymentAddress(k.a1), + EncodePaymentAddress(k.u1), + EncodePaymentAddress(k.x1), + EncodePaymentAddress(k.foreign), + "not-an-address", + }; + size_t total = 0; + for (const std::string& address : addresses) { + for (int minDepth : {0, 1}) { + for (bool ignoreSpent : {false, true}) { + for (bool requireSpendingKey : {false, true}) { + SCOPED_TRACE(testing::Message() << "address=\"" << address << "\"" + << " minDepth=" << minDepth << " ignoreSpent=" << ignoreSpent + << " requireSpendingKey=" << requireSpendingKey); + std::set filter; + if (address.length() > 0) { + filter.insert(DecodePaymentAddress(address)); + } + std::vector expected; + std::vector actual; + RefGetFilteredNotes(wallet, expected, filter, + minDepth, INT_MAX, ignoreSpent, requireSpendingKey, true); + wallet.GetFilteredNotes(actual, address, minDepth, ignoreSpent, requireSpendingKey); + ExpectSameEntries(expected, actual); + total += expected.size(); + } + } + } + } + EXPECT_GT(total, (size_t)0); +} + +// An encrypted, locked wallet: crypted spending keys still satisfy requireSpendingKey and +// the ivk/fvk maps are not encrypted, so both implementations must still agree. +TEST_F(WalletScanNotes, MatchesReferenceWhenEncryptedAndLocked) +{ + EncryptableWallet wallet; + TestKeys k; + ASSERT_NO_FATAL_FAILURE(BuildWallet(wallet, k)); + + CKeyingMaterial masterKey(WALLET_CRYPTO_KEY_SIZE, 0x5a); + ASSERT_TRUE(wallet.EncryptKeysForTest(masterKey)); + ASSERT_TRUE(wallet.Lock()); + ASSERT_TRUE(wallet.IsCrypted()); + ASSERT_TRUE(wallet.IsLocked()); + EXPECT_TRUE(wallet.HaveSaplingSpendingKey(k.fvk1)); + + std::vector filters = AllFilters(k); + size_t total = 0; + size_t spendable = 0; + for (NamedFilter& f : filters) { + for (bool ignoreSpent : {false, true}) { + for (bool requireSpendingKey : {false, true}) { + for (bool ignoreLocked : {false, true}) { + size_t n = ExpectMatchesReference(wallet, f, 0, INT_MAX, + ignoreSpent, requireSpendingKey, ignoreLocked); + total += n; + if (requireSpendingKey) { + spendable += n; + } + } + } + } + } + EXPECT_GT(total, (size_t)0); + EXPECT_GT(spendable, (size_t)0); +} + +// decrypt_with_pk_d returns the same plaintext as decrypt() and the pk_d that +// ivk.address(d) recomputes, and fails exactly when decrypt() fails. +TEST_F(WalletScanNotes, DecryptWithPkDMatchesDecrypt) +{ + CWallet wallet; + TestKeys k; + ASSERT_NO_FATAL_FAILURE(BuildWallet(wallet, k)); + + LOCK2(cs_main, wallet.cs_wallet); + size_t checked = 0; + for (const auto& p : wallet.mapWallet) { + const CWalletTx& wtx = p.second; + for (const auto& item : wtx.mapSaplingNoteData) { + SCOPED_TRACE(testing::Message() << "note " << item.first.ToString()); + const OutputDescription& od = wtx.vShieldedOutput[item.first.n]; + const libzcash::SaplingIncomingViewingKey& ivk = item.second.ivk; + + uint256 pk_d; + auto expected = libzcash::SaplingNotePlaintext::decrypt( + od.encCiphertext, ivk, od.ephemeralKey, od.cm); + auto actual = libzcash::SaplingNotePlaintext::decrypt_with_pk_d( + od.encCiphertext, ivk, od.ephemeralKey, od.cm, pk_d); + ASSERT_TRUE(static_cast(expected)); + ASSERT_TRUE(static_cast(actual)); + EXPECT_TRUE(actual->d == expected->d); + EXPECT_EQ(actual->value(), expected->value()); + EXPECT_EQ(actual->rcm, expected->rcm); + EXPECT_TRUE(actual->memo() == expected->memo()); + auto addr = ivk.address(actual->d); + ASSERT_TRUE(static_cast(addr)); + EXPECT_EQ(pk_d, addr->pk_d); + checked++; + + // Wrong ivk: the AEAD fails. + const libzcash::SaplingIncomingViewingKey& wrongIvk = (ivk == k.ivk2) ? k.ivk1 : k.ivk2; + EXPECT_FALSE(DecryptBothWays(od.encCiphertext, wrongIvk, od.ephemeralKey, od.cm)); + // Wrong cmu: decryption and ivk_to_pkd succeed, the commitment check fails, so + // pk_d has been computed but must not reach pk_d_out. + uint256 badCm = od.cm; + badCm.begin()[0] ^= 1; + EXPECT_FALSE(DecryptBothWays(od.encCiphertext, ivk, od.ephemeralKey, badCm)); + // Tampered ciphertext. + libzcash::SaplingEncCiphertext badCt = od.encCiphertext; + badCt[0] ^= 1; + EXPECT_FALSE(DecryptBothWays(badCt, ivk, od.ephemeralKey, od.cm)); + // ivk1 + r_J: whatever librustzcash decides for a non-canonical scalar, both agree. + if (ivk == k.ivk1) { + DecryptBothWays(od.encCiphertext, k.aliasIvk1, od.ephemeralKey, od.cm); + } + } + } + EXPECT_EQ(checked, k.nNotes); +}