Files
ObsidianDragon/tests/test_phase4.cpp
dan_s 973c390df5 fix(history): keep wallet-created sends visible
Replay cached outgoing viewtransaction entries during transaction refresh so shielded sends created from the wallet remain in the History tab after send tracking is cleared.

Keep incomplete tracked sends retryable, preserve cached send timestamp/confirmation metadata, and emit a send placeholder from gettransaction metadata when viewtransaction enrichment is not yet available.

Add regression coverage for cached sends, retryable empty entries, placeholder sends, and send txid cleanup behavior.
2026-04-30 14:57:37 -05:00

1915 lines
82 KiB
C++

#include "daemon/daemon_controller.h"
#include "daemon/lifecycle_adapters.h"
#include "data/wallet_state.h"
#include "rpc/connection.h"
#include "resources/embedded_resources.h"
#include "services/network_refresh_service.h"
#include "services/refresh_scheduler.h"
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "services/wallet_security_workflow_executor.h"
#include "ui/windows/balance_address_list.h"
#include "ui/windows/balance_recent_tx.h"
#include "ui/windows/console_input_model.h"
#include "ui/windows/console_output_model.h"
#include "ui/windows/console_tab_helpers.h"
#include "ui/windows/mining_benchmark.h"
#include "ui/windows/mining_pool_panel.h"
#include "ui/windows/mining_tab_helpers.h"
#include "util/amount_format.h"
#include "util/payment_uri.h"
#include <chrono>
#include <cmath>
#include <atomic>
#include <deque>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <set>
#include <stdexcept>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
namespace fs = std::filesystem;
namespace {
int g_failures = 0;
void expectTrue(bool value, const char* expr, const char* file, int line)
{
if (value) return;
std::cerr << file << ":" << line << " expected true: " << expr << "\n";
++g_failures;
}
template <typename T, typename U>
void expectEqual(const T& actual, const U& expected, const char* actualExpr,
const char* expectedExpr, const char* file, int line)
{
if (actual == expected) return;
std::cerr << file << ":" << line << " expected " << actualExpr << " == "
<< expectedExpr << "\n";
++g_failures;
}
void expectNear(double actual, double expected, double epsilon, const char* file, int line)
{
if (std::abs(actual - expected) <= epsilon) return;
std::cerr << file << ":" << line << " expected near " << expected
<< " (actual=" << actual << ")\n";
++g_failures;
}
#define EXPECT_TRUE(expr) expectTrue((expr), #expr, __FILE__, __LINE__)
#define EXPECT_FALSE(expr) expectTrue(!(expr), "!(" #expr ")", __FILE__, __LINE__)
#define EXPECT_EQ(actual, expected) expectEqual((actual), (expected), #actual, #expected, __FILE__, __LINE__)
#define EXPECT_NEAR(actual, expected, epsilon) expectNear((actual), (expected), (epsilon), __FILE__, __LINE__)
fs::path makeTempDir()
{
auto now = std::chrono::steady_clock::now().time_since_epoch().count();
fs::path dir = fs::temp_directory_path() / ("obsidian_phase4_tests_" + std::to_string(now));
fs::create_directories(dir);
return dir;
}
class MockWalletSecurityRpc : public dragonx::services::WalletSecurityController::RpcGateway {
public:
bool encryptResult = true;
std::string encryptError;
int encryptCalls = 0;
std::string lastPassphrase;
bool encryptWallet(const std::string& passphrase, std::string& error) override
{
++encryptCalls;
lastPassphrase = passphrase;
error = encryptError;
return encryptResult;
}
bool unlockWallet(const std::string&, int, std::string&) override { return true; }
bool exportWallet(const std::string&, long, std::string&) override { return true; }
bool importWallet(const std::string&, long, std::string&) override { return true; }
};
class MockDaemonLifecycleRuntime : public dragonx::daemon::DaemonController::LifecycleRuntime {
public:
std::vector<std::string> calls;
int deletedItems = 7;
bool startResult = true;
std::string rpcStopContext;
std::string disconnectReason;
void stopDaemonWithPolicy() override { calls.push_back("stop"); }
bool startDaemon() override
{
calls.push_back("start");
return startResult;
}
int deleteBlockchainData() override
{
calls.push_back("delete");
return deletedItems;
}
void resetOutputOffset() override { calls.push_back("reset-output"); }
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
{
calls.push_back("rpc-stop-disconnect");
rpcStopContext = context ? context : "";
disconnectReason = reason ? reason : "";
}
};
class MockDaemonLifecycleTask : public dragonx::daemon::DaemonController::LifecycleTaskContext {
public:
bool isCancelled = false;
bool isShuttingDown = false;
int cancelAfterSleeps = 0;
int sleeps = 0;
int sleptMs = 0;
bool cancelled() const override { return isCancelled; }
bool shuttingDown() const override { return isShuttingDown; }
void sleepForMs(int milliseconds) override
{
++sleeps;
sleptMs += milliseconds;
if (cancelAfterSleeps > 0 && sleeps >= cancelAfterSleeps) {
isCancelled = true;
}
}
};
class MockWalletSecurityVault : public dragonx::services::WalletSecurityController::VaultGateway {
public:
bool storeResult = true;
int storeCalls = 0;
std::string lastPin;
std::string lastPassphrase;
bool storePin(const std::string& pin, const std::string& passphrase) override
{
++storeCalls;
lastPin = pin;
lastPassphrase = passphrase;
return storeResult;
}
};
class MockWorkflowRpc : public dragonx::services::WalletSecurityWorkflowExecutor::RpcGateway {
public:
bool unlockResult = true;
bool exportResult = true;
bool stopResult = true;
int probeSuccessOnCall = 1;
std::string unlockError;
std::string exportError;
std::string stopError;
std::string probeError;
int unlockCalls = 0;
int exportCalls = 0;
int stopCalls = 0;
int probeCalls = 0;
std::string lastPassphrase;
std::string lastExportFile;
bool unlockWallet(const std::string& passphrase, int, std::string& error) override
{
++unlockCalls;
lastPassphrase = passphrase;
error = unlockError;
return unlockResult;
}
bool exportWallet(const std::string& fileName, long, std::string& error) override
{
++exportCalls;
lastExportFile = fileName;
error = exportError;
return exportResult;
}
bool requestDaemonStop(std::string& error) override
{
++stopCalls;
error = stopError;
return stopResult;
}
bool probeDaemon(std::string& error) override
{
++probeCalls;
error = probeError;
return probeCalls >= probeSuccessOnCall;
}
};
class MockWorkflowFiles : public dragonx::services::WalletSecurityWorkflowExecutor::FileGateway {
public:
std::string dir = "/tmp/dragonx/";
bool backupResult = true;
std::string backupError;
int backupCalls = 0;
std::string dataDir() override { return dir; }
bool backupEncryptedWallet(const dragonx::services::WalletSecurityWorkflowExecutor::WalletFilePlan&,
std::string& error) override
{
++backupCalls;
error = backupError;
return backupResult;
}
};
class MockWorkflowDaemon : public dragonx::services::WalletSecurityWorkflowExecutor::DaemonGateway {
public:
bool usingEmbedded = true;
bool isCancelled = false;
bool isShuttingDown = false;
int cancelAfterSleeps = 0;
int sleepCalls = 0;
int sleptMs = 0;
int stopCalls = 0;
int startCalls = 0;
bool isUsingEmbeddedDaemon() const override { return usingEmbedded; }
void stopEmbeddedDaemon() override { ++stopCalls; }
bool startEmbeddedDaemon() override { ++startCalls; return true; }
bool cancelled() const override { return isCancelled; }
bool shuttingDown() const override { return isShuttingDown; }
void sleepForMs(int milliseconds) override
{
++sleepCalls;
sleptMs += milliseconds;
if (cancelAfterSleeps > 0 && sleepCalls >= cancelAfterSleeps) {
isCancelled = true;
}
}
};
class MockWorkflowImporter : public dragonx::services::WalletSecurityWorkflowExecutor::ImportGateway {
public:
bool result = true;
std::string importError;
int importCalls = 0;
std::string lastExportPath;
bool importWallet(const std::string& exportPath, long, std::string& error) override
{
++importCalls;
lastExportPath = exportPath;
error = importError;
return result;
}
};
class FakeRefreshWorker {
public:
std::size_t reportedPending = 0;
std::vector<dragonx::rpc::RPCWorker::WorkFn> tasks;
std::size_t pendingTaskCount() const { return reportedPending; }
void post(dragonx::rpc::RPCWorker::WorkFn work)
{
tasks.push_back(std::move(work));
}
dragonx::rpc::RPCWorker::MainCb runNext()
{
auto work = std::move(tasks.front());
tasks.erase(tasks.begin());
return work();
}
dragonx::rpc::RPCWorker::MainCb runAt(std::size_t index)
{
auto work = std::move(tasks[index]);
tasks.erase(tasks.begin() + static_cast<std::ptrdiff_t>(index));
return work();
}
};
class MockRefreshRpc : public dragonx::services::NetworkRefreshService::RefreshRpcGateway {
public:
struct RecordedCall {
std::string method;
nlohmann::json params;
};
struct Response {
nlohmann::json value;
bool throws = false;
std::string error = "mock rpc failure";
};
std::vector<RecordedCall> calls;
std::unordered_map<std::string, std::deque<Response>> responses;
void addResponse(const std::string& method, nlohmann::json value)
{
responses[method].push_back({std::move(value), false, {}});
}
void addFailure(const std::string& method, std::string error = "mock rpc failure")
{
responses[method].push_back({nlohmann::json(), true, std::move(error)});
}
nlohmann::json call(const std::string& method, const nlohmann::json& params) override
{
calls.push_back({method, params});
auto& queue = responses[method];
if (queue.empty()) {
throw std::runtime_error("unexpected rpc call: " + method);
}
auto response = std::move(queue.front());
queue.pop_front();
if (response.throws) throw std::runtime_error(response.error);
return response.value;
}
std::vector<std::string> methodNames() const
{
std::vector<std::string> names;
names.reserve(calls.size());
for (const auto& call : calls) names.push_back(call.method);
return names;
}
};
void testConnectionConfig()
{
using dragonx::rpc::AuthSource;
using dragonx::rpc::Connection;
fs::path dir = makeTempDir();
fs::path conf = dir / "DRAGONX.conf";
{
std::ofstream out(conf);
out << "rpcuser=alice\n"
<< "rpcpassword = secret \r\n"
<< "rpcconnect=rpc.example.test\n"
<< "rpcport=12345\n"
<< "rpctls=yes\n";
}
auto config = Connection::parseConfFile(conf.string());
EXPECT_EQ(config.rpcuser, std::string("alice"));
EXPECT_EQ(config.rpcpassword, std::string("secret"));
EXPECT_EQ(config.host, std::string("rpc.example.test"));
EXPECT_EQ(config.port, std::string("12345"));
EXPECT_TRUE(config.use_tls);
EXPECT_EQ(config.auth_source, AuthSource::ConfigFile);
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
fs::path tlsConf = dir / "tls.conf";
{
std::ofstream out(tlsConf);
out << "rpcuser=bob\n"
<< "rpcpassword=remote-secret\n"
<< "rpcconnect=198.51.100.10\n"
<< "rpcssl=1\n";
}
auto tlsConfig = Connection::parseConfFile(tlsConf.string());
EXPECT_TRUE(tlsConfig.use_tls);
EXPECT_FALSE(Connection::usesPlaintextRemote(tlsConfig));
config.use_tls = false;
EXPECT_TRUE(Connection::usesPlaintextRemote(config));
config.host = "127.4.5.6";
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
config.host = "[::1]";
EXPECT_FALSE(Connection::usesPlaintextRemote(config));
fs::path cookieDir = dir / "cookie";
fs::create_directories(cookieDir);
{
std::ofstream out(cookieDir / ".cookie");
out << "__cookie__:cookie-secret\r\n";
}
dragonx::rpc::ConnectionConfig cookieBase;
cookieBase.host = "rpc.example.test";
cookieBase.hush_dir = cookieDir.string();
cookieBase.auth_source = AuthSource::ConfigFile;
cookieBase.use_tls = true;
dragonx::rpc::ConnectionConfig cookieConfig;
EXPECT_TRUE(Connection::buildCookieAuthConfig(cookieBase, cookieConfig));
EXPECT_EQ(cookieConfig.rpcuser, std::string("__cookie__"));
EXPECT_EQ(cookieConfig.rpcpassword, std::string("cookie-secret"));
EXPECT_EQ(cookieConfig.auth_source, AuthSource::Cookie);
EXPECT_TRUE(cookieConfig.use_tls);
fs::remove_all(dir);
}
void testPaymentUri()
{
std::string taddr = "R" + std::string(33, 'a');
auto parsed = dragonx::util::parsePaymentURI(
"drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks");
EXPECT_TRUE(parsed.valid);
EXPECT_EQ(parsed.address, taddr);
EXPECT_NEAR(parsed.amount, 1.25, 0.00000001);
EXPECT_EQ(parsed.label, std::string("Main Wallet"));
EXPECT_EQ(parsed.memo, std::string("hello there"));
EXPECT_EQ(parsed.message, std::string("thanks"));
std::string zaddr = "zs" + std::string(76, 'b');
auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5");
EXPECT_TRUE(zparsed.valid);
EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001);
auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1");
EXPECT_FALSE(invalid.valid);
EXPECT_EQ(invalid.error, std::string("Invalid negative amount"));
}
void testAmountFormatting()
{
EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000"));
EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000"));
EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002"));
EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35"));
}
void testSpendableFiltering()
{
std::vector<dragonx::AddressInfo> addresses;
addresses.push_back({"zs-view", 50.0, "shielded", false});
addresses.push_back({"zs-low", 2.0, "shielded", true});
addresses.push_back({"R-zero", 0.0, "transparent", true});
addresses.push_back({"R-high", 5.0, "transparent", true});
EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3);
auto indices = dragonx::sortedSpendableAddressIndices(addresses);
EXPECT_EQ(indices.size(), static_cast<size_t>(2));
EXPECT_EQ(indices[0], static_cast<size_t>(3));
EXPECT_EQ(indices[1], static_cast<size_t>(1));
auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false);
EXPECT_EQ(includeZero.size(), static_cast<size_t>(3));
}
void testRefreshScheduler()
{
using dragonx::services::RefreshScheduler;
using Timer = RefreshScheduler::Timer;
RefreshScheduler scheduler;
scheduler.applyPage(dragonx::ui::NavPage::Overview);
EXPECT_NEAR(scheduler.intervals().core, 2.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().peers, 0.0, 0.0001);
scheduler.tick(1.99f);
EXPECT_FALSE(scheduler.isDue(Timer::Core));
scheduler.tick(0.02f);
EXPECT_TRUE(scheduler.consumeDue(Timer::Core));
EXPECT_FALSE(scheduler.isDue(Timer::Core));
scheduler.markWalletMutationRefresh();
EXPECT_TRUE(scheduler.isDue(Timer::Core));
EXPECT_TRUE(scheduler.isDue(Timer::Transactions));
EXPECT_TRUE(scheduler.isDue(Timer::Addresses));
EXPECT_FALSE(scheduler.isDue(Timer::Peers));
scheduler.applyPage(dragonx::ui::NavPage::Peers);
scheduler.markDue(Timer::Peers);
EXPECT_TRUE(scheduler.consumeDue(Timer::Peers));
EXPECT_FALSE(scheduler.isDue(Timer::Price));
scheduler.markDue(Timer::Price);
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false, false));
scheduler.tick(RefreshScheduler::kTxMaxAge);
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, false, false));
}
void testNetworkRefreshService()
{
using dragonx::services::NetworkRefreshService;
using Job = NetworkRefreshService::Job;
using Timer = NetworkRefreshService::Timer;
NetworkRefreshService service;
service.applyPage(dragonx::ui::NavPage::Peers);
service.markImmediateRefresh();
EXPECT_TRUE(service.consumeDue(Timer::Core));
EXPECT_TRUE(service.consumeDue(Timer::Transactions));
EXPECT_TRUE(service.consumeDue(Timer::Addresses));
EXPECT_TRUE(service.consumeDue(Timer::Peers));
EXPECT_TRUE(service.beginJob(Job::Core));
EXPECT_TRUE(service.jobInProgress(Job::Core));
EXPECT_FALSE(service.beginJob(Job::Core));
service.finishJob(Job::Core);
EXPECT_FALSE(service.jobInProgress(Job::Core));
EXPECT_TRUE(service.beginJob(Job::Core));
service.resetJobs();
EXPECT_FALSE(service.jobInProgress(Job::Core));
EXPECT_TRUE(service.beginJob(Job::ConnectionInit));
EXPECT_TRUE(service.jobInProgress(Job::ConnectionInit));
service.finishJob(Job::ConnectionInit);
EXPECT_FALSE(service.jobInProgress(Job::ConnectionInit));
auto cancelOld = service.beginDispatch(Job::Core);
EXPECT_TRUE(cancelOld.accepted);
service.resetJobs();
auto cancelNew = service.beginDispatch(Job::Core);
EXPECT_TRUE(cancelNew.accepted);
service.cancelDispatch(cancelOld);
service.cancelDispatch(cancelNew);
EXPECT_EQ(service.stats(Job::Core).staleCallbacks, static_cast<uint64_t>(1));
auto ticket = service.beginDispatch(Job::Addresses, 1, 4);
EXPECT_TRUE(ticket.accepted);
auto busyTicket = service.beginDispatch(Job::Addresses, 1, 4);
EXPECT_FALSE(busyTicket.accepted);
auto stats = service.stats(Job::Addresses);
EXPECT_EQ(stats.started, static_cast<uint64_t>(1));
EXPECT_EQ(stats.skippedInFlight, static_cast<uint64_t>(1));
EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(1));
EXPECT_TRUE(service.completeDispatch(ticket));
stats = service.stats(Job::Addresses);
EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));
auto pressureTicket = service.beginDispatch(Job::Mining, 3, 3);
EXPECT_FALSE(pressureTicket.accepted);
stats = service.stats(Job::Mining);
EXPECT_EQ(stats.skippedQueuePressure, static_cast<uint64_t>(1));
EXPECT_EQ(stats.lastQueueDepth, static_cast<size_t>(3));
auto staleTicket = service.beginDispatch(Job::Peers);
EXPECT_TRUE(staleTicket.accepted);
service.resetJobs();
auto newerTicket = service.beginDispatch(Job::Peers);
EXPECT_TRUE(newerTicket.accepted);
EXPECT_FALSE(service.completeDispatch(staleTicket));
service.cancelDispatch(newerTicket);
stats = service.stats(Job::Peers);
EXPECT_EQ(stats.started, static_cast<uint64_t>(2));
EXPECT_EQ(stats.staleCallbacks, static_cast<uint64_t>(1));
FakeRefreshWorker worker;
int workerRuns = 0;
int mainRuns = 0;
auto enqueued = service.enqueue(Job::Price, worker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
++workerRuns;
return [&]() { ++mainRuns; };
}, 3);
EXPECT_TRUE(enqueued.enqueued);
EXPECT_TRUE(service.jobInProgress(Job::Price));
EXPECT_EQ(worker.tasks.size(), static_cast<size_t>(1));
auto mainCallback = worker.runNext();
EXPECT_EQ(workerRuns, 1);
mainCallback();
EXPECT_EQ(mainRuns, 1);
EXPECT_FALSE(service.jobInProgress(Job::Price));
stats = service.stats(Job::Price);
EXPECT_EQ(stats.finished, static_cast<uint64_t>(1));
auto nullMainEnqueued = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
return nullptr;
}, 3);
EXPECT_TRUE(nullMainEnqueued.enqueued);
EXPECT_TRUE(service.jobInProgress(Job::Price));
auto nullMainCallback = worker.runNext();
EXPECT_TRUE(static_cast<bool>(nullMainCallback));
nullMainCallback();
EXPECT_FALSE(service.jobInProgress(Job::Price));
stats = service.stats(Job::Price);
EXPECT_EQ(stats.finished, static_cast<uint64_t>(2));
worker.reportedPending = 5;
auto unboundedPrice = service.enqueue(Job::Price, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
return []() {};
}, 0);
EXPECT_TRUE(unboundedPrice.enqueued);
EXPECT_EQ(unboundedPrice.queueDepth, static_cast<size_t>(5));
auto unboundedPriceMain = worker.runNext();
unboundedPriceMain();
EXPECT_FALSE(service.jobInProgress(Job::Price));
worker.reportedPending = 3;
auto pressured = service.enqueue(Job::Mining, worker, []() -> dragonx::rpc::RPCWorker::MainCb {
return []() {};
}, 3);
EXPECT_FALSE(pressured.enqueued);
EXPECT_EQ(service.stats(Job::Mining).skippedQueuePressure, static_cast<uint64_t>(2));
FakeRefreshWorker staleWorker;
int staleMainRuns = 0;
auto first = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
return [&]() { ++staleMainRuns; };
}, 3);
EXPECT_TRUE(first.enqueued);
auto oldCallback = staleWorker.runNext();
service.resetJobs();
auto second = service.enqueue(Job::Encryption, staleWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
return [&]() { ++staleMainRuns; };
}, 3);
EXPECT_TRUE(second.enqueued);
oldCallback();
EXPECT_EQ(staleMainRuns, 0);
auto freshCallback = staleWorker.runNext();
freshCallback();
EXPECT_EQ(staleMainRuns, 1);
EXPECT_EQ(service.stats(Job::Encryption).staleCallbacks, static_cast<uint64_t>(1));
FakeRefreshWorker orderingWorker;
std::vector<std::string> callbackOrder;
auto orderedCore = service.enqueue(Job::Core, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
callbackOrder.push_back("core-work");
return [&]() { callbackOrder.push_back("core-main"); };
}, 3);
auto orderedPrice = service.enqueue(Job::Price, orderingWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
callbackOrder.push_back("price-work");
return [&]() { callbackOrder.push_back("price-main"); };
}, 3);
EXPECT_TRUE(orderedCore.enqueued);
EXPECT_TRUE(orderedPrice.enqueued);
auto priceMain = orderingWorker.runAt(1);
auto coreMain = orderingWorker.runAt(0);
priceMain();
coreMain();
EXPECT_TRUE(callbackOrder == std::vector<std::string>({"price-work", "core-work", "price-main", "core-main"}));
EXPECT_FALSE(service.jobInProgress(Job::Core));
EXPECT_FALSE(service.jobInProgress(Job::Price));
FakeRefreshWorker reconnectWorker;
int reconnectMainRuns = 0;
auto staleBefore = service.stats(Job::Transactions).staleCallbacks;
auto oldTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
return [&]() { ++reconnectMainRuns; };
}, 3);
EXPECT_TRUE(oldTransactions.enqueued);
auto oldTransactionsMain = reconnectWorker.runNext();
service.resetJobs();
auto freshTransactions = service.enqueue(Job::Transactions, reconnectWorker, [&]() -> dragonx::rpc::RPCWorker::MainCb {
return [&]() { ++reconnectMainRuns; };
}, 3);
EXPECT_TRUE(freshTransactions.enqueued);
auto freshTransactionsMain = reconnectWorker.runNext();
freshTransactionsMain();
oldTransactionsMain();
EXPECT_EQ(reconnectMainRuns, 1);
EXPECT_EQ(service.stats(Job::Transactions).staleCallbacks, staleBefore + 1);
EXPECT_FALSE(service.jobInProgress(Job::Transactions));
}
void testNetworkRefreshSnapshotHelpers()
{
using Refresh = dragonx::services::NetworkRefreshService;
using nlohmann::json;
auto shielded = Refresh::buildShieldedAddressInfo("zs-viewonly", json{{"ismine", false}}, true);
EXPECT_EQ(shielded.address, std::string("zs-viewonly"));
EXPECT_EQ(shielded.type, std::string("shielded"));
EXPECT_FALSE(shielded.has_spending_key);
auto legacyShielded = Refresh::buildShieldedAddressInfo("zs-legacy", json::object(), false);
EXPECT_TRUE(legacyShielded.has_spending_key);
auto transparentAddresses = Refresh::parseTransparentAddressList(json::array({"R-one", "R-two"}));
EXPECT_EQ(transparentAddresses.size(), static_cast<size_t>(2));
EXPECT_EQ(transparentAddresses[0].type, std::string("transparent"));
std::vector<dragonx::AddressInfo> balanceAddresses = {shielded, legacyShielded};
Refresh::applyShieldedBalancesFromUnspent(balanceAddresses, json::array({
json{{"address", "zs-viewonly"}, {"amount", 1.25}},
json{{"address", "zs-viewonly"}, {"amount", 0.75}},
json{{"address", "zs-legacy"}, {"amount", 3.0}}
}));
EXPECT_NEAR(balanceAddresses[0].balance, 2.0, 0.00000001);
EXPECT_NEAR(balanceAddresses[1].balance, 3.0, 0.00000001);
std::vector<dragonx::TransactionInfo> transactions;
std::set<std::string> knownTxids;
Refresh::appendTransparentTransactions(transactions, knownTxids, json::array({
json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.5},
{"timereceived", 100}, {"confirmations", 2}, {"address", "R-one"}},
json{{"txid", "transparent-b"}, {"category", "send"}, {"amount", -0.25},
{"time", 300}, {"confirmations", 12}}
}));
EXPECT_EQ(transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(knownTxids.count("transparent-a"), static_cast<size_t>(1));
EXPECT_EQ(transactions[0].timestamp, static_cast<int64_t>(100));
EXPECT_EQ(transactions[1].timestamp, static_cast<int64_t>(300));
Refresh::appendShieldedReceivedTransactions(transactions, knownTxids, "zs-viewonly", json::array({
json{{"txid", "transparent-a"}, {"amount", 9.0}, {"confirmations", 1}},
json{{"txid", "shielded-change"}, {"change", true}, {"amount", 2.0}},
json{{"txid", "shielded-receive"}, {"amount", 4.0}, {"confirmations", 5},
{"time", 200}, {"memoStr", "hello"}}
}));
EXPECT_EQ(transactions.size(), static_cast<size_t>(3));
EXPECT_EQ(knownTxids.count("shielded-receive"), static_cast<size_t>(1));
EXPECT_EQ(transactions.back().address, std::string("zs-viewonly"));
EXPECT_EQ(transactions.back().memo, std::string("hello"));
Refresh::sortTransactionsNewestFirst(transactions);
EXPECT_EQ(transactions.front().txid, std::string("transparent-b"));
EXPECT_EQ(transactions.back().txid, std::string("transparent-a"));
dragonx::WalletState state;
state.z_addresses.push_back(shielded);
dragonx::AddressInfo emptyShielded;
emptyShielded.type = "shielded";
state.z_addresses.push_back(emptyShielded);
dragonx::TransactionInfo oldTransaction;
oldTransaction.txid = "old-complete";
oldTransaction.confirmations = 7;
oldTransaction.timestamp = 1000;
state.transactions.push_back(oldTransaction);
Refresh::TransactionViewCache viewCache;
viewCache["cached-view"].from_address = "zs-from";
std::unordered_set<std::string> sendTxids{"pending-send"};
auto snapshot = Refresh::buildTransactionRefreshSnapshot(state, viewCache, sendTxids);
EXPECT_EQ(snapshot.shieldedAddresses.size(), static_cast<size_t>(1));
EXPECT_EQ(snapshot.shieldedAddresses[0], std::string("zs-viewonly"));
EXPECT_EQ(snapshot.fullyEnrichedTxids.count("cached-view"), static_cast<size_t>(1));
EXPECT_EQ(snapshot.fullyEnrichedTxids.count("old-complete"), static_cast<size_t>(1));
EXPECT_EQ(snapshot.viewTxCache.size(), static_cast<size_t>(1));
EXPECT_EQ(snapshot.sendTxids.count("pending-send"), static_cast<size_t>(1));
}
void testNetworkRefreshRpcCollectors()
{
using Refresh = dragonx::services::NetworkRefreshService;
using nlohmann::json;
MockRefreshRpc warmupRpc;
warmupRpc.addResponse("getinfo", json{
{"version", 120000},
{"protocolversion", 170002},
{"blocks", 12},
{"longestchain", 15},
{"notarized", 9}
});
auto warmup = Refresh::collectWarmupPollResult(warmupRpc);
EXPECT_TRUE(warmupRpc.methodNames() == std::vector<std::string>({"getinfo"}));
EXPECT_EQ(warmupRpc.calls[0].params, json::array());
EXPECT_TRUE(warmup.ready);
EXPECT_TRUE(warmup.info.ok);
EXPECT_EQ(*warmup.info.blocks, 12);
EXPECT_EQ(*warmup.info.longestChain, 15);
EXPECT_EQ(warmup.errorMessage, std::string());
MockRefreshRpc warmupFailureRpc;
warmupFailureRpc.addFailure("getinfo", "Loading block index");
auto warmupFailure = Refresh::collectWarmupPollResult(warmupFailureRpc);
EXPECT_TRUE(warmupFailureRpc.methodNames() == std::vector<std::string>({"getinfo"}));
EXPECT_EQ(warmupFailureRpc.calls[0].params, json::array());
EXPECT_FALSE(warmupFailure.ready);
EXPECT_FALSE(warmupFailure.info.ok);
EXPECT_EQ(warmupFailure.errorMessage, std::string("Loading block index"));
MockRefreshRpc connectionRpc;
connectionRpc.addResponse("getinfo", json{
{"version", 120001},
{"protocolversion", 170003},
{"p2pport", 21769},
{"longestchain", 250},
{"blocks", 240},
{"notarized", 230}
});
connectionRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
auto connection = Refresh::collectConnectionInitResult(connectionRpc);
EXPECT_TRUE(connectionRpc.methodNames() == std::vector<std::string>({
"getinfo", "getwalletinfo"
}));
EXPECT_EQ(connectionRpc.calls[0].params, json::array());
EXPECT_EQ(connectionRpc.calls[1].params, json::array());
EXPECT_TRUE(connection.info.ok);
EXPECT_EQ(*connection.info.daemonVersion, 120001);
EXPECT_TRUE(connection.encryption.ok);
EXPECT_TRUE(connection.encryption.encrypted);
EXPECT_EQ(connection.encryption.unlockedUntil, static_cast<int64_t>(0));
MockRefreshRpc connectionPrefetchRpc;
connectionPrefetchRpc.addFailure("getinfo", "daemon info unavailable");
connectionPrefetchRpc.addResponse("getwalletinfo", json{{"unlocked_until", 99}});
auto prefetch = Refresh::collectConnectionInitResult(connectionPrefetchRpc);
EXPECT_TRUE(connectionPrefetchRpc.methodNames() == std::vector<std::string>({
"getinfo", "getwalletinfo"
}));
EXPECT_FALSE(prefetch.info.ok);
EXPECT_TRUE(prefetch.encryption.ok);
EXPECT_TRUE(prefetch.encryption.encrypted);
EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast<int64_t>(99));
MockRefreshRpc coreRpc;
coreRpc.addResponse("z_gettotalbalance", json{
{"private", "3.00000000"},
{"transparent", "1.25000000"},
{"total", "4.25000000"}
});
coreRpc.addResponse("getblockchaininfo", json{
{"blocks", 150},
{"headers", 155},
{"verificationprogress", 0.80},
{"longestchain", 160},
{"notarized", 145}
});
auto core = Refresh::collectCoreRefreshResult(coreRpc);
EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({
"z_gettotalbalance", "getblockchaininfo"
}));
EXPECT_EQ(coreRpc.calls[0].params, json::array());
EXPECT_EQ(coreRpc.calls[1].params, json::array());
EXPECT_TRUE(core.balanceOk);
EXPECT_TRUE(core.blockchainOk);
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
EXPECT_EQ(*core.blocks, 150);
EXPECT_EQ(*core.longestChain, 160);
MockRefreshRpc coreFallbackRpc;
coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up");
coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
"z_gettotalbalance", "getblockchaininfo"
}));
EXPECT_FALSE(partialCore.balanceOk);
EXPECT_TRUE(partialCore.blockchainOk);
EXPECT_EQ(*partialCore.blocks, 8);
MockRefreshRpc peerRpc;
peerRpc.addResponse("getpeerinfo", json::array({
json{{"id", 42}, {"addr", "203.0.113.7:21769"}, {"tls_verified", true}}
}));
peerRpc.addResponse("listbanned", json::array({
json{{"address", "198.51.100.9"}, {"banned_until", 4444}}
}));
auto peers = Refresh::collectPeerRefreshResult(peerRpc);
EXPECT_TRUE(peerRpc.methodNames() == std::vector<std::string>({
"getpeerinfo", "listbanned"
}));
EXPECT_EQ(peerRpc.calls[0].params, json::array());
EXPECT_EQ(peerRpc.calls[1].params, json::array());
EXPECT_EQ(peers.peers.size(), static_cast<size_t>(1));
EXPECT_EQ(peers.peers[0].id, 42);
EXPECT_TRUE(peers.peers[0].tls_verified);
EXPECT_EQ(peers.bannedPeers.size(), static_cast<size_t>(1));
EXPECT_EQ(peers.bannedPeers[0].address, std::string("198.51.100.9"));
MockRefreshRpc peerPartialRpc;
peerPartialRpc.addFailure("getpeerinfo", "peer table unavailable");
peerPartialRpc.addResponse("listbanned", json::array({
json{{"address", "203.0.113.8"}, {"banned_until", 5555}}
}));
auto partialPeers = Refresh::collectPeerRefreshResult(peerPartialRpc);
EXPECT_TRUE(peerPartialRpc.methodNames() == std::vector<std::string>({
"getpeerinfo", "listbanned"
}));
EXPECT_EQ(partialPeers.peers.size(), static_cast<size_t>(0));
EXPECT_EQ(partialPeers.bannedPeers.size(), static_cast<size_t>(1));
MockRefreshRpc miningSlowRpc;
miningSlowRpc.addResponse("getlocalsolps", json(72.5));
miningSlowRpc.addResponse("getmininginfo", json{
{"generate", true},
{"genproclimit", 6},
{"blocks", 321},
{"difficulty", 19.75},
{"networkhashps", 1250.0},
{"chain", "main"}
});
auto miningSlow = Refresh::collectMiningRefreshResult(miningSlowRpc, 128.0, true);
EXPECT_TRUE(miningSlowRpc.methodNames() == std::vector<std::string>({
"getlocalsolps", "getmininginfo"
}));
EXPECT_EQ(miningSlowRpc.calls[0].params, json::array());
EXPECT_EQ(miningSlowRpc.calls[1].params, json::array());
EXPECT_NEAR(*miningSlow.localHashrate, 72.5, 0.00000001);
EXPECT_TRUE(miningSlow.miningOk);
EXPECT_TRUE(*miningSlow.generate);
EXPECT_EQ(*miningSlow.genproclimit, 6);
EXPECT_NEAR(miningSlow.daemonMemoryMb, 128.0, 0.00000001);
MockRefreshRpc miningFastRpc;
miningFastRpc.addResponse("getlocalsolps", json(80.25));
auto miningFast = Refresh::collectMiningRefreshResult(miningFastRpc, 64.0, false);
EXPECT_TRUE(miningFastRpc.methodNames() == std::vector<std::string>({
"getlocalsolps"
}));
EXPECT_NEAR(*miningFast.localHashrate, 80.25, 0.00000001);
EXPECT_FALSE(miningFast.miningOk);
EXPECT_NEAR(miningFast.daemonMemoryMb, 64.0, 0.00000001);
MockRefreshRpc miningPartialRpc;
miningPartialRpc.addFailure("getlocalsolps", "local solps unavailable");
miningPartialRpc.addResponse("getmininginfo", json{{"generate", false}, {"blocks", 7}});
auto miningPartial = Refresh::collectMiningRefreshResult(miningPartialRpc, 12.0, true);
EXPECT_TRUE(miningPartialRpc.methodNames() == std::vector<std::string>({
"getlocalsolps", "getmininginfo"
}));
EXPECT_FALSE(miningPartial.localHashrate.has_value());
EXPECT_TRUE(miningPartial.miningOk);
EXPECT_FALSE(*miningPartial.generate);
MockRefreshRpc addressRpc;
addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"}));
addressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
addressRpc.addResponse("z_validateaddress", json{{"ismine", false}});
addressRpc.addResponse("z_listunspent", json::array({
json{{"address", "zs-one"}, {"amount", 1.0}},
json{{"address", "zs-one"}, {"amount", 0.5}}
}));
addressRpc.addResponse("getaddressesbyaccount", json::array({"R-one"}));
addressRpc.addResponse("listunspent", json::array({
json{{"address", "R-one"}, {"amount", 2.25}}
}));
auto addresses = Refresh::collectAddressRefreshResult(addressRpc);
EXPECT_TRUE(addressRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_validateaddress",
"z_listunspent", "getaddressesbyaccount", "listunspent"
}));
EXPECT_EQ(addressRpc.calls[1].params, json::array({"zs-one"}));
EXPECT_EQ(addressRpc.calls[2].params, json::array({"zs-two"}));
EXPECT_EQ(addressRpc.calls[4].params, json::array({""}));
EXPECT_EQ(addresses.shieldedAddresses.size(), static_cast<size_t>(2));
EXPECT_TRUE(addresses.shieldedAddresses[0].has_spending_key);
EXPECT_FALSE(addresses.shieldedAddresses[1].has_spending_key);
EXPECT_NEAR(addresses.shieldedAddresses[0].balance, 1.5, 0.00000001);
EXPECT_EQ(addresses.transparentAddresses.size(), static_cast<size_t>(1));
EXPECT_NEAR(addresses.transparentAddresses[0].balance, 2.25, 0.00000001);
MockRefreshRpc fallbackRpc;
fallbackRpc.addResponse("z_listaddresses", json::array({"zs-fallback"}));
fallbackRpc.addFailure("z_validateaddress", "legacy daemon");
fallbackRpc.addFailure("z_listunspent", "method not found");
fallbackRpc.addResponse("z_getbalance", json(4.75));
fallbackRpc.addResponse("getaddressesbyaccount", json::array());
fallbackRpc.addResponse("listunspent", json::array());
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"z_getbalance", "getaddressesbyaccount", "listunspent"
}));
EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001);
Refresh::TransactionRefreshSnapshot snapshot;
snapshot.shieldedAddresses = {"zs-one", "zs-two"};
snapshot.sendTxids = {"cached-send", "pending-send"};
snapshot.fullyEnrichedTxids = {"shielded-receive", "transparent-a"};
Refresh::TransactionViewCacheEntry cachedEntry;
cachedEntry.from_address = "zs-cache-from";
cachedEntry.outgoing_outputs.push_back({"zs-cache-dest", 0.20, "cached memo"});
snapshot.viewTxCache["cached-send"] = cachedEntry;
MockRefreshRpc transactionRpc;
transactionRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.0},
{"time", 100}, {"confirmations", 3}, {"address", "R-one"}}
}));
transactionRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "shielded-receive"}, {"amount", 2.0}, {"confirmations", 4},
{"time", 50}, {"memoStr", "shielded memo"}}
}));
transactionRpc.addResponse("z_listreceivedbyaddress", json::array());
transactionRpc.addResponse("z_viewtransaction", json{
{"spends", json::array({json{{"address", "zs-from"}}})},
{"outputs", json::array({
json{{"outgoing", true}, {"address", "zs-dest"}, {"value", 0.40}, {"memoStr", "fresh memo"}}
})}
});
transactionRpc.addResponse("gettransaction", json{{"time", 500}, {"confirmations", 1}});
auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4);
EXPECT_TRUE(transactionRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress",
"z_viewtransaction", "gettransaction"
}));
EXPECT_EQ(transactionRpc.calls[1].params, json::array({"zs-one", 0}));
EXPECT_EQ(transactionRpc.calls[2].params, json::array({"zs-two", 0}));
EXPECT_EQ(transactionRpc.calls[3].params, json::array({"pending-send"}));
EXPECT_EQ(transactionRpc.calls[4].params, json::array({"pending-send"}));
EXPECT_EQ(transactionResult.blockHeight, 321);
EXPECT_EQ(transactionResult.newViewTxEntries.size(), static_cast<size_t>(1));
EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast<size_t>(1));
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").timestamp, static_cast<int64_t>(500));
EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 1);
EXPECT_EQ(transactionResult.transactions.size(), static_cast<size_t>(4));
EXPECT_EQ(transactionResult.transactions.front().txid, std::string("pending-send"));
EXPECT_EQ(transactionResult.transactions.front().type, std::string("send"));
EXPECT_NEAR(transactionResult.transactions.front().amount, -0.40, 0.00000001);
EXPECT_EQ(transactionResult.transactions.front().timestamp, static_cast<int64_t>(500));
EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a"));
Refresh::TransactionRefreshSnapshot cachedOnlySnapshot;
auto cachedOnlyEntry = cachedEntry;
cachedOnlyEntry.timestamp = 450;
cachedOnlyEntry.confirmations = 6;
cachedOnlySnapshot.viewTxCache["cached-only-send"] = cachedOnlyEntry;
cachedOnlySnapshot.fullyEnrichedTxids = {"cached-only-send"};
MockRefreshRpc cachedOnlyRpc;
cachedOnlyRpc.addResponse("listtransactions", json::array());
auto cachedOnlyResult = Refresh::collectTransactionRefreshResult(cachedOnlyRpc, cachedOnlySnapshot, 322, 4);
EXPECT_TRUE(cachedOnlyRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(cachedOnlyResult.transactions[0].txid, std::string("cached-only-send"));
EXPECT_EQ(cachedOnlyResult.transactions[0].type, std::string("send"));
EXPECT_NEAR(cachedOnlyResult.transactions[0].amount, -0.20, 0.00000001);
EXPECT_EQ(cachedOnlyResult.transactions[0].timestamp, static_cast<int64_t>(450));
EXPECT_EQ(cachedOnlyResult.transactions[0].confirmations, 6);
Refresh::TransactionRefreshSnapshot retrySnapshot;
retrySnapshot.sendTxids = {"retry-send"};
retrySnapshot.viewTxCache["retry-send"] = Refresh::TransactionViewCacheEntry{};
retrySnapshot.fullyEnrichedTxids = {"retry-send"};
MockRefreshRpc retryRpc;
retryRpc.addResponse("listtransactions", json::array());
retryRpc.addResponse("z_viewtransaction", json{
{"spends", json::array({json{{"address", "zs-retry-from"}}})},
{"outputs", json::array({
json{{"outgoing", true}, {"address", "zs-retry-dest"}, {"value", 0.55}}
})}
});
retryRpc.addResponse("gettransaction", json{{"time", 650}, {"confirmations", 2}});
auto retryResult = Refresh::collectTransactionRefreshResult(retryRpc, retrySnapshot, 323, 4);
EXPECT_TRUE(retryRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_viewtransaction", "gettransaction"
}));
EXPECT_EQ(retryResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(retryResult.transactions[0].txid, std::string("retry-send"));
EXPECT_NEAR(retryResult.transactions[0].amount, -0.55, 0.00000001);
EXPECT_EQ(retryResult.newViewTxEntries.count("retry-send"), static_cast<size_t>(1));
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast<int64_t>(650));
EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").confirmations, 2);
Refresh::TransactionRefreshSnapshot placeholderSnapshot;
placeholderSnapshot.sendTxids = {"placeholder-send"};
MockRefreshRpc placeholderRpc;
placeholderRpc.addResponse("listtransactions", json::array());
placeholderRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}});
placeholderRpc.addResponse("gettransaction", json{
{"time", 700},
{"confirmations", 0},
{"amount", -0.33},
{"details", json::array({
json{{"category", "send"}, {"address", "zs-placeholder-dest"}, {"amount", -0.33}}
})}
});
auto placeholderResult = Refresh::collectTransactionRefreshResult(placeholderRpc, placeholderSnapshot, 324, 4);
EXPECT_TRUE(placeholderRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_viewtransaction", "gettransaction"
}));
EXPECT_EQ(placeholderResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(placeholderResult.transactions[0].txid, std::string("placeholder-send"));
EXPECT_EQ(placeholderResult.transactions[0].type, std::string("send"));
EXPECT_NEAR(placeholderResult.transactions[0].amount, -0.33, 0.00000001);
EXPECT_EQ(placeholderResult.transactions[0].timestamp, static_cast<int64_t>(700));
EXPECT_EQ(placeholderResult.transactions[0].confirmations, 0);
EXPECT_EQ(placeholderResult.transactions[0].address, std::string("zs-placeholder-dest"));
EXPECT_EQ(placeholderResult.newViewTxEntries.count("placeholder-send"), static_cast<size_t>(0));
}
void testNetworkRefreshResultModels()
{
using Refresh = dragonx::services::NetworkRefreshService;
using nlohmann::json;
dragonx::WalletState state;
auto core = Refresh::parseCoreRefreshResult(
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}},
true,
json{{"blocks", 100}, {"headers", 105}, {"verificationprogress", 0.75},
{"longestchain", 110}, {"notarized", 90}},
true);
Refresh::applyCoreRefreshResult(state, core, 1234);
EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001);
EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001);
EXPECT_NEAR(state.total_balance, 1.75, 0.00000001);
EXPECT_EQ(state.sync.blocks, 100);
EXPECT_EQ(state.sync.headers, 105);
EXPECT_TRUE(state.sync.syncing);
EXPECT_EQ(state.longestchain, 110);
EXPECT_EQ(state.notarized, 90);
EXPECT_EQ(state.last_balance_update, static_cast<int64_t>(1234));
auto connectionInfo = Refresh::parseConnectionInfoResult(
json{{"version", 120000}, {"protocolversion", 170002}, {"p2pport", 8233},
{"longestchain", 0}, {"blocks", 120}, {"notarized", 118}});
Refresh::applyConnectionInfoResult(state, connectionInfo);
EXPECT_EQ(state.daemon_version, 120000);
EXPECT_EQ(state.protocol_version, 170002);
EXPECT_EQ(state.p2p_port, 8233);
EXPECT_EQ(state.sync.blocks, 120);
EXPECT_EQ(state.longestchain, 120);
EXPECT_EQ(state.notarized, 118);
auto encrypted = Refresh::parseWalletEncryptionResult(json{{"unlocked_until", 0}});
Refresh::applyWalletEncryptionResult(state, encrypted);
EXPECT_TRUE(state.encrypted);
EXPECT_TRUE(state.locked);
EXPECT_TRUE(state.encryption_state_known);
auto unencrypted = Refresh::parseWalletEncryptionResult(json::object());
Refresh::applyWalletEncryptionResult(state, unencrypted);
EXPECT_FALSE(state.encrypted);
EXPECT_FALSE(state.locked);
EXPECT_EQ(state.unlocked_until, static_cast<int64_t>(0));
auto mining = Refresh::parseMiningRefreshResult(
json{{"generate", true}, {"genproclimit", 4}, {"blocks", 101},
{"difficulty", 12.5}, {"networkhashps", 900.0}, {"chain", "main"}},
true,
json(45.0),
true,
64.0);
Refresh::applyMiningRefreshResult(state, mining, 2345);
EXPECT_TRUE(state.mining.generate);
EXPECT_EQ(state.mining.genproclimit, 4);
EXPECT_NEAR(state.mining.localHashrate, 45.0, 0.0001);
EXPECT_EQ(state.mining.hashrate_history.size(), static_cast<size_t>(1));
EXPECT_NEAR(state.mining.networkHashrate, 900.0, 0.0001);
EXPECT_NEAR(state.mining.daemon_memory_mb, 64.0, 0.0001);
EXPECT_EQ(state.last_mining_update, static_cast<int64_t>(2345));
auto peers = Refresh::parsePeerRefreshResult(
json::array({json{{"id", 7}, {"addr", "127.0.0.1:8233"}, {"tls_verified", true}}}),
json::array({json{{"address", "192.0.2.1"}, {"banned_until", 3456}}}));
Refresh::applyPeerRefreshResult(state, std::move(peers), 3456);
EXPECT_EQ(state.peers.size(), static_cast<size_t>(1));
EXPECT_EQ(state.peers[0].id, 7);
EXPECT_TRUE(state.peers[0].tls_verified);
EXPECT_EQ(state.bannedPeers.size(), static_cast<size_t>(1));
EXPECT_EQ(state.bannedPeers[0].address, std::string("192.0.2.1"));
EXPECT_EQ(state.last_peer_update, static_cast<int64_t>(3456));
auto price = Refresh::parseCoinGeckoPriceResponse(
R"({"dragonx-2":{"usd":0.5,"btc":0.00001,"usd_24h_change":2.5,"usd_24h_vol":1000,"usd_market_cap":50000}})",
0);
EXPECT_TRUE(price.has_value());
if (price) {
Refresh::markPriceRefreshStarted(state);
EXPECT_TRUE(state.market.price_loading);
Refresh::applyPriceRefreshResult(state, *price, std::chrono::steady_clock::now());
EXPECT_FALSE(state.market.price_loading);
EXPECT_EQ(state.market.price_error, std::string());
EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);
EXPECT_NEAR(state.market.price_btc, 0.00001, 0.00000001);
EXPECT_NEAR(state.market.change_24h, 2.5, 0.0001);
EXPECT_EQ(state.market.price_history.size(), static_cast<size_t>(1));
}
Refresh::markPriceRefreshStarted(state);
Refresh::applyPriceRefreshFailure(state, "timeout");
EXPECT_FALSE(state.market.price_loading);
EXPECT_EQ(state.market.price_error, std::string("timeout"));
EXPECT_NEAR(state.market.price_usd, 0.5, 0.00000001);
Refresh::PriceHttpResponse priceHttpOk;
priceHttpOk.transportOk = true;
priceHttpOk.httpStatus = 200;
priceHttpOk.body = R"({"dragonx-2":{"usd":0.75,"btc":0.00002,"usd_24h_change":1.5,"usd_24h_vol":2000,"usd_market_cap":75000}})";
auto priceHttp = Refresh::parsePriceHttpResponse(priceHttpOk, 0);
EXPECT_TRUE(priceHttp.price.has_value());
if (priceHttp.price) {
EXPECT_NEAR(priceHttp.price->market.price_usd, 0.75, 0.00000001);
EXPECT_NEAR(priceHttp.price->market.price_btc, 0.00002, 0.00000001);
}
EXPECT_EQ(priceHttp.errorMessage, std::string());
Refresh::PriceHttpResponse priceHttpStatus;
priceHttpStatus.transportOk = true;
priceHttpStatus.httpStatus = 429;
auto priceHttpStatusResult = Refresh::parsePriceHttpResponse(priceHttpStatus, 0);
EXPECT_FALSE(priceHttpStatusResult.price.has_value());
EXPECT_EQ(priceHttpStatusResult.errorMessage, std::string("Price fetch failed: OK (HTTP 429)"));
Refresh::PriceHttpResponse priceHttpTransport;
priceHttpTransport.transportOk = false;
priceHttpTransport.httpStatus = 0;
priceHttpTransport.transportError = "timeout";
auto priceHttpTransportResult = Refresh::parsePriceHttpResponse(priceHttpTransport, 0);
EXPECT_FALSE(priceHttpTransportResult.price.has_value());
EXPECT_EQ(priceHttpTransportResult.errorMessage, std::string("Price fetch failed: timeout (HTTP 0)"));
Refresh::PriceHttpResponse priceHttpParse;
priceHttpParse.transportOk = true;
priceHttpParse.httpStatus = 200;
priceHttpParse.body = R"({"other-coin":{"usd":1.0}})";
auto priceHttpParseResult = Refresh::parsePriceHttpResponse(priceHttpParse, 0);
EXPECT_FALSE(priceHttpParseResult.price.has_value());
EXPECT_EQ(priceHttpParseResult.errorMessage, std::string("Price fetch returned an unrecognized response"));
Refresh::AddressRefreshResult addresses;
dragonx::AddressInfo zAddr;
zAddr.address = "zs-refresh";
zAddr.type = "shielded";
zAddr.balance = 2.0;
zAddr.has_spending_key = false;
dragonx::AddressInfo tAddr;
tAddr.address = "R-refresh";
tAddr.type = "transparent";
tAddr.balance = 3.0;
addresses.shieldedAddresses.push_back(zAddr);
addresses.transparentAddresses.push_back(tAddr);
Refresh::applyAddressRefreshResult(state, std::move(addresses));
EXPECT_EQ(state.z_addresses.size(), static_cast<size_t>(1));
EXPECT_EQ(state.t_addresses.size(), static_cast<size_t>(1));
EXPECT_EQ(state.z_addresses[0].address, std::string("zs-refresh"));
EXPECT_FALSE(state.z_addresses[0].has_spending_key);
auto viewEntry = Refresh::parseViewTransactionCacheEntry(json{
{"spends", json::array({json{{"address", "zs-from"}}})},
{"outputs", json::array({
json{{"outgoing", true}, {"address", "zs-destination"}, {"value", 0.75}, {"memoStr", "memo"}},
json{{"outgoing", false}, {"address", "zs-change"}, {"value", 0.25}}
})}
});
EXPECT_EQ(viewEntry.from_address, std::string("zs-from"));
EXPECT_EQ(viewEntry.outgoing_outputs.size(), static_cast<size_t>(1));
EXPECT_EQ(viewEntry.outgoing_outputs[0].address, std::string("zs-destination"));
std::vector<dragonx::TransactionInfo> enrichedTxs;
dragonx::TransactionInfo baseTx;
baseTx.txid = "shielded-send";
baseTx.type = "receive";
baseTx.confirmations = 7;
baseTx.timestamp = 111;
enrichedTxs.push_back(baseTx);
Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
Refresh::appendViewTransactionOutputs(enrichedTxs, "shielded-send", viewEntry);
EXPECT_EQ(enrichedTxs.size(), static_cast<size_t>(2));
EXPECT_EQ(enrichedTxs[1].type, std::string("send"));
EXPECT_EQ(enrichedTxs[1].from_address, std::string("zs-from"));
EXPECT_NEAR(enrichedTxs[1].amount, -0.75, 0.00000001);
EXPECT_EQ(enrichedTxs[1].confirmations, 7);
EXPECT_EQ(enrichedTxs[1].timestamp, static_cast<int64_t>(111));
Refresh::TransactionRefreshResult transactionResult;
transactionResult.blockHeight = 222;
dragonx::TransactionInfo confirmedTx;
confirmedTx.txid = "confirmed";
confirmedTx.type = "receive";
confirmedTx.confirmations = 12;
confirmedTx.timestamp = 1000;
dragonx::TransactionInfo pendingTx;
pendingTx.txid = "pending";
pendingTx.type = "receive";
pendingTx.confirmations = 2;
pendingTx.timestamp = 1001;
transactionResult.transactions = {pendingTx, confirmedTx};
transactionResult.newViewTxEntries["shielded-send"] = viewEntry;
transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{};
Refresh::TransactionViewCache viewCache;
std::unordered_set<std::string> sendTxids{"shielded-send", "pending", "pending-empty"};
std::vector<dragonx::TransactionInfo> confirmedCache;
std::unordered_set<std::string> confirmedIds;
int confirmedBlock = -1;
int lastTxBlock = -1;
Refresh::TransactionCacheUpdate cacheUpdate{
viewCache,
sendTxids,
confirmedCache,
confirmedIds,
confirmedBlock,
lastTxBlock
};
Refresh::applyTransactionRefreshResult(state, cacheUpdate, std::move(transactionResult), 4567);
EXPECT_EQ(state.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(state.last_tx_update, static_cast<int64_t>(4567));
EXPECT_EQ(lastTxBlock, 222);
EXPECT_EQ(viewCache.size(), static_cast<size_t>(2));
EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
EXPECT_EQ(sendTxids.count("pending"), static_cast<size_t>(1));
EXPECT_EQ(sendTxids.count("pending-empty"), static_cast<size_t>(1));
EXPECT_EQ(confirmedCache.size(), static_cast<size_t>(1));
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
EXPECT_EQ(confirmedBlock, 222);
}
void testWalletSecurityController()
{
using dragonx::services::WalletSecurityController;
using PinError = WalletSecurityController::PinValidationError;
WalletSecurityController controller;
EXPECT_FALSE(controller.hasDeferredEncryption());
controller.beginDeferredEncryption("secret-passphrase", "1234");
EXPECT_TRUE(controller.hasDeferredEncryption());
auto snapshot = controller.deferredEncryption();
EXPECT_EQ(snapshot.passphrase, std::string("secret-passphrase"));
EXPECT_EQ(snapshot.pin, std::string("1234"));
EXPECT_TRUE(controller.shouldAttemptDeferredConnect(0.0));
EXPECT_FALSE(controller.shouldAttemptDeferredConnect(1.0));
EXPECT_TRUE(controller.shouldAttemptDeferredConnect(3.1));
controller.clearDeferredEncryption();
EXPECT_FALSE(controller.hasDeferredEncryption());
EXPECT_FALSE(controller.shouldAttemptDeferredConnect(10.0));
auto valid = WalletSecurityController::validatePinSetup("1234", "1234");
EXPECT_TRUE(valid.ok);
auto emptyAllowed = WalletSecurityController::validatePinSetup("", "", true);
EXPECT_TRUE(emptyAllowed.ok);
auto mismatch = WalletSecurityController::validatePinSetup("1234", "4321");
EXPECT_EQ(mismatch.error, PinError::Mismatch);
auto shortPin = WalletSecurityController::validatePinSetup("123", "123");
EXPECT_EQ(shortPin.error, PinError::TooShort);
auto nondigit = WalletSecurityController::validatePinSetup("12a4", "12a4");
EXPECT_EQ(nondigit.error, PinError::NonDigit);
EXPECT_EQ(WalletSecurityController::classifyAddress("zs123"), WalletSecurityController::KeyKind::Shielded);
EXPECT_EQ(WalletSecurityController::classifyAddress("R123"), WalletSecurityController::KeyKind::Transparent);
EXPECT_EQ(WalletSecurityController::classifyPrivateKey("secret-key"), WalletSecurityController::KeyKind::Shielded);
EXPECT_EQ(WalletSecurityController::classifyPrivateKey("Kx123"), WalletSecurityController::KeyKind::Transparent);
EXPECT_EQ(std::string(WalletSecurityController::importSuccessMessage(WalletSecurityController::KeyKind::Shielded)),
std::string("Z-address key imported successfully. Wallet is rescanning."));
EXPECT_EQ(WalletSecurityController::decryptExportFileName(42), std::string("obsidiandecryptexport42"));
MockWalletSecurityRpc rpc;
MockWalletSecurityVault vault;
auto encrypted = controller.runDeferredEncryption({"passphrase", "9876"}, rpc, &vault);
EXPECT_TRUE(encrypted.encrypted);
EXPECT_TRUE(encrypted.pinProvided);
EXPECT_TRUE(encrypted.pinStored);
EXPECT_TRUE(encrypted.restartRequired);
EXPECT_EQ(rpc.encryptCalls, 1);
EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase"));
EXPECT_EQ(vault.storeCalls, 1);
EXPECT_EQ(vault.lastPin, std::string("9876"));
EXPECT_EQ(vault.lastPassphrase, std::string("passphrase"));
MockWalletSecurityRpc failingRpc;
MockWalletSecurityVault unusedVault;
failingRpc.encryptResult = false;
failingRpc.encryptError = "rpc unavailable";
auto failed = controller.runDeferredEncryption({"bad-passphrase", "1111"}, failingRpc, &unusedVault);
EXPECT_FALSE(failed.encrypted);
EXPECT_TRUE(failed.pinProvided);
EXPECT_FALSE(failed.pinStored);
EXPECT_FALSE(failed.restartRequired);
EXPECT_EQ(failed.error, std::string("rpc unavailable"));
EXPECT_EQ(unusedVault.storeCalls, 0);
}
void testWalletSecurityWorkflow()
{
using dragonx::services::WalletSecurityWorkflow;
using DecryptPhase = WalletSecurityWorkflow::DecryptPhase;
using DecryptStep = WalletSecurityWorkflow::DecryptStep;
WalletSecurityWorkflow workflow;
auto start = std::chrono::steady_clock::now();
EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry);
EXPECT_TRUE(workflow.canClose());
workflow.start(start);
EXPECT_EQ(workflow.phase(), DecryptPhase::Working);
EXPECT_EQ(workflow.step(), DecryptStep::Unlock);
EXPECT_FALSE(workflow.canClose());
EXPECT_TRUE(workflow.inProgress());
workflow.advanceTo(DecryptStep::BackupWallet,
WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
start + std::chrono::seconds(2));
EXPECT_EQ(workflow.step(), DecryptStep::BackupWallet);
EXPECT_EQ(workflow.status(), std::string("Backing up encrypted wallet..."));
EXPECT_TRUE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::StopDaemon));
EXPECT_FALSE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::RestartDaemon));
workflow.closeDialogForImport();
EXPECT_FALSE(workflow.inProgress());
EXPECT_TRUE(workflow.importActive());
workflow.finishImport();
EXPECT_FALSE(workflow.importActive());
workflow.fail("Daemon failed to restart");
EXPECT_EQ(workflow.phase(), DecryptPhase::Error);
EXPECT_TRUE(workflow.canClose());
EXPECT_EQ(workflow.status(), std::string("Daemon failed to restart"));
workflow.failEntry("Incorrect passphrase");
EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry);
EXPECT_FALSE(workflow.inProgress());
EXPECT_EQ(workflow.status(), std::string("Incorrect passphrase"));
auto plan = WalletSecurityWorkflow::planWalletFiles("/tmp/dragonx/", 1234);
EXPECT_EQ(plan.exportFile, std::string("obsidiandecryptexport1234"));
EXPECT_EQ(plan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport1234"));
EXPECT_EQ(plan.walletPath, std::string("/tmp/dragonx/wallet.dat"));
EXPECT_EQ(plan.backupPath, std::string("/tmp/dragonx/wallet.dat.encrypted.bak"));
}
void testWalletSecurityWorkflowExecutor()
{
using Executor = dragonx::services::WalletSecurityWorkflowExecutor;
MockWorkflowRpc rpc;
auto unlock = Executor::unlockWallet("passphrase", rpc);
EXPECT_TRUE(unlock.ok);
EXPECT_EQ(rpc.unlockCalls, 1);
EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase"));
rpc.unlockResult = false;
rpc.unlockError = "bad passphrase";
auto failedUnlock = Executor::unlockWallet("bad", rpc);
EXPECT_FALSE(failedUnlock.ok);
EXPECT_TRUE(failedUnlock.passphraseRejected);
EXPECT_EQ(failedUnlock.error, std::string("bad passphrase"));
MockWorkflowFiles files;
auto exported = Executor::exportWallet(rpc, files, 77);
EXPECT_TRUE(exported.ok);
EXPECT_EQ(exported.filePlan.exportFile, std::string("obsidiandecryptexport77"));
EXPECT_EQ(exported.filePlan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport77"));
EXPECT_EQ(rpc.lastExportFile, std::string("obsidiandecryptexport77"));
rpc.exportResult = false;
rpc.exportError = "disk full";
auto failedExport = Executor::exportWallet(rpc, files, 78);
EXPECT_FALSE(failedExport.ok);
EXPECT_EQ(failedExport.error, std::string("Export failed: disk full"));
rpc.stopResult = false;
auto stop = Executor::stopDaemon(rpc);
EXPECT_TRUE(stop.ok);
EXPECT_EQ(rpc.stopCalls, 1);
auto backup = Executor::backupEncryptedWallet(files, exported.filePlan);
EXPECT_TRUE(backup.ok);
EXPECT_EQ(files.backupCalls, 1);
files.backupResult = false;
files.backupError = "permission denied";
auto failedBackup = Executor::backupEncryptedWallet(files, exported.filePlan);
EXPECT_FALSE(failedBackup.ok);
EXPECT_EQ(failedBackup.error, std::string("Failed to rename wallet.dat: permission denied"));
MockWorkflowDaemon daemon;
rpc.probeCalls = 0;
rpc.probeSuccessOnCall = 3;
auto restart = Executor::restartDaemonAndWait(daemon, rpc, 200, 100, 5);
EXPECT_TRUE(restart.ok);
EXPECT_EQ(daemon.stopCalls, 1);
EXPECT_EQ(daemon.startCalls, 1);
EXPECT_EQ(rpc.probeCalls, 3);
MockWorkflowDaemon cancelledDaemon;
cancelledDaemon.cancelAfterSleeps = 1;
rpc.probeCalls = 0;
auto cancelled = Executor::restartDaemonAndWait(cancelledDaemon, rpc, 200, 100, 5);
EXPECT_FALSE(cancelled.ok);
EXPECT_TRUE(cancelled.error.empty());
MockWorkflowDaemon failedDaemon;
rpc.probeCalls = 0;
rpc.probeSuccessOnCall = 99;
auto failedRestart = Executor::restartDaemonAndWait(failedDaemon, rpc, 0, 0, 2);
EXPECT_FALSE(failedRestart.ok);
EXPECT_EQ(failedRestart.error, std::string("Daemon failed to restart"));
MockWorkflowImporter importer;
auto imported = Executor::importWallet(importer, exported.filePlan.exportPath);
EXPECT_TRUE(imported.ok);
EXPECT_EQ(importer.importCalls, 1);
EXPECT_EQ(importer.lastExportPath, exported.filePlan.exportPath);
importer.result = false;
importer.importError = "rescan failed";
auto failedImport = Executor::importWallet(importer, exported.filePlan.exportPath);
EXPECT_FALSE(failedImport.ok);
EXPECT_EQ(failedImport.error, std::string("Key import failed: rescan failed"));
int cleanupCalls = 0;
Executor::cleanupVaultAndPin([&]() { ++cleanupCalls; });
EXPECT_EQ(cleanupCalls, 1);
}
void testDaemonShutdownPolicy()
{
using dragonx::daemon::DaemonController;
using ShutdownAction = DaemonController::ShutdownAction;
auto noDaemon = DaemonController::evaluateShutdownPolicy(false, false, false, false);
EXPECT_EQ(noDaemon.action, ShutdownAction::DisconnectOnly);
auto keepRunning = DaemonController::evaluateShutdownPolicy(true, false, true, false);
EXPECT_EQ(keepRunning.action, ShutdownAction::DisconnectOnly);
EXPECT_EQ(std::string(keepRunning.logReason), std::string("keep_daemon_running enabled"));
auto externalPreserved = DaemonController::evaluateShutdownPolicy(true, true, false, false);
EXPECT_EQ(externalPreserved.action, ShutdownAction::DisconnectOnly);
EXPECT_EQ(std::string(externalPreserved.logReason), std::string("external daemon (not ours to stop)"));
auto externalStopAllowed = DaemonController::evaluateShutdownPolicy(true, true, false, true);
EXPECT_EQ(externalStopAllowed.action, ShutdownAction::StopDaemon);
auto ownedDaemon = DaemonController::evaluateShutdownPolicy(true, false, false, false);
EXPECT_EQ(ownedDaemon.action, ShutdownAction::StopDaemon);
auto restart = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::ManualRestart, true, true, true, false);
EXPECT_TRUE(restart.allowed);
EXPECT_TRUE(restart.wasRunning);
EXPECT_TRUE(restart.resetCrashCount);
EXPECT_TRUE(restart.disconnectRpc);
EXPECT_EQ(restart.restartDelayMs, 500);
EXPECT_EQ(std::string(restart.taskName), std::string("daemon-restart"));
auto blockedRestart = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::ManualRestart, true, true, true, true);
EXPECT_FALSE(blockedRestart.allowed);
auto externalRescan = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::Rescan, false, false, false, false);
EXPECT_FALSE(externalRescan.allowed);
EXPECT_EQ(std::string(externalRescan.warning),
std::string("Rescan requires embedded daemon. Restart your daemon with -rescan manually."));
auto deleteData = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
EXPECT_TRUE(deleteData.allowed);
EXPECT_FALSE(deleteData.wasRunning);
EXPECT_EQ(deleteData.restartDelayMs, 3000);
auto bootstrap = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::BootstrapStop, false, false, true, false);
EXPECT_TRUE(bootstrap.allowed);
EXPECT_TRUE(bootstrap.wasRunning);
EXPECT_TRUE(bootstrap.disconnectRpc);
}
void testDaemonLifecycleExecution()
{
using dragonx::daemon::DaemonController;
auto restart = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::ManualRestart, true, true, true, false);
MockDaemonLifecycleRuntime restartRuntime;
MockDaemonLifecycleTask restartTask;
auto restartResult = DaemonController::executeLifecycleOperation(restart, restartRuntime, restartTask);
EXPECT_TRUE(restartResult.completed);
EXPECT_TRUE(restartResult.stopped);
EXPECT_TRUE(restartResult.started);
EXPECT_EQ(restartTask.sleeps, 5);
EXPECT_TRUE(restartRuntime.calls == std::vector<std::string>({"stop", "start"}));
auto rescan = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::Rescan, true, true, true, false);
MockDaemonLifecycleRuntime rescanRuntime;
MockDaemonLifecycleTask rescanTask;
auto rescanResult = DaemonController::executeLifecycleOperation(rescan, rescanRuntime, rescanTask);
EXPECT_TRUE(rescanResult.completed);
EXPECT_EQ(rescanTask.sleeps, 30);
EXPECT_TRUE(rescanRuntime.calls == std::vector<std::string>({"stop", "reset-output", "start"}));
auto deleteData = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false);
MockDaemonLifecycleRuntime deleteRuntime;
MockDaemonLifecycleTask deleteTask;
auto deleteResult = DaemonController::executeLifecycleOperation(deleteData, deleteRuntime, deleteTask);
EXPECT_TRUE(deleteResult.completed);
EXPECT_EQ(deleteResult.deletedItems, 7);
EXPECT_TRUE(deleteRuntime.calls == std::vector<std::string>({"stop", "delete", "reset-output", "start"}));
auto bootstrap = DaemonController::evaluateLifecycleOperation(
DaemonController::LifecycleOperation::BootstrapStop, true, true, true, false);
MockDaemonLifecycleRuntime bootstrapRuntime;
MockDaemonLifecycleTask bootstrapTask;
auto bootstrapResult = DaemonController::executeLifecycleOperation(bootstrap, bootstrapRuntime, bootstrapTask);
EXPECT_TRUE(bootstrapResult.completed);
EXPECT_TRUE(bootstrapResult.stopped);
EXPECT_TRUE(bootstrapRuntime.calls == std::vector<std::string>({"rpc-stop-disconnect"}));
EXPECT_EQ(bootstrapRuntime.rpcStopContext, std::string("Bootstrap daemon stop"));
EXPECT_EQ(bootstrapRuntime.disconnectReason, std::string("Bootstrap"));
MockDaemonLifecycleRuntime cancelRuntime;
MockDaemonLifecycleTask cancelTask;
cancelTask.cancelAfterSleeps = 1;
auto cancelResult = DaemonController::executeLifecycleOperation(rescan, cancelRuntime, cancelTask);
EXPECT_TRUE(cancelResult.cancelled);
EXPECT_FALSE(cancelResult.started);
EXPECT_TRUE(cancelRuntime.calls == std::vector<std::string>({"stop"}));
MockDaemonLifecycleRuntime shutdownRuntime;
MockDaemonLifecycleTask shutdownTask;
shutdownTask.isShuttingDown = true;
auto shutdownResult = DaemonController::executeLifecycleOperation(restart, shutdownRuntime, shutdownTask);
EXPECT_TRUE(shutdownResult.cancelled);
EXPECT_TRUE(shutdownResult.stopped);
EXPECT_FALSE(shutdownResult.started);
EXPECT_TRUE(shutdownRuntime.calls == std::vector<std::string>({"stop"}));
}
void testDaemonLifecycleAdapters()
{
using dragonx::daemon::AsyncLifecycleTaskContext;
using dragonx::daemon::BlockchainDataCleaner;
using dragonx::daemon::ImmediateLifecycleTaskContext;
using dragonx::util::AsyncTaskManager;
auto cancelled = std::make_shared<std::atomic<bool>>(false);
std::atomic<bool> shuttingDown{false};
AsyncTaskManager::Token token(cancelled);
AsyncLifecycleTaskContext asyncContext(token, shuttingDown);
EXPECT_FALSE(asyncContext.cancelled());
EXPECT_FALSE(asyncContext.shuttingDown());
cancelled->store(true);
EXPECT_TRUE(asyncContext.cancelled());
shuttingDown.store(true);
EXPECT_TRUE(asyncContext.shuttingDown());
ImmediateLifecycleTaskContext immediateContext;
EXPECT_FALSE(immediateContext.cancelled());
EXPECT_FALSE(immediateContext.shuttingDown());
immediateContext.sleepForMs(50);
fs::path dataDir = makeTempDir();
fs::create_directories(dataDir / "blocks" / "nested");
fs::create_directories(dataDir / "chainstate");
fs::create_directories(dataDir / "database");
fs::create_directories(dataDir / "notarizations");
std::ofstream(dataDir / "blocks" / "nested" / "blk00000.dat") << "block";
std::ofstream(dataDir / "chainstate" / "state.dat") << "state";
std::ofstream(dataDir / "database" / "db.dat") << "db";
std::ofstream(dataDir / "notarizations" / "notary.dat") << "notary";
std::ofstream(dataDir / "peers.dat") << "peers";
std::ofstream(dataDir / "fee_estimates.dat") << "fees";
std::ofstream(dataDir / "wallet.dat") << "wallet";
int removed = BlockchainDataCleaner::removeBlockchainData(dataDir);
EXPECT_TRUE(removed >= 6);
EXPECT_FALSE(fs::exists(dataDir / "blocks"));
EXPECT_FALSE(fs::exists(dataDir / "chainstate"));
EXPECT_FALSE(fs::exists(dataDir / "database"));
EXPECT_FALSE(fs::exists(dataDir / "notarizations"));
EXPECT_FALSE(fs::exists(dataDir / "peers.dat"));
EXPECT_FALSE(fs::exists(dataDir / "fee_estimates.dat"));
EXPECT_TRUE(fs::exists(dataDir / "wallet.dat"));
fs::remove_all(dataDir);
}
void testRendererHelpers()
{
EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
43.0, 0.0001);
EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(200.0f, 50.0f, 120.0f, 0.75f),
150.0, 0.0001);
EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(120.0f, 80.0f, 70.0f, 0.5f),
70.0, 0.0001);
EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(40.0f, 8.0f), 50.0, 0.0001);
EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(200.0f, 12.0f), 176.0, 0.0001);
EXPECT_EQ(dragonx::ui::ClampMiningThreads(0, 8), 1);
EXPECT_EQ(dragonx::ui::ClampMiningThreads(12, 8), 8);
EXPECT_EQ(dragonx::ui::ClampMiningThreads(4, 8), 4);
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(true, true, false));
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, 10, 20};
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
filter.daemonMessagesEnabled = true;
filter.errorsOnly = true;
filter.text.clear();
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", 30, filter));
std::vector<dragonx::AddressInfo> poolAddresses;
poolAddresses.push_back({"R-transparent", 0.0, "transparent", true});
poolAddresses.push_back({"zs-default-worker", 0.0, "shielded", true});
EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("", false));
EXPECT_TRUE(dragonx::ui::shouldDefaultPoolWorker("x", false));
EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("zs-manual", false));
EXPECT_FALSE(dragonx::ui::shouldDefaultPoolWorker("", true));
EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker"));
EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b"));
EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, ""));
EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433"));
dragonx::TransactionInfo tx;
tx.type = "send";
tx.amount = -1.25;
tx.address = "zsabcdefghijklmnopqrstuvwxyz";
auto txDisplay = dragonx::ui::buildRecentTxDisplay(tx, 12);
EXPECT_EQ(txDisplay.typeText, std::string("Sent"));
EXPECT_EQ(txDisplay.addressText, std::string("zsab...wxyz"));
EXPECT_EQ(txDisplay.amountText, std::string("-1.2500 DRGX"));
EXPECT_EQ(txDisplay.timeText, std::string(""));
}
void testConsoleInputModel()
{
std::vector<std::string> history;
dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
dragonx::ui::AppendConsoleHistory(history, "getinfo", 3);
dragonx::ui::AppendConsoleHistory(history, "getpeerinfo", 3);
dragonx::ui::AppendConsoleHistory(history, "getblockcount", 3);
dragonx::ui::AppendConsoleHistory(history, "help", 3);
EXPECT_EQ(history.size(), static_cast<size_t>(3));
EXPECT_EQ(history.front(), std::string("getpeerinfo"));
int index = dragonx::ui::NavigateConsoleHistoryIndex(-1, history.size(), true);
EXPECT_EQ(index, 2);
EXPECT_EQ(dragonx::ui::ConsoleHistoryEntry(history, index), std::string("help"));
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), true);
EXPECT_EQ(index, 1);
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
EXPECT_EQ(index, 2);
index = dragonx::ui::NavigateConsoleHistoryIndex(index, history.size(), false);
EXPECT_EQ(index, -1);
auto single = dragonx::ui::CompleteConsoleCommand("stop");
EXPECT_EQ(single.matches.size(), static_cast<size_t>(1));
EXPECT_EQ(single.commonPrefix, std::string("stop"));
auto multiple = dragonx::ui::CompleteConsoleCommand("getblock");
EXPECT_TRUE(multiple.matches.size() > 1);
EXPECT_EQ(multiple.commonPrefix, std::string("getblock"));
auto lines = dragonx::ui::FormatConsoleCompletionLines(multiple.matches, 40);
EXPECT_FALSE(lines.empty());
auto args = dragonx::ui::ParseConsoleCommandArgs("z_sendmany \"from addr\" [{\"address\":\"zs\",\"amount\":1.25}] true");
EXPECT_EQ(args.size(), static_cast<size_t>(4));
EXPECT_EQ(args[1], std::string("from addr"));
auto call = dragonx::ui::BuildConsoleRpcCall("setgenerate true 4");
EXPECT_TRUE(call.valid);
EXPECT_EQ(call.method, std::string("setgenerate"));
EXPECT_EQ(call.params.size(), static_cast<size_t>(2));
EXPECT_TRUE(call.params[0].get<bool>());
EXPECT_EQ(call.params[1].get<long long>(), 4LL);
auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n \"balance\": 12,\n \"ok\": true\n}", false);
EXPECT_EQ(resultLines.size(), static_cast<size_t>(4));
EXPECT_EQ(resultLines[0].role, dragonx::ui::ConsoleResultLineRole::JsonBrace);
EXPECT_EQ(resultLines[1].role, dragonx::ui::ConsoleResultLineRole::JsonKey);
auto nullLines = dragonx::ui::FormatConsoleRpcResultLines("null", false);
EXPECT_EQ(nullLines[0].text, std::string("(no result)"));
auto errorLines = dragonx::ui::FormatConsoleRpcResultLines("bad rpc", true);
EXPECT_EQ(errorLines[0].role, dragonx::ui::ConsoleResultLineRole::Error);
EXPECT_EQ(errorLines[0].text, std::string("Error: bad rpc"));
}
void testMiningBenchmarkModel()
{
dragonx::ui::ThreadBenchmark benchmark;
EXPECT_FALSE(benchmark.active());
benchmark.buildCandidates(8);
EXPECT_EQ(benchmark.candidates.size(), static_cast<size_t>(5));
EXPECT_EQ(benchmark.candidates.front(), 4);
EXPECT_EQ(benchmark.candidates.back(), 8);
EXPECT_NEAR(benchmark.avgWarmupSecs(), 195.0, 0.0001);
benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::WarmingUp;
EXPECT_TRUE(benchmark.active());
benchmark.current_index = 2;
benchmark.total_warmup_secs = 210.0f;
EXPECT_NEAR(benchmark.avgWarmupSecs(), 105.0, 0.0001);
EXPECT_NEAR(benchmark.perTestSecs(), 135.0, 0.0001);
benchmark.phase_timer = 15.0f;
EXPECT_TRUE(benchmark.progress() > 0.0f);
benchmark.resetStabilityTracking();
EXPECT_EQ(benchmark.window_samples, 0);
EXPECT_EQ(benchmark.consecutive_stable, 0);
benchmark.phase = dragonx::ui::ThreadBenchmark::Phase::Starting;
benchmark.candidates = {2};
benchmark.current_index = 0;
auto update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
EXPECT_TRUE(update.stopPoolMining);
EXPECT_TRUE(update.startPoolMining);
EXPECT_EQ(update.startThreads, 2);
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::WarmingUp);
benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MAX_WARMUP_SECS - 0.5f;
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 100.0);
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Measuring);
benchmark.phase_timer = dragonx::ui::ThreadBenchmark::MEASURE_SECS - 0.5f;
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 1.0f, 250.0);
EXPECT_TRUE(benchmark.phase == dragonx::ui::ThreadBenchmark::Phase::Advancing);
EXPECT_EQ(benchmark.optimal_threads, 2);
benchmark.was_pool_running = true;
update = dragonx::ui::AdvanceThreadBenchmark(benchmark, 0.1f, 0.0);
EXPECT_TRUE(update.stopPoolMining);
EXPECT_TRUE(update.saveOptimalThreads);
EXPECT_TRUE(update.startPoolMining);
EXPECT_EQ(update.optimalThreads, 2);
benchmark.reset();
EXPECT_FALSE(benchmark.active());
EXPECT_TRUE(benchmark.candidates.empty());
}
void testBalanceAddressListModel()
{
std::vector<dragonx::AddressInfo> addresses;
dragonx::AddressInfo zHigh;
zHigh.address = "zs-high";
zHigh.label = "Vault";
zHigh.balance = 10.0;
zHigh.type = "shielded";
addresses.push_back(zHigh);
dragonx::AddressInfo tZero;
tZero.address = "R-zero";
tZero.label = "Empty";
tZero.balance = 0.0;
tZero.type = "transparent";
addresses.push_back(tZero);
dragonx::AddressInfo tFav;
tFav.address = "R-favorite";
tFav.label = "Favorite";
tFav.balance = 0.0;
tFav.type = "transparent";
addresses.push_back(tFav);
std::vector<dragonx::ui::AddressListInput> inputs = {
{&addresses[0], true, false, false, "Main Vault", "", -1},
{&addresses[1], false, false, false, "Empty", "", -1},
{&addresses[2], false, false, true, "Favorite", "", -1}
};
auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
EXPECT_EQ(rows.size(), static_cast<size_t>(2));
EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
EXPECT_EQ(rows[1].info->address, std::string("zs-high"));
inputs[2].sortOrder = 0;
rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false);
EXPECT_EQ(rows[0].info->address, std::string("R-favorite"));
rows = dragonx::ui::BuildAddressListRows(inputs, "vault", false, false);
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
EXPECT_EQ(rows[0].info->address, std::string("zs-high"));
auto layout = dragonx::ui::ComputeAddressRowLayout(10.0f, 20.0f, 300.0f, 50.0f,
12.0f, 16.0f, 6.0f, 4.0f, 2.0f);
EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001);
EXPECT_NEAR(layout.contentStartY, 26.0, 0.0001);
EXPECT_NEAR(layout.buttonSize, 38.0, 0.0001);
EXPECT_NEAR(layout.favoriteButton.x, 270.0, 0.0001);
EXPECT_NEAR(layout.visibilityButton.x, 228.0, 0.0001);
EXPECT_NEAR(layout.contentRight, 224.0, 0.0001);
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(2.0, 3.5), std::string("$7.00"));
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.001, 2.0), std::string("$0.002000"));
EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string(""));
}
void testGeneratedResourceBehavior()
{
const auto* themes = dragonx::resources::getEmbeddedThemes();
EXPECT_TRUE(themes != nullptr);
EXPECT_TRUE(dragonx::resources::getEmbeddedResource("__missing_resource__") == nullptr);
if (!dragonx::resources::hasEmbeddedResources()) {
EXPECT_TRUE(themes[0].data == nullptr);
EXPECT_EQ(dragonx::resources::extractBundledThemes("/tmp/obsidian-dragon-empty-themes"), 0);
}
}
} // namespace
int main()
{
testConnectionConfig();
testPaymentUri();
testAmountFormatting();
testSpendableFiltering();
testRefreshScheduler();
testNetworkRefreshService();
testNetworkRefreshSnapshotHelpers();
testNetworkRefreshRpcCollectors();
testNetworkRefreshResultModels();
testWalletSecurityController();
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testRendererHelpers();
testConsoleInputModel();
testMiningBenchmarkModel();
testBalanceAddressListModel();
testGeneratedResourceBehavior();
if (g_failures != 0) {
std::cerr << g_failures << " test assertion(s) failed\n";
return 1;
}
std::cout << "Focused service tests passed\n";
return 0;
}