Files
ObsidianDragon/tests/test_phase4.cpp
DanS c71c3c3378 fix(network): keep the status-bar peer count current on every tab
The peer count in the status bar is state_.peers.size(), refreshed only by
getpeerinfo — and the peers refresh interval was 0 (disabled) on every tab
except Peers. So the count never changed until you opened the Peers/Network
tab. Give peers a slow 20s cadence on all tabs (30s on Console); the Peers tab
keeps its fast 5s for the live list. During sync this is still overridden by
kSyncProfile (peers 0) so it can't contend with block download. Test updated to
the new intervals.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:12:13 -05:00

4717 lines
190 KiB
C++

#include "daemon/daemon_controller.h"
#include "data/transaction_history_cache.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/explorer/explorer_block_cache.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/address_validation.h"
#include "util/amount_format.h"
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h"
#include "wallet/lite_diagnostics.h"
#include "wallet/lite_owned_string.h"
#include "wallet/lite_rollout_policy.h"
#include "wallet/lite_wallet_controller.h"
#include "wallet/lite_wallet_gateway.h"
#include "wallet/lite_wallet_state_mapper.h"
#include "config/settings.h"
#include "data/wallet_state.h"
#include "fake_lite_backend.h"
#include <chrono>
#include <cmath>
#include <thread>
#include <atomic>
#include <deque>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <new>
#include <set>
#include <stdexcept>
#include <string>
#include <type_traits>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
#include <sodium.h>
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;
}
void writeTestFile(const fs::path& path, const std::string& content)
{
std::ofstream output(path, std::ios::binary);
output << content;
}
int g_liteBridgeRuntimeFakeCallCount = 0;
int g_liteBridgeRuntimeTrackedFreeCount = 0;
std::vector<std::string> g_liteBridgeRuntimeTrackedFreedValues;
char* makeLiteBridgeRuntimeOwnedCString(const std::string& value)
{
char* rawValue = new char[value.size() + 1];
for (std::size_t index = 0; index < value.size(); ++index) {
rawValue[index] = value[index];
}
rawValue[value.size()] = '\0';
return rawValue;
}
void resetLiteBridgeRuntimeTrackedFree()
{
g_liteBridgeRuntimeTrackedFreeCount = 0;
g_liteBridgeRuntimeTrackedFreedValues.clear();
}
void fakeLiteBridgeRuntimeTrackedFreeString(char* value)
{
++g_liteBridgeRuntimeTrackedFreeCount;
if (value) {
g_liteBridgeRuntimeTrackedFreedValues.push_back(value);
}
delete[] value;
}
bool fakeLiteBridgeRuntimeWalletExists(const char*)
{
++g_liteBridgeRuntimeFakeCallCount;
return true;
}
char* fakeLiteBridgeRuntimeInitializeNew(bool, const char*)
{
++g_liteBridgeRuntimeFakeCallCount;
return nullptr;
}
char* fakeLiteBridgeRuntimeInitializeNewFromPhrase(bool,
const char*,
const char*,
unsigned long long,
unsigned long long,
bool)
{
++g_liteBridgeRuntimeFakeCallCount;
return nullptr;
}
char* fakeLiteBridgeRuntimeInitializeExisting(bool, const char*)
{
++g_liteBridgeRuntimeFakeCallCount;
return nullptr;
}
char* fakeLiteBridgeRuntimeExecute(const char*, const char*)
{
++g_liteBridgeRuntimeFakeCallCount;
return nullptr;
}
char* fakeLiteBridgeRuntimeOwnedExecute(const char*, const char*)
{
++g_liteBridgeRuntimeFakeCallCount;
return makeLiteBridgeRuntimeOwnedCString("bridge-runtime-ok");
}
void fakeLiteBridgeRuntimeFreeString(char*)
{
++g_liteBridgeRuntimeFakeCallCount;
}
bool fakeLiteBridgeRuntimeCheckServerOnline(const char*)
{
++g_liteBridgeRuntimeFakeCallCount;
return true;
}
void fakeLiteBridgeRuntimeShutdown()
{
++g_liteBridgeRuntimeFakeCallCount;
}
dragonx::wallet::LiteClientBridgeApi makeCompleteFakeLiteBridgeRuntimeApi()
{
return dragonx::wallet::LiteClientBridgeApi{
&fakeLiteBridgeRuntimeWalletExists,
&fakeLiteBridgeRuntimeInitializeNew,
&fakeLiteBridgeRuntimeInitializeNewFromPhrase,
&fakeLiteBridgeRuntimeInitializeExisting,
&fakeLiteBridgeRuntimeExecute,
&fakeLiteBridgeRuntimeFreeString,
&fakeLiteBridgeRuntimeCheckServerOnline,
&fakeLiteBridgeRuntimeShutdown,
};
}
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);
// Peers now polls on every tab (slow cadence) so the status-bar peer count stays current.
EXPECT_NEAR(scheduler.intervals().peers, 20.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));
scheduler.applyPage(dragonx::ui::NavPage::Console);
EXPECT_NEAR(scheduler.intervals().core, 10.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().transactions, 30.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().addresses, 30.0, 0.0001);
EXPECT_NEAR(scheduler.intervals().peers, 30.0, 0.0001);
EXPECT_FALSE(scheduler.isDue(Timer::Price));
scheduler.markDue(Timer::Price);
EXPECT_TRUE(scheduler.consumeDue(Timer::Price));
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(-1, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(99, 100, false));
EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, true));
scheduler.tick(RefreshScheduler::kTxMaxAge);
EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false));
EXPECT_TRUE(scheduler.isDue(Timer::TxAge));
}
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 connectionReuseRpc;
connectionReuseRpc.addResponse("getwalletinfo", json{{"unlocked_until", 0}});
auto reusedInfo = Refresh::parseConnectionInfoResult(json{{"version", 120002}, {"blocks", 241}});
auto reusedConnection = Refresh::collectConnectionInitResult(connectionReuseRpc, reusedInfo);
EXPECT_TRUE(connectionReuseRpc.methodNames() == std::vector<std::string>({"getwalletinfo"}));
EXPECT_TRUE(reusedConnection.info.ok);
EXPECT_EQ(*reusedConnection.info.daemonVersion, 120002);
EXPECT_TRUE(reusedConnection.encryption.ok);
MockRefreshRpc coreRpc;
coreRpc.addResponse("z_gettotalbalance", json{
{"private", "3.00000000"},
{"transparent", "1.25000000"},
{"total", "4.25000000"}
});
coreRpc.addResponse("getblockchaininfo", json{
{"blocks", 150},
{"headers", 155},
{"bestblockhash", "core-best-150"},
{"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.bestBlockHash, std::string("core-best-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 miningSlowOnlyRpc;
miningSlowOnlyRpc.addResponse("getmininginfo", json{{"generate", false}, {"networkhashps", 222.0}});
auto miningSlowOnly = Refresh::collectMiningRefreshResult(miningSlowOnlyRpc, 7.0, true, false);
EXPECT_TRUE(miningSlowOnlyRpc.methodNames() == std::vector<std::string>({"getmininginfo"}));
EXPECT_FALSE(miningSlowOnly.localHashrate.has_value());
EXPECT_TRUE(miningSlowOnly.miningOk);
EXPECT_NEAR(*miningSlowOnly.networkHashrate, 222.0, 0.00000001);
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);
dragonx::WalletState cachedAddressState;
cachedAddressState.z_addresses.push_back({"zs-cached", 0.0, "shielded", false});
auto addressSnapshot = Refresh::buildAddressRefreshSnapshot(cachedAddressState);
MockRefreshRpc cachedAddressRpc;
cachedAddressRpc.addResponse("z_listaddresses", json::array({"zs-cached", "zs-new"}));
cachedAddressRpc.addResponse("z_validateaddress", json{{"ismine", true}});
cachedAddressRpc.addResponse("z_listunspent", json::array());
cachedAddressRpc.addResponse("getaddressesbyaccount", json::array());
cachedAddressRpc.addResponse("listunspent", json::array());
auto cachedAddresses = Refresh::collectAddressRefreshResult(cachedAddressRpc, addressSnapshot);
EXPECT_TRUE(cachedAddressRpc.methodNames() == std::vector<std::string>({
"z_listaddresses", "z_validateaddress", "z_listunspent",
"getaddressesbyaccount", "listunspent"
}));
EXPECT_FALSE(cachedAddresses.shieldedAddresses[0].has_spending_key);
EXPECT_TRUE(cachedAddresses.shieldedAddresses[1].has_spending_key);
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));
Refresh::TransactionRefreshSnapshot missingAddressesSnapshot;
missingAddressesSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"shielded-fallback", "receive", 1.25, 150, 2, "zs-one", "", "memo"
});
MockRefreshRpc missingAddressesRpc;
missingAddressesRpc.addResponse("listtransactions", json::array());
auto missingAddressesResult = Refresh::collectTransactionRefreshResult(
missingAddressesRpc, missingAddressesSnapshot, 325, 4);
EXPECT_TRUE(missingAddressesRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(missingAddressesResult.transactions[0].txid, std::string("shielded-fallback"));
EXPECT_EQ(missingAddressesResult.transactions[0].type, std::string("receive"));
Refresh::TransactionRefreshSnapshot pendingOpidSnapshot;
pendingOpidSnapshot.pendingOpids = {"opid-visible-send"};
pendingOpidSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"opid-visible-send", "send", -4.25, 170, 0, "R-destination", "R-source", ""
});
MockRefreshRpc pendingOpidRpc;
pendingOpidRpc.addResponse("listtransactions", json::array());
auto pendingOpidResult = Refresh::collectTransactionRefreshResult(
pendingOpidRpc, pendingOpidSnapshot, 326, 4);
EXPECT_TRUE(pendingOpidRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(pendingOpidResult.transactions[0].txid, std::string("opid-visible-send"));
EXPECT_EQ(pendingOpidResult.transactions[0].type, std::string("send"));
EXPECT_NEAR(pendingOpidResult.transactions[0].amount, -4.25, 0.00000001);
Refresh::TransactionRefreshSnapshot partialFailureSnapshot;
partialFailureSnapshot.shieldedAddresses = {"zs-one"};
partialFailureSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"old-receive", "receive", 2.50, 140, 8, "zs-one", "", "old memo"
});
MockRefreshRpc partialFailureRpc;
partialFailureRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-b"}, {"category", "receive"}, {"amount", 0.75},
{"time", 160}, {"confirmations", 2}, {"address", "R-two"}}
}));
partialFailureRpc.addFailure("z_listreceivedbyaddress", "temporary receive failure");
auto partialFailureResult = Refresh::collectTransactionRefreshResult(
partialFailureRpc, partialFailureSnapshot, 326, 4);
EXPECT_EQ(partialFailureResult.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(partialFailureResult.transactions[0].txid, std::string("transparent-b"));
EXPECT_EQ(partialFailureResult.transactions[1].txid, std::string("old-receive"));
Refresh::TransactionRefreshSnapshot pagedSnapshot;
MockRefreshRpc pagedRpc;
json firstPage = json::array();
for (int i = 0; i < 1000; ++i) {
firstPage.push_back(json{{"txid", "paged-" + std::to_string(i)}, {"category", "receive"},
{"amount", 0.01}, {"time", i}, {"confirmations", 10},
{"address", "R-page"}});
}
pagedRpc.addResponse("listtransactions", firstPage);
pagedRpc.addResponse("listtransactions", json::array({
json{{"txid", "paged-1000"}, {"category", "receive"}, {"amount", 0.02},
{"time", 2000}, {"confirmations", 11}, {"address", "R-page"}}
}));
auto pagedResult = Refresh::collectTransactionRefreshResult(pagedRpc, pagedSnapshot, 327, 0);
EXPECT_TRUE(pagedRpc.methodNames() == std::vector<std::string>({"listtransactions", "listtransactions"}));
EXPECT_EQ(pagedRpc.calls[0].params, json::array({"", 1000, 0}));
EXPECT_EQ(pagedRpc.calls[1].params, json::array({"", 1000, 1000}));
EXPECT_EQ(pagedResult.transactions.size(), static_cast<size_t>(1001));
EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000"));
// shieldedScanTipTolerance: an address scanned a few blocks below the tip is re-scanned when
// strict (tolerance 0) but SKIPPED within tolerance — this is what lets a multi-block pass
// complete (and unsticks the perpetual "refreshing history" state).
{
Refresh::TransactionRefreshSnapshot strictSnapshot;
strictSnapshot.shieldedAddresses = {"zs-a", "zs-b"};
strictSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}};
strictSnapshot.maxShieldedReceiveScans = 4;
strictSnapshot.shieldedScanTipTolerance = 0; // strict: heights 100 are stale at tip 102
MockRefreshRpc strictRpc;
strictRpc.addResponse("listtransactions", json::array());
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
strictRpc.addResponse("z_listreceivedbyaddress", json::array());
auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4);
EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast<size_t>(2)); // both re-scanned
Refresh::TransactionRefreshSnapshot tolerantSnapshot;
tolerantSnapshot.shieldedAddresses = {"zs-a", "zs-b"};
tolerantSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}};
tolerantSnapshot.maxShieldedReceiveScans = 4;
tolerantSnapshot.shieldedScanTipTolerance = 5; // 100 >= 102-5 -> still fresh
MockRefreshRpc tolerantRpc;
tolerantRpc.addResponse("listtransactions", json::array());
// No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws).
auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4);
EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast<size_t>(0)); // both skipped
EXPECT_TRUE(tolerantResult.shieldedScanComplete);
EXPECT_TRUE(tolerantRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
}
Refresh::TransactionRefreshSnapshot recentSnapshot;
dragonx::TransactionInfo previousShielded;
previousShielded.txid = "shielded-old";
previousShielded.type = "receive";
previousShielded.address = "zs-one";
previousShielded.amount = 3.0;
previousShielded.timestamp = 10;
dragonx::TransactionInfo previousTransparent;
previousTransparent.txid = "recent-one";
previousTransparent.type = "receive";
previousTransparent.address = "R-one";
previousTransparent.amount = 1.0;
previousTransparent.timestamp = 20;
recentSnapshot.previousTransactions = {previousShielded, previousTransparent};
MockRefreshRpc recentRpc;
recentRpc.addResponse("listtransactions", json::array({
json{{"txid", "recent-one"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 30}, {"confirmations", 0}},
json{{"txid", "recent-two"}, {"category", "send"}, {"address", "R-two"}, {"amount", -0.5}, {"time", 40}, {"confirmations", 0}}
}));
auto recent = Refresh::collectRecentTransactionRefreshResult(recentRpc, recentSnapshot, 123);
EXPECT_TRUE(recentRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_EQ(recentRpc.calls[0].params, json::array({"", 100, 0}));
EXPECT_EQ(recent.blockHeight, 123);
EXPECT_EQ(recent.transactions.size(), static_cast<size_t>(3));
EXPECT_EQ(recent.transactions[0].txid, std::string("recent-two"));
EXPECT_EQ(recent.transactions[1].txid, std::string("recent-one"));
EXPECT_NEAR(recent.transactions[1].amount, 2.0, 0.00000001);
EXPECT_EQ(recent.transactions[2].txid, std::string("shielded-old"));
Refresh::TransactionRefreshSnapshot recentShieldedProbeSnapshot;
recentShieldedProbeSnapshot.shieldedAddresses = {"zs-probe-a", "zs-probe-b"};
recentShieldedProbeSnapshot.shieldedScanStartIndex = 1;
recentShieldedProbeSnapshot.maxShieldedReceiveScans = 1;
recentShieldedProbeSnapshot.shieldedScanHeights = {{"zs-probe-a", 600}, {"zs-probe-b", 600}};
MockRefreshRpc recentShieldedProbeRpc;
recentShieldedProbeRpc.addResponse("listtransactions", json::array());
recentShieldedProbeRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "same-tip-shielded"}, {"amount", 1.75}, {"confirmations", 0}, {"time", 170}}
}));
auto recentShieldedProbe = Refresh::collectRecentTransactionRefreshResult(
recentShieldedProbeRpc, recentShieldedProbeSnapshot, 600);
EXPECT_TRUE(recentShieldedProbeRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0}));
EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast<size_t>(0));
EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast<size_t>(1));
EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600);
EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(recentShieldedProbe.transactions[0].txid, std::string("same-tip-shielded"));
EXPECT_EQ(recentShieldedProbe.transactions[0].confirmations, 0);
Refresh::TransactionRefreshSnapshot partialShieldedSnapshot;
partialShieldedSnapshot.shieldedAddresses = {"zs-zero", "zs-one", "zs-two"};
partialShieldedSnapshot.maxShieldedReceiveScans = 2;
partialShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"old-zs-two", "receive", 1.0, 90, 10, "zs-two", "", "memo"
});
MockRefreshRpc partialShieldedRpc;
partialShieldedRpc.addResponse("listtransactions", json::array());
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "new-zs-zero"}, {"amount", 0.5}, {"confirmations", 1}, {"time", 100}}
}));
partialShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
auto partialShielded = Refresh::collectTransactionRefreshResult(
partialShieldedRpc, partialShieldedSnapshot, 400, 0);
EXPECT_TRUE(partialShieldedRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress", "z_listreceivedbyaddress"
}));
EXPECT_EQ(partialShieldedRpc.calls[1].params, json::array({"zs-zero", 0}));
EXPECT_EQ(partialShieldedRpc.calls[2].params, json::array({"zs-one", 0}));
EXPECT_FALSE(partialShielded.shieldedScanComplete);
EXPECT_EQ(partialShielded.nextShieldedScanStartIndex, static_cast<size_t>(2));
EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast<size_t>(2));
EXPECT_EQ(partialShielded.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(partialShielded.transactions[0].txid, std::string("new-zs-zero"));
EXPECT_EQ(partialShielded.transactions[1].txid, std::string("old-zs-two"));
partialShieldedSnapshot.shieldedScanStartIndex = partialShielded.nextShieldedScanStartIndex;
partialShieldedSnapshot.shieldedScanHeights = partialShielded.shieldedScanHeights;
MockRefreshRpc finalShieldedRpc;
finalShieldedRpc.addResponse("listtransactions", json::array());
finalShieldedRpc.addResponse("z_listreceivedbyaddress", json::array());
auto finalShielded = Refresh::collectTransactionRefreshResult(
finalShieldedRpc, partialShieldedSnapshot, 400, 0);
EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0}));
EXPECT_TRUE(finalShielded.shieldedScanComplete);
EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast<size_t>(0));
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-zero"), 400);
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-one"), 400);
EXPECT_EQ(finalShielded.shieldedScanHeights.at("zs-two"), 400);
Refresh::TransactionRefreshSnapshot cachedShieldedSnapshot;
cachedShieldedSnapshot.shieldedAddresses = {"zs-cached"};
cachedShieldedSnapshot.shieldedScanHeights = {{"zs-cached", 500}};
cachedShieldedSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"cached-shielded", "receive", 1.5, 120, 20, "zs-cached", "", "cached memo"
});
MockRefreshRpc cachedShieldedRpc;
cachedShieldedRpc.addResponse("listtransactions", json::array());
auto cachedShielded = Refresh::collectTransactionRefreshResult(
cachedShieldedRpc, cachedShieldedSnapshot, 500, 0);
EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector<std::string>({"listtransactions"}));
EXPECT_TRUE(cachedShielded.shieldedScanComplete);
EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast<size_t>(0));
EXPECT_EQ(cachedShielded.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(cachedShielded.transactions[0].txid, std::string("cached-shielded"));
Refresh::TransactionRefreshSnapshot staleProgressSnapshot;
staleProgressSnapshot.shieldedAddresses = {"zs-current", "zs-stale", "zs-missing"};
staleProgressSnapshot.shieldedScanHeights = {{"zs-current", 500}, {"zs-stale", 499}};
staleProgressSnapshot.maxShieldedReceiveScans = 1;
staleProgressSnapshot.previousTransactions.push_back(dragonx::TransactionInfo{
"current-shielded", "receive", 0.75, 110, 12, "zs-current", "", ""
});
MockRefreshRpc staleProgressRpc;
staleProgressRpc.addResponse("listtransactions", json::array());
staleProgressRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "stale-shielded"}, {"amount", 2.0}, {"confirmations", 1}, {"time", 130}}
}));
auto staleProgress = Refresh::collectTransactionRefreshResult(
staleProgressRpc, staleProgressSnapshot, 500, 0);
EXPECT_TRUE(staleProgressRpc.methodNames() == std::vector<std::string>({
"listtransactions", "z_listreceivedbyaddress"
}));
EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0}));
EXPECT_FALSE(staleProgress.shieldedScanComplete);
EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast<size_t>(2));
EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast<size_t>(1));
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-current"), 500);
EXPECT_EQ(staleProgress.shieldedScanHeights.at("zs-stale"), 500);
EXPECT_TRUE(staleProgress.shieldedScanHeights.find("zs-missing") == staleProgress.shieldedScanHeights.end());
EXPECT_EQ(staleProgress.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(staleProgress.transactions[0].txid, std::string("stale-shielded"));
EXPECT_EQ(staleProgress.transactions[1].txid, std::string("current-shielded"));
Refresh::TransactionRefreshSnapshot miningSnapshot;
miningSnapshot.shieldedAddresses = {"zs-mine"};
miningSnapshot.miningAddresses = {"R-mine", "zs-mine"};
MockRefreshRpc miningTxRpc;
miningTxRpc.addResponse("listtransactions", json::array({
json{{"txid", "transparent-mined"}, {"category", "receive"}, {"amount", 3.0},
{"time", 220}, {"confirmations", 101}, {"address", "R-mine"}}
}));
miningTxRpc.addResponse("z_listreceivedbyaddress", json::array({
json{{"txid", "shielded-mined"}, {"amount", 4.0}, {"confirmations", 102},
{"time", 210}, {"memoStr", "pool"}}
}));
auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0);
EXPECT_EQ(miningTxResult.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(miningTxResult.transactions[0].type, std::string("mined"));
EXPECT_EQ(miningTxResult.transactions[1].type, std::string("mined"));
}
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}, {"bestblockhash", "apply-best-100"}, {"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_EQ(state.sync.best_blockhash, std::string("apply-best-100"));
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 testOperationStatusPollParsing()
{
using Refresh = dragonx::services::NetworkRefreshService;
using nlohmann::json;
auto parsed = Refresh::parseOperationStatusPoll(json::array({
json{{"id", "op-success"}, {"status", "success"}, {"result", json{{"txid", "tx-success"}}}},
json{{"id", "op-failed"}, {"status", "failed"}, {"error", json{{"message", "bad memo"}}}},
json{{"id", "op-running"}, {"status", "executing"}}
}), {"op-success", "op-failed", "op-running", "op-stale"});
EXPECT_TRUE(parsed.anySuccess);
EXPECT_EQ(parsed.doneOpids.size(), static_cast<size_t>(2));
EXPECT_EQ(parsed.doneOpids[0], std::string("op-success"));
EXPECT_EQ(parsed.doneOpids[1], std::string("op-failed"));
EXPECT_EQ(parsed.successTxids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.successTxids[0], std::string("tx-success"));
EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success"));
EXPECT_EQ(parsed.failureMessages.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.failureMessages[0], std::string("bad memo"));
// Per-opid failure map drives terminal-status routing to the originating send UI.
EXPECT_EQ(parsed.failureByOpid.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo"));
EXPECT_EQ(parsed.staleOpids.size(), static_cast<size_t>(1));
EXPECT_EQ(parsed.staleOpids[0], std::string("op-stale"));
auto malformed = Refresh::parseOperationStatusPoll(json{{"status", "success"}}, {"op-keep"});
EXPECT_FALSE(malformed.anySuccess);
EXPECT_TRUE(malformed.doneOpids.empty());
EXPECT_TRUE(malformed.staleOpids.empty());
}
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, false, 10, 20, 30};
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
filter.text.clear();
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
filter.rpcTraceEnabled = true;
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, 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, false, "Main Vault", "", -1},
{&addresses[1], false, false, false, false, "Empty", "", -1},
{&addresses[2], false, false, true, false, "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 testExplorerBlockCache()
{
using dragonx::ui::ExplorerBlockCache;
using dragonx::ui::ExplorerBlockSummary;
using nlohmann::json;
fs::path dir = makeTempDir();
fs::path databasePath = dir / "explorer_blocks.sqlite";
fs::path legacyPath = dir / "explorer_blocks_cache.json";
json legacy = {
{"version", 1},
{"tip_height", 10},
{"tip_hash", "hash-10"},
{"blocks", json::array({
json{{"height", 10}, {"hash", "hash-10"}, {"tx_count", 3}, {"size", 1000}, {"time", 5000}, {"difficulty", 1.25}},
json{{"height", 9}, {"hash", "hash-9"}, {"tx_count", 2}, {"size", 900}, {"time", 4900}, {"difficulty", 1.20}}
})}
};
{
std::ofstream file(legacyPath);
file << legacy.dump(2);
}
ExplorerBlockCache cache(databasePath.string(), legacyPath.string());
EXPECT_TRUE(cache.ensureOpen());
EXPECT_EQ(cache.cachedBlockCount(), 2);
auto range = cache.loadRange(9, 10);
EXPECT_EQ(range.size(), static_cast<size_t>(2));
EXPECT_EQ(range[10].hash, std::string("hash-10"));
EXPECT_EQ(range[9].tx_count, 2);
auto sameTipValidation = cache.prepareValidation(10, "hash-10");
EXPECT_FALSE(sameTipValidation.needed);
auto advancedValidation = cache.prepareValidation(12, "hash-12");
EXPECT_TRUE(advancedValidation.needed);
EXPECT_EQ(advancedValidation.height, 10);
EXPECT_EQ(advancedValidation.expectedHash, std::string("hash-10"));
cache.applySavedTipValidation(advancedValidation, "hash-10", 12, "hash-12");
ExplorerBlockSummary block12;
block12.height = 12;
block12.hash = "hash-12";
block12.tx_count = 5;
block12.size = 1200;
block12.time = 5200;
block12.difficulty = 1.40;
EXPECT_TRUE(cache.storeBlock(block12));
EXPECT_EQ(cache.loadRange(12, 12)[12].tx_count, 5);
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
EXPECT_EQ(reopened.cachedBlockCount(), 3);
auto noRepeatValidation = reopened.prepareValidation(12, "hash-12");
EXPECT_FALSE(noRepeatValidation.needed);
}
cache.prepareValidation(12, "different-tip");
EXPECT_EQ(cache.cachedBlockCount(), 0);
{
ExplorerBlockCache reopened(databasePath.string(), legacyPath.string());
EXPECT_TRUE(reopened.ensureOpen());
EXPECT_EQ(reopened.cachedBlockCount(), 0);
}
fs::remove_all(dir);
}
void testTransactionHistoryCache()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
std::string identityA = TransactionHistoryCache::walletIdentityFromAddresses(
{"zs-beta", "zs-alpha"}, {"R-one"});
std::string identityB = TransactionHistoryCache::walletIdentityFromAddresses(
{"zs-alpha", "zs-beta"}, {"R-one"});
std::string identityWithSameText = TransactionHistoryCache::walletIdentityFromAddresses(
{"same-address-text"}, {"same-address-text"});
EXPECT_EQ(identityA, identityB);
EXPECT_TRUE(identityA.find("protocol=") == std::string::npos);
EXPECT_TRUE(identityA.find("p2p_port=") == std::string::npos);
EXPECT_TRUE(identityA.find("z:zs-alpha") != std::string::npos);
EXPECT_TRUE(identityA.find("t:R-one") != std::string::npos);
EXPECT_TRUE(identityWithSameText.find("z:same-address-text") != std::string::npos);
EXPECT_TRUE(identityWithSameText.find("t:same-address-text") != std::string::npos);
fs::path dir = makeTempDir();
fs::path databasePath = dir / "transaction_history.sqlite";
std::string walletIdentity = "mainnet|R-alpha|zs-beta";
std::string passphrase = "correct horse battery staple";
std::vector<TransactionInfo> transactions;
transactions.push_back(TransactionInfo{
"tx-sensitive-send", "send", -1.25, 1700000100, 12,
"zs-destination-sensitive", "R-source-sensitive", "private memo"
});
transactions.push_back(TransactionInfo{
"tx-mined", "mined", 3.5, 1700000000, 104,
"R-mining-address", "", ""
});
std::unordered_map<std::string, int> shieldedScanHeights{
{"zs-beta", 120},
{"zs-archive", 118}
};
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.ensureOpen());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200, shieldedScanHeights));
EXPECT_EQ(cache.snapshotCount(), 1);
}
{
std::ifstream database(databasePath, std::ios::binary);
std::string bytes((std::istreambuf_iterator<char>(database)), std::istreambuf_iterator<char>());
EXPECT_TRUE(bytes.find("tx-sensitive-send") == std::string::npos);
EXPECT_TRUE(bytes.find("zs-destination-sensitive") == std::string::npos);
EXPECT_TRUE(bytes.find("zs-archive") == std::string::npos);
EXPECT_TRUE(bytes.find("private memo") == std::string::npos);
}
{
TransactionHistoryCache wrongKey(databasePath.string());
EXPECT_TRUE(wrongKey.unlockWithPassphrase(walletIdentity, "wrong passphrase"));
auto wrongLoad = wrongKey.load(walletIdentity, 120, "tip-120");
EXPECT_FALSE(wrongLoad.loaded);
}
{
TransactionHistoryCache reopened(databasePath.string());
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
auto loaded = reopened.load(walletIdentity, 120, "tip-120");
EXPECT_TRUE(loaded.loaded);
EXPECT_FALSE(loaded.invalidated);
EXPECT_EQ(loaded.tipHeight, 120);
EXPECT_EQ(loaded.tipHash, std::string("tip-120"));
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000200));
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(2));
EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send"));
EXPECT_EQ(loaded.transactions[0].memo, std::string("private memo"));
EXPECT_EQ(loaded.transactions[1].type, std::string("mined"));
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(2));
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-beta"), 120);
EXPECT_EQ(loaded.shieldedScanHeights.at("zs-archive"), 118);
}
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000250));
auto loaded = cache.load(walletIdentity, 120, "tip-120");
EXPECT_TRUE(loaded.loaded);
EXPECT_EQ(loaded.shieldedScanHeights.size(), static_cast<size_t>(0));
}
{
TransactionHistoryCache staleTip(databasePath.string());
EXPECT_TRUE(staleTip.unlockWithPassphrase(walletIdentity, passphrase));
auto invalidated = staleTip.load(walletIdentity, 119, "tip-119");
EXPECT_FALSE(invalidated.loaded);
EXPECT_TRUE(invalidated.invalidated);
EXPECT_EQ(staleTip.snapshotCount(), 0);
}
{
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000300));
auto invalidated = cache.load(walletIdentity, 120, "different-tip");
EXPECT_FALSE(invalidated.loaded);
EXPECT_TRUE(invalidated.invalidated);
EXPECT_EQ(cache.snapshotCount(), 0);
}
fs::remove_all(dir);
}
// GC: generating a new address changes the wallet-identity hash; the prior hash's snapshot
// + salt must not accumulate forever. A save under a new identity prunes the old rows.
void testTransactionHistoryCachePrunesOldWallets()
{
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
fs::path dbPath = dir / "transaction_history.sqlite";
const std::string idA = "mainnet|R-a|zs-a";
const std::string idB = "mainnet|R-a|zs-a|zs-newly-generated"; // new address -> new identity
const std::string pass = "correct horse battery staple";
std::vector<TransactionInfo> txs;
txs.push_back(TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""});
TransactionHistoryCache cache(dbPath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass));
EXPECT_TRUE(cache.replace(idA, 100, "tip-a", txs, 1700000000));
EXPECT_EQ(cache.snapshotCount(), 1);
EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass));
EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000001));
// Old wallet A's snapshot was pruned — only the live wallet's row remains.
EXPECT_EQ(cache.snapshotCount(), 1);
auto loadedB = cache.load(idB, 100, "tip-b");
EXPECT_TRUE(loadedB.loaded);
fs::remove_all(dir);
}
void testTransactionHistoryCacheRefreshApply()
{
using Refresh = dragonx::services::NetworkRefreshService;
using dragonx::TransactionInfo;
using dragonx::data::TransactionHistoryCache;
fs::path dir = makeTempDir();
fs::path databasePath = dir / "transaction_history_refresh.sqlite";
std::string walletIdentity = "mainnet|R-refresh-cache|zs-refresh-cache";
std::string passphrase = "refresh cache passphrase";
dragonx::WalletState state;
state.sync.blocks = 333;
state.sync.best_blockhash = "tip-333";
Refresh::TransactionRefreshResult refreshResult;
refreshResult.blockHeight = 333;
TransactionInfo refreshedTx;
refreshedTx.txid = "rpc-refreshed-send";
refreshedTx.type = "send";
refreshedTx.amount = -2.75;
refreshedTx.timestamp = 1700000400;
refreshedTx.confirmations = 14;
refreshedTx.address = "zs-rpc-destination";
refreshedTx.from_address = "R-refresh-cache";
refreshedTx.memo = "rpc refreshed memo";
refreshResult.transactions = {refreshedTx};
Refresh::TransactionViewCache viewCache;
std::unordered_set<std::string> sendTxids;
std::vector<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(refreshResult), 1700000500);
TransactionHistoryCache cache(databasePath.string());
EXPECT_TRUE(cache.unlockWithPassphrase(walletIdentity, passphrase));
EXPECT_TRUE(cache.replace(walletIdentity,
state.sync.blocks,
state.sync.best_blockhash,
state.transactions,
state.last_tx_update));
TransactionHistoryCache reopened(databasePath.string());
EXPECT_TRUE(reopened.unlockWithPassphrase(walletIdentity, passphrase));
auto loaded = reopened.load(walletIdentity, 333, "tip-333");
EXPECT_TRUE(loaded.loaded);
EXPECT_EQ(loaded.transactions.size(), static_cast<size_t>(1));
EXPECT_EQ(loaded.transactions[0].txid, std::string("rpc-refreshed-send"));
EXPECT_EQ(loaded.transactions[0].memo, std::string("rpc refreshed memo"));
EXPECT_EQ(loaded.updatedAt, static_cast<std::time_t>(1700000500));
EXPECT_EQ(lastTxBlock, 333);
EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast<size_t>(1));
fs::remove_all(dir);
}
void testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess()
{
resetLiteBridgeRuntimeTrackedFree();
dragonx::wallet::LiteBridgeOwnedString ownedString(
makeLiteBridgeRuntimeOwnedCString("phase2-ok"),
&fakeLiteBridgeRuntimeTrackedFreeString);
auto result = ownedString.intoResult();
EXPECT_TRUE(result.ok);
EXPECT_EQ(result.value, std::string("phase2-ok"));
EXPECT_TRUE(result.error.empty());
EXPECT_TRUE(ownedString.rawPointerReceived());
EXPECT_TRUE(ownedString.copiedBeforeFree());
EXPECT_TRUE(ownedString.freed());
EXPECT_FALSE(ownedString.rawPointerEscaped());
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-ok"));
auto secondResult = ownedString.intoResult();
EXPECT_FALSE(secondResult.ok);
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
}
void testLiteBridgeOwnedStringClassifiesNullWithoutFree()
{
resetLiteBridgeRuntimeTrackedFree();
dragonx::wallet::LiteBridgeOwnedString ownedString(
nullptr,
&fakeLiteBridgeRuntimeTrackedFreeString);
auto result = ownedString.intoResult();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.value.empty());
EXPECT_TRUE(result.error.find("null string") != std::string::npos);
EXPECT_FALSE(ownedString.rawPointerReceived());
EXPECT_FALSE(ownedString.copiedBeforeFree());
EXPECT_FALSE(ownedString.freed());
EXPECT_FALSE(ownedString.rawPointerEscaped());
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 0);
}
void testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce()
{
resetLiteBridgeRuntimeTrackedFree();
dragonx::wallet::LiteBridgeOwnedString ownedString(
makeLiteBridgeRuntimeOwnedCString("Error: phase2 denied"),
&fakeLiteBridgeRuntimeTrackedFreeString);
auto result = ownedString.intoResult();
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.value.empty());
EXPECT_EQ(result.error, std::string("Error: phase2 denied"));
EXPECT_TRUE(ownedString.copiedBeforeFree());
EXPECT_TRUE(ownedString.freed());
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("Error: phase2 denied"));
}
void testLiteBridgeOwnedStringMovePreventsDoubleFree()
{
resetLiteBridgeRuntimeTrackedFree();
{
dragonx::wallet::LiteBridgeOwnedString original(
makeLiteBridgeRuntimeOwnedCString("phase2-move"),
&fakeLiteBridgeRuntimeTrackedFreeString);
dragonx::wallet::LiteBridgeOwnedString moved(std::move(original));
EXPECT_FALSE(original.rawPointerReceived());
EXPECT_TRUE(moved.rawPointerReceived());
EXPECT_FALSE(moved.rawPointerEscaped());
}
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("phase2-move"));
}
void testLiteClientBridgeUsesRuntimeOwnedStringCleanup()
{
resetLiteBridgeRuntimeTrackedFree();
g_liteBridgeRuntimeFakeCallCount = 0;
auto bridgeApi = makeCompleteFakeLiteBridgeRuntimeApi();
bridgeApi.execute = &fakeLiteBridgeRuntimeOwnedExecute;
bridgeApi.freeString = &fakeLiteBridgeRuntimeTrackedFreeString;
auto bridge = dragonx::wallet::LiteClientBridge::fromApi(bridgeApi);
auto result = bridge.execute("phase2", "{}");
EXPECT_TRUE(result.ok);
EXPECT_EQ(result.value, std::string("bridge-runtime-ok"));
EXPECT_TRUE(result.error.empty());
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreeCount, 1);
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues.size(), static_cast<std::size_t>(1));
EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("bridge-runtime-ok"));
EXPECT_EQ(g_liteBridgeRuntimeFakeCallCount, 1);
}
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);
}
}
// M0: deterministic injectable fake backend driving the real LiteClientBridge
// (ungated fromApi path). This is the harness M1 service tests reuse.
void testLiteBackendInjectableFakeBridge()
{
using dragonx::wallet::LiteClientBridge;
// available() + bool round-trips
{
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
EXPECT_TRUE(bridge.available());
EXPECT_TRUE(bridge.walletExists("DRGX"));
EXPECT_TRUE(bridge.checkServerOnline("https://lite.example"));
}
// execute() round-trips the canned value and frees the owned string exactly once
{
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
const auto result = bridge.execute("ping", ""); // unknown command -> default fake response
EXPECT_TRUE(result.ok);
EXPECT_EQ(result.value, std::string("{\"version\":\"sdxl-fake\"}"));
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 1L);
EXPECT_EQ(dragonx::test::g_liteFakeFreed, 1L);
}
// lifecycle calls round-trip
{
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
EXPECT_TRUE(bridge.initializeNew(false, "https://lite.example").ok);
EXPECT_TRUE(bridge.initializeExisting(false, "https://lite.example").ok);
EXPECT_TRUE(bridge.initializeNewFromPhrase(false, "https://lite.example", "seed words", 0, 0, false).ok);
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
}
// an "Error:"-prefixed backend response maps to ok=false with the message propagated
{
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
const auto result = bridge.execute("boom", "");
EXPECT_FALSE(result.ok);
EXPECT_TRUE(result.error.rfind("Error:", 0) == 0);
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
}
// empty command is rejected before reaching the backend (no allocation)
{
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
EXPECT_FALSE(bridge.execute("", "").ok);
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
}
// an unavailable bridge fails closed (never fakes success)
{
dragonx::test::resetLiteFakeCounters();
auto bridge = LiteClientBridge::unavailable("lite backend is not linked");
EXPECT_FALSE(bridge.available());
EXPECT_FALSE(bridge.execute("info", "").ok);
EXPECT_FALSE(bridge.walletExists("DRGX"));
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
}
// shutdown is invoked (via destructor) and no owned string leaks or double-frees
{
dragonx::test::resetLiteFakeCounters();
{
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
(void)bridge.execute("info", "");
}
EXPECT_TRUE(dragonx::test::g_liteFakeShutdownCalled);
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, dragonx::test::g_liteFakeFreed);
}
}
// M1: the App-owned LiteWalletController drives real create/open/restore through an
// (injected fake) bridge, reports wallet-ready, persists, wipes secrets, and fails closed.
void testLiteWalletControllerLifecycle()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
// create -> wallet ready, walletOpen() true, persist callback fires once.
// (No alloc==freed leak assert here: a ready wallet launches the detached sync thread +
// refresh worker, which are still in flight at end-of-scope and legitimately hold owned
// strings. The leak/double-free invariant is checked in the thread-free bridge test.)
{
dragonx::test::resetLiteFakeCounters();
int persistCount = 0;
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
controller.setPersistCallback([&persistCount]() { ++persistCount; });
EXPECT_FALSE(controller.walletOpen());
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
const auto result = controller.createWallet(req);
EXPECT_TRUE(result.ok);
EXPECT_TRUE(result.walletReady);
EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::CreateNew);
EXPECT_TRUE(controller.walletOpen());
EXPECT_EQ(persistCount, 1);
}
// open existing -> ready. Regression guard: the real backend's open path
// (litelib_initialize_existing) returns the bare string "OK", which is NOT valid JSON, so
// walletReady must key off a non-empty success response, not JSON validity.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = true;
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletOpenRequest req;
req.passphrase = "hunter2";
const auto result = controller.openWallet(req);
EXPECT_TRUE(result.ok);
EXPECT_TRUE(result.walletReady);
EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::OpenExisting);
EXPECT_TRUE(controller.walletOpen());
}
// restore-from-seed round-trips to ready
{
dragonx::test::resetLiteFakeCounters();
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletRestoreRequest req;
req.seedPhrase = "abandon abandon abandon ... art";
const auto result = controller.restoreWallet(req);
EXPECT_TRUE(result.walletReady);
EXPECT_TRUE(result.operation == LiteWalletLifecycleOperation::RestoreFromSeed);
EXPECT_TRUE(controller.walletOpen());
}
// empty restore seed is rejected before the backend is touched
{
dragonx::test::resetLiteFakeCounters();
LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const auto result = controller.restoreWallet(LiteWalletRestoreRequest{});
EXPECT_FALSE(result.ok);
EXPECT_FALSE(result.walletReady);
EXPECT_FALSE(controller.walletOpen());
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
}
// bridge calls disabled -> blocked, backend never reached
{
dragonx::test::resetLiteFakeCounters();
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
LiteWalletControllerOptions{/*allowBridgeCalls*/ false});
const auto result = controller.createWallet(LiteWalletCreateRequest{});
EXPECT_FALSE(result.walletReady);
EXPECT_FALSE(controller.walletOpen());
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
}
// full-node capabilities -> unsupported, fails closed
{
dragonx::test::resetLiteFakeCounters();
const auto fullNodeCaps = makeWalletCapabilities(WalletBuildKind::FullNode, true, false);
LiteWalletController controller(fullNodeCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const auto result = controller.createWallet(LiteWalletCreateRequest{});
EXPECT_FALSE(result.walletReady);
EXPECT_EQ(dragonx::test::g_liteFakeAlloc, 0L);
}
// secret-wipe helper zeroes and clears
{
std::string secret = "super-secret-seed-phrase";
secureWipeLiteSecret(secret);
EXPECT_TRUE(secret.empty());
}
}
// M4: spend & backup. The controller drives send/shield/import/export/seed through the
// (injected fake) bridge with the real backend's arg/response contracts: send uses the
// JSON-array form (litelib_execute takes one arg), failures arrive as {"error":..} in the
// body, and the async send/shield path delivers a txid via takeBroadcastResult().
void testLiteWalletControllerM4()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
auto openController = [&]() {
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
return c;
};
// send (blocking core): JSON-array payload -> {"txid":..}
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openController();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 150000000ULL, "hello memo with spaces"});
const auto r = c->sendTransactionBlocking(req);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.txid, std::string("faketxid123"));
EXPECT_TRUE(r.error.empty());
}
// send with no recipients -> error, backend never reached
{
auto c = openController();
const auto r = c->sendTransactionBlocking(LiteSendRequest{});
EXPECT_FALSE(r.ok);
EXPECT_FALSE(r.error.empty());
}
// send failure surfaces the backend's {"error":..} body (not an "Error:" prefix)
{
dragonx::test::g_liteFakeSendFails = true;
auto c = openController();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 1ULL, ""});
const auto r = c->sendTransactionBlocking(req);
EXPECT_FALSE(r.ok);
EXPECT_EQ(r.error, std::string("insufficient funds"));
dragonx::test::g_liteFakeSendFails = false;
}
// shield (blocking core) -> txid
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openController();
const auto r = c->shieldFundsBlocking();
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.txid, std::string("fakeshieldtxid"));
}
// async send: delivers the result to the main-thread slot
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openController();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 42ULL, ""});
EXPECT_TRUE(c->sendTransaction(req));
LiteBroadcastResult r;
bool got = false;
for (int i = 0; i < 400 && !got; ++i) { // bounded wait (<= ~2s)
got = c->takeBroadcastResult(r);
if (!got) std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
EXPECT_TRUE(got);
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.txid, std::string("faketxid123"));
}
// send before a wallet is open -> rejected
{
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 1ULL, ""});
EXPECT_FALSE(c.sendTransaction(req));
const auto r = c.sendTransactionBlocking(req);
EXPECT_FALSE(r.ok);
}
// export private keys (SECRET) -> parsed, non-empty
{
auto c = openController();
const auto r = c->exportPrivateKeys();
EXPECT_TRUE(r.ok);
EXPECT_TRUE(r.privateKeysJson.find("private_key") != std::string::npos);
}
// export seed (SECRET) -> seed phrase + birthday
{
auto c = openController();
const auto r = c->exportSeed();
EXPECT_TRUE(r.ok);
EXPECT_EQ(r.seedPhrase, std::string("fake seed phrase words"));
EXPECT_EQ(r.birthday, 0ULL);
}
// import shielded key -> "import"; the key argument is wiped on return
{
auto c = openController();
std::string key = "secret-extended-key-main1fakeshieldedkey";
const auto r = c->importKey(std::move(key));
EXPECT_TRUE(r.ok);
EXPECT_TRUE(r.detail.find("zs1imported") != std::string::npos);
}
// import transparent WIF (begins with K) -> "timport"
{
auto c = openController();
const auto r = c->importKey("Kx1faketransparentwif");
EXPECT_TRUE(r.ok);
}
// import with no wallet open -> rejected (and key still wiped)
{
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const auto r = c.importKey("secret-extended-key-main1abc");
EXPECT_FALSE(r.ok);
}
}
// M5: persistence. The backend auto-saves on new-address/import but NOT after sync/send/shield,
// so the controller must trigger `save` at those points (otherwise a ~30-min scan and sent txs
// are lost on restart). Drives the fake's save counter to prove saves happen exactly when needed.
void testLiteWalletControllerM5Persistence()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
auto openController = [&]() {
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
return c;
};
// Open and wait until the background sync (and its persist) has completed, so the save count
// is stable before a sub-test captures its baseline.
auto openSynced = [&]() {
auto c = openController();
for (int i = 0; i < 400 && !c->syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
return c;
};
// sync persists: completing a sync saves the freshly-scanned wallet.
{
dragonx::test::g_liteFakeSyncBlock = false;
dragonx::test::g_liteFakeSaveCount = 0;
auto c = openSynced();
EXPECT_TRUE(c->syncComplete());
EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 1);
}
// send persists the new transaction (backend does not auto-save sends)
{
dragonx::test::g_liteFakeSendFails = false;
auto c = openSynced();
const long before = dragonx::test::g_liteFakeSaveCount.load();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 1ULL, ""});
EXPECT_TRUE(c->sendTransactionBlocking(req).ok);
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
}
// a FAILED send does NOT save
{
dragonx::test::g_liteFakeSendFails = true;
auto c = openSynced();
const long before = dragonx::test::g_liteFakeSaveCount.load();
LiteSendRequest req;
req.recipients.push_back({"zs1dest", 1ULL, ""});
EXPECT_FALSE(c->sendTransactionBlocking(req).ok);
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
dragonx::test::g_liteFakeSendFails = false;
}
// shield persists
{
auto c = openSynced();
const long before = dragonx::test::g_liteFakeSaveCount.load();
EXPECT_TRUE(c->shieldFundsBlocking().ok);
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
}
// explicit saveWallet() persists; with no wallet open it is a no-op
{
auto c = openSynced();
const long before = dragonx::test::g_liteFakeSaveCount.load();
EXPECT_TRUE(c->saveWallet());
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1);
}
{
LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const long before = dragonx::test::g_liteFakeSaveCount.load();
EXPECT_FALSE(c.saveWallet());
EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before);
}
}
// Encryption: the controller drives encrypt/unlock/lock/decrypt + encryptionstatus through the
// (injected fake) backend, wipes passphrases, and folds status into WalletState (encrypted/locked).
void testLiteWalletControllerEncryption()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
const LiteConnectionSettings conn = defaultLiteConnectionSettings();
auto open = [&]() {
dragonx::test::g_liteFakeEncrypted = false;
dragonx::test::g_liteFakeLocked = false;
auto c = std::make_unique<LiteWalletController>(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
(void)c->createWallet(req);
return c;
};
// encrypt -> unlock -> lock -> decrypt, observed via encryptionStatus(). NOTE: encrypt LOCKS
// immediately (the real backend removes keys after encrypting — verified via lite_smoke).
{
auto c = open();
const auto s0 = c->encryptionStatus();
EXPECT_TRUE(s0.ok);
EXPECT_FALSE(s0.encrypted);
EXPECT_FALSE(s0.locked);
EXPECT_TRUE(c->encryptWallet("walletpass").ok);
const auto s1 = c->encryptionStatus();
EXPECT_TRUE(s1.encrypted);
EXPECT_TRUE(s1.locked); // encrypt locks immediately
EXPECT_TRUE(c->unlockWallet("walletpass"));
const auto s2 = c->encryptionStatus();
EXPECT_TRUE(s2.encrypted);
EXPECT_FALSE(s2.locked);
EXPECT_TRUE(c->lockWallet());
const auto s3 = c->encryptionStatus();
EXPECT_TRUE(s3.encrypted);
EXPECT_TRUE(s3.locked);
EXPECT_TRUE(c->decryptWallet("walletpass").ok);
const auto s4 = c->encryptionStatus();
EXPECT_FALSE(s4.encrypted);
EXPECT_FALSE(s4.locked);
}
// empty passphrase rejected; operations on a closed wallet fail closed
{
auto c = open();
EXPECT_FALSE(c->encryptWallet("").ok);
LiteWalletController closed(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_FALSE(closed.unlockWallet("x"));
EXPECT_FALSE(closed.lockWallet());
EXPECT_FALSE(closed.encryptionStatus().ok);
}
// encryption status folds into WalletState (gates the locked UI)
{
LiteWalletAppRefreshModel model;
model.hasEncryptionStatus = true;
model.encrypted = true;
model.locked = true;
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(model, state);
EXPECT_TRUE(state.isEncrypted());
EXPECT_TRUE(state.isLocked());
}
}
// Migration: a saved lite chain_name outside {main,test,regtest} (e.g. the legacy
// "DRAGONX" ticker) is rewritten to "main" on load, since the backend hard-panics
// on unknown chains. Valid values are preserved.
void testLiteChainNameMigration()
{
const fs::path dir = fs::temp_directory_path() / "dragonx_lite_chain_migration_test";
fs::create_directories(dir);
{
const fs::path p = dir / "legacy.json";
writeTestFile(p, "{\"lite_wallet\":{\"chain_name\":\"DRAGONX\"}}");
dragonx::config::Settings settings;
EXPECT_TRUE(settings.load(p.string()));
EXPECT_EQ(settings.getLiteChainName(), std::string("main"));
}
{
const fs::path p = dir / "valid.json";
writeTestFile(p, "{\"lite_wallet\":{\"chain_name\":\"test\"}}");
dragonx::config::Settings settings;
EXPECT_TRUE(settings.load(p.string()));
EXPECT_EQ(settings.getLiteChainName(), std::string("test"));
}
std::error_code ec;
fs::remove_all(dir, ec);
}
// Async open with server failover: when the preferred lite server is unreachable, the controller
// transparently opens against the next usable one; only an all-servers-down case fails (and then
// surfaces a reason). Open runs off the UI thread, finalized by pumpAsyncOpen() on the main thread.
void testLiteWalletControllerOpenFailover()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
LiteConnectionSettings conn;
conn.chainName = "main";
conn.servers = {
LiteServerEndpoint{"https://dead.example", "Dead", true},
LiteServerEndpoint{"https://good.example", "Good", true},
};
conn.selectionMode = LiteServerSelectionMode::Sticky;
conn.stickyServerUrl = "https://dead.example"; // the PREFERRED server is the dead one
// Preferred server dead, fallback good -> the wallet opens via the fallback.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = true;
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
LiteDiagnostics::instance().clear(); // verify the open populates the console log
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.beginOpenExisting());
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_FALSE(controller.openInProgress());
controller.pumpAsyncOpen();
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.lastOpenError().empty());
// The Console tab reads this log: the failover attempt + success must be recorded.
const auto log = LiteDiagnostics::instance().snapshot();
EXPECT_TRUE(!log.empty());
bool sawConnecting = false, sawOpened = false;
for (const auto& l : log) {
if (l.find("connecting to") != std::string::npos) sawConnecting = true;
if (l.find("Wallet ready via") != std::string::npos) sawOpened = true;
}
EXPECT_TRUE(sawConnecting);
EXPECT_TRUE(sawOpened);
}
// All servers dead -> open fails, wallet stays closed, reason surfaced.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = true;
dragonx::test::g_liteFakeDeadServerSubstr = "example"; // both servers fail
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.beginOpenExisting());
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
controller.pumpAsyncOpen();
EXPECT_FALSE(controller.walletOpen());
EXPECT_TRUE(!controller.lastOpenError().empty());
}
// A warming-up (-28) server is flagged so the caller can retry sooner. Preferred server is
// warming, the fallback is dead -> open fails but lastOpenWasWarmup() is set.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = true;
dragonx::test::g_liteFakeWarmupServerSubstr = "dead.example"; // preferred (sticky) is warming
dragonx::test::g_liteFakeDeadServerSubstr = "good.example"; // fallback unreachable
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.beginOpenExisting());
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
controller.pumpAsyncOpen();
EXPECT_FALSE(controller.walletOpen());
EXPECT_TRUE(controller.lastOpenWasWarmup());
}
// Create also fails over: preferred dead, fallback good -> a new wallet is created via the
// fallback (no existing wallet beforehand).
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false; // creating fresh
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_FALSE(controller.walletOpen());
EXPECT_TRUE(controller.beginCreateWallet());
for (int i = 0; i < 400 && controller.openInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
controller.pumpAsyncOpen();
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.lastOpenError().empty());
}
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr.clear();
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}
// Interactive console: runConsoleCommand() forwards an arbitrary backend command on a background
// thread and delivers the raw response to a main-thread slot drained by takeConsoleResult().
void testLiteWalletControllerConsoleCommand()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
LiteConnectionSettings conn;
conn.chainName = "main";
conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} };
dragonx::test::resetLiteFakeCounters();
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const auto drainConsole = [&](LiteConsoleResult& res) {
for (int i = 0; i < 400 && !controller.takeConsoleResult(res); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
};
// A single-token command (no args) returns the backend's raw response.
{
EXPECT_TRUE(controller.runConsoleCommand("info"));
LiteConsoleResult res;
drainConsole(res);
EXPECT_TRUE(res.ok);
EXPECT_EQ(res.command, std::string("info"));
EXPECT_TRUE(res.response.find("sdxl-fake") != std::string::npos);
}
// "<command> <args>" splits into the command token + the remainder as the single arg string
// (new zs -> the shielded-address path of the fake backend).
{
EXPECT_TRUE(controller.runConsoleCommand("new zs"));
LiteConsoleResult res;
drainConsole(res);
EXPECT_TRUE(res.ok);
EXPECT_TRUE(res.response.find("zs1fakenew") != std::string::npos);
}
// A blank line is rejected (nothing to run).
EXPECT_FALSE(controller.runConsoleCommand(" "));
}
// Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also
// fails over: the request runs off the UI thread against the preferred server, then the other
// usable defaults, finalized by pumpLifecycleResult() on the main thread.
void testLiteWalletControllerAsyncLifecycleFailover()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true);
LiteConnectionSettings conn;
conn.chainName = "main";
conn.servers = {
LiteServerEndpoint{"https://dead.example", "Dead", true},
LiteServerEndpoint{"https://good.example", "Good", true},
};
conn.selectionMode = LiteServerSelectionMode::Sticky;
conn.stickyServerUrl = "https://dead.example"; // preferred server is the dead one
const auto drain = [](LiteWalletController& c) {
for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
c.pumpLifecycleResult();
};
// Async create with a passphrase: preferred dead, fallback good -> wallet created via fallback.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletCreateRequest req;
req.passphrase = "hunter2";
EXPECT_TRUE(controller.beginCreateWalletAsync(req));
drain(controller);
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.lastLifecycleResult().walletReady);
}
// Async restore from seed: preferred dead, fallback good -> wallet restored via fallback.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr = "dead.example";
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletRestoreRequest req;
req.seedPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon abandon "
"abandon abandon abandon abandon abandon abandon abandon art";
req.birthday = 0;
EXPECT_TRUE(controller.beginRestoreWalletAsync(req));
drain(controller);
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.lastLifecycleResult().walletReady);
}
// All servers dead -> async lifecycle fails, wallet stays closed, reason surfaced.
{
dragonx::test::resetLiteFakeCounters();
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr = "example"; // both servers fail
LiteWalletController controller(liteCaps, conn,
LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.beginCreateWalletAsync(LiteWalletCreateRequest{}));
drain(controller);
EXPECT_FALSE(controller.walletOpen());
EXPECT_FALSE(controller.lastLifecycleResult().walletReady);
EXPECT_TRUE(!controller.lastOpenError().empty());
}
dragonx::test::g_liteFakeWalletExists = false;
dragonx::test::g_liteFakeDeadServerSubstr.clear();
dragonx::test::g_liteFakeWarmupServerSubstr.clear();
}
// M2: a parsed lite refresh bundle maps through to the app's WalletState (the last hop
// the Balance/Receive/Transactions tabs read), with zatoshi->DRGX conversion, z/t address
// split, transaction typing, confirmations, and sync progress.
void testLiteRefreshModelAppliesToWalletState()
{
using namespace dragonx::wallet;
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.successfulCommandCount = 4;
bundle.hasBalance = true;
bundle.balance.shieldedBalance = 200000000; // 2 DRGX
bundle.balance.transparentBalance = 100000000; // 1 DRGX
bundle.balance.unconfirmedBalance = 50000000; // 0.5 DRGX
bundle.hasAddresses = true;
bundle.addresses.zAddresses = {"zs1shielded"};
bundle.addresses.tAddresses = {"t1transparent"};
bundle.hasTransactions = true;
LiteTransactionRecord rec;
rec.txid = "deadbeef";
rec.datetime = 1700000000;
rec.blockHeight = 990;
rec.unconfirmed = false;
rec.direction = LiteTransactionDirection::Receive;
rec.address = "zs1shielded";
rec.amount = 150000000; // 1.5 DRGX
bundle.transactions.transactions.push_back(rec);
bundle.hasSyncStatus = true;
bundle.syncStatus.syncedBlocks = 1000;
bundle.syncStatus.totalBlocks = 1000;
bundle.syncStatus.progress = 1.0;
bundle.syncStatus.complete = true;
const auto mapped = mapLiteWalletRefreshBundle(bundle);
EXPECT_TRUE(mapped.ok);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapped.model, state);
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
EXPECT_NEAR(state.totalBalance, 3.0, 1e-9);
EXPECT_NEAR(state.unconfirmedBalance, 0.5, 1e-9);
EXPECT_EQ(static_cast<int>(state.addresses.size()), 2);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
EXPECT_EQ(state.z_addresses[0].type, std::string("shielded"));
EXPECT_EQ(state.t_addresses[0].type, std::string("transparent"));
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
EXPECT_EQ(state.transactions[0].confirmations, 11); // chain 1000 - block 990 + 1
EXPECT_EQ(state.sync.blocks, 1000);
EXPECT_EQ(state.sync.headers, 1000);
EXPECT_FALSE(state.sync.syncing);
}
// A Send record carries its recipient in outgoing_metadata, not the top-level address/memo —
// the transactions list must surface the destination + memo instead of blanks.
void testLiteSendShowsRecipientFromOutgoing()
{
using namespace dragonx::wallet;
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.hasTransactions = true;
LiteTransactionRecord rec;
rec.txid = "sendtx";
rec.datetime = 1700000000;
rec.unconfirmed = true;
rec.direction = LiteTransactionDirection::Send;
rec.address = ""; // sends have no top-level address
rec.memo = "";
LiteTransactionOutput out;
out.address = "zs1recipient";
out.value = 75000000; // 0.75 DRGX
out.memo = "thanks";
rec.outgoingMetadata.push_back(out);
bundle.transactions.transactions.push_back(rec);
const auto mapped = mapLiteWalletRefreshBundle(bundle);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapped.model, state);
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
EXPECT_EQ(state.transactions[0].type, std::string("send"));
EXPECT_EQ(state.transactions[0].address, std::string("zs1recipient"));
EXPECT_EQ(state.transactions[0].memo, std::string("thanks"));
}
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
void testLitePartialRefreshKeepsPriorAddressBalances()
{
using namespace dragonx::wallet;
// First, a full refresh establishes a per-address balance.
LiteWalletRefreshBundle full;
full.complete = true;
full.hasAddresses = true;
full.addresses.zAddresses = {"zs1keep"};
full.hasNotes = true;
LiteSpendableOutput note;
note.address = "zs1keep";
note.value = 120000000; // 1.2 DRGX
note.spent = false;
full.notes.unspentNotes.push_back(note);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(full).model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9);
// Now a partial refresh: addresses present, notes command failed (hasNotes=false).
LiteWalletRefreshBundle partial;
partial.complete = true;
partial.hasAddresses = true;
partial.addresses.zAddresses = {"zs1keep"};
partial.hasNotes = false; // notes/utxo failed this cycle
applyLiteRefreshModelToWalletState(mapLiteWalletRefreshBundle(partial).model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_NEAR(state.z_addresses[0].balance, 1.2, 1e-9); // preserved, not zeroed
}
// M2b: the controller, after a wallet is ready, auto-starts sync and refreshWalletState()
// pulls balance/addresses/transactions/syncstatus through the shared bridge into WalletState.
void testLiteWalletControllerRefreshPopulatesState()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
EXPECT_TRUE(controller.walletOpen());
EXPECT_TRUE(controller.syncStarted()); // auto-started when the wallet became ready
// Sync runs on a detached thread; the full refresh (balance/addresses) only runs once it
// completes. Wait for it (instant with the fake) so the refresh is deterministic.
for (int i = 0; i < 500 && !controller.syncComplete(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(5));
EXPECT_TRUE(controller.syncComplete());
dragonx::WalletState state;
EXPECT_TRUE(controller.refreshWalletState(state));
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
EXPECT_NEAR(state.transparentBalance, 1.0, 1e-9);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
EXPECT_EQ(static_cast<int>(state.transactions.size()), 1);
EXPECT_EQ(state.transactions[0].type, std::string("receive"));
EXPECT_NEAR(state.transactions[0].amount, 1.5, 1e-9);
EXPECT_EQ(state.sync.headers, 1000);
// No wallet open -> refresh is a safe no-op
LiteWalletController fresh(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
dragonx::WalletState empty;
EXPECT_FALSE(fresh.refreshWalletState(empty));
}
// syncstatus parser must accept both real backend shapes: idle {"syncing":"false"} and
// in-progress {"syncing":"true","synced_blocks":N,"total_blocks":M} ("syncing" is a string).
void testLiteSyncStatusParserRealShapes()
{
using namespace dragonx::wallet;
// Idle: no block counts, syncing is the string "false".
{
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"false\"}"));
EXPECT_TRUE(p.ok);
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 0LL);
EXPECT_TRUE(p.syncStatus.complete);
}
// In-progress: block counts present.
{
const auto p = parseLiteSyncStatusResponse(
std::string("{\"syncing\":\"true\",\"synced_blocks\":900,\"total_blocks\":1000}"));
EXPECT_TRUE(p.ok);
EXPECT_EQ(static_cast<long long>(p.syncStatus.syncedBlocks), 900LL);
EXPECT_EQ(static_cast<long long>(p.syncStatus.totalBlocks), 1000LL);
EXPECT_FALSE(p.syncStatus.complete);
EXPECT_NEAR(p.syncStatus.progress, 0.9, 1e-9);
}
// While syncing, missing block counts is still an error.
{
const auto p = parseLiteSyncStatusResponse(std::string("{\"syncing\":\"true\"}"));
EXPECT_FALSE(p.ok);
}
}
// Per-address balances: unspent notes/utxos are summed per address (spent ones excluded)
// into the WalletState address list, instead of aggregate-only zeros.
void testLitePerAddressBalances()
{
using namespace dragonx::wallet;
LiteWalletRefreshBundle bundle;
bundle.complete = true;
bundle.hasAddresses = true;
bundle.addresses.zAddresses = {"zs1aaa"};
bundle.addresses.tAddresses = {"t1bbb"};
bundle.hasNotes = true;
LiteSpendableOutput note; note.address = "zs1aaa"; note.value = 150000000; note.createdInTxid = "n1";
LiteSpendableOutput utxo; utxo.address = "t1bbb"; utxo.value = 50000000; utxo.createdInTxid = "u1";
LiteSpendableOutput spentNote; spentNote.address = "zs1aaa"; spentNote.value = 999; spentNote.spent = true; spentNote.createdInTxid = "n2";
bundle.notes.unspentNotes.push_back(note);
bundle.notes.unspentNotes.push_back(spentNote); // excluded: spent
bundle.notes.utxos.push_back(utxo);
const auto mapped = mapLiteWalletRefreshBundle(bundle);
EXPECT_TRUE(mapped.ok);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(mapped.model, state);
EXPECT_EQ(static_cast<int>(state.z_addresses.size()), 1);
EXPECT_EQ(static_cast<int>(state.t_addresses.size()), 1);
EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // 150000000; the spent note is excluded
EXPECT_NEAR(state.t_addresses[0].balance, 0.5, 1e-9); // 50000000
}
// M3: new-address generation via the controller (backend "new" zs/R) returns the address.
void testLiteWalletControllerNewAddress()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady);
const auto z = controller.newAddress(/*shielded*/ true);
EXPECT_TRUE(z.ok);
EXPECT_TRUE(z.address.rfind("zs1", 0) == 0);
const auto t = controller.newAddress(/*shielded*/ false);
EXPECT_TRUE(t.ok);
EXPECT_TRUE(t.address.rfind("R1", 0) == 0);
// No wallet open -> error, no address.
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
const auto none = idle.newAddress(true);
EXPECT_FALSE(none.ok);
}
// Gateway hardening: one command's parse failure must not abort the whole refresh — the
// other commands still populate the bundle (graceful degradation against real-shape drift).
void testLiteWalletGatewayRefreshSkipsFailedCommand()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi());
LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{/*allowBridgeCalls*/ true});
dragonx::test::g_liteFakeBadBalance.store(true); // make only the balance command fail to parse
const auto result = gateway.refresh(LiteWalletRefreshRequest{});
dragonx::test::g_liteFakeBadBalance.store(false);
EXPECT_TRUE(result.ok); // partial success, not a total failure
EXPECT_FALSE(result.bundle.complete); // not all commands succeeded
EXPECT_FALSE(result.bundle.hasBalance); // the failed command is skipped
EXPECT_TRUE(result.bundle.hasAddresses); // the others still populate
EXPECT_TRUE(result.bundle.hasTransactions);
EXPECT_TRUE(result.bundle.hasInfo);
}
// M2b-3: opening a wallet auto-starts the background worker, which produces a refresh model
// the main thread can pick up via takeRefreshedModel() and apply to WalletState.
void testLiteWalletControllerWorkerProducesModel()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); // auto-starts the worker
// The worker publishes progress-only models while syncing, then full models once synced.
// Poll until a full (balance-bearing) model arrives (sync is instant with the fake).
LiteWalletAppRefreshModel model;
bool gotFull = false;
for (int i = 0; i < 500 && !gotFull; ++i) {
LiteWalletAppRefreshModel m;
if (controller.takeRefreshedModel(m) && m.hasBalance) {
model = m;
gotFull = true;
break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
EXPECT_TRUE(gotFull);
EXPECT_TRUE(model.hasBalance);
EXPECT_TRUE(model.hasAddresses);
dragonx::WalletState state;
applyLiteRefreshModelToWalletState(model, state);
EXPECT_NEAR(state.privateBalance, 2.0, 1e-9);
// Idle controller (no wallet -> no worker) has nothing pending.
LiteWalletController idle(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
LiteWalletAppRefreshModel none;
EXPECT_FALSE(idle.takeRefreshedModel(none));
}
// M2b-3 hardening: the backend `sync` is a blocking, uninterruptible full scan. Destroying the
// controller while a sync is in flight must NOT hang (the sync thread is detached, not joined).
void testLiteWalletControllerShutdownDoesNotHangDuringSync()
{
using namespace dragonx::wallet;
const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
dragonx::test::g_liteFakeSyncBlock.store(true); // make the backend "sync" block indefinitely
const auto start = std::chrono::steady_clock::now();
{
LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()));
controller.createWallet(LiteWalletCreateRequest{}); // launches the (now-blocked) sync thread
EXPECT_TRUE(controller.syncStarted());
EXPECT_FALSE(controller.syncComplete());
// controller destructs here with the sync thread still blocked -> must return promptly.
}
const auto elapsedMs = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
EXPECT_TRUE(elapsedMs < 1500); // did not wait for the (blocked) sync to finish
dragonx::test::g_liteFakeSyncBlock.store(false); // release the detached sync thread
std::this_thread::sleep_for(std::chrono::milliseconds(50)); // let it unwind cleanly
}
// M5b: lite-wallet rollout / kill-switch policy (wallet/lite_rollout_policy.h).
void testLiteRolloutVersionCompare()
{
using namespace dragonx::wallet;
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2.0"), 0);
EXPECT_EQ(compareLiteVersions("1.2.0", "1.2"), 0); // missing components treated as 0
EXPECT_EQ(compareLiteVersions("1.2.0-rc1", "1.2.0"), 0); // pre-release suffix ignored
EXPECT_EQ(compareLiteVersions("1.2.0", "1.3.0"), -1);
EXPECT_EQ(compareLiteVersions("2.0.0", "1.9.9"), 1);
EXPECT_EQ(compareLiteVersions("1.2.10", "1.2.9"), 1); // numeric, not lexical
}
void testLiteRolloutBucketAndOverrideHelpers()
{
using namespace dragonx::wallet;
const int a = liteRolloutBucketFromInstallId("install-abc");
EXPECT_EQ(a, liteRolloutBucketFromInstallId("install-abc")); // deterministic
EXPECT_TRUE(a >= 0 && a < 1000); // in range
EXPECT_EQ(liteRolloutBucketFromInstallId(""), 0); // empty -> 0
EXPECT_TRUE(liteRolloutBucketFromInstallId("install-abc")
!= liteRolloutBucketFromInstallId("install-xyz"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_on"))),
std::string("force_on"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("force_off"))),
std::string("force_off"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("auto"))),
std::string("auto"));
EXPECT_EQ(std::string(liteRolloutOverrideToString(liteRolloutOverrideFromString("garbage"))),
std::string("auto")); // unknown -> auto
}
void testLiteRolloutPolicyOverridesAndKillSwitch()
{
using namespace dragonx::wallet;
LiteRolloutInputs in;
in.appVersion = "1.2.0";
// No manifest, auto -> allowed (fail-open).
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
// force_off -> disabled by local override.
in.override = LiteRolloutOverride::ForceOff;
auto off = evaluateLiteRollout(in);
EXPECT_FALSE(off.allowed);
EXPECT_TRUE(off.status == LiteRolloutStatus::DisabledByLocalOverride);
// force_on -> allowed, even with a disabling manifest.
in.override = LiteRolloutOverride::ForceOn;
in.manifest.present = true; in.manifest.valid = true; in.manifest.globalEnabled = false;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
// env kill-switch is absolute: disables even with force_on.
in.killSwitchEnv = true;
auto killed = evaluateLiteRollout(in);
EXPECT_FALSE(killed.allowed);
EXPECT_TRUE(killed.status == LiteRolloutStatus::DisabledByKillSwitchEnv);
}
void testLiteRolloutPolicyManifestGates()
{
using namespace dragonx::wallet;
LiteRolloutInputs in;
in.appVersion = "1.2.0";
in.manifest.present = true;
in.manifest.valid = true;
// Global disable carries the manifest message through to the decision.
in.manifest.globalEnabled = false;
in.manifest.message = "paused for maintenance";
{
auto d = evaluateLiteRollout(in);
EXPECT_FALSE(d.allowed);
EXPECT_TRUE(d.status == LiteRolloutStatus::DisabledByManifest);
EXPECT_EQ(d.message, std::string("paused for maintenance"));
}
in.manifest.globalEnabled = true;
in.manifest.message.clear();
// Version floor / ceiling / blocklist.
in.manifest.minVersion = "1.3.0";
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
in.manifest.minVersion.clear();
in.manifest.maxVersion = "1.1.0";
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledUnsupportedVersion);
in.manifest.maxVersion.clear();
in.manifest.blockedVersions = {"1.2.0"};
EXPECT_TRUE(evaluateLiteRollout(in).status == LiteRolloutStatus::DisabledBlockedVersion);
in.manifest.blockedVersions.clear();
// Within range and not blocked -> allowed.
in.manifest.minVersion = "1.0.0";
in.manifest.maxVersion = "2.0.0";
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
void testLiteRolloutPolicyStagedRollout()
{
using namespace dragonx::wallet;
LiteRolloutInputs in;
in.appVersion = "1.2.0";
in.manifest.present = true;
in.manifest.valid = true;
in.manifest.globalEnabled = true;
in.manifest.rolloutPermille = 0; // nobody (bucket 0 >= 0)
in.installBucket = 0;
EXPECT_FALSE(evaluateLiteRollout(in).allowed);
in.manifest.rolloutPermille = 500; // bucket < permille is in
in.installBucket = 499;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
in.installBucket = 500;
auto out = evaluateLiteRollout(in);
EXPECT_FALSE(out.allowed);
EXPECT_TRUE(out.status == LiteRolloutStatus::DisabledByStagedRollout);
in.manifest.rolloutPermille = 1000; // everyone (bucket 999 < 1000)
in.installBucket = 999;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
void testLiteRolloutManifestLoader()
{
using namespace dragonx::wallet;
// Missing file -> not present -> fail-open allowed.
auto missing = loadLiteRolloutManifestFromFile("/tmp/obsidian-rollout-missing-xyz.json");
EXPECT_FALSE(missing.present);
{
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = missing;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
// A valid manifest file parses into the expected fields.
const std::string path = "/tmp/obsidian-rollout-test.json";
{
std::ofstream f(path);
f << R"({"global_enabled":false,"min_version":"1.0.0","rollout_permille":250,"message":"hold"})";
}
auto m = loadLiteRolloutManifestFromFile(path);
EXPECT_TRUE(m.present);
EXPECT_TRUE(m.valid);
EXPECT_FALSE(m.globalEnabled);
EXPECT_EQ(m.minVersion, std::string("1.0.0"));
EXPECT_EQ(m.rolloutPermille, 250);
EXPECT_EQ(m.message, std::string("hold"));
std::remove(path.c_str());
// Malformed JSON -> present but invalid -> fail-open allowed.
const std::string badPath = "/tmp/obsidian-rollout-bad.json";
{ std::ofstream f(badPath); f << "{ this is not json"; }
auto bad = loadLiteRolloutManifestFromFile(badPath);
EXPECT_TRUE(bad.present);
EXPECT_FALSE(bad.valid);
{
LiteRolloutInputs in; in.appVersion = "1.2.0"; in.manifest = bad;
EXPECT_TRUE(evaluateLiteRollout(in).allowed);
}
std::remove(badPath.c_str());
}
// M5b: the rollout gate threads through the controller -> lifecycle availability() and blocks
// lifecycle execution with the gate's user-facing message.
void testLiteWalletControllerRolloutGate()
{
using namespace dragonx::wallet;
const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true);
const auto conn = defaultLiteConnectionSettings();
// Gated OFF: availability() reports RolloutDisabled, create is blocked, wallet stays closed.
{
LiteWalletController controller(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ true,
"lite paused for maintenance"});
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::RolloutDisabled);
const auto result = controller.createWallet(LiteWalletCreateRequest{});
EXPECT_FALSE(result.walletReady);
EXPECT_TRUE(result.status.message.find("maintenance") != std::string::npos);
EXPECT_FALSE(controller.walletOpen());
}
// Not gated: availability() is Ready (matches the other lifecycle tests).
{
LiteWalletController controller(
liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()),
LiteWalletControllerOptions{/*allowBridgeCalls*/ true, /*rolloutBlocked*/ false, ""});
EXPECT_TRUE(controller.availability() == LiteWalletLifecycleAvailability::Ready);
}
}
// ── xmrig updater pure core (util/xmrig_updater.h) — driven by the real release fixture ──
static std::string readXmrigFixture()
{
const std::string path = std::string(DRAGONX_TEST_FIXTURE_DIR) + "/xmrig/release_latest.json";
std::ifstream f(path, std::ios::binary);
return std::string((std::istreambuf_iterator<char>(f)), std::istreambuf_iterator<char>());
}
void testXmrigReleaseParsing()
{
using namespace dragonx::util;
const auto rel = parseXmrigRelease(readXmrigFixture());
EXPECT_TRUE(rel.ok);
EXPECT_EQ(rel.tag, std::string("v1.0.0"));
EXPECT_EQ(rel.assets.size(), static_cast<std::size_t>(2));
// Both assets carry a name + a usable download URL.
for (const auto& a : rel.assets) {
EXPECT_TRUE(!a.name.empty());
EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0);
EXPECT_TRUE(a.size > 0);
}
// Garbage input fails closed.
EXPECT_FALSE(parseXmrigRelease("not json at all").ok);
EXPECT_FALSE(parseXmrigRelease("{}").ok); // no tag_name
}
void testXmrigAssetSelection()
{
using namespace dragonx::util;
const auto rel = parseXmrigRelease(readXmrigFixture());
const int linux = selectXmrigAsset(rel, "linux-x64");
const int win = selectXmrigAsset(rel, "win-x64");
EXPECT_TRUE(linux >= 0);
EXPECT_TRUE(win >= 0);
EXPECT_TRUE(linux != win);
EXPECT_TRUE(rel.assets[linux].name.find("linux-x64.zip") != std::string::npos);
EXPECT_TRUE(rel.assets[win].name.find("win-x64.zip") != std::string::npos);
// No macOS build in this fixture -> graceful "not found".
EXPECT_EQ(selectXmrigAsset(rel, "macos-x86_64"), -1);
EXPECT_EQ(selectXmrigAsset(rel, "macos-arm64"), -1);
EXPECT_EQ(selectXmrigAsset(rel, ""), -1);
// The matcher handles the macOS asset naming ("macos-x86_64", not "macos-x64").
XmrigRelease mac; mac.ok = true; mac.tag = "v6.25.3";
mac.assets.push_back({"drg-xmrig-6.25.3-macos-x86_64.zip", "https://x/m", 100});
EXPECT_TRUE(selectXmrigAsset(mac, "macos-x86_64") >= 0);
EXPECT_EQ(selectXmrigAsset(mac, "macos-x64"), -1); // the wrong token must NOT match
}
void testXmrigChecksumParsing()
{
using namespace dragonx::util;
const auto rel = parseXmrigRelease(readXmrigFixture());
const auto sums = parseXmrigChecksums(rel.body);
// Inner-binary checksums (what we verify before making the binary executable).
EXPECT_EQ(sums.at("xmrig"),
std::string("37c178f743c269c1d9e18302cead0ed117ded2b5fe30910e836896b4abc20e57"));
EXPECT_EQ(sums.at("xmrig.exe"),
std::string("01223711eddea347eee394c4b6d265b9a3e5c13fe93204bc041c4399c9c758f8"));
// Archive checksum, keyed by the asset filename.
EXPECT_EQ(sums.at("drg-xmrig-6.25.1-drg1-linux-x64.zip"),
std::string("c121a078ee46943584aa6148cac26583409c832d28668cf38ba908e10214c9a6"));
// Non-checksum prose lines are ignored.
EXPECT_TRUE(parseXmrigChecksums("just some text\nno hashes here").empty());
}
void testXmrigSha256AndBasenames()
{
using namespace dragonx::util;
// Known-answer: SHA-256("abc").
const std::string abc = "abc";
EXPECT_EQ(sha256Hex(abc.data(), abc.size()),
std::string("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"));
// The selected asset's published archive checksum matches what the verifier will compute over
// the bytes (closes the loop between parse + hash). Empty input is the SHA-256 of "".
EXPECT_EQ(sha256Hex("", 0),
std::string("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"));
const auto linuxNames = xmrigExtractBasenames("linux-x64");
EXPECT_EQ(linuxNames.size(), static_cast<std::size_t>(1));
EXPECT_EQ(linuxNames.front(), std::string("xmrig"));
const auto winNames = xmrigExtractBasenames("win-x64");
EXPECT_EQ(winNames.size(), static_cast<std::size_t>(2));
EXPECT_EQ(winNames.front(), std::string("xmrig.exe")); // miner binary is always first
EXPECT_TRUE(std::find(winNames.begin(), winNames.end(), std::string("WinRing0x64.sys"))
!= winNames.end());
}
void testXmrigSignatureAssetSelection()
{
using namespace dragonx::util;
XmrigRelease rel; rel.ok = true; rel.tag = "v1";
rel.assets.push_back({"drg-xmrig-linux-x64.zip", "https://x/zip", 100});
EXPECT_EQ(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip"), -1); // none published
rel.assets.push_back({"drg-xmrig-linux-x64.zip.sig", "https://x/sig", 64});
EXPECT_TRUE(selectXmrigSignatureAsset(rel, "drg-xmrig-linux-x64.zip") >= 0);
EXPECT_EQ(selectXmrigSignatureAsset(rel, "other.zip"), -1);
}
void testXmrigPinnedKeyValidity()
{
using namespace dragonx::util;
const std::string pin = kXmrigSignaturePublicKeyBase64;
if (pin.empty()) return; // signing disabled -> nothing to validate
// A non-empty pinned key MUST decode to exactly a 32-byte ed25519 public key, or signature
// verification would silently never succeed. Guards against a malformed/truncated paste.
EXPECT_TRUE(sodium_init() >= 0);
unsigned char pk[crypto_sign_PUBLICKEYBYTES];
std::size_t n = 0; const char* end = nullptr;
const int rc = sodium_base642bin(pk, sizeof(pk), pin.data(), pin.size(), " \t\r\n",
&n, &end, sodium_base64_VARIANT_ORIGINAL);
EXPECT_EQ(rc, 0);
EXPECT_EQ(n, static_cast<std::size_t>(crypto_sign_PUBLICKEYBYTES));
}
// Lite Network tab pure helpers.
void testLiteServerHostParsing()
{
using dragonx::util::liteServerHost;
EXPECT_EQ(liteServerHost("https://lite.dragonx.is"), std::string("lite.dragonx.is"));
EXPECT_EQ(liteServerHost("https://lite.dragonx.is:443/path"), std::string("lite.dragonx.is:443"));
EXPECT_EQ(liteServerHost("http://1.2.3.4:9067"), std::string("1.2.3.4:9067"));
EXPECT_EQ(liteServerHost("https://user@host.example/x?y"), std::string("host.example"));
EXPECT_EQ(liteServerHost("lite.dragonx.is"), std::string("lite.dragonx.is")); // no scheme
}
void testLiteOfficialServerDetection()
{
using dragonx::wallet::isOfficialLiteServer;
EXPECT_TRUE(isOfficialLiteServer("https://lite.dragonx.is"));
EXPECT_TRUE(isOfficialLiteServer("https://lite3.dragonx.is"));
EXPECT_TRUE(isOfficialLiteServer(" https://lite.dragonx.is ")); // trimmed
EXPECT_FALSE(isOfficialLiteServer("https://my-custom-server.example"));
EXPECT_FALSE(isOfficialLiteServer(""));
}
// H7: atomic + durable file writes (settings.json / addressbook.json persistence).
void testAtomicFileWrite()
{
namespace fs = std::filesystem;
using dragonx::util::Platform;
fs::path dir = fs::temp_directory_path() / "obsidian_atomic_test";
fs::remove_all(dir);
const std::string target = (dir / "nested" / "settings.json").string();
// Creates parent dirs and writes content.
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":1}"));
EXPECT_TRUE(fs::exists(target));
{
std::ifstream in(target);
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
EXPECT_EQ(body, std::string("{\"a\":1}"));
}
// Overwrites in place (atomic replace), and leaves no stray .tmp behind.
EXPECT_TRUE(Platform::writeFileAtomically(target, "{\"a\":2,\"b\":3}"));
{
std::ifstream in(target);
std::string body((std::istreambuf_iterator<char>(in)), std::istreambuf_iterator<char>());
EXPECT_EQ(body, std::string("{\"a\":2,\"b\":3}"));
}
EXPECT_FALSE(fs::exists(target + ".tmp"));
#ifndef _WIN32
// restrictPermissions -> owner-only (0600).
const std::string secret = (dir / "vault.bin").string();
EXPECT_TRUE(Platform::writeFileAtomically(secret, "shh", /*restrictPermissions=*/true));
auto perms = fs::status(secret).permissions();
EXPECT_TRUE((perms & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none);
#endif
fs::remove_all(dir);
}
// Address checksum validation. Uses standard Base58Check / BIP173 Bech32 vectors — the
// algorithms are chain-agnostic, so passing these means real DRGX addresses validate too.
void testAddressChecksumValidation()
{
using dragonx::util::isValidBase58Check;
using dragonx::util::isValidBech32;
// Base58Check: a valid P2PKH address verifies; a one-char transcription error fails.
EXPECT_TRUE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
EXPECT_FALSE(isValidBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna")); // last char flipped
EXPECT_FALSE(isValidBase58Check("not-base58-0OIl")); // invalid alphabet
EXPECT_FALSE(isValidBase58Check(""));
// Bech32 (BIP173 valid vectors) verify; corrupted checksums fail.
EXPECT_TRUE(isValidBech32("A12UEL5L"));
EXPECT_TRUE(isValidBech32("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"));
EXPECT_TRUE(isValidBech32("split1checkupstagehandshakeupstreamerranterredcaperred2y9e3w"));
EXPECT_FALSE(isValidBech32("A12UEL5M")); // checksum corrupted
EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum
EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case
EXPECT_FALSE(isValidBech32("nosalt")); // no separator
}
// Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture.
void testLiteServerProbeLive()
{
if (!std::getenv("DRAGONX_TEST_NETWORK")) {
std::printf("[skip] testLiteServerProbeLive (set DRAGONX_TEST_NETWORK=1 to run)\n");
return;
}
using namespace dragonx::util;
LiteServerProbe probe;
probe.start({"https://lite.dragonx.is"});
for (int i = 0; i < 150 && probe.busy(); ++i)
std::this_thread::sleep_for(std::chrono::milliseconds(100));
const auto res = probe.results();
const auto it = res.find("https://lite.dragonx.is");
EXPECT_TRUE(it != res.end());
if (it != res.end()) {
EXPECT_TRUE(it->second.probed);
if (it->second.online) { // reachable -> latency + IP captured
EXPECT_TRUE(!it->second.ip.empty());
std::printf("[live] lite.dragonx.is online %dms ip=%s\n",
it->second.latencyMs, it->second.ip.c_str());
}
}
}
void testXmrigSignatureVerify()
{
using namespace dragonx::util;
EXPECT_TRUE(sodium_init() >= 0);
unsigned char pk[crypto_sign_PUBLICKEYBYTES], sk[crypto_sign_SECRETKEYBYTES];
crypto_sign_keypair(pk, sk);
const std::string data = "drg-xmrig archive payload bytes for signing test";
unsigned char sig[crypto_sign_BYTES];
crypto_sign_detached(sig, nullptr,
reinterpret_cast<const unsigned char*>(data.data()), data.size(), sk);
auto b64 = [](const unsigned char* b, std::size_t n) {
std::vector<char> buf(sodium_base64_encoded_len(n, sodium_base64_VARIANT_ORIGINAL));
sodium_bin2base64(buf.data(), buf.size(), b, n, sodium_base64_VARIANT_ORIGINAL);
return std::string(buf.data());
};
const std::string pkB64 = b64(pk, sizeof(pk));
const std::string sigB64 = b64(sig, sizeof(sig));
// Valid base64 signature verifies; a raw 64-byte signature is also accepted.
EXPECT_TRUE(verifyXmrigSignature(data, sigB64, pkB64));
EXPECT_TRUE(verifyXmrigSignature(data, std::string(reinterpret_cast<const char*>(sig),
crypto_sign_BYTES), pkB64));
// Whitespace around the base64 signature is tolerated.
EXPECT_TRUE(verifyXmrigSignature(data, " " + sigB64 + "\n", pkB64));
// Regression: a RAW 64-byte signature whose final byte equals a whitespace value (~1.6% of
// signatures) must still verify — it must not be whitespace-trimmed. Force that case.
{
unsigned char rpk[crypto_sign_PUBLICKEYBYTES], rsk[crypto_sign_SECRETKEYBYTES],
rsig[crypto_sign_BYTES];
bool found = false;
for (int i = 0; i < 20000 && !found; ++i) {
crypto_sign_keypair(rpk, rsk);
const std::string m = "raw-ws-regression-" + std::to_string(i);
crypto_sign_detached(rsig, nullptr,
reinterpret_cast<const unsigned char*>(m.data()), m.size(), rsk);
const unsigned char last = rsig[crypto_sign_BYTES - 1];
if (last == '\n' || last == '\r' || last == ' ' || last == '\t') {
found = true;
const std::string rawSig(reinterpret_cast<const char*>(rsig), crypto_sign_BYTES);
EXPECT_TRUE(verifyXmrigSignature(m, rawSig, b64(rpk, sizeof(rpk))));
}
}
EXPECT_TRUE(found);
}
// Fails closed: tampered data, wrong key, malformed/empty inputs.
EXPECT_FALSE(verifyXmrigSignature(data + "x", sigB64, pkB64));
unsigned char pk2[crypto_sign_PUBLICKEYBYTES], sk2[crypto_sign_SECRETKEYBYTES];
crypto_sign_keypair(pk2, sk2);
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, b64(pk2, sizeof(pk2))));
EXPECT_FALSE(verifyXmrigSignature(data, "not valid base64 !!!", pkB64));
EXPECT_FALSE(verifyXmrigSignature(data, sigB64, ""));
EXPECT_FALSE(verifyXmrigSignature(data, "", pkB64));
}
// Live end-to-end exercise of the XmrigUpdater WORKER (real network + curl + miniz). Env-gated so
// CI / offline runs skip it; run with DRAGONX_TEST_NETWORK=1 to hit git.dragonx.is. Verifies the
// full download -> archive-checksum -> extract/flatten -> inner-binary-checksum -> install path.
void testXmrigLiveInstall()
{
if (!std::getenv("DRAGONX_TEST_NETWORK")) {
std::printf("[skip] testXmrigLiveInstall (set DRAGONX_TEST_NETWORK=1 to run)\n");
return;
}
using namespace dragonx::util;
const std::string dir = "/tmp/obsidian-xmrig-live-test";
std::error_code ec; std::filesystem::remove_all(dir, ec);
XmrigUpdater up;
up.startInstall(dir);
for (int i = 0; i < 1200 && !up.isDone(); ++i) // up to ~120s for a ~4 MiB download
std::this_thread::sleep_for(std::chrono::milliseconds(100));
const auto p = up.getProgress();
EXPECT_TRUE(up.isDone());
EXPECT_TRUE(p.state == XmrigUpdater::State::Done);
if (p.state != XmrigUpdater::State::Done) {
std::printf("[testXmrigLiveInstall] failed: %s\n", p.error.c_str());
return;
}
// The miner binary was flattened out of the versioned subdir into the target dir. We don't pin
// a release-specific hash here (releases change) — reaching State::Done already means the worker
// verified the binary against the release's own published SHA-256. Just assert a real binary landed.
const std::string bin = dir + "/" + xmrigExtractBasenames(currentXmrigPlatformToken()).front();
EXPECT_TRUE(std::filesystem::exists(bin));
if (std::filesystem::exists(bin)) {
std::error_code szEc;
EXPECT_TRUE(std::filesystem::file_size(bin, szEc) > 100000); // a real miner binary is MBs
}
std::filesystem::remove_all(dir, ec);
}
} // namespace
int main()
{
testConnectionConfig();
testPaymentUri();
testAmountFormatting();
testSpendableFiltering();
testRefreshScheduler();
testNetworkRefreshService();
testNetworkRefreshSnapshotHelpers();
testNetworkRefreshRpcCollectors();
testNetworkRefreshResultModels();
testOperationStatusPollParsing();
testWalletSecurityController();
testWalletSecurityWorkflow();
testWalletSecurityWorkflowExecutor();
testDaemonShutdownPolicy();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testRendererHelpers();
testConsoleInputModel();
testMiningBenchmarkModel();
testBalanceAddressListModel();
testExplorerBlockCache();
testTransactionHistoryCache();
testTransactionHistoryCachePrunesOldWallets();
testTransactionHistoryCacheRefreshApply();
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce();
testLiteBridgeOwnedStringMovePreventsDoubleFree();
testLiteClientBridgeUsesRuntimeOwnedStringCleanup();
testLiteBackendInjectableFakeBridge();
testLiteWalletControllerLifecycle();
testLiteWalletControllerOpenFailover();
testLiteWalletControllerAsyncLifecycleFailover();
testLiteWalletControllerConsoleCommand();
testLiteWalletControllerM4();
testLiteWalletControllerM5Persistence();
testLiteWalletControllerEncryption();
testLiteChainNameMigration();
testLiteRefreshModelAppliesToWalletState();
testLiteSendShowsRecipientFromOutgoing();
testLitePartialRefreshKeepsPriorAddressBalances();
testLitePerAddressBalances();
testLiteWalletControllerNewAddress();
testLiteSyncStatusParserRealShapes();
testLiteWalletControllerRefreshPopulatesState();
testLiteWalletGatewayRefreshSkipsFailedCommand();
testLiteWalletControllerWorkerProducesModel();
testLiteWalletControllerShutdownDoesNotHangDuringSync();
testLiteRolloutVersionCompare();
testLiteRolloutBucketAndOverrideHelpers();
testLiteRolloutPolicyOverridesAndKillSwitch();
testLiteRolloutPolicyManifestGates();
testLiteRolloutPolicyStagedRollout();
testLiteRolloutManifestLoader();
testLiteWalletControllerRolloutGate();
testXmrigReleaseParsing();
testXmrigAssetSelection();
testXmrigChecksumParsing();
testXmrigSha256AndBasenames();
testXmrigSignatureAssetSelection();
testXmrigPinnedKeyValidity();
testXmrigSignatureVerify();
testLiteServerHostParsing();
testLiteOfficialServerDetection();
testAtomicFileWrite();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();
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;
}