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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <gtest/gtest.h>
|
||||
#include <univalue.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <ctime>
|
||||
#include <future>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#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<FixedStateOperation> MakeOperation(int kind, int tag)
|
||||
{
|
||||
std::shared_ptr<FixedStateOperation> 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<AsyncRPCOperationId> 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<AsyncRPCQueue> q = getAsyncRPCQueue();
|
||||
std::vector<AsyncRPCOperationId> ids = q->getAllOperationIds();
|
||||
|
||||
for (auto id : ids) {
|
||||
if (useFilter && !filter.count(id))
|
||||
continue;
|
||||
|
||||
std::shared_ptr<AsyncRPCOperation> 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<UniValue> 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<AsyncRPCQueue> q = getAsyncRPCQueue();
|
||||
std::vector<AsyncRPCOperationId> ids = q->getAllOperationIds();
|
||||
for (auto id : ids) {
|
||||
std::shared_ptr<AsyncRPCOperation> 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<std::string>& 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<std::string> RequestedIds(const UniValue& params)
|
||||
{
|
||||
std::set<std::string> ids;
|
||||
if (params.size() == 1) {
|
||||
const std::vector<UniValue>& 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<std::string> ListedIds()
|
||||
{
|
||||
UniValue listed = z_listoperationids(NoParams(), false, CPubKey());
|
||||
std::set<std::string> 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<AsyncRPCQueue>& Queue() const { return queue_; }
|
||||
|
||||
// False if the queue refused an operation.
|
||||
bool AddAll(const std::vector<std::shared_ptr<FixedStateOperation> >& 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<AsyncRPCQueue> queue_;
|
||||
CWallet wallet_;
|
||||
CWallet* savedWallet_;
|
||||
bool wasEmpty_;
|
||||
std::vector<AsyncRPCOperationId> 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<std::mutex> lk(m_);
|
||||
return cv_.wait_for(lk, timeout, [this]() { return held_; });
|
||||
}
|
||||
|
||||
void Release() {
|
||||
{
|
||||
std::lock_guard<std::mutex> guard(m_);
|
||||
release_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
private:
|
||||
void Run() {
|
||||
LOCK2(cs_main, pwalletMain->cs_wallet);
|
||||
std::unique_lock<std::mutex> 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<std::shared_ptr<FixedStateOperation> >& 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<std::vector<std::shared_ptr<FixedStateOperation> > > groups;
|
||||
};
|
||||
|
||||
OperationPool BuildPool()
|
||||
{
|
||||
OperationPool pool;
|
||||
pool.ok = true;
|
||||
for (int g = 0; g < kPoolGroups && pool.ok; g++) {
|
||||
std::vector<std::shared_ptr<FixedStateOperation> > 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<int>((i + 2 * g) % FAKE_KINDS),
|
||||
static_cast<int>(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<std::shared_ptr<FixedStateOperation> > 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<Population> Populations(const OperationPool& pool)
|
||||
{
|
||||
static const size_t sizes[] = {0, 1, 2, 16, 17, 40, 300};
|
||||
std::vector<Population> 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<UniValue> StatusParamSets(const std::vector<std::shared_ptr<FixedStateOperation> >& ops)
|
||||
{
|
||||
std::vector<UniValue> sets;
|
||||
sets.push_back(NoParams());
|
||||
sets.push_back(IdParams(std::vector<std::string>()));
|
||||
sets.push_back(IdParams(std::vector<std::string>(1, "opid-unknown")));
|
||||
if (!ops.empty()) {
|
||||
std::vector<std::string> two;
|
||||
two.push_back(ops.front()->getId());
|
||||
two.push_back(ops.back()->getId());
|
||||
sets.push_back(IdParams(two));
|
||||
sets.push_back(IdParams(std::vector<std::string>(2, ops.front()->getId())));
|
||||
|
||||
std::vector<std::string> 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<AsyncRPCOperationId>* ids,
|
||||
const std::atomic<bool>* go, std::vector<AsyncRPCOperationId>* 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<bool>* go, std::vector<std::string>* out)
|
||||
{
|
||||
while (!go->load())
|
||||
std::this_thread::yield();
|
||||
const UniValue params(UniValue::VARR);
|
||||
std::shared_ptr<AsyncRPCQueue> 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("<exception>");
|
||||
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<bool>* go, const std::atomic<bool>* 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<std::string> 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<Population> pops = Populations(pool);
|
||||
for (size_t p = 0; p < pops.size(); p++) {
|
||||
ASSERT_TRUE(env.AddAll(pops[p].ops));
|
||||
const std::vector<UniValue> 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<std::shared_ptr<FixedStateOperation> > tied(pool.groups[1].begin(), pool.groups[1].begin() + 40);
|
||||
ASSERT_TRUE(env.AddAll(tied));
|
||||
const UniValue ref = ReferenceOperationStatus(NoParams(), false);
|
||||
std::vector<std::string> reported;
|
||||
for (size_t i = 0; i < ref.size(); i++)
|
||||
reported.push_back(find_value(ref[i], "id").get_str());
|
||||
const std::vector<std::string> 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<Population> pops = Populations(pool);
|
||||
for (size_t p = 0; p < pops.size(); p++) {
|
||||
const std::vector<UniValue> 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<std::string> requested = RequestedIds(params);
|
||||
std::set<std::string> remaining;
|
||||
int wrong = 0;
|
||||
for (size_t j = 0; j < pops[p].ops.size(); j++) {
|
||||
const std::shared_ptr<FixedStateOperation>& 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<Population> 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<std::shared_ptr<FixedStateOperation> > 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<std::shared_ptr<FixedStateOperation> > 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<std::string>(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<std::string> 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<std::string> 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<AsyncRPCQueue> q = std::make_shared<AsyncRPCQueue>();
|
||||
std::shared_ptr<FixedStateOperation> 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<AsyncRPCQueue> q = std::make_shared<AsyncRPCQueue>();
|
||||
std::vector<AsyncRPCOperationId> ids;
|
||||
for (int i = 0; i < kOps; i++) {
|
||||
std::shared_ptr<AsyncRPCOperation> op(new FixedStateOperation());
|
||||
ASSERT_TRUE(q->addOperation(op));
|
||||
ids.push_back(op->getId());
|
||||
}
|
||||
|
||||
std::atomic<bool> go(false);
|
||||
std::vector<std::vector<AsyncRPCOperationId> > popped(kThreads);
|
||||
std::vector<std::thread> 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<AsyncRPCOperationId, int> 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<AsyncRPCOperationId, int>::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<size_t>(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<std::shared_ptr<FixedStateOperation> > ops;
|
||||
std::set<std::string> 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<bool> go(false);
|
||||
std::atomic<bool> stop(false);
|
||||
std::vector<std::vector<std::string> > removed(kRemovers);
|
||||
std::vector<int> readerFaults(kReaders, 0);
|
||||
std::vector<std::thread> 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<std::string, int> 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<std::string, int>::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<size_t>(kOps), reported.size());
|
||||
EXPECT_EQ(static_cast<size_t>(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<FixedStateOperation> 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());
|
||||
}
|
||||
}
|
||||
@@ -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 <gtest/gtest.h>
|
||||
|
||||
#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 <algorithm>
|
||||
#include <array>
|
||||
#include <cassert>
|
||||
#include <climits>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <boost/filesystem.hpp>
|
||||
|
||||
// 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<SaplingNoteEntry>&, std::set<PaymentAddress>&,
|
||||
* 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<SaplingNoteEntry>& saplingEntries,
|
||||
std::set<libzcash::PaymentAddress>& 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<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))) {
|
||||
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<libzcash::SaplingPaymentAddress> 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<uint256> nullifier;
|
||||
};
|
||||
|
||||
struct NamedFilter {
|
||||
std::string name;
|
||||
std::set<libzcash::PaymentAddress> 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<unsigned char, HUSH_MEMO_SIZE> 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<libzcash::PaymentAddress> Filter(const std::vector<libzcash::SaplingPaymentAddress>& addrs)
|
||||
{
|
||||
std::set<libzcash::PaymentAddress> s;
|
||||
for (const libzcash::SaplingPaymentAddress& a : addrs) {
|
||||
s.insert(a);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
void ExpectSameEntries(const std::vector<SaplingNoteEntry>& expected,
|
||||
const std::vector<SaplingNoteEntry>& 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<SaplingNoteEntry> expected;
|
||||
std::vector<SaplingNoteEntry> 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<libzcash::PaymentAddress> filter, int minDepth,
|
||||
bool ignoreSpent, bool requireSpendingKey, bool ignoreLocked)
|
||||
{
|
||||
std::vector<SaplingNoteEntry> entries;
|
||||
RefGetFilteredNotes(wallet, entries, filter,
|
||||
minDepth, INT_MAX, ignoreSpent, requireSpendingKey, ignoreLocked);
|
||||
return entries.size();
|
||||
}
|
||||
|
||||
// Every filter shape the pre-filter distinguishes.
|
||||
std::vector<NamedFilter> AllFilters(const TestKeys& k)
|
||||
{
|
||||
// 16 recorded, verifiable addresses: the pre-filter is on (the cap is inclusive).
|
||||
std::vector<libzcash::SaplingPaymentAddress> 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<libzcash::SaplingPaymentAddress> seventeen = sixteen;
|
||||
seventeen.push_back(k.empty[11]);
|
||||
|
||||
std::set<libzcash::PaymentAddress> invalidOnly;
|
||||
invalidOnly.insert(libzcash::InvalidEncoding());
|
||||
std::set<libzcash::PaymentAddress> invalidAndA2 = invalidOnly;
|
||||
invalidAndA2.insert(k.a2);
|
||||
|
||||
std::vector<NamedFilter> 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<bool>(expected), static_cast<bool>(actual));
|
||||
if (!actual) {
|
||||
EXPECT_EQ(pk_d, sentinel);
|
||||
}
|
||||
return static_cast<bool>(actual);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class WalletScanNotes : public ::testing::Test {
|
||||
protected:
|
||||
boost::filesystem::path datadir;
|
||||
bool hadDatadirArg;
|
||||
std::string oldDatadirArg;
|
||||
bool oldPrintToDebugLog;
|
||||
CBlockIndex* oldTip;
|
||||
std::vector<std::unique_ptr<CBlockIndex>> blocks;
|
||||
std::vector<CTransaction> 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<CBlockIndex> 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<CTransaction> removed;
|
||||
mempool.remove(tx, removed, false);
|
||||
}
|
||||
mempoolTxs.clear();
|
||||
chainActive.SetTip(oldTip);
|
||||
for (const std::unique_ptr<CBlockIndex>& 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<OwnedOutput>& owned,
|
||||
const std::vector<libzcash::SaplingPaymentAddress>& foreign,
|
||||
const std::vector<uint256>& 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<libzcash::SaplingPaymentAddress> 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<bool>(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<uint256> noNf;
|
||||
std::vector<uint256> noSpends;
|
||||
std::vector<libzcash::SaplingPaymentAddress> 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<libzcash::PaymentAddress> none;
|
||||
std::vector<SaplingNoteEntry> all;
|
||||
RefGetFilteredNotes(wallet, all, none, -1, INT_MAX, false, false, false);
|
||||
EXPECT_EQ(all.size(), k.nNotes);
|
||||
std::set<int> depths;
|
||||
for (const SaplingNoteEntry& e : all) {
|
||||
depths.insert(e.confirmations);
|
||||
}
|
||||
EXPECT_EQ(depths, (std::set<int>{-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<NamedFilter> 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<NamedFilter> 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<std::string> 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<libzcash::PaymentAddress> filter;
|
||||
if (address.length() > 0) {
|
||||
filter.insert(DecodePaymentAddress(address));
|
||||
}
|
||||
std::vector<SaplingNoteEntry> expected;
|
||||
std::vector<SaplingNoteEntry> 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<NamedFilter> 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<bool>(expected));
|
||||
ASSERT_TRUE(static_cast<bool>(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<bool>(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);
|
||||
}
|
||||
Reference in New Issue
Block a user