Files
ObsidianDragon/tests/test_phase4.cpp
dan_s d684db446e Refactor app services and stabilize refresh/UI flows
- Add refresh scheduler and network refresh service boundaries for typed
  refresh results, ordered RPC collectors, applicators, and price parsing.
- Add daemon lifecycle and wallet security workflow helpers while preserving
  App-owned command RPC, decrypt, cancellation, and UI handoff behavior.
- Split balance, console, mining, amount formatting, and async task logic into
  focused modules with expanded Phase 4 test coverage.
- Fix market price loading by triggering price refresh immediately, avoiding
  queue-pressure drops, tracking loading/error state, and adding translations.
- Polish send, explorer, peers, settings, theme/schema, and related tab UI.
- Replace checked-in generated language headers with build-generated resources.
- Document the cleanup audit, UI static-state guidance, and architecture updates.
2026-04-29 12:47:57 -05:00

1844 lines
77 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.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"));
}
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;
Refresh::TransactionViewCache viewCache;
std::unordered_set<std::string> sendTxids{"shielded-send", "pending"};
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>(1));
EXPECT_EQ(sendTxids.count("shielded-send"), static_cast<size_t>(0));
EXPECT_EQ(sendTxids.count("pending"), 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;
}