#include "daemon/daemon_controller.h" #include "daemon/lifecycle_adapters.h" #include "data/wallet_state.h" #include "rpc/connection.h" #include "resources/embedded_resources.h" #include "services/network_refresh_service.h" #include "services/refresh_scheduler.h" #include "services/wallet_security_controller.h" #include "services/wallet_security_workflow.h" #include "services/wallet_security_workflow_executor.h" #include "ui/windows/balance_address_list.h" #include "ui/windows/balance_recent_tx.h" #include "ui/windows/console_input_model.h" #include "ui/windows/console_output_model.h" #include "ui/windows/console_tab_helpers.h" #include "ui/windows/mining_benchmark.h" #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" #include "util/amount_format.h" #include "util/payment_uri.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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 void expectEqual(const T& actual, const U& expected, const char* actualExpr, const char* expectedExpr, const char* file, int line) { if (actual == expected) return; std::cerr << file << ":" << line << " expected " << actualExpr << " == " << expectedExpr << "\n"; ++g_failures; } void expectNear(double actual, double expected, double epsilon, const char* file, int line) { if (std::abs(actual - expected) <= epsilon) return; std::cerr << file << ":" << line << " expected near " << expected << " (actual=" << actual << ")\n"; ++g_failures; } #define EXPECT_TRUE(expr) expectTrue((expr), #expr, __FILE__, __LINE__) #define EXPECT_FALSE(expr) expectTrue(!(expr), "!(" #expr ")", __FILE__, __LINE__) #define EXPECT_EQ(actual, expected) expectEqual((actual), (expected), #actual, #expected, __FILE__, __LINE__) #define EXPECT_NEAR(actual, expected, epsilon) expectNear((actual), (expected), (epsilon), __FILE__, __LINE__) fs::path makeTempDir() { auto now = std::chrono::steady_clock::now().time_since_epoch().count(); fs::path dir = fs::temp_directory_path() / ("obsidian_phase4_tests_" + std::to_string(now)); fs::create_directories(dir); return dir; } class MockWalletSecurityRpc : public dragonx::services::WalletSecurityController::RpcGateway { public: bool encryptResult = true; std::string encryptError; int encryptCalls = 0; std::string lastPassphrase; bool encryptWallet(const std::string& passphrase, std::string& error) override { ++encryptCalls; lastPassphrase = passphrase; error = encryptError; return encryptResult; } bool unlockWallet(const std::string&, int, std::string&) override { return true; } bool exportWallet(const std::string&, long, std::string&) override { return true; } bool importWallet(const std::string&, long, std::string&) override { return true; } }; class MockDaemonLifecycleRuntime : public dragonx::daemon::DaemonController::LifecycleRuntime { public: std::vector 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 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(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 calls; std::unordered_map> 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 methodNames() const { std::vector 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 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(2)); EXPECT_EQ(indices[0], static_cast(3)); EXPECT_EQ(indices[1], static_cast(1)); auto includeZero = dragonx::sortedSpendableAddressIndices(addresses, false); EXPECT_EQ(includeZero.size(), static_cast(3)); } void testRefreshScheduler() { using dragonx::services::RefreshScheduler; using Timer = RefreshScheduler::Timer; RefreshScheduler scheduler; scheduler.applyPage(dragonx::ui::NavPage::Overview); EXPECT_NEAR(scheduler.intervals().core, 2.0, 0.0001); EXPECT_NEAR(scheduler.intervals().peers, 0.0, 0.0001); scheduler.tick(1.99f); EXPECT_FALSE(scheduler.isDue(Timer::Core)); scheduler.tick(0.02f); EXPECT_TRUE(scheduler.consumeDue(Timer::Core)); EXPECT_FALSE(scheduler.isDue(Timer::Core)); scheduler.markWalletMutationRefresh(); EXPECT_TRUE(scheduler.isDue(Timer::Core)); EXPECT_TRUE(scheduler.isDue(Timer::Transactions)); EXPECT_TRUE(scheduler.isDue(Timer::Addresses)); EXPECT_FALSE(scheduler.isDue(Timer::Peers)); scheduler.applyPage(dragonx::ui::NavPage::Peers); scheduler.markDue(Timer::Peers); EXPECT_TRUE(scheduler.consumeDue(Timer::Peers)); EXPECT_FALSE(scheduler.isDue(Timer::Price)); scheduler.markDue(Timer::Price); EXPECT_TRUE(scheduler.consumeDue(Timer::Price)); EXPECT_FALSE(scheduler.shouldRefreshTransactions(100, 100, false, false)); scheduler.tick(RefreshScheduler::kTxMaxAge); EXPECT_TRUE(scheduler.shouldRefreshTransactions(100, 100, false, false)); } void testNetworkRefreshService() { using dragonx::services::NetworkRefreshService; using Job = NetworkRefreshService::Job; using Timer = NetworkRefreshService::Timer; NetworkRefreshService service; service.applyPage(dragonx::ui::NavPage::Peers); service.markImmediateRefresh(); EXPECT_TRUE(service.consumeDue(Timer::Core)); EXPECT_TRUE(service.consumeDue(Timer::Transactions)); EXPECT_TRUE(service.consumeDue(Timer::Addresses)); EXPECT_TRUE(service.consumeDue(Timer::Peers)); EXPECT_TRUE(service.beginJob(Job::Core)); EXPECT_TRUE(service.jobInProgress(Job::Core)); EXPECT_FALSE(service.beginJob(Job::Core)); service.finishJob(Job::Core); EXPECT_FALSE(service.jobInProgress(Job::Core)); EXPECT_TRUE(service.beginJob(Job::Core)); service.resetJobs(); EXPECT_FALSE(service.jobInProgress(Job::Core)); EXPECT_TRUE(service.beginJob(Job::ConnectionInit)); EXPECT_TRUE(service.jobInProgress(Job::ConnectionInit)); service.finishJob(Job::ConnectionInit); EXPECT_FALSE(service.jobInProgress(Job::ConnectionInit)); auto cancelOld = service.beginDispatch(Job::Core); EXPECT_TRUE(cancelOld.accepted); service.resetJobs(); auto cancelNew = service.beginDispatch(Job::Core); EXPECT_TRUE(cancelNew.accepted); service.cancelDispatch(cancelOld); service.cancelDispatch(cancelNew); EXPECT_EQ(service.stats(Job::Core).staleCallbacks, static_cast(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(1)); EXPECT_EQ(stats.skippedInFlight, static_cast(1)); EXPECT_EQ(stats.lastQueueDepth, static_cast(1)); EXPECT_TRUE(service.completeDispatch(ticket)); stats = service.stats(Job::Addresses); EXPECT_EQ(stats.finished, static_cast(1)); auto pressureTicket = service.beginDispatch(Job::Mining, 3, 3); EXPECT_FALSE(pressureTicket.accepted); stats = service.stats(Job::Mining); EXPECT_EQ(stats.skippedQueuePressure, static_cast(1)); EXPECT_EQ(stats.lastQueueDepth, static_cast(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(2)); EXPECT_EQ(stats.staleCallbacks, static_cast(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(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(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(nullMainCallback)); nullMainCallback(); EXPECT_FALSE(service.jobInProgress(Job::Price)); stats = service.stats(Job::Price); EXPECT_EQ(stats.finished, static_cast(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(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(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(1)); FakeRefreshWorker orderingWorker; std::vector 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({"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(2)); EXPECT_EQ(transparentAddresses[0].type, std::string("transparent")); std::vector 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 transactions; std::set 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(2)); EXPECT_EQ(knownTxids.count("transparent-a"), static_cast(1)); EXPECT_EQ(transactions[0].timestamp, static_cast(100)); EXPECT_EQ(transactions[1].timestamp, static_cast(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(3)); EXPECT_EQ(knownTxids.count("shielded-receive"), static_cast(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 sendTxids{"pending-send"}; auto snapshot = Refresh::buildTransactionRefreshSnapshot(state, viewCache, sendTxids); EXPECT_EQ(snapshot.shieldedAddresses.size(), static_cast(1)); EXPECT_EQ(snapshot.shieldedAddresses[0], std::string("zs-viewonly")); EXPECT_EQ(snapshot.fullyEnrichedTxids.count("cached-view"), static_cast(1)); EXPECT_EQ(snapshot.fullyEnrichedTxids.count("old-complete"), static_cast(1)); EXPECT_EQ(snapshot.viewTxCache.size(), static_cast(1)); EXPECT_EQ(snapshot.sendTxids.count("pending-send"), static_cast(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({"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({"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({ "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(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({ "getinfo", "getwalletinfo" })); EXPECT_FALSE(prefetch.info.ok); EXPECT_TRUE(prefetch.encryption.ok); EXPECT_TRUE(prefetch.encryption.encrypted); EXPECT_EQ(prefetch.encryption.unlockedUntil, static_cast(99)); MockRefreshRpc coreRpc; coreRpc.addResponse("z_gettotalbalance", json{ {"private", "3.00000000"}, {"transparent", "1.25000000"}, {"total", "4.25000000"} }); coreRpc.addResponse("getblockchaininfo", json{ {"blocks", 150}, {"headers", 155}, {"verificationprogress", 0.80}, {"longestchain", 160}, {"notarized", 145} }); auto core = Refresh::collectCoreRefreshResult(coreRpc); EXPECT_TRUE(coreRpc.methodNames() == std::vector({ "z_gettotalbalance", "getblockchaininfo" })); EXPECT_EQ(coreRpc.calls[0].params, json::array()); EXPECT_EQ(coreRpc.calls[1].params, json::array()); EXPECT_TRUE(core.balanceOk); EXPECT_TRUE(core.blockchainOk); EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001); EXPECT_EQ(*core.blocks, 150); EXPECT_EQ(*core.longestChain, 160); MockRefreshRpc coreFallbackRpc; coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up"); coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}}); auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc); EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector({ "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({ "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(1)); EXPECT_EQ(peers.peers[0].id, 42); EXPECT_TRUE(peers.peers[0].tls_verified); EXPECT_EQ(peers.bannedPeers.size(), static_cast(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({ "getpeerinfo", "listbanned" })); EXPECT_EQ(partialPeers.peers.size(), static_cast(0)); EXPECT_EQ(partialPeers.bannedPeers.size(), static_cast(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({ "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({ "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({ "getlocalsolps", "getmininginfo" })); EXPECT_FALSE(miningPartial.localHashrate.has_value()); EXPECT_TRUE(miningPartial.miningOk); EXPECT_FALSE(*miningPartial.generate); MockRefreshRpc addressRpc; addressRpc.addResponse("z_listaddresses", json::array({"zs-one", "zs-two"})); addressRpc.addResponse("z_validateaddress", json{{"ismine", true}}); addressRpc.addResponse("z_validateaddress", json{{"ismine", false}}); addressRpc.addResponse("z_listunspent", json::array({ json{{"address", "zs-one"}, {"amount", 1.0}}, json{{"address", "zs-one"}, {"amount", 0.5}} })); addressRpc.addResponse("getaddressesbyaccount", json::array({"R-one"})); addressRpc.addResponse("listunspent", json::array({ json{{"address", "R-one"}, {"amount", 2.25}} })); auto addresses = Refresh::collectAddressRefreshResult(addressRpc); EXPECT_TRUE(addressRpc.methodNames() == std::vector({ "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(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(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({ "z_listaddresses", "z_validateaddress", "z_listunspent", "z_getbalance", "getaddressesbyaccount", "listunspent" })); EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key); EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001); Refresh::TransactionRefreshSnapshot snapshot; snapshot.shieldedAddresses = {"zs-one", "zs-two"}; snapshot.sendTxids = {"cached-send", "pending-send"}; snapshot.fullyEnrichedTxids = {"shielded-receive", "transparent-a"}; Refresh::TransactionViewCacheEntry cachedEntry; cachedEntry.from_address = "zs-cache-from"; cachedEntry.outgoing_outputs.push_back({"zs-cache-dest", 0.20, "cached memo"}); snapshot.viewTxCache["cached-send"] = cachedEntry; MockRefreshRpc transactionRpc; transactionRpc.addResponse("listtransactions", json::array({ json{{"txid", "transparent-a"}, {"category", "receive"}, {"amount", 1.0}, {"time", 100}, {"confirmations", 3}, {"address", "R-one"}} })); transactionRpc.addResponse("z_listreceivedbyaddress", json::array({ json{{"txid", "shielded-receive"}, {"amount", 2.0}, {"confirmations", 4}, {"time", 50}, {"memoStr", "shielded memo"}} })); transactionRpc.addResponse("z_listreceivedbyaddress", json::array()); transactionRpc.addResponse("z_viewtransaction", json{ {"spends", json::array({json{{"address", "zs-from"}}})}, {"outputs", json::array({ json{{"outgoing", true}, {"address", "zs-dest"}, {"value", 0.40}, {"memoStr", "fresh memo"}} })} }); transactionRpc.addResponse("gettransaction", json{{"time", 500}, {"confirmations", 1}}); auto transactionResult = Refresh::collectTransactionRefreshResult(transactionRpc, snapshot, 321, 4); EXPECT_TRUE(transactionRpc.methodNames() == std::vector({ "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(1)); EXPECT_EQ(transactionResult.newViewTxEntries.count("pending-send"), static_cast(1)); EXPECT_EQ(transactionResult.transactions.size(), static_cast(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(500)); EXPECT_EQ(transactionResult.transactions[1].txid, std::string("transparent-a")); } void testNetworkRefreshResultModels() { using Refresh = dragonx::services::NetworkRefreshService; using nlohmann::json; dragonx::WalletState state; auto core = Refresh::parseCoreRefreshResult( json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, true, json{{"blocks", 100}, {"headers", 105}, {"verificationprogress", 0.75}, {"longestchain", 110}, {"notarized", 90}}, true); Refresh::applyCoreRefreshResult(state, core, 1234); EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001); EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001); EXPECT_NEAR(state.total_balance, 1.75, 0.00000001); EXPECT_EQ(state.sync.blocks, 100); EXPECT_EQ(state.sync.headers, 105); EXPECT_TRUE(state.sync.syncing); EXPECT_EQ(state.longestchain, 110); EXPECT_EQ(state.notarized, 90); EXPECT_EQ(state.last_balance_update, static_cast(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(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(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(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(1)); EXPECT_EQ(state.peers[0].id, 7); EXPECT_TRUE(state.peers[0].tls_verified); EXPECT_EQ(state.bannedPeers.size(), static_cast(1)); EXPECT_EQ(state.bannedPeers[0].address, std::string("192.0.2.1")); EXPECT_EQ(state.last_peer_update, static_cast(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(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(1)); EXPECT_EQ(state.t_addresses.size(), static_cast(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(1)); EXPECT_EQ(viewEntry.outgoing_outputs[0].address, std::string("zs-destination")); std::vector 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(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(111)); Refresh::TransactionRefreshResult transactionResult; transactionResult.blockHeight = 222; dragonx::TransactionInfo confirmedTx; confirmedTx.txid = "confirmed"; confirmedTx.type = "receive"; confirmedTx.confirmations = 12; confirmedTx.timestamp = 1000; dragonx::TransactionInfo pendingTx; pendingTx.txid = "pending"; pendingTx.type = "receive"; pendingTx.confirmations = 2; pendingTx.timestamp = 1001; transactionResult.transactions = {pendingTx, confirmedTx}; transactionResult.newViewTxEntries["shielded-send"] = viewEntry; Refresh::TransactionViewCache viewCache; std::unordered_set sendTxids{"shielded-send", "pending"}; std::vector confirmedCache; std::unordered_set 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(2)); EXPECT_EQ(state.last_tx_update, static_cast(4567)); EXPECT_EQ(lastTxBlock, 222); EXPECT_EQ(viewCache.size(), static_cast(1)); EXPECT_EQ(sendTxids.count("shielded-send"), static_cast(0)); EXPECT_EQ(sendTxids.count("pending"), static_cast(1)); EXPECT_EQ(confirmedCache.size(), static_cast(1)); EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed")); EXPECT_EQ(confirmedIds.count("confirmed"), static_cast(1)); EXPECT_EQ(confirmedBlock, 222); } void testWalletSecurityController() { using dragonx::services::WalletSecurityController; using PinError = WalletSecurityController::PinValidationError; WalletSecurityController controller; EXPECT_FALSE(controller.hasDeferredEncryption()); controller.beginDeferredEncryption("secret-passphrase", "1234"); EXPECT_TRUE(controller.hasDeferredEncryption()); auto snapshot = controller.deferredEncryption(); EXPECT_EQ(snapshot.passphrase, std::string("secret-passphrase")); EXPECT_EQ(snapshot.pin, std::string("1234")); EXPECT_TRUE(controller.shouldAttemptDeferredConnect(0.0)); EXPECT_FALSE(controller.shouldAttemptDeferredConnect(1.0)); EXPECT_TRUE(controller.shouldAttemptDeferredConnect(3.1)); controller.clearDeferredEncryption(); EXPECT_FALSE(controller.hasDeferredEncryption()); EXPECT_FALSE(controller.shouldAttemptDeferredConnect(10.0)); auto valid = WalletSecurityController::validatePinSetup("1234", "1234"); EXPECT_TRUE(valid.ok); auto emptyAllowed = WalletSecurityController::validatePinSetup("", "", true); EXPECT_TRUE(emptyAllowed.ok); auto mismatch = WalletSecurityController::validatePinSetup("1234", "4321"); EXPECT_EQ(mismatch.error, PinError::Mismatch); auto shortPin = WalletSecurityController::validatePinSetup("123", "123"); EXPECT_EQ(shortPin.error, PinError::TooShort); auto nondigit = WalletSecurityController::validatePinSetup("12a4", "12a4"); EXPECT_EQ(nondigit.error, PinError::NonDigit); EXPECT_EQ(WalletSecurityController::classifyAddress("zs123"), WalletSecurityController::KeyKind::Shielded); EXPECT_EQ(WalletSecurityController::classifyAddress("R123"), WalletSecurityController::KeyKind::Transparent); EXPECT_EQ(WalletSecurityController::classifyPrivateKey("secret-key"), WalletSecurityController::KeyKind::Shielded); EXPECT_EQ(WalletSecurityController::classifyPrivateKey("Kx123"), WalletSecurityController::KeyKind::Transparent); EXPECT_EQ(std::string(WalletSecurityController::importSuccessMessage(WalletSecurityController::KeyKind::Shielded)), std::string("Z-address key imported successfully. Wallet is rescanning.")); EXPECT_EQ(WalletSecurityController::decryptExportFileName(42), std::string("obsidiandecryptexport42")); MockWalletSecurityRpc rpc; MockWalletSecurityVault vault; auto encrypted = controller.runDeferredEncryption({"passphrase", "9876"}, rpc, &vault); EXPECT_TRUE(encrypted.encrypted); EXPECT_TRUE(encrypted.pinProvided); EXPECT_TRUE(encrypted.pinStored); EXPECT_TRUE(encrypted.restartRequired); EXPECT_EQ(rpc.encryptCalls, 1); EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase")); EXPECT_EQ(vault.storeCalls, 1); EXPECT_EQ(vault.lastPin, std::string("9876")); EXPECT_EQ(vault.lastPassphrase, std::string("passphrase")); MockWalletSecurityRpc failingRpc; MockWalletSecurityVault unusedVault; failingRpc.encryptResult = false; failingRpc.encryptError = "rpc unavailable"; auto failed = controller.runDeferredEncryption({"bad-passphrase", "1111"}, failingRpc, &unusedVault); EXPECT_FALSE(failed.encrypted); EXPECT_TRUE(failed.pinProvided); EXPECT_FALSE(failed.pinStored); EXPECT_FALSE(failed.restartRequired); EXPECT_EQ(failed.error, std::string("rpc unavailable")); EXPECT_EQ(unusedVault.storeCalls, 0); } void testWalletSecurityWorkflow() { using dragonx::services::WalletSecurityWorkflow; using DecryptPhase = WalletSecurityWorkflow::DecryptPhase; using DecryptStep = WalletSecurityWorkflow::DecryptStep; WalletSecurityWorkflow workflow; auto start = std::chrono::steady_clock::now(); EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry); EXPECT_TRUE(workflow.canClose()); workflow.start(start); EXPECT_EQ(workflow.phase(), DecryptPhase::Working); EXPECT_EQ(workflow.step(), DecryptStep::Unlock); EXPECT_FALSE(workflow.canClose()); EXPECT_TRUE(workflow.inProgress()); workflow.advanceTo(DecryptStep::BackupWallet, WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet), start + std::chrono::seconds(2)); EXPECT_EQ(workflow.step(), DecryptStep::BackupWallet); EXPECT_EQ(workflow.status(), std::string("Backing up encrypted wallet...")); EXPECT_TRUE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::StopDaemon)); EXPECT_FALSE(WalletSecurityWorkflow::stepIsComplete(workflow.step(), DecryptStep::RestartDaemon)); workflow.closeDialogForImport(); EXPECT_FALSE(workflow.inProgress()); EXPECT_TRUE(workflow.importActive()); workflow.finishImport(); EXPECT_FALSE(workflow.importActive()); workflow.fail("Daemon failed to restart"); EXPECT_EQ(workflow.phase(), DecryptPhase::Error); EXPECT_TRUE(workflow.canClose()); EXPECT_EQ(workflow.status(), std::string("Daemon failed to restart")); workflow.failEntry("Incorrect passphrase"); EXPECT_EQ(workflow.phase(), DecryptPhase::PassphraseEntry); EXPECT_FALSE(workflow.inProgress()); EXPECT_EQ(workflow.status(), std::string("Incorrect passphrase")); auto plan = WalletSecurityWorkflow::planWalletFiles("/tmp/dragonx/", 1234); EXPECT_EQ(plan.exportFile, std::string("obsidiandecryptexport1234")); EXPECT_EQ(plan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport1234")); EXPECT_EQ(plan.walletPath, std::string("/tmp/dragonx/wallet.dat")); EXPECT_EQ(plan.backupPath, std::string("/tmp/dragonx/wallet.dat.encrypted.bak")); } void testWalletSecurityWorkflowExecutor() { using Executor = dragonx::services::WalletSecurityWorkflowExecutor; MockWorkflowRpc rpc; auto unlock = Executor::unlockWallet("passphrase", rpc); EXPECT_TRUE(unlock.ok); EXPECT_EQ(rpc.unlockCalls, 1); EXPECT_EQ(rpc.lastPassphrase, std::string("passphrase")); rpc.unlockResult = false; rpc.unlockError = "bad passphrase"; auto failedUnlock = Executor::unlockWallet("bad", rpc); EXPECT_FALSE(failedUnlock.ok); EXPECT_TRUE(failedUnlock.passphraseRejected); EXPECT_EQ(failedUnlock.error, std::string("bad passphrase")); MockWorkflowFiles files; auto exported = Executor::exportWallet(rpc, files, 77); EXPECT_TRUE(exported.ok); EXPECT_EQ(exported.filePlan.exportFile, std::string("obsidiandecryptexport77")); EXPECT_EQ(exported.filePlan.exportPath, std::string("/tmp/dragonx/obsidiandecryptexport77")); EXPECT_EQ(rpc.lastExportFile, std::string("obsidiandecryptexport77")); rpc.exportResult = false; rpc.exportError = "disk full"; auto failedExport = Executor::exportWallet(rpc, files, 78); EXPECT_FALSE(failedExport.ok); EXPECT_EQ(failedExport.error, std::string("Export failed: disk full")); rpc.stopResult = false; auto stop = Executor::stopDaemon(rpc); EXPECT_TRUE(stop.ok); EXPECT_EQ(rpc.stopCalls, 1); auto backup = Executor::backupEncryptedWallet(files, exported.filePlan); EXPECT_TRUE(backup.ok); EXPECT_EQ(files.backupCalls, 1); files.backupResult = false; files.backupError = "permission denied"; auto failedBackup = Executor::backupEncryptedWallet(files, exported.filePlan); EXPECT_FALSE(failedBackup.ok); EXPECT_EQ(failedBackup.error, std::string("Failed to rename wallet.dat: permission denied")); MockWorkflowDaemon daemon; rpc.probeCalls = 0; rpc.probeSuccessOnCall = 3; auto restart = Executor::restartDaemonAndWait(daemon, rpc, 200, 100, 5); EXPECT_TRUE(restart.ok); EXPECT_EQ(daemon.stopCalls, 1); EXPECT_EQ(daemon.startCalls, 1); EXPECT_EQ(rpc.probeCalls, 3); MockWorkflowDaemon cancelledDaemon; cancelledDaemon.cancelAfterSleeps = 1; rpc.probeCalls = 0; auto cancelled = Executor::restartDaemonAndWait(cancelledDaemon, rpc, 200, 100, 5); EXPECT_FALSE(cancelled.ok); EXPECT_TRUE(cancelled.error.empty()); MockWorkflowDaemon failedDaemon; rpc.probeCalls = 0; rpc.probeSuccessOnCall = 99; auto failedRestart = Executor::restartDaemonAndWait(failedDaemon, rpc, 0, 0, 2); EXPECT_FALSE(failedRestart.ok); EXPECT_EQ(failedRestart.error, std::string("Daemon failed to restart")); MockWorkflowImporter importer; auto imported = Executor::importWallet(importer, exported.filePlan.exportPath); EXPECT_TRUE(imported.ok); EXPECT_EQ(importer.importCalls, 1); EXPECT_EQ(importer.lastExportPath, exported.filePlan.exportPath); importer.result = false; importer.importError = "rescan failed"; auto failedImport = Executor::importWallet(importer, exported.filePlan.exportPath); EXPECT_FALSE(failedImport.ok); EXPECT_EQ(failedImport.error, std::string("Key import failed: rescan failed")); int cleanupCalls = 0; Executor::cleanupVaultAndPin([&]() { ++cleanupCalls; }); EXPECT_EQ(cleanupCalls, 1); } void testDaemonShutdownPolicy() { using dragonx::daemon::DaemonController; using ShutdownAction = DaemonController::ShutdownAction; auto noDaemon = DaemonController::evaluateShutdownPolicy(false, false, false, false); EXPECT_EQ(noDaemon.action, ShutdownAction::DisconnectOnly); auto keepRunning = DaemonController::evaluateShutdownPolicy(true, false, true, false); EXPECT_EQ(keepRunning.action, ShutdownAction::DisconnectOnly); EXPECT_EQ(std::string(keepRunning.logReason), std::string("keep_daemon_running enabled")); auto externalPreserved = DaemonController::evaluateShutdownPolicy(true, true, false, false); EXPECT_EQ(externalPreserved.action, ShutdownAction::DisconnectOnly); EXPECT_EQ(std::string(externalPreserved.logReason), std::string("external daemon (not ours to stop)")); auto externalStopAllowed = DaemonController::evaluateShutdownPolicy(true, true, false, true); EXPECT_EQ(externalStopAllowed.action, ShutdownAction::StopDaemon); auto ownedDaemon = DaemonController::evaluateShutdownPolicy(true, false, false, false); EXPECT_EQ(ownedDaemon.action, ShutdownAction::StopDaemon); auto restart = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::ManualRestart, true, true, true, false); EXPECT_TRUE(restart.allowed); EXPECT_TRUE(restart.wasRunning); EXPECT_TRUE(restart.resetCrashCount); EXPECT_TRUE(restart.disconnectRpc); EXPECT_EQ(restart.restartDelayMs, 500); EXPECT_EQ(std::string(restart.taskName), std::string("daemon-restart")); auto blockedRestart = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::ManualRestart, true, true, true, true); EXPECT_FALSE(blockedRestart.allowed); auto externalRescan = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::Rescan, false, false, false, false); EXPECT_FALSE(externalRescan.allowed); EXPECT_EQ(std::string(externalRescan.warning), std::string("Rescan requires embedded daemon. Restart your daemon with -rescan manually.")); auto deleteData = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::DeleteBlockchainData, true, true, false, false); EXPECT_TRUE(deleteData.allowed); EXPECT_FALSE(deleteData.wasRunning); EXPECT_EQ(deleteData.restartDelayMs, 3000); auto bootstrap = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::BootstrapStop, false, false, true, false); EXPECT_TRUE(bootstrap.allowed); EXPECT_TRUE(bootstrap.wasRunning); EXPECT_TRUE(bootstrap.disconnectRpc); } void testDaemonLifecycleExecution() { using dragonx::daemon::DaemonController; auto restart = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::ManualRestart, true, true, true, false); MockDaemonLifecycleRuntime restartRuntime; MockDaemonLifecycleTask restartTask; auto restartResult = DaemonController::executeLifecycleOperation(restart, restartRuntime, restartTask); EXPECT_TRUE(restartResult.completed); EXPECT_TRUE(restartResult.stopped); EXPECT_TRUE(restartResult.started); EXPECT_EQ(restartTask.sleeps, 5); EXPECT_TRUE(restartRuntime.calls == std::vector({"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({"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({"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({"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({"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({"stop"})); } void testDaemonLifecycleAdapters() { using dragonx::daemon::AsyncLifecycleTaskContext; using dragonx::daemon::BlockchainDataCleaner; using dragonx::daemon::ImmediateLifecycleTaskContext; using dragonx::util::AsyncTaskManager; auto cancelled = std::make_shared>(false); std::atomic shuttingDown{false}; AsyncTaskManager::Token token(cancelled); AsyncLifecycleTaskContext asyncContext(token, shuttingDown); EXPECT_FALSE(asyncContext.cancelled()); EXPECT_FALSE(asyncContext.shuttingDown()); cancelled->store(true); EXPECT_TRUE(asyncContext.cancelled()); shuttingDown.store(true); EXPECT_TRUE(asyncContext.shuttingDown()); ImmediateLifecycleTaskContext immediateContext; EXPECT_FALSE(immediateContext.cancelled()); EXPECT_FALSE(immediateContext.shuttingDown()); immediateContext.sleepForMs(50); fs::path dataDir = makeTempDir(); fs::create_directories(dataDir / "blocks" / "nested"); fs::create_directories(dataDir / "chainstate"); fs::create_directories(dataDir / "database"); fs::create_directories(dataDir / "notarizations"); std::ofstream(dataDir / "blocks" / "nested" / "blk00000.dat") << "block"; std::ofstream(dataDir / "chainstate" / "state.dat") << "state"; std::ofstream(dataDir / "database" / "db.dat") << "db"; std::ofstream(dataDir / "notarizations" / "notary.dat") << "notary"; std::ofstream(dataDir / "peers.dat") << "peers"; std::ofstream(dataDir / "fee_estimates.dat") << "fees"; std::ofstream(dataDir / "wallet.dat") << "wallet"; int removed = BlockchainDataCleaner::removeBlockchainData(dataDir); EXPECT_TRUE(removed >= 6); EXPECT_FALSE(fs::exists(dataDir / "blocks")); EXPECT_FALSE(fs::exists(dataDir / "chainstate")); EXPECT_FALSE(fs::exists(dataDir / "database")); EXPECT_FALSE(fs::exists(dataDir / "notarizations")); EXPECT_FALSE(fs::exists(dataDir / "peers.dat")); EXPECT_FALSE(fs::exists(dataDir / "fee_estimates.dat")); EXPECT_TRUE(fs::exists(dataDir / "wallet.dat")); fs::remove_all(dataDir); } void testRendererHelpers() { EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f), 43.0, 0.0001); EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(200.0f, 50.0f, 120.0f, 0.75f), 150.0, 0.0001); EXPECT_NEAR(dragonx::ui::ComputeConsoleOutputHeight(120.0f, 80.0f, 70.0f, 0.5f), 70.0, 0.0001); EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(40.0f, 8.0f), 50.0, 0.0001); EXPECT_NEAR(dragonx::ui::ClampConsoleWrapWidth(200.0f, 12.0f), 176.0, 0.0001); EXPECT_EQ(dragonx::ui::ClampMiningThreads(0, 8), 1); EXPECT_EQ(dragonx::ui::ClampMiningThreads(12, 8), 8); EXPECT_EQ(dragonx::ui::ClampMiningThreads(4, 8), 4); EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(true, true, false)); EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true)); EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true)); dragonx::ui::ConsoleOutputFilter filter{"error", false, false, 10, 20}; EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter)); EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter)); filter.daemonMessagesEnabled = true; filter.errorsOnly = true; filter.text.clear(); EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", 20, filter)); EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", 30, filter)); std::vector 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 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(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(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(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(2)); EXPECT_TRUE(call.params[0].get()); EXPECT_EQ(call.params[1].get(), 4LL); auto resultLines = dragonx::ui::FormatConsoleRpcResultLines("{\n \"balance\": 12,\n \"ok\": true\n}", false); EXPECT_EQ(resultLines.size(), static_cast(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(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 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 inputs = { {&addresses[0], true, false, false, "Main Vault", "", -1}, {&addresses[1], false, false, false, "Empty", "", -1}, {&addresses[2], false, false, true, "Favorite", "", -1} }; auto rows = dragonx::ui::BuildAddressListRows(inputs, "", true, false); EXPECT_EQ(rows.size(), static_cast(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(1)); EXPECT_EQ(rows[0].info->address, std::string("zs-high")); auto layout = dragonx::ui::ComputeAddressRowLayout(10.0f, 20.0f, 300.0f, 50.0f, 12.0f, 16.0f, 6.0f, 4.0f, 2.0f); EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001); EXPECT_NEAR(layout.contentStartY, 26.0, 0.0001); EXPECT_NEAR(layout.buttonSize, 38.0, 0.0001); EXPECT_NEAR(layout.favoriteButton.x, 270.0, 0.0001); EXPECT_NEAR(layout.visibilityButton.x, 228.0, 0.0001); EXPECT_NEAR(layout.contentRight, 224.0, 0.0001); EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(2.0, 3.5), std::string("$7.00")); EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.001, 2.0), std::string("$0.002000")); EXPECT_EQ(dragonx::ui::FormatAddressUsdValue(0.0, 2.0), std::string("")); } void testGeneratedResourceBehavior() { const auto* themes = dragonx::resources::getEmbeddedThemes(); EXPECT_TRUE(themes != nullptr); EXPECT_TRUE(dragonx::resources::getEmbeddedResource("__missing_resource__") == nullptr); if (!dragonx::resources::hasEmbeddedResources()) { EXPECT_TRUE(themes[0].data == nullptr); EXPECT_EQ(dragonx::resources::extractBundledThemes("/tmp/obsidian-dragon-empty-themes"), 0); } } } // namespace int main() { testConnectionConfig(); testPaymentUri(); testAmountFormatting(); testSpendableFiltering(); testRefreshScheduler(); testNetworkRefreshService(); testNetworkRefreshSnapshotHelpers(); testNetworkRefreshRpcCollectors(); testNetworkRefreshResultModels(); testWalletSecurityController(); testWalletSecurityWorkflow(); testWalletSecurityWorkflowExecutor(); testDaemonShutdownPolicy(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters(); testRendererHelpers(); testConsoleInputModel(); testMiningBenchmarkModel(); testBalanceAddressListModel(); testGeneratedResourceBehavior(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n"; return 1; } std::cout << "Focused service tests passed\n"; return 0; }