#ifdef _WIN32 #ifndef NOMINMAX #define NOMINMAX #endif #include #else #include #include #include #include #include #include #include #endif #include "data/exchange_info.h" #include "util/text_format.h" #include "data/transaction_csv.h" #include "chat/chat_crypto.h" #include "chat/chat_identity.h" #include "chat/chat_service.h" #include "chat/chat_database.h" #include "daemon/daemon_controller.h" #include "daemon/daemon_busy_detect.h" #include "daemon/embedded_daemon.h" #include "util/connect_stall.h" #include "util/fetch_backoff.h" #include "util/serial_job_lane.h" #include "util/logger.h" #include "data/transaction_history_cache.h" #include "data/transaction_history_store_policy.h" #include "util/coalescing_writer.h" #include "data/address_book.h" #include "data/wallet_index.h" #include "util/secure_vault.h" #include "daemon/lifecycle_adapters.h" #include "data/wallet_state.h" #include "data/earnings_series.h" #include "rpc/connection.h" #include "rpc/rpc_failure.h" #include "rpc/rpc_busy_gate.h" #include "rpc/long_call_lane.h" #include "resources/embedded_resources.h" #include "resources/provision_state.h" #include "services/history_scan_policy.h" #include "services/wallet_scan_governor.h" #include "services/scan_policy.h" #include "services/connect_scan_gate.h" #include "services/seed_migration_poll.h" #include "services/chat_scan_policy.h" #include "services/mining_thread_commit.h" #include "services/network_refresh_service.h" #include "services/refresh_scheduler.h" #include "services/lowest_block_search.h" #include "services/note_consolidation.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_lerp.h" #include "ui/windows/chat_thread_layout.h" #include "ui/windows/balance_recent_tx.h" #include "ui/windows/console_input_model.h" #include "ui/windows/console_model.h" #include "ui/windows/console_output_model.h" #include "ui/windows/console_scroll_controller.h" #include "ui/windows/console_selection_controller.h" #include "ui/windows/console_tab_helpers.h" #include "ui/windows/console_text_layout.h" #include "ui/windows/mining_benchmark.h" #include "ui/windows/transactions_tab_helpers.h" #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" #include "ui/material/scroll_edge_fade.h" #include "ui/node_status_banner.h" #include "ui/staleness_badge.h" #include "ui/effects/effect_clock.h" #include "ui/effects/border_sampling.h" #include "ui/effects/capture_extent.h" #include "ui/material/rect_cull.h" #include "ui/material/fill_strip.h" #include "ui/notifications.h" #include "data/seed_migration_resume.h" #include "data/pin_vault_policy.h" #include "data/wallet_security_reconcile.h" #include "util/address_validation.h" #include "util/seed_phrase.h" #include "util/local_time.h" #include "util/text_fit.h" #include "util/idle_tracker.h" #include "util/image_decode_core.h" #include "daemon/daemon_startup_diagnosis.h" #include "util/amount_format.h" #include "util/payment_uri.h" #include "util/platform.h" #include "util/private_temp_dir.h" #include "util/i18n.h" #include "util/xmrig_updater.h" #include "util/pool_registry.h" #include "util/daemon_updater.h" #include "util/http_download.h" #include "util/bootstrap.h" #include "ui/windows/bootstrap_completion.h" #include "util/background_sampler.h" #include "util/lite_server_probe.h" #include "util/wallet_file_probe.h" #include "util/wallet_folder_scan.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_lifecycle_ui_adapter.h" #include "wallet/lite_wallet_gateway.h" #include "wallet/lite_wallet_state_mapper.h" #include "config/settings.h" #include "data/wallet_state.h" #include "data/portfolio.h" #include "data/market_series.h" #include "data/exchange_candles.h" #include "fake_lite_backend.h" #include #include #include #include #include #include #include #include #include #include "util/single_instance.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #define MINIZ_NO_ZLIB_COMPATIBLE_NAMES // only the mz_zip_* API is used; keep zlib-style names out of this file #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; } // Best-effort cleanup. Windows can't delete a file that is still open, and the throwing remove_all // would abort the whole suite over a leftover temp file. void removeTree(const fs::path& path) { std::error_code ec; fs::remove_all(path, ec); } double secondsSince(std::chrono::steady_clock::time_point t0) { return std::chrono::duration(std::chrono::steady_clock::now() - t0).count(); } // A named path under the platform temp dir (/tmp on Linux, %TEMP% on Windows, where "/tmp/..." // would mean C:\tmp). fs::path testTmpPath(const std::string& name) { return fs::temp_directory_path() / name; } // The fixture root: compiled in, unless DRAGONX_TEST_FIXTURE_DIR is set at runtime. The Windows // build runs from WSL (scripts/test-windows.sh), where the compiled-in Linux path doesn't resolve. std::string fixtureDir() { const char* env = std::getenv("DRAGONX_TEST_FIXTURE_DIR"); return (env && *env) ? std::string(env) : std::string(DRAGONX_TEST_FIXTURE_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 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 calls; int deletedItems = 7; bool startResult = true; bool stopResult = true; // false: the node didn't exit std::string disconnectReason; bool stopDaemonWithPolicy() override { calls.push_back("stop"); return stopResult; } 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 beginBackgroundStop(const char* reason) override { calls.push_back("background-stop"); 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 stopResult = true; bool isUsingEmbeddedDaemon() const override { return usingEmbedded; } bool stopEmbeddedDaemon() override { ++stopCalls; return stopResult; } 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::exception_ptr exception; // rethrown as-is when set (e.g. an RpcTransportError) }; 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)}); } void addException(const std::string& method, std::exception_ptr exception) { responses[method].push_back({nlohmann::json(), true, {}, std::move(exception)}); } int longCalls = 0; long lastLongTimeout = 0; nlohmann::json callLong(const std::string& method, const nlohmann::json& params, long timeoutSec) override { ++longCalls; lastLongTimeout = timeoutSec; return call(method, params); // recorded like any call, so the pinned call sequences still hold } 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.exception) std::rethrow_exception(response.exception); 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; } }; // S7: RPC failures are classified from structured data (curl code, request-sent bit, HTTP status, // JSON-RPC code, plain-text body), never from what() text. void testClassifyRpcFailure() { using dragonx::rpc::RpcFailureKind; using dragonx::rpc::classifyRpcFailure; using dragonx::rpc::rpcFailureKindName; using K = RpcFailureKind; struct Row { int curl; bool sent; long http; int rpcCode; const char* body; K want; }; const Row rows[] = { {CURLE_OK, true, 200, 0, "", K::None}, {CURLE_COULDNT_CONNECT, false, 0, 0, "", K::NotListening}, {CURLE_OPERATION_TIMEDOUT, false, 0, 0, "", K::ConnectTimeout}, // never reached the daemon {CURLE_OPERATION_TIMEDOUT, true, 0, 0, "", K::ReplyTimeout}, // sent; the daemon holds it {CURLE_ABORTED_BY_CALLBACK, true, 0, 0, "", K::Aborted}, {CURLE_GOT_NOTHING, true, 0, 0, "", K::Other}, {CURLE_OK, true, 500, 0, "Work queue depth exceeded", K::QueueFull}, {CURLE_OK, true, 500, 0, "Unhandled request", K::Unhandled}, {CURLE_OK, true, 500, 0, "", K::Other}, {CURLE_OK, true, 503, 0, "503 Service Unavailable", K::ShuttingDown}, {CURLE_OK, true, 401, 0, "", K::Auth}, {CURLE_OK, true, 500, -6, "", K::AppError}, // "Insufficient funds" {CURLE_OK, true, 500, -28, "", K::Warmup}, // "Loading block index..." {CURLE_OK, true, 404, -32601, "", K::AppError}, // Method not found {CURLE_OK, true, 400, -32600, "", K::AppError}, // Invalid request {CURLE_OK, true, 200, -28, "", K::Warmup}, // Body text is never read as warmup or auth, whatever it says. {CURLE_OK, true, 500, 0, "Loading block index... 401", K::Other}, }; for (const auto& r : rows) { const K got = classifyRpcFailure(r.curl, r.sent, r.http, r.rpcCode, r.body); if (got != r.want) std::cerr << "classifyRpcFailure(curl=" << r.curl << ", sent=" << r.sent << ", http=" << r.http << ", rpc=" << r.rpcCode << ", \"" << r.body << "\") = " << rpcFailureKindName(got) << ", want " << rpcFailureKindName(r.want) << "\n"; EXPECT_TRUE(got == r.want); } EXPECT_TRUE(dragonx::rpc::isRpcTimeout(K::ConnectTimeout)); EXPECT_TRUE(dragonx::rpc::isRpcTimeout(K::ReplyTimeout)); EXPECT_FALSE(dragonx::rpc::isRpcTimeout(K::QueueFull)); } void testSecureWipeAndRemove() { namespace fs = std::filesystem; using dragonx::util::Platform; std::error_code ec; const fs::path dir = fs::temp_directory_path(ec) / "od_secwipe_test"; fs::create_directories(dir, ec); const std::string f = (dir / "victim.bin").string(); { std::ofstream o(f, std::ios::binary); const std::string blob(200 * 1024, static_cast(0xAB)); o.write(blob.data(), static_cast(blob.size())); } EXPECT_TRUE(fs::exists(f, ec)); { std::ifstream hold(f, std::ios::binary); // second handle open across the wipe EXPECT_TRUE(Platform::secureWipeAndRemove(f)); } EXPECT_FALSE(fs::exists(f, ec)); EXPECT_TRUE(Platform::secureWipeAndRemove(f)); // missing path #ifndef _WIN32 const std::string target = (dir / "target.bin").string(); const std::string link = (dir / "link.bin").string(); const std::string payload(4096, 'Z'); { std::ofstream o(target, std::ios::binary); o.write(payload.data(), static_cast(payload.size())); } fs::create_symlink(target, link, ec); if (!ec) { EXPECT_TRUE(Platform::secureWipeAndRemove(link)); EXPECT_FALSE(fs::is_symlink(link, ec)); std::ifstream in(target, std::ios::binary); std::string got((std::istreambuf_iterator(in)), std::istreambuf_iterator()); EXPECT_TRUE(got == payload); // target untouched } #endif fs::remove_all(dir, ec); } void testHttpSinkAccept() { using dragonx::util::httpSinkAccept; EXPECT_EQ(httpSinkAccept(0, 100, 1000), (size_t)100); EXPECT_EQ(httpSinkAccept(900, 100, 1000), (size_t)100); // exactly at the cap is allowed EXPECT_EQ(httpSinkAccept(901, 100, 1000), (size_t)0); // crossing the cap aborts EXPECT_EQ(httpSinkAccept(0, 1001, 1000), (size_t)0); EXPECT_EQ(httpSinkAccept(5000, 100, 0), (size_t)100); // cap <= 0 => unlimited EXPECT_EQ(httpSinkAccept(5000, 100, -1), (size_t)100); dragonx::util::HttpGetOptions o; EXPECT_EQ(o.maxBytes, 16LL * 1024 * 1024); EXPECT_EQ(o.maxRedirects, 5L); } void testClassifyUnlockFailure() { using dragonx::rpc::RpcError; using dragonx::rpc::RpcTransportError; using dragonx::rpc::RpcFailureKind; using dragonx::rpc::UnlockFailure; using dragonx::rpc::classifyUnlockFailure; EXPECT_TRUE(classifyUnlockFailure(RpcError(-14, "bad")) == UnlockFailure::WrongPassphrase); EXPECT_TRUE(classifyUnlockFailure(RpcError(-15, "not enc")) == UnlockFailure::NotEncrypted); EXPECT_TRUE(classifyUnlockFailure(RpcError(-28, "warmup")) == UnlockFailure::NodeBusy); EXPECT_TRUE(classifyUnlockFailure(RpcError(-6, "funds")) == UnlockFailure::Other); const RpcFailureKind busy[] = {RpcFailureKind::ReplyTimeout, RpcFailureKind::QueueFull, RpcFailureKind::Deferred, RpcFailureKind::ConnectTimeout, RpcFailureKind::NotListening, RpcFailureKind::Warmup}; for (auto k : busy) EXPECT_TRUE(classifyUnlockFailure(RpcTransportError(k, "x")) == UnlockFailure::NodeBusy); const RpcFailureKind other[] = {RpcFailureKind::ShuttingDown, RpcFailureKind::Unhandled, RpcFailureKind::Aborted, RpcFailureKind::Auth}; for (auto k : other) EXPECT_TRUE(classifyUnlockFailure(RpcTransportError(k, "x")) == UnlockFailure::Other); EXPECT_TRUE(classifyUnlockFailure(std::runtime_error("x")) == UnlockFailure::Other); } void testFormatHttpError() { using dragonx::rpc::formatHttpError; // dragonxd's plain-text refusal is kept, so the log says what happened instead of a bare "HTTP 500". EXPECT_EQ(formatHttpError(500, "Work queue depth exceeded"), std::string("RPC error: HTTP 500: Work queue depth exceeded")); EXPECT_EQ(formatHttpError(401, ""), std::string("RPC error: HTTP 401")); // empty body: exactly the old text EXPECT_EQ(formatHttpError(503, " \r\n "), std::string("RPC error: HTTP 503")); // A long HTML page with control characters: bounded to 120 chars, printable ASCII only. std::string html = "\n\x01\x02\x7f"; while (html.size() < 300) html += "

junk junk

\n"; const std::string s = formatHttpError(502, html); const std::string prefix = "RPC error: HTTP 502: "; EXPECT_TRUE(s.rfind(prefix + "

junk", 0) == 0); EXPECT_TRUE(s.size() - prefix.size() <= 120); bool printable = true; for (unsigned char c : s) if (c < 0x20 || c > 0x7e) printable = false; EXPECT_TRUE(printable); } void testRpcFailureKindOf() { using namespace dragonx::rpc; using K = RpcFailureKind; EXPECT_TRUE(rpcFailureKindOf(RpcError(-28, "Loading block index...")) == K::Warmup); EXPECT_TRUE(rpcFailureKindOf(RpcError(-6, "Insufficient funds")) == K::AppError); EXPECT_TRUE(rpcFailureKindOf(RpcTransportError(K::QueueFull, "RPC error: HTTP 500")) == K::QueueFull); EXPECT_TRUE(rpcFailureKindOf(std::runtime_error("Not connected")) == K::Other); // Callers catch RpcError to mean "the daemon answered no" (e.g. no mnemonic -> fall back); a // transport failure must not be mistaken for that. bool caughtAsRpcError = false; try { throw RpcTransportError(K::ReplyTimeout, "RPC request failed: Timeout was reached"); } catch (const RpcError&) { caughtAsRpcError = true; } catch (const std::exception&) { } EXPECT_FALSE(caughtAsRpcError); } 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); removeTree(dir); } void testPaymentUri() { // Real checksummed addresses — the parser now checksum-validates the recipient (not a bare // prefix/length filter), so it accepts P2PKH / P2SH / shielded and rejects transcription errors. std::string taddr = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // P2PKH (v60) 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 = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5"; auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5"); EXPECT_TRUE(zparsed.valid); EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001); // Regression: a P2SH/multisig recipient ("b…", v85) must parse — the old 'R'/'t'-only filter dropped it. auto p2sh = dragonx::util::parsePaymentURI("drgx:bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C?amount=1"); EXPECT_TRUE(p2sh.valid); // A transcription error (flipped checksum char) is now rejected at parse time. auto typo = dragonx::util::parsePaymentURI("drgx:R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX?amount=1"); EXPECT_FALSE(typo.valid); auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1"); EXPECT_FALSE(invalid.valid); EXPECT_EQ(invalid.error, std::string("Invalid negative amount")); } void testWalletFileProbe() { namespace fs = std::filesystem; using dragonx::util::probeWalletFile; const fs::path dir = testTmpPath("dragonx") / "probe"; std::error_code ec; fs::create_directories(dir, ec); auto writef = [](const fs::path& p, const std::string& d) { std::ofstream o(p, std::ios::binary); o.write(d.data(), (std::streamsize)d.size()); }; // A valid Berkeley DB btree metapage has the magic 0x00053162 at byte offset 12 (little-endian here). auto bdb = []() { std::string h(16, '\0'); h[12] = 0x62; h[13] = 0x31; h[14] = 0x05; h[15] = 0x00; return h; }; // Wallet records are keyed by a CompactSize-length-prefixed name, e.g. "mkey" -> {0x04,'m','k','e','y'}. auto rec = [](const std::string& name) { return std::string(1, (char)name.size()) + name; }; // 1) Non-BDB file → not recognized, no flags. writef(dir / "junk.dat", std::string(4096, 'x')); { auto p = probeWalletFile((dir / "junk.dat").string()); EXPECT_FALSE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); } // 2) BDB with no markers → legacy plaintext; a complete scan makes the negatives trustworthy. writef(dir / "legacy.dat", bdb() + std::string(2000, '\0')); { auto p = probeWalletFile((dir / "legacy.dat").string()); EXPECT_TRUE(p.isBerkeleyDB); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); EXPECT_FALSE(p.hasShielded); EXPECT_TRUE(p.scanComplete); } // 3) Encrypted (mkey) + HD (hdchain) + shielded (csapzkey). writef(dir / "enc.dat", bdb() + std::string(500, 'a') + rec("mkey") + std::string(300, 'b') + rec("hdchain") + std::string(300, 'c') + rec("csapzkey") + std::string(500, 'd')); { auto p = probeWalletFile((dir / "enc.dat").string()); EXPECT_TRUE(p.encrypted); EXPECT_TRUE(p.hdSeed); EXPECT_TRUE(p.hasShielded); } // 4) False-positive guard: the bare word "mkey" WITHOUT the 0x04 length prefix must not count. writef(dir / "bare.dat", bdb() + std::string(100, '\0') + "mkey" + std::string(100, '\0')); { auto p = probeWalletFile((dir / "bare.dat").string()); EXPECT_FALSE(p.encrypted); } // 5) Byte cap: a marker beyond maxBytes is missed AND the scan is flagged incomplete (so a caller // won't wrongly assert "legacy"); the same file scanned fully finds it and reports complete. { std::string d = bdb(); d.resize(2u * 1024 * 1024, 'q'); d += rec("mkey"); writef(dir / "deep.dat", d); auto capped = probeWalletFile((dir / "deep.dat").string(), 1024u * 1024u); EXPECT_TRUE(capped.isBerkeleyDB); EXPECT_FALSE(capped.encrypted); EXPECT_FALSE(capped.scanComplete); EXPECT_TRUE(capped.bytesRead <= 1024u * 1024u); // budget accounting reflects a truncated read auto full = probeWalletFile((dir / "deep.dat").string()); EXPECT_TRUE(full.encrypted); EXPECT_TRUE(full.scanComplete); } // 6) Exact-boundary case: a file whose length is an exact 1 MiB multiple (and == maxBytes) must still // report scanComplete=true (the peek() guard), so a fully-scanned wallet isn't mislabeled truncated. { std::string d = bdb(); d.resize(1024u * 1024u, 'e'); // exactly one 1 MiB chunk, no markers writef(dir / "exact.dat", d); auto p = probeWalletFile((dir / "exact.dat").string(), 1024u * 1024u); EXPECT_TRUE(p.isBerkeleyDB); EXPECT_TRUE(p.scanComplete); EXPECT_FALSE(p.encrypted); EXPECT_FALSE(p.hdSeed); } // 7) Btree record counting (tier 2): hand-build a minimal single-database BDB btree — a 512-byte // metapage (magic/pagesize/root) + one leaf page holding "tx", "key", and a "keymeta" record — and // confirm parseWalletBtree walks it, tallies the counts, and reads the keymeta nCreateTime (wallet // birthday). Also that a non-BDB file yields parsed=false. { std::string w(1024, '\0'); auto put16 = [&](std::size_t o, unsigned v) { w[o] = (char)(v & 0xff); w[o+1] = (char)((v >> 8) & 0xff); }; auto put32 = [&](std::size_t o, unsigned v) { for (int k=0;k<4;k++) w[o+k] = (char)((v >> (8*k)) & 0xff); }; put32(12, 0x00053162u); put32(20, 512); w[25] = (char)9; put32(88, 1); // page 0: metapage, root = page 1 const std::size_t P = 512; // page 1: a btree leaf put16(P + 20, 6); w[P + 24] = (char)1; w[P + 25] = (char)5; // entries=6 (3 pairs), level 1, P_LBTREE // index array → (key,data) item offsets, laid out high→low without overlap: put16(P + 26, 500); put16(P + 28, 496); // tx put16(P + 30, 488); put16(P + 32, 484); // key put16(P + 34, 472); put16(P + 36, 456); // keymeta auto keyItem = [&](std::size_t off, const std::string& type) { // BKEYDATA holding CompactSize(len)+type std::string kd; kd += (char)type.size(); kd += type; put16(P + off, (unsigned)kd.size()); w[P + off + 2] = (char)1; for (std::size_t k = 0; k < kd.size(); ++k) w[P + off + 3 + k] = kd[k]; }; auto dataItem = [&](std::size_t off) { put16(P + off, 1); w[P + off + 2] = (char)1; w[P + off + 3] = 0; }; keyItem(500, "tx"); dataItem(496); keyItem(488, "key"); dataItem(484); keyItem(472, "keymeta"); // keymeta data = CKeyMetadata: nVersion(int32=10) + nCreateTime(int64) — little-endian. const long long kCreated = 1700000000LL; put16(P + 456, 12); w[P + 456 + 2] = (char)1; // BKEYDATA len=12, type=1, data@459 put32(456 + 3 + P, 10); // nVersion for (int k = 0; k < 8; ++k) w[P + 456 + 3 + 4 + k] = (char)((kCreated >> (8 * k)) & 0xff); writef(dir / "btree.dat", w); auto s = dragonx::util::parseWalletBtree((dir / "btree.dat").string()); EXPECT_TRUE(s.parsed); EXPECT_TRUE(s.complete); EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1); EXPECT_EQ(s.createdEpoch, kCreated); EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed); // Byte-collecting reader (the wallet-REBUILD read half): same walk, but collects (key,value) bytes. auto ex = dragonx::util::extractWalletBtreeRecords((dir / "btree.dat").string()); EXPECT_TRUE(ex.parsed); EXPECT_TRUE(ex.records.size() >= static_cast(3)); // tx + key + keymeta captured verbatim EXPECT_EQ(ex.keyRecords, 1); // the "key" record is fund-critical bool foundKeyRec = false; // value copied byte-for-byte, name intact for (const auto& kv : ex.records) if (kv.first.size() >= 4 && (unsigned char)kv.first[0] == 3 && kv.first.compare(1, 3, "key") == 0 && !kv.second.empty()) foundKeyRec = true; EXPECT_TRUE(foundKeyRec); EXPECT_FALSE(dragonx::util::extractWalletBtreeRecords((dir / "junk.dat").string()).parsed); } // 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the // CHDChain value once nVersion>=3; v1/v2 have no such field (→ "no phrase"), and a short/garbage // value is undecidable (0), never mislabeled "legacy". { using dragonx::util::hdChainMnemonicFlag; auto chdchain = [](int32_t ver, int flag /* -1 = omit the trailing byte */) { std::string v; for (int k = 0; k < 4; ++k) v += (char)(((uint32_t)ver >> (8 * k)) & 0xff); // nVersion, LE v.append(32 + 8 + 4 + 4, '\x07'); // seedFp+times+counters (48B) if (flag >= 0) v += (char)(flag ? 1 : 0); // fMnemonicSeed @ offset 52 return v; }; auto flagOf = [&](const std::string& v) { return hdChainMnemonicFlag(reinterpret_cast(v.data()), (uint32_t)v.size()); }; EXPECT_EQ(flagOf(chdchain(3, 1)), 1); // v3, fMnemonicSeed=1 → seed phrase EXPECT_EQ(flagOf(chdchain(3, 0)), 2); // v3, fMnemonicSeed=0 → HD, no phrase EXPECT_EQ(flagOf(chdchain(2, -1)), 2); // v2 (field not serialized) → no phrase EXPECT_EQ(flagOf(chdchain(1, -1)), 2); // v1 → no phrase EXPECT_EQ(flagOf(chdchain(3, -1)), 0); // v3 but value ends before offset 52 → undecidable EXPECT_EQ(flagOf(chdchain(999, 1)), 0); // implausible version → undecidable, NOT legacy EXPECT_EQ(hdChainMnemonicFlag(nullptr, 0), 0); EXPECT_EQ(flagOf(std::string(3, '\0')), 0); // < 4 bytes → undecidable } // 9) End-to-end: a minimal btree whose single record is an hdchain with a v3 CHDChain value → // parseWalletBtree surfaces hdSeed AND the decoded mnemonicSeed (1 for a seed-phrase wallet, 2 when // the flag is clear). Exercises the value-reading path inside the real walk, not just the decoder. { auto buildHdchainWallet = [](const std::string& chdValue) { std::string w(1024, '\0'); auto put16 = [&](std::size_t o, unsigned v){ w[o]=(char)(v&0xff); w[o+1]=(char)((v>>8)&0xff); }; auto put32 = [&](std::size_t o, unsigned v){ for(int k=0;k<4;k++) w[o+k]=(char)((v>>(8*k))&0xff); }; put32(12, 0x00053162u); put32(20, 512); w[25]=(char)9; put32(88, 1); // metapage → root = page 1 const std::size_t P = 512; // page 1: one (key,data) pair put16(P+20, 2); w[P+24]=(char)1; w[P+25]=(char)5; // entries=2, level 1, P_LBTREE put16(P+26, 490); put16(P+28, 430); // item offsets: key@490, data@430 std::string kd; kd += (char)7; kd += "hdchain"; // key BKEYDATA: CompactSize(7)+name put16(P+490, (unsigned)kd.size()); w[P+490+2]=(char)1; for (std::size_t k=0;k>(8*k))&0xff); v3seed.append(48, '\0'); v3seed += (char)1; writef(dir / "hdchain_seed.dat", buildHdchainWallet(v3seed)); auto s1 = dragonx::util::parseWalletBtree((dir / "hdchain_seed.dat").string()); EXPECT_TRUE(s1.parsed); EXPECT_TRUE(s1.hdSeed); EXPECT_EQ(s1.mnemonicSeed, 1); std::string v3nophrase = v3seed; v3nophrase.back() = (char)0; // fMnemonicSeed=0 writef(dir / "hdchain_legacy.dat", buildHdchainWallet(v3nophrase)); auto s2 = dragonx::util::parseWalletBtree((dir / "hdchain_legacy.dat").string()); EXPECT_TRUE(s2.parsed); EXPECT_TRUE(s2.hdSeed); EXPECT_EQ(s2.mnemonicSeed, 2); } fs::remove_all(dir, ec); } 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}); for (auto& a : addresses) a.spendableBalance = a.balance; // selection now ranks by CONFIRMED balance 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)); using dragonx::resolveSourceSelection; EXPECT_EQ(dragonx::addressIndexByString(addresses, "R-high"), 3); EXPECT_EQ(dragonx::addressIndexByString(addresses, "nope"), -1); EXPECT_EQ(dragonx::addressIndexByString(addresses, ""), -1); { // a "Send from this address" pre-fill on the first visit must survive the auto-pick auto r = resolveSourceSelection(addresses, "zs-low", /*latched=*/false, /*connected=*/true); EXPECT_EQ(r.address, std::string("zs-low")); EXPECT_EQ(r.index, 1); EXPECT_TRUE(r.autoLatched); } { // empty source -> best spendable (R-high), latched auto r = resolveSourceSelection(addresses, "", false, true); EXPECT_EQ(r.address, std::string("R-high")); EXPECT_EQ(r.index, 3); EXPECT_TRUE(r.autoLatched); } { // a pre-fill that is not in the (short) list is kept, index -1, no re-pick auto r = resolveSourceSelection(addresses, "zs-gone", false, true); EXPECT_EQ(r.address, std::string("zs-gone")); EXPECT_EQ(r.index, -1); EXPECT_TRUE(r.autoLatched); } { // not connected / empty list: nothing latches, pre-fill kept EXPECT_FALSE(resolveSourceSelection(addresses, "zs-low", false, false).autoLatched); EXPECT_FALSE(resolveSourceSelection({}, "zs-low", false, true).autoLatched); EXPECT_EQ(resolveSourceSelection({}, "zs-low", false, true).address, std::string("zs-low")); } { // already latched + stale index: index is re-derived from the string auto r = resolveSourceSelection(addresses, "R-high", true, true); EXPECT_EQ(r.index, 3); EXPECT_TRUE(r.autoLatched); } } // S8: deferFor() is an absolute hold, independent of the page interval (the retry-storm fix). void testIdleTracker() { using dragonx::util::IdleTracker; using Clock = IdleTracker::Clock; const auto t0 = Clock::now(); IdleTracker idle(t0); EXPECT_NEAR(idle.idleSeconds(t0), 0.0, 1e-9); // No events: idle time keeps growing, however long a (conceptual) field stays focused. EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(900)), 900.0, 1e-6); EXPECT_TRUE(idle.idleSeconds(t0 + std::chrono::seconds(901)) >= 900.0); // would trip a 900 s auto-lock // Activity resets it. idle.noteActivity(t0 + std::chrono::seconds(600)); EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(900)), 300.0, 1e-6); // Never moves backwards. idle.noteActivity(t0 + std::chrono::seconds(100)); EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(900)), 300.0, 1e-6); // A query before the last activity is 0, not negative. EXPECT_NEAR(idle.idleSeconds(t0 + std::chrono::seconds(10)), 0.0, 1e-9); } void testEncryptionPendingPolicy() { using namespace dragonx::data; // flag, rpcOk, encrypted, deferred, inProgress, warned EXPECT_TRUE(decideEncryptionPending(true, true, true, false, false, false) == EncryptionPendingAction::Clear); EXPECT_TRUE(decideEncryptionPending(true, true, true, true, true, true ) == EncryptionPendingAction::Clear); // encrypted wins EXPECT_TRUE(decideEncryptionPending(true, true, false, false, false, false) == EncryptionPendingAction::Warn); EXPECT_TRUE(decideEncryptionPending(true, true, false, true, false, false) == EncryptionPendingAction::None); // deferred still queued EXPECT_TRUE(decideEncryptionPending(true, true, false, false, true, false) == EncryptionPendingAction::None); // encryptwallet in flight EXPECT_TRUE(decideEncryptionPending(true, true, false, false, false, true ) == EncryptionPendingAction::None); // once per session EXPECT_TRUE(decideEncryptionPending(true, false, false, false, false, false) == EncryptionPendingAction::None); // -28 / failed RPC EXPECT_TRUE(decideEncryptionPending(false, true, true, false, false, false) == EncryptionPendingAction::None); // nothing pending EXPECT_TRUE(decideEncryptionPending(false, true, false, false, false, false) == EncryptionPendingAction::None); } void testWalletLockReconcilePolicy() { using namespace dragonx::data; // Set PIN while unlocked keeps the wallet unlocked for the time it has left. auto p = planSetPinVerify(true, 1000 + 1700, 1000); EXPECT_EQ(p.timeoutSecs, 1700); EXPECT_FALSE(p.relockAfter); // Locked, or about to expire (<= 5 s left), or no deadline: 5 s probe + relock as before. p = planSetPinVerify(false, 0, 1000); EXPECT_EQ(p.timeoutSecs, 5); EXPECT_TRUE(p.relockAfter); p = planSetPinVerify(true, 1000 + 5, 1000); EXPECT_EQ(p.timeoutSecs, 5); EXPECT_TRUE(p.relockAfter); p = planSetPinVerify(true, 0, 1000); EXPECT_TRUE(p.relockAfter); p = planSetPinVerify(true, 1000 + 99999999, 1000); EXPECT_EQ(p.timeoutSecs, 86400 * 7); // capped // Expiry predicate. EXPECT_TRUE(unlockWindowExpired(true, false, 1000, 1000)); EXPECT_TRUE(unlockWindowExpired(true, false, 1000, 5000)); EXPECT_FALSE(unlockWindowExpired(true, false, 1000, 999)); EXPECT_FALSE(unlockWindowExpired(true, true, 1000, 5000)); // already locked in the GUI EXPECT_FALSE(unlockWindowExpired(false, false, 1000, 5000)); // unencrypted EXPECT_FALSE(unlockWindowExpired(true, false, 0, 5000)); // no deadline known (Lite / unknown) } void testRefreshSchedulerDeferFor() { using dragonx::services::RefreshScheduler; using Timer = RefreshScheduler::Timer; { // The old bug, pinned: "setTimer(kCoreDefault - 1)" meant 4s ELAPSED, and Overview's interval is 2s, // so the retry was due at once (every frame). RefreshScheduler s; s.applyPage(dragonx::ui::NavPage::Overview); s.setTimer(Timer::Core, RefreshScheduler::kCoreDefault - 1.0f); EXPECT_TRUE(s.isDue(Timer::Core)); } { // Overview + deferFor(4): not due at 3.9, due at 4.0. RefreshScheduler s; s.applyPage(dragonx::ui::NavPage::Overview); s.deferFor(Timer::Core, 4.0f); s.tick(3.9f); EXPECT_FALSE(s.isDue(Timer::Core)); s.tick(0.1f); EXPECT_TRUE(s.isDue(Timer::Core)); } { // A page change mid-hold doesn't move it. RefreshScheduler s; s.applyPage(dragonx::ui::NavPage::Overview); s.deferFor(Timer::Core, 4.0f); s.tick(1.0f); s.applyPage(dragonx::ui::NavPage::Console); // 10s interval s.tick(2.9f); EXPECT_FALSE(s.isDue(Timer::Core)); s.tick(0.1f); EXPECT_TRUE(s.isDue(Timer::Core)); } { // Shorter than the interval: due at 1.0 on Console, not at 10. RefreshScheduler s; s.applyPage(dragonx::ui::NavPage::Console); s.deferFor(Timer::Core, 1.0f); s.tick(1.0f); EXPECT_TRUE(s.consumeDue(Timer::Core)); // consumeDue ended the hold: back to the 10s interval. s.tick(5.0f); EXPECT_FALSE(s.isDue(Timer::Core)); } { // A user action (markDue / markImmediateRefresh) overrides a backoff. RefreshScheduler s; s.applyPage(dragonx::ui::NavPage::Overview); s.deferFor(Timer::Core, 30.0f); s.tick(1.0f); s.markImmediateRefresh(); EXPECT_TRUE(s.isDue(Timer::Core)); } } // C119: the Mining tab's per-block Recent poll and the coinbase category transition in the Recent merge. void testMiningTabTxPoll() { using namespace dragonx::services; using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; MiningTxPollInputs p; p.tip = 100; p.lastPolledTip = 99; p.txAgeDue = true; p.historyLoaded = true; EXPECT_TRUE(miningTxPollDue(p)); p.lastPolledTip = 100; EXPECT_FALSE(miningTxPollDue(p)); // one poll per block p.lastPolledTip = 99; p.txAgeDue = false; EXPECT_FALSE(miningTxPollDue(p)); p.txAgeDue = true; p.historyLoaded = false; EXPECT_FALSE(miningTxPollDue(p)); p.historyLoaded = true; p.dirty = true; EXPECT_FALSE(miningTxPollDue(p)); p.dirty = false; p.addressesDirty = true; EXPECT_FALSE(miningTxPollDue(p)); TxCycleInputs in; in.newTip = true; in.routineScanWork = true; in.heightKnown = true; in.gateOpen = true; EXPECT_TRUE(decideTxCycle(in) == TxCycle::Full); TxCycleInputs shaped = shapeTxCycleForMiningPage(in); shaped.recentDue = true; EXPECT_TRUE(decideTxCycle(shaped) == TxCycle::RecentWithProbe); shaped.gateOpen = false; EXPECT_TRUE(decideTxCycle(shaped) == TxCycle::Recent); TxCycleInputs tracked; tracked.newTip = true; tracked.sendTracking = true; tracked.heightKnown = true; EXPECT_TRUE(decideTxCycle(shapeTxCycleForMiningPage(tracked)) == TxCycle::Light); TxCycleInputs expl; expl.explicitScanWork = true; expl.heightKnown = true; EXPECT_TRUE(decideTxCycle(shapeTxCycleForMiningPage(expl)) == TxCycle::Full); { // immature -> generate at maturity: one row, the fresh category Refresh::TransactionRefreshSnapshot snap; dragonx::TransactionInfo imm; imm.txid = "cb1"; imm.type = "immature"; imm.amount = 3.0; imm.timestamp = 50; imm.confirmations = 99; snap.previousTransactions = {imm}; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array({ json{{"txid", "cb1"}, {"category", "generate"}, {"amount", 3.0}, {"time", 50}, {"confirmations", 100}} })); auto r = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 200); EXPECT_EQ(r.transactions.size(), static_cast(1)); EXPECT_EQ(r.transactions[0].type, std::string("generate")); } { // generate -> orphan on a reorg Refresh::TransactionRefreshSnapshot snap; dragonx::TransactionInfo gen; gen.txid = "cb2"; gen.type = "generate"; gen.amount = 3.0; gen.timestamp = 50; gen.confirmations = 100; snap.previousTransactions = {gen}; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array({ json{{"txid", "cb2"}, {"category", "orphan"}, {"amount", 3.0}, {"time", 50}, {"confirmations", 0}} })); auto r = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 200); EXPECT_EQ(r.transactions.size(), static_cast(1)); EXPECT_EQ(r.transactions[0].type, std::string("orphan")); } { // a non-coinbase row sharing the txid is left alone Refresh::TransactionRefreshSnapshot snap; dragonx::TransactionInfo rec; rec.txid = "cb3"; rec.type = "receive"; rec.amount = 1.0; rec.timestamp = 40; rec.address = "zs-x"; dragonx::TransactionInfo imm; imm.txid = "cb3"; imm.type = "immature"; imm.amount = 3.0; imm.timestamp = 50; snap.previousTransactions = {rec, imm}; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array({ json{{"txid", "cb3"}, {"category", "generate"}, {"amount", 3.0}, {"time", 50}, {"confirmations", 100}} })); auto r = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 200); EXPECT_EQ(r.transactions.size(), static_cast(2)); int receives = 0, gens = 0; for (const auto& t : r.transactions) { receives += t.type == "receive"; gens += t.type == "generate"; } EXPECT_EQ(receives, 1); EXPECT_EQ(gens, 1); } } 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(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)); { // C266: "move" accounting rows (no txid) are skipped; labelled-address receive rows are kept. std::vector moveTx; std::set moveKnown; Refresh::appendTransparentTransactions(moveTx, moveKnown, json::array({ json{{"category", "move"}, {"account", "x"}, {"amount", 0.5}, {"time", 50}}, json{{"txid", "labelled-recv"}, {"category", "receive"}, {"account", "savings"}, {"amount", 2.0}, {"time", 60}, {"confirmations", 3}, {"address", "R-labelled"}} })); EXPECT_EQ(moveTx.size(), static_cast(1)); EXPECT_EQ(moveTx[0].txid, std::string("labelled-recv")); EXPECT_EQ(moveKnown.count(""), static_cast(0)); } 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 testParseDaemonRescanOutput() { using dragonx::services::NetworkRefreshService; // "Still rescanning ... Progress=" — fraction scaled to a percentage. { auto scan = NetworkRefreshService::parseDaemonRescanOutput( "2026-07-01 Still rescanning at height 500000. Progress=0.42 tip=600000\n"); EXPECT_TRUE(scan.foundRescan); EXPECT_NEAR(scan.rescanPct, 42.0f, 0.01f); } // "Rescanning... %" — percentage read directly (already 0..100). { auto scan = NetworkRefreshService::parseDaemonRescanOutput( "Rescanning... 37% done\n"); EXPECT_TRUE(scan.foundRescan); EXPECT_NEAR(scan.rescanPct, 37.0f, 0.01f); } // Explicit completion line marks finished. { auto scan = NetworkRefreshService::parseDaemonRescanOutput("Done rescanning\n"); EXPECT_TRUE(scan.finished); } // The daemon that prints no "Done rescanning" logs a rescan benchmark ending in "ms". { auto scan = NetworkRefreshService::parseDaemonRescanOutput( " rescan 16760577ms\n"); EXPECT_TRUE(scan.finished); } // "Building Witnesses for block complete, remaining". { auto scan = NetworkRefreshService::parseDaemonRescanOutput( "Building Witnesses for block 123 0.5 complete, 40 remaining\n"); EXPECT_TRUE(scan.foundWitness); EXPECT_NEAR(scan.witnessPct, 50.0f, 0.01f); EXPECT_EQ(scan.witnessRemaining, 40); } // "Reading blocks for witness rebuild: / " — captures total and remaining. { auto scan = NetworkRefreshService::parseDaemonRescanOutput( "Reading blocks for witness rebuild: 10 / 200\n"); EXPECT_TRUE(scan.foundWitness); EXPECT_EQ(scan.witnessReadTotal, 200); EXPECT_EQ(scan.witnessRemaining, 190); // total - done } // Parallel-rebuild completion line snaps witnessRebuilt=true. { auto scan = NetworkRefreshService::parseDaemonRescanOutput( "rebuilt 3 note witness cache(s) to height 99 in 12ms using 4 thread(s)\n"); EXPECT_TRUE(scan.foundWitness); EXPECT_TRUE(scan.witnessRebuilt); } // "Setting Initial Sapling Witness for tx , of " — distinct hashes, max N. { auto scan = NetworkRefreshService::parseDaemonRescanOutput( "Setting Initial Sapling Witness for tx abc123, 2 of 7\n" "Setting Initial Sapling Witness for tx def456, 3 of 7\n" "Setting Initial Sapling Witness for tx abc123, 5 of 7\n"); EXPECT_TRUE(scan.foundWitness); // The parser collects every occurrence (dedup happens in the apply step's set), so the // vector has all three keys but only two distinct values. EXPECT_EQ(scan.witnessTxids.size(), static_cast(3)); std::set distinct(scan.witnessTxids.begin(), scan.witnessTxids.end()); EXPECT_EQ(distinct.size(), static_cast(2)); EXPECT_EQ(distinct.count("abc123"), static_cast(1)); EXPECT_EQ(distinct.count("def456"), static_cast(1)); EXPECT_EQ(scan.witnessTotalTxs, 7); } // Empty input yields all defaults. { auto scan = NetworkRefreshService::parseDaemonRescanOutput(""); EXPECT_FALSE(scan.foundRescan); EXPECT_FALSE(scan.finished); EXPECT_NEAR(scan.rescanPct, 0.0f, 0.0001f); EXPECT_TRUE(scan.lastStatus.empty()); EXPECT_FALSE(scan.foundWitness); EXPECT_NEAR(scan.witnessPct, -1.0f, 0.0001f); EXPECT_EQ(scan.witnessRemaining, -1); EXPECT_EQ(scan.witnessReadTotal, -1); EXPECT_FALSE(scan.witnessRebuilt); EXPECT_TRUE(scan.witnessTxids.empty()); EXPECT_EQ(scan.witnessTotalTxs, -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 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({"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", 150}, {"bestblockhash", "core-best-150"}, {"verificationprogress", 1.0}, {"longestchain", 150}, // caught up → balance scan runs {"notarized", 145} }); auto core = Refresh::collectCoreRefreshResult(coreRpc); // getblockchaininfo is issued FIRST — sync detection gates (and must not be delayed behind) the // O(mapWallet) balance scan. When caught up, the balance scan follows: display (minconf=0) + // spendable (minconf=1). EXPECT_TRUE(coreRpc.methodNames() == std::vector({ "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance" })); EXPECT_EQ(coreRpc.calls[1].params, json::array({0})); EXPECT_EQ(coreRpc.calls[2].params, json::array({1})); 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, 150); // When getblockchaininfo shows the node is behind (blocks < longestchain - 2), the balance scan is // skipped this cycle regardless of includeBalance — so sync-state (and hence kSyncProfile) updates // promptly instead of after the multi-second scan. Only getblockchaininfo is issued. MockRefreshRpc coreBehindRpc; coreBehindRpc.addResponse("z_gettotalbalance", json{{"total", "4.25000000"}}); coreBehindRpc.addResponse("getblockchaininfo", json{ {"blocks", 150}, {"headers", 160}, {"longestchain", 160} }); auto coreBehind = Refresh::collectCoreRefreshResult(coreBehindRpc); EXPECT_TRUE(coreBehindRpc.methodNames() == std::vector({"getblockchaininfo"})); EXPECT_FALSE(coreBehind.balanceOk); EXPECT_TRUE(coreBehind.blockchainOk); EXPECT_EQ(*coreBehind.blocks, 150); EXPECT_EQ(*coreBehind.longestChain, 160); MockRefreshRpc coreFallbackRpc; coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up"); coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}}); auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc); // No longestchain in the response → not classified as "behind" → balance is still attempted (here it // fails). getblockchaininfo is still issued first. The minconf=1 call follows only a minconf=0 call // that succeeded (its result would be dropped anyway). EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector({ "getblockchaininfo", "z_gettotalbalance" })); EXPECT_FALSE(partialCore.balanceOk); EXPECT_TRUE(partialCore.blockchainOk); EXPECT_EQ(*partialCore.blocks, 8); EXPECT_TRUE(partialCore.blockchainErrKind == dragonx::rpc::RpcFailureKind::None); // it succeeded EXPECT_TRUE(partialCore.balanceErrKind == dragonx::rpc::RpcFailureKind::Other); // a plain runtime_error // S7: the collector reports HOW each call failed, from the exception's type rather than its text. { using dragonx::rpc::RpcFailureKind; using dragonx::rpc::RpcTransportError; MockRefreshRpc busyRpc; busyRpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError( RpcFailureKind::QueueFull, "RPC error: HTTP 500: Work queue depth exceeded", CURLE_OK, 500, true))); busyRpc.addException("z_gettotalbalance", std::make_exception_ptr(RpcTransportError( RpcFailureKind::ReplyTimeout, "RPC request failed: Timeout was reached", CURLE_OPERATION_TIMEDOUT, 0, true))); auto busyCore = Refresh::collectCoreRefreshResult(busyRpc); EXPECT_FALSE(busyCore.blockchainOk); EXPECT_FALSE(busyCore.balanceOk); EXPECT_TRUE(busyCore.blockchainErrKind == RpcFailureKind::QueueFull); // S6: after the chain call found the node busy, no balance scan is sent at all (each would only // queue behind the same cs_main and take another slot). EXPECT_TRUE(busyRpc.methodNames() == std::vector({"getblockchaininfo"})); EXPECT_TRUE(busyCore.balanceErrKind == RpcFailureKind::None); } 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 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({"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({ "z_listaddresses", "z_validateaddress", "z_validateaddress", "getaddressesbyaccount", "z_listunspent", "listunspent" // both lists first, then the balances })); EXPECT_EQ(addressRpc.calls[1].params, json::array({"zs-one"})); EXPECT_EQ(addressRpc.calls[2].params, json::array({"zs-two"})); EXPECT_EQ(addressRpc.calls[3].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", "getaddressesbyaccount", "z_listunspent", "z_getbalance", "z_getbalance", "listunspent" // display (minconf=0) + spendable (minconf=1) })); 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({ "z_listaddresses", "z_validateaddress", "getaddressesbyaccount", "z_listunspent", "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_listsentbyaddress", 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_listsentbyaddress", "getblockhash", "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({"zs-one", 0, 2, 321})); // banded full sweep [0, tip] EXPECT_EQ(transactionRpc.calls[5].params, json::array({"pending-send"})); EXPECT_EQ(transactionRpc.calls[6].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.newViewTxEntries.at("pending-send").timestamp, static_cast(500)); EXPECT_EQ(transactionResult.newViewTxEntries.at("pending-send").confirmations, 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")); // Outgoing shielded (z->z) send discovery. A pure z->z send has valueBalance == 0, so it is // absent from listtransactions AND (being a send, not a receive) from z_listreceivedbyaddress — // it is surfaced only via the whole-wallet z_listsentbyaddress sweep, whose txids are folded // into the z_viewtransaction enrichment to build the send row. Regression test for the // "outgoing transactions missing from History" bug. { Refresh::TransactionRefreshSnapshot zzSnapshot; zzSnapshot.shieldedAddresses = {"zs-mine"}; MockRefreshRpc zzRpc; zzRpc.addResponse("listtransactions", json::array()); // no transparent movement zzRpc.addResponse("z_listreceivedbyaddress", json::array()); // it's a send, not a receive zzRpc.addResponse("z_listsentbyaddress", json::array({ json{{"txid", "zz-send"}} // discovered only here })); zzRpc.addResponse("z_viewtransaction", json{ {"spends", json::array({json{{"address", "zs-mine"}}})}, {"outputs", json::array({ json{{"outgoing", true}, {"address", "zs-recipient"}, {"value", 1.25}, {"memoStr", "z2z memo"}} })} }); zzRpc.addResponse("gettransaction", json{{"time", 800}, {"confirmations", 3}}); auto zzResult = Refresh::collectTransactionRefreshResult(zzRpc, zzSnapshot, 400, 4); EXPECT_TRUE(zzRpc.methodNames() == std::vector({ "listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress", "getblockhash", "z_viewtransaction", "gettransaction" })); EXPECT_EQ(zzResult.transactions.size(), static_cast(1)); EXPECT_EQ(zzResult.transactions[0].txid, std::string("zz-send")); EXPECT_EQ(zzResult.transactions[0].type, std::string("send")); EXPECT_NEAR(zzResult.transactions[0].amount, -1.25, 0.00000001); EXPECT_EQ(zzResult.transactions[0].address, std::string("zs-recipient")); EXPECT_EQ(zzResult.transactions[0].memo, std::string("z2z memo")); EXPECT_EQ(zzResult.transactions[0].timestamp, static_cast(800)); EXPECT_EQ(zzResult.newViewTxEntries.count("zz-send"), static_cast(1)); // Guard: with no shielded addresses the sweep is skipped entirely (transparent sends are // already covered by listtransactions), so no z_listsentbyaddress call is made. Refresh::TransactionRefreshSnapshot noZSnapshot; MockRefreshRpc noZRpc; noZRpc.addResponse("listtransactions", json::array()); auto noZResult = Refresh::collectTransactionRefreshResult(noZRpc, noZSnapshot, 401, 4); EXPECT_TRUE(noZRpc.methodNames() == std::vector({"listtransactions"})); EXPECT_EQ(noZResult.transactions.size(), static_cast(0)); } // z->t deshield: the external transparent recipient is reported ONLY in z_listsentbyaddress's // sends.transparentSends (z_viewtransaction sees only the shielded change note, isOutgoing=false; // listtransactions omits the t-vout because inputs are shielded). The row must be synthesized // directly from that payload — a direct sibling of the z->z gap. { Refresh::TransactionRefreshSnapshot ztSnapshot; ztSnapshot.shieldedAddresses = {"zs-mine"}; MockRefreshRpc ztRpc; ztRpc.addResponse("listtransactions", json::array()); // z-originated: no t-input row ztRpc.addResponse("z_listreceivedbyaddress", json::array()); ztRpc.addResponse("z_listsentbyaddress", json::array({ json{{"txid", "zt-send"}, {"confirmations", 5}, {"blocktime", 900}, {"sends", json{ {"transparentSends", json::array({ json{{"address", "R-recipient"}, {"amount", -2.5}, {"vout", 0}} })}, {"saplingSends", json::array()} }}} })); // z->t viewtransaction: only a change note back to self -> no outgoing outputs. ztRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}}); auto ztResult = Refresh::collectTransactionRefreshResult(ztRpc, ztSnapshot, 500, 4); EXPECT_EQ(ztResult.transactions.size(), static_cast(1)); EXPECT_EQ(ztResult.transactions[0].txid, std::string("zt-send")); EXPECT_EQ(ztResult.transactions[0].type, std::string("send")); EXPECT_NEAR(ztResult.transactions[0].amount, -2.5, 0.00000001); EXPECT_EQ(ztResult.transactions[0].address, std::string("R-recipient")); EXPECT_EQ(ztResult.transactions[0].confirmations, 5); } // t->t send is reported by BOTH listtransactions AND z_listsentbyaddress's transparentSends — // it must appear ONCE (deduped by txid+address+amount), not doubled. { Refresh::TransactionRefreshSnapshot ttSnapshot; ttSnapshot.shieldedAddresses = {"zs-mine"}; MockRefreshRpc ttRpc; ttRpc.addResponse("listtransactions", json::array({ json{{"txid", "tt-send"}, {"category", "send"}, {"amount", -1.0}, {"time", 800}, {"confirmations", 3}, {"address", "R-dest"}} })); ttRpc.addResponse("z_listreceivedbyaddress", json::array()); ttRpc.addResponse("z_listsentbyaddress", json::array({ json{{"txid", "tt-send"}, {"confirmations", 3}, {"blocktime", 800}, {"sends", json{{"transparentSends", json::array({ json{{"address", "R-dest"}, {"amount", -1.0}, {"vout", 0}} })}}}} })); ttRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}}); auto ttResult = Refresh::collectTransactionRefreshResult(ttRpc, ttSnapshot, 500, 4); int ttSends = 0; for (const auto& t : ttResult.transactions) if (t.txid == std::string("tt-send") && t.type == std::string("send")) ++ttSends; EXPECT_EQ(ttSends, 1); // not double-counted } // Multi-output send: two outgoing outputs of EQUAL value to DIFFERENT recipients in one tx must // both render (address-keyed dedup) — an amount-only dedup dropped the second. { Refresh::TransactionRefreshSnapshot multiSnapshot; multiSnapshot.sendTxids = {"multi-send"}; MockRefreshRpc multiRpc; multiRpc.addResponse("listtransactions", json::array()); multiRpc.addResponse("z_viewtransaction", json{ {"spends", json::array({json{{"address", "zs-from"}}})}, {"outputs", json::array({ json{{"outgoing", true}, {"address", "zs-a"}, {"value", 0.5}, {"memoStr", "a"}}, json{{"outgoing", true}, {"address", "zs-b"}, {"value", 0.5}, {"memoStr", "b"}} })} }); multiRpc.addResponse("gettransaction", json{{"time", 700}, {"confirmations", 2}}); auto multiResult = Refresh::collectTransactionRefreshResult(multiRpc, multiSnapshot, 500, 4); int multiSends = 0; for (const auto& t : multiResult.transactions) if (t.txid == std::string("multi-send") && t.type == std::string("send")) ++multiSends; EXPECT_EQ(multiSends, 2); // both equal-value outputs kept } 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({"listtransactions"})); EXPECT_EQ(cachedOnlyResult.transactions.size(), static_cast(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(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({ "listtransactions", "z_viewtransaction", "gettransaction" })); EXPECT_EQ(retryResult.transactions.size(), static_cast(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(1)); EXPECT_EQ(retryResult.newViewTxEntries.at("retry-send").timestamp, static_cast(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({ "listtransactions", "z_viewtransaction", "gettransaction" })); EXPECT_EQ(placeholderResult.transactions.size(), static_cast(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(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(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({"listtransactions"})); EXPECT_EQ(missingAddressesResult.transactions.size(), static_cast(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({"listtransactions"})); EXPECT_EQ(pendingOpidResult.transactions.size(), static_cast(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(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({"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(1001)); EXPECT_EQ(pagedResult.transactions[0].txid, std::string("paged-1000")); { // C267: a tx added between page RPCs shifts the list, so the previous page's last row reappears at // the head of the next page. Rows (txid, send/non-send, vout) already taken are dropped; a sibling // output of the same tx (different vout) and an un-keyable row (no vout) are kept (fail-open). Refresh::TransactionRefreshSnapshot dedupSnapshot; MockRefreshRpc dedupRpc; json page1 = json::array(); for (int i = 0; i < 1000; ++i) { page1.push_back(json{{"txid", "dd-" + std::to_string(i)}, {"vout", 0}, {"category", "receive"}, {"amount", 0.01}, {"time", i}, {"confirmations", 10}, {"address", "R-dd"}}); } dedupRpc.addResponse("listtransactions", page1); dedupRpc.addResponse("listtransactions", json::array({ json{{"txid", "dd-999"}, {"vout", 0}, {"category", "receive"}, {"amount", 0.01}, {"time", 999}, {"confirmations", 10}, {"address", "R-dd"}}, // repeat: dropped json{{"txid", "dd-999"}, {"vout", 1}, {"category", "receive"}, {"amount", 0.01}, {"time", 999}, {"confirmations", 10}, {"address", "R-dd"}}, // sibling vout: kept json{{"txid", "dd-novout"}, {"category", "receive"}, {"amount", 0.02}, {"time", 1001}, {"confirmations", 9}, {"address", "R-dd"}} // no vout: kept })); auto dedupResult = Refresh::collectTransactionRefreshResult(dedupRpc, dedupSnapshot, 328, 0); EXPECT_EQ(dedupRpc.calls.size(), static_cast(2)); EXPECT_EQ(dedupResult.transactions.size(), static_cast(1002)); EXPECT_EQ(dedupResult.transparentRowCount, static_cast(1003)); // RAW count, not deduped size_t dd999 = 0; for (const auto& t : dedupResult.transactions) if (t.txid == "dd-999") ++dd999; EXPECT_EQ(dd999, static_cast(2)); // vout 0 once + vout 1 } // The pass floor: an address scanned below it is scanned again; without a floor (no pass running) // only never-scanned addresses are. A pass's floor is its start height, so it completes however far // the tip moves meanwhile (this is what 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.shieldedScanFloorHeight = 102; // a pass that started at 102: both below it MockRefreshRpc strictRpc; strictRpc.addResponse("listtransactions", json::array()); strictRpc.addResponse("z_listreceivedbyaddress", json::array()); strictRpc.addResponse("z_listreceivedbyaddress", json::array()); strictRpc.addResponse("z_listsentbyaddress", json::array()); auto strictResult = Refresh::collectTransactionRefreshResult(strictRpc, strictSnapshot, 102, 4); EXPECT_EQ(strictResult.shieldedAddressesScanned, static_cast(2)); // both re-scanned Refresh::TransactionRefreshSnapshot tolerantSnapshot; tolerantSnapshot.shieldedAddresses = {"zs-a", "zs-b"}; tolerantSnapshot.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}}; tolerantSnapshot.maxShieldedReceiveScans = 4; // no floor: no pass is running MockRefreshRpc tolerantRpc; tolerantRpc.addResponse("listtransactions", json::array()); // No z_listreceivedbyaddress responses: the addresses must be skipped (else call() throws). // The shielded scan still completes, so the whole-wallet z_listsentbyaddress sweep runs once. tolerantRpc.addResponse("z_listsentbyaddress", json::array()); auto tolerantResult = Refresh::collectTransactionRefreshResult(tolerantRpc, tolerantSnapshot, 102, 4); EXPECT_EQ(tolerantResult.shieldedAddressesScanned, static_cast(0)); // both skipped EXPECT_TRUE(tolerantResult.shieldedScanComplete); EXPECT_TRUE(tolerantRpc.methodNames() == std::vector({"listtransactions", "z_listsentbyaddress", "getblockhash"})); } 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({"listtransactions"})); EXPECT_EQ(recentRpc.calls[0].params, json::array({"*", 100, 0})); EXPECT_EQ(recent.blockHeight, 123); EXPECT_EQ(recent.transactions.size(), static_cast(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({ "listtransactions", "z_listreceivedbyaddress" })); EXPECT_EQ(recentShieldedProbeRpc.calls[1].params, json::array({"zs-probe-b", 0})); EXPECT_EQ(recentShieldedProbe.nextShieldedScanStartIndex, static_cast(0)); EXPECT_EQ(recentShieldedProbe.shieldedAddressesScanned, static_cast(1)); EXPECT_EQ(recentShieldedProbe.shieldedScanHeights.at("zs-probe-b"), 600); EXPECT_EQ(recentShieldedProbe.transactions.size(), static_cast(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({ "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(2)); EXPECT_EQ(partialShielded.shieldedAddressesScanned, static_cast(2)); EXPECT_EQ(partialShielded.transactions.size(), static_cast(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()); finalShieldedRpc.addResponse("z_listsentbyaddress", json::array()); auto finalShielded = Refresh::collectTransactionRefreshResult( finalShieldedRpc, partialShieldedSnapshot, 400, 0); EXPECT_TRUE(finalShieldedRpc.methodNames() == std::vector({ "listtransactions", "z_listreceivedbyaddress", "z_listsentbyaddress", "getblockhash" })); EXPECT_EQ(finalShieldedRpc.calls[1].params, json::array({"zs-two", 0})); EXPECT_TRUE(finalShielded.shieldedScanComplete); EXPECT_EQ(finalShielded.nextShieldedScanStartIndex, static_cast(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()); cachedShieldedRpc.addResponse("z_listsentbyaddress", json::array()); auto cachedShielded = Refresh::collectTransactionRefreshResult( cachedShieldedRpc, cachedShieldedSnapshot, 500, 0); EXPECT_TRUE(cachedShieldedRpc.methodNames() == std::vector({"listtransactions", "z_listsentbyaddress", "getblockhash"})); EXPECT_TRUE(cachedShielded.shieldedScanComplete); EXPECT_EQ(cachedShielded.shieldedAddressesScanned, static_cast(0)); EXPECT_EQ(cachedShielded.transactions.size(), static_cast(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.shieldedScanFloorHeight = 500; 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({ "listtransactions", "z_listreceivedbyaddress" })); EXPECT_EQ(staleProgressRpc.calls[1].params, json::array({"zs-stale", 0})); EXPECT_FALSE(staleProgress.shieldedScanComplete); EXPECT_EQ(staleProgress.nextShieldedScanStartIndex, static_cast(2)); EXPECT_EQ(staleProgress.shieldedAddressesScanned, static_cast(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(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"}} })); miningTxRpc.addResponse("z_listsentbyaddress", json::array()); auto miningTxResult = Refresh::collectTransactionRefreshResult(miningTxRpc, miningSnapshot, 328, 0); EXPECT_EQ(miningTxResult.transactions.size(), static_cast(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"}}, // display (minconf=0) json{{"private", "1.00000000"}, {"transparent", "0.50000000"}, {"total", "1.50000000"}}, // spendable (minconf=1) 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); // Confirmed/spendable stays at minconf=1; unconfirmed = display total - spendable total (the pending change). EXPECT_NEAR(state.spendablePrivateBalance, 1.0, 0.00000001); EXPECT_NEAR(state.spendableTotalBalance, 1.5, 0.00000001); EXPECT_NEAR(state.unconfirmedBalance, 0.25, 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(1234)); EXPECT_TRUE(state.sync.tip_known); // longestchain>0 -> peer-derived tip is known EXPECT_FALSE(state.sync.isSynced()); // still catching up (blocks 100 < tip 110) // Peerless node: the daemon reports longestchain == 0. Even at the local best height (blocks == // headers) it must NOT be reported synced (the tip is unknown) — otherwise Send would be offered // against a stale balance on a node that has silently lost all peers. (audit: peerless-synced) { dragonx::WalletState peerless; auto pc = Refresh::parseCoreRefreshResult( json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}}, json{{"private", "5.00000000"}, {"transparent", "0.00000000"}, {"total", "5.00000000"}}, true, json{{"blocks", 200}, {"headers", 200}, {"bestblockhash", "peerless-200"}, {"verificationprogress", 1.0}, {"longestchain", 0}, {"notarized", 0}}, true); Refresh::applyCoreRefreshResult(peerless, pc, 1235); EXPECT_FALSE(peerless.sync.tip_known); EXPECT_FALSE(peerless.sync.isSynced()); // peerless -> not synced despite blocks == headers } 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_EQ(state.mining.hashrate_history_t.size(), static_cast(1)); EXPECT_EQ(state.mining.hashrate_history_t[0], static_cast(2345)); { auto capState = state; for (int i = 0; i < dragonx::MiningInfo::MAX_HISTORY + 20; ++i) { Refresh::applyMiningRefreshResult(capState, mining, 3000 + i); } EXPECT_EQ(capState.mining.hashrate_history.size(), static_cast(dragonx::MiningInfo::MAX_HISTORY)); EXPECT_EQ(capState.mining.hashrate_history_t.size(), capState.mining.hashrate_history.size()); } 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)); } // Regression: CoinGecko emits JSON null (not an omitted key) for fields it can't compute — // commonly usd_24h_change on illiquid tokens like DRGX — while still returning a valid spot // price. The parser must keep the valid usd/btc, not discard the whole update on the null. auto priceNull = Refresh::parseCoinGeckoPriceResponse( R"({"dragonx-2":{"usd":0.42,"btc":0.000009,"usd_24h_change":null,"usd_24h_vol":null,"usd_market_cap":50000}})", 0); EXPECT_TRUE(priceNull.has_value()); if (priceNull) { EXPECT_NEAR(priceNull->market.price_usd, 0.42, 0.00000001); EXPECT_NEAR(priceNull->market.price_btc, 0.000009, 0.00000001); EXPECT_NEAR(priceNull->market.change_24h, 0.0, 0.0001); // null -> default, not a throw EXPECT_NEAR(priceNull->market.market_cap, 50000.0, 0.0001); } 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")); // CoinGecko market_chart parser: [ms, price] rows -> (seconds, price), oldest->newest. auto chart = Refresh::parseCoinGeckoMarketChart( R"({"prices":[[1700000000000,1.5],[1700003600000,1.6],[1700007200000,1.55]],)" R"("market_caps":[[1700000000000,100]],"total_volumes":[[1700000000000,10]]})"); EXPECT_EQ(chart.size(), static_cast(3)); if (chart.size() == 3) { EXPECT_EQ(chart[0].first, static_cast(1700000000)); EXPECT_NEAR(chart[0].second, 1.5, 0.00000001); EXPECT_EQ(chart[2].first, static_cast(1700007200)); EXPECT_NEAR(chart[2].second, 1.55, 0.00000001); } // Malformed rows are skipped; a good row still comes through. auto chartSkip = Refresh::parseCoinGeckoMarketChart( R"({"prices":[[1700000000000],["x",1.0],[1700003600000,2.0]]})"); EXPECT_EQ(chartSkip.size(), static_cast(1)); if (chartSkip.size() == 1) EXPECT_NEAR(chartSkip[0].second, 2.0, 0.00000001); // Missing/!array "prices" and invalid JSON both yield an empty series. EXPECT_EQ(Refresh::parseCoinGeckoMarketChart(R"({"nope":[]})").size(), static_cast(0)); EXPECT_EQ(Refresh::parseCoinGeckoMarketChart("not json").size(), static_cast(0)); 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; transactionResult.newViewTxEntries["pending-empty"] = Refresh::TransactionViewCacheEntry{}; Refresh::TransactionViewCache viewCache; std::unordered_set sendTxids{"shielded-send", "pending", "pending-empty"}; 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(2)); EXPECT_EQ(sendTxids.count("shielded-send"), static_cast(0)); EXPECT_EQ(sendTxids.count("pending"), static_cast(1)); EXPECT_EQ(sendTxids.count("pending-empty"), 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 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(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(1)); EXPECT_EQ(parsed.successTxids[0], std::string("tx-success")); EXPECT_EQ(parsed.successTxidsByOpid.size(), static_cast(1)); EXPECT_EQ(parsed.successTxidsByOpid.at("op-success"), std::string("tx-success")); EXPECT_EQ(parsed.failureMessages.size(), static_cast(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(1)); EXPECT_EQ(parsed.failureByOpid.at("op-failed"), std::string("bad memo")); EXPECT_EQ(parsed.staleOpids.size(), static_cast(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()); // Regression: a type-anomalous element (non-string "id"/"status") must be skipped, not throw — // a throw here would escape the parser and permanently wedge opid polling for the session. A // valid tracked opid alongside the anomaly must still be processed. auto typeSafe = Refresh::parseOperationStatusPoll(json::array({ json{{"id", 12345}, {"status", "failed"}}, // non-string id -> skipped, no throw json{{"id", "op-ok"}, {"status", 7}}, // non-string status -> "" (not done) json{{"id", "op-good"}, {"status", "success"}, {"result", json{{"txid", "tx-good"}}}} }), {"op-ok", "op-good", "op-x"}); EXPECT_TRUE(typeSafe.anySuccess); EXPECT_EQ(typeSafe.successTxidsByOpid.at("op-good"), std::string("tx-good")); EXPECT_TRUE(typeSafe.failureMessages.empty()); // the non-string-id "failed" was skipped EXPECT_EQ(typeSafe.staleOpids.size(), static_cast(1)); // op-x absent; op-ok was seen (not stale) EXPECT_EQ(typeSafe.staleOpids[0], std::string("op-x")); } void testSecureVaultScope() { using dragonx::util::SecureVault; auto endsWith = [](const std::string& s, const std::string& suf) { return s.size() >= suf.size() && s.compare(s.size() - suf.size(), suf.size(), suf) == 0; }; // Default wallet (empty or "wallet.dat") keeps the legacy global vault.dat for back-compat. EXPECT_TRUE(endsWith(SecureVault("").getVaultPath(), "vault.dat")); EXPECT_TRUE(endsWith(SecureVault("wallet.dat").getVaultPath(), "vault.dat")); // A non-default wallet gets its own, distinct vault (not the legacy path). SecureVault vb("wallet-savings.dat"); const std::string pb = vb.getVaultPath(); EXPECT_TRUE(pb.find("vault-") != std::string::npos); EXPECT_TRUE(pb != SecureVault("").getVaultPath()); EXPECT_TRUE(pb != SecureVault("wallet-cold.dat").getVaultPath()); // The FNV suffix disambiguates sanitize-colliding names, so distinct files never share a vault. EXPECT_TRUE(SecureVault("wallet foo.dat").getVaultPath() != SecureVault("wallet_foo.dat").getVaultPath()); // Re-scoping moves the path; scoping back to the default returns to vault.dat. vb.setWalletScope("wallet-cold.dat"); EXPECT_TRUE(vb.getVaultPath() == SecureVault("wallet-cold.dat").getVaultPath()); vb.setWalletScope("wallet.dat"); EXPECT_TRUE(endsWith(vb.getVaultPath(), "vault.dat")); } // C87: a passphrase change marks the PIN vault out of date (D9(a)) and never removes it; RPC -14 on a // PIN unlock whose vault opened is "PIN out of date", not a wrong PIN (D10). void testPinVaultPolicy() { using dragonx::data::VaultAfterChange; using dragonx::data::decideVaultAfterPassphraseChange; using dragonx::data::isStaleVaultUnlockFailure; const std::string pa = "/cfg/vault.dat", pb = "/cfg/vault-wallet-b-1234abcd.dat"; // The spec rows: failure, no vault, same passphrase, path mismatch -> Keep; otherwise MarkStale. EXPECT_TRUE(decideVaultAfterPassphraseChange(false, true, false, pa, pa) == VaultAfterChange::Keep); EXPECT_TRUE(decideVaultAfterPassphraseChange(true, false, false, pa, pa) == VaultAfterChange::Keep); EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, true, pa, pa) == VaultAfterChange::Keep); EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, false, pa, pb) == VaultAfterChange::Keep); EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, false, "", "") == VaultAfterChange::Keep); EXPECT_TRUE(decideVaultAfterPassphraseChange(true, true, false, pa, pa) == VaultAfterChange::MarkStale); // Full table: MarkStale exactly when all four conditions hold; there is no "remove" outcome. int stale = 0; for (int m = 0; m < 16; ++m) { const bool ok = m & 1, exists = m & 2, same = m & 4, match = m & 8; const VaultAfterChange r = decideVaultAfterPassphraseChange(ok, exists, same, pa, match ? pa : pb); const bool want = ok && exists && !same && match; EXPECT_TRUE(r == (want ? VaultAfterChange::MarkStale : VaultAfterChange::Keep)); if (r == VaultAfterChange::MarkStale) ++stale; } EXPECT_EQ(stale, 1); EXPECT_EQ(dragonx::data::kRpcWalletPassphraseIncorrect, -14); EXPECT_TRUE(isStaleVaultUnlockFailure(true, -14)); EXPECT_FALSE(isStaleVaultUnlockFailure(true, -28)); // warmup: a transient refusal, not a stale vault EXPECT_FALSE(isStaleVaultUnlockFailure(false, -14)); // the vault did not open: a wrong PIN EXPECT_FALSE(isStaleVaultUnlockFailure(true, 0)); // transport / non-RPC failure EXPECT_FALSE(isStaleVaultUnlockFailure(true, -1)); } #ifndef _WIN32 // C87 D11: isWellFormed() is a structural check only (size + version byte). A zero-filled file (what a // removeVault() whose unlink failed leaves) or a truncated one is not well formed, and retrieve() fails // on it before any key derivation; the PIN unlock then treats it like a missing vault (no penalty). // Under a temp $HOME: the vault lives in the config dir. void testSecureVaultWellFormed() { using dragonx::util::SecureVault; const char* oldHome = std::getenv("HOME"); const std::string savedHome = oldHome ? oldHome : ""; const char* oldXdg = std::getenv("XDG_CONFIG_HOME"); const bool hadXdg = oldXdg != nullptr; const std::string savedXdg = hadXdg ? oldXdg : ""; const fs::path tmp = makeTempDir(); setenv("HOME", tmp.string().c_str(), 1); setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1); { std::error_code ec; fs::create_directories(dragonx::util::Platform::getConfigDir(), ec); SecureVault v("wallet-c87.dat"); const std::string path = v.getVaultPath(); EXPECT_TRUE(path.rfind(tmp.string(), 0) == 0); // never the real config dir EXPECT_FALSE(v.hasVault()); EXPECT_FALSE(v.isWellFormed()); // missing EXPECT_TRUE(v.store("1234", "correct horse battery")); EXPECT_TRUE(v.hasVault()); EXPECT_TRUE(v.isWellFormed()); std::string pass; EXPECT_TRUE(v.retrieve("1234", pass)); EXPECT_EQ(pass, std::string("correct horse battery")); const std::string good = [&] { std::ifstream in(path, std::ios::binary); return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); }(); EXPECT_TRUE(good.size() > 41); auto rewrite = [&](const std::string& bytes) { std::ofstream out(path, std::ios::binary | std::ios::trunc); out.write(bytes.data(), static_cast(bytes.size())); }; // Zero-filled, same size. rewrite(std::string(good.size(), '\0')); EXPECT_TRUE(v.hasVault()); EXPECT_FALSE(v.isWellFormed()); pass.clear(); EXPECT_FALSE(v.retrieve("1234", pass)); EXPECT_TRUE(pass.empty()); // Truncated (a valid version byte, too short to hold salt + nonce + tag). rewrite(good.substr(0, 20)); EXPECT_FALSE(v.isWellFormed()); EXPECT_FALSE(v.retrieve("1234", pass)); // One byte short of the minimum (version + salt + nonce + tag + 1). rewrite(good.substr(0, 1 + 16 + 24 + 16)); EXPECT_FALSE(v.isWellFormed()); // Full size, unknown version byte. std::string badVersion = good; badVersion[0] = '\x02'; rewrite(badVersion); EXPECT_FALSE(v.isWellFormed()); EXPECT_FALSE(v.retrieve("1234", pass)); // Empty file. rewrite(std::string()); EXPECT_FALSE(v.isWellFormed()); // The original bytes are well formed again (the check is structural only). rewrite(good); EXPECT_TRUE(v.isWellFormed()); EXPECT_TRUE(v.removeVault()); EXPECT_FALSE(v.hasVault()); } if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME"); removeTree(tmp); } #endif #ifndef _WIN32 void testInstalledDaemonInfoPosix() { const char* oldHome = std::getenv("HOME"); const std::string savedHome = oldHome ? oldHome : ""; const char* oldXdg = std::getenv("XDG_CONFIG_HOME"); const bool hadXdg = oldXdg != nullptr; const std::string savedXdg = hadXdg ? oldXdg : ""; const fs::path tmp = makeTempDir(); setenv("HOME", tmp.string().c_str(), 1); setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1); { const fs::path dir = fs::path(dragonx::resources::getDaemonDirectory()); std::error_code ec; fs::create_directories(dir, ec); EXPECT_TRUE(dir.string().rfind(tmp.string(), 0) == 0); // never the real config dir auto info0 = dragonx::resources::getInstalledDaemonInfo(false); EXPECT_FALSE(info0.exists); // nothing installed yet { // a stamp in a small file std::ofstream o(dir / "dragonxd", std::ios::binary); o << "garbage v9 x v1.3.0-0123abcdef trailing"; } auto info1 = dragonx::resources::getInstalledDaemonInfo(true); EXPECT_TRUE(info1.exists); EXPECT_EQ(info1.version, std::string("v1.3.0-0123abcdef")); { // a stamp straddling the 4 MiB chunk boundary std::string blob(4u * 1024 * 1024 - 7, 'x'); blob += "v1.4.2-deadbeef12"; blob += std::string(100, 'y'); std::ofstream o(dir / "dragonxd", std::ios::binary | std::ios::trunc); o << blob; } EXPECT_EQ(dragonx::resources::getInstalledDaemonInfo(true).version, std::string("v1.4.2-deadbeef12")); { // no stamp => "" std::ofstream o(dir / "dragonxd", std::ios::binary | std::ios::trunc); o << std::string(5000, 'z'); } EXPECT_TRUE(dragonx::resources::getInstalledDaemonInfo(true).version.empty()); EXPECT_EQ(std::string(dragonx::resources::DAEMON_BINARY_NAME), std::string("dragonxd")); } if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME"); removeTree(tmp); } #endif 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 stuckDaemon; // S11: never start a second node over one that hasn't exited stuckDaemon.stopResult = false; rpc.probeCalls = 0; auto stuckRestart = Executor::restartDaemonAndWait(stuckDaemon, rpc, 0, 0, 5); EXPECT_FALSE(stuckRestart.ok); EXPECT_FALSE(stuckRestart.error.empty()); EXPECT_EQ(stuckDaemon.startCalls, 0); EXPECT_EQ(rpc.probeCalls, 0); 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 testIsLocalHost() { using dragonx::rpc::Connection; // Genuine loopback / local hosts. EXPECT_TRUE(Connection::isLocalHost("127.0.0.1")); EXPECT_TRUE(Connection::isLocalHost("127.1.2.3")); EXPECT_TRUE(Connection::isLocalHost("localhost")); EXPECT_TRUE(Connection::isLocalHost("LocalHost")); EXPECT_TRUE(Connection::isLocalHost("::1")); EXPECT_TRUE(Connection::isLocalHost("[::1]")); // The regression this fix targets: a hostname merely starting "127." is NOT loopback. EXPECT_TRUE(!Connection::isLocalHost("127.evil.com")); EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example")); EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1")); EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1")); EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5")); EXPECT_TRUE(!Connection::isLocalHost("example.com")); } void testAllowsPlaintextRemote() { using dragonx::rpc::Connection; using dragonx::rpc::ConnectionConfig; ConnectionConfig local; local.host = "127.0.0.1"; local.use_tls = false; EXPECT_TRUE(!Connection::usesPlaintextRemote(local)); // local is never "plaintext remote" ConnectionConfig remote; remote.host = "10.0.0.5"; remote.use_tls = false; EXPECT_TRUE(Connection::usesPlaintextRemote(remote)); // remote + no TLS EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused remote.allow_plaintext_remote = true; EXPECT_TRUE(Connection::allowsPlaintextRemote(remote)); // explicit opt-in ConnectionConfig remoteTls; remoteTls.host = "10.0.0.5"; remoteTls.use_tls = true; EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused } void testConsoleSecretRedaction() { using dragonx::ui::RedactConsoleCommand; using dragonx::ui::ConsoleCommandCarriesSecret; // Secret-bearing commands are recognized (case- and whitespace-insensitive on the name). EXPECT_TRUE(ConsoleCommandCarriesSecret("walletpassphrase myPass 60")); EXPECT_TRUE(ConsoleCommandCarriesSecret("z_importkey SK-secret")); EXPECT_TRUE(ConsoleCommandCarriesSecret(" ENCRYPTWALLET topsecret")); EXPECT_TRUE(!ConsoleCommandCarriesSecret("getinfo")); EXPECT_TRUE(!ConsoleCommandCarriesSecret("getwalletinfo")); // not a false-positive substring match // Redaction replaces the arguments with **** but preserves the (original-case) command name. EXPECT_EQ(RedactConsoleCommand("walletpassphrase myPass 60"), std::string("walletpassphrase ****")); EXPECT_EQ(RedactConsoleCommand("z_importkey SK-secret-key"), std::string("z_importkey ****")); EXPECT_EQ(RedactConsoleCommand("ENCRYPTWALLET topsecret"), std::string("ENCRYPTWALLET ****")); // A bare secret command with no argument is left unchanged (nothing to hide). EXPECT_EQ(RedactConsoleCommand("walletpassphrase"), std::string("walletpassphrase")); // Non-secret commands pass through untouched. EXPECT_EQ(RedactConsoleCommand("sendtoaddress addr 1.0"), std::string("sendtoaddress addr 1.0")); EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo")); } void testNodeStatusBanner() { using namespace dragonx::ui; // S9: a busy node gets its own banner, ahead of every other full-node state. It must show even while // daemon_initializing is set (the busy overlay sets it), and offers no action (a reconnect would only // queue more requests behind the busy node). { NodeBannerInputs in; in.daemon_initializing = true; in.node_busy = true; in.node_busy_detail = "Running for 12:03."; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.show); EXPECT_TRUE(s.reason == NodeBannerReason::NodeBusy); EXPECT_TRUE(s.action == NodeBannerAction::None); EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning); EXPECT_EQ(s.detail, std::string("Running for 12:03.")); } { // ...over "no peers" while connected, and over "offline" when not. NodeBannerInputs connected; connected.connected = true; connected.tip_known = false; connected.blocks = 5; connected.node_busy = true; EXPECT_TRUE(evaluateNodeStatusBanner(connected).reason == NodeBannerReason::NodeBusy); NodeBannerInputs offline; offline.node_busy = true; EXPECT_TRUE(evaluateNodeStatusBanner(offline).reason == NodeBannerReason::NodeBusy); NodeBannerInputs lite; lite.lite = true; lite.node_busy = true; // lite has no node to be busy EXPECT_FALSE(evaluateNodeStatusBanner(lite).show); } // Connected full node → no banner. { NodeBannerInputs in; in.connected = true; EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); } // Connected but no peer-derived tip (0 peers) once chain info is in → amber "no peers" banner. // An external node (no embedded controller) gets no action button, and the detail is cleared: // connection_status ("Peers: 0"/"Connected") would contradict the headline, so App renders a // translated actionable hint for this reason instead. { NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 3262059; in.connection_status = "Peers: 0"; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.show); EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning); EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers); EXPECT_TRUE(s.action == NodeBannerAction::None); EXPECT_TRUE(s.detail.empty()); } // ...but with the embedded daemon running, offer a Restart (re-triggers peer discovery). { NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 3262059; in.using_embedded_daemon = true; in.has_daemon_controller = true; in.daemon_running = true; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.reason == NodeBannerReason::NoPeers); EXPECT_TRUE(s.action == NodeBannerAction::RestartNode); EXPECT_TRUE(s.detail.empty()); } // Connected, tip unknown, but no chain info yet (blocks==0) → no banner (avoid startup flicker). { NodeBannerInputs in; in.connected = true; in.tip_known = false; in.blocks = 0; EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); } // Connected with a known tip → no banner. { NodeBannerInputs in; in.connected = true; in.tip_known = true; in.blocks = 100; EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); } // Expected startup phases own the screen (loading/warmup overlay) → no banner. { NodeBannerInputs in; in.warming_up = true; EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); NodeBannerInputs in2; in2.daemon_initializing = true; EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show); NodeBannerInputs in3; in3.connection_in_progress = true; EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show); } // Genuinely offline full node → amber, reconnect offered, detail passed through. { NodeBannerInputs in; in.connection_status = "Lost connection to daemon"; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.show); EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning); EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline); EXPECT_TRUE(s.action == NodeBannerAction::Reconnect); EXPECT_EQ(s.detail, std::string("Lost connection to daemon")); } // Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred. { NodeBannerInputs in; in.using_embedded_daemon = true; in.has_daemon_controller = true; in.daemon_running = false; in.daemon_crash_count = kNodeBannerCrashGiveUpCount; in.daemon_last_error = "exit code 134"; in.connection_status = "Daemon crashed 3 times"; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.show); EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed); EXPECT_TRUE(s.action == NodeBannerAction::RestartNode); EXPECT_EQ(s.detail, std::string("exit code 134")); } // Below the give-up threshold it's still just an offline/reconnect banner, not the crash one. { NodeBannerInputs in; in.using_embedded_daemon = true; in.has_daemon_controller = true; in.daemon_running = false; in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.show); EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline); EXPECT_TRUE(s.action == NodeBannerAction::Reconnect); } // Lite: an open failure shows a red, action-less banner; no failure → nothing. { NodeBannerInputs in; in.lite = true; in.connected = false; in.lite_open_error = "wallet.dat is corrupt"; NodeBannerState s = evaluateNodeStatusBanner(in); EXPECT_TRUE(s.show); EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed); EXPECT_TRUE(s.action == NodeBannerAction::None); EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt")); NodeBannerInputs clean; clean.lite = true; clean.connected = false; // no error yet EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show); NodeBannerInputs open; open.lite = true; open.connected = true; EXPECT_TRUE(!evaluateNodeStatusBanner(open).show); } } void testStalenessBadge() { using namespace dragonx::ui; const int64_t now = 1'000'000; // Disconnected → banner's job, never a badge. EXPECT_TRUE(!evaluateStalenessBadge(now - 999, now, /*connected=*/false).show); // Never updated this session (0 stamp, e.g. fresh start / reset on disconnect) → nothing. EXPECT_TRUE(!evaluateStalenessBadge(0, now, true).show); // Fresh (just under the threshold) → no badge. EXPECT_TRUE(!evaluateStalenessBadge(now - (kStaleAfterSeconds - 1), now, true).show); // At the threshold → amber badge, age reported. { StalenessBadge b = evaluateStalenessBadge(now - kStaleAfterSeconds, now, true); EXPECT_TRUE(b.show); EXPECT_TRUE(b.severity == StalenessSeverity::Warning); EXPECT_EQ(b.seconds_old, (int64_t)kStaleAfterSeconds); } // Past the very-stale threshold → red. { StalenessBadge b = evaluateStalenessBadge(now - kVeryStaleAfterSeconds, now, true); EXPECT_TRUE(b.show); EXPECT_TRUE(b.severity == StalenessSeverity::Error); } // Clock skew (future timestamp) is clamped to age 0 → no badge, no negative age. { StalenessBadge b = evaluateStalenessBadge(now + 100, now, true); EXPECT_TRUE(!b.show); } } void testNotificationLiveness() { using namespace dragonx::ui; using clk = std::chrono::steady_clock; // prune removes expired entries anywhere in the queue, not just the front std::deque q; q.emplace_back("long", NotificationType::Error, 30.0f); q.emplace_back("short", NotificationType::Info, 1.0f); q.back().created_at = clk::now() - std::chrono::seconds(2); // expired q.emplace_back("mid", NotificationType::Info, 5.0f); q[2].created_at = clk::now() - std::chrono::seconds(10); // expired Notifications::pruneExpired(q, clk::now()); EXPECT_EQ((int)q.size(), 1); EXPECT_EQ(q.back().message, std::string("long")); // visibleToast skips an expired newest entry and hasActive tracks visibility auto& n = Notifications::instance(); n.clear(); n.push("a", NotificationType::Warning, 30.0f); n.push("b", NotificationType::Info, 0.001f); std::this_thread::sleep_for(std::chrono::milliseconds(10)); EXPECT_TRUE(n.visibleToast() != nullptr && n.visibleToast()->message == "a"); EXPECT_TRUE(n.hasActive()); n.clear(); EXPECT_TRUE(!n.hasActive()); } void testNotificationHistory() { using dragonx::ui::Notifications; using dragonx::ui::NotificationType; auto& n = Notifications::instance(); n.clearHistory(); auto base = n.totalPushed(); // monotonic counter is NOT reset by clearHistory() n.push("first", NotificationType::Info, 5.0f); n.push("second", NotificationType::Error, 5.0f); EXPECT_EQ((int)n.history().size(), 2); EXPECT_TRUE(n.hasHistory()); // Oldest first, newest last; type + wall-clock stamp retained. EXPECT_EQ(n.history().front().message, std::string("first")); EXPECT_EQ(n.history().back().message, std::string("second")); EXPECT_TRUE(n.history().back().type == NotificationType::Error); EXPECT_TRUE(n.history().back().epoch > 0); EXPECT_EQ((int)(n.totalPushed() - base), 2); // Cap at 100: push past it → size caps, oldest entries drop, totalPushed keeps counting. for (int i = 0; i < 150; ++i) n.push("bulk", NotificationType::Info, 5.0f); EXPECT_EQ((int)n.history().size(), 100); EXPECT_EQ((int)(n.totalPushed() - base), 152); EXPECT_EQ(n.history().front().message, std::string("bulk")); // the two originals fell off n.clearHistory(); EXPECT_TRUE(!n.hasHistory()); EXPECT_EQ((int)n.history().size(), 0); EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter } void testSeedMigrationResume() { using dragonx::decideSeedMigrationResume; using dragonx::MigrationResume; // No pending migration (or missing dest) → start fresh at the intro. EXPECT_TRUE(decideSeedMigrationResume(false, false, "", "", true) == MigrationResume::Intro); EXPECT_TRUE(decideSeedMigrationResume(false, true, "tx", "op", true) == MigrationResume::Intro); EXPECT_TRUE(decideSeedMigrationResume(true, false, "tx", "op", true) == MigrationResume::Intro); // A persisted txid outranks everything → resume at the confirm/adopt gate (txid-first invariant). EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "", true) == MigrationResume::Confirming); EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", true) == MigrationResume::Confirming); EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", false) == MigrationResume::Confirming); // Opid but no txid, AND connected → re-track the opid (recover the txid / detect stale). EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", true) == MigrationResume::RetrackOpid); // W3-3 connectivity gate: opid but NOT connected → the dismissable Sweep gate, NOT the buttonless // Sweeping spinner (whose only exit is the opid poller, which needs a connection). This is the // trap the review caught. EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", false) == MigrationResume::SweepGate); // No txid and no opid → the Sweep gate (whether or not connected). EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate); EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate); // fund-safety: the Sweep step may only short-circuit a ~0 balance straight to adopt when NO sweep // was ever submitted (a genuinely empty wallet). Once a sweep has been broadcast, a ~0 balance is // ambiguous (the tx may be unconfirmed in the mempool), so direct adopt must be blocked. EXPECT_TRUE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/false)); // empty wallet -> ok EXPECT_FALSE(dragonx::sweepGateMayAdoptEmptyWallet(/*sweepSubmitted=*/true)); // in-flight -> blocked } void testLoggerFileSink() { using dragonx::util::Logger; namespace fsn = std::filesystem; fsn::path logPath = fsn::temp_directory_path() / "od_logger_test.log"; std::error_code ec; fsn::remove(logPath, ec); fsn::remove(logPath.string() + ".1", ec); // W7-2: init() opens the file sink and must NOT deadlock — it writes the banner under the same // non-recursive lock it holds (this test would hang if that regressed). Logger& lg = Logger::instance(); EXPECT_TRUE(lg.init(logPath.string())); lg.write("hello-w7-2-sink"); EXPECT_TRUE(fsn::exists(logPath)); std::ifstream f(logPath.string()); std::string all, line; while (std::getline(f, line)) all += line + "\n"; f.close(); EXPECT_TRUE(all.find("hello-w7-2-sink") != std::string::npos); EXPECT_TRUE(all.find("Logger initialized") != std::string::npos); fsn::remove(logPath, ec); fsn::remove(logPath.string() + ".1", ec); } void testConnectHasStalled() { using dragonx::util::connectHasStalled; EXPECT_TRUE(connectHasStalled(100.0, 145.0, 45.0f)); // exactly at threshold EXPECT_TRUE(connectHasStalled(100.0, 300.0, 45.0f)); // well over EXPECT_TRUE(!connectHasStalled(100.0, 144.0, 45.0f)); // just under EXPECT_TRUE(!connectHasStalled(0.0, 1000.0, 45.0f)); // sentinel: not stalling EXPECT_TRUE(!connectHasStalled(-1.0, 1000.0, 45.0f)); // sentinel: not stalling EXPECT_TRUE(!connectHasStalled(10.0, 20.0, 0.0f)); // disabled: threshold 0 EXPECT_TRUE(!connectHasStalled(10.0, 20.0, -5.0f)); // disabled: negative threshold } void testVerifySaplingParams() { using dragonx::rpc::Connection; namespace fsn = std::filesystem; fsn::path dir = fsn::temp_directory_path() / "od_sapling_test"; std::error_code rmec; fsn::remove_all(dir, rmec); fsn::create_directories(dir); auto writeFile = [](const fsn::path& p, const std::string& content) { std::ofstream(p.string(), std::ios::binary) << content; }; const std::string spendContent = "fake-spend-params-contents"; const std::string outputContent = "fake-output-params-contents"; writeFile(dir / "sapling-spend.params", spendContent); writeFile(dir / "sapling-output.params", outputContent); const std::string spendHash = dragonx::util::sha256Hex(spendContent.data(), spendContent.size()); const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size()); const std::vector> good = { { "sapling-spend.params", spendHash }, { "sapling-output.params", outputHash }, }; // Valid params → pass, and a verification marker is written. EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good)); EXPECT_TRUE(fsn::exists(dir / ".sapling_verified")); // Second call → marker fast-path, still true (round-trips the cache). EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good)); // Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it). fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2"; fsn::remove_all(dir2, rmec); fsn::create_directories(dir2); writeFile(dir2 / "sapling-spend.params", spendContent); writeFile(dir2 / "sapling-output.params", outputContent); const std::vector> wrong = { { "sapling-spend.params", std::string(64, 'a') }, { "sapling-output.params", outputHash }, }; EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong)); // Truncated content (size change) invalidates the marker AND fails the hash. writeFile(dir / "sapling-spend.params", std::string("x")); EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good)); // A missing param → fail. fsn::remove(dir / "sapling-output.params", rmec); EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good)); fsn::remove_all(dir, rmec); fsn::remove_all(dir2, rmec); } // ── Startup node files (resources/provision_state.h, embedded_resources atomic writers, Sapling param state) ── static std::string readProvisionFile(const fs::path& p) { std::ifstream f(p.string(), std::ios::binary); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } void testStartupProvisionState() { using namespace dragonx::resources; EXPECT_EQ(provisionPercent(0, 0), 0); EXPECT_EQ(provisionPercent(50, 100), 50); EXPECT_EQ(provisionPercent(100, 100), 100); EXPECT_EQ(provisionPercent(150, 100), 100); EXPECT_EQ(provisionPercent(1, 3), 33); EXPECT_TRUE(!provisionChipVisible(true, 0.1, 0.4)); EXPECT_TRUE(provisionChipVisible(true, 0.5, 0.4)); EXPECT_TRUE(!provisionChipVisible(false, 5.0, 0.4)); EXPECT_TRUE(daemonStartGate(false, true) == DaemonStartGate::Proceed); EXPECT_TRUE(daemonStartGate(false, false) == DaemonStartGate::Proceed); EXPECT_TRUE(daemonStartGate(true, true) == DaemonStartGate::Defer); EXPECT_TRUE(daemonStartGate(true, false) == DaemonStartGate::Wait); ProvisionProgress progress; EXPECT_TRUE(progress.getPhase() == ProvisionPhase::Idle); progress.setPhase(ProvisionPhase::Verifying); EXPECT_TRUE(progress.getPhase() == ProvisionPhase::Verifying); StartupProvision sp; StartupProvision::Result r; EXPECT_TRUE(!sp.isRunning()); EXPECT_TRUE(!sp.hasCompletion()); EXPECT_TRUE(!sp.takeCompletion(&r)); EXPECT_TRUE(sp.waitUntilDone(0)); sp.begin(); EXPECT_TRUE(sp.isRunning()); EXPECT_TRUE(!sp.hasCompletion()); // nothing to reap while it runs (no idle-loop wake for the whole job) EXPECT_TRUE(!sp.takeCompletion(&r)); EXPECT_TRUE(!sp.waitUntilDone(10)); EXPECT_TRUE(sp.elapsedSeconds() >= 0.0); std::thread worker([&sp] { sp.finish({ true, false, false }); }); EXPECT_TRUE(sp.waitUntilDone(5000)); worker.join(); EXPECT_TRUE(!sp.isRunning()); EXPECT_TRUE(sp.hasCompletion()); EXPECT_TRUE(sp.takeCompletion(&r)); EXPECT_TRUE(r.extractOk && !r.verifyOk && !r.cancelled); EXPECT_TRUE(!sp.takeCompletion(&r)); // exactly once EXPECT_TRUE(!sp.hasCompletion()); EXPECT_NEAR(sp.elapsedSeconds(), 0.0, 1e-9); EXPECT_TRUE(!sp.endedCancelled()); // A job that shutdown cancelled: a waiting daemon start / miner start must abort, not fall through. StartupProvision cancelledJob; cancelledJob.begin(); EXPECT_TRUE(!cancelledJob.endedCancelled()); // still running cancelledJob.finish({ false, true, true }); EXPECT_TRUE(cancelledJob.waitUntilDone(0)); EXPECT_TRUE(cancelledJob.endedCancelled()); EXPECT_TRUE(cancelledJob.takeCompletion(&r) && r.cancelled); EXPECT_TRUE(cancelledJob.endedCancelled()); // a non-reaping peek: still true once reaped StartupProvision idle; idle.markNothingToDo(); EXPECT_TRUE(!idle.hasCompletion() && !idle.isRunning()); EXPECT_TRUE(!idle.takeCompletion(nullptr)); EXPECT_TRUE(idle.waitUntilDone(0)); EXPECT_TRUE(!idle.endedCancelled()); } void testResourceNeedsWrite() { using dragonx::resources::resourceNeedsWrite; const fs::path dir = makeTempDir(); const std::string f = (dir / "res.bin").string(); EXPECT_TRUE(resourceNeedsWrite(10, false, f)); // missing EXPECT_TRUE(resourceNeedsWrite(10, true, f)); EXPECT_TRUE(!resourceNeedsWrite(0, true, f)); // nothing embedded { std::ofstream(f, std::ios::binary) << "12345"; } EXPECT_TRUE(!resourceNeedsWrite(10, false, f)); // present, size not tracked EXPECT_TRUE(!resourceNeedsWrite(5, true, f)); // same size EXPECT_TRUE(resourceNeedsWrite(10, true, f)); // the bundled size differs EXPECT_TRUE(!resourceNeedsWrite(0, true, f)); removeTree(dir); } // C120: the bundled miner is placed only when missing, so an "Update miner..." install of a different size is // never overwritten by the bundled copy on the next start. void testXmrigProvisionPolicy() { using dragonx::resources::kXmrigRefreshOnSizeMismatch; using dragonx::resources::resourceNeedsWrite; EXPECT_TRUE(!kXmrigRefreshOnSizeMismatch); const fs::path dir = makeTempDir(); const std::string f = (dir / "xmrig.exe").string(); const std::string missing = (dir / "missing-xmrig.exe").string(); { std::ofstream(f, std::ios::binary) << "12345"; } EXPECT_TRUE(!resourceNeedsWrite(10, kXmrigRefreshOnSizeMismatch, f)); // present, different size: kept EXPECT_TRUE(resourceNeedsWrite(10, kXmrigRefreshOnSizeMismatch, missing)); // missing: placed removeTree(dir); } // ── C222: bootstrap preflight (util/bootstrap.h) ── // Writes a zip with miniz. level 0 stores members uncompressed, so a test can corrupt member data in place. static bool writeBootstrapTestZip(const fs::path& zipPath, const std::vector>& entries, mz_uint level = MZ_DEFAULT_LEVEL) { mz_zip_archive zip; std::memset(&zip, 0, sizeof(zip)); if (!mz_zip_writer_init_file(&zip, zipPath.string().c_str(), 0)) return false; bool ok = true; for (const auto& e : entries) ok = ok && mz_zip_writer_add_mem(&zip, e.first.c_str(), e.second.data(), e.second.size(), level); ok = mz_zip_writer_finalize_archive(&zip) && ok; mz_zip_writer_end(&zip); return ok; } static fs::space_info bootstrapTestSpace(std::uintmax_t avail, std::uintmax_t capacity) { fs::space_info si{}; si.capacity = capacity; si.free = avail; si.available = avail; return si; } static bool bootstrapPathAbsent(const fs::path& p) { std::error_code ec; return fs::symlink_status(p, ec).type() == fs::file_type::not_found; } static bool waitBootstrapDone(const dragonx::util::Bootstrap& b, double timeoutSec) { const auto t0 = std::chrono::steady_clock::now(); while (secondsSince(t0) < timeoutSec) { if (b.isDone()) return true; std::this_thread::sleep_for(std::chrono::milliseconds(2)); } return b.isDone(); } // An old chain plus a wallet in : blocks/old, chainstate/old, wallet.dat. static void plantOldBootstrapChain(const fs::path& data, const std::string& walletBytes) { fs::create_directories(data / "blocks"); fs::create_directories(data / "chainstate"); { std::ofstream(data / "blocks" / "old", std::ios::binary) << "OLD-BLOCKS"; } { std::ofstream(data / "chainstate" / "old", std::ios::binary) << "OLD-CHAINSTATE"; } { std::ofstream f(data / "wallet.dat", std::ios::binary); f.write(walletBytes.data(), (std::streamsize)walletBytes.size()); } } void testBootstrapParseChecksum() { using dragonx::util::Bootstrap; const std::string h64(64, 'a'), h32(32, 'b'); EXPECT_EQ(Bootstrap::parseChecksumFile(h64 + " DRAGONX.zip", 64), h64); { std::string up(64, 'A'); EXPECT_EQ(Bootstrap::parseChecksumFile(up, 64), std::string(64, 'a')); } EXPECT_EQ(Bootstrap::parseChecksumFile("...", 64), std::string()); EXPECT_EQ(Bootstrap::parseChecksumFile(std::string(63, 'a'), 64), std::string()); EXPECT_EQ(Bootstrap::parseChecksumFile(std::string(65, 'a'), 64), std::string()); { std::string bad(63, 'a'); bad += 'g'; EXPECT_EQ(Bootstrap::parseChecksumFile(bad, 64), std::string()); } EXPECT_EQ(Bootstrap::parseChecksumFile(h32, 32), h32); EXPECT_EQ(Bootstrap::parseChecksumFile(h32, 64), std::string()); EXPECT_EQ(Bootstrap::parseChecksumFile("", 64), std::string()); } void testBootstrapDecideResume() { using R = dragonx::util::Bootstrap::ResumeAction; using dragonx::util::Bootstrap; EXPECT_TRUE(Bootstrap::decideResume(0, false, -1) == R::Fresh); EXPECT_TRUE(Bootstrap::decideResume(0, true, 100) == R::Fresh); EXPECT_TRUE(Bootstrap::decideResume(5, true, 3) == R::TruncateAndFresh); EXPECT_TRUE(Bootstrap::decideResume(5, true, 5) == R::ReuseComplete); EXPECT_TRUE(Bootstrap::decideResume(3, true, 5) == R::RangedGet); EXPECT_TRUE(Bootstrap::decideResume(3, false, -1) == R::RangedGet); // offline: keep + try range EXPECT_TRUE(Bootstrap::decideResume(3, false, 999) == R::RangedGet); // bogus error-page length ignored } // C219/C220: the single-instance lock lives in a per-user dir, is 0600, O_NOFOLLOW (POSIX only). // Mutates XDG_RUNTIME_DIR, so it saves/restores it and runs before anything that reads that env. void testSingleInstance() { #ifndef _WIN32 namespace fs = std::filesystem; std::error_code ec; const fs::path tmp = fs::temp_directory_path(ec) / ("od_si_test_" + std::to_string(::getpid())); fs::create_directories(tmp, ec); ::chmod(tmp.c_str(), 0700); const char* prev = ::getenv("XDG_RUNTIME_DIR"); const std::string saved = prev ? prev : ""; const bool had = prev != nullptr; ::setenv("XDG_RUNTIME_DIR", tmp.c_str(), 1); { dragonx::util::SingleInstance a("od-test"), b("od-test"); EXPECT_TRUE(a.tryLock()); // first wins EXPECT_TRUE(!b.tryLock()); // flock conflicts across OFDs, even in one process const fs::path lockf = tmp / "od-test.lock"; struct stat st{}; EXPECT_TRUE(::stat(lockf.c_str(), &st) == 0); EXPECT_EQ((int)(st.st_mode & 0777), 0600); a.unlock(); EXPECT_TRUE(fs::exists(lockf, ec)); // lock file is NOT removed on unlock (stale is harmless) // A planted symlink at the lock path is refused by O_NOFOLLOW (open fails -> tryLock false). const fs::path sym = tmp / "od-sym.lock"; fs::create_symlink("/nonexistent/target", sym, ec); if (!ec) { dragonx::util::SingleInstance c("od-sym"); EXPECT_TRUE(!c.tryLock()); } } if (had) ::setenv("XDG_RUNTIME_DIR", saved.c_str(), 1); else ::unsetenv("XDG_RUNTIME_DIR"); fs::remove_all(tmp, ec); #endif } void testBootstrapSpaceSufficient() { using dragonx::util::Bootstrap; const std::uint64_t MiB = 1024ull * 1024ull; const std::uint64_t GiB = 1024ull * MiB; const std::uint64_t kMax = std::numeric_limits::max(); std::string msg; // margin = max(256 MiB, 5%): 5 GiB needs 5 GiB + 256 MiB EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 1 * GiB, 100 * GiB, (36 * GiB) / 10, &msg)); EXPECT_TRUE(msg.find("need") != std::string::npos); EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 6 * GiB, 100 * GiB, 0, nullptr)); // A genuinely full disk (known capacity, nothing available) is a refusal, not "unknown". msg.clear(); EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 0, 100 * GiB, 0, &msg)); EXPECT_TRUE(msg.find("need") != std::string::npos); // Unknown: capacity 0 or avail == (uintmax_t)-1 -> fail open. EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 0, 0, 0, nullptr)); EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, kMax, 100 * GiB, 0, nullptr)); // Exact boundary. EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 5 * GiB + 256 * MiB, 100 * GiB, 0, nullptr)); EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 5 * GiB + 256 * MiB - 1, 100 * GiB, 0, nullptr)); // The old chain's bytes count toward it. EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 2 * GiB, 100 * GiB, 3 * GiB + 256 * MiB, nullptr)); EXPECT_TRUE(!Bootstrap::spaceSufficient(5 * GiB, 2 * GiB, 100 * GiB, 3 * GiB + 256 * MiB - 1, nullptr)); // 256 MiB floor for a small archive; 5% above it. EXPECT_TRUE(!Bootstrap::spaceSufficient(1 * MiB, 256 * MiB, 100 * GiB, 0, nullptr)); EXPECT_TRUE(Bootstrap::spaceSufficient(1 * MiB, 257 * MiB, 100 * GiB, 0, nullptr)); EXPECT_TRUE(!Bootstrap::spaceSufficient(100 * GiB, 105 * GiB - 1, 1000 * GiB, 0, nullptr)); EXPECT_TRUE(Bootstrap::spaceSufficient(100 * GiB, 105 * GiB, 1000 * GiB, 0, nullptr)); // Huge values saturate instead of wrapping around to a small "need". EXPECT_TRUE(!Bootstrap::spaceSufficient(kMax - 1, 10 * GiB, 100 * GiB, 0, nullptr)); EXPECT_TRUE(!Bootstrap::spaceSufficient(kMax, kMax - 1, kMax, 0, nullptr)); EXPECT_TRUE(Bootstrap::spaceSufficient(5 * GiB, 1 * GiB, 100 * GiB, kMax, nullptr)); } void testBootstrapPlanArchive() { using dragonx::util::Bootstrap; const fs::path dir = makeTempDir(); const fs::path good = dir / "good.zip"; EXPECT_TRUE(writeBootstrapTestZip(good, { {"blocks/", ""}, {"blocks/a", std::string(1000, 'b')}, {"chainstate/b", std::string(500, 'c')}, {"peers.dat", std::string(19, 'p')}, {"wallet.dat", std::string(300, 'w')}, {"wallet-cold.dat", "cold"}, {"blocks/sub/wallet.dat", "nested"}, {"DRAGONX.conf", "rpcpassword=x"}, {"database/", ""}, {"database/log.0000000001", "bdb-log"}, {".cookie", "__cookie__:x"}, {"cold.dat", "another wallet file"}, })); Bootstrap::ArchivePlan plan = Bootstrap::planArchive(good.string()); EXPECT_TRUE(plan.ok); EXPECT_EQ(plan.bytes, (std::uint64_t)(1000 + 500 + 19)); // wallet*, *.conf, database/, .cookie, cold.dat excluded const fs::path evil = dir / "evil.zip"; EXPECT_TRUE(writeBootstrapTestZip(evil, {{"blocks/a", std::string(10, 'b')}, {"../evil", "x"}})); plan = Bootstrap::planArchive(evil.string()); EXPECT_TRUE(!plan.ok); EXPECT_TRUE(plan.error.find("unsafe") != std::string::npos); const fs::path garbage = dir / "garbage.zip"; { std::ofstream(garbage, std::ios::binary) << "this is not a zip archive, just some bytes"; } plan = Bootstrap::planArchive(garbage.string()); EXPECT_TRUE(!plan.ok); EXPECT_TRUE(!plan.error.empty()); const fs::path truncated = dir / "truncated.zip"; // the first half: the central directory is gone { const std::string bytes = readProvisionFile(good); std::ofstream f(truncated, std::ios::binary); f.write(bytes.data(), (std::streamsize)(bytes.size() / 2)); } plan = Bootstrap::planArchive(truncated.string()); EXPECT_TRUE(!plan.ok); EXPECT_TRUE(!Bootstrap::planArchive((dir / "missing.zip").string()).ok); for (const char* n : {"wallet.dat", "Wallet.DAT", "wallet-ip-1.dat", "wallets/w.dat", "blocks/wallet.dat", "database/", "database/log.1", "DRAGONX.conf", "sub/x.conf", ".cookie", "cold.dat", "sub\\wallet.dat"}) EXPECT_TRUE(Bootstrap::isProtectedEntry(n) || (std::cerr << " not protected: " << n << "\n", false)); for (const char* n : {"blocks/", "blocks/blk00000.dat", "blocks/index/000001.ldb", "chainstate/000002.ldb", "notarizations/x", "peers.dat", "fee_estimates.dat", "banlist.dat"}) EXPECT_TRUE(!Bootstrap::isProtectedEntry(n) || (std::cerr << " protected: " << n << "\n", false)); // Names that land on a protected file once "." components, trailing dots/spaces and an NTFS stream // suffix are resolved (Win32 does all three; the check applies them everywhere). for (const char* n : {"./foo.dat", "./wallet.dat", ".//./wallet.dat", ".\\wallet.dat", "DRAGONX.conf.", "DRAGONX.conf ", "DRAGONX.conf::$DATA", "database./log.1", "database /log.1", "./database/", "blocks/./wallet.dat", "./.cookie", "./sub/x.conf. . "}) EXPECT_TRUE(Bootstrap::isProtectedEntry(n) || (std::cerr << " not protected: " << n << "\n", false)); for (const char* n : {"./blocks/./index/000001.ldb", "./peers.dat", "./chainstate/", "./", "."}) EXPECT_TRUE(!Bootstrap::isProtectedEntry(n) || (std::cerr << " protected: " << n << "\n", false)); // The resolved names in a plan: skipped like the plain names; the nested safe path counts. const fs::path dotted = dir / "dotted.zip"; EXPECT_TRUE(writeBootstrapTestZip(dotted, { {"./foo.dat", "x"}, {"./wallet.dat", "xx"}, {"DRAGONX.conf.", "xxx"}, {"database./log.1", "xxxx"}, {"./blocks/./index/000001.ldb", std::string(700, 'i')}, })); plan = Bootstrap::planArchive(dotted.string()); EXPECT_TRUE(plan.ok); EXPECT_EQ(plan.bytes, (std::uint64_t)700); // Names a Windows file system would resolve onto another file, or that only it would accept: refused. // Non-ASCII and control bytes: refused too. macOS APFS/HFS+ fold U+017F to 's' and U+212A (Kelvin) to 'k' // and ignore U+200C, so these three land on database/, .cookie and DRAGONX.conf there. for (const char* n : {"DRAGONX.conf::$DATA", "C:x.dat", "blocks/a:b", "DRAGON~1.CON", "DATABA~1/log.1", ".. /evil", "blocks/.../x", "blocks/ /x", "..\\evil", "databa\xC5\xBF" "e/log.1", // "databa\u017Fe/log.1" ".coo\xE2\x84\xAA" "ie", // ".coo\u212Aie" "DRAGONX.co\xE2\x80\x8C" "nf", // "DRAGONX.co\u200Cnf" "blocks/blk\x01" "00000.dat", "blocks/a\nb", "blocks/a\tb", "blocks/a\x7F" "b", "blocks/caf\xC3\xA9"}) { const fs::path z = dir / "refused.zip"; EXPECT_TRUE(writeBootstrapTestZip(z, {{"blocks/a", "b"}, {n, "x"}})); plan = Bootstrap::planArchive(z.string()); EXPECT_TRUE((!plan.ok && plan.error.find("unsafe") != std::string::npos) || (std::cerr << " not refused: " << n << "\n", false)); } // The refused name is echoed with its non-printable bytes escaped, never raw. { const fs::path z = dir / "refused.zip"; EXPECT_TRUE(writeBootstrapTestZip(z, {{"blocks/a\n[Bootstrap] forged\x1B[0m", "x"}})); plan = Bootstrap::planArchive(z.string()); EXPECT_TRUE(!plan.ok); EXPECT_TRUE(plan.error.find("blocks/a\\x0A[Bootstrap] forged\\x1B[0m") != std::string::npos); EXPECT_TRUE(std::none_of(plan.error.begin(), plan.error.end(), [](char c) { return (unsigned char)c < 0x20 || (unsigned char)c >= 0x7f; })); } removeTree(dir); } #ifndef _WIN32 void testBootstrapReclaimable() { using dragonx::util::Bootstrap; const fs::path dir = makeTempDir(); fs::create_directories(dir / "blocks"); { std::ofstream(dir / "blocks" / "f", std::ios::binary) << std::string(1000, 'x'); } fs::create_directories(dir / "elsewhere"); { std::ofstream(dir / "elsewhere" / "g", std::ios::binary) << std::string(5000, 'y'); } // chainstate is a symlink to a 5000 B dir: removing it frees nothing on this filesystem. fs::create_directory_symlink(dir / "elsewhere", dir / "chainstate"); // A file symlink inside blocks/ frees nothing either. fs::create_symlink(dir / "elsewhere" / "g", dir / "blocks" / "link"); EXPECT_EQ(Bootstrap::reclaimableBytes(dir.string()), (std::uint64_t)1000); fs::create_directories(dir / "notarizations" / "sub"); { std::ofstream(dir / "notarizations" / "sub" / "n", std::ios::binary) << std::string(24, 'n'); } EXPECT_EQ(Bootstrap::reclaimableBytes(dir.string()), (std::uint64_t)1024); EXPECT_EQ(Bootstrap::reclaimableBytes((dir / "missing").string()), (std::uint64_t)0); removeTree(dir); } #endif void testBootstrapPreflightKeepsOldChain() { using dragonx::util::Bootstrap; const auto noDaemon = [] { return false; }; const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) { ec.clear(); return bootstrapTestSpace(1ull << 40, 1ull << 41); }; std::string walletBytes = "WALLET-bytes-must-survive"; walletBytes.push_back('\0'); walletBytes += "\x01\x02 tail"; // (1) An unsafe entry: refused before anything is deleted. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "unsafe.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"../evil", "x"}})); Bootstrap b; b.startFromZip(data.string(), zip.string(), noDaemon, plenty); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed); EXPECT_TRUE(p.error.find("unsafe") != std::string::npos); EXPECT_TRUE(p.cancellable); EXPECT_TRUE(fs::exists(data / "blocks" / "old")); EXPECT_TRUE(fs::exists(data / "chainstate" / "old")); EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); EXPECT_TRUE(!fs::exists(dir / "evil")); EXPECT_TRUE(fs::exists(zip)); // the download is kept EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } // (2) A full disk (avail 0, capacity 100 GiB): refused, the old chain untouched. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "full.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", std::string(4096, 'n')}, {"chainstate/new", std::string(1024, 'c')}})); std::atomic spaceCalls{0}; Bootstrap b; b.startFromZip(data.string(), zip.string(), noDaemon, [&spaceCalls](const std::string&, std::error_code& ec) { ++spaceCalls; ec.clear(); return bootstrapTestSpace(0, 100ull * 1024ull * 1024ull * 1024ull); }); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed); EXPECT_TRUE(p.error.find("need") != std::string::npos); EXPECT_TRUE(p.cancellable); EXPECT_EQ(spaceCalls.load(), 1); EXPECT_TRUE(fs::exists(data / "blocks" / "old")); EXPECT_TRUE(fs::exists(data / "chainstate" / "old")); EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); EXPECT_TRUE(fs::exists(zip)); EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } // (3) A corrupt critical member: the partial chain is removed BEFORE the failure is published. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "corrupt.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"chainstate/y", std::string(2048, 'y')}, {"blocks/x", std::string(4096, 'x')}, {"blocks/z", std::string(64, 'z')}}, MZ_NO_COMPRESSION)); // Flip one byte of blocks/x's stored data (its CRC no longer matches). std::uint64_t headerOfs = 0; { mz_zip_archive zr; std::memset(&zr, 0, sizeof(zr)); EXPECT_TRUE(mz_zip_reader_init_file(&zr, zip.string().c_str(), 0)); const int idx = mz_zip_reader_locate_file(&zr, "blocks/x", nullptr, 0); mz_zip_archive_file_stat st; EXPECT_TRUE(idx >= 0 && mz_zip_reader_file_stat(&zr, (mz_uint)idx, &st)); if (idx >= 0) headerOfs = st.m_local_header_ofs; mz_zip_reader_end(&zr); } { std::fstream f(zip, std::ios::in | std::ios::out | std::ios::binary); unsigned char lh[30] = {}; f.seekg((std::streamoff)headerOfs); f.read(reinterpret_cast(lh), sizeof(lh)); const std::uint64_t nameLen = lh[26] | (lh[27] << 8); const std::uint64_t extraLen = lh[28] | (lh[29] << 8); const std::uint64_t dataOfs = headerOfs + 30 + nameLen + extraLen + 100; f.seekp((std::streamoff)dataOfs); f.put('X'); } Bootstrap b; b.startFromZip(data.string(), zip.string(), noDaemon, plenty); bool sawDone = false, chainGoneAtDone = false; const auto t0 = std::chrono::steady_clock::now(); while (secondsSince(t0) < 20.0) { // tight poll: the first "done" must already see the clean slate if (b.isDone()) { chainGoneAtDone = bootstrapPathAbsent(data / "blocks") && bootstrapPathAbsent(data / "chainstate"); sawDone = true; break; } std::this_thread::yield(); } EXPECT_TRUE(sawDone); EXPECT_TRUE(chainGoneAtDone); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed); EXPECT_EQ(p.error, std::string("Failed to extract critical file: blocks/x")); EXPECT_TRUE(!p.cancellable); EXPECT_TRUE(bootstrapPathAbsent(data / "blocks")); EXPECT_TRUE(bootstrapPathAbsent(data / "chainstate")); EXPECT_TRUE(fs::exists(zip)); // kept for a retry EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } // (4) Success: not cancellable at the end, wallet.dat byte-identical, protected members never written. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "good.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/", ""}, {"blocks/blk00000.dat", std::string(3000, 'B')}, {"chainstate/000001.ldb", std::string(1500, 'C')}, {"peers.dat", "peers"}, {"wallet.dat", "EVIL-WALLET"}, {"wallet-cold.dat", "EVIL-COLD"}, {"DRAGONX.conf", "rpcpassword=evil"}, {"database/log.0000000001", "evil"}, {".cookie", "evil"}})); Bootstrap b; EXPECT_TRUE(b.getProgress().cancellable); b.startFromZip(data.string(), zip.string(), noDaemon, plenty); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Completed); EXPECT_TRUE(!p.cancellable); EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); EXPECT_TRUE(!fs::exists(data / "chainstate" / "old")); EXPECT_EQ(readProvisionFile(data / "blocks" / "blk00000.dat"), std::string(3000, 'B')); EXPECT_TRUE(fs::exists(data / "chainstate" / "000001.ldb")); EXPECT_TRUE(fs::exists(data / "peers.dat")); EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); EXPECT_TRUE(!fs::exists(data / "wallet-cold.dat")); EXPECT_TRUE(!fs::exists(data / "DRAGONX.conf")); EXPECT_TRUE(!fs::exists(data / "database")); EXPECT_TRUE(!fs::exists(data / ".cookie")); EXPECT_TRUE(!fs::exists(zip)); // removed after a successful extraction removeTree(dir); } // (5) Unknown free space (the probe fails): fails open, as before. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "unknown.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); Bootstrap b; b.startFromZip(data.string(), zip.string(), noDaemon, [](const std::string&, std::error_code& ec) { ec = std::make_error_code(std::errc::io_error); return bootstrapTestSpace(static_cast(-1), static_cast(-1)); }); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Completed); EXPECT_TRUE(fs::exists(data / "blocks" / "new")); EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } // (6) Members whose names resolve onto protected files ("./foo.dat" = a -wallet=foo.dat file, // "DRAGONX.conf.", "database./log.1") are skipped like the plain names; a "./"-prefixed chain // member is written to its resolved path. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); { std::ofstream(data / "foo.dat", std::ios::binary) << "SECOND-WALLET"; } { std::ofstream(data / "DRAGONX.conf", std::ios::binary) << "rpcpassword=mine"; } fs::create_directories(data / "database"); { std::ofstream(data / "database" / "log.1", std::ios::binary) << "MY-BDB-LOG"; } const fs::path zip = dir / "dotted.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"./foo.dat", "EVIL-FOO"}, {"./wallet.dat", "EVIL-WALLET"}, {"DRAGONX.conf.", "blocknotify=evil"}, {"DRAGONX.conf ", "blocknotify=evil"}, {"database./log.1", "EVIL-LOG"}, {"./blocks/./index/000001.ldb", "IDX"}, {"chainstate/000001.ldb", "CS"}})); Bootstrap b; b.startFromZip(data.string(), zip.string(), noDaemon, plenty); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Completed); EXPECT_EQ(readProvisionFile(data / "foo.dat"), std::string("SECOND-WALLET")); EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); EXPECT_EQ(readProvisionFile(data / "DRAGONX.conf"), std::string("rpcpassword=mine")); EXPECT_EQ(readProvisionFile(data / "database" / "log.1"), std::string("MY-BDB-LOG")); #ifndef _WIN32 // Win32 resolves these names onto the real files checked above EXPECT_TRUE(bootstrapPathAbsent(data / "DRAGONX.conf.")); EXPECT_TRUE(bootstrapPathAbsent(data / "DRAGONX.conf ")); EXPECT_TRUE(bootstrapPathAbsent(data / "database.")); #endif EXPECT_EQ(readProvisionFile(data / "blocks" / "index" / "000001.ldb"), std::string("IDX")); EXPECT_EQ(readProvisionFile(data / "chainstate" / "000001.ldb"), std::string("CS")); EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); removeTree(dir); } } // The user's Cancel (requestCancel) works until the old chain data starts being replaced, and is a no-op // from then on; the start of that window re-checks a request made just before it. void testBootstrapUserCancelWindow() { using dragonx::util::Bootstrap; const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) { ec.clear(); return bootstrapTestSpace(1ull << 40, 1ull << 41); }; const std::string walletBytes = "WALLET-cancel-window"; // (a) Before the window (waiting for the daemon): accepted, the old chain is kept. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "a.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); Bootstrap b; b.startFromZip(data.string(), zip.string(), [] { return true; }, plenty); EXPECT_TRUE(b.requestCancel()); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed); EXPECT_TRUE(p.cancellable); EXPECT_TRUE(fs::exists(data / "blocks" / "old")); EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } // (b) A click that lands right before the window opens (here: during the free-space probe) is caught // by the re-check: nothing is deleted. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "b.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); Bootstrap b; std::atomic accepted{false}; b.startFromZip(data.string(), zip.string(), [] { return false; }, [&b, &accepted](const std::string&, std::error_code& ec) { accepted = b.requestCancel(); ec.clear(); return bootstrapTestSpace(1ull << 40, 1ull << 41); }); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); EXPECT_TRUE(accepted.load()); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed); EXPECT_TRUE(p.error.find("not touched") != std::string::npos); EXPECT_TRUE(fs::exists(data / "blocks" / "old")); EXPECT_TRUE(fs::exists(data / "chainstate" / "old")); EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); EXPECT_TRUE(fs::exists(zip)); removeTree(dir); } // (c) Inside the window: refused, and the run completes with the new chain. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "c.zip"; std::vector> entries; for (int i = 0; i < 400; ++i) entries.push_back({"blocks/blk" + std::to_string(i) + ".dat", std::string(4096, (char)('a' + i % 26))}); entries.push_back({"chainstate/000001.ldb", "CS"}); EXPECT_TRUE(writeBootstrapTestZip(zip, entries)); Bootstrap b; b.startFromZip(data.string(), zip.string(), [] { return false; }, plenty); const auto t0 = std::chrono::steady_clock::now(); while (b.getProgress().cancellable && !b.isDone() && secondsSince(t0) < 20.0) std::this_thread::yield(); EXPECT_TRUE(!b.getProgress().cancellable); EXPECT_TRUE(!b.requestCancel()); // a no-op: the old chain data is being replaced EXPECT_TRUE(waitBootstrapDone(b, 20.0)); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Completed); EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); EXPECT_TRUE(fs::exists(data / "blocks" / "blk399.dat")); EXPECT_TRUE(fs::exists(data / "chainstate" / "000001.ldb")); EXPECT_TRUE(!b.requestCancel()); // still refused after the result is published EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } } // Quit: cancelAndWait() is bounded, and safeToAbandon() is true only where the process exit can no longer // leave a part-replaced chain (done, or cancelled before the destructive window). void testBootstrapQuitWait() { using dragonx::util::Bootstrap; const Bootstrap::SpaceFn plenty = [](const std::string&, std::error_code& ec) { ec.clear(); return bootstrapTestSpace(1ull << 40, 1ull << 41); }; const std::string walletBytes = "WALLET-quit-wait"; { // nothing running Bootstrap b; EXPECT_TRUE(b.safeToAbandon()); EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now())); } // (a) The worker is stuck in a slow step before the window: the wait ends at the deadline, the worker may be // left to the process exit (it never touches the chain data), and it ends on its own later. { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "a.zip"; EXPECT_TRUE(writeBootstrapTestZip(zip, {{"blocks/new", "NEW"}, {"chainstate/new", "NEW"}})); Bootstrap b; b.startFromZip(data.string(), zip.string(), [] { std::this_thread::sleep_for(std::chrono::milliseconds(1500)); return true; }, plenty); std::this_thread::sleep_for(std::chrono::milliseconds(100)); const auto t0 = std::chrono::steady_clock::now(); EXPECT_TRUE(!b.cancelAndWait(t0 + std::chrono::milliseconds(200))); EXPECT_TRUE(secondsSince(t0) < 1.0); EXPECT_TRUE(b.safeToAbandon()); EXPECT_TRUE(waitBootstrapDone(b, 20.0)); EXPECT_TRUE(b.getProgress().state == Bootstrap::State::Failed); EXPECT_TRUE(fs::exists(data / "blocks" / "old")); EXPECT_TRUE(fs::exists(data / "chainstate" / "old")); EXPECT_TRUE(!fs::exists(data / "blocks" / "new")); EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now())); removeTree(dir); } // (b) Cancelled inside the window: not safe to abandon until the worker has finished, and by then the data // dir holds no partial chain (aborted: blocks/ and chainstate/ removed; finished first: the full chain). { const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; plantOldBootstrapChain(data, walletBytes); const fs::path zip = dir / "b.zip"; std::vector> entries; for (int i = 0; i < 400; ++i) entries.push_back({"blocks/blk" + std::to_string(i) + ".dat", std::string(4096, (char)('a' + i % 26))}); entries.push_back({"chainstate/000001.ldb", "CS"}); EXPECT_TRUE(writeBootstrapTestZip(zip, entries)); Bootstrap b; b.startFromZip(data.string(), zip.string(), [] { return false; }, plenty); const auto t0 = std::chrono::steady_clock::now(); while (b.getProgress().cancellable && !b.isDone() && secondsSince(t0) < 20.0) std::this_thread::yield(); b.cancel(); bool safe = false, doneWhenSafe = false, cleanWhenSafe = false; while (secondsSince(t0) < 20.0) { // tight poll: the first "safe" must already see a settled data dir if (b.safeToAbandon()) { safe = true; doneWhenSafe = b.isDone(); const bool aborted = bootstrapPathAbsent(data / "blocks") && bootstrapPathAbsent(data / "chainstate"); const bool complete = fs::exists(data / "blocks" / "blk399.dat") && fs::exists(data / "chainstate" / "000001.ldb"); cleanWhenSafe = aborted || complete; break; } std::this_thread::yield(); } EXPECT_TRUE(safe); EXPECT_TRUE(doneWhenSafe); EXPECT_TRUE(cleanWhenSafe); EXPECT_TRUE(b.cancelAndWait(std::chrono::steady_clock::now() + std::chrono::seconds(5))); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed || p.state == Bootstrap::State::Completed); EXPECT_TRUE(!fs::exists(data / "blocks" / "old")); EXPECT_EQ(readProvisionFile(data / "wallet.dat"), walletBytes); removeTree(dir); } } void testWriteFileAtomic() { using namespace dragonx::resources; const fs::path dir = makeTempDir(); std::vector buf(9u * 1024u * 1024u); // > two 4 MB chunks for (std::size_t i = 0; i < buf.size(); ++i) buf[i] = static_cast((i * 31u + (i >> 13)) & 0xFFu); const std::string expect(reinterpret_cast(buf.data()), buf.size()); const fs::path dest = dir / "sub" / "out.bin"; // parent created on demand const std::string partial = dest.string() + kPartialFileSuffix; // (a) a new file: byte-exact, no temp left, progress counts every byte { ProvisionProgress p; EXPECT_TRUE(writeFileAtomic(dest.string(), buf.data(), buf.size(), false, &p)); EXPECT_TRUE(readProvisionFile(dest) == expect); EXPECT_TRUE(!fs::exists(partial)); EXPECT_EQ(p.bytesDone.load(), static_cast(buf.size())); } // (b) an existing file is replaced { { std::ofstream(dest.string(), std::ios::binary | std::ios::trunc) << "old contents"; } EXPECT_TRUE(writeFileAtomic(dest.string(), buf.data(), buf.size(), false)); EXPECT_TRUE(readProvisionFile(dest) == expect); EXPECT_TRUE(!fs::exists(partial)); } // (c) cancelled on the 2nd poll: false, the destination is absent / unchanged, no temp left { int calls = 0; const CancelFn cancel = [&calls]() { return ++calls >= 2; }; const fs::path fresh = dir / "fresh.bin"; EXPECT_TRUE(!writeFileAtomic(fresh.string(), buf.data(), buf.size(), false, nullptr, cancel)); EXPECT_TRUE(!fs::exists(fresh)); EXPECT_TRUE(!fs::exists(fresh.string() + kPartialFileSuffix)); calls = 0; { std::ofstream(dest.string(), std::ios::binary | std::ios::trunc) << "keep me"; } EXPECT_TRUE(!writeFileAtomic(dest.string(), buf.data(), buf.size(), false, nullptr, cancel)); EXPECT_TRUE(readProvisionFile(dest) == "keep me"); EXPECT_TRUE(!fs::exists(partial)); } // (d) nothing to write { EXPECT_TRUE(!writeFileAtomic((dir / "null.bin").string(), nullptr, 10, false)); EXPECT_TRUE(!writeFileAtomic((dir / "zero.bin").string(), buf.data(), 0, false)); EXPECT_TRUE(!fs::exists(dir / "null.bin")); EXPECT_TRUE(!fs::exists(dir / "zero.bin")); } #ifndef _WIN32 // (e) executable: +x is set before the file appears under its final name { const fs::path exe = dir / "tool"; EXPECT_TRUE(writeFileAtomic(exe.string(), buf.data(), 1024, true)); EXPECT_TRUE((fs::status(exe).permissions() & fs::perms::owner_exec) != fs::perms::none); } #endif removeTree(dir); } void testCopyFileAtomic() { using namespace dragonx::resources; const fs::path dir = makeTempDir(); std::string body(5u * 1024u * 1024u, '\0'); for (std::size_t i = 0; i < body.size(); ++i) body[i] = static_cast((i * 7u + 3u) & 0xFFu); const fs::path src = dir / "src.bin"; { std::ofstream out(src.string(), std::ios::binary); out.write(body.data(), static_cast(body.size())); } #ifndef _WIN32 fs::permissions(src, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec | fs::perms::others_read | fs::perms::others_exec, fs::perm_options::replace); // 0755 #endif const fs::path dst = dir / "d" / "dst.bin"; { ProvisionProgress p; EXPECT_TRUE(copyFileAtomic(src.string(), dst.string(), &p)); EXPECT_TRUE(readProvisionFile(dst) == body); EXPECT_EQ(p.bytesDone.load(), static_cast(body.size())); EXPECT_TRUE(!fs::exists(dst.string() + kPartialFileSuffix)); #ifndef _WIN32 EXPECT_TRUE((fs::status(dst).permissions() & fs::perms::owner_exec) != fs::perms::none); #endif } // Cancelled on the first poll: false, no destination, no temp. { const fs::path d2 = dir / "d" / "cancelled.bin"; const CancelFn cancelNow = []() { return true; }; EXPECT_TRUE(!copyFileAtomic(src.string(), d2.string(), nullptr, cancelNow)); EXPECT_TRUE(!fs::exists(d2)); EXPECT_TRUE(!fs::exists(d2.string() + kPartialFileSuffix)); } // A missing source: false, nothing written. { const fs::path d3 = dir / "d" / "missing.bin"; EXPECT_TRUE(!copyFileAtomic((dir / "nope.bin").string(), d3.string())); EXPECT_TRUE(!fs::exists(d3)); EXPECT_TRUE(!fs::exists(d3.string() + kPartialFileSuffix)); } removeTree(dir); } void testBundleCopy() { using namespace dragonx::resources; const fs::path root = makeTempDir(); const fs::path A = root / "A", B = root / "B", D = root / "D"; fs::create_directories(A); fs::create_directories(B); auto put = [](const fs::path& p, const std::string& c) { std::ofstream(p.string(), std::ios::binary | std::ios::trunc) << c; }; put(A / "one", "A-one"); put(B / "one", "B-one"); put(B / "two", "B-two"); const std::vector dirs = { A.string(), B.string() }; const std::vector names = { "one", "two", "three" }; // "three" has no source: optional const std::string partialOne = (D / "one").string() + kPartialFileSuffix; // The plan: the first search dir holding a name wins; names with no source are skipped. const auto plan = planBundleCopy(dirs, D.string(), names, false); EXPECT_EQ(plan.size(), static_cast(2)); bool sawOne = false; for (const auto& it : plan) { if (it.name != "one") continue; sawOne = true; EXPECT_TRUE(fs::path(it.src) == A / "one"); EXPECT_TRUE(fs::path(it.dst) == D / "one"); } EXPECT_TRUE(sawOne); // A stale temp from an interrupted copy is removed by the run that copies that name. fs::create_directories(D); put(fs::path(partialOne), "stale"); auto r = copyBundledFiles(dirs, D.string(), names, false); EXPECT_TRUE(r.ok); EXPECT_EQ(r.copied, 2); EXPECT_TRUE(r.firstFailure.empty()); EXPECT_TRUE(readProvisionFile(D / "one") == "A-one"); EXPECT_TRUE(readProvisionFile(D / "two") == "B-two"); EXPECT_TRUE(!fs::exists(D / "three")); EXPECT_TRUE(!fs::exists(partialOne)); // Everything present: the stat-only early return copies nothing. r = copyBundledFiles(dirs, D.string(), names, false); EXPECT_TRUE(r.ok); EXPECT_EQ(r.copied, 0); EXPECT_TRUE(planBundleCopy(dirs, D.string(), names, false).empty()); // An existing destination is kept without replaceExisting and overwritten with it. put(D / "one", "local"); r = copyBundledFiles(dirs, D.string(), { "one" }, false); EXPECT_EQ(r.copied, 0); EXPECT_TRUE(readProvisionFile(D / "one") == "local"); r = copyBundledFiles(dirs, D.string(), { "one" }, true); EXPECT_TRUE(r.ok); EXPECT_EQ(r.copied, 1); EXPECT_TRUE(readProvisionFile(D / "one") == "A-one"); removeTree(root); } void testSaplingParamState() { using dragonx::rpc::Connection; using State = Connection::SaplingParamState; const fs::path dir = makeTempDir() / "sapling_state"; fs::create_directories(dir); auto writeFile = [](const fs::path& p, const std::string& c) { std::ofstream(p.string(), std::ios::binary | std::ios::trunc) << c; }; const std::string spend = "fake-spend-params-contents"; const std::string output = "fake-output-params-contents"; const std::vector> digests = { { "sapling-spend.params", dragonx::util::sha256Hex(spend.data(), spend.size()) }, { "sapling-output.params", dragonx::util::sha256Hex(output.data(), output.size()) }, }; writeFile(dir / "sapling-spend.params", spend); EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::Missing); // one absent writeFile(dir / "sapling-output.params", output); EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::NeedsHash); // no marker yet EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), digests)); EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::Verified); writeFile(dir / "sapling-spend.params", spend + "-longer"); // size changed EXPECT_TRUE(Connection::saplingParamStateIn(dir.string(), digests) == State::NeedsHash); EXPECT_TRUE(Connection::saplingParamStateIn("", digests) == State::Missing); removeTree(dir.parent_path()); } // Wallets dialog: the pure folder walk that moved out of WalletsDialog::scan() (now run on a worker). void testWalletFolderScan() { using namespace dragonx::util; namespace fsn = std::filesystem; const fsn::path root = makeTempDir() / "wallet_folder_scan"; const fsn::path dd = root / "datadir", ext = root / "ext", ext2 = root / "ext2"; auto touch = [](const fsn::path& p, const std::string& body = "x") { std::error_code ec; fsn::create_directories(p.parent_path(), ec); std::ofstream(p.string(), std::ios::binary) << body; }; auto names = [](const std::vector& v) { std::set s; for (const auto& w : v) s.insert(w.fileName); return s; }; // Datadir: top level only, wallet-prefixed, in-place links and node files hidden. for (const char* n : { "wallet.dat", "wallet-x.dat", "wallet-ip-1a.dat", "peers.dat", "other.dat" }) touch(dd / n); touch(dd / "blocks" / "blk00000.dat"); { std::unordered_set seen; const auto rows = scanDatadirWallets(dd.string(), seen); EXPECT_EQ(rows.size(), static_cast(2)); EXPECT_TRUE((names(rows) == std::set{ "wallet.dat", "wallet-x.dat" })); for (const auto& w : rows) EXPECT_TRUE(w.inDatadir); EXPECT_EQ(seen.size(), static_cast(2)); std::unordered_set seen2; EXPECT_TRUE(scanDatadirWallets((root / "missing").string(), seen2).empty()); } // External: any .dat except node junk, recursive within the depth cap, never into node subtrees. touch(ext / "a.dat"); touch(ext / "peers.dat"); touch(ext / "blk1.dat"); touch(ext / "blocks" / "b.dat"); touch(ext / "sub" / "w.dat"); touch(ext / "d1" / "d2" / "d3" / "deep.dat"); touch(ext / "notes.txt"); { WalletScanLimits lim; lim.maxDepth = 2; std::unordered_set seen; const auto rows = scanExternalWalletFolders({ ext.string() }, lim, seen, 0); EXPECT_TRUE((names(rows) == std::set{ "a.dat", "w.dat" })); for (const auto& w : rows) { EXPECT_TRUE(!w.inDatadir); if (w.fileName == "w.dat") EXPECT_TRUE(fsn::path(w.dir) == ext / "sub"); // the real parent if (w.fileName == "a.dat") EXPECT_TRUE(fsn::path(w.dir) == ext); } } { std::unordered_set seen; const auto rows = scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0); EXPECT_TRUE((names(rows) == std::set{ "a.dat", "w.dat", "deep.dat" })); } // The hit cap holds across folders. touch(ext2 / "e.dat"); { WalletScanLimits lim; lim.maxHits = 1; std::unordered_set seen; EXPECT_EQ(scanExternalWalletFolders({ ext.string(), ext2.string() }, lim, seen, 0).size(), static_cast(1)); } // The visit cap stops the walk early. { WalletScanLimits lim; lim.maxVisited = 1; std::unordered_set seen; EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, lim, seen, 0).size() <= 1); } // Rows already listed (the datadir pass) count toward the hit cap. { std::unordered_set seen; EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 200).size() <= 1); } // A cancelled walk returns nothing. { std::atomic cancel{true}; std::unordered_set seen; EXPECT_TRUE(scanExternalWalletFolders({ ext.string() }, WalletScanLimits{}, seen, 0, &cancel).empty()); } #ifndef _WIN32 // A symlink to a datadir wallet inside an added folder is the same physical wallet: listed once. { const fsn::path lnDir = root / "ext_link"; std::error_code ec; fsn::create_directories(lnDir, ec); fsn::create_symlink(dd / "wallet.dat", lnDir / "linked.dat", ec); EXPECT_TRUE(!ec); touch(lnDir / "own.dat"); std::unordered_set seen; const auto dataRows = scanDatadirWallets(dd.string(), seen); const auto rows = scanExternalWalletFolders({ lnDir.string() }, WalletScanLimits{}, seen, dataRows.size()); EXPECT_TRUE((names(rows) == std::set{ "own.dat" })); } #endif removeTree(root.parent_path()); } void testExternalScanCache() { using dragonx::util::externalScanKey; using dragonx::util::externalScanCacheUsable; EXPECT_TRUE(externalScanKey({ "a", "b" }) != externalScanKey({ "ab" })); EXPECT_TRUE(externalScanKey({}).empty()); const std::string k = externalScanKey({ "/home/u/wallets" }); EXPECT_TRUE(externalScanCacheUsable(true, k, k, 10.0, 60.0, false)); EXPECT_TRUE(!externalScanCacheUsable(true, k, k, 10.0, 60.0, /*force=*/true)); EXPECT_TRUE(!externalScanCacheUsable(true, k, externalScanKey({ "/other" }), 10.0, 60.0, false)); EXPECT_TRUE(!externalScanCacheUsable(true, k, k, 61.0, 60.0, false)); EXPECT_TRUE(!externalScanCacheUsable(false, k, k, 10.0, 60.0, false)); EXPECT_TRUE(!externalScanCacheUsable(true, k, k, -1.0, 60.0, false)); } void testPlatformEnsureDirectory() { using dragonx::util::Platform; // An existing directory → true (temp_directory_path always exists). { std::string err = "sentinel"; EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err)); } // A fresh nested path → created, no error. { std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test"; std::error_code rmec; std::filesystem::remove_all(base, rmec); std::filesystem::path nested = base / "a" / "b" / "c"; std::string err; EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err)); EXPECT_TRUE(std::filesystem::is_directory(nested)); EXPECT_TRUE(err.empty()); std::filesystem::remove_all(base, rmec); } // Empty path → false with a message. { std::string err; EXPECT_TRUE(!Platform::ensureDirectory("", &err)); EXPECT_TRUE(!err.empty()); } // A path whose parent component is a regular file cannot be created. This fails the // same way for root and non-root, so it's a stable negative case across environments. { std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file"; std::error_code rmec; std::filesystem::remove_all(f, rmec); { std::ofstream(f.string()) << "x"; } std::string err; bool ok = Platform::ensureDirectory((f / "child").string(), &err); std::filesystem::remove_all(f, rmec); EXPECT_TRUE(!ok); EXPECT_TRUE(err.find("Cannot create") != std::string::npos); } } #ifndef _WIN32 // Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only). // Every start() in these tests is isolated (X13). The skipped port check also skips the datadir-lock gate; // the -datadir override (a temp dir) means no ownership record (the user's real // ~/.hush/DRAGONX/obsidiandragon-dragonxd.pid) is written and

/debug.log is tailed instead of the real // one. The override is one-shot: set it again before every start(). void isolateNextDaemonStart(dragonx::daemon::EmbeddedDaemon& d, const fs::path& dir) { d.setSkipPortCheck(true); d.setNextStartOverride(dir.string(), {}); } bool waitUntilTrue(const std::function& cond, double seconds) { const auto t0 = std::chrono::steady_clock::now(); while (!cond()) { if (secondsSince(t0) >= seconds) return false; std::this_thread::sleep_for(std::chrono::milliseconds(10)); } return true; } void testExecFailureReported() { using dragonx::daemon::EmbeddedDaemon; namespace fsn = std::filesystem; // A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake // must report it as a start FAILURE with a precise reason — not a transient "Running". fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin"; { std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; } fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); // bypass the port + datadir-lock gates so we reach startProcess() EXPECT_TRUE(!d.start(bin.string())); EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos); EXPECT_TRUE(!d.isRunning()); std::error_code ec; fsn::remove(bin, ec); removeTree(tmp); } void testDaemonCrashDetected() { using dragonx::daemon::EmbeddedDaemon; namespace fsn = std::filesystem; // A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally // — standing in for a daemon that crashes. is_script detection runs it via /bin/bash. fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh"; { std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; } fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid() // Hammer isRunning() the way the UI thread does while the child exits and monitorProcess() // reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the // crash; with the fix the monitor is the sole reaper and always sees it. for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) { (void)d.isRunning(); std::this_thread::sleep_for(std::chrono::milliseconds(5)); } EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error const int crashes = d.getCrashCount(); d.stop(); // join the monitor thread cleanly EXPECT_EQ(d.getCrashCount(), crashes); // a later stop never erases a real crash std::error_code ec; fsn::remove(script, ec); removeTree(tmp); } // Writes an executable bash stand-in for dragonxd (EmbeddedDaemon runs it via /bin/bash); returns its path. std::filesystem::path writeFakeDaemonScript(const std::string& name, const std::string& body) { namespace fsn = std::filesystem; fsn::path script = fsn::temp_directory_path() / name; { std::ofstream(script.string()) << "#!/bin/bash\n" << body; } fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x return script; } // The same, in `dir` (a per-test temp dir). std::filesystem::path writeFakeDaemonScript(const fs::path& dir, const std::string& name, const std::string& body) { const fs::path script = dir / name; { std::ofstream(script.string()) << "#!/bin/bash\n" << body; } fs::permissions(script, fs::perms::owner_all, fs::perm_options::replace); // +x return script; } size_t countSubstring(const std::string& hay, const std::string& needle) { size_t n = 0; for (size_t pos = hay.find(needle); pos != std::string::npos; pos = hay.find(needle, pos + needle.size())) ++n; return n; } // C101: start() after an unexpected exit, with no stop() in between, must supervise the new run. The // crashed run's monitor returned on its own but stayed joinable, and start() only spawned a monitor when // none was joinable: the restarted node ran unwatched (its crash never seen, its output never drained). void testDaemonRestartAfterCrashIsSupervised() { using dragonx::daemon::EmbeddedDaemon; const fs::path tmp = makeTempDir(); const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_restart.sh", "echo RUN\nsleep 0.2\nexit 7\n"); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 1; }, 3.0)); isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); // no stop() in between EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 2 && d.getState() == EmbeddedDaemon::State::Error; }, 3.0)); EXPECT_EQ(countSubstring(d.getOutput(), "RUN"), static_cast(2)); // both runs' output drained const auto t0 = std::chrono::steady_clock::now(); d.stop(500); EXPECT_TRUE(secondsSince(t0) < 2.0); EXPECT_EQ(d.getCrashCount(), 2); removeTree(tmp); } // C101: a run restarted after a crash is watched by a new monitor, and a later stop joins it: the stop is // prompt, recorded as Stopped and not as a crash, and nothing is left running. void testRestartAfterCrashThenStop() { using dragonx::daemon::EmbeddedDaemon; const fs::path tmp = makeTempDir(); const std::string runs = (tmp / "runs").string(); // Run 1 exits 3 at once; every later run prints RUN and sleeps 30 s. const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_restart_stop.sh", "n=$(cat '" + runs + "' 2>/dev/null || echo 0)\nn=$((n+1))\necho $n > '" + runs + "'\n" "if [ \"$n\" -eq 1 ]; then exit 3; fi\necho RUN$n\nsleep 30\n"); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 1; }, 3.0)); isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); // run 2 (sleeps 30 s) EXPECT_TRUE(waitUntilTrue([&d] { return d.getOutput().find("RUN2") != std::string::npos; }, 3.0)); // drained EXPECT_TRUE(d.isRunning()); d.markStopRequested(); const auto t0 = std::chrono::steady_clock::now(); d.stop(500); // 0.5 s grace, then the group SIGTERM ends bash and its sleep EXPECT_TRUE(secondsSince(t0) < 2.0); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); EXPECT_FALSE(d.isRunning()); EXPECT_EQ(d.getCrashCount(), 1); removeTree(tmp); } #ifdef __linux__ size_t countOpenFds() { size_t n = 0; std::error_code ec; for (fs::directory_iterator it("/proc/self/fd", ec), end; !ec && it != end; it.increment(ec)) ++n; return n; } // C101 (Linux): crash + restart cycles leak no descriptor. Each run's stdout pipe used to be overwritten by // the next start() without being closed (and inherited by the next child). void testDaemonRestartCyclesLeakNoFds() { using dragonx::daemon::EmbeddedDaemon; const fs::path tmp = makeTempDir(); const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_fdleak.sh", "sleep 0.1\nexit 9\n"); const size_t baseline = countOpenFds(); { EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(waitUntilTrue([&d] { return d.getCrashCount() == 1; }, 3.0)); for (int cycle = 2; cycle <= 4; ++cycle) { // 3 crash + restart cycles isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(waitUntilTrue([&d, cycle] { return d.getCrashCount() == cycle; }, 3.0)); } d.stop(500); EXPECT_EQ(countOpenFds(), baseline); } removeTree(tmp); } #endif // C101 D4: start() is refused while a stop session is in progress. After beginStop() and a reap (here // forceKillOwnedProcess(): pid 0, state still Stopping) start() used to spawn a process that the session's // endStop() then abandoned. void testStartRefusedWhileStopping() { using dragonx::daemon::EmbeddedDaemon; const fs::path tmp = makeTempDir(); const fs::path launches = tmp / "launches"; const auto script = writeFakeDaemonScript(tmp, "od_fake_daemon_stopping.sh", "echo $$ >> '" + launches.string() + "'\nsleep 30\n"); auto launchPids = [&launches] { std::vector v; std::ifstream in(launches); long p = 0; while (in >> p) v.push_back(p); return v; }; EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(waitUntilTrue([&launchPids] { return launchPids().size() == 1; }, 3.0)); EXPECT_TRUE(d.beginStop()); d.forceKillOwnedProcess(); EXPECT_FALSE(d.isRunning()); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopping); isolateNextDaemonStart(d, tmp); EXPECT_FALSE(d.start(script.string())); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopping); // unchanged std::this_thread::sleep_for(std::chrono::milliseconds(300)); const auto pids = launchPids(); EXPECT_EQ(pids.size(), static_cast(1)); // nothing spawned d.clearNextStartOverride(); // the refused start didn't consume it d.endStop(/*keepWatching=*/false); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); EXPECT_EQ(d.getCrashCount(), 0); // Before the fix a second run was spawned and endStop() left it running: end it (its own process group). for (size_t i = 1; i < pids.size(); ++i) if (pids[i] > 0) kill(-static_cast(pids[i]), SIGKILL); removeTree(tmp); } // S4: stop() must see a clean exit and return at once. Before the fix the monitor thread exited // without reaping and the state-based isRunning() stayed true, so every POSIX stop waited out the // whole grace period, then sent SIGTERM, then SIGKILL (here ~5 + 10 = 15s). void testPosixStopReturnsAfterCleanExit() { using dragonx::daemon::EmbeddedDaemon; const auto script = writeFakeDaemonScript("od_fake_daemon_clean.sh", "sleep 1\nexit 0\n"); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); const auto t0 = std::chrono::steady_clock::now(); d.stop(5000); EXPECT_TRUE(secondsSince(t0) < 2.5); EXPECT_EQ(d.getCrashCount(), 0); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); EXPECT_TRUE(!d.isRunning()); std::error_code ec; std::filesystem::remove(script, ec); removeTree(tmp); } // S4: an exit after markStopRequested() is the stop we asked for, even when the monitor reaps it // before stop() runs (the RPC stop landed first). It must not count as a crash or flip to Error. void testPosixRequestedStopIsNotACrash() { using dragonx::daemon::EmbeddedDaemon; const auto script = writeFakeDaemonScript("od_fake_daemon_requested.sh", "sleep 0.3\nexit 0\n"); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); d.markStopRequested(); for (int i = 0; i < 300 && d.getState() == EmbeddedDaemon::State::Running; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); // the monitor reaps it EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); EXPECT_EQ(d.getCrashCount(), 0); const auto t0 = std::chrono::steady_clock::now(); d.stop(5000); EXPECT_TRUE(secondsSince(t0) < 1.0); EXPECT_EQ(d.getCrashCount(), 0); std::error_code ec; std::filesystem::remove(script, ec); removeTree(tmp); } // C103: EmbeddedDaemon::memoryProbe (POSIX: place after testPosixRequestedStopIsNotACrash, it uses // writeFakeDaemonScript). void testEmbeddedDaemonMemoryProbe() { using dragonx::daemon::EmbeddedDaemon; { EmbeddedDaemon idle; EXPECT_FALSE(static_cast(idle.memoryProbe())); // no process: empty probe EXPECT_NEAR(idle.getMemoryUsageMB(), 0.0, 1e-9); } const auto script = writeFakeDaemonScript("od_fake_daemon_mem.sh", "sleep 2\nexit 0\n"); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); auto probe = d.memoryProbe(); EXPECT_TRUE(static_cast(probe)); double mb = 0.0; std::thread t([&probe, &mb] { mb = probe(); }); // runs off the creating thread, as in the app t.join(); EXPECT_TRUE(mb > 0.0); // the bash stand-in's process group d.stop(5000); std::error_code ec; std::filesystem::remove(script, ec); removeTree(tmp); } // S4: SIGTERM is graceful on POSIX (dragonxd treats it like RPC stop), and stop() reaps the clean // exit it leads to at once instead of waiting out the 10s SIGKILL timer (~11s before the fix). void testPosixStopSigtermIsGraceful() { using dragonx::daemon::EmbeddedDaemon; namespace fsn = std::filesystem; const fsn::path events = fsn::temp_directory_path() / "od_fake_daemon_term.events"; std::error_code ec; fsn::remove(events, ec); // Flushes for 1s on SIGTERM, then exits 0; otherwise never exits on its own. const auto script = writeFakeDaemonScript("od_fake_daemon_term.sh", "trap 'echo term >> \"$OD_TEST_EVENTS\"; sleep 1; echo clean >> \"$OD_TEST_EVENTS\"; exit 0' TERM\n" "while :; do sleep 0.1; done\n"); setenv("OD_TEST_EVENTS", events.string().c_str(), 1); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); const auto t0 = std::chrono::steady_clock::now(); d.stop(1000); // nothing asked it to stop, so SIGTERM goes out at +1s EXPECT_TRUE(secondsSince(t0) < 4.0); std::ifstream in(events); const std::string seen((std::istreambuf_iterator(in)), std::istreambuf_iterator()); EXPECT_EQ(seen, std::string("term\nclean\n")); // the trap ran to completion: no SIGKILL EXPECT_EQ(d.getCrashCount(), 0); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); unsetenv("OD_TEST_EVENTS"); fsn::remove(script, ec); fsn::remove(events, ec); removeTree(tmp); } // S3: destroying an EmbeddedDaemon must not stop (let alone kill) the process. The destructor used to // call stop(3000), which on Windows fell back to killing any dragonxd.exe by name; owners now stop() // explicitly and the destructor only stops watching. Before the fix this took ~3s and SIGTERMed it. void testPosixDestructorLeavesProcessRunning() { using dragonx::daemon::EmbeddedDaemon; namespace fsn = std::filesystem; const fsn::path pidFile = fsn::temp_directory_path() / "od_fake_daemon_dtor.pid"; std::error_code ec; fsn::remove(pidFile, ec); const auto script = writeFakeDaemonScript("od_fake_daemon_dtor.sh", "echo $$ > \"$OD_TEST_PIDFILE\"\nsleep 30\n"); setenv("OD_TEST_PIDFILE", pidFile.string().c_str(), 1); pid_t pid = 0; const fs::path tmp = makeTempDir(); auto d = std::make_unique(); isolateNextDaemonStart(*d, tmp); EXPECT_TRUE(d->start(script.string())); for (int i = 0; i < 300 && pid <= 0; ++i) { std::ifstream in(pidFile); long v = 0; if (in >> v) pid = static_cast(v); else std::this_thread::sleep_for(std::chrono::milliseconds(10)); } EXPECT_TRUE(pid > 0); const auto t0 = std::chrono::steady_clock::now(); d.reset(); EXPECT_TRUE(secondsSince(t0) < 1.0); EXPECT_TRUE(pid > 0 && kill(pid, 0) == 0); // still running if (pid > 0) { kill(-pid, SIGKILL); // the test's own child (its own process group) waitpid(pid, nullptr, 0); } unsetenv("OD_TEST_PIDFILE"); fsn::remove(script, ec); fsn::remove(pidFile, ec); removeTree(tmp); } // executeDaemonStop over a REAL EmbeddedDaemon child: steady clock, real waitpid/signals/CPU sampling. // No RPC here (stop attempts report NotListening), so on POSIX the group SIGTERM is the stop channel. class RealStopRuntime final : public dragonx::daemon::DaemonController::StopRuntime { public: using DC = dragonx::daemon::DaemonController; explicit RealStopRuntime(dragonx::daemon::EmbeddedDaemon& d) : d_(d), t0_(std::chrono::steady_clock::now()) {} double detachAt = -1.0; // choose "Leave running" at this time double maxCpuPercent = -1.0; double nowSec() override { return secondsSince(t0_); } bool processAlive() override { return d_.pollOwnedProcess(); } dragonx::daemon::StopSendResult trySendStop() override { return dragonx::daemon::StopSendResult::NotListening; } bool nextCredentials() override { return false; } bool sendGracefulSignal() override { return d_.signalGracefulStop(); } double cpuSeconds() override { return d_.ownedResourceUsage().cpuSeconds; } double ioBytes() override { return -1.0; } bool rebuildActive() override { return false; } DC::StopUserChoice userChoice() override { return (detachAt >= 0 && nowSec() >= detachAt) ? DC::StopUserChoice::Detach : DC::StopUserChoice::None; } void offerForceKill(bool) override {} void forceKill() override { d_.forceKillOwnedProcess(); } void setStatus(const DC::StopStatus& s) override { maxCpuPercent = std::max(maxCpuPercent, s.cpuPercent); } private: dragonx::daemon::EmbeddedDaemon& d_; std::chrono::steady_clock::time_point t0_; }; class RealSleepTask final : public dragonx::daemon::DaemonController::LifecycleTaskContext { public: bool cancelled() const override { return false; } bool shuttingDown() const override { return false; } void sleepForMs(int ms) override { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } }; pid_t readPidFile(const std::filesystem::path& pidFile) { for (int i = 0; i < 300; ++i) { std::ifstream in(pidFile); long v = 0; if (in >> v && v > 0) return static_cast(v); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } return 0; } // S1: "Leave running" on a node that ignores SIGTERM and keeps a core busy: the stop returns at the next // tick, nothing kills it, it is still alive afterwards, and its CPU was being measured. void testPosixStopSessionDetach() { using dragonx::daemon::EmbeddedDaemon; using DC = dragonx::daemon::DaemonController; namespace fsn = std::filesystem; const fsn::path pidFile = fsn::temp_directory_path() / "od_fake_daemon_detach.pid"; std::error_code ec; fsn::remove(pidFile, ec); const auto script = writeFakeDaemonScript("od_fake_daemon_detach.sh", "trap '' TERM\necho $$ > \"$OD_TEST_PIDFILE\"\nwhile :; do :; done\n"); // pid file = trap is set setenv("OD_TEST_PIDFILE", pidFile.string().c_str(), 1); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); const pid_t pid = readPidFile(pidFile); EXPECT_TRUE(pid > 0); EXPECT_TRUE(d.beginStop()); RealStopRuntime rt(d); rt.detachAt = 2.0; RealSleepTask task; DC::StopPolicy p; p.gracefulSignal = true; p.userChoices = true; const auto outcome = DC::executeDaemonStop(p, rt, task); EXPECT_TRUE(outcome == DC::StopOutcome::Detached); EXPECT_TRUE(rt.nowSec() < 2.6); EXPECT_TRUE(rt.maxCpuPercent > 20.0); // the busy loop was measured d.endStop(/*keepWatching=*/false); EXPECT_TRUE(pid > 0 && kill(pid, 0) == 0); // left running EXPECT_FALSE(d.isRunning()); if (pid > 0) { kill(-pid, SIGKILL); // endStop() left a detached reaper waiting on it: the zombie disappears once it is reaped. bool reaped = false; for (int i = 0; i < 200 && !reaped; ++i) { reaped = (kill(pid, 0) != 0); if (!reaped) std::this_thread::sleep_for(std::chrono::milliseconds(10)); } EXPECT_TRUE(reaped); } unsetenv("OD_TEST_PIDFILE"); fsn::remove(script, ec); fsn::remove(pidFile, ec); removeTree(tmp); } // S1/S2 on POSIX: with no RPC answer, the group SIGTERM stops a node that handles it, promptly and // without counting a crash. void testPosixStopSessionSigterm() { using dragonx::daemon::EmbeddedDaemon; using DC = dragonx::daemon::DaemonController; const auto script = writeFakeDaemonScript("od_fake_daemon_session_term.sh", "trap 'exit 0' TERM\nwhile :; do sleep 0.1; done\n"); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(d.beginStop()); RealStopRuntime rt(d); RealSleepTask task; DC::StopPolicy p; p.gracefulSignal = true; p.userChoices = true; EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == DC::StopOutcome::Exited); EXPECT_TRUE(rt.nowSec() < 2.0); d.endStop(/*keepWatching=*/false); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); EXPECT_EQ(d.getCrashCount(), 0); std::error_code ec; std::filesystem::remove(script, ec); removeTree(tmp); } // S1: a bounded wait that gives up keeps watching the node: its later exit is still seen, and booked // as the clean stop we asked for (not a crash). void testPosixStopSessionGiveUpKeepsWatching() { using dragonx::daemon::EmbeddedDaemon; using DC = dragonx::daemon::DaemonController; namespace fsn = std::filesystem; const fsn::path readyFile = fsn::temp_directory_path() / "od_fake_daemon_session_slow.ready"; std::error_code rmEc; fsn::remove(readyFile, rmEc); // Signal readiness only once the trap is installed: a SIGTERM before that line would kill it. const auto script = writeFakeDaemonScript("od_fake_daemon_session_slow.sh", "trap '' TERM\necho $$ > \"$OD_TEST_PIDFILE\"\nsleep 1.5\nexit 0\n"); setenv("OD_TEST_PIDFILE", readyFile.string().c_str(), 1); const fs::path tmp = makeTempDir(); EmbeddedDaemon d; isolateNextDaemonStart(d, tmp); EXPECT_TRUE(d.start(script.string())); EXPECT_TRUE(readPidFile(readyFile) > 0); EXPECT_TRUE(d.beginStop()); RealStopRuntime rt(d); RealSleepTask task; DC::StopPolicy p; p.gracefulSignal = true; p.maxWaitSec = 0.5; EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == DC::StopOutcome::StillRunning); d.endStop(/*keepWatching=*/true); EXPECT_TRUE(d.isRunning()); for (int i = 0; i < 400 && d.getState() != EmbeddedDaemon::State::Stopped; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); EXPECT_TRUE(d.getState() == EmbeddedDaemon::State::Stopped); EXPECT_EQ(d.getCrashCount(), 0); d.stop(1000); // join the resumed monitor unsetenv("OD_TEST_PIDFILE"); std::error_code ec; fsn::remove(script, ec); fsn::remove(readyFile, ec); removeTree(tmp); } #endif // !_WIN32 #ifdef _WIN32 // S3: EmbeddedDaemon must never kill a dragonxd.exe it didn't launch. Before the fix, stop() (and the // destructor's stop(3000)) with no live process of its own looked up "dragonxd.exe" by name and // TerminateProcess'd the first match: an old node still shutting down, or the user's real node. A fake // dragonxd.exe (tools/fake_dragonxd) on an isolated datadir and port plays the foreign node. void testWindowsStopNeverKillsForeignDaemon() { const char* stub = std::getenv("DRAGONX_TEST_FAKE_DAEMON"); if (!stub || !*stub) { std::printf("[skip] testWindowsStopNeverKillsForeignDaemon (set DRAGONX_TEST_FAKE_DAEMON; " "scripts/test-windows.sh does)\n"); return; } const fs::path root = makeTempDir(); const fs::path dir = root / "DRAGONX"; fs::create_directories(dir); writeTestFile(dir / "DRAGONX.conf", "rpcuser=u\nrpcpassword=p\nrpcport=39769\n"); _putenv_s("FAKE_REBUILD_SECS", "0"); _putenv_s("FAKE_WARMUP_SECS", "0"); std::string cmd = "\"" + std::string(stub) + "\" -datadir=\"" + dir.string() + "\""; STARTUPINFOA si{}; si.cb = sizeof(si); PROCESS_INFORMATION pi{}; const BOOL started = CreateProcessA(nullptr, &cmd[0], nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); _putenv_s("FAKE_REBUILD_SECS", ""); _putenv_s("FAKE_WARMUP_SECS", ""); EXPECT_TRUE(started); if (!started) return; std::this_thread::sleep_for(std::chrono::milliseconds(500)); EXPECT_TRUE(WaitForSingleObject(pi.hProcess, 0) == WAIT_TIMEOUT); // the foreign node is up { dragonx::daemon::EmbeddedDaemon d; const auto t0 = std::chrono::steady_clock::now(); EXPECT_TRUE(d.stop(2000)); // nothing of its own to stop: returns at once and kills nothing EXPECT_TRUE(secondsSince(t0) < 1.0); } // ...nor does the destructor EXPECT_TRUE(WaitForSingleObject(pi.hProcess, 500) == WAIT_TIMEOUT); // the foreign node survived TerminateProcess(pi.hProcess, 0); // the test's own child, by its own handle WaitForSingleObject(pi.hProcess, 5000); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); removeTree(root); } #endif // _WIN32 // --------------------------------------------------------------------------------------------------- // S2 + S1: delivering "stop" and the never-kill wait (DaemonController::executeDaemonStop), driven by a // scripted runtime on a virtual clock. // --------------------------------------------------------------------------------------------------- void testStopDeliveryPolicy() { using dragonx::daemon::StopDeliveryPolicy; using dragonx::daemon::StopSendResult; using R = StopSendResult; { // Refused before queueing: not delivered, cheap to retry after 1.5s. StopDeliveryPolicy p(true); EXPECT_TRUE(p.shouldSend(0.0)); EXPECT_TRUE(p.record(R::QueueFull, 0.0) == R::QueueFull); EXPECT_FALSE(p.shouldSend(1.0)); EXPECT_TRUE(p.shouldSend(1.5)); EXPECT_FALSE(p.delivered()); } { // Queued: the daemon has it; no resend for 30s (each resend takes a queue slot). StopDeliveryPolicy p(true); p.record(R::Queued, 0.0); EXPECT_TRUE(p.heard()); EXPECT_FALSE(p.delivered()); EXPECT_FALSE(p.shouldSend(29.9)); EXPECT_TRUE(p.shouldSend(30.0)); // ...and a refusal after that means it unbound its listener to shut down. EXPECT_TRUE(p.record(R::NotListening, 30.0) == R::ShuttingDown); EXPECT_TRUE(p.delivered()); } { // NotListening before we were ever heard is just "not up / not bound": retry. StopDeliveryPolicy p(true); EXPECT_TRUE(p.record(R::NotListening, 0.0) == R::NotListening); EXPECT_FALSE(p.delivered()); EXPECT_TRUE(p.shouldSend(1.5)); } { // Accepted / ShuttingDown: delivered, never resend. StopDeliveryPolicy a(true), s(true); a.record(R::Accepted, 0.0); s.record(R::ShuttingDown, 0.0); EXPECT_TRUE(a.delivered() && a.finished() && !a.shouldSend(1000.0)); EXPECT_TRUE(s.delivered() && s.finished() && !s.shouldSend(1000.0)); } { // Auth: terminal for a node we don't own; our own may retry with other credentials. StopDeliveryPolicy unowned(false), owned(true); unowned.record(R::Auth, 0.0); EXPECT_TRUE(unowned.finished() && !unowned.delivered()); owned.record(R::Auth, 0.0); EXPECT_FALSE(owned.finished()); owned.retryNow(0.2); EXPECT_TRUE(owned.shouldSend(0.2)); } using dragonx::daemon::stopSendResultFromKind; using K = dragonx::rpc::RpcFailureKind; EXPECT_TRUE(stopSendResultFromKind(K::None) == R::Accepted); EXPECT_TRUE(stopSendResultFromKind(K::ReplyTimeout) == R::Queued); EXPECT_TRUE(stopSendResultFromKind(K::QueueFull) == R::QueueFull); EXPECT_TRUE(stopSendResultFromKind(K::NotListening) == R::NotListening); EXPECT_TRUE(stopSendResultFromKind(K::ConnectTimeout) == R::NotListening); EXPECT_TRUE(stopSendResultFromKind(K::ShuttingDown) == R::ShuttingDown); EXPECT_TRUE(stopSendResultFromKind(K::Unhandled) == R::ShuttingDown); EXPECT_TRUE(stopSendResultFromKind(K::Auth) == R::Auth); EXPECT_TRUE(stopSendResultFromKind(K::Aborted) == R::Other); } void testProcStatParse() { using dragonx::daemon::EmbeddedDaemon; long pgrp = 0; unsigned long long ticks = 0; // comm with spaces AND a ')': fields must be read after the LAST ')'. tpgid -1 must parse. EXPECT_TRUE(EmbeddedDaemon::parseProcStatLine( "4242 (weird) name) S 1 4240 4240 0 -1 4194560 100 0 0 0 250 50 0 0 20 0 1 0 12345 1000 10", pgrp, ticks)); EXPECT_EQ(pgrp, 4240L); EXPECT_EQ(ticks, 300ULL); EXPECT_FALSE(EmbeddedDaemon::parseProcStatLine("garbage without parens", pgrp, ticks)); EXPECT_FALSE(EmbeddedDaemon::parseProcStatLine("1 (x) S 1", pgrp, ticks)); } void testStopProgressTracker() { using Tracker = dragonx::daemon::DaemonController::StopProgressTracker; { // Flat CPU and I/O: idle time grows from the first sample. Tracker tr; for (int s = 0; s <= 300; ++s) tr.add(s, 10.0, 5e6); EXPECT_NEAR(tr.idleSec(300.0), 300.0, 1e-9); } { // 0.1 cores is progress (threshold 0.05). Tracker tr; for (int s = 0; s <= 300; ++s) tr.add(s, 0.1 * s, -1.0); EXPECT_TRUE(tr.idleSec(300.0) < 1.0); EXPECT_NEAR(tr.cpuPercent(), 10.0, 1e-6); } { // A low-CPU flush still writing ~2 MB/min is progress too. Tracker tr; for (int s = 0; s <= 300; ++s) tr.add(s, 1.0, 2e6 / 60.0 * s); EXPECT_TRUE(tr.idleSec(300.0) < 1.0); } { // No readings at all: never "idle", so Kill is never offered. Tracker tr; for (int s = 0; s <= 3600; s += 10) tr.add(s, -1.0, -1.0); EXPECT_NEAR(tr.idleSec(3600.0), 0.0, 1e-9); EXPECT_TRUE(tr.cpuPercent() < 0); } } void testEvaluateStopWait() { using DC = dragonx::daemon::DaemonController; using D = DC::StopWaitDecision; DC::StopPolicy shutdown; // the shutdown screen: owned, unbounded, choices shutdown.userChoices = true; DC::StopPolicy background; // a restart/rescan/switch: bounded, no choices background.maxWaitSec = 120; background.abandonOnShutdown = true; auto sample = [] { DC::StopWaitSample s; return s; }; auto s = sample(); s.processAlive = false; EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Done); s = sample(); s.choice = DC::StopUserChoice::Detach; EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Detach); EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::Wait); // no choices there s = sample(); s.choice = DC::StopUserChoice::Kill; // Kill without an offer: ignored EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); s.killOffered = true; EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::ForceKill); s = sample(); s.delivered = true; s.idleSec = 600; // delivered + idle 10 min: offer EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::OfferForceKill); s.rebuildActive = true; // ...never during a rebuild EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); s = sample(); s.delivered = false; s.idleSec = 6000; // never delivered: no offer EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); s = sample(); s.elapsedSec = 120; EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp); EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); // the shutdown screen has no budget s = sample(); s.shuttingDown = true; EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp); EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); DC::StopPolicy unowned = shutdown; unowned.owned = false; s = sample(); s.delivered = true; s.idleSec = 6000; s.killOffered = true; s.choice = DC::StopUserChoice::Kill; EXPECT_TRUE(DC::evaluateStopWait(unowned, s) == D::Wait); // never kill what we didn't launch // S11: no way to reach the node (refused our credentials, no signal): a bounded stop gives up after // a short grace instead of its whole budget; the shutdown screen keeps waiting (the user decides). s = sample(); s.undeliverable = true; s.elapsedSec = DC::kUndeliverableGraceSec - 0.1; EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::Wait); s.elapsedSec = DC::kUndeliverableGraceSec; EXPECT_TRUE(DC::evaluateStopWait(background, s) == D::GiveUp); EXPECT_TRUE(DC::evaluateStopWait(shutdown, s) == D::Wait); } // A scripted node for executeDaemonStop, on a virtual clock (seconds) that sleeps and sends advance. class MockStopRuntime final : public dragonx::daemon::DaemonController::StopRuntime { public: using DC = dragonx::daemon::DaemonController; using R = dragonx::daemon::StopSendResult; double clock = 0.0; std::vector sends; // results in order; the last one repeats double exitAt = -1.0; // the process exits at this time (< 0: not on its own) double exitAfterDelivery = -1.0; // ...or this long after stop/SIGTERM reached it bool hasSignal = false; // POSIX-like graceful channel int credentialsLeft = 0; std::function cpuAt; // cumulative CPU seconds at t; empty = unknown std::function choiceAt; bool rebuild = false; std::vector calls; // "stop", "sigterm", "creds", "offer", "withdraw", "kill" double deliveredAt = -1.0, firstSigtermAt = -1.0, firstOfferAt = -1.0; double lastStopAt = -1.0; bool killed = false; bool hitSafetyLimit = false; DC::StopStatus lastStatus; int count(const std::string& c) const { return (int)std::count(calls.begin(), calls.end(), c); } double nowSec() override { return clock; } bool processAlive() override { if (clock > 36000.0) { hitSafetyLimit = true; return false; } // a test bug, not an outcome if (killed) return false; if (exitAt >= 0 && clock >= exitAt) return false; if (exitAfterDelivery >= 0 && deliveredAt >= 0 && clock >= deliveredAt + exitAfterDelivery) return false; return true; } R trySendStop() override { calls.push_back("stop"); lastStopAt = clock; const size_t n = calls.empty() ? 0 : (size_t)count("stop") - 1; const R r = sends.empty() ? R::Accepted : sends[std::min(n, sends.size() - 1)]; clock += (r == R::Queued) ? 2.0 : 0.01; // a reply timeout costs the client timeout if ((r == R::Accepted || r == R::ShuttingDown) && deliveredAt < 0) deliveredAt = clock; return r; } bool nextCredentials() override { calls.push_back("creds"); return credentialsLeft-- > 0; } bool sendGracefulSignal() override { if (!hasSignal) return false; calls.push_back("sigterm"); if (firstSigtermAt < 0) firstSigtermAt = clock; if (deliveredAt < 0) deliveredAt = clock; return true; } double cpuSeconds() override { return cpuAt ? cpuAt(clock) : -1.0; } double ioBytes() override { return -1.0; } bool rebuildActive() override { return rebuild; } DC::StopUserChoice userChoice() override { return choiceAt ? choiceAt(clock) : DC::StopUserChoice::None; } void offerForceKill(bool offered) override { calls.push_back(offered ? "offer" : "withdraw"); if (offered && firstOfferAt < 0) firstOfferAt = clock; } void forceKill() override { calls.push_back("kill"); killed = true; } void setStatus(const DC::StopStatus& s) override { lastStatus = s; } }; class MockStopTask final : public dragonx::daemon::DaemonController::LifecycleTaskContext { public: explicit MockStopTask(MockStopRuntime& rt) : rt_(rt) {} double shutdownAt = -1.0; bool cancelled() const override { return false; } bool shuttingDown() const override { return shutdownAt >= 0 && rt_.clock >= shutdownAt; } void sleepForMs(int ms) override { rt_.clock += ms / 1000.0; } private: MockStopRuntime& rt_; }; dragonx::daemon::DaemonController::StopPolicy shutdownStopPolicy(bool posix) { dragonx::daemon::DaemonController::StopPolicy p; p.owned = true; p.gracefulSignal = posix; p.userChoices = true; return p; } void testDaemonStopExecution() { using DC = dragonx::daemon::DaemonController; using R = dragonx::daemon::StopSendResult; using O = DC::StopOutcome; { // Already exited: not a single RPC call. MockStopRuntime rt; MockStopTask task(rt); rt.exitAt = 0.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_TRUE(rt.calls.empty()); } { // S11: a wallet switch stopping a node we don't own that refuses our credentials (a foreign dragonxd): // one attempt, then give up after the short grace, not the 120s budget. Never kill it. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::Auth}; DC::StopPolicy p; p.owned = false; p.maxWaitSec = 120; p.abandonOnShutdown = true; EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::StillRunning); EXPECT_EQ(rt.count("stop"), 1); EXPECT_EQ(rt.count("kill"), 0); EXPECT_TRUE(rt.clock >= DC::kUndeliverableGraceSec && rt.clock < DC::kUndeliverableGraceSec + 1.0); } { // ...whereas our own node stays waited on (out of credentials, but SIGTERM still reaches it). MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::Auth}; rt.hasSignal = true; rt.exitAfterDelivery = 20.0; DC::StopPolicy p; p.gracefulSignal = true; p.maxWaitSec = 120; EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::Exited); EXPECT_EQ(rt.count("sigterm"), 1); } { // The 2026-09-30 12:21:44 -> 12:22:04 regression: the queue is full for a while. Retry until it // lands (Windows-like, no signal channel), then wait for the exit. Never kill. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::QueueFull, R::QueueFull, R::QueueFull, R::QueueFull, R::QueueFull, R::Accepted}; rt.exitAfterDelivery = 3.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_EQ(rt.count("stop"), 6); EXPECT_EQ(rt.count("kill"), 0); EXPECT_TRUE(rt.deliveredAt >= 5 * 1.5); // retries were spaced, not a tight loop EXPECT_FALSE(rt.hitSafetyLimit); } { // Never automatic: queue full for an hour, CPU busy (a rebuild with no log marker). No kill, no // offer; the node exits on its own at 60 min. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::QueueFull}; rt.exitAt = 3600.0; rt.cpuAt = [](double t) { return 6.0 * t; }; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_EQ(rt.count("kill"), 0); EXPECT_EQ(rt.count("offer"), 0); EXPECT_TRUE(rt.count("stop") > 1000); // still trying every 1.5s EXPECT_FALSE(rt.hitSafetyLimit); } { // POSIX: the first attempt is refused, so the group SIGTERM goes out at once (<= 2s), and no // more RPC attempts are needed. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::QueueFull}; rt.hasSignal = true; rt.exitAfterDelivery = 2.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(true), rt, task) == O::Exited); EXPECT_TRUE(rt.firstSigtermAt >= 0 && rt.firstSigtermAt <= 2.0); EXPECT_EQ(rt.count("stop"), 1); EXPECT_EQ(rt.count("kill"), 0); } { // Queued (reply timeout) is not a failure: no SIGTERM, no resend before 30s. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::Queued}; rt.hasSignal = true; rt.exitAt = 25.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(true), rt, task) == O::Exited); EXPECT_EQ(rt.count("stop"), 1); EXPECT_EQ(rt.count("sigterm"), 0); } { // Delivered, then flat CPU: Kill is OFFERED after ~10 min, and only an explicit Kill kills (once). MockStopRuntime rt; MockStopTask task(rt); rt.cpuAt = [](double) { return 42.0; }; rt.choiceAt = [](double t) { return t >= 900.0 ? DC::StopUserChoice::Kill : DC::StopUserChoice::None; }; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Killed); EXPECT_TRUE(rt.firstOfferAt >= 600.0 && rt.firstOfferAt < 602.0); EXPECT_EQ(rt.count("kill"), 1); EXPECT_TRUE(rt.clock >= 900.0); // waited for the user, didn't act on the offer itself } { // ...with no answer to the offer, it keeps waiting (and never kills). MockStopRuntime rt; MockStopTask task(rt); rt.cpuAt = [](double) { return 42.0; }; rt.exitAt = 3600.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_EQ(rt.count("offer"), 1); EXPECT_EQ(rt.count("kill"), 0); } { // A rebuild in progress suppresses the offer even with flat readings. MockStopRuntime rt; MockStopTask task(rt); rt.cpuAt = [](double) { return 42.0; }; rt.rebuild = true; rt.exitAt = 1800.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_EQ(rt.count("offer"), 0); } { // "Leave running": returns at once, no kill, no further stop attempts. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::QueueFull}; rt.choiceAt = [](double t) { return t >= 5.0 ? DC::StopUserChoice::Detach : DC::StopUserChoice::None; }; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Detached); EXPECT_EQ(rt.count("kill"), 0); EXPECT_TRUE(rt.lastStopAt < 5.0); EXPECT_TRUE(rt.clock < 5.5); } { // A node we didn't launch is never killed, even with Kill chosen; a bounded wait then gives up. MockStopRuntime rt; MockStopTask task(rt); DC::StopPolicy p = shutdownStopPolicy(false); p.owned = false; p.maxWaitSec = 120.0; rt.choiceAt = [](double) { return DC::StopUserChoice::Kill; }; EXPECT_TRUE(DC::executeDaemonStop(p, rt, task) == O::StillRunning); EXPECT_EQ(rt.count("kill"), 0); EXPECT_EQ(rt.count("offer"), 0); EXPECT_TRUE(rt.clock >= 120.0 && rt.clock < 121.0); } { // Background callers (restart, rescan, switch): bounded and never kill; they also let go as soon // as the app starts shutting down, so the shutdown can take over. DC::StopPolicy bg; bg.maxWaitSec = 120.0; bg.abandonOnShutdown = true; MockStopRuntime a; MockStopTask ta(a); a.cpuAt = [](double) { return 1.0; }; EXPECT_TRUE(DC::executeDaemonStop(bg, a, ta) == O::StillRunning); EXPECT_EQ(a.count("kill"), 0); EXPECT_TRUE(a.clock >= 120.0 && a.clock < 121.0); MockStopRuntime b; MockStopTask tb(b); tb.shutdownAt = 10.0; EXPECT_TRUE(DC::executeDaemonStop(bg, b, tb) == O::StillRunning); EXPECT_TRUE(b.clock >= 10.0 && b.clock < 11.0); } { // Our own node refusing our credentials: try the next source, then deliver. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::Auth, R::Auth, R::Accepted}; rt.credentialsLeft = 2; rt.exitAfterDelivery = 1.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(false), rt, task) == O::Exited); EXPECT_EQ(rt.count("creds"), 2); EXPECT_EQ(rt.count("stop"), 3); } { // ...out of credential sources on POSIX: the graceful signal still stops it. MockStopRuntime rt; MockStopTask task(rt); rt.sends = {R::Auth}; rt.hasSignal = true; rt.exitAfterDelivery = 1.0; EXPECT_TRUE(DC::executeDaemonStop(shutdownStopPolicy(true), rt, task) == O::Exited); EXPECT_EQ(rt.count("sigterm"), 1); EXPECT_EQ(rt.count("kill"), 0); } } // --------------------------------------------------------------------------------------------------- // S5: is the node busy with something stopping it would throw away? (DaemonBusyTracker, order-based) // --------------------------------------------------------------------------------------------------- void testParseDaemonLogUtc() { using dragonx::daemon::parseDaemonLogUtc; // UTC, independent of the machine's timezone (no mktime). EXPECT_EQ(parseDaemonLogUtc("2026-09-30 16:34:29 Reading blocks for witness rebuild: 5836 / 62583"), (std::int64_t)1790786069); EXPECT_EQ(parseDaemonLogUtc("2026-09-30 17:21:56 BuildWitnessCache: rebuilt ..."), (std::int64_t)1790788916); EXPECT_EQ(parseDaemonLogUtc("2000-02-29 12:00:00 leap day"), (std::int64_t)951825600); EXPECT_EQ(parseDaemonLogUtc("Reading blocks for witness rebuild: 5836 / 62583"), (std::int64_t)0); // stdout EXPECT_EQ(parseDaemonLogUtc("2026-13-01 00:00:00 bad month"), (std::int64_t)0); } void testDaemonBusyTracker() { using dragonx::daemon::DaemonBusyTracker; const std::string ts = "2026-09-30 16:34:29 "; const std::vector phase1 = { "Reading blocks for witness rebuild: 836 / 62583", "Reading blocks for witness rebuild: 5836 / 62583", "Reading blocks for witness rebuild: 60836 / 62583", }; const std::string endLine = "BuildWitnessCache: rebuilt 13605 note witness cache(s) to height 3326759 in 2820550ms using 24 thread(s)"; for (const bool withTs : {true, false}) { // Windows debug.log vs POSIX stdout: identical results const std::string p = withTs ? ts : ""; { // The 15s-rule regression: Phase 1, then ~47 min of unrelated lines (Phase 2 logs nothing). DaemonBusyTracker tr; for (const auto& l : phase1) tr.feedLine(p + l); EXPECT_TRUE(tr.rebuilding()); EXPECT_EQ(tr.readDone(), 60836L); EXPECT_EQ(tr.readTotal(), 62583L); EXPECT_EQ(tr.rebuildStartUtc(), withTs ? (std::int64_t)1790786069 : (std::int64_t)0); for (int i = 0; i < 2000; ++i) { tr.feedLine(p + "socket sending timeout: 1201s"); tr.feedLine(p + "TLS: connection from 203.0.113.10:21768 has been established (initiated)"); tr.feedLine(p + "Using /16 prefix for IP bucketing"); } EXPECT_TRUE(tr.rebuilding()); tr.feedLine(p + endLine); // the real session-1 end line EXPECT_FALSE(tr.rebuilding()); } { // UpdateTip is logged under cs_main: the rebuild released it (no end line on some paths). DaemonBusyTracker tr; tr.feedLine(p + phase1[0]); tr.feedLine(p + "UpdateTip: new best=00ab height=3264178 log2_work=56.8 tx=1 date=x progress=1.0"); EXPECT_FALSE(tr.rebuilding()); } for (const char* abort : {"BuildWitnessCache: shutdown requested, aborting witness rebuild", "BuildWitnessCache: rescan aborted during witness rebuild", "aborting building witnesses"}) { DaemonBusyTracker tr; tr.feedLine(p + phase1[0]); tr.feedLine(p + abort); EXPECT_FALSE(tr.rebuilding()); } { // A new process (banner) or a finished shutdown forgets everything. DaemonBusyTracker a, b, c; a.feedLine(p + phase1[0]); a.feedLine(p + "DragonX version v1.3.0-c1040028e"); EXPECT_FALSE(a.busy()); b.feedLine(p + "init message: Activating best chain..."); b.feedLine(p + "Hush version v1.2.0"); EXPECT_FALSE(b.busy()); c.feedLine(p + phase1[0]); c.feedLine(p + "Shutdown: done"); EXPECT_FALSE(c.busy()); } { // Routine per-tx witness seeding and the pre-rescan clear are not a rebuild. DaemonBusyTracker tr; for (int i = 0; i < 3; ++i) tr.feedLine(p + "Setting Initial Sapling Witness for tx 00ab, 1 of 5"); tr.feedLine(p + "Cleared witness data from 12 notes"); EXPECT_FALSE(tr.rebuilding()); } { // The legacy v1.0.2 marker counts. DaemonBusyTracker tr; tr.feedLine(p + "Building Witnesses for block 123 0.5 complete, 40 remaining"); EXPECT_TRUE(tr.rebuilding()); } { // The startup replay: UpdateTips don't end it, a rebuild nested inside doesn't either. DaemonBusyTracker tr; tr.feedLine(p + "init message: Activating best chain..."); for (int i = 0; i < 5; ++i) tr.feedLine(p + "UpdateTip: new best=00ab height=3264179"); EXPECT_TRUE(tr.replaying()); EXPECT_FALSE(tr.rebuilding()); tr.feedLine(p + phase1[0]); EXPECT_TRUE(tr.rebuilding() && tr.replaying()); tr.feedLine(p + endLine); EXPECT_FALSE(tr.rebuilding()); EXPECT_TRUE(tr.replaying()); tr.feedLine(p + "init message: Full Node Done Loading! :)"); EXPECT_FALSE(tr.replaying()); } } { // A marker split across two output chunks is still recognised (and \r\n line ends too). DaemonBusyTracker tr; tr.feed("noise line\r\nReading blocks for wit"); EXPECT_FALSE(tr.rebuilding()); tr.feed("ness rebuild: 836 / 62583\r\nnext"); EXPECT_TRUE(tr.rebuilding()); EXPECT_EQ(tr.readDone(), 836L); } } void testShouldConfirmStop() { using dragonx::daemon::DaemonBusyTracker; using dragonx::daemon::shouldConfirmStop; DaemonBusyTracker idle, rebuilding, replaying; rebuilding.feedLine("Reading blocks for witness rebuild: 836 / 62583"); replaying.feedLine("init message: Activating best chain..."); EXPECT_TRUE(shouldConfirmStop(true, rebuilding, false)); // the inverse of the old v1.3.0 bypass EXPECT_FALSE(shouldConfirmStop(false, rebuilding, false)); // a quit that leaves the node running loses nothing EXPECT_TRUE(shouldConfirmStop(true, idle, true)); // busy but alive: a rebuild with no marker EXPECT_FALSE(shouldConfirmStop(true, idle, false)); EXPECT_FALSE(shouldConfirmStop(true, replaying, false)); // stopping a replay is safe: no prompt } void testOutputSinceAcrossTrim() { using dragonx::daemon::EmbeddedDaemon; // Offsets are absolute: a reader that is behind when old output is trimmed gets everything still // buffered (the old index-based offset pointed past it and skipped up to a trim's worth of new text). std::string all; for (int i = 0; i < 100; ++i) all += "line " + std::to_string(i) + "\n"; size_t offset = 0; std::string got = EmbeddedDaemon::outputSince(all, all.size(), offset); EXPECT_EQ(got, all); EXPECT_EQ(offset, all.size()); // 20 more lines arrive, then the buffer keeps only its last 300 bytes. std::string more; for (int i = 100; i < 120; ++i) more += "line " + std::to_string(i) + "\n"; const std::string full = all + more; const std::string kept = full.substr(full.size() - 300); got = EmbeddedDaemon::outputSince(kept, full.size(), offset); EXPECT_EQ(got, more); // every byte after where we left off, none skipped EXPECT_EQ(offset, full.size()); // A reader so far behind that part of its unread text was trimmed resumes at what's left. size_t stale = 10; got = EmbeddedDaemon::outputSince(kept, full.size(), stale); EXPECT_EQ(got, kept); // Nothing new: empty, offset unchanged. got = EmbeddedDaemon::outputSince(kept, full.size(), offset); EXPECT_TRUE(got.empty()); } void testNextMonitorInterval() { using dragonx::daemon::EmbeddedDaemon; using ms = std::chrono::milliseconds; const ms fast(100), cap(500); auto next = [&](ms cur, bool got, ms sinceStart, ms sinceData) { return EmbeddedDaemon::nextMonitorInterval(cur, got, sinceStart, sinceData, fast, cap); }; EXPECT_TRUE(next(fast, false, ms(1000), ms(1000)) == fast); // first 30 s stay fast EXPECT_TRUE(next(cap, false, ms(29999), ms(60000)) == fast); EXPECT_TRUE(next(cap, false, ms(60000), ms(2999)) == fast); // recent data stays fast EXPECT_TRUE(next(cap, true, ms(60000), ms(60000)) == fast); // data resets EXPECT_TRUE(next(fast, false, ms(60000), ms(10000)) == ms(200)); // doubling EXPECT_TRUE(next(ms(200), false, ms(60000), ms(10000)) == ms(400)); EXPECT_TRUE(next(ms(400), false, ms(60000), ms(10000)) == cap); // capped at 500 EXPECT_TRUE(next(cap, false, ms(60000), ms(10000)) == cap); } void testOutputWindowSaturation() { using dragonx::daemon::appendBounded; using dragonx::daemon::outputWindowSince; const size_t cap = 1024 * 1024; std::string buf, seen, expected; size_t total = 0, offset = 0; int n = 0; // Feed well past the 1.25x trim point in 4 KB chunks of whole lines; the reader polls every chunk. while (total < cap * 2) { std::string chunk; while (chunk.size() < 4096) chunk += "line " + std::to_string(n++) + "\n"; appendBounded(buf, total, chunk.data(), chunk.size(), cap); expected += chunk; seen += outputWindowSince(buf, total, offset); } EXPECT_TRUE(buf.size() <= cap + cap / 4); EXPECT_TRUE(buf.size() > cap / 2); // trimmed, but not wiped EXPECT_EQ(total, expected.size()); EXPECT_EQ(seen, expected); // the feed never dies at the trim EXPECT_EQ(offset, total); // A lone trailing newline must not erase the whole buffer. std::string b2(cap + cap / 4 - 1, 'x'); size_t t2 = b2.size(); appendBounded(b2, t2, "\n", 1, cap); EXPECT_TRUE(b2.size() >= cap); // clear()-then-keep-total (process restart) keeps absolute offsets valid. buf.clear(); const std::string fresh = "new run\n"; appendBounded(buf, total, fresh.data(), fresh.size(), cap); EXPECT_EQ(outputWindowSince(buf, total, offset), fresh); EXPECT_EQ(offset, total); } void testOutputEndWatermarkReplay() { using dragonx::daemon::appendBounded; using dragonx::daemon::outputWindowSince; const size_t cap = 1024 * 1024; std::string buf; size_t total = 0; const std::string run1 = "run1 rescanning\nrun1 rescan complete\n"; appendBounded(buf, total, run1.data(), run1.size(), cap); const size_t end = total; // what getOutputEnd() reports at the pre-start boundary const std::string run2 = "run2 start\n"; appendBounded(buf, total, run2.data(), run2.size(), cap); size_t off = end; EXPECT_EQ(outputWindowSince(buf, total, off), run2); // only the new run, none of run1 size_t zero = 0; EXPECT_EQ(outputWindowSince(buf, total, zero), run1 + run2); // the old reset-to-0 replayed run1 } // --------------------------------------------------------------------------------------------------- // S6: the busy gate (don't flood a busy node's RPC queue) and the connect backoff. // --------------------------------------------------------------------------------------------------- void testRpcBusyGate() { using dragonx::rpc::RpcBusyGate; using K = dragonx::rpc::RpcFailureKind; RpcBusyGate g; EXPECT_TRUE(g.admit("getinfo")); EXPECT_FALSE(g.busy()); // The first reply timeout on a cs_main call flips to busy: nothing needing cs_main goes out. g.record("getblockchaininfo", K::ReplyTimeout); EXPECT_TRUE(g.busy()); EXPECT_EQ(g.abandoned(), 1); EXPECT_EQ(g.busyPeriods(), 1); EXPECT_FALSE(g.admit("getinfo")); EXPECT_FALSE(g.admit("z_gettotalbalance")); EXPECT_TRUE(g.admit("stop")); // lock-free: always EXPECT_TRUE(g.admit("help")); EXPECT_TRUE(g.admit("getrescaninfo")); // Lock-free calls say nothing about cs_main; "nothing listening" says nothing about busyness. g.record("help", K::ReplyTimeout); g.record("getinfo", K::NotListening); EXPECT_EQ(g.abandoned(), 1); EXPECT_TRUE(g.busy()); g.record("getinfo", K::ReplyTimeout); // a second timeout in the same busy period EXPECT_EQ(g.busyPeriods(), 1); // ...is not a new period (one canary per period) // Any answer clears it, even an application error. g.record("z_sendmany", K::AppError); EXPECT_FALSE(g.busy()); EXPECT_EQ(g.abandoned(), 0); // Queue full is busy too, but the refused request holds no slot. g.record("getinfo", K::QueueFull); EXPECT_TRUE(g.busy()); EXPECT_EQ(g.abandoned(), 0); EXPECT_EQ(g.busyPeriods(), 2); g.clear(); EXPECT_FALSE(g.busy()); // An hour of polling on two lanes against a node stuck on cs_main: only what was already in flight // when it turned busy gets abandoned; everything after is held back. (Was ~24: every slot.) RpcBusyGate hour; int sent = 0; for (int second = 0; second < 3600; ++second) { for (int lane = 0; lane < 2; ++lane) { if (!hour.admit("getblockchaininfo")) continue; ++sent; if (second > 0 || lane == 1) hour.record("getblockchaininfo", K::ReplyTimeout); } } EXPECT_TRUE(sent <= 2); EXPECT_TRUE(hour.abandoned() <= 2); } // --------------------------------------------------------------------------------------------------- // C61: long wallet RPCs (rescan, key imports) run one at a time on their own ungated client with no client // timeout, and an abort is never lost, whenever it lands. // --------------------------------------------------------------------------------------------------- void testIsLongWalletMethod() { using dragonx::rpc::isLongWalletMethod; for (const char* m : {"rescan", "importprivkey", "importaddress", "importwallet", "z_importkey", "z_importviewingkey", "z_importwallet"}) EXPECT_TRUE(isLongWalletMethod(m)); for (const char* m : {"getinfo", "z_sendmany", "z_exportkey", "dumpprivkey", "getrescaninfo", "stop", ""}) EXPECT_FALSE(isLongWalletMethod(m)); // The console routes a typed rescan / import through the lane. const auto rescan = dragonx::ui::BuildConsoleRpcCall("rescan 100"); EXPECT_TRUE(rescan.valid); EXPECT_TRUE(isLongWalletMethod(rescan.method)); EXPECT_TRUE(isLongWalletMethod(dragonx::ui::BuildConsoleRpcCall("z_importkey \"secret-extended-key-main1x\" yes 5").method)); EXPECT_FALSE(isLongWalletMethod(dragonx::ui::BuildConsoleRpcCall("getinfo").method)); // One at a time; a new call starts un-aborted. dragonx::rpc::LongCallLane lane; EXPECT_FALSE(lane.active()); lane.abort(); // nothing running: sticky until the next tryBegin() EXPECT_TRUE(lane.aborted()); EXPECT_TRUE(lane.tryBegin()); EXPECT_FALSE(lane.aborted()); EXPECT_TRUE(lane.active()); EXPECT_FALSE(lane.tryBegin()); lane.end(); EXPECT_FALSE(lane.active()); EXPECT_TRUE(lane.tryBegin()); lane.end(); } // The wording of a key import that ended in a transport error: only the node's own HTTP-layer refusals say // nothing was imported; a sent request with any other ending (a proxy's 502/504 while the node still runs // the import, a dropped reply) says the key may be in and to run Rescan. void testLongCallCutOff() { using dragonx::rpc::LongCallCutOff; using dragonx::rpc::RpcFailureKind; using dragonx::rpc::classifyLongCallCutOff; const RpcFailureKind all[] = { RpcFailureKind::None, RpcFailureKind::NotListening, RpcFailureKind::ConnectTimeout, RpcFailureKind::ReplyTimeout, RpcFailureKind::QueueFull, RpcFailureKind::ShuttingDown, RpcFailureKind::Unhandled, RpcFailureKind::Auth, RpcFailureKind::Warmup, RpcFailureKind::AppError, RpcFailureKind::Aborted, RpcFailureKind::Deferred, RpcFailureKind::Other, }; for (RpcFailureKind k : all) { for (bool sent : {false, true}) { LongCallCutOff want; if (k == RpcFailureKind::Aborted) want = LongCallCutOff::Cancelled; else if (k == RpcFailureKind::Auth || k == RpcFailureKind::ShuttingDown || k == RpcFailureKind::QueueFull || k == RpcFailureKind::Unhandled) want = LongCallCutOff::Refused; else want = sent ? LongCallCutOff::Interrupted : LongCallCutOff::None; const LongCallCutOff got = classifyLongCallCutOff(k, sent); EXPECT_TRUE(got == want || (std::cerr << " classifyLongCallCutOff(" << dragonx::rpc::rpcFailureKindName(k) << ", sent=" << sent << ") = " << (int)got << "\n", false)); } } // End to end from the HTTP reply, as the import sees it. using dragonx::rpc::classifyRpcFailure; const auto viaHttp = [](long http, const std::string& body) { return classifyLongCallCutOff(classifyRpcFailure(CURLE_OK, true, http, 0, body), true); }; EXPECT_TRUE(viaHttp(502, "Bad Gateway") == LongCallCutOff::Interrupted); EXPECT_TRUE(viaHttp(504, "Gateway Timeout") == LongCallCutOff::Interrupted); EXPECT_TRUE(viaHttp(500, "Internal Server Error") == LongCallCutOff::Interrupted); EXPECT_TRUE(viaHttp(401, "") == LongCallCutOff::Refused); EXPECT_TRUE(viaHttp(503, "") == LongCallCutOff::Refused); EXPECT_TRUE(viaHttp(500, "Work queue depth exceeded") == LongCallCutOff::Refused); EXPECT_TRUE(viaHttp(500, "Unhandled request") == LongCallCutOff::Refused); EXPECT_TRUE(classifyLongCallCutOff(classifyRpcFailure(CURLE_OPERATION_TIMEDOUT, true, 0, 0, ""), true) == LongCallCutOff::Interrupted); EXPECT_TRUE(classifyLongCallCutOff(classifyRpcFailure(CURLE_COULDNT_CONNECT, false, 0, 0, ""), false) == LongCallCutOff::None); EXPECT_TRUE(classifyLongCallCutOff(classifyRpcFailure(CURLE_ABORTED_BY_CALLBACK, true, 0, 0, ""), true) == LongCallCutOff::Cancelled); } #ifndef _WIN32 // A loopback JSON-RPC stub: answers each request delayMs after reading it (never, when delayMs < 0) with // {"result":null,"error":null,"id":"x"}. One connection at a time; requests() counts those it has read. class LoopbackRpcStub { public: explicit LoopbackRpcStub(int delayMs) : delay_ms_(delayMs) { fd_ = ::socket(AF_INET, SOCK_STREAM, 0); sockaddr_in a{}; a.sin_family = AF_INET; a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); socklen_t len = sizeof(a); if (fd_ < 0 || ::bind(fd_, reinterpret_cast(&a), sizeof(a)) != 0 || ::listen(fd_, 4) != 0 || ::getsockname(fd_, reinterpret_cast(&a), &len) != 0) return; port_ = ntohs(a.sin_port); thread_ = std::thread([this] { serve(); }); } ~LoopbackRpcStub() { stop_ = true; if (thread_.joinable()) thread_.join(); if (fd_ >= 0) ::close(fd_); } int port() const { return port_; } int requests() const { return requests_.load(); } private: void serve() { while (!stop_) { pollfd lp{fd_, POLLIN, 0}; if (::poll(&lp, 1, 20) <= 0) continue; const int c = ::accept(fd_, nullptr, nullptr); if (c < 0) continue; std::string req; // headers, then Content-Length bytes of body for (;;) { pollfd p{c, POLLIN, 0}; if (stop_ || ::poll(&p, 1, 20) < 0) break; if (!(p.revents & POLLIN)) continue; char buf[2048]; const ssize_t n = ::recv(c, buf, sizeof(buf), 0); if (n <= 0) break; req.append(buf, static_cast(n)); const size_t h = req.find("\r\n\r\n"); const size_t cl = req.find("Content-Length:"); if (h != std::string::npos && cl != std::string::npos && req.size() >= h + 4 + std::strtoul(req.c_str() + cl + 15, nullptr, 10)) break; } ++requests_; const auto until = std::chrono::steady_clock::now() + std::chrono::milliseconds(std::max(delay_ms_, 0)); while (!stop_ && (delay_ms_ < 0 || std::chrono::steady_clock::now() < until)) std::this_thread::sleep_for(std::chrono::milliseconds(10)); if (!stop_) { const std::string body = "{\"result\":null,\"error\":null,\"id\":\"x\"}"; const std::string resp = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + std::to_string(body.size()) + "\r\nConnection: close\r\n\r\n" + body; ::send(c, resp.data(), resp.size(), MSG_NOSIGNAL); // the client may have given up: no SIGPIPE } ::close(c); } } int delay_ms_; int fd_ = -1; int port_ = 0; std::atomic stop_{false}; std::atomic requests_{0}; std::thread thread_; }; dragonx::rpc::ConnectionConfig loopbackStubConfig(const LoopbackRpcStub& stub) { dragonx::rpc::ConnectionConfig cfg; cfg.host = "127.0.0.1"; cfg.port = std::to_string(stub.port()); cfg.rpcuser = "user"; cfg.rpcpassword = "pass"; return cfg; } // lane.run() + a no-timeout "rescan" on a thread; returns whether it finished within `seconds` and how the call // ended. A call still blocked after that (an abort was lost) is cut through the client directly so the test // can finish, and counts as a failure. struct LaneCallResult { bool finishedInTime = false; dragonx::rpc::RpcFailureKind kind = dragonx::rpc::RpcFailureKind::Other; double seconds = 0.0; }; LaneCallResult runLaneCall(dragonx::rpc::LongCallLane& lane, const dragonx::rpc::ConnectionConfig& cfg, const std::function& whileRunning, double seconds) { using namespace dragonx::rpc; RPCClient client; std::atomic done{false}; std::atomic kind{static_cast(RpcFailureKind::Other)}; const auto t0 = std::chrono::steady_clock::now(); std::thread t([&] { lane.run(client, cfg, [&](RPCClient& c) { try { c.call("rescan", json::array({0}), 0L); kind = static_cast(RpcFailureKind::None); } catch (const std::exception& e) { kind = static_cast(rpcFailureKindOf(e)); } }); done = true; }); if (whileRunning) whileRunning(); LaneCallResult r; r.finishedInTime = waitUntilTrue([&] { return done.load(); }, seconds); r.seconds = secondsSince(t0); if (!r.finishedInTime) client.requestAbort(); // rescue a lost abort so the test ends t.join(); r.kind = static_cast(kind.load()); return r; } void testLongCallLaneAbortBeforeOpen() { using namespace dragonx::rpc; LoopbackRpcStub stub(-1); // never answers EXPECT_TRUE(stub.port() > 0); LongCallLane lane; EXPECT_TRUE(lane.tryBegin()); lane.abort(); // e.g. quit right after the task was submitted: open() must not erase it const LaneCallResult r = runLaneCall(lane, loopbackStubConfig(stub), nullptr, 3.0); EXPECT_TRUE(r.finishedInTime); EXPECT_TRUE(r.kind == RpcFailureKind::Aborted); lane.end(); EXPECT_FALSE(lane.active()); } void testLongCallLaneAbortDuringCall() { using namespace dragonx::rpc; LoopbackRpcStub stub(-1); // never answers LongCallLane lane; EXPECT_TRUE(lane.tryBegin()); double abortAt = -1.0; const auto t0 = std::chrono::steady_clock::now(); const LaneCallResult r = runLaneCall(lane, loopbackStubConfig(stub), [&] { // The request is in flight (the stub has read it), then the abort lands from this thread. EXPECT_TRUE(waitUntilTrue([&] { return stub.requests() >= 1; }, 3.0)); std::this_thread::sleep_for(std::chrono::milliseconds(300)); abortAt = secondsSince(t0); lane.abort(); }, 6.0); EXPECT_TRUE(r.finishedInTime); EXPECT_TRUE(r.kind == RpcFailureKind::Aborted); EXPECT_TRUE(abortAt >= 0.0 && r.seconds - abortAt < 3.0); // returns within 3 s of the abort lane.end(); } void testLongCallNoTimeout() { using namespace dragonx::rpc; { LoopbackRpcStub stub(3000); // a "scan" that takes 3 s const ConnectionConfig cfg = loopbackStubConfig(stub); RPCClient lane; // ungated, like the lane's client EXPECT_TRUE(lane.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, false)); const auto t0 = std::chrono::steady_clock::now(); bool ok = false; try { ok = lane.call("rescan", json::array({0}), 0L).is_null(); // 0 = no client timeout } catch (const std::exception& e) { std::cerr << "no-timeout call threw: " << e.what() << "\n"; } EXPECT_TRUE(ok); EXPECT_TRUE(secondsSince(t0) >= 2.5); // Control: a 1 s client timeout gives up on the same call (ReplyTimeout), and on a client that shares the // busy gate it flips the gate to busy and abandons a daemon slot. Why the lane has no gate and no timeout. auto gate = std::make_shared(); RPCClient gated; gated.setBusyGate(gate); EXPECT_TRUE(gated.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, false)); RpcFailureKind kind = RpcFailureKind::None; try { gated.call("rescan", json::array({0}), 1L); } catch (const std::exception& e) { kind = rpcFailureKindOf(e); } EXPECT_TRUE(kind == RpcFailureKind::ReplyTimeout); EXPECT_TRUE(gate->busy()); EXPECT_EQ(gate->abandoned(), 1); } { // The console's callRaw(.., timeoutSec) override, and both restore the 30 s default afterwards. LoopbackRpcStub stub(1500); const ConnectionConfig cfg = loopbackStubConfig(stub); RPCClient c; EXPECT_TRUE(c.open(cfg.host, cfg.port, cfg.rpcuser, cfg.rpcpassword, false)); std::string raw; try { raw = c.callRaw("rescan", json::array({0}), 0L); } catch (const std::exception& e) { raw = std::string("threw: ") + e.what(); } EXPECT_EQ(raw, std::string("null")); RpcFailureKind kind = RpcFailureKind::None; try { c.callRaw("rescan", json::array({0}), 1L); } catch (const std::exception& e) { kind = rpcFailureKindOf(e); } EXPECT_TRUE(kind == RpcFailureKind::ReplyTimeout); bool ok = false; try { ok = c.call("getinfo").is_null(); // the default timeout again (30 s), not the 1 s override } catch (const std::exception& e) { std::cerr << "default-timeout call threw: " << e.what() << "\n"; } EXPECT_TRUE(ok); } } // Quit during the bootstrap's HEAD request (a host that accepts and never answers): the cancel ends the // request at once instead of after its 15 s timeout, and a partial download is kept for the next resume. void testBootstrapCancelEndsHeadRequest() { using dragonx::util::Bootstrap; LoopbackRpcStub stub(-1); // accepts, never answers EXPECT_TRUE(stub.port() > 0); const fs::path dir = makeTempDir(); const fs::path data = dir / "DRAGONX"; fs::create_directories(data); const std::string partial(1000, 'z'); { std::ofstream(data / "bootstrap_tmp.zip", std::ios::binary) << partial; } Bootstrap b; b.start(data.string(), "http://127.0.0.1:" + std::to_string(stub.port()) + "/DRAGONX.zip"); std::this_thread::sleep_for(std::chrono::milliseconds(300)); const auto t0 = std::chrono::steady_clock::now(); EXPECT_TRUE(b.cancelAndWait(t0 + std::chrono::seconds(10))); EXPECT_TRUE(secondsSince(t0) < 3.0); const Bootstrap::Progress p = b.getProgress(); EXPECT_TRUE(p.state == Bootstrap::State::Failed); EXPECT_EQ(p.error, std::string("Download cancelled")); EXPECT_EQ(readProvisionFile(data / "bootstrap_tmp.zip"), partial); removeTree(dir); } #endif // !_WIN32 void testRpcKindClasses() { using namespace dragonx::rpc; using K = RpcFailureKind; for (K k : {K::ReplyTimeout, K::QueueFull, K::Deferred}) { EXPECT_TRUE(rpcKindMeansBusy(k)); EXPECT_FALSE(rpcKindMeansLost(k)); // a busy node is still there: never a reconnect } for (K k : {K::NotListening, K::ConnectTimeout, K::Auth, K::ShuttingDown, K::Unhandled, K::Other}) { EXPECT_FALSE(rpcKindMeansBusy(k)); EXPECT_TRUE(rpcKindMeansLost(k)); } for (K k : {K::None, K::Warmup, K::AppError, K::Aborted}) { EXPECT_FALSE(rpcKindMeansBusy(k)); EXPECT_FALSE(rpcKindMeansLost(k)); } } // Failed market fetches back off exponentially instead of retrying every frame. void testFetchBackoff() { using dragonx::util::FetchBackoff; using Clock = FetchBackoff::Clock; using std::chrono::seconds; const Clock::time_point t0 = Clock::time_point{} + std::chrono::hours(1); FetchBackoff b; EXPECT_TRUE(b.due(t0)); // nothing failed yet b.fail(t0); // 1st failure: 30 s EXPECT_FALSE(b.due(t0 + seconds(29))); EXPECT_TRUE(b.due(t0 + seconds(30))); b.fail(t0); // 2nd: 60 s EXPECT_FALSE(b.due(t0 + seconds(59))); EXPECT_TRUE(b.due(t0 + seconds(60))); for (int i = 0; i < 20; ++i) b.fail(t0); // many failures: capped at 10 min EXPECT_FALSE(b.due(t0 + seconds(599))); EXPECT_TRUE(b.due(t0 + seconds(600))); b.forceDue(); // manual retry: allowed now... EXPECT_TRUE(b.due(t0)); EXPECT_TRUE(b.failures() > 0); // ...but the next failure still waits long b.succeed(); EXPECT_TRUE(b.due(t0)); EXPECT_EQ(b.failures(), 0); // Jitter scales the wait by at most +/-20%. EXPECT_TRUE(FetchBackoff::delayFor(1, 1.0) == std::chrono::duration_cast(seconds(36))); EXPECT_TRUE(FetchBackoff::delayFor(1, -1.0) == std::chrono::duration_cast(seconds(24))); for (int i = 0; i < 50; ++i) { const double j = FetchBackoff::randomJitter(); EXPECT_TRUE(j >= -1.0 && j <= 1.0); } } // C73: a cancelled GET returns before touching the network. void testHttpGetCancel() { std::atomic cancel{true}; dragonx::util::HttpGetOptions o; o.cancel = &cancel; const dragonx::util::HttpGetResult r = dragonx::util::httpGet("https://127.0.0.1:9/never", "[test]", o); EXPECT_TRUE(r.cancelled); EXPECT_FALSE(r.transportOk); EXPECT_EQ(r.httpStatus, 0L); EXPECT_TRUE(r.body.empty()); EXPECT_EQ(r.error, std::string("cancelled")); EXPECT_EQ(dragonx::util::httpGetString("https://127.0.0.1:9/never", "[test]", o), std::string()); } // C67: the pure search and the error classification. void testLowestBlockSearch() { using namespace dragonx::services; using dragonx::rpc::RpcError; using dragonx::rpc::RpcTransportError; using dragonx::rpc::RpcFailureKind; using json = nlohmann::json; int calls = 0; auto run = [&calls](int tip, int base) { // block data present from `base` upward calls = 0; return findLowestAvailableBlock(tip, [&calls, base](int h) { ++calls; return h >= base ? BlockProbe::Present : BlockProbe::Missing; }); }; const int tip = 1000000; LowestBlockResult r = run(tip, 1); // full history: ONE probe (was ~42) EXPECT_TRUE(r.ok); EXPECT_TRUE(r.fullHistory); EXPECT_EQ(r.lowest, 1); EXPECT_EQ(calls, 1); for (int base : {2, 7, 524288, 999999, 1000000}) { r = run(tip, base); EXPECT_TRUE(r.ok); EXPECT_FALSE(r.fullHistory); EXPECT_EQ(r.lowest, base); EXPECT_TRUE(calls <= 2 + 20); // 1, the tip, then ceil(log2(tip)) halvings } r = run(2, 2); EXPECT_TRUE(r.ok); EXPECT_EQ(r.lowest, 2); EXPECT_EQ(calls, 2); r = run(1, 1); // no usable tip: nothing probed EXPECT_FALSE(r.ok); EXPECT_EQ(calls, 0); r = run(tip, tip + 1); // unreadable tip: no answer EXPECT_FALSE(r.ok); // THE regression: every probe failing used to read as "missing" and answer ok, lowest = tip. calls = 0; r = findLowestAvailableBlock(tip, [&calls](int) { ++calls; return BlockProbe::Unknown; }); EXPECT_FALSE(r.ok); EXPECT_EQ(calls, 1); // Unknown in the middle of the search stops it at once. calls = 0; r = findLowestAvailableBlock(tip, [&calls](int h) { ++calls; if (calls == 4) return BlockProbe::Unknown; return h >= 500000 ? BlockProbe::Present : BlockProbe::Missing; }); EXPECT_FALSE(r.ok); EXPECT_EQ(calls, 4); EXPECT_TRUE(classifyBlockProbeError(RpcError(-32603, "Can't read block from disk")) == BlockProbe::Missing); EXPECT_TRUE(classifyBlockProbeError(RpcError(-32603, "RPC error: Block not available (pruned data)")) == BlockProbe::Missing); EXPECT_TRUE(classifyBlockProbeError(RpcError(-1, "pruned data")) == BlockProbe::Missing); EXPECT_TRUE(classifyBlockProbeError(RpcError(-28, "Loading block index...")) == BlockProbe::Unknown); EXPECT_TRUE(classifyBlockProbeError(RpcError(-8, "Block height out of range")) == BlockProbe::Unknown); EXPECT_TRUE(classifyBlockProbeError(RpcError(-5, "Block not found")) == BlockProbe::Unknown); EXPECT_TRUE(classifyBlockProbeError(RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy")) == BlockProbe::Unknown); EXPECT_TRUE(classifyBlockProbeError(RpcTransportError(RpcFailureKind::ReplyTimeout, "RPC request failed: Timeout was reached")) == BlockProbe::Unknown); EXPECT_TRUE(classifyBlockProbeError(std::runtime_error("Not connected")) == BlockProbe::Unknown); BlockProbe p = probeBlockPresence([](const std::string& m, const json&) -> json { return m == "getblockhash" ? json("00ab") : json::object({{"height", 5}}); }, 5); EXPECT_TRUE(p == BlockProbe::Present); p = probeBlockPresence([](const std::string&, const json&) -> json { return json(5); }, 5); // hash not a string EXPECT_TRUE(p == BlockProbe::Unknown); p = probeBlockPresence([](const std::string& m, const json&) -> json { if (m == "getblock") throw RpcError(-32603, "Can't read block from disk"); return json("00ab"); }, 5); EXPECT_TRUE(p == BlockProbe::Missing); p = probeBlockPresence([](const std::string&, const json&) -> json { throw RpcTransportError(RpcFailureKind::Deferred, "RPC deferred: the node is busy"); }, 5); EXPECT_TRUE(p == BlockProbe::Unknown); } // C103: the sampler (pure threading, no OS probes). void testBackgroundSampler() { using dragonx::util::BackgroundSampler; using Clock = BackgroundSampler::Clock; using std::chrono::milliseconds; auto waitFor = [](const std::function& cond) { for (int i = 0; i < 400 && !cond(); ++i) std::this_thread::sleep_for(milliseconds(5)); return cond(); }; { BackgroundSampler idle; EXPECT_TRUE(idle.due(Clock::now(), milliseconds(1000))); } // no thread: no hang BackgroundSampler s; const auto t0 = Clock::now(); std::atomic calls{0}; EXPECT_TRUE(s.requestIfStale([&calls] { ++calls; return 42.0; }, t0, milliseconds(1000))); EXPECT_TRUE(waitFor([&s] { return s.hasSample(); })); EXPECT_NEAR(s.value(), 42.0, 1e-9); EXPECT_EQ(calls.load(), 1); EXPECT_FALSE(s.due(t0 + milliseconds(500), milliseconds(1000))); // throttled EXPECT_FALSE(s.requestIfStale([&calls] { ++calls; return 1.0; }, t0 + milliseconds(500), milliseconds(1000))); EXPECT_TRUE(s.requestIfStale([&calls] { ++calls; return 7.0; }, t0 + milliseconds(1000), milliseconds(1000))); EXPECT_TRUE(waitFor([&s] { return s.value() == 7.0; })); EXPECT_EQ(calls.load(), 2); // Coalescing: nothing is accepted while a probe runs; reset() discards its result. std::atomic release{false}, running{false}; EXPECT_TRUE(s.requestIfStale([&release, &running] { running = true; while (!release) std::this_thread::sleep_for(milliseconds(1)); return 9.0; }, t0 + milliseconds(5000), milliseconds(1000))); EXPECT_TRUE(waitFor([&running] { return running.load(); })); EXPECT_FALSE(s.due(t0 + milliseconds(60000), milliseconds(1000))); EXPECT_FALSE(s.requestIfStale([&calls] { ++calls; return 1.0; }, t0 + milliseconds(60000), milliseconds(1000))); s.reset(); release = true; EXPECT_TRUE(waitFor([&s, t0] { return s.due(t0 + milliseconds(5000), milliseconds(1000)); })); // after reset: at once EXPECT_FALSE(s.hasSample()); EXPECT_NEAR(s.value(), 0.0, 1e-9); EXPECT_EQ(calls.load(), 2); // A throwing probe leaves no sample and the thread keeps working. EXPECT_TRUE(s.requestIfStale([]() -> double { throw std::runtime_error("probe failed"); }, t0 + milliseconds(5000), milliseconds(1000))); EXPECT_TRUE(waitFor([&s, t0] { return s.due(t0 + milliseconds(7000), milliseconds(1000)); })); EXPECT_FALSE(s.hasSample()); EXPECT_TRUE(s.requestIfStale([] { return 5.0; }, t0 + milliseconds(7000), milliseconds(1000))); EXPECT_TRUE(waitFor([&s] { return s.hasSample(); })); EXPECT_NEAR(s.value(), 5.0, 1e-9); s.stop(); s.stop(); // idempotent EXPECT_FALSE(s.requestIfStale([] { return 1.0; }, t0 + milliseconds(60000), milliseconds(1000))); EXPECT_NEAR(s.value(), 5.0, 1e-9); // last value kept } void testConnectRetryBackoff() { using dragonx::rpc::ConnectRetryBackoff; using K = dragonx::rpc::RpcFailureKind; ConnectRetryBackoff b; EXPECT_NEAR(b.next(K::NotListening), 1.0, 1e-6); EXPECT_NEAR(b.next(K::NotListening), 2.0, 1e-6); EXPECT_NEAR(b.next(K::ConnectTimeout), 1.0, 1e-6); // a different kind starts over b.reset(); const double qf[] = {2, 4, 8, 16, 30, 30}; for (double want : qf) EXPECT_NEAR(b.next(K::QueueFull), want, 1e-6); EXPECT_NEAR(b.next(K::ReplyTimeout), 30.0, 1e-6); // the canary owns recovery EXPECT_NEAR(b.next(K::Other), 4.0, 1e-6); // A minute of queue-full refusals: a handful of attempts, not thousands (was ~3,000/min). b.reset(); double t = 0; int attempts = 0; while (t < 60.0) { t += b.next(K::QueueFull); ++attempts; } EXPECT_TRUE(attempts <= 6); } // S9: the startup overlay says what the node is really doing, most specific first. void testChooseDaemonStatus() { using dragonx::daemon::DaemonBusyTracker; using dragonx::daemon::DaemonStatusKind; using dragonx::daemon::chooseDaemonStatus; DaemonBusyTracker idle, rebuilding, replaying; rebuilding.feedLine("Reading blocks for witness rebuild: 60000 / 62583"); replaying.feedLine("init message: Activating best chain..."); // The incident: a full queue during a rebuild showed "Launching dragonxd…". EXPECT_TRUE(chooseDaemonStatus(false, true, rebuilding) == DaemonStatusKind::Rebuilding); EXPECT_TRUE(chooseDaemonStatus(true, true, rebuilding) == DaemonStatusKind::Rebuilding); EXPECT_TRUE(chooseDaemonStatus(true, false, replaying) == DaemonStatusKind::Replaying); EXPECT_TRUE(chooseDaemonStatus(true, true, idle) == DaemonStatusKind::InitPhase); // "Loading block index" EXPECT_TRUE(chooseDaemonStatus(false, true, idle) == DaemonStatusKind::Busy); // reachable, no answer EXPECT_TRUE(chooseDaemonStatus(false, false, idle) == DaemonStatusKind::Launching); // not bound yet } // Every key exists in English and in all 8 languages, with the same printf arguments (a mismatch is rejected // at runtime and falls back to English, or worse). static void expectKeysTranslated(const std::vector& keys) { // printf conversions in order, "%%" excluded: what must survive translation. auto conversions = [](const std::string& s) { std::string sig; for (size_t i = 0; i < s.size(); ++i) { if (s[i] != '%' || ++i >= s.size()) continue; if (s[i] == '%') continue; while (i < s.size() && std::strchr("-+ #0123456789.*lhLzjtq", s[i])) ++i; if (i < s.size()) sig.push_back(s[i]); } return sig; }; const fs::path langDir = fs::path(fixtureDir()) / ".." / ".." / "res" / "lang"; for (const char* key : keys) { const std::string english = dragonx::util::I18n::instance().translate(key); EXPECT_TRUE(english != key); // present in the built-in English table for (const char* lang : {"de", "es", "fr", "pt", "ru", "zh", "ja", "ko"}) { std::ifstream in(langDir / (std::string(lang) + ".json")); const auto j = nlohmann::json::parse(in, nullptr, false); const bool has = !j.is_discarded() && j.contains(key) && j[key].is_string(); if (!has) std::cerr << "missing translation: " << lang << "." << key << "\n"; EXPECT_TRUE(has); if (has) EXPECT_EQ(conversions(j[key].get()), conversions(english)); } } } // S10: every string added by the daemon-stop work. void testDaemonStopStringsTranslated() { expectKeysTranslated({ "shutdown_leave_running", "shutdown_leave_running_tip", "shutdown_kill_node", "shutdown_kill_node_tip", "shutdown_kill_confirm_title", "shutdown_kill_confirm_msg", "shutdown_kill_confirm_yes", "shutdown_stop_asking", "shutdown_stop_queued", "shutdown_stop_delivered", "shutdown_stop_cpu_suffix", "shutdown_stop_idle", "node_busy_rebuild_title", "node_busy_rebuild_desc", "node_busy_rebuild_reading", "node_busy_rebuild_extending", "node_busy_replay_title", "node_busy_replay_desc", "node_busy_rpc_title", "node_busy_rpc_desc", "node_starting_title", "node_starting_busy_desc", "node_starting_launch_desc", "sb_node_busy", "stopconfirm_rebuild_title", "stopconfirm_rebuild_body", "stopconfirm_busy_title", "stopconfirm_busy_body", "stopconfirm_stop_wait", "appx_daemon_reinstall_node_busy", "node_busy_running", "appx_node_busy_try_later", "appx_node_not_stopped", }); } // Per-address consolidation strings. void testConsolidationStringsTranslated() { expectKeysTranslated({ "merge_where", "merge_into_one", "merge_keep_each", "merge_keep_each_desc", "merge_keep_each_none", "merge_keep_each_row_fmt", "merge_keep_each_too_small", "merge_keep_each_batches_fmt", "merge_keep_each_confirm_fmt", "merge_keep_each_select", "merge_keep_each_nothing_submitted", "merge_keep_each_some_failed", "merge_keep_each_status_not_started", "merge_keep_each_status_failed", "merge_keep_each_status_done", "merge_keep_each_status_running", "merge_keep_each_run_again", "merge_waiting_node", "merge_wait_locked", "merge_scope_failed_fmt", "retry", "merge_keep_each_status_unconfirmed", "merge_submit_uncertain", "merge_run_progress_fmt", "merge_run_stop", "merge_run_stopping", "merge_run_status_waiting", "merge_run_status_not_run", "merge_run_wait_sync", "merge_run_wait_send", "merge_run_wait_batch", "merge_run_err_timeout", "merge_run_stopped_toast", "merge_run_aborted_toast", "merge_run_start_failed", "merge_run_chip_fmt", "merge_run_halted_fmt", "merge_run_busy", "merge_run_migration_pending", }); } // Mining addresses: the strings that explain transparent addresses only receive mining rewards. void testMiningAddressStringsTranslated() { expectKeysTranslated({ "send_mining_addr_short", "send_mining_addr_blocked", "mining_payout_needs_shielded", "new_mining_address", "tt_new_mining_address", "mining_address", "addr_type_mining", "mining_rewards", "mining_rewards_unshielded", "baltab_mining_addresses_count", "baltab_mining_amount", "balance_mining_fmt", "mining_card_tooltip", "mining_card_click_to_shield", "contact_chip_mining", "failed_create_mining_address", "new_mining_address_created", "mining_addr_explainer", "receive_use_shielded", "receive_not_for_payments", "validate_mining_type", "faq_w_addr_1_a", "faq_w_addr_2_a", "faq_w_addr_3_a", "faq_w_addr_4_a", "faq_w_send_1_a", "faq_w_send_3_a", }); } // S3: on Linux a node in its final shutdown phase reads comm "hush-shutoff"; its executable still says // dragonxd, so it must still count as running (it holds the datadir lock until it exits). void testDaemonProcessIdentity() { using dragonx::daemon::EmbeddedDaemon; EXPECT_TRUE(EmbeddedDaemon::isDaemonProcessIdentity("dragonxd", "dragonxd")); EXPECT_TRUE(EmbeddedDaemon::isDaemonProcessIdentity("hush-shutoff", "dragonxd")); EXPECT_TRUE(EmbeddedDaemon::isDaemonProcessIdentity("hush-shutoff", "dragonxd (deleted)")); // binary replaced EXPECT_TRUE(EmbeddedDaemon::isDaemonProcessIdentity("dragonxd", "")); // another user's: no exe link EXPECT_FALSE(EmbeddedDaemon::isDaemonProcessIdentity("hush-shutoff", "hushd")); // a different Hush daemon EXPECT_FALSE(EmbeddedDaemon::isDaemonProcessIdentity("hushd", "hushd")); EXPECT_FALSE(EmbeddedDaemon::isDaemonProcessIdentity("bash", "bash")); EXPECT_FALSE(EmbeddedDaemon::isDaemonProcessIdentity("dragonx-cli", "dragonx-cli")); } // S14: -rpcthreads=16 only when DRAGONX.conf doesn't set it (a command-line flag would override the user's). void testConfSetsKey() { using dragonx::daemon::EmbeddedDaemon; EXPECT_FALSE(EmbeddedDaemon::confSetsKey("", "rpcthreads")); EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcuser=u\nrpcpassword=p\n", "rpcthreads")); EXPECT_TRUE(EmbeddedDaemon::confSetsKey("rpcuser=u\nrpcthreads=4\n", "rpcthreads")); EXPECT_TRUE(EmbeddedDaemon::confSetsKey(" rpcthreads = 4\r\n", "rpcthreads")); // spaces, CRLF EXPECT_TRUE(EmbeddedDaemon::confSetsKey("rpcthreads=4", "rpcthreads")); // no final newline EXPECT_FALSE(EmbeddedDaemon::confSetsKey("#rpcthreads=4\n", "rpcthreads")); // commented out EXPECT_FALSE(EmbeddedDaemon::confSetsKey(" # rpcthreads=4\n", "rpcthreads")); EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcthreadsx=4\nxrpcthreads=4\n", "rpcthreads")); EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcthreads\n", "rpcthreads")); // not a key=value line EXPECT_FALSE(EmbeddedDaemon::confSetsKey("rpcworkqueue=64 # rpcthreads=4\n", "rpcthreads")); } // C97: a user dbcache= in DRAGONX.conf wins over the wallet's -dbcache pin; default argv is unchanged. void testDropPinnedDbcache() { using dragonx::daemon::EmbeddedDaemon; const std::vector base{"-a", "-dbcache=450", "-b"}; auto run = [&](const std::string& conf) { std::vector v = base; EmbeddedDaemon::dropPinnedDbcacheIfConfSets(v, conf); return v; }; const std::vector dropped{"-a", "-b"}; EXPECT_TRUE(run("dbcache=1000") == dropped); EXPECT_TRUE(run(" dbcache = 8\r\n") == dropped); EXPECT_TRUE(run("rpcuser=x\ndbcache=2000\n") == dropped); EXPECT_TRUE(run("#dbcache=1") == base); EXPECT_TRUE(run("") == base); EXPECT_TRUE(run("rpcuser=x") == base); } // S12: the ownership record names the node we launched, by pid AND start time, so a later session can // recognise it after "Leave running" — and a reused pid, or a record from before a reboot, never matches. void testOwnershipRecord() { using dragonx::daemon::EmbeddedDaemon; using Rec = EmbeddedDaemon::OwnershipRecord; Rec r; r.pid = 4242; r.startToken = "boot-1/123456"; r.exePath = "/opt/dragonx/dragonxd"; Rec back; EXPECT_TRUE(EmbeddedDaemon::parseOwnershipRecord(EmbeddedDaemon::formatOwnershipRecord(r), back)); EXPECT_EQ(back.pid, 4242L); EXPECT_EQ(back.startToken, r.startToken); EXPECT_EQ(back.exePath, r.exePath); EXPECT_TRUE(EmbeddedDaemon::parseOwnershipRecord("pid=7\r\nstart=x\r\n", back) && back.pid == 7); EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("", back)); EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("pid=7\n", back)); // no start token EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("start=x\npid=0\n", back)); // no pid EXPECT_FALSE(EmbeddedDaemon::parseOwnershipRecord("garbage", back)); char state = 0; unsigned long long start = 0; EXPECT_TRUE(EmbeddedDaemon::parseProcStatStart( "4242 (drag on) x) S 1 4242 4242 0 -1 4194560 100 0 0 0 7 3 0 0 20 0 9 0 98765 1000 50", state, start)); EXPECT_TRUE(state == 'S'); EXPECT_EQ(start, 98765ULL); EXPECT_FALSE(EmbeddedDaemon::parseProcStatStart("garbage", state, start)); #ifdef _WIN32 const long self = static_cast(GetCurrentProcessId()); #else const long self = static_cast(getpid()); #endif const std::string mine = EmbeddedDaemon::processStartToken(self); EXPECT_FALSE(mine.empty()); EXPECT_EQ(EmbeddedDaemon::processStartToken(self), mine); // stable EXPECT_TRUE(EmbeddedDaemon::processStartToken(0).empty()); #ifndef _WIN32 { // an exited (reaped) process has no token: its pid may be anyone's now const pid_t child = fork(); if (child == 0) _exit(0); int status = 0; waitpid(child, &status, 0); EXPECT_TRUE(EmbeddedDaemon::processStartToken(static_cast(child)).empty()); } #endif // A record for a live process is kept; one whose pid is alive but started at another time (a reused // pid) is stale and deleted, as is an unreadable one. Temp paths only (never the real datadir). const fs::path dir = makeTempDir(); const std::string path = (dir / "obsidiandragon-dragonxd.pid").string(); Rec live; live.pid = self; live.startToken = mine; live.exePath = "x"; EmbeddedDaemon::writeOwnershipRecord(path, live); Rec got; EXPECT_TRUE(EmbeddedDaemon::liveOwnershipRecord(path, &got)); EXPECT_EQ(got.pid, self); EXPECT_TRUE(fs::exists(path)); Rec reused = live; reused.startToken = mine + "0"; EmbeddedDaemon::writeOwnershipRecord(path, reused); EXPECT_FALSE(EmbeddedDaemon::liveOwnershipRecord(path)); EXPECT_FALSE(fs::exists(path)); { std::ofstream(path) << "garbage"; } EXPECT_FALSE(EmbeddedDaemon::liveOwnershipRecord(path)); EXPECT_FALSE(fs::exists(path)); EXPECT_FALSE(EmbeddedDaemon::liveOwnershipRecord(path)); // missing: just false std::error_code ec; fs::remove_all(dir, ec); } // S13: the node writes its timestamped debug.log on every platform, which we tail. Fixed epochs (UTC). void testDebugLogTailRules() { using dragonx::daemon::EmbeddedDaemon; using dragonx::daemon::externalLogFeedOffset; const std::int64_t t0 = 1790000000; // 2026-09-21 13:46:40 UTC auto stamp = [](std::int64_t utc) { const std::time_t tt = static_cast(utc); std::tm tm{}; #ifdef _WIN32 gmtime_s(&tm, &tt); #else gmtime_r(&tt, &tm); #endif char buf[32]; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S ", &tm); return std::string(buf); }; const std::string oldRun = stamp(t0 - 3600) + "DragonX version v1.3.0\n" + stamp(t0 - 3500) + "Reading blocks for witness rebuild: 5000 / 62583\n"; const std::string newRun = stamp(t0 + 1) + "DragonX version v1.3.0\n" + stamp(t0 + 2) + "init message: Loading\n"; // A shrink at startup: resume at THIS run's banner, never replay the old run's rebuild lines. EXPECT_EQ(EmbeddedDaemon::shrinkResumeOffset(oldRun + newRun, t0), oldRun.size()); EXPECT_EQ(EmbeddedDaemon::shrinkResumeOffset(oldRun, t0), oldRun.size()); // banner not written yet: skip EXPECT_EQ(EmbeddedDaemon::shrinkResumeOffset("", t0), size_t(0)); // A node someone else started at t0: only its own lines count. EXPECT_EQ(externalLogFeedOffset(oldRun + newRun, false, t0), oldRun.size()); EXPECT_TRUE(externalLogFeedOffset(oldRun, false, t0) == std::string::npos); // a stale log (-printtoconsole node) const std::string midRun = stamp(t0 + 600) + "Reading blocks for witness rebuild: 9000 / 62583\n"; EXPECT_EQ(externalLogFeedOffset("ing a partial line\n" + midRun, true, t0), size_t(0)); // banner before the window EXPECT_TRUE(externalLogFeedOffset(oldRun.substr(30), true, t0) == std::string::npos); // ...but old lines: no EXPECT_TRUE(externalLogFeedOffset(midRun, false, t0) == std::string::npos); // whole file, no banner: can't tell EXPECT_TRUE(externalLogFeedOffset(oldRun + newRun, false, 0) == std::string::npos); // C107: the miss-retry schedule: fast in the grace window, then 4,8,...,60 s cap. { double b = 0.0; EXPECT_NEAR(dragonx::daemon::nextExternalLogRetryDelay(0.0, b), 2.0, 1e-9); EXPECT_NEAR(dragonx::daemon::nextExternalLogRetryDelay(14.9, b), 2.0, 1e-9); EXPECT_NEAR(b, 0.0, 1e-9); EXPECT_NEAR(dragonx::daemon::nextExternalLogRetryDelay(15.0, b), 4.0, 1e-9); EXPECT_NEAR(dragonx::daemon::nextExternalLogRetryDelay(20.0, b), 8.0, 1e-9); EXPECT_NEAR(dragonx::daemon::nextExternalLogRetryDelay(30.0, b), 16.0, 1e-9); for (int i = 0; i < 6; ++i) dragonx::daemon::nextExternalLogRetryDelay(100.0, b); EXPECT_NEAR(b, 60.0, 1e-9); } #ifdef _WIN32 const long self = static_cast(GetCurrentProcessId()); #else const long self = static_cast(getpid()); #endif const std::int64_t started = EmbeddedDaemon::processStartUtc(self); const std::int64_t nowUtc = static_cast(std::time(nullptr)); EXPECT_TRUE(started > 0 && started <= nowUtc + 2 && started > nowUtc - 3600); // this test started recently EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(self)); EXPECT_FALSE(EmbeddedDaemon::processIsDaemon(0)); } // Per-address consolidation: each address's notes merge back into that same address, so separately // labelled funds stay separate while the wallet sheds notes. void testNoteConsolidation() { using namespace dragonx::services; const nlohmann::json unspent = nlohmann::json::parse(R"([ {"address":"zsA","amount":1.0,"confirmations":5,"spendable":true}, {"address":"zsA","amount":2.0,"confirmations":5,"spendable":true}, {"address":"zsA","amount":0.5,"confirmations":1}, {"address":"zsA","amount":9.0,"confirmations":0,"spendable":true}, {"address":"zsB","amount":3.0,"confirmations":9,"spendable":true}, {"address":"zsW","amount":4.0,"confirmations":9,"spendable":false}, {"address":"zsW","amount":4.0,"confirmations":9,"spendable":false}, {"amount":1.0,"confirmations":9} ])"); const auto groups = groupSpendableNotes(unspent); EXPECT_EQ(groups.size(), size_t(2)); // watch-only and address-less left out EXPECT_EQ(groups[0].address, std::string("zsA")); // most notes first EXPECT_EQ(groups[0].notes, 3); // the unconfirmed note is not spendable yet EXPECT_NEAR(groups[0].amount, 3.5, 1e-9); EXPECT_EQ(groups[1].notes, 1); EXPECT_TRUE(groupSpendableNotes(nlohmann::json::object()).empty()); const double fee = 0.0001; EXPECT_TRUE(worthConsolidating(groups[0], fee)); EXPECT_FALSE(worthConsolidating(groups[1], fee)); // a single note: nothing to merge EXPECT_FALSE(worthConsolidating(NoteGroup{"zsD", 4, 0.00015}, fee)); // the fee would eat it EXPECT_EQ(estimateBatches(0, 90), 0); EXPECT_EQ(estimateBatches(1, 90), 0); EXPECT_EQ(estimateBatches(2, 90), 1); EXPECT_EQ(estimateBatches(90, 90), 1); EXPECT_EQ(estimateBatches(91, 90), 1); // the lone leftover note is not a batch EXPECT_EQ(estimateBatches(92, 90), 2); EXPECT_EQ(estimateBatches(182, 90), 3); EXPECT_EQ(estimateBatches(5, 1), 2); // a limit below 2 is treated as 2 // A stand-in daemon: each call merges up to `limit` of the address's unlocked notes into itself and // refuses a lone note, like z_mergetoaddress. auto daemonFor = [](int notes, int limit, int failOnCall) { auto left = std::make_shared(notes); auto calls = std::make_shared(0); return [=](const std::string&) { MergeCallResult r; if (++*calls == failOnCall) { r.error = "Insufficient funds"; return r; } if (*left < 2) { r.error = "Destination address is also the only source address"; return r; } r.merging = std::min(limit, *left); *left -= r.merging; r.remaining = *left; r.opid = "opid-" + std::to_string(*calls); return r; }; }; for (int notes : {2, 90, 91, 92, 250}) { const AddressRun run = consolidateInPlace("zsA", daemonFor(notes, 90, -1)); EXPECT_TRUE(run.error.empty()); EXPECT_EQ(static_cast(run.opids.size()), estimateBatches(notes, 90)); EXPECT_TRUE(run.remaining < 2); EXPECT_EQ(run.notesMerged + run.remaining, notes); } { // the first call fails: nothing submitted const AddressRun run = consolidateInPlace("zsA", daemonFor(10, 90, 1)); EXPECT_TRUE(run.opids.empty()); EXPECT_FALSE(run.error.empty()); } { // a later call fails: the earlier batch stands, the error is reported const AddressRun run = consolidateInPlace("zsA", daemonFor(200, 90, 2)); EXPECT_EQ(run.opids.size(), size_t(1)); EXPECT_EQ(run.notesMerged, 90); EXPECT_FALSE(run.error.empty()); } { // bounded const AddressRun run = consolidateInPlace("zsA", daemonFor(1000, 2, -1), /*maxBatches=*/5); EXPECT_EQ(run.opids.size(), size_t(5)); EXPECT_TRUE(run.remaining >= 2); } } void testDatadirLockGate() { using dragonx::daemon::EmbeddedDaemon; // Normal start, no lingering daemon after the bounded wait → proceed. auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false); EXPECT_TRUE(clear.proceed); // A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg. auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true); EXPECT_TRUE(!locked.proceed); EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos); // Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running. auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true); EXPECT_TRUE(skipPort.proceed); // Isolated instance via -datadir override is exempt even if a sibling is running. auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true); EXPECT_TRUE(isolated.proceed); } 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({"background-stop"})); EXPECT_EQ(bootstrapRuntime.disconnectReason, std::string("Bootstrap")); // S11: a node that doesn't exit cancels the operation before it changes anything: no second node is // started over it, and chain data is not deleted under it. for (auto op : {DaemonController::LifecycleOperation::ManualRestart, DaemonController::LifecycleOperation::Rescan, DaemonController::LifecycleOperation::RepairWallet, DaemonController::LifecycleOperation::DeleteBlockchainData}) { auto decision = DaemonController::evaluateLifecycleOperation(op, true, true, true, false); MockDaemonLifecycleRuntime stuckRuntime; stuckRuntime.stopResult = false; MockDaemonLifecycleTask stuckTask; auto stuck = DaemonController::executeLifecycleOperation(decision, stuckRuntime, stuckTask); EXPECT_TRUE(stuck.stopFailed); EXPECT_FALSE(stuck.stopped); EXPECT_FALSE(stuck.started); EXPECT_FALSE(stuck.completed); EXPECT_EQ(stuck.deletedItems, 0); EXPECT_TRUE(stuckRuntime.calls == std::vector({"stop"})); EXPECT_EQ(stuckTask.sleeps, 0); } { // A restart with no node of ours running just starts one (nothing to stop). auto idle = DaemonController::evaluateLifecycleOperation( DaemonController::LifecycleOperation::ManualRestart, true, true, false, false); MockDaemonLifecycleRuntime idleRuntime; idleRuntime.stopResult = false; MockDaemonLifecycleTask idleTask; auto idleResult = DaemonController::executeLifecycleOperation(idle, idleRuntime, idleTask); EXPECT_FALSE(idleResult.stopFailed); EXPECT_TRUE(idleResult.started); EXPECT_TRUE(idleRuntime.calls == std::vector({"start"})); } 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")); removeTree(dataDir); } // Pure console wrap layout / hit-test / selection (console_text_layout.h), driven by a // fixed-width measurement stub so the byte-offset selection math is unit-testable. void testConsoleTextLayout() { using namespace dragonx::ui; struct StubMeasure : ConsoleTextMeasure { float charW = 10.0f; float width(const char* b, const char* e) const override { return static_cast(e - b) * charW; } const char* wrapPosition(const char* b, const char* e, float w) const override { int maxChars = static_cast(w / charW); if (maxChars < 1) maxChars = 1; return (e - b) <= maxChars ? e : b + maxChars; } } stub; std::vector lines = { "", "abc", "abcdefghij" }; // 0, 3, 10 chars auto get = [&lines](int i) -> const std::string& { return lines[i]; }; const float lineH = 12.0f, gap = 2.0f; // wrapWidth 50 (charW 10 -> 5 chars/row) ConsoleLayout L = BuildConsoleLayout(static_cast(lines.size()), get, 50.0f, lineH, gap, stub); EXPECT_EQ(L.segments.size(), static_cast(3)); EXPECT_EQ(L.segments[0].size(), static_cast(1)); // empty -> one empty seg EXPECT_EQ(L.segments[0][0].byteEnd, 0); EXPECT_NEAR(L.heights[0], lineH + gap, 0.01); EXPECT_EQ(L.segments[1].size(), static_cast(1)); // "abc" fits EXPECT_EQ(L.segments[1][0].byteEnd, 3); EXPECT_EQ(L.segments[2].size(), static_cast(2)); // 10 chars, 5/row -> 2 rows EXPECT_EQ(L.segments[2][0].byteEnd, 5); EXPECT_EQ(L.segments[2][1].byteStart, 5); EXPECT_EQ(L.segments[2][1].byteEnd, 10); EXPECT_NEAR(L.heights[2], 2 * lineH + gap, 0.01); EXPECT_NEAR(L.cumulativeY[2], 2 * (lineH + gap), 0.01); EXPECT_NEAR(L.totalHeight, 3 * (lineH + gap) + lineH, 0.01); // Hit-test: second wrap row of line 2, column 7. ConsoleHit hit = HitTestConsoleLayout(L, 3, get, /*relX*/ 20.0f, /*relY*/ 45.0f, stub); EXPECT_EQ(hit.visibleRow, 2); EXPECT_EQ(hit.col, 7); // Past the bottom clamps to the last line's end. ConsoleHit last = HitTestConsoleLayout(L, 3, get, 1000.0f, 9999.0f, stub); EXPECT_EQ(last.visibleRow, 2); EXPECT_EQ(last.col, 10); // Selection extraction (multi-line, no filter, then filtered to line 0 only). std::vector lines2 = { "hello", "world" }; auto get2 = [&lines2](int i) -> const std::string& { return lines2[i]; }; EXPECT_EQ(ExtractConsoleSelection(2, get2, {}, {0, 1}, {1, 3}), std::string("ello\nwor")); EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector{0}, {0, 1}, {1, 3}), std::string("ello")); } // VERBATIM copy of the OLD full-rebuild builder and its result type (refs/scratch/t4g-c26 // src/ui/windows/console_text_layout.{h,cpp}, before the wrap cache): the independent reference the incremental // ConsoleWrapCache + AssembleConsoleLayout path must reproduce bit for bit. namespace cwlegacy { using namespace dragonx::ui; // The wrap layout for a set of visible lines. struct ConsoleLayout { std::vector> segments; // per visible line std::vector heights; // per visible line (includes the inter-line gap) std::vector cumulativeY; // cumulative Y offset of each visible line float totalHeight = 0.0f; }; ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText, float wrapWidth, float lineHeight, float interLineGap, const ConsoleTextMeasure& measure) { ConsoleLayout L; if (visibleCount < 0) visibleCount = 0; L.segments.resize(visibleCount); L.heights.resize(visibleCount); L.cumulativeY.resize(visibleCount); L.totalHeight = 0.0f; for (int vi = 0; vi < visibleCount; vi++) { const std::string& text = getVisibleText(vi); auto& segs = L.segments[vi]; segs.clear(); if (text.empty()) { segs.push_back({0, 0, 0.0f, lineHeight}); L.cumulativeY[vi] = L.totalHeight; L.heights[vi] = lineHeight + interLineGap; L.totalHeight += L.heights[vi]; continue; } const char* textStart = text.c_str(); const char* textEnd = textStart + text.size(); const char* cur = textStart; float segY = 0.0f; while (cur < textEnd) { const char* wrapPos = measure.wrapPosition(cur, textEnd, wrapWidth); if (wrapPos <= cur) wrapPos = cur + 1; // ensure forward progress if (*cur == '\n') { cur++; continue; } // skip a newline ending the prior seg int byteStart = static_cast(cur - textStart); int byteEnd = static_cast(wrapPos - textStart); if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--; // trim trailing '\n' segs.push_back({byteStart, byteEnd, segY, lineHeight}); segY += lineHeight; cur = wrapPos; } if (segs.empty()) { segs.push_back({0, 0, 0.0f, lineHeight}); segY = lineHeight; } L.cumulativeY[vi] = L.totalHeight; L.heights[vi] = segY + interLineGap; L.totalHeight += L.heights[vi]; } return L; } } // namespace cwlegacy // Incremental console wrap cache (console_text_layout.h): per-raw-line wraps aligned to the model by absolute // sequence number; only new / invalidated lines are wrapped and the assembled layout is bit-identical to the // OLD full-rebuild builder (cwlegacy above) across appends, cap eviction, width and filter changes (C198). void testConsoleWrapCache() { using namespace dragonx::ui; struct CountingMeasure : ConsoleTextMeasure { float charW = 10.0f; mutable int calls = 0; float width(const char* b, const char* e) const override { return static_cast(e - b) * charW; } const char* wrapPosition(const char* b, const char* e, float w) const override { ++calls; int maxChars = static_cast(w / charW); if (maxChars < 1) maxChars = 1; return (e - b) <= maxChars ? e : b + maxChars; } } m; auto sameLayout = [](const ConsoleLayout& a, const ConsoleLayout& b) { if (a.segments.size() != b.segments.size() || a.heights != b.heights || a.cumulativeY != b.cumulativeY || a.rowStart != b.rowStart || a.totalHeight != b.totalHeight) return false; for (std::size_t i = 0; i < a.segments.size(); ++i) { if (a.segments[i].size() != b.segments[i].size()) return false; for (std::size_t s = 0; s < a.segments[i].size(); ++s) { const ConsoleWrapSegment& x = a.segments[i][s]; const ConsoleWrapSegment& y = b.segments[i][s]; if (x.byteStart != y.byteStart || x.byteEnd != y.byteEnd || x.yOffset != y.yOffset || x.height != y.height) return false; } } return true; }; auto bitEq = [](float x, float y) { return std::memcmp(&x, &y, sizeof(float)) == 0; }; auto sameAsLegacy = [&bitEq](const ConsoleLayout& a, const cwlegacy::ConsoleLayout& b) { const std::size_t V = b.segments.size(); if (a.segments.size() != V || a.heights.size() != V || a.cumulativeY.size() != V || b.heights.size() != V || b.cumulativeY.size() != V || a.rowStart.size() != V + 1 || !bitEq(a.totalHeight, b.totalHeight)) return false; int rows = 0; for (std::size_t i = 0; i < V; ++i) { if (a.rowStart[i] != rows || !bitEq(a.heights[i], b.heights[i]) || !bitEq(a.cumulativeY[i], b.cumulativeY[i]) || a.segments[i].size() != b.segments[i].size()) return false; rows += static_cast(b.segments[i].size()); for (std::size_t s = 0; s < b.segments[i].size(); ++s) { const ConsoleWrapSegment& x = a.segments[i][s]; const ConsoleWrapSegment& y = b.segments[i][s]; if (x.byteStart != y.byteStart || x.byteEnd != y.byteEnd || !bitEq(x.yOffset, y.yOffset) || !bitEq(x.height, y.height)) return false; } } return a.rowStart[V] == rows; }; auto lineText = [](int i) { std::string s; if (i % 5 == 0) return s; // empty line for (int k = 0; k < (i * 7) % 23; ++k) s += static_cast('a' + (k + i) % 26); if (i % 7 == 0) s += "\nxyz"; // embedded newline if (i % 11 == 0) s += " \xC3\xA9\xE2\x82\xAC tail"; // multi-byte UTF-8 return s; }; std::deque model; std::uint64_t firstSeq = 0; for (int i = 0; i < 200; ++i) model.push_back(lineText(i)); auto getRaw = [&model](int i) -> const std::string& { return model[static_cast(i)]; }; auto all = [&model]() { std::vector v; for (int i = 0; i < static_cast(model.size()); ++i) v.push_back(i); return v; }; const float H = 12.3f, G = 2.7f; // not dyadic: sums round, so a changed float op order would show auto fresh = [&](float w, float gap) { return BuildConsoleLayout(static_cast(model.size()), getRaw, w, H, gap, m); }; CountingMeasure refM; // the reference's own measure: never perturbs m.calls auto legacy = [&](const std::vector& vis, float w, float gap) { auto getVis = [&](int vi) -> const std::string& { return model[static_cast(vis[static_cast(vi)])]; }; return cwlegacy::BuildConsoleLayout(static_cast(vis.size()), getVis, w, H, gap, refM); }; ConsoleWrapKey key; key.wrapWidth = 50.0f; key.lineHeight = H; key.fontSize = H; key.rasterDensity = 1.0f; key.fontId = 1; ConsoleWrapCache cache; ConsoleLayout L; // 1) Equivalence with the old builder (and the new one-shot one); each visible line wrapped exactly once; // rowStart = prefix rows. cache.sync(firstSeq, model.size(), key); AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, G, m); EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, G))); EXPECT_TRUE(sameAsLegacy(fresh(50.0f, G), legacy(all(), 50.0f, G))); EXPECT_TRUE(sameLayout(L, fresh(50.0f, G))); EXPECT_EQ(cache.wrapCount(), static_cast(200)); EXPECT_EQ(L.rowStart.size(), static_cast(201)); bool rowsOk = true; for (std::size_t i = 0; i < L.segments.size(); ++i) rowsOk = rowsOk && (L.rowStart[i + 1] - L.rowStart[i] == static_cast(L.segments[i].size())); EXPECT_TRUE(rowsOk); // 2) Append-only: only the 3 new lines are wrapped; existing entries do not move. const ConsoleWrapSegment* line1Segs = L.segments[1].begin(); for (int i = 200; i < 203; ++i) model.push_back(lineText(i)); std::size_t before = cache.wrapCount(); cache.sync(firstSeq, model.size(), key); AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, G, m); EXPECT_EQ(cache.wrapCount() - before, static_cast(3)); EXPECT_TRUE(L.segments[1].begin() == line1Segs); EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, G))); // 3) Cap eviction is an offset: 2 popped + 1 added => exactly 1 wrap; layout == fresh build of the survivors. model.pop_front(); model.pop_front(); firstSeq += 2; model.push_back(lineText(203)); before = cache.wrapCount(); cache.sync(firstSeq, model.size(), key); AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, G, m); EXPECT_EQ(cache.wrapCount() - before, static_cast(1)); EXPECT_EQ(cache.size(), model.size()); EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, G))); // 4) Gap-only change: zero wraps and zero measuring. Width change: every visible line re-wrapped. before = cache.wrapCount(); const int callsBefore = m.calls; AssembleConsoleLayout(L, cache, all(), getRaw, 50.0f, H, 5.0f, m); EXPECT_EQ(cache.wrapCount() - before, static_cast(0)); EXPECT_EQ(m.calls, callsBefore); EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 50.0f, 5.0f))); ConsoleWrapKey narrow = key; narrow.wrapWidth = 30.0f; EXPECT_FALSE(cache.matches(narrow)); before = cache.wrapCount(); cache.sync(firstSeq, model.size(), narrow); AssembleConsoleLayout(L, cache, all(), getRaw, 30.0f, H, G, m); EXPECT_EQ(cache.wrapCount() - before, model.size()); EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 30.0f, G))); EXPECT_TRUE(cache.matches(narrow)); ConsoleWrapKey tiny = narrow; tiny.wrapWidth = 30.01f; // sub-1/16 px change still invalidates ConsoleWrapKey reloaded = narrow; reloaded.fontId = 2; // atlas reload at the same address/size EXPECT_FALSE(cache.matches(tiny)); EXPECT_FALSE(cache.matches(reloaded)); // 5) Hit-testing the assembled layout == hit-testing the one-shot layout. { ConsoleLayout ref = fresh(30.0f, G); const float probes[][2] = {{0.f, 0.f}, {20.f, 45.f}, {-5.f, 100.f}, {35.f, 333.f}, {1000.f, 99999.f}}; bool same = true; for (const auto& p : probes) { ConsoleHit a = HitTestConsoleLayout(L, static_cast(model.size()), getRaw, p[0], p[1], m); ConsoleHit b = HitTestConsoleLayout(ref, static_cast(model.size()), getRaw, p[0], p[1], m); same = same && a.visibleRow == b.visibleRow && a.col == b.col; } EXPECT_TRUE(same); } // 5b) Filter changes reuse the cached wraps (zero wraps) and match the old builder over the filtered set; a // filtered view across a cap eviction + append wraps only the new line. { auto filtered = [&](bool (*pred)(const std::string&)) { std::vector v; for (int i = 0; i < static_cast(model.size()); ++i) if (pred(model[static_cast(i)])) v.push_back(i); return v; }; bool (*hasE)(const std::string&) = [](const std::string& s) { return s.find('e') != std::string::npos; }; bool (*blankOrMulti)(const std::string&) = [](const std::string& s) { return s.empty() || s.find('\n') != std::string::npos; }; before = cache.wrapCount(); for (auto pred : {hasE, blankOrMulti, hasE}) { const std::vector vis = filtered(pred); EXPECT_TRUE(!vis.empty() && vis.size() < model.size()); AssembleConsoleLayout(L, cache, vis, getRaw, 30.0f, H, G, m); EXPECT_TRUE(sameAsLegacy(L, legacy(vis, 30.0f, G))); } AssembleConsoleLayout(L, cache, all(), getRaw, 30.0f, H, G, m); // filter cleared EXPECT_TRUE(sameAsLegacy(L, legacy(all(), 30.0f, G))); EXPECT_EQ(cache.wrapCount() - before, static_cast(0)); model.pop_front(); ++firstSeq; // at the cap: evict 1, add 1 model.push_back(lineText(204) + "e"); before = cache.wrapCount(); cache.sync(firstSeq, model.size(), narrow); const std::vector vis = filtered(hasE); AssembleConsoleLayout(L, cache, vis, getRaw, 30.0f, H, G, m); EXPECT_EQ(cache.wrapCount() - before, static_cast(1)); EXPECT_TRUE(sameAsLegacy(L, legacy(vis, 30.0f, G))); } // 6) Filtered / folded subsets wrap lazily; a window jump restarts; reset() forgets everything. { ConsoleWrapCache c2; ConsoleLayout L2; c2.sync(firstSeq, model.size(), key); const std::vector sub = {0, 5, 9}; AssembleConsoleLayout(L2, c2, sub, getRaw, 50.0f, H, G, m); EXPECT_EQ(c2.wrapCount(), static_cast(3)); EXPECT_TRUE(sameAsLegacy(L2, legacy(sub, 50.0f, G))); std::vector ten; for (int i = 0; i < 10; ++i) ten.push_back(i); AssembleConsoleLayout(L2, c2, ten, getRaw, 50.0f, H, G, m); EXPECT_EQ(c2.wrapCount(), static_cast(10)); // only the 7 newly visible lines EXPECT_TRUE(sameAsLegacy(L2, legacy(ten, 50.0f, G))); c2.sync(firstSeq + 5000, 10, key); // more evicted than cached EXPECT_EQ(c2.size(), static_cast(10)); before = c2.wrapCount(); AssembleConsoleLayout(L2, c2, ten, getRaw, 50.0f, H, G, m); EXPECT_EQ(c2.wrapCount() - before, static_cast(10)); EXPECT_TRUE(sameAsLegacy(L2, legacy(ten, 50.0f, G))); c2.reset(); EXPECT_FALSE(c2.matches(key)); EXPECT_EQ(c2.size(), static_cast(0)); } // 7) ConsoleModel::firstSeq advances only when a line leaves the FRONT of lines_ (cap eviction or // clear); a pending line dropped by the byte/line budget never entered lines_ and does not advance // it. Untouched by fold toggles. (cap 3: 0..4 ingested -> pending drops "0","1" before they reach // lines_, so lines_=[2,3,4] with zero front evictions -> firstSeq 0.) { ConsoleModel cm(3); for (int i = 0; i < 5; ++i) cm.ingest(std::to_string(i), ConsoleChannel::None); cm.drain(); EXPECT_EQ(cm.firstSeq(), static_cast(0)); // "2","3","4" land; nothing evicted from front cm.ingest("{", ConsoleChannel::JsonBrace, 2); cm.drain(); EXPECT_EQ(cm.firstSeq(), static_cast(1)); // "{" pushes over cap -> evict "2" from front cm.toggleCollapsed(2); EXPECT_EQ(cm.firstSeq(), static_cast(1)); cm.clear(); EXPECT_EQ(cm.firstSeq(), static_cast(4)); // + lines_.size() (3) on clear } } // ExtendFlatVisibleIndices (console_output_model.h): the incremental filtered visible set equals a full rescan. void testConsoleFlatVisibleIndices() { using dragonx::ui::ExtendFlatVisibleIndices; std::deque mdl; std::uint64_t first = 0; auto passes = [&mdl](int i) { return mdl[static_cast(i)].find('x') != std::string::npos; }; auto rescan = [&]() { std::vector v; for (int i = 0; i < static_cast(mdl.size()); ++i) if (passes(i)) v.push_back(i); return v; }; for (int i = 0; i < 20; ++i) mdl.push_back(i % 3 == 0 ? "x" + std::to_string(i) : std::string("y")); std::vector vis = rescan(); std::uint64_t oldFirst = first, oldEnd = first + mdl.size(); for (int k = 0; k < 4; ++k) { mdl.pop_front(); ++first; } // evict 4 (incl. matches 0 and 3) for (int i = 20; i < 25; ++i) mdl.push_back(i % 2 == 0 ? "x" : "y"); // append 5 EXPECT_TRUE(ExtendFlatVisibleIndices(vis, oldFirst, oldEnd, first, static_cast(mdl.size()), passes)); EXPECT_TRUE(vis == rescan()); const std::vector untouched = vis; // non-chaining window => refused EXPECT_FALSE(ExtendFlatVisibleIndices(vis, first, first + mdl.size(), first + mdl.size() + 1, 3, passes)); EXPECT_TRUE(vis == untouched); oldFirst = first; oldEnd = first + mdl.size(); // clear(): firstSeq jumps to old end first += mdl.size(); mdl.clear(); mdl.push_back("x"); mdl.push_back("y"); EXPECT_TRUE(ExtendFlatVisibleIndices(vis, oldFirst, oldEnd, first, static_cast(mdl.size()), passes)); EXPECT_TRUE(vis == rescan()); } // ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog). void testConsoleScrollController() { using dragonx::ui::ConsoleScrollController; // Starts pinned to the bottom; new lines while pinned are not counted as a backlog. { ConsoleScrollController s; EXPECT_TRUE(s.autoScroll()); s.onLinesAdded(5); EXPECT_EQ(s.newLines(), 0); } // Wheel-up detaches, starts the cooldown, and begins counting backlog lines. { ConsoleScrollController s; s.onUserScrolledUp(); EXPECT_FALSE(s.autoScroll()); s.onLinesAdded(3); s.onLinesAdded(2); EXPECT_EQ(s.newLines(), 5); // At-bottom is ignored until the cooldown elapses. s.considerReenable(true); EXPECT_FALSE(s.autoScroll()); s.tickCooldown(ConsoleScrollController::kScrollUpCooldownSeconds + 0.01f); // Elapsed, but only re-pins when actually at the bottom. s.considerReenable(false); EXPECT_FALSE(s.autoScroll()); s.considerReenable(true); EXPECT_TRUE(s.autoScroll()); EXPECT_EQ(s.newLines(), 0); // re-pinning clears the backlog } // Checkbox toggle: enabling clears the backlog, disabling preserves state. { ConsoleScrollController s; s.onUserScrolledUp(); s.onLinesAdded(4); EXPECT_EQ(s.newLines(), 4); s.setAutoScroll(true); EXPECT_TRUE(s.autoScroll()); EXPECT_EQ(s.newLines(), 0); s.setAutoScroll(false); EXPECT_FALSE(s.autoScroll()); } // Jump-to-bottom pill re-pins and clears the backlog. { ConsoleScrollController s; s.onUserScrolledUp(); s.onLinesAdded(9); s.jumpToBottom(); EXPECT_TRUE(s.autoScroll()); EXPECT_EQ(s.newLines(), 0); } } // ConsoleSelectionController — anchor/caret state, ordering, select-all, eviction shift. void testConsoleSelectionController() { using dragonx::ui::ConsoleSelectionController; using dragonx::ui::ConsoleTextPos; // Ordering helper. EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 2}, {1, 5})); EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 5}, {2, 0})); EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({3, 4}, {3, 4})); EXPECT_FALSE(ConsoleSelectionController::isBeforeOrEqual({2, 0}, {1, 9})); // Drag lifecycle: press starts a drag but is not yet a real selection. { ConsoleSelectionController sel; sel.beginDrag({2, 3}); EXPECT_TRUE(sel.dragging()); EXPECT_FALSE(sel.active()); sel.updateDrag({2, 3}); // no movement -> still not active EXPECT_FALSE(sel.active()); sel.updateDrag({4, 1}); // moved -> real selection EXPECT_TRUE(sel.active()); sel.endDrag({4, 1}); EXPECT_FALSE(sel.dragging()); EXPECT_TRUE(sel.active()); // Ordered range is anchor..caret regardless of drag direction. EXPECT_EQ(sel.rangeStart().line, 2); EXPECT_EQ(sel.rangeStart().col, 3); EXPECT_EQ(sel.rangeEnd().line, 4); EXPECT_EQ(sel.rangeEnd().col, 1); } // Upward drag: caret before anchor -> range is still ordered low..high. { ConsoleSelectionController sel; sel.beginDrag({5, 2}); sel.updateDrag({1, 0}); EXPECT_EQ(sel.rangeStart().line, 1); EXPECT_EQ(sel.rangeEnd().line, 5); } // Select-all + clear. { ConsoleSelectionController sel; sel.selectAll(0, 0); // empty buffer -> no-op EXPECT_FALSE(sel.active()); sel.selectAll(3, 7); // lines 0..2, last line 7 bytes EXPECT_TRUE(sel.active()); EXPECT_EQ(sel.rangeStart().line, 0); EXPECT_EQ(sel.rangeStart().col, 0); EXPECT_EQ(sel.rangeEnd().line, 2); EXPECT_EQ(sel.rangeEnd().col, 7); sel.clear(); EXPECT_FALSE(sel.active()); EXPECT_FALSE(sel.dragging()); } // Eviction shift: partial (top of selection clamped), full (dropped), and no-op. { ConsoleSelectionController sel; sel.selectAll(10, 4); // [0,0]..[9,4] sel.shiftForEviction(3); // 3 lines fell off the top EXPECT_TRUE(sel.active()); EXPECT_EQ(sel.rangeStart().line, 0); // was line 0 -> clamped to 0 EXPECT_EQ(sel.rangeStart().col, 0); EXPECT_EQ(sel.rangeEnd().line, 6); // 9 - 3 EXPECT_EQ(sel.rangeEnd().col, 4); sel.shiftForEviction(0); // no-op EXPECT_EQ(sel.rangeEnd().line, 6); sel.shiftForEviction(100); // whole selection evicted -> dropped EXPECT_FALSE(sel.active()); } // extract(): inactive -> "", active -> the joined selected text. { std::vector lines = {"hello", "world"}; auto get = [&lines](int i) -> const std::string& { return lines[i]; }; ConsoleSelectionController sel; EXPECT_EQ(sel.extract(2, get, {}), std::string("")); sel.beginDrag({0, 1}); sel.updateDrag({1, 3}); EXPECT_EQ(sel.extract(2, get, {}), std::string("ello\nwor")); // Filtered so only line 0 is visible -> selection clips to it. EXPECT_EQ(sel.extract(2, get, std::vector{0}), std::string("ello")); } } // ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap. void testConsoleModel() { using dragonx::ui::ConsoleModel; using dragonx::ui::ConsoleChannel; // Basic ingest -> drain: order + channels preserved, counts reported. { ConsoleModel m; m.ingest("one", ConsoleChannel::Command); m.ingest("two", ConsoleChannel::Info); m.ingest("three", ConsoleChannel::Error); auto dr = m.drain(); EXPECT_EQ(dr.added, static_cast(3)); EXPECT_EQ(dr.popped, static_cast(0)); EXPECT_EQ(m.size(), static_cast(3)); EXPECT_EQ(m[0].text, std::string("one")); EXPECT_TRUE(m[0].channel == ConsoleChannel::Command); EXPECT_TRUE(m[2].channel == ConsoleChannel::Error); EXPECT_EQ(m.back().text, std::string("three")); // Draining again with nothing pending is a no-op. auto empty = m.drain(); EXPECT_EQ(empty.added, static_cast(0)); EXPECT_EQ(empty.popped, static_cast(0)); EXPECT_EQ(m.size(), static_cast(3)); } // Line cap: front lines are evicted, popped is reported for selection adjustment. { ConsoleModel m(3); // cap = 3 for (int i = 0; i < 5; ++i) m.ingest(std::to_string(i), ConsoleChannel::None); auto dr = m.drain(); EXPECT_EQ(dr.added, static_cast(3)); // pending is capped at ingest: "0","1" never reach the deque EXPECT_EQ(dr.popped, static_cast(0)); EXPECT_EQ(dr.dropped, static_cast(2)); EXPECT_EQ(m.size(), static_cast(3)); EXPECT_EQ(m[0].text, std::string("2")); // "0" and "1" evicted EXPECT_EQ(m[2].text, std::string("4")); } // Pending byte budget keeps the newest lines; a single oversized line is never dropped. { ConsoleModel m(100, 10); for (int i = 0; i < 5; ++i) m.ingest("abcd", ConsoleChannel::None); auto dr = m.drain(); EXPECT_EQ(dr.added, static_cast(2)); EXPECT_EQ(dr.dropped, static_cast(3)); ConsoleModel big(100, 10); big.ingest(std::string(50, 'x'), ConsoleChannel::None); EXPECT_EQ(big.drain().added, static_cast(1)); // ingestBatch appends a block contiguously. std::vector blk(3); blk[0].text = "B0"; blk[1].text = "B1"; blk[2].text = "B2"; ConsoleModel bm; bm.ingestBatch(std::move(blk)); bm.drain(); EXPECT_EQ(bm.size(), static_cast(3)); EXPECT_EQ(bm[2].text, std::string("B2")); } // clear() drops the visible lines but not lines still queued for the next drain. { ConsoleModel m; m.ingest("a", ConsoleChannel::None); m.drain(); EXPECT_EQ(m.size(), static_cast(1)); m.ingest("b", ConsoleChannel::None); // queued, not yet visible m.clear(); EXPECT_EQ(m.size(), static_cast(0)); m.drain(); EXPECT_EQ(m.size(), static_cast(1)); // "b" survived the clear EXPECT_EQ(m[0].text, std::string("b")); } // Concurrency: many producer threads ingest while the main thread drains; no line lost. { ConsoleModel m(1000000); // large cap so nothing is evicted const int kThreads = 8, kPerThread = 2000; std::atomic go{false}; std::vector producers; for (int t = 0; t < kThreads; ++t) { producers.emplace_back([&m, &go, t]() { while (!go.load()) { /* spin until start */ } for (int i = 0; i < kPerThread; ++i) m.ingest("x", ConsoleChannel::None); }); } go.store(true); // Interleave drains on this thread while producers run. size_t drained = 0; while (drained < static_cast(kThreads * kPerThread)) { drained += m.drain().added; } for (auto& th : producers) th.join(); drained += m.drain().added; // final sweep for anything queued after the last drain EXPECT_EQ(drained, static_cast(kThreads * kPerThread)); EXPECT_EQ(m.size(), static_cast(kThreads * kPerThread)); } // JSON fold state: ingest carries a fold span; toggleCollapsed flips only openers. { ConsoleModel m; m.ingest("{", ConsoleChannel::JsonBrace, 3); m.ingest(" \"a\": 1", ConsoleChannel::JsonKey, 0); m.ingest("}", ConsoleChannel::JsonBrace, 0); m.drain(); EXPECT_EQ(m[0].foldSpan, 3); EXPECT_FALSE(m[0].collapsed); EXPECT_TRUE(m.toggleCollapsed(0)); // opener -> collapsed EXPECT_TRUE(m[0].collapsed); EXPECT_FALSE(m.toggleCollapsed(0)); // -> expanded EXPECT_FALSE(m[0].collapsed); EXPECT_FALSE(m.toggleCollapsed(1)); // not an opener -> no-op EXPECT_FALSE(m.toggleCollapsed(99)); // out of range -> no-op } } // Portfolio helpers — sum/query a labeled address group against per-address balances. void testPortfolioHelpers() { using dragonx::AddressInfo; using dragonx::data::SumPortfolioBalance; using dragonx::data::PortfolioEntryContains; using dragonx::data::PortfolioEntryAdd; using dragonx::data::PortfolioEntryRemove; std::vector wallet; auto mk = [](const char* addr, double bal, const char* type) { AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a; }; wallet.push_back(mk("R-t1", 1.5, "transparent")); wallet.push_back(mk("R-t2", 2.0, "transparent")); wallet.push_back(mk("zs-s1", 10.0, "shielded")); wallet.push_back(mk("zs-s2", 0.25, "shielded")); // Empty group sums to 0. EXPECT_NEAR(SumPortfolioBalance({}, wallet), 0.0, 1e-9); // A group of two known addresses. EXPECT_NEAR(SumPortfolioBalance({"R-t1", "zs-s1"}, wallet), 11.5, 1e-9); // Unknown addresses contribute 0 (not present in the wallet). EXPECT_NEAR(SumPortfolioBalance({"R-t2", "does-not-exist"}, wallet), 2.0, 1e-9); // All shielded. EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "zs-s2"}, wallet), 10.25, 1e-9); // Membership + add/remove semantics. std::vector group = {"R-t1"}; EXPECT_TRUE(PortfolioEntryContains(group, "R-t1")); EXPECT_FALSE(PortfolioEntryContains(group, "R-t2")); EXPECT_TRUE(PortfolioEntryAdd(group, "R-t2")); // added EXPECT_FALSE(PortfolioEntryAdd(group, "R-t2")); // already present -> no-op EXPECT_EQ(group.size(), static_cast(2)); EXPECT_TRUE(PortfolioEntryRemove(group, "R-t1")); // removed EXPECT_FALSE(PortfolioEntryRemove(group, "R-t1"));// gone -> no-op EXPECT_EQ(group.size(), static_cast(1)); EXPECT_EQ(group[0], std::string("R-t2")); } void testPortfolioScopeVisibility() { using E = dragonx::config::Settings::PortfolioEntry; E global; // scope "" E stable; stable.scope = "w:aaaa"; E legacy; legacy.scope = std::string(64, 'a'); // an old address-set hash EXPECT_TRUE(global.visibleInWallet("w:aaaa")); EXPECT_TRUE(global.visibleInWallet("")); // global shows even before the wallet resolves EXPECT_TRUE(stable.visibleInWallet("w:aaaa")); EXPECT_FALSE(stable.visibleInWallet("w:bbbb")); // another wallet's group EXPECT_FALSE(stable.visibleInWallet("")); // wallet unresolved: hidden EXPECT_TRUE(legacy.visibleInWallet("w:aaaa")); // fail open until migrated EXPECT_FALSE(legacy.visibleInWallet("")); EXPECT_TRUE(legacy.isLegacyScope()); EXPECT_FALSE(stable.isLegacyScope()); EXPECT_FALSE(global.isLegacyScope()); // Migration rules. dragonx::config::Settings s; E byHash; byHash.label = "byHash"; byHash.scope = "H1"; E byAddr; byAddr.label = "byAddr"; byAddr.scope = "H0"; byAddr.addresses = {"zs-mine", "zs-other"}; E foreign; foreign.label = "foreign"; foreign.scope = "H9"; foreign.addresses = {"zs-theirs"}; E already; already.label = "already"; already.scope = "w:keep"; E glob; glob.label = "glob"; s.setPortfolioEntries({byHash, byAddr, foreign, already, glob}); const std::set mine = {"zs-mine", "zs-x"}; EXPECT_EQ(s.reattachLegacyPortfolioScopes("", "H1", mine, false), 0); // no id -> no-op EXPECT_EQ(s.reattachLegacyPortfolioScopes("w:me", "H1", mine, false), 2); // hash match + address overlap const auto& e = s.getPortfolioEntries(); EXPECT_EQ(e[0].scope, std::string("w:me")); EXPECT_EQ(e[1].scope, std::string("w:me")); EXPECT_EQ(e[2].scope, std::string("H9")); // ambiguous: left legacy (still visible, fail open) EXPECT_EQ(e[3].scope, std::string("w:keep")); // stable ids are never touched EXPECT_EQ(e[4].scope, std::string("")); // global stays global EXPECT_EQ(s.reattachLegacyPortfolioScopes("w:me", "H1", mine, false), 0); // idempotent EXPECT_EQ(s.reattachLegacyPortfolioScopes("w:me", "", {}, true), 1); // single wallet claims the rest EXPECT_EQ(s.getPortfolioEntries()[2].scope, std::string("w:me")); // Round trip: the stable scope survives save/load and an address-set change cannot orphan it. const fs::path dir = fs::temp_directory_path() / "dragonx_portfolio_scope_test"; fs::create_directories(dir); const std::string p = (dir / "settings.json").string(); EXPECT_TRUE(s.save(p)); dragonx::config::Settings s2; EXPECT_TRUE(s2.load(p)); EXPECT_EQ(s2.getPortfolioEntries().size(), static_cast(5)); EXPECT_EQ(s2.getPortfolioEntries()[0].scope, std::string("w:me")); std::error_code ec; fs::remove_all(dir, ec); } // Settings::load must tolerate wrongly-typed values without quarantining the whole file. void testSettingsLoadTypeTolerance() { namespace fs = std::filesystem; const fs::path dir = fs::temp_directory_path() / "dragonx_settings_type_tolerance_test"; std::error_code ec; fs::remove_all(dir, ec); fs::create_directories(dir, ec); const std::string p = (dir / "settings.json").string(); auto writeAndLoad = [&](const std::string& json, dragonx::config::Settings& s) { { std::ofstream o(p, std::ios::binary | std::ios::trunc); o << json; } return s.load(p); }; auto noQuarantine = [&]() { for (const auto& de : fs::directory_iterator(dir, ec)) if (de.path().filename().string().find(".corrupt-") != std::string::npos) return false; return true; }; for (const char* bad : {"\"x\"", "null", "5", "0.01", "true"}) { dragonx::config::Settings s; const std::string js = std::string("{\"ui_opacity\":") + bad + ",\"chat_density\":1}"; EXPECT_TRUE(writeAndLoad(js, s)); EXPECT_TRUE(noQuarantine()); EXPECT_TRUE(s.getUIOpacity() >= 0.3f && s.getUIOpacity() <= 1.0f); } { dragonx::config::Settings s; EXPECT_TRUE(writeAndLoad( "{\"blocked_chat_convs\":[{\"cid\":\"abc\",\"name\":7}]," "\"portfolio_entries\":[{\"label\":\"L\",\"scope\":7}]}", s)); EXPECT_TRUE(noQuarantine()); EXPECT_EQ(s.blockedChatConversations().size(), static_cast(1)); EXPECT_EQ(s.getPortfolioEntries().size(), static_cast(1)); EXPECT_EQ(s.getPortfolioEntries()[0].scope, std::string()); } { dragonx::config::Settings s; EXPECT_TRUE(writeAndLoad("{\"reduced_transparency\":true}", s)); EXPECT_NEAR(s.getUIOpacity(), 1.0f, 1e-6f); } fs::remove_all(dir, ec); } // Market price-series math (data/market_series.h) — minute-resampling + time-window bucketing // that feed the price chart and the portfolio-group sparklines. void testMarketSeries() { using dragonx::MarketInfo; using dragonx::data::resampleHistory; using dragonx::data::bucketBySeconds; using dragonx::data::sparklineSeries; using dragonx::data::chartSeries; // resampleHistory: interval 0 (minute) returns the input untouched. std::vector minute = {1.0, 2.0, 3.0, 4.0}; EXPECT_EQ(resampleHistory(minute, 0).size(), static_cast(4)); EXPECT_NEAR(resampleHistory(minute, 0)[3], 4.0, 1e-9); // interval 1 (hour = 60 minute-samples): 120 samples -> 2 averaged points. std::vector longHist(120, 0.0); for (int i = 0; i < 120; i++) longHist[i] = (i < 60) ? 10.0 : 20.0; auto hourly = resampleHistory(longHist, 1); EXPECT_EQ(hourly.size(), static_cast(2)); EXPECT_NEAR(hourly[0], 10.0, 1e-9); EXPECT_NEAR(hourly[1], 20.0, 1e-9); // A trailing partial block still averages (61 samples -> 2 points). EXPECT_EQ(resampleHistory(std::vector(61, 5.0), 1).size(), static_cast(2)); // Empty input -> empty output. EXPECT_TRUE(resampleHistory({}, 2).empty()); // bucketBySeconds: guards + averaging within fixed windows. EXPECT_TRUE(bucketBySeconds({}, 3600).empty()); EXPECT_TRUE(bucketBySeconds({{0, 1.0}}, 0).empty()); std::vector> s = { {0, 10.0}, {1000, 20.0}, // bucket 0 (< 3600s) -> avg 15 {3600, 40.0}, {7000, 60.0}, // bucket 1 -> avg 50 }; auto b = bucketBySeconds(s, 3600); EXPECT_EQ(b.size(), static_cast(2)); EXPECT_NEAR(b[0], 15.0, 1e-9); EXPECT_NEAR(b[1], 50.0, 1e-9); // sparklineSeries: hour interval buckets the intraday series... MarketInfo m; m.price_history = {1.0, 2.0, 3.0}; m.price_chart_intraday = { {0, 10.0}, {100, 12.0}, {3600, 20.0}, {3700, 22.0} }; // 2 hourly buckets EXPECT_EQ(sparklineSeries(m, 1).size(), static_cast(2)); // ...interval 0 (minute) always uses the live buffer... EXPECT_EQ(sparklineSeries(m, 0).size(), static_cast(3)); // ...and with no historical series, an interval falls back to the resampled live buffer. MarketInfo bare; bare.price_history = std::vector(120, 7.0); EXPECT_EQ(sparklineSeries(bare, 1).size(), static_cast(2)); // chartSeries: the 1H window keeps only samples within the last hour of `now`. std::time_t now = 100000; MarketInfo cm; cm.price_chart_intraday = { {now - 7200, 1.0}, // 2h ago -> excluded from 1H {now - 1800, 2.0}, // 30m ago -> included {now - 60, 3.0}, // 1m ago -> included }; auto oneH = chartSeries(cm, 1, now); EXPECT_EQ(oneH.size(), static_cast(2)); EXPECT_NEAR(oneH[0].second, 2.0, 1e-9); // Live (interval 0) synthesizes timestamps from the minute buffer, newest at `now`. MarketInfo lm; lm.price_history = {1.0, 2.0, 3.0}; auto live = chartSeries(lm, 0, now); EXPECT_EQ(live.size(), static_cast(3)); EXPECT_EQ(static_cast(live.back().first), static_cast(now)); EXPECT_NEAR(live.back().second, 3.0, 1e-9); } // Per-exchange candle adapters (data/exchange_candles.h) — the URL builder + the Ourbit (array) / // NonKYC (UDF bars) parsers, verified against real captured API responses, plus the chartSeries // switch that draws the per-exchange series when it's active. void testExchangeCandles() { using dragonx::data::buildExchangeCandleUrl; using dragonx::data::parseExchangeCandles; using dragonx::data::hasExchangeCandleAdapter; using dragonx::data::CandleRange; // --- URL builder (deterministic given `now`) --- EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Intraday, 1000) == "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=5m&limit=576"); EXPECT_TRUE(buildExchangeCandleUrl("ourbit", "DRGX", "USDT", CandleRange::Daily, 1000) == "https://api.ourbit.com/api/v3/klines?symbol=DRGXUSDT&interval=1d&limit=365"); EXPECT_TRUE(buildExchangeCandleUrl("nonkyc_io", "DRGX", "USDT", CandleRange::Daily, 1000000000) == "https://api.nonkyc.io/api/v2/market/candles?symbol=DRGX_USDT&resolution=1440&from=968464000&to=1000000000"); EXPECT_TRUE(buildExchangeCandleUrl("binance", "DRGX", "USDT", CandleRange::Daily, 1000).empty()); EXPECT_TRUE(hasExchangeCandleAdapter("ourbit") && hasExchangeCandleAdapter("nonkyc_io")); EXPECT_TRUE(!hasExchangeCandleAdapter("kraken")); // --- Ourbit parse: array of arrays, close at index 4, prices as strings, time in ms --- auto o = parseExchangeCandles("ourbit", R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"],)" R"([1783814400000,"0.01395","0.01418","0.01364","0.01371","584276.8",1783900800000,"8126.07742"]])"); EXPECT_EQ(o.size(), static_cast(2)); EXPECT_NEAR(o[0].second, 0.01395, 1e-9); // close = idx 4 EXPECT_EQ(static_cast(o[0].first), 1783728000L); // ms -> seconds EXPECT_TRUE(o[0].first < o[1].first); // ascending // --- NonKYC parse: {"bars":[{time,close,...}]}, close numeric, time in ms --- auto n = parseExchangeCandles("nonkyc_io", R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":23550.357},)" R"({"time":1781395200000,"close":0.02555,"open":0.031091,"high":0.03296,"low":0.025035,"volume":17657.726}]})"); EXPECT_EQ(n.size(), static_cast(2)); EXPECT_NEAR(n[0].second, 0.031354, 1e-9); EXPECT_EQ(static_cast(n[0].first), 1781308800L); // --- robustness: empty / garbage / no-data / unmapped -> empty --- EXPECT_TRUE(parseExchangeCandles("ourbit", "").empty()); EXPECT_TRUE(parseExchangeCandles("ourbit", "not json").empty()); EXPECT_TRUE(parseExchangeCandles("nonkyc_io", R"({"bars":[],"meta":{"noData":true}})").empty()); EXPECT_TRUE(parseExchangeCandles("binance", "[[1,2,3,4,5]]").empty()); // --- chartSeries prefers the per-exchange series when active, else the aggregate --- using dragonx::MarketInfo; using dragonx::data::chartSeries; std::time_t now = 2000000000; MarketInfo m; // Aggregate daily (two points in the last 30d) vs a DISTINCT exchange daily series. m.price_chart_daily = { {now - 10 * 86400, 1.0}, {now - 1 * 86400, 1.1} }; m.exchange_chart_daily = { {now - 10 * 86400, 5.0}, {now - 1 * 86400, 5.5} }; m.exchange_chart_active = false; auto agg = chartSeries(m, 4, now); // 1M EXPECT_TRUE(!agg.empty() && agg.back().second < 2.0); // aggregate values m.exchange_chart_active = true; auto exch = chartSeries(m, 4, now); EXPECT_TRUE(!exch.empty() && exch.back().second > 4.0); // exchange values // --- OHLC parse: full open/high/low/close, both formats --- using dragonx::data::parseExchangeOHLC; auto oc = parseExchangeOHLC("ourbit", R"([[1783728000000,"0.01298","0.01406","0.01286","0.01395","1203855.69",1783814400000,"16132.16809"]])"); EXPECT_EQ(oc.size(), static_cast(1)); EXPECT_NEAR(oc[0].open, 0.01298, 1e-9); EXPECT_NEAR(oc[0].high, 0.01406, 1e-9); EXPECT_NEAR(oc[0].low, 0.01286, 1e-9); EXPECT_NEAR(oc[0].close, 0.01395, 1e-9); auto nc = parseExchangeOHLC("nonkyc_io", R"({"bars":[{"time":1781308800000,"close":0.031354,"open":0.032559,"high":0.034999,"low":0.02804,"volume":1}]})"); EXPECT_EQ(nc.size(), static_cast(1)); EXPECT_NEAR(nc[0].high, 0.034999, 1e-9); EXPECT_NEAR(nc[0].low, 0.02804, 1e-9); // --- bucketOHLC: two 5-min candles in the same hour -> one hourly candle (o=first, h=max, l=min, c=last) --- using dragonx::data::bucketOHLC; using dragonx::data::Candle; std::vector fine = { {3600, 10.0, 12.0, 9.0, 11.0}, // hour bucket 1 {3900, 11.0, 15.0, 8.0, 14.0}, // same hour bucket 1 {7200, 20.0, 21.0, 19.0, 20.5}, // hour bucket 2 }; auto hourly = bucketOHLC(fine, 3600); EXPECT_EQ(hourly.size(), static_cast(2)); EXPECT_NEAR(hourly[0].open, 10.0, 1e-9); // first open EXPECT_NEAR(hourly[0].high, 15.0, 1e-9); // max high EXPECT_NEAR(hourly[0].low, 8.0, 1e-9); // min low EXPECT_NEAR(hourly[0].close, 14.0, 1e-9); // last close EXPECT_TRUE(bucketOHLC({}, 3600).empty()); // --- chartCandles: candles only when the per-exchange series is active --- using dragonx::data::chartCandles; MarketInfo cm; cm.exchange_ohlc_daily = { {now - 3 * 86400, 1, 1.2, 0.9, 1.1}, {now - 1 * 86400, 1.1, 1.3, 1.0, 1.25} }; cm.exchange_chart_active = false; EXPECT_TRUE(chartCandles(cm, 4, now).empty()); // inactive -> line (no candles) cm.exchange_chart_active = true; EXPECT_EQ(chartCandles(cm, 4, now).size(), static_cast(2)); // 1M daily EXPECT_TRUE(chartCandles(cm, 0, now).empty()); // Live -> line } // Regression: the Market-tab portfolio (and other consumers) read the combined // WalletState::addresses view, which the full-node refresh path builds from the // authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild // was never called, so `addresses` stayed empty and every portfolio group summed // to 0 and showed no addresses to pick. Guard that the rebuilt view carries the // z/t union with balances, so SumPortfolioBalance resolves right-click-added // addresses. void testWalletStateAddressListRebuild() { using dragonx::AddressInfo; using dragonx::data::SumPortfolioBalance; dragonx::WalletState state; auto mk = [](const char* addr, double bal, const char* type) { AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a; }; state.z_addresses.push_back(mk("zs-s1", 10.0, "shielded")); state.z_addresses.push_back(mk("zs-s2", 0.25, "shielded")); state.t_addresses.push_back(mk("R-t1", 1.5, "transparent")); // Before the rebuild the combined view is empty -> the reported bug: a portfolio // group of real addresses sums to 0 because none are found in state.addresses. EXPECT_EQ(state.addresses.size(), static_cast(0)); EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 0.0, 1e-9); // After the rebuild the combined view is the z/t union with balances. state.rebuildAddressList(); EXPECT_EQ(state.addresses.size(), static_cast(3)); // z entries come first, then t entries (rebuild order). EXPECT_EQ(state.addresses.front().address, std::string("zs-s1")); EXPECT_EQ(state.addresses.back().address, std::string("R-t1")); // A group spanning both pools now resolves its balances. EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 11.5, 1e-9); // In-place z/t balance mutation (pending-send delta) is reflected on the next rebuild. state.z_addresses[0].balance = 4.0; state.rebuildAddressList(); EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 5.5, 1e-9); } // ComputeConsoleFoldSpans — brace/bracket matching over pretty-printed JSON lines. void testConsoleFoldSpans() { using dragonx::ui::ComputeConsoleFoldSpans; // Nested object: outer '{' spans the whole block; inner "obj": { spans its two-line body. std::vector obj = { "{", // 0 -> closer at 6, span 6 " \"a\": 1,", // 1 " \"obj\": {", // 2 -> closer at 4, span 2 " \"b\": 2", // 3 " },", // 4 (closer for 2) " \"c\": 3", // 5 "}" // 6 (closer for 0) }; auto spans = ComputeConsoleFoldSpans(obj); EXPECT_EQ(spans.size(), static_cast(7)); EXPECT_EQ(spans[0], 6); EXPECT_EQ(spans[1], 0); EXPECT_EQ(spans[2], 2); EXPECT_EQ(spans[3], 0); EXPECT_EQ(spans[4], 0); // a closer is not an opener EXPECT_EQ(spans[5], 0); EXPECT_EQ(spans[6], 0); // Array opener '['. std::vector arr = { "\"outputs\": [", " 1,", " 2", "]" }; EXPECT_EQ(ComputeConsoleFoldSpans(arr)[0], 3); // Empty block (opener immediately followed by closer) is not worth folding (span < 2). std::vector empty = { "{", "}" }; EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0); // Plain non-JSON lines: no openers. std::vector plain = { "hello", "world" }; EXPECT_EQ(ComputeConsoleFoldSpans(plain)[0], 0); EXPECT_EQ(ComputeConsoleFoldSpans(plain)[1], 0); // A string value merely containing a brace does not open a block (last char isn't '{'). std::vector str = { "\"note\": \"a { brace\",", "\"x\": 1" }; EXPECT_EQ(ComputeConsoleFoldSpans(str)[0], 0); } 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); // The daemon-error line prints once per error, not on every poll (it flooded one line per frame). using dragonx::ui::ShouldPrintDaemonErrorLine; EXPECT_TRUE(ShouldPrintDaemonErrorLine(false, true, "bind failed", "")); // entering Error EXPECT_FALSE(ShouldPrintDaemonErrorLine(true, true, "bind failed", "bind failed")); // still the same error EXPECT_TRUE(ShouldPrintDaemonErrorLine(true, true, "binary missing", "bind failed")); // reason changed EXPECT_TRUE(ShouldPrintDaemonErrorLine(false, true, "bind failed", "bind failed")); // a new episode EXPECT_FALSE(ShouldPrintDaemonErrorLine(true, false, "bind failed", "")); // not in Error EXPECT_TRUE(ShouldPrintDaemonErrorLine(true, true, "exit code 1", "exit code 1", true)); // crashed again 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)); // The output filter keys off the line's semantic ConsoleChannel (not a color / prefix). using CC = dragonx::ui::ConsoleChannel; // {text, daemonMessagesEnabled, errorsOnly, rpcTraceEnabled, appMessagesEnabled} dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, true}; EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", CC::Error, filter)); // substring "error" EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Daemon line", CC::Daemon, filter)); // no "error" substring filter.text.clear(); // Daemon/Xmrig channels hidden while daemonMessagesEnabled=false. EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter)); EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("hashrate 1000", CC::Xmrig, filter)); // Rpc channel hidden while rpcTraceEnabled=false, shown once enabled. EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter)); filter.rpcTraceEnabled = true; EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter)); filter.daemonMessagesEnabled = true; // Errors-only keeps only the Error channel. filter.errorsOnly = true; EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", CC::Error, filter)); EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", CC::Info, filter)); EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter)); // App channel is toggled by appMessagesEnabled; other channels are unaffected by it. filter.errorsOnly = false; filter.appMessagesEnabled = false; EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter)); EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("trace", CC::Rpc, filter)); filter.appMessagesEnabled = true; EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, 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"}, "")); // resolveMiningUserAddress: the configured payout address is the xmrig "user" // (where rewards go) and must win over the wallet's own addresses. EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"), std::string("zs-payout")); // explicit payout wins EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"), std::string("zs-own")); // unset -> own shielded EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"), std::string("zs-own")); // "x" placeholder counts as unset EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"), std::string("R-own")); // no shielded -> transparent EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""), std::string("")); // nothing anywhere -> caller errors 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")); // An explicitly-quoted empty argument must be preserved (not dropped), otherwise // later positional RPC params silently shift by one (F14-1). auto emptyArgs = dragonx::ui::ParseConsoleCommandArgs("walletpassphrase \"\" 60"); EXPECT_EQ(emptyArgs.size(), static_cast(3)); EXPECT_EQ(emptyArgs[1], std::string("")); EXPECT_EQ(emptyArgs[2], std::string("60")); 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); // Quoted numeric-looking args must stay strings (not coerced to numbers). auto qcall = dragonx::ui::BuildConsoleRpcCall("setaccount \"123\" \"456.7\""); EXPECT_TRUE(qcall.valid); EXPECT_EQ(qcall.params.size(), static_cast(2)); EXPECT_TRUE(qcall.params[0].is_string()); EXPECT_EQ(qcall.params[0].get(), std::string("123")); EXPECT_TRUE(qcall.params[1].is_string()); // Bare finite numbers become numbers; non-finite / non-whole-token tokens fall back to strings // (no std::stoll truncation of "1e999" to 1, no inf). auto ncall = dragonx::ui::BuildConsoleRpcCall("foo 42 3.5 1e999 123abc"); EXPECT_TRUE(ncall.params[0].is_number_integer()); EXPECT_EQ(ncall.params[0].get(), 42LL); EXPECT_TRUE(ncall.params[1].is_number_float()); EXPECT_TRUE(ncall.params[2].is_string()); EXPECT_EQ(ncall.params[2].get(), std::string("1e999")); EXPECT_TRUE(ncall.params[3].is_string()); 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")); // C202: getline parity + backward tail cap. { using dragonx::ui::FormatConsoleRpcResultLines; EXPECT_EQ(FormatConsoleRpcResultLines("", false).size(), static_cast(0)); EXPECT_EQ(FormatConsoleRpcResultLines("\n", false).size(), static_cast(1)); EXPECT_EQ(FormatConsoleRpcResultLines("a\n", false).size(), static_cast(1)); EXPECT_EQ(FormatConsoleRpcResultLines("a\n\n", false).size(), static_cast(2)); EXPECT_EQ(FormatConsoleRpcResultLines("a\n\nb", false).size(), static_cast(3)); auto tail = FormatConsoleRpcResultLines("l1\nl2\nl3\nl4\n", false, 2); EXPECT_EQ(tail.size(), static_cast(2)); EXPECT_EQ(tail[0].text, std::string("l3")); EXPECT_EQ(tail[1].text, std::string("l4")); EXPECT_EQ(FormatConsoleRpcResultLines("l1\nl2", false, 2).size(), static_cast(2)); EXPECT_EQ(FormatConsoleRpcResultLines("l1\nl2", false, 5).size(), static_cast(2)); auto one = FormatConsoleRpcResultLines("l1\nl2\nl3", false, 1); EXPECT_EQ(one.size(), static_cast(1)); EXPECT_EQ(one[0].text, std::string("l3")); // JSON classification keys off the whole result even when the head is cut. auto jt = FormatConsoleRpcResultLines("{\n \"a\": 1,\n \"b\": 2\n}", false, 2); EXPECT_EQ(jt[0].role, dragonx::ui::ConsoleResultLineRole::JsonKey); auto rs = dragonx::ui::ComputeConsoleResultFoldSpans( FormatConsoleRpcResultLines("{\n \"a\": 1\n}", false)); EXPECT_EQ(rs[0], 2); } } void testMiningThreadCommit() { using dragonx::services::MiningThreadCommit; using dragonx::services::effectivePoolThreads; int out = 0; // Stepper: three clicks inside the debounce window -> one commit, with the last value. { MiningThreadCommit c; c.select(5, 10.0); c.select(6, 10.1); c.select(7, 10.2); EXPECT_TRUE(c.pending()); EXPECT_FALSE(c.take(10.5, false, out)); // 0.3 s after the last click: not yet EXPECT_TRUE(c.take(10.75, false, out)); EXPECT_EQ(out, 7); EXPECT_FALSE(c.pending()); EXPECT_FALSE(c.take(11.0, false, out)); // consumed exactly once } // Drag: nothing commits while held, however long; the release commits the last tile at once. { MiningThreadCommit c; for (int i = 1; i <= 16; ++i) { c.select(i, 20.0 + i * 0.1); EXPECT_FALSE(c.take(20.0 + i * 0.1 + 0.9, true, out)); } EXPECT_FALSE(c.take(40.0, true, out)); // still held 20 s later c.release(40.1); EXPECT_TRUE(c.take(40.1, false, out)); EXPECT_EQ(out, 16); } // Plain click in real frame order: the tab's render selects (button down); on the next frame the App's // tick sees the button up, calls release() and then take(): it commits on that frame, not 0.5 s later. { MiningThreadCommit c; c.select(4, 30.0); c.release(30.016); EXPECT_TRUE(c.take(30.016, false, out)); EXPECT_EQ(out, 4); } // release() with nothing pending changes nothing. { MiningThreadCommit c; c.release(1.0); EXPECT_FALSE(c.pending()); EXPECT_FALSE(c.take(2.0, false, out)); } // A new pick during the wait pushes the deadline out; cancel() drops it. { MiningThreadCommit c; c.select(2, 50.0); c.select(9, 50.4); EXPECT_FALSE(c.take(50.6, false, out)); c.cancel(); EXPECT_FALSE(c.pending()); EXPECT_FALSE(c.take(60.0, false, out)); } // Which pool count idle scaling compares with. EXPECT_EQ(effectivePoolThreads(8, 4, 2), 8); // a queued restart's target beats the stale values EXPECT_EQ(effectivePoolThreads(0, 4, 2), 4); EXPECT_EQ(effectivePoolThreads(0, 0, 2), 2); EXPECT_EQ(effectivePoolThreads(0, 0, 0), 0); } void testMiningEarningsCore() { using dragonx::TransactionInfo; using namespace dragonx::ui; auto mk = [](const char* type, double amt, std::int64_t ts, const char* memo, int conf, const char* txid) { TransactionInfo t; t.type = type; t.amount = amt; t.timestamp = ts; t.memo = memo; t.confirmations = conf; t.txid = txid; return t; }; const std::int64_t today = 1000000, yest = today - 86400; std::vector txs = { mk("mined", 3.0, today + 10, "", 150, "a"), mk("receive", 2.0, today + 5, "Mining Pool payout #7", 3, "b"), mk("receive", 9.0, today + 5, "thanks", 3, "c"), // not mining mk("generate", -1.5, yest + 1, "", 20, "d"), // abs(amount) mk("immature", 1.0, yest - 50, "", 0, "e"), // older than yesterday mk("send", 4.0, today, "", 9, "f"), // not mining }; auto all = ComputeMiningEarnings(txs, 0, true, false, today, yest); EXPECT_EQ(all.allTimeCount, 4); EXPECT_NEAR(all.allTime, 7.5, 1e-9); EXPECT_EQ(all.todayCount, 2); EXPECT_NEAR(all.today, 5.0, 1e-9); EXPECT_EQ(all.yesterdayCount, 1); EXPECT_NEAR(all.yesterday, 1.5, 1e-9); EXPECT_EQ(all.recent.size(), static_cast(3)); // solo mode: a, d, e EXPECT_TRUE(all.recent[0].mature); EXPECT_FALSE(all.recent[1].mature); EXPECT_EQ(all.recent[0].txid, std::string("a")); EXPECT_NEAR(all.recent[1].amount, 1.5, 1e-12); auto pool = ComputeMiningEarnings(txs, 0, true, true, today, yest); EXPECT_EQ(pool.recent.size(), static_cast(1)); // pool mode: b only EXPECT_TRUE(pool.recent[0].isPoolPayout); EXPECT_EQ(ComputeMiningEarnings(txs, 1, true, false, today, yest).allTimeCount, 3); // Solo EXPECT_EQ(ComputeMiningEarnings(txs, 2, true, false, today, yest).allTimeCount, 1); // Pool EXPECT_EQ(ComputeMiningEarnings(txs, 1, false, false, today, yest).allTimeCount, 1); // no solo: 1 = Pool EXPECT_EQ(ComputeMiningEarnings(txs, 2, false, false, today, yest).allTimeCount, 4); // no solo: 2 = no skip std::vector many; for (int i = 0; i < 10; ++i) many.push_back(mk("mined", 1.0, today, "", 1, "m")); EXPECT_EQ(ComputeMiningEarnings(many, 0, true, false, today, yest).recent.size(), static_cast(4)); EXPECT_EQ(ComputeMiningEarnings({}, 0, true, false, today, yest).allTimeCount, 0); } void testMiningEarningsCache() { using dragonx::TransactionInfo; using namespace dragonx::ui; TransactionInfo t; t.type = "mined"; t.amount = 1.0; t.timestamp = 2000000; t.txid = "x"; std::vector txs = {t}; MiningEarningsCache cache; MiningEarningsKey k; k.txRevision = 7; k.txCount = txs.size(); k.todayStart = 1000000; k.soloSupported = true; EXPECT_NEAR(cache.get(k, txs, 0).allTime, 1.0, 1e-9); EXPECT_EQ(cache.computeCount(), 1); cache.get(k, txs, 500); cache.get(k, txs, 1999); // hits EXPECT_EQ(cache.computeCount(), 1); txs[0].amount = 5.0; // write WITHOUT a revision bump: EXPECT_NEAR(cache.get(k, txs, 1999).allTime, 1.0, 1e-9); // stale until the 2 s max age... EXPECT_NEAR(cache.get(k, txs, 2000).allTime, 5.0, 1e-9); // ...then recomputed int c = cache.computeCount(); EXPECT_EQ(c, 2); k.txRevision = 8; cache.get(k, txs, 2001); EXPECT_EQ(cache.computeCount(), ++c); k.filter = 1; cache.get(k, txs, 2002); EXPECT_EQ(cache.computeCount(), ++c); k.poolMode = true; cache.get(k, txs, 2003); EXPECT_EQ(cache.computeCount(), ++c); k.soloSupported = false; cache.get(k, txs, 2004); EXPECT_EQ(cache.computeCount(), ++c); k.todayStart += 86400; cache.get(k, txs, 2005); EXPECT_EQ(cache.computeCount(), ++c); k.txCount += 1; cache.get(k, txs, 2006); EXPECT_EQ(cache.computeCount(), ++c); cache.get(k, txs, 2007); EXPECT_EQ(cache.computeCount(), c); // hit EXPECT_TRUE(IntervalElapsed(100, 200, 1000)); // clock went backwards -> resample EXPECT_FALSE(IntervalElapsed(1199, 200, 1000)); EXPECT_TRUE(IntervalElapsed(1200, 200, 1000)); } // Local-midnight rollover with the tx list untouched (same tx_revision / count) and inside the 2 s max age: // LocalDayStartMemo recomputes the day start as soon as `now` changes, the day start is part of the memo key, // so "Today" empties into "Yesterday" at local midnight. Fixed POSIX TZ (UTC+3, no DST, no tzdata), restored. void testMiningEarningsMidnightRollover() { #ifndef _WIN32 using dragonx::TransactionInfo; using namespace dragonx::ui; const char* prev = std::getenv("TZ"); const bool hadTz = prev != nullptr; const std::string savedTz = hadTz ? prev : ""; setenv("TZ", "XXX-3", 1); dragonx::util::refreshTimezone(); // UTC+3: local midnight is 21:00 UTC const std::int64_t midnight = 86400LL * 20000 - 3 * 3600; // 2024-10-04 00:00:00 local LocalDayStartMemo day; EXPECT_EQ(day.get(midnight - 2), midnight - 86400); // 23:59:58 local: still the previous day EXPECT_EQ(day.get(midnight - 2), midnight - 86400); // same second: memo hit EXPECT_EQ(day.get(midnight - 1), midnight - 86400); EXPECT_EQ(day.get(midnight), midnight); // 00:00:00 local: rolled over EXPECT_EQ(day.get(midnight + 3599), midnight); TransactionInfo t; t.type = "mined"; t.amount = 2.0; t.timestamp = midnight - 600; t.txid = "m"; // 23:50 const std::vector txs = {t}; LocalDayStartMemo memo; MiningEarningsCache cache; MiningEarningsKey k; k.txRevision = 5; k.txCount = txs.size(); k.soloSupported = true; k.todayStart = memo.get(midnight - 1); const MiningEarningsTotals before = cache.get(k, txs, 10000); EXPECT_EQ(before.todayCount, 1); EXPECT_EQ(before.yesterdayCount, 0); k.todayStart = memo.get(midnight + 1); // the clock passes midnight, no tx change const MiningEarningsTotals after = cache.get(k, txs, 10500); // 0.5 s later: well inside the max age EXPECT_EQ(cache.computeCount(), 2); EXPECT_EQ(after.todayCount, 0); EXPECT_EQ(after.yesterdayCount, 1); EXPECT_NEAR(after.today, 0.0, 1e-9); EXPECT_NEAR(after.yesterday, 2.0, 1e-9); if (hadTz) setenv("TZ", savedTz.c_str(), 1); else unsetenv("TZ"); dragonx::util::refreshTimezone(); #endif } void testScrollEdgeFade() { using namespace dragonx::ui::material; EXPECT_FALSE(ComputeScrollEdgeFade(0.0f, 0.0f).any()); // not scrollable EXPECT_FALSE(ComputeScrollEdgeFade(1.0f, 0.0f).any()); // scrollY must be > 1 EXPECT_TRUE(ComputeScrollEdgeFade(100.0f, 100.0f).top); // at the bottom: top fade only EXPECT_FALSE(ComputeScrollEdgeFade(100.0f, 100.0f).bottom); EXPECT_TRUE(ComputeScrollEdgeFade(0.0f, 200.0f).bottom); // top of a scrollable list: bottom fade only EXPECT_FALSE(ComputeScrollEdgeFade(0.0f, 200.0f).top); EXPECT_FALSE(ComputeScrollEdgeFade(199.5f, 200.0f).bottom); // within 1 px of the bottom // Bit-equivalence with the former inline maths over a grid, and the early-out invariant. auto oldAlpha = [](float scrollY, float scrollMaxY, float y, float topEdge, float bottomEdge, float fz) { float alpha = 1.0f; if (scrollY > 1.0f) { float d = y - topEdge; if (d < fz) alpha = std::min(alpha, std::max(0.0f, d / fz)); } if (scrollMaxY > 0 && scrollY < scrollMaxY - 1.0f) { float d = bottomEdge - y; if (d < fz) alpha = std::min(alpha, std::max(0.0f, d / fz)); } return alpha; }; const float states[][2] = {{0, 0}, {0, 300}, {0.5f, 300}, {1.0f, 300}, {1.5f, 300}, {150, 300}, {298.9f, 300}, {299.5f, 300}, {300, 300}, {50, 0}}; for (const auto& s : states) for (float y = -20.0f; y <= 420.0f; y += 7.0f) { const ScrollEdgeFade f = ComputeScrollEdgeFade(s[0], s[1]); const float a = ScrollEdgeAlpha(f, y, 10.0f, 360.0f, 40.0f); EXPECT_NEAR(a, oldAlpha(s[0], s[1], y, 10.0f, 360.0f, 40.0f), 0.0); if (!f.any()) EXPECT_NEAR(a, 1.0, 0.0); } } void testAddressRowWindow() { using namespace dragonx::ui; auto w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 50.0f, 10); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 5); // rows 0-3 visible + guard 4 (its top is the clip edge) w = ComputeAddressRowWindow(175.0f, 260.0f, 100.0f, 50.0f, 10); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 5); // rows 1-3 visible + guards 0 and 4 w = ComputeAddressRowWindow(420.0f, 520.0f, 100.0f, 50.0f, 20); EXPECT_EQ(w.begin, 5); EXPECT_EQ(w.end, 10); // rows 6-8 visible + guards 5 and 9 w = ComputeAddressRowWindow(90.0f, 200.0f, 100.0f, 50.0f, 10); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 3); // list starts below the clip top w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 50.0f, 3); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 3); // clamped to the row count w = ComputeAddressRowWindow(900.0f, 1000.0f, 100.0f, 50.0f, 5); EXPECT_EQ(w.begin, 5); EXPECT_EQ(w.end, 5); // nothing visible: empty (IncludeItemsByIndex no-op) w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 50.0f, 0); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 0); w = ComputeAddressRowWindow(std::numeric_limits::quiet_NaN(), 300.0f, 100.0f, 50.0f, 7); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 7); // non-finite: fail open (every row) w = ComputeAddressRowWindow(100.0f, 300.0f, 100.0f, 0.0f, 4); EXPECT_EQ(w.begin, 0); EXPECT_EQ(w.end, 4); // degenerate pitch: every row // Row entry cursor = where ImGui::ItemSize left it after the previous row's InvisibleButton. EXPECT_NEAR(AddressRowEntryCursorY(0, 100.25f, 60.5f, 4.0f), 100.25, 0.0); EXPECT_NEAR(AddressRowEntryCursorY(1, 100.25f, 60.5f, 4.0f), 164.0, 0.0); // trunc(100.25+60.5+4) EXPECT_NEAR(AddressRowEntryCursorY(3, 100.25f, 60.5f, 4.0f), 285.0, 0.0); // trunc(221.25+60.5+4) } 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, 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(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")); // Mining-flagged addresses stay visible at 0 balance even with hide-zero on. dragonx::AddressInfo tMine; tMine.address = "R-miner"; tMine.label = "Payout"; tMine.balance = 0.0; tMine.type = "transparent"; std::vector mineInputs = { {&addresses[0], true, false, false, false, "Vault", "", -1}, // funded {&tMine, false, false, false, /*mining=*/true, "Payout", "", -1}, // 0-balance mining }; auto mineRows = dragonx::ui::BuildAddressListRows(mineInputs, "", /*hideZero=*/true, false); EXPECT_EQ(mineRows.size(), static_cast(2)); bool sawMiner = false; for (const auto& r : mineRows) if (r.info->address == std::string("R-miner")) sawMiner = true; EXPECT_TRUE(sawMiner); // Explicit sort order overrides starred: a non-favorite ordered row outranks a favorite. std::vector ordInputs = { {&addresses[0], true, false, false, false, "Vault", "", 0}, // ordered (0) {&addresses[2], false, false, true, false, "Favorite", "", -1}, // favorite, unordered }; auto ordRows = dragonx::ui::BuildAddressListRows(ordInputs, "", false, false); EXPECT_EQ(ordRows[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); // Trailing (favorite) button is inset by rowPadLeft (12) so it mirrors the left margin // instead of hugging the card edge: 310 - 38 - 12 = 260. EXPECT_NEAR(layout.favoriteButton.x, 260.0, 0.0001); EXPECT_NEAR(layout.visibilityButton.x, 218.0, 0.0001); EXPECT_NEAR(layout.contentRight, 214.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); } auto cacheOwner = std::make_unique(databasePath.string(), legacyPath.string()); ExplorerBlockCache& cache = *cacheOwner; EXPECT_TRUE(cache.ensureOpen()); EXPECT_EQ(cache.cachedBlockCount(), 2); auto range = cache.loadRange(9, 10); EXPECT_EQ(range.size(), static_cast(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); // A steady tip writes nothing: this check runs every frame the Explorer is open (it used to // rewrite both tip rows each time). const int changesBeforeSteadyTip = cache.totalChanges(); for (int i = 0; i < 100; ++i) EXPECT_FALSE(cache.prepareValidation(10, "hash-10").needed); EXPECT_EQ(cache.totalChanges(), changesBeforeSteadyTip); 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); } // Search: substring of hash (case-insensitive) and of height text; non-digit needles skip heights. { ExplorerBlockCache searchCache(databasePath.string(), legacyPath.string()); for (int h = 100; h < 103; ++h) { ExplorerBlockSummary b; b.height = h; b.hash = "abcdef" + std::to_string(h); b.tx_count = 1; EXPECT_TRUE(searchCache.storeBlock(b)); } auto byHash = searchCache.searchBlocks("ABCDEF", 10); EXPECT_EQ(byHash.size(), static_cast(3)); EXPECT_EQ(byHash.front().height, 102); EXPECT_EQ(searchCache.searchBlocks("101", 10).size(), static_cast(1)); EXPECT_EQ(searchCache.searchBlocks("abcdef", 2).size(), static_cast(2)); EXPECT_EQ(searchCache.searchBlocks("%", 10).size(), static_cast(0)); } // DB down (parent is a regular file): negative-cached open, rows spill to memory and stay searchable. { fs::path blocker = dir / "blocker"; { std::ofstream f(blocker); f << "x"; } ExplorerBlockCache down((blocker / "sub" / "explorer.sqlite").string(), std::string()); down.setOpenRetryInterval(std::chrono::milliseconds(10000)); for (int i = 0; i < 50; ++i) EXPECT_FALSE(down.ensureOpen()); EXPECT_EQ(down.openAttempts(), 1); ExplorerBlockSummary b; b.height = 777; b.hash = "deadbeef777"; b.tx_count = 2; EXPECT_FALSE(down.storeBlock(b)); EXPECT_EQ(down.spillCount(), static_cast(1)); EXPECT_EQ(down.loadRange(777, 777).size(), static_cast(1)); EXPECT_EQ(down.searchBlocks("DEADBE", 10).size(), static_cast(1)); EXPECT_EQ(down.searchBlocks("777", 10).size(), static_cast(1)); EXPECT_EQ(down.searchBlocks("nomatch", 10).size(), static_cast(0)); // Tip regression clears the spill; an advancing tip keeps it. down.prepareValidation(800, "tip-800"); down.prepareValidation(801, "tip-801"); EXPECT_EQ(down.spillCount(), static_cast(1)); down.prepareValidation(790, "tip-790"); EXPECT_EQ(down.spillCount(), static_cast(0)); for (int h = 1; h <= 600; ++h) { ExplorerBlockSummary s; s.height = h; s.hash = "h" + std::to_string(h); down.storeBlock(s); } EXPECT_EQ(down.spillCount(), static_cast(512)); EXPECT_EQ(down.loadRange(600, 600).size(), static_cast(1)); down.clearBlocks(); EXPECT_EQ(down.spillCount(), static_cast(0)); // A zero retry interval retries every call. ExplorerBlockCache eager((blocker / "sub" / "explorer.sqlite").string(), std::string()); eager.setOpenRetryInterval(std::chrono::milliseconds(0)); EXPECT_FALSE(eager.ensureOpen()); EXPECT_FALSE(eager.ensureOpen()); EXPECT_EQ(eager.openAttempts(), 2); } // storeBlocks batches into one savepoint; pruneTo keeps the highest rows. { ExplorerBlockCache batch((dir / "batch.sqlite").string(), std::string()); std::vector blocks; for (int h = 1; h <= 50; ++h) { ExplorerBlockSummary s; s.height = h; s.hash = "b" + std::to_string(h); blocks.push_back(s); } EXPECT_TRUE(batch.storeBlocks(blocks)); EXPECT_EQ(batch.cachedBlockCount(), 50); EXPECT_TRUE(batch.storeBlocks(blocks)); EXPECT_EQ(batch.cachedBlockCount(), 50); batch.pruneTo(20); EXPECT_EQ(batch.cachedBlockCount(), 20); EXPECT_EQ(batch.loadRange(1, 50).size(), static_cast(20)); EXPECT_EQ(batch.loadRange(1, 50).begin()->first, 31); } cacheOwner.reset(); // close the database first: Windows can't delete an open file removeTree(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 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 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(database)), std::istreambuf_iterator()); 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(1700000200)); EXPECT_EQ(loaded.transactions.size(), static_cast(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(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(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); } // Unencrypted-wallet fast path (BLAKE2b, no Argon2id): round-trip, reopen, key separation. { fs::path unencPath = dir / "transaction_history_unenc.sqlite"; std::uint64_t fastKeyId = 0; { TransactionHistoryCache cache(unencPath.string()); EXPECT_TRUE(cache.unlockUnencrypted(walletIdentity)); EXPECT_TRUE(cache.isUnlockedFor(walletIdentity)); fastKeyId = cache.keyId(); EXPECT_TRUE(fastKeyId != 0); EXPECT_TRUE(cache.replace(walletIdentity, 120, "tip-120", transactions, 1700000200)); auto loaded = cache.load(walletIdentity, 120, "tip-120"); EXPECT_TRUE(loaded.loaded); EXPECT_EQ(loaded.transactions.size(), static_cast(2)); } { TransactionHistoryCache reopened(unencPath.string()); EXPECT_TRUE(reopened.unlockUnencrypted(walletIdentity)); EXPECT_EQ(reopened.keyId(), fastKeyId); auto loaded = reopened.load(walletIdentity, 120, "tip-120"); EXPECT_TRUE(loaded.loaded); EXPECT_EQ(loaded.transactions[0].txid, std::string("tx-sensitive-send")); } { // Same DB and salt, Argon2 passphrase unlock: key must differ and the snapshot must fail closed. TransactionHistoryCache argon(unencPath.string()); EXPECT_TRUE(argon.unlockWithPassphrase(walletIdentity, passphrase)); EXPECT_TRUE(argon.keyId() != fastKeyId); auto crossLoad = argon.load(walletIdentity, 120, "tip-120"); EXPECT_FALSE(crossLoad.loaded); EXPECT_EQ(crossLoad.transactions.size(), static_cast(0)); } } removeTree(dir); } // Sent-sweep watermark persistence (SENT-HISTORY-SWEEP Part 1): the watermark + enrichment backlog // round-trip through the encrypted snapshot, and a missing / inconsistent one reads as "no watermark". void testSentSweepPersistence() { using dragonx::TransactionInfo; using dragonx::data::TransactionHistoryCache; using dragonx::data::SentSweepState; fs::path dir = makeTempDir(); fs::path dbPath = dir / "th.sqlite"; std::string id = "mainnet|R|zs"; std::string pass = "pw"; std::vector txs{ TransactionInfo{"tx1", "send", -1.0, 1700000000, 5, "zdst", "zsrc", "m"} }; { // valid watermark round-trips, backlog + backoff included TransactionHistoryCache cache(dbPath.string()); EXPECT_TRUE(cache.ensureOpen()); EXPECT_TRUE(cache.unlockWithPassphrase(id, pass)); SentSweepState sw; sw.height = 500; sw.anchorHeight = 380; sw.anchorHash = "ha380"; sw.backlog = {"bt1", "bt2"}; sw.fullFailures = 2; sw.fullRetryDelaySec = 3600; sw.daemonId = "v1.3.0|t"; sw.lastFullTime = 1699990000; sw.lastFullHeight = 420; EXPECT_TRUE(cache.replace(id, 500, "tip", txs, 1700000000, {}, sw)); auto loaded = cache.load(id, 500, "tip"); EXPECT_TRUE(loaded.loaded); EXPECT_TRUE(loaded.sentSweep.valid()); EXPECT_EQ(loaded.sentSweep.height, 500); EXPECT_EQ(loaded.sentSweep.anchorHeight, 380); EXPECT_EQ(loaded.sentSweep.anchorHash, std::string("ha380")); EXPECT_EQ(loaded.sentSweep.backlog.size(), static_cast(2)); EXPECT_EQ(loaded.sentSweep.fullFailures, 2); EXPECT_EQ(loaded.sentSweep.fullRetryDelaySec, 3600L); EXPECT_EQ(loaded.sentSweep.daemonId, std::string("v1.3.0|t")); EXPECT_EQ(loaded.sentSweep.lastFullTime, static_cast(1699990000)); EXPECT_EQ(loaded.sentSweep.lastFullHeight, 420); } { // no watermark passed -> reads invalid (W = -1), i.e. today's full-sweep behaviour TransactionHistoryCache cache(dbPath.string()); EXPECT_TRUE(cache.unlockWithPassphrase(id, pass)); EXPECT_TRUE(cache.replace(id, 500, "tip", txs, 1700000001)); auto loaded = cache.load(id, 500, "tip"); EXPECT_TRUE(loaded.loaded); EXPECT_FALSE(loaded.sentSweep.valid()); EXPECT_EQ(loaded.sentSweep.height, -1); } { // inconsistent watermarks are refused (W above tip, or A > W) TransactionHistoryCache cache(dbPath.string()); EXPECT_TRUE(cache.unlockWithPassphrase(id, pass)); SentSweepState aboveTip; aboveTip.height = 9999; aboveTip.anchorHeight = 100; aboveTip.anchorHash = "h"; EXPECT_TRUE(cache.replace(id, 500, "tip", txs, 1700000002, {}, aboveTip)); EXPECT_FALSE(cache.load(id, 500, "tip").sentSweep.valid()); SentSweepState anchorAbove; anchorAbove.height = 400; anchorAbove.anchorHeight = 450; anchorAbove.anchorHash = "h"; EXPECT_TRUE(cache.replace(id, 500, "tip", txs, 1700000003, {}, anchorAbove)); EXPECT_FALSE(cache.load(id, 500, "tip").sentSweep.valid()); } removeTree(dir); } // Sent-sweep pure policy helpers (anchor, margin, long-call threshold, windowed params). void testSentSweepPolicyHelpers() { using namespace dragonx::services; EXPECT_EQ(sentSweepMargin(false), kSentSweepReorgMarginBlocks); EXPECT_EQ(sentSweepMargin(true), kSentSweepReorgMarginBlocks + kSentSweepFirstLoadExtraBlocks); EXPECT_EQ(sentSweepAnchor(5000, false), 5000 - kSentSweepReorgMarginBlocks); EXPECT_EQ(sentSweepAnchor(50, false), 1); // clamped: W - margin < 1 EXPECT_EQ(sentSweepAnchor(5000, true), 5000 - (kSentSweepReorgMarginBlocks + kSentSweepFirstLoadExtraBlocks)); EXPECT_FALSE(sentSweepNeedsLongCall(1000, 999)); // tiny window EXPECT_FALSE(sentSweepNeedsLongCall(1000, -1)); // no watermark (full-sweep path) EXPECT_TRUE(sentSweepNeedsLongCall(10000, 10000 - kSentSweepLongCallBlocks - 1)); EXPECT_FALSE(sentSweepSafetyNetDue(1000, 0)); // no full sweep recorded yet EXPECT_FALSE(sentSweepSafetyNetDue(1000 + kSentSweepSafetyNetSec, 1000)); // exactly at the threshold EXPECT_TRUE(sentSweepSafetyNetDue(1000 + kSentSweepSafetyNetSec + 1, 1000)); // past it -> due EXPECT_FALSE(sentSweepSafetyNetDue(1100, 1000)); // recent full sweep EXPECT_EQ(sentSweepNextBandSize(2000, 0.0), 2000); // unmeasured -> unchanged EXPECT_EQ(sentSweepNextBandSize(2000, 1.0), 8000); // very fast -> +4x (capped) EXPECT_EQ(sentSweepNextBandSize(2000, 100000.0), 1000); // very slow -> -2x EXPECT_EQ(sentSweepNextBandSize(kSentSweepBandMaxBlocks, 1.0), kSentSweepBandMaxBlocks); // clamp max EXPECT_EQ(sentSweepNextBandSize(kSentSweepBandMinBlocks, 100000.0), kSentSweepBandMinBlocks); // clamp min auto windowed = sentSweepParams("z1", 2000, 1500, sentSweepMargin(true)); EXPECT_EQ(windowed.size(), static_cast(4)); EXPECT_EQ(windowed[3].get(), (2000 - 1500) + sentSweepMargin(true)); EXPECT_EQ(sentSweepParams("z1", 2000, -1).size(), static_cast(2)); // full sweep } // 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 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); removeTree(dir); } void testHistorySnapshotSignature() { using namespace dragonx::data; using dragonx::TransactionInfo; auto mk = [](const char* id, double amt, int conf, const char* memo = "") { return TransactionInfo{id, "receive", amt, 1700000000, conf, "zs-a", "", memo}; }; const std::unordered_set none; const std::unordered_map h{{"zs-a", 100}}; const std::vector a{mk("t1", 1.0, 3), mk("t2", 2.0, 40)}; const auto base = historySnapshotSignature(a, none, "w", 7, h, 500, "tip"); EXPECT_EQ(base.rows, static_cast(2)); // row order is irrelevant const std::vector reordered{a[1], a[0]}; const auto r = historySnapshotSignature(reordered, none, "w", 7, h, 500, "tip"); EXPECT_EQ(r.content, base.content); EXPECT_EQ(r.slow, base.slow); // confirmations inside a bucket move only the slow axis auto c7 = a; c7[0].confirmations = 7; const auto s7 = historySnapshotSignature(c7, none, "w", 7, h, 500, "tip"); EXPECT_EQ(s7.content, base.content); EXPECT_TRUE(s7.slow != base.slow); // crossing a UI threshold is a content change: <0|0, 0|1, 9|10, 99|100 auto contentAt = [&](int conf) { auto v = a; v[0].confirmations = conf; return historySnapshotSignature(v, none, "w", 7, h, 500, "tip").content; }; EXPECT_TRUE(contentAt(-1) != contentAt(0)); EXPECT_TRUE(contentAt(0) != contentAt(1)); EXPECT_TRUE(contentAt(9) != contentAt(10)); EXPECT_EQ(contentAt(10), contentAt(99)); EXPECT_TRUE(contentAt(99) != contentAt(100)); EXPECT_EQ(contentAt(100), contentAt(5000)); // every persisted field, the wallet hash and the key id are content auto m = a; m[0].memo = "x"; EXPECT_TRUE(historySnapshotSignature(m, none, "w", 7, h, 500, "tip").content != base.content); auto ty = a; ty[0].type = "mined"; EXPECT_TRUE(historySnapshotSignature(ty, none, "w", 7, h, 500, "tip").content != base.content); auto am = a; am[0].amount = 1.0000001; EXPECT_TRUE(historySnapshotSignature(am, none, "w", 7, h, 500, "tip").content != base.content); auto fa = a; fa[0].from_address = "zs-other"; EXPECT_TRUE(historySnapshotSignature(fa, none, "w", 7, h, 500, "tip").content != base.content); EXPECT_TRUE(historySnapshotSignature(a, none, "w2", 7, h, 500, "tip").content != base.content); EXPECT_TRUE(historySnapshotSignature(a, none, "w", 8, h, 500, "tip").content != base.content); // the tip only moves the slow axis const auto t = historySnapshotSignature(a, none, "w", 7, h, 501, "tip2"); EXPECT_EQ(t.content, base.content); EXPECT_TRUE(t.slow != base.slow); // pending sends are excluded auto withPending = a; withPending.push_back(mk("opid-1", -1.0, 0)); const auto p = historySnapshotSignature(withPending, {"opid-1"}, "w", 7, h, 500, "tip"); EXPECT_EQ(p.content, base.content); EXPECT_EQ(p.slow, base.slow); EXPECT_EQ(p.rows, static_cast(2)); // heights are slow; entries replace() would not persist are ignored auto h2 = h; h2["zs-a"] = 101; const auto hs = historySnapshotSignature(a, none, "w", 7, h2, 500, "tip"); EXPECT_EQ(hs.content, base.content); EXPECT_TRUE(hs.slow != base.slow); auto h3 = h; h3[""] = 5; h3["zs-neg"] = -1; EXPECT_EQ(historySnapshotSignature(a, none, "w", 7, h3, 500, "tip").slow, base.slow); } void testHistoryStorePolicy() { using namespace dragonx::data; using M = HistoryStoreMode; using A = HistoryStoreAction; const HistorySnapshotSig s1{1, 10, 5}, s2{2, 10, 5}, s1slow{1, 11, 5}; auto act = [](M mode, bool have, HistorySnapshotSig stored, HistorySnapshotSig current, double since) { HistoryStoreInput in; in.mode = mode; in.haveStored = have; in.stored = stored; in.current = current; in.sinceLastSubmitSec = since; return decideHistoryStore(in); }; EXPECT_TRUE(act(M::Coalesce, true, s1, s1, 1e9) == A::Skip); // nothing changed EXPECT_TRUE(act(M::Coalesce, true, s1, s2, 5.0) == A::Defer); // changed, too soon EXPECT_TRUE(act(M::Coalesce, true, s1, s2, kHistoryMinStoreIntervalSec) == A::Store); EXPECT_TRUE(act(M::Coalesce, false, s1, s1, 1e9) == A::Store); // nothing on disk yet EXPECT_TRUE(act(M::Coalesce, false, s1, s1, 3.0) == A::Defer); // retry is paced EXPECT_TRUE(act(M::Coalesce, true, s1, s1slow, 100.0) == A::Skip); // slow drift: not yet EXPECT_TRUE(act(M::Coalesce, true, s1, s1slow, kHistorySlowStoreIntervalSec) == A::Store); EXPECT_TRUE(act(M::Now, true, s1, s1, 0.0) == A::Skip); // clean: no write EXPECT_TRUE(act(M::Now, true, s1, s1slow, 0.0) == A::Store); // slow drift flushed EXPECT_TRUE(act(M::Now, true, s1, s2, 0.0) == A::Store); // ignores pacing EXPECT_TRUE(act(M::Now, false, s1, s1, 0.0) == A::Store); } // The background writer's path: a job carries its own key copy (a lockKey() after queueing cannot drop it), // the copy is wiped, a keyless job is refused, keyId() tracks the key across lock/unlock, and a late job for the // previous wallet keeps the salt of the wallet unlocked since. void testTransactionHistoryCacheBackgroundWrite() { using dragonx::TransactionInfo; using dragonx::data::TransactionHistoryCache; fs::path dir = makeTempDir(); const std::string dbPath = (dir / "transaction_history.sqlite").string(); const std::string idA = "mainnet|R-a|zs-a"; const std::string idB = "mainnet|R-a|zs-a|zs-b"; const std::string pass = "correct horse battery staple"; const std::vector txs{TransactionInfo{"tx1", "mined", 1.0, 1700000000, 10, "R-a", "", ""}}; TransactionHistoryCache cache(dbPath); EXPECT_EQ(cache.keyId(), static_cast(0)); EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass)); const std::uint64_t idOfPass = cache.keyId(); EXPECT_TRUE(idOfPass != 0); std::array scratch{}; EXPECT_FALSE(cache.captureKey(idB, scratch)); // not unlocked for B { // a keyless job is refused: it would replace the snapshot with one nobody can read TransactionHistoryCache::SnapshotJob keyless; keyless.walletIdentity = idA; keyless.transactions = txs; EXPECT_FALSE(cache.writeSnapshot(std::move(keyless))); EXPECT_EQ(cache.snapshotCount(), 0); } // A job captured before lockKey() still lands after it (flush-before-lock). TransactionHistoryCache::SnapshotJob job; job.walletIdentity = idA; EXPECT_TRUE(cache.captureKey(idA, job.key)); job.tipHeight = 100; job.tipHash = "tip"; job.transactions = txs; job.updatedAt = 1700000000; cache.lockKey(); EXPECT_FALSE(cache.isUnlockedFor(idA)); EXPECT_TRUE(cache.writeSnapshot(std::move(job))); EXPECT_TRUE(job.key == (std::array{})); // wiped EXPECT_EQ(cache.keyId(), idOfPass); // survives lockKey() EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass)); EXPECT_EQ(cache.keyId(), idOfPass); // same passphrase -> same id EXPECT_TRUE(cache.load(idA, 100, "tip").loaded); EXPECT_TRUE(cache.unlockWithPassphrase(idA, "another passphrase")); EXPECT_TRUE(cache.keyId() != idOfPass); // a passphrase change is visible // A late job for wallet A must not prune the salt of B, unlocked since. EXPECT_TRUE(cache.unlockWithPassphrase(idA, pass)); TransactionHistoryCache::SnapshotJob lateA; lateA.walletIdentity = idA; EXPECT_TRUE(cache.captureKey(idA, lateA.key)); lateA.tipHeight = 100; lateA.tipHash = "tip"; lateA.transactions = txs; lateA.updatedAt = 1700000001; EXPECT_TRUE(cache.unlockWithPassphrase(idB, pass)); EXPECT_TRUE(cache.writeSnapshot(std::move(lateA))); EXPECT_EQ(cache.snapshotCount(), 1); // A's row; B's salt row must survive EXPECT_TRUE(cache.replace(idB, 100, "tip-b", txs, 1700000002)); EXPECT_EQ(cache.snapshotCount(), 1); // B's own write prunes A { TransactionHistoryCache reopened(dbPath); EXPECT_TRUE(reopened.unlockWithPassphrase(idB, pass)); EXPECT_TRUE(reopened.load(idB, 100, "tip-b").loaded); // the salt B's key came from survived } removeTree(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 sendTxids; 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(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(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(1700000500)); EXPECT_EQ(lastTxBlock, 333); EXPECT_EQ(confirmedIds.count("rpc-refreshed-send"), static_cast(1)); removeTree(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(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(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(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(1)); EXPECT_EQ(g_liteBridgeRuntimeTrackedFreedValues[0], std::string("bridge-runtime-ok")); EXPECT_EQ(g_liteBridgeRuntimeFakeCallCount, 1); } // ---- image decode (C185 / C186) ---------------------------------------------------------------- unsigned char imgU8(int v) { return static_cast(v); } // GIF "literal" LZW: min code size 8, a clear code every 100 pixels so the code width stays 9 bits. std::vector imgTestLzw(const std::vector& idx) { std::vector out; std::uint32_t acc = 0; int nb = 0; auto put = [&](std::uint32_t code) { acc |= code << nb; nb += 9; while (nb >= 8) { out.push_back(imgU8((int)(acc & 0xFF))); acc >>= 8; nb -= 8; } }; put(256); // clear int run = 0; for (unsigned char p : idx) { if (run == 100) { put(256); run = 0; } put(p); ++run; } put(257); // end of information if (nb > 0) out.push_back(imgU8((int)(acc & 0xFF))); return out; } // n-frame GIF89a, full-canvas frames, 256-entry global palette; disposal[f] in 0..3, delayCs[f] in 1/100 s. std::vector imgTestGif(int w, int h, const std::vector& disposal, const std::vector& delayCs) { std::vector g = { 'G','I','F','8','9','a', imgU8(w & 255), imgU8(w >> 8), imgU8(h & 255), imgU8(h >> 8), 0xF7, 0, 0 }; for (int i = 0; i < 256; ++i) { g.push_back(imgU8(i)); g.push_back(imgU8(255 - i)); g.push_back(imgU8(i / 2)); } for (size_t f = 0; f < disposal.size(); ++f) { const int cs = delayCs[f]; const unsigned char gce[] = { 0x21, 0xF9, 0x04, imgU8(disposal[f] << 2), imgU8(cs & 255), imgU8(cs >> 8), 0, 0 }; g.insert(g.end(), std::begin(gce), std::end(gce)); const unsigned char desc[] = { 0x2C, 0, 0, 0, 0, imgU8(w & 255), imgU8(w >> 8), imgU8(h & 255), imgU8(h >> 8), 0 }; g.insert(g.end(), std::begin(desc), std::end(desc)); std::vector idx((size_t)w * h); for (size_t i = 0; i < idx.size(); ++i) idx[i] = imgU8((int)((i + f * 37) & 255)); const std::vector lzw = imgTestLzw(idx); g.push_back(8); // LZW minimum code size for (size_t o = 0; o < lzw.size(); o += 255) { const size_t n = std::min(255, lzw.size() - o); g.push_back(imgU8((int)n)); g.insert(g.end(), lzw.begin() + (std::ptrdiff_t)o, lzw.begin() + (std::ptrdiff_t)(o + n)); } g.push_back(0); // block terminator } g.push_back(0x3B); // trailer return g; } // 24-bit BMP; withPixels=false gives a header-only file (limit tests: nothing to allocate). std::vector imgTestBmp(int w, int h, bool withPixels) { const std::uint32_t rowBytes = (std::uint32_t)((w * 3 + 3) & ~3); const std::uint32_t dataBytes = withPixels ? rowBytes * (std::uint32_t)h : 0; std::vector b; auto le32 = [&](std::uint32_t v) { for (int i = 0; i < 4; ++i) b.push_back(imgU8((int)((v >> (8 * i)) & 255))); }; auto le16 = [&](unsigned v) { b.push_back(imgU8((int)(v & 255))); b.push_back(imgU8((int)(v >> 8))); }; b.push_back('B'); b.push_back('M'); le32(54 + dataBytes); le32(0); le32(54); le32(40); le32((std::uint32_t)w); le32((std::uint32_t)h); le16(1); le16(24); le32(0); le32(dataBytes); le32(2835); le32(2835); le32(0); le32(0); b.resize(54 + dataBytes, 0x80); return b; } void imgWriteFile(const fs::path& p, const std::vector& bytes) { std::ofstream o(p, std::ios::binary); o.write(reinterpret_cast(bytes.data()), (std::streamsize)bytes.size()); } void testImageDecodePolicy() { namespace util = dragonx::util; int dw = 0, dh = 0; util::FitImageDims(1000, 500, 250, &dw, &dh); EXPECT_EQ(dw, 250); EXPECT_EQ(dh, 125); util::FitImageDims(100, 50, 250, &dw, &dh); // already small: unchanged EXPECT_EQ(dw, 100); EXPECT_EQ(dh, 50); util::FitImageDims(5000, 100, 2048, &dw, &dh); // theme-art cap EXPECT_EQ(dw, 2048); EXPECT_EQ(dh, 40); // truncating arithmetic (40.96) const util::ImageLimits lim; EXPECT_TRUE(util::DimsWithinLimits(4000, 3000, false, lim)); // 12 MP photo EXPECT_TRUE(util::DimsWithinLimits(16320, 12240, false, lim)); // 200 MP phone mode still loads EXPECT_TRUE(util::DimsWithinLimits(30000, 4000, false, lim)); // wide panorama (side > 16384) still loads EXPECT_FALSE(util::DimsWithinLimits(20000, 20000, false, lim)); // 400 MP: rejected EXPECT_TRUE(util::DimsWithinLimits(3840, 2160, true, lim)); // 4K animation EXPECT_TRUE(util::DimsWithinLimits(8192, 4096, true, lim)); // exactly 32 Mi px EXPECT_FALSE(util::DimsWithinLimits(8193, 4096, true, lim)); EXPECT_FALSE(util::DimsWithinLimits(0, 5, false, lim)); const std::vector gif = imgTestGif(8, 4, {0}, {5}); int gw = 0, gh = 0; EXPECT_TRUE(util::ParseGifLogicalScreen(gif.data(), gif.size(), &gw, &gh)); EXPECT_EQ(gw, 8); EXPECT_EQ(gh, 4); EXPECT_FALSE(util::ParseGifLogicalScreen(gif.data(), 12, &gw, &gh)); // too short const unsigned char notGif[13] = { 'P','N','G',0,0,0,0,0,0,0,0,0,0 }; EXPECT_FALSE(util::ParseGifLogicalScreen(notGif, sizeof(notGif), &gw, &gh)); // Eviction: LRU first, recently used items protected, zero-byte items never chosen. std::vector items = { {0, 100, 1}, {1, 100, 5}, {2, 100, 9}, {3, 0, 0} }; std::vector ev = util::selectEvictions(items, 150, 9); EXPECT_EQ(ev.size(), (std::size_t)2); if (ev.size() == 2) { EXPECT_EQ(ev[0], (std::size_t)0); EXPECT_EQ(ev[1], (std::size_t)1); } ev = util::selectEvictions(items, 150, 5); // lastUse >= 5 protected EXPECT_EQ(ev.size(), (std::size_t)1); if (ev.size() == 1) EXPECT_EQ(ev[0], (std::size_t)0); EXPECT_TRUE(util::selectEvictions(items, 400, 9).empty()); // under budget: nothing } void testImageDecodeCore() { namespace util = dragonx::util; const util::ImageLimits lim; util::AnimFrames af; // 2-frame animation: frame count, per-frame delay (cs -> s), animated flag, source size. { auto g = imgTestGif(8, 4, {1, 1}, {5, 10}); EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.frames.size(), (std::size_t)2); EXPECT_EQ(af.delaysSec.size(), (std::size_t)2); if (af.delaysSec.size() == 2) { EXPECT_NEAR(af.delaysSec[0], 0.05, 1e-4); EXPECT_NEAR(af.delaysSec[1], 0.10, 1e-4); } EXPECT_TRUE(af.animated); EXPECT_EQ(af.srcW, 8); EXPECT_EQ(af.srcH, 4); if (!af.frames.empty()) EXPECT_EQ(af.frames[0].size(), (std::size_t)(8 * 4 * 4)); } // maxFrames truncation: streaming stops after 3 of 5 frames; the SOURCE still counts as animated. { auto g = imgTestGif(8, 4, {1, 1, 1, 1, 1}, {5, 10, 15, 20, 25}); EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 3, lim, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.frames.size(), (std::size_t)3); if (af.delaysSec.size() == 3) EXPECT_NEAR(af.delaysSec[2], 0.15, 1e-4); EXPECT_TRUE(af.animated); } // Disposal 3 on every frame: stb's own loop read before the heap buffer here. Run under ASan/valgrind. { auto g = imgTestGif(8, 4, {3, 3, 3, 3}, {5, 5, 5, 5}); EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.frames.size(), (std::size_t)4); } // Truncated GIF: keeps the frames decoded so far, never crashes. { auto g = imgTestGif(8, 4, {1, 1}, {5, 5}); g.resize(g.size() - 20); const util::ImageStatus st = util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af); EXPECT_TRUE(st == util::ImageStatus::Ok || st == util::ImageStatus::DecodeError); if (st == util::ImageStatus::Ok) EXPECT_TRUE(af.frames.size() >= 1 && af.frames.size() <= 2); } // Single-frame GIF above the still bound is downscaled (512x256 -> 256x128) and is not "animated". { auto g = imgTestGif(512, 256, {0}, {0}); EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.frames.size(), (std::size_t)1); EXPECT_EQ(af.w, 256); EXPECT_EQ(af.h, 128); EXPECT_FALSE(af.animated); } // Oversized GIF canvas (8000x8000 = 64 M px > 32 Mi px): rejected from the 13-byte header, nothing allocated. { auto g = imgTestGif(8, 4, {}, {}); // header + palette + trailer, no frames g[6] = imgU8(8000 & 255); g[7] = imgU8(8000 >> 8); g[8] = imgU8(8000 & 255); g[9] = imgU8(8000 >> 8); EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, nullptr, af) == util::ImageStatus::TooLarge); EXPECT_TRUE(af.frames.empty()); } // Stills: header-only BMP claiming 20000x20000 (400 MP) is rejected without a pixel buffer; a real 3x2 decodes. { auto big = imgTestBmp(20000, 20000, false); EXPECT_TRUE(util::DecodeAnimatedFromMemory(big.data(), big.size(), 1, lim, nullptr, af) == util::ImageStatus::TooLarge); auto ok = imgTestBmp(3, 2, true); EXPECT_TRUE(util::DecodeAnimatedFromMemory(ok.data(), ok.size(), 1, lim, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.frames.size(), (std::size_t)1); EXPECT_EQ(af.w, 3); EXPECT_EQ(af.h, 2); EXPECT_FALSE(af.animated); } // Theme-art cap (texture_loader): a >2048 still downscales to a 2048 long side with aspect kept; over-area is rejected. { util::ImageLimits tl; tl.stillMax = 2048; tl.animMax = 2048; auto wide = imgTestBmp(5000, 100, true); EXPECT_TRUE(util::DecodeAnimatedFromMemory(wide.data(), wide.size(), 1, tl, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.w, 2048); EXPECT_TRUE(std::abs(af.h - (int)(100.0 * 2048 / 5000)) <= 1); EXPECT_EQ(af.frames.size(), (std::size_t)1); auto huge = imgTestBmp(20000, 20000, false); EXPECT_TRUE(util::DecodeAnimatedFromMemory(huge.data(), huge.size(), 1, tl, nullptr, af) == util::ImageStatus::TooLarge); auto small = imgTestBmp(300, 200, true); // under the cap: untouched EXPECT_TRUE(util::DecodeAnimatedFromMemory(small.data(), small.size(), 1, tl, nullptr, af) == util::ImageStatus::Ok); EXPECT_EQ(af.w, 300); EXPECT_EQ(af.h, 200); } // Garbage and cancellation. { const unsigned char junk[16] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16 }; EXPECT_TRUE(util::DecodeAnimatedFromMemory(junk, sizeof(junk), 1, lim, nullptr, af) == util::ImageStatus::Unsupported); auto g = imgTestGif(8, 4, {1, 1, 1}, {5, 5, 5}); std::atomic cancelNow{true}; EXPECT_TRUE(util::DecodeAnimatedFromMemory(g.data(), g.size(), 300, lim, &cancelNow, af) == util::ImageStatus::Cancelled); EXPECT_TRUE(af.frames.empty()); } // File-size cap is applied BEFORE the read; back-compat wrappers still work. { const fs::path dir = makeTempDir(); const fs::path p = dir / "a.gif"; imgWriteFile(p, imgTestGif(8, 4, {1, 1}, {5, 5})); util::ImageLimits tiny = lim; tiny.maxFileBytes = 10; EXPECT_TRUE(util::LoadAnimatedRGBAEx(p.string().c_str(), 300, tiny, nullptr, af) == util::ImageStatus::TooLarge); EXPECT_TRUE(util::LoadAnimatedRGBAEx(p.string().c_str(), 300, lim, nullptr, af) == util::ImageStatus::Ok); EXPECT_TRUE(util::LoadAnimatedRGBA(p.string().c_str(), 300, af)); EXPECT_TRUE(util::IsAnimatedImageFile(p.string().c_str())); EXPECT_TRUE(util::LoadAnimatedRGBAEx((dir / "missing.gif").string().c_str(), 1, lim, nullptr, af) == util::ImageStatus::ReadError); removeTree(dir); } } void testImageDecodeJobs() { namespace util = dragonx::util; const fs::path dir = makeTempDir(); const fs::path p = dir / "anim.gif"; imgWriteFile(p, imgTestGif(8, 4, {1, 1, 1}, {5, 5, 5})); auto waitFor = [](const util::ImageDecodeJobPtr& j) { for (int i = 0; i < 500 && !j->finished(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(10)); return j->finished(); }; util::ImageDecodeLane closed(0); // a full lane never starts work EXPECT_TRUE(util::StartImageDecode(closed, p.string(), 300, util::ImageLimits{}) == nullptr); util::ImageDecodeLane lane(2); auto job = util::StartImageDecode(lane, p.string(), 300, util::ImageLimits{}); EXPECT_TRUE(job != nullptr); if (job) { EXPECT_TRUE(waitFor(job)); EXPECT_TRUE(job->ok()); EXPECT_EQ(job->result.frames.size(), (std::size_t)3); EXPECT_TRUE(job->result.animated); } EXPECT_EQ(lane.inFlight(), 0); // slot released before `done` is published // afterDecode runs on the worker only after a successful decode; its return value is outPath. auto j2 = util::StartImageDecode(lane, p.string(), 1, util::ImageLimits{}, [](const std::string& s) { return s + ".copy"; }); EXPECT_TRUE(j2 != nullptr && waitFor(j2)); if (j2) { EXPECT_TRUE(j2->ok()); EXPECT_EQ(j2->outPath, p.string() + ".copy"); } auto j3 = util::StartImageDecode(lane, p.string(), 1, util::ImageLimits{}, [](const std::string&) { return std::string(); }); EXPECT_TRUE(j3 != nullptr && waitFor(j3)); if (j3) { EXPECT_TRUE(j3->status == util::ImageStatus::PostFailed); EXPECT_FALSE(j3->ok()); } // Missing file -> ReadError (never a hang, never a crash); afterDecode is not called. bool called = false; auto j4 = util::StartImageDecode(lane, (dir / "nope.png").string(), 1, util::ImageLimits{}, [&called](const std::string& s) { called = true; return s; }); EXPECT_TRUE(j4 != nullptr && waitFor(j4)); if (j4) { EXPECT_FALSE(j4->ok()); EXPECT_TRUE(j4->status == util::ImageStatus::ReadError); } EXPECT_FALSE(called); // Cancelled right after start: finishes either way (won the race = Ok) and never leaks the slot. auto j5 = util::StartImageDecode(lane, p.string(), 300, util::ImageLimits{}); if (j5) { j5->cancel.store(true); EXPECT_TRUE(waitFor(j5)); EXPECT_TRUE(j5->ok() || j5->result.frames.empty()); } EXPECT_EQ(lane.inFlight(), 0); removeTree(dir); } 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(testTmpPath("obsidian-dragon-empty-themes").string()), 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; 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; 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( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; (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 (Base58Check, version 0x80, compressed; key = 1) -> "timport" { std::vector payload; EXPECT_TRUE(dragonx::util::decodeBase58Check("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn", payload)); auto c = openController(); const auto r = c->importKey("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"); 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( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; (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); } } // C30: flushOnExit() persists once on the quit path, is bounded when the save blocks, and obeys the guards. void testLiteWalletControllerFlushOnExit() { using namespace dragonx::wallet; const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const LiteConnectionSettings conn = defaultLiteConnectionSettings(); auto openSynced = [&]() { dragonx::test::g_liteFakeSyncBlock = false; auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; (void)c->createWallet(req); for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); return c; }; // (a) open + synced + idle -> exactly one save { auto c = openSynced(); EXPECT_TRUE(c->syncComplete()); const long before = dragonx::test::g_liteFakeSaveCount.load(); c->flushOnExit(std::chrono::milliseconds(500)); EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before + 1); } // (b) a blocked save never hangs the caller past the budget { auto c = openSynced(); dragonx::test::g_liteFakeBlockSave = true; const auto t0 = std::chrono::steady_clock::now(); c->flushOnExit(std::chrono::milliseconds(200)); const auto dt = std::chrono::steady_clock::now() - t0; EXPECT_TRUE(dt >= std::chrono::milliseconds(150)); EXPECT_TRUE(dt < std::chrono::milliseconds(1500)); dragonx::test::g_liteFakeBlockSave = false; // let the abandoned save thread finish std::this_thread::sleep_for(std::chrono::milliseconds(150)); } // (c) retired -> no save { auto c = openSynced(); c->retire("switch"); const long before = dragonx::test::g_liteFakeSaveCount.load(); c->flushOnExit(std::chrono::milliseconds(300)); EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), before); } // (d) wallet never opened -> no save { LiteWalletController c(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); const long before = dragonx::test::g_liteFakeSaveCount.load(); c.flushOnExit(std::chrono::milliseconds(300)); 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( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; (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()); // ...and marks it known: the wallet tabs wait for that (lite used to sit behind the loading // overlay until the 15 s "assume unencrypted" fallback). EXPECT_TRUE(state.encryption_state_known); LiteWalletAppRefreshModel syncOnly; syncOnly.hasSyncStatus = true; dragonx::WalletState untouched; applyLiteRefreshModelToWalletState(syncOnly, untouched); EXPECT_FALSE(untouched.encryption_state_known); } } // 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); } // " " 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. // W5-3: a create-time passphrase now actually encrypts (and locks) the new lite wallet, and it // unlocks with the same passphrase — previously the field was collected but ignored. void testLiteWalletControllerCreateEncryptsWithPassphrase() { using namespace dragonx::wallet; const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const LiteConnectionSettings conn = defaultLiteConnectionSettings(); dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; req.passphrase = "hunter2"; (void)c->createWallet(req); const auto s = c->encryptionStatus(); EXPECT_TRUE(s.ok); EXPECT_TRUE(s.encrypted); // the create-time passphrase encrypted the new wallet EXPECT_TRUE(s.locked); // encrypt locks immediately EXPECT_TRUE(c->unlockWallet("hunter2")); const auto s2 = c->encryptionStatus(); EXPECT_FALSE(s2.locked); } 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; 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(); } // C1/M1: the ASYNC lifecycle path (the one the Settings UI actually calls) must apply the // passphrase — encrypt a freshly created/restored wallet, unlock an existing encrypted one. // Previously the async path discarded the passphrase (encrypt/unlock lived only in the sync // wrappers), so a UI-created wallet stored its seed in PLAINTEXT and an encrypted wallet opened // but stayed locked. void testLiteWalletControllerAsyncAppliesPassphrase() { 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} }; conn.selectionMode = LiteServerSelectionMode::Sticky; conn.stickyServerUrl = "https://good.example"; 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(); }; // C1: async create with a passphrase encrypts (and locks) the new wallet; same passphrase unlocks. { dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = false; dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; 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()); const auto s = controller.encryptionStatus(); EXPECT_TRUE(s.encrypted); // async create ENCRYPTED the wallet (C1 fixed) EXPECT_TRUE(s.locked); // encrypt locks immediately EXPECT_TRUE(controller.unlockWallet("hunter2")); EXPECT_FALSE(controller.encryptionStatus().locked); } // C1: async restore with a passphrase likewise encrypts the restored wallet. { dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = false; dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; 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; req.passphrase = "hunter2"; EXPECT_TRUE(controller.beginRestoreWalletAsync(req)); drain(controller); EXPECT_TRUE(controller.walletOpen()); EXPECT_TRUE(controller.encryptionStatus().encrypted); // C1 fixed for restore } // M1: async open of an already-encrypted+locked wallet unlocks it with the supplied passphrase. { dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = true; dragonx::test::g_liteFakeEncrypted = true; // existing wallet is encrypted... dragonx::test::g_liteFakeLocked = true; // ...and locked at open time LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletOpenRequest req; req.passphrase = "hunter2"; EXPECT_TRUE(controller.beginOpenWalletAsync(req)); drain(controller); EXPECT_TRUE(controller.walletOpen()); const auto s = controller.encryptionStatus(); EXPECT_TRUE(s.encrypted); EXPECT_FALSE(s.locked); // async open UNLOCKED it (M1 fixed) } dragonx::test::g_liteFakeWalletExists = false; dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; dragonx::test::g_liteFakeDeadServerSubstr.clear(); dragonx::test::g_liteFakeWarmupServerSubstr.clear(); } // C2 (s1-D8, X7): a NEW Lite passphrase (Settings "Encrypt wallet", or Create/Restore with one entered) // must be 8+ characters and confirmed. Opening and unlocking an existing wallet never apply the rule, so // a wallet encrypted earlier with a short passphrase still opens and unlocks. void testLiteNewPassphraseRules() { using namespace dragonx::wallet; using Op = LiteWalletLifecycleOperation; EXPECT_EQ(kLiteMinNewPassphraseLength, static_cast(8)); // The validator: empty, short or mismatched is rejected; 8+ and confirmed is accepted. EXPECT_FALSE(liteEncryptPassphraseValid("", "")); EXPECT_FALSE(liteEncryptPassphraseValid("a", "a")); EXPECT_FALSE(liteEncryptPassphraseValid("1234567", "1234567")); // 7 < 8 EXPECT_FALSE(liteEncryptPassphraseValid("", "12345678")); EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "")); // not confirmed EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "12345679")); // mismatch EXPECT_FALSE(liteEncryptPassphraseValid("12345678", "12345678 ")); // a trailing space is a mismatch EXPECT_TRUE(liteEncryptPassphraseValid("12345678", "12345678")); // exactly 8 EXPECT_TRUE(liteEncryptPassphraseValid("correct horse battery staple", "correct horse battery staple")); { // The page passes its fixed char buffers straight in (no copy of the secret). char pass[128] = "hunter2hunter2"; char confirm[128] = "hunter2hunter2"; EXPECT_TRUE(liteEncryptPassphraseValid(pass, confirm)); confirm[0] = 'H'; EXPECT_FALSE(liteEncryptPassphraseValid(pass, confirm)); } // The Settings request gate. OpenExisting is never validated: a short or unconfirmed passphrase // for an existing wallet passes. EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "abc", "")); EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "hunter2", "different")); EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, "", "")); // Create/Restore without a passphrase make an unencrypted wallet: nothing to confirm. EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::CreateNew, "", "")); EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::RestoreFromSeed, "", "")); // With a passphrase they set a NEW one: 8+ characters and confirmed. for (const Op op : {Op::CreateNew, Op::RestoreFromSeed}) { EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "abc", "abc")); EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "1234567", "1234567")); EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "12345678", "")); EXPECT_FALSE(liteLifecyclePassphraseAcceptable(op, "12345678", "1234567X")); EXPECT_TRUE(liteLifecyclePassphraseAcceptable(op, "12345678", "12345678")); } // The controller's open and unlock paths take an existing short passphrase as is: an encrypted, // locked wallet opens and unlocks with "abc" (as the Settings Open request sends it), and unlocks // with it again after a lock. { const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); LiteConnectionSettings conn; conn.chainName = "main"; conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} }; conn.selectionMode = LiteServerSelectionMode::Sticky; conn.stickyServerUrl = "https://good.example"; dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = true; dragonx::test::g_liteFakeEncrypted = true; dragonx::test::g_liteFakeLocked = true; LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletOpenRequest req; req.passphrase = "abc"; EXPECT_TRUE(liteLifecyclePassphraseAcceptable(Op::OpenExisting, req.passphrase, "")); EXPECT_TRUE(controller.beginOpenWalletAsync(req)); for (int i = 0; i < 400 && controller.lifecycleRequestInProgress(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); controller.pumpLifecycleResult(); EXPECT_TRUE(controller.walletOpen()); EXPECT_TRUE(controller.encryptionStatus().encrypted); EXPECT_FALSE(controller.encryptionStatus().locked); // the short passphrase unlocked it EXPECT_TRUE(controller.lockWallet()); EXPECT_TRUE(controller.encryptionStatus().locked); EXPECT_TRUE(controller.unlockWallet("abc")); EXPECT_FALSE(controller.encryptionStatus().locked); } dragonx::test::g_liteFakeWalletExists = false; dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; } // WalletState::tx_revision (the History / Mining caches key on it): sameTransactionInfo covers every // TransactionInfo field and compares `amount` bit-exactly; setTransactionsIfChanged bumps only on a real change // and otherwise keeps the old list. void testSameTransactionList() { auto row = [](const char* txid, const char* type, double amount, std::int64_t ts, int conf, const char* addr, const char* from, const char* memo) { dragonx::TransactionInfo t; t.txid = txid; t.type = type; t.amount = amount; t.timestamp = ts; t.confirmations = conf; t.address = addr; t.from_address = from; t.memo = memo; return t; }; const std::vector base = { row("tx-a", "receive", 1.5, 1000, 12, "zs-a", "", "hello"), row("tx-b", "send", -0.25, 1001, 3, "zs-b", "zs-own", "pay"), row("tx-c", "mined", 3.0, 1002, 120, "R-mine", "", ""), }; EXPECT_TRUE(dragonx::sameTransactionList(base, base)); // Each of the 8 fields, mutated alone in the middle row, is a real change: true and exactly +1. const std::vector> mutations = { [](dragonx::TransactionInfo& t) { t.txid += "x"; }, [](dragonx::TransactionInfo& t) { t.type = "shield"; }, [](dragonx::TransactionInfo& t) { t.amount = -0.26; }, [](dragonx::TransactionInfo& t) { t.timestamp += 1; }, [](dragonx::TransactionInfo& t) { t.confirmations += 1; }, [](dragonx::TransactionInfo& t) { t.address = "zs-other"; }, [](dragonx::TransactionInfo& t) { t.from_address = "zs-else"; }, [](dragonx::TransactionInfo& t) { t.memo = "pay!"; }, }; EXPECT_EQ(mutations.size(), static_cast(8)); for (const auto& mutate : mutations) { dragonx::WalletState state; state.replaceTransactions(std::vector(base)); const std::uint64_t before = state.tx_revision; auto next = base; mutate(next[1]); EXPECT_FALSE(dragonx::sameTransactionInfo(base[1], next[1])); EXPECT_TRUE(state.setTransactionsIfChanged(std::move(next))); EXPECT_EQ(state.tx_revision, before + 1); EXPECT_FALSE(dragonx::sameTransactionList(state.transactions, base)); } // An identical copy keeps the old list (same storage), the revision and the contents. { dragonx::WalletState state; state.replaceTransactions(std::vector(base)); const std::uint64_t before = state.tx_revision; const dragonx::TransactionInfo* storage = state.transactions.data(); EXPECT_FALSE(state.setTransactionsIfChanged(std::vector(base))); EXPECT_EQ(state.tx_revision, before); EXPECT_TRUE(state.transactions.data() == storage); EXPECT_TRUE(dragonx::sameTransactionList(state.transactions, base)); // A size change is a change. auto shorter = base; shorter.pop_back(); EXPECT_TRUE(state.setTransactionsIfChanged(std::move(shorter))); EXPECT_EQ(state.tx_revision, before + 1); EXPECT_EQ(state.transactions.size(), static_cast(2)); EXPECT_TRUE(state.setTransactionsIfChanged(std::vector(base))); EXPECT_EQ(state.tx_revision, before + 2); } // `amount` is compared bit-exactly: 0.1+0.2 is not 0.3, and -0.0 (a zero-amount pending send) is not +0.0. { dragonx::TransactionInfo a = base[0]; dragonx::TransactionInfo b = base[0]; double sum = 0.1; sum += 0.2; a.amount = sum; b.amount = 0.3; EXPECT_FALSE(dragonx::sameTransactionInfo(a, b)); a.amount = 0.0; b.amount = -0.0; EXPECT_TRUE(a.amount == b.amount); // numerically equal... EXPECT_FALSE(dragonx::sameTransactionInfo(a, b)); // ...but rendered "+0.0" vs "+-0.0" b.amount = 0.0; EXPECT_TRUE(dragonx::sameTransactionInfo(a, b)); dragonx::WalletState state; state.replaceTransactions({a}); const std::uint64_t before = state.tx_revision; b.amount = -0.0; EXPECT_TRUE(state.setTransactionsIfChanged({b})); EXPECT_EQ(state.tx_revision, before + 1); } } void testHistoryTxidGroups() { using dragonx::ui::history::groupByTxid; auto mk = [](const std::vector& ids) { std::vector v; for (const auto& id : ids) { dragonx::TransactionInfo t; t.txid = id; v.push_back(t); } return v; }; { auto g = groupByTxid(mk({"A", "B", "A", "C", "B", "A"})); EXPECT_TRUE((g.first == std::vector{0, 1, 0, 3, 1, 0})); EXPECT_TRUE((g.next == std::vector{2, 4, 5, -1, -1, -1})); EXPECT_TRUE(g.hasSiblings(0) && g.hasSiblings(1) && g.hasSiblings(2) && g.hasSiblings(5)); EXPECT_TRUE(!g.hasSiblings(3)); } { // property check against a reference map unsigned seed = 12345; std::vector ids; for (int i = 0; i < 500; ++i) { seed = seed * 1103515245u + 12345u; ids.push_back("t" + std::to_string((seed >> 16) % 40)); } auto txs = mk(ids); auto g = groupByTxid(txs); std::unordered_map> ref; for (size_t i = 0; i < txs.size(); ++i) ref[txs[i].txid].push_back(i); bool ok = true; for (const auto& kv : ref) { int cur = (int)kv.second[0]; for (size_t k = 0; k < kv.second.size(); ++k) { if (cur != (int)kv.second[k] || g.first[kv.second[k]] != (int)kv.second[0]) { ok = false; break; } cur = g.next[cur]; } if (cur != -1) ok = false; } EXPECT_TRUE(ok); } { auto g = groupByTxid(mk({})); EXPECT_TRUE(g.first.empty() && g.next.empty()); auto s = groupByTxid(mk({"X"})); EXPECT_TRUE(s.first.size() == 1 && s.first[0] == 0 && s.next[0] == -1 && !s.hasSiblings(0)); } } // History row identity + filter-change detection (c196): a kept-open detail panel must survive list // rebuilds (keyed by content, not index) and page/filter changes must reset and collapse. void testHistoryFilterInputs() { using namespace dragonx::ui::history; // rowKey distinguishes each field, and amount by exact bits (so +0.0 != -0.0). EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) == rowKey("tx","Sent","zaddr",1.0)); EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx2","Sent","zaddr",1.0)); EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx","Recv","zaddr",1.0)); EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx","Sent","taddr",1.0)); EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx","Sent","zaddr",2.0)); EXPECT_TRUE(rowKey("tx","Sent","zaddr",0.0) != rowKey("tx","Sent","zaddr",-0.0)); // filterInputsChanged: first call at defaults is not a change; each input change is, and latches. FilterInputs prev; EXPECT_TRUE(!filterInputsChanged(prev, 0, 0, "")); // defaults -> no change EXPECT_TRUE(filterInputsChanged(prev, 1, 0, "")); // type changed EXPECT_TRUE(!filterInputsChanged(prev, 1, 0, "")); // same -> latched EXPECT_TRUE(filterInputsChanged(prev, 1, 2, "")); // sort changed EXPECT_TRUE(filterInputsChanged(prev, 1, 2, "abc")); // search changed EXPECT_TRUE(!filterInputsChanged(prev, 1, 2, "abc")); // same -> latched EXPECT_TRUE(filterInputsChanged(prev, 1, 2, "ab")); // full-text change (prefix differs) } // tx_revision starts at >=1 (a UI cache's 0 sentinel never matches), each helper adds exactly 1, the helpers // never touch last_tx_update, and clear() bumps without resetting, so no earlier revision ever recurs. void testWalletStateTxRevision() { dragonx::WalletState state; EXPECT_TRUE(state.tx_revision >= 1); state.last_tx_update = 42; const std::uint64_t rev = state.tx_revision; dragonx::TransactionInfo t; t.txid = "tx-1"; t.type = "receive"; t.amount = 1.0; state.replaceTransactions({t}); EXPECT_EQ(state.tx_revision, rev + 1); EXPECT_EQ(state.transactions.size(), static_cast(1)); state.touchTransactions(); EXPECT_EQ(state.tx_revision, rev + 2); EXPECT_FALSE(state.setTransactionsIfChanged({t})); EXPECT_EQ(state.tx_revision, rev + 2); EXPECT_EQ(state.last_tx_update, static_cast(42)); std::set seen{rev, rev + 1, rev + 2}; for (int i = 0; i < 3; ++i) { const std::uint64_t beforeClear = state.tx_revision; state.clear(); EXPECT_TRUE(state.transactions.empty()); EXPECT_TRUE(state.tx_revision > beforeClear); EXPECT_EQ(seen.count(state.tx_revision), static_cast(0)); seen.insert(state.tx_revision); state.replaceTransactions({t}); EXPECT_EQ(seen.count(state.tx_revision), static_cast(0)); seen.insert(state.tx_revision); } } // Full-node refresh: re-applying identical content keeps the list and the revision (only the wall-clock // stamp and the confirmed cache are refreshed); a changed field bumps the revision once. void testTransactionRefreshRevision() { using Refresh = dragonx::services::NetworkRefreshService; auto makeResult = [](const std::string& memo) { Refresh::TransactionRefreshResult result; dragonx::TransactionInfo pendingTx; pendingTx.txid = "pending"; pendingTx.type = "send"; pendingTx.amount = -0.5; pendingTx.confirmations = 2; pendingTx.timestamp = 1001; pendingTx.address = "zs-dest"; dragonx::TransactionInfo confirmedTx; confirmedTx.txid = "confirmed"; confirmedTx.type = "receive"; confirmedTx.amount = 1.25; confirmedTx.confirmations = 12; confirmedTx.timestamp = 1000; confirmedTx.address = "zs-own"; confirmedTx.memo = memo; result.transactions = {pendingTx, confirmedTx}; result.blockHeight = 222; return result; }; dragonx::WalletState state; Refresh::TransactionViewCache viewCache; std::unordered_set sendTxids; std::vector confirmedCache; std::unordered_set confirmedIds; int confirmedBlock = -1; int lastTxBlock = -1; Refresh::TransactionCacheUpdate cacheUpdate{viewCache, sendTxids, confirmedCache, confirmedIds, confirmedBlock, lastTxBlock}; const std::uint64_t initial = state.tx_revision; Refresh::applyTransactionRefreshResult(state, cacheUpdate, makeResult("hi"), 100); const std::uint64_t rev = state.tx_revision; EXPECT_EQ(rev, initial + 1); EXPECT_EQ(state.transactions.size(), static_cast(2)); EXPECT_EQ(state.last_tx_update, static_cast(100)); EXPECT_EQ(confirmedCache.size(), static_cast(1)); const dragonx::TransactionInfo* storage = state.transactions.data(); Refresh::applyTransactionRefreshResult(state, cacheUpdate, makeResult("hi"), 200); EXPECT_EQ(state.tx_revision, rev); EXPECT_TRUE(state.transactions.data() == storage); EXPECT_EQ(state.last_tx_update, static_cast(200)); EXPECT_EQ(lastTxBlock, 222); EXPECT_EQ(confirmedBlock, 222); 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(confirmedIds.size(), static_cast(1)); Refresh::applyTransactionRefreshResult(state, cacheUpdate, makeResult("hi there"), 300); EXPECT_EQ(state.tx_revision, rev + 1); EXPECT_EQ(state.transactions[1].memo, std::string("hi there")); EXPECT_EQ(state.last_tx_update, static_cast(300)); } // ChatStore::hasChatTxid (History "Message" badge / Chat filter): a lazy txid index keyed on the monotonic // revision(), so every mutation (append / updateDelivery / eraseConversation / clear) is seen on the next lookup; // clear() also drops it. store.empty() must equal conversationIds().empty() (shouldSplitChatBuffer relies on it). void testChatStoreLatestAndMove() { using namespace dragonx::chat; auto mk = [](const std::string& txid, std::size_t pos, const std::string& cid, int64_t ts) { ChatMessage m; m.txid = txid; m.payload_position = pos; m.conversation_id = cid; m.timestamp = ts; m.body = "body-" + txid; return m; }; ChatStore a, b; a.reserve(8); std::vector in{ mk("t1", 0, "x", 50), mk("t2", 0, "y", 10), mk("t3", 0, "x", 90), mk("t4", 0, "y", 70), mk("t5", 0, "x", 20) }; for (const auto& m : in) EXPECT_TRUE(a.append(m)); for (auto m : in) EXPECT_TRUE(b.append(std::move(m))); // dedup on txid:position via the move overload; moved-from message not stored twice EXPECT_FALSE(b.append(mk("t1", 0, "x", 999))); EXPECT_TRUE(b.append(mk("t1", 1, "x", 60))); EXPECT_EQ(a.size(), static_cast(5)); EXPECT_EQ(b.size(), static_cast(6)); EXPECT_EQ(b.conversation("x")[0].body, std::string("body-t5")); auto latest = a.latestTimestampByConversation(); EXPECT_EQ(latest.size(), static_cast(2)); for (const auto& cid : a.conversationIds()) EXPECT_EQ(latest[cid], static_cast(a.conversation(cid).back().timestamp)); EXPECT_EQ(latest["x"], static_cast(90)); EXPECT_EQ(latest["y"], static_cast(70)); EXPECT_TRUE(ChatStore().latestTimestampByConversation().empty()); } void testChatStoreTxidIndex() { using namespace dragonx::chat; ChatStore store; auto mk = [](const std::string& txid, std::size_t pos, const std::string& cid) { ChatMessage m; m.txid = txid; m.payload_position = pos; m.conversation_id = cid; m.body = "b"; return m; }; auto emptyMatches = [&store]() { return store.empty() == store.conversationIds().empty(); }; EXPECT_FALSE(store.hasChatTxid("t1")); EXPECT_FALSE(store.hasChatTxid("")); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.append(mk("t1", 0, "a"))); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.hasChatTxid("t1")); EXPECT_FALSE(store.hasChatTxid("t2")); // One txid backing two messages in two conversations: erasing one keeps the txid reported. EXPECT_TRUE(store.append(mk("t1", 1, "b"))); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.hasChatTxid("t1")); EXPECT_EQ(store.eraseConversation("a").size(), static_cast(1)); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.hasChatTxid("t1")); EXPECT_EQ(store.eraseConversation("b").size(), static_cast(1)); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.empty()); EXPECT_FALSE(store.hasChatTxid("t1")); // A lookup after the index was built still sees a later append (revision moved). EXPECT_TRUE(store.append(mk("t3", 0, "c"))); EXPECT_TRUE(store.hasChatTxid("t3")); EXPECT_TRUE(store.append(mk("t2", 0, "c"))); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.hasChatTxid("t2")); const std::uint64_t revBeforeClear = store.revision(); store.clear(); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.revision() > revBeforeClear); EXPECT_FALSE(store.hasChatTxid("t2")); EXPECT_FALSE(store.hasChatTxid("t3")); // An empty txid is never reported, even when a message carries one. EXPECT_TRUE(store.append(mk("", 0, "d"))); EXPECT_TRUE(emptyMatches()); EXPECT_FALSE(store.hasChatTxid("")); // An outgoing echo stays reported across a delivery change. ChatMessage echo = mk("out:ab", 0, "e"); echo.direction = ChatDirection::Outgoing; echo.delivery = ChatDelivery::Sending; EXPECT_TRUE(store.append(echo)); EXPECT_TRUE(emptyMatches()); EXPECT_TRUE(store.hasChatTxid("out:ab")); const std::uint64_t revBeforeUpdate = store.revision(); EXPECT_TRUE(store.updateDelivery("out:ab", ChatDelivery::Failed) != nullptr); EXPECT_TRUE(store.revision() > revBeforeUpdate); EXPECT_TRUE(store.hasChatTxid("out:ab")); EXPECT_FALSE(store.hasChatTxid("out:zz")); store.clear(); EXPECT_TRUE(store.empty()); EXPECT_TRUE(emptyMatches()); EXPECT_FALSE(store.hasChatTxid("out:ab")); // Erasing a conversation while the store stays non-empty (so the empty-store early return can't hide a // stale index): with the index already built, only the erased conversation's txid disappears. EXPECT_TRUE(store.append(mk("t1", 0, "a"))); EXPECT_TRUE(store.append(mk("t2", 0, "c"))); EXPECT_TRUE(store.hasChatTxid("t1")); EXPECT_TRUE(store.hasChatTxid("t2")); EXPECT_EQ(store.eraseConversation("a").size(), static_cast(1)); EXPECT_FALSE(store.empty()); EXPECT_TRUE(emptyMatches()); EXPECT_FALSE(store.hasChatTxid("t1")); EXPECT_TRUE(store.hasChatTxid("t2")); } // 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(state.addresses.size()), 2); EXPECT_EQ(static_cast(state.z_addresses.size()), 1); EXPECT_EQ(static_cast(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(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); // tx_revision: lite applies every ~2 s, so re-applying an identical model must keep the list and the // revision; a real change (a new chain tip moves every confirmation count) bumps it exactly once. auto model = mapped.model; const std::uint64_t revAfterFirst = state.tx_revision; applyLiteRefreshModelToWalletState(model, state); applyLiteRefreshModelToWalletState(model, state); EXPECT_EQ(state.tx_revision, revAfterFirst); model.sync.chainHeight += 1; applyLiteRefreshModelToWalletState(model, state); EXPECT_EQ(state.tx_revision, revAfterFirst + 1); model.hasTransactions = false; applyLiteRefreshModelToWalletState(model, state); EXPECT_EQ(state.tx_revision, revAfterFirst + 1); EXPECT_EQ(static_cast(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, 12); // chain 1001 - block 990 + 1 } // The backend lists transactions oldest-first; the UI copy must be pending-first then newest-first // (Recent panels take a prefix), stable for same-txid rows, and re-applying must not bump tx_revision. void testLiteTransactionsAreNewestFirst() { using namespace dragonx::wallet; LiteWalletRefreshBundle bundle; bundle.complete = true; bundle.successfulCommandCount = 2; bundle.hasTransactions = true; auto add = [&](const char* txid, std::int64_t height, std::int64_t dt, bool unconfirmed, const char* addr) { LiteTransactionRecord r; r.txid = txid; r.datetime = dt; if (!unconfirmed) r.blockHeight = height; r.unconfirmed = unconfirmed; r.direction = LiteTransactionDirection::Receive; r.address = addr; r.amount = 100000000; bundle.transactions.transactions.push_back(r); }; add("A", 100, 1000, false, "zs1x"); add("B", 200, 2000, false, "zs1x"); add("C", 300, 3000, false, "zs1x"); add("D", 0, 500, true, "zs1x"); add("dup", 250, 2500, false, "zs1a"); add("dup", 250, 2500, false, "zs1b"); 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_EQ(static_cast(state.transactions.size()), 6); if (state.transactions.size() == 6) { EXPECT_EQ(state.transactions[0].txid, std::string("D")); EXPECT_EQ(state.transactions[1].txid, std::string("C")); EXPECT_EQ(state.transactions[2].txid, std::string("dup")); EXPECT_EQ(state.transactions[2].address, std::string("zs1a")); EXPECT_EQ(state.transactions[3].txid, std::string("dup")); EXPECT_EQ(state.transactions[3].address, std::string("zs1b")); EXPECT_EQ(state.transactions[4].txid, std::string("B")); EXPECT_EQ(state.transactions[5].txid, std::string("A")); } const std::uint64_t rev = state.tx_revision; applyLiteRefreshModelToWalletState(mapped.model, state); applyLiteRefreshModelToWalletState(mapped.model, state); EXPECT_EQ(state.tx_revision, rev); } // 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(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(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(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(state.z_addresses.size()), 1); EXPECT_EQ(static_cast(state.t_addresses.size()), 1); EXPECT_EQ(static_cast(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(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(p.syncStatus.syncedBlocks), 900LL); EXPECT_EQ(static_cast(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(state.z_addresses.size()), 1); EXPECT_EQ(static_cast(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)); } // C23: the background worker change-gates the refresh -- an unchanged, synced cycle skips the full // gateway refresh + re-publish, but any real change (height advance, local mutation, encryption flip) // forces a full refresh within a cycle. Confirmed-funds freshness is the overriding rule. void testLiteWalletControllerWorkerChangeGate() { using namespace dragonx::wallet; namespace t = dragonx::test; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); t::resetLiteFakeCounters(); t::g_liteFakeLogEnabled = true; LiteWalletController controller(caps, conn, LiteClientBridge::fromApi(t::makeFakeLiteApi())); controller.setGateHeartbeatMsForTest(600000); // disable the heartbeat so only real signals refresh controller.setRefreshIntervalMsForTest(20); // fast cycles EXPECT_TRUE(controller.createWallet(LiteWalletCreateRequest{}).walletReady); // Arm: wait for the first full (balance-bearing) model. auto waitFull = [&](int maxMs) { LiteWalletAppRefreshModel m; bool got = false; for (int i = 0; i < maxMs / 5 && !got; ++i) { if (controller.takeRefreshedModel(m) && m.hasBalance) got = true; else std::this_thread::sleep_for(std::chrono::milliseconds(5)); } return got; }; EXPECT_TRUE(waitFull(3000)); // (1) Skip when nothing changed: settle, then over a window no data commands run and nothing republishes. std::this_thread::sleep_for(std::chrono::milliseconds(120)); // settle into the Skip steady state (void)t::liteFakeTakeLog(); { LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} } std::this_thread::sleep_for(std::chrono::milliseconds(200)); auto idleLog = t::liteFakeTakeLog(); EXPECT_EQ(t::liteFakeCount(idleLog, "balance"), 0L); EXPECT_EQ(t::liteFakeCount(idleLog, "list"), 0L); EXPECT_EQ(t::liteFakeCount(idleLog, "notes"), 0L); EXPECT_TRUE(t::liteFakeCount(idleLog, "sync") >= 1L); // the one-connection probe still runs each idle cycle LiteWalletAppRefreshModel noPub; EXPECT_FALSE(controller.takeRefreshedModel(noPub)); // no re-publish on idle cycles // (2) Height advance -> full refresh + publish. (void)t::liteFakeTakeLog(); t::g_liteFakeHeight = 1001; std::this_thread::sleep_for(std::chrono::milliseconds(200)); auto advLog = t::liteFakeTakeLog(); EXPECT_TRUE(t::liteFakeCount(advLog, "balance") >= 1L); EXPECT_TRUE(waitFull(2000)); // (3) Local mutation (new address) bumps the bridge epoch -> full refresh, even at the same height. std::this_thread::sleep_for(std::chrono::milliseconds(120)); (void)t::liteFakeTakeLog(); { LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} } controller.newAddress(/*shielded*/ true); std::this_thread::sleep_for(std::chrono::milliseconds(200)); EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L); // (4) Encryption state change with no bridge mutation (signal 7, epoch unchanged) -> full refresh. // The fake reports `locked` only while `encrypted`, so flip `encrypted` directly (not via a bridge // command, so the mutation epoch stays put and ONLY the enc-status compare can trigger the refresh). std::this_thread::sleep_for(std::chrono::milliseconds(120)); (void)t::liteFakeTakeLog(); { LiteWalletAppRefreshModel drain; while (controller.takeRefreshedModel(drain)) {} } t::g_liteFakeEncrypted = true; t::g_liteFakeLocked = true; std::this_thread::sleep_for(std::chrono::milliseconds(200)); EXPECT_TRUE(t::liteFakeCount(t::liteFakeTakeLog(), "balance") >= 1L); t::g_liteFakeEncrypted = false; t::g_liteFakeLocked = false; t::g_liteFakeLogEnabled = false; } // C23: the lite bridge bumps its mutation epoch for wallet-mutating commands and leaves it unchanged // for read-only ones, so the refresh worker can detect local mutations from any thread. void testLiteClientBridgeMutationEpoch() { using namespace dragonx::wallet; auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()); const auto e0 = bridge.mutationEpoch(); for (const char* ro : {"balance", "height", "sync", "syncstatus", "encryptionstatus", "save", "addresses", "list", "notes"}) bridge.execute(ro, ""); EXPECT_TRUE(bridge.mutationEpoch() == e0); // no read-only command bumps for (const char* mut : {"send", "shield", "new", "import", "timport", "encrypt", "unlock", "lock", "decrypt", "frobnicate"}) { const auto before = bridge.mutationEpoch(); bridge.execute(mut, ""); EXPECT_TRUE(bridge.mutationEpoch() == before + 1); // each mutating (or unknown) command bumps exactly once } } // 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::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 } // Tier 4 (g1-lite): SerialJobLane — completions only via pump(), FIFO, abandonable, exception-safe. void testSerialJobLane() { using dragonx::util::SerialJobLane; { // completions run only inside pump(), on the pumping thread, in FIFO order SerialJobLane lane; std::vector order; const auto mainId = std::this_thread::get_id(); bool rightThread = true; for (int i = 0; i < 3; ++i) EXPECT_TRUE(lane.post([i, &order, &rightThread, mainId]() -> SerialJobLane::Completion { return [i, &order, &rightThread, mainId]() { order.push_back(i); if (std::this_thread::get_id() != mainId) rightThread = false; }; })); EXPECT_TRUE(lane.waitIdle(std::chrono::milliseconds(3000))); EXPECT_TRUE(order.empty()); EXPECT_EQ(lane.pump(), 3); EXPECT_TRUE(order == std::vector({0, 1, 2})); EXPECT_TRUE(rightThread); } { // post() never waits for a blocked job; waitIdle is bounded SerialJobLane lane; auto gate = std::make_shared>(true); const auto t0 = std::chrono::steady_clock::now(); lane.post([gate]() -> SerialJobLane::Completion { while (gate->load()) std::this_thread::sleep_for(std::chrono::milliseconds(2)); return {}; }); EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); EXPECT_TRUE(lane.busy()); EXPECT_FALSE(lane.waitIdle(std::chrono::milliseconds(50))); gate->store(false); EXPECT_TRUE(lane.waitIdle(std::chrono::milliseconds(3000))); } { // destroying the lane with a blocked job returns at once; queued job dropped; late completion never runs auto gate = std::make_shared>(true); auto ran = std::make_shared>(0); auto entered = std::make_shared>(false); std::chrono::steady_clock::time_point t0; { SerialJobLane lane; lane.post([gate, ran, entered]() -> SerialJobLane::Completion { entered->store(true); while (gate->load()) std::this_thread::sleep_for(std::chrono::milliseconds(2)); ran->fetch_add(1); return [ran]() { ran->fetch_add(100); }; }); lane.post([ran]() -> SerialJobLane::Completion { ran->fetch_add(10); return {}; }); // The first job must be RUNNING when the lane dies: a job the lane thread hasn't picked up yet is // dropped unrun like the queued one. for (int i = 0; i < 400 && !entered->load(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(entered->load()); t0 = std::chrono::steady_clock::now(); } EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); gate->store(false); for (int i = 0; i < 400 && ran->load() == 0; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); std::this_thread::sleep_for(std::chrono::milliseconds(30)); EXPECT_EQ(ran->load(), 1); } { // a throwing job doesn't kill the lane; post() after abandon() is refused SerialJobLane lane; int got = 0; lane.post([]() -> SerialJobLane::Completion { throw std::runtime_error("boom"); }); lane.post([&got]() -> SerialJobLane::Completion { return [&got]() { got = 7; }; }); EXPECT_TRUE(lane.waitIdle(std::chrono::milliseconds(3000))); EXPECT_EQ(lane.pump(), 1); EXPECT_EQ(got, 7); lane.abandon(); EXPECT_FALSE(lane.post([]() -> SerialJobLane::Completion { return {}; })); } } void testLiteGatewayRefreshPolicyHelpers() { using namespace dragonx::wallet; EXPECT_TRUE(liteResponseIsBackendError("")); EXPECT_FALSE(liteResponseIsBackendError(" {\"a\":1}")); EXPECT_FALSE(liteResponseIsBackendError("\n[]")); EXPECT_TRUE(liteResponseIsBackendError("Error getting latest block transport error")); EXPECT_TRUE(liteResponseIsBackendError("Server's latest block(5) is behind ours(9)")); EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Info)); EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Balance)); EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Notes)); EXPECT_TRUE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::List)); EXPECT_FALSE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Height)); EXPECT_FALSE(liteGatewayCommandReachesServer(LiteWalletGatewayCommand::Addresses)); const int expected[] = {2000, 4000, 8000, 15000, 15000}; for (int i = 0; i < 5; ++i) EXPECT_EQ(liteRefreshDelayMs(i, 2000), expected[i]); EXPECT_EQ(liteRefreshDelayMs(1, 0), 4000); // base defaults to 2000 EXPECT_EQ(liteRefreshDelayMs(100000, 2000), 15000); // Server-switch deferral EXPECT_TRUE(liteServerSwitchMayProceed(false, 0.0)); EXPECT_FALSE(liteServerSwitchMayProceed(true, 0.0)); EXPECT_FALSE(liteServerSwitchMayProceed(true, kLiteServerSwitchMaxDeferSeconds - 0.5)); EXPECT_TRUE(liteServerSwitchMayProceed(true, kLiteServerSwitchMaxDeferSeconds)); } void testLiteGatewayRefreshCancelAndSkip() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); auto bridge = LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi()); LiteWalletGateway gateway(caps, conn, &bridge, LiteWalletGatewayOptions{true}); { // keepGoing stops between commands LiteWalletRefreshRequest req; int calls = 0; req.keepGoing = [&calls]() { return ++calls <= 2; }; const auto r = gateway.refresh(req); EXPECT_TRUE(r.cancelled); EXPECT_FALSE(r.ok); EXPECT_EQ(r.commandResults.size(), static_cast(2)); EXPECT_TRUE(r.commandResults.size() < r.plan.commands.size()); } { // after a backend error, the remaining syncing reads are skipped; local `addresses` still runs dragonx::test::g_liteFakeBalanceError = true; dragonx::test::g_liteFakeLogEnabled = true; (void)dragonx::test::liteFakeTakeLog(); LiteWalletRefreshRequest req; req.includeInfo = false; req.includeHeight = false; req.stopSyncReadsOnFailure = true; const auto r = gateway.refresh(req); const auto log = dragonx::test::liteFakeTakeLog(); EXPECT_TRUE(r.syncReadFailed); EXPECT_TRUE(r.ok); EXPECT_TRUE(r.bundle.hasAddresses); EXPECT_FALSE(r.bundle.hasBalance); EXPECT_EQ(dragonx::test::liteFakeCount(log, "balance"), 1L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "addresses"), 1L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "list"), 0L); // Without the flag (existing behaviour) every command still runs. req.stopSyncReadsOnFailure = false; (void)gateway.refresh(req); const auto log2 = dragonx::test::liteFakeTakeLog(); EXPECT_EQ(dragonx::test::liteFakeCount(log2, "notes"), 1L); EXPECT_EQ(dragonx::test::liteFakeCount(log2, "list"), 1L); dragonx::test::g_liteFakeBalanceError = false; dragonx::test::g_liteFakeLogEnabled = false; } } void testLiteWalletControllerWalletOpsAsync() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); { // closed controller: rejected inline, exactly once int n = 0; bool ok = true; EXPECT_FALSE(c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; ok = r.ok; })); EXPECT_EQ(n, 1); EXPECT_FALSE(ok); EXPECT_FALSE(c.walletOpInProgress()); } EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); const auto waitCompletion = [&c]() { for (int i = 0; i < 600 && !c.hasWalletOpCompletions(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); }; { // a blocked op doesn't block the caller; callback once, from pumpWalletOps() dragonx::test::g_liteFakeBlockNew = true; int n = 0; LiteNewAddressResult got; const auto t0 = std::chrono::steady_clock::now(); EXPECT_TRUE(c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; got = r; })); EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); EXPECT_TRUE(c.walletOpInProgress()); EXPECT_EQ(c.pumpWalletOps(), 0); EXPECT_EQ(n, 0); dragonx::test::g_liteFakeBlockNew = false; waitCompletion(); EXPECT_EQ(c.pumpWalletOps(), 1); EXPECT_EQ(n, 1); EXPECT_TRUE(got.ok); EXPECT_EQ(got.address, std::string("zs1fakenew")); EXPECT_FALSE(c.walletOpInProgress()); } { // encrypt + save is ONE op; decrypt likewise; empty passphrase rejected inline dragonx::test::g_liteFakeBlockSave = true; int n = 0; LiteEncryptionResult got; EXPECT_TRUE(c.encryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; })); for (int i = 0; i < 400 && !dragonx::test::g_liteFakeEncrypted.load(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(dragonx::test::g_liteFakeEncrypted.load()); // encrypted, save still pending EXPECT_EQ(c.pumpWalletOps(), 0); EXPECT_EQ(n, 0); dragonx::test::g_liteFakeBlockSave = false; waitCompletion(); c.pumpWalletOps(); EXPECT_EQ(n, 1); EXPECT_TRUE(got.ok); EXPECT_TRUE(c.encryptionStatus().encrypted); n = 0; EXPECT_TRUE(c.decryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; })); waitCompletion(); c.pumpWalletOps(); EXPECT_EQ(n, 1); EXPECT_TRUE(got.ok); EXPECT_FALSE(c.encryptionStatus().encrypted); n = 0; EXPECT_FALSE(c.encryptWalletAsync("", [&](const LiteEncryptionResult& r) { ++n; got = r; })); EXPECT_EQ(n, 1); EXPECT_FALSE(got.ok); } { // import int n = 0; LiteImportResult got; EXPECT_TRUE(c.importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n; got = r; })); waitCompletion(); c.pumpWalletOps(); EXPECT_EQ(n, 1); EXPECT_TRUE(got.ok); } { // failPendingWalletOps: each callback once with the reason; late results dropped dragonx::test::g_liteFakeBlockNew = true; int n1 = 0, n2 = 0; std::string e1, e2; c.newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n1; e1 = r.error; }); c.newAddressAsync(false, [&](const LiteNewAddressResult& r) { ++n2; e2 = r.error; }); c.failPendingWalletOps("x"); EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1); EXPECT_EQ(e1, std::string("x")); EXPECT_EQ(e2, std::string("x")); EXPECT_FALSE(c.walletOpInProgress()); dragonx::test::g_liteFakeBlockNew = false; EXPECT_TRUE(c.waitWalletOpsIdle(std::chrono::milliseconds(3000))); c.pumpWalletOps(); EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1); } } void testLiteWalletControllerRetire() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); auto c = std::make_unique(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); // One op in flight (blocked "new") + one queued (import, stuck behind it on the single lane); the worker parked // inside "balance". dragonx::test::g_liteFakeLogEnabled = true; dragonx::test::g_liteFakeBlockNew = true; int n1 = 0, n2 = 0; bool ok1 = true, ok2 = true; EXPECT_TRUE(c->newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n1; ok1 = r.ok; })); EXPECT_TRUE(c->importKeyAsync("secret-extended-key-x", [&](const LiteImportResult& r) { ++n2; ok2 = r.ok; })); const long entered = dragonx::test::g_liteFakeBalanceEntered.load(); dragonx::test::g_liteFakeBlockBalance = true; // The refresh worker change-gates (C23): it only runs `balance` on a full-refresh cycle. Bump the // scanned height so the gate forces a DoFull, which enters the now-blocked `balance` and parks there. dragonx::test::g_liteFakeHeight = dragonx::test::g_liteFakeHeight.load() + 1; for (int i = 0; i < 1000 && dragonx::test::g_liteFakeBalanceEntered.load() == entered; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(dragonx::test::g_liteFakeBalanceEntered.load() > entered); (void)dragonx::test::liteFakeTakeLog(); // only what happens from here on is checked dragonx::test::g_liteFakeShutdownCalled = false; // a late bridge release from an earlier test could have set it const auto t0 = std::chrono::steady_clock::now(); c->retire("switch"); EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(500)); // never joins EXPECT_EQ(n1, 1); EXPECT_EQ(n2, 1); EXPECT_FALSE(ok1); EXPECT_FALSE(ok2); EXPECT_TRUE(dragonx::test::g_liteFakeShutdownCalled); int n3 = 0; EXPECT_FALSE(c->newAddressAsync(true, [&](const LiteNewAddressResult&) { ++n3; })); // rejected inline EXPECT_EQ(n3, 1); const long saves = dragonx::test::g_liteFakeSaveCount.load(); auto fut = std::async(std::launch::async, [&c]() { c.reset(); }); EXPECT_TRUE(fut.wait_for(std::chrono::milliseconds(150)) == std::future_status::timeout); // joins the parked worker dragonx::test::g_liteFakeBlockBalance = false; EXPECT_TRUE(fut.wait_for(std::chrono::milliseconds(3000)) == std::future_status::ready); dragonx::test::g_liteFakeBlockNew = false; // let the in-flight "new" finish; its completion is dropped std::this_thread::sleep_for(std::chrono::milliseconds(100)); const auto log = dragonx::test::liteFakeTakeLog(); EXPECT_EQ(dragonx::test::g_liteFakeSaveCount.load(), saves); // retired: no final save EXPECT_EQ(dragonx::test::liteFakeCount(log, "import") + dragonx::test::liteFakeCount(log, "timport"), 0L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); // the cancelled cycle stopped after "balance" dragonx::test::g_liteFakeLogEnabled = false; } // retire() mid-op: a running op issues no follow-up backend command (it would land on the NEXT server's client), and an // op that had already finished is delivered with its real result instead of being failed as "reconnecting". void testLiteWalletControllerRetireStopsOpFollowUps() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; const auto openController = [&caps, &conn]() { auto c = std::make_unique(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); (void)dragonx::test::liteFakeTakeLog(); dragonx::test::g_liteFakeLogEnabled = true; return c; }; const auto drainLog = [](std::vector& log) { auto more = dragonx::test::liteFakeTakeLog(); log.insert(log.end(), more.begin(), more.end()); }; // The fake logs a command before blocking in it, so this returns once `cmd` is in flight. const auto waitForCommand = [&drainLog](std::vector& log, const char* cmd) { for (int i = 0; i < 600 && dragonx::test::liteFakeCount(log, cmd) == 0; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); drainLog(log); } return dragonx::test::liteFakeCount(log, cmd) > 0; }; { // "encrypt" in flight -> retire -> its "save" never runs (it would save on the next client) auto c = openController(); dragonx::test::g_liteFakeBlockEncrypt = true; int n = 0; LiteEncryptionResult got; got.ok = true; EXPECT_TRUE(c->encryptWalletAsync("pw", [&](const LiteEncryptionResult& r) { ++n; got = r; })); std::vector log; EXPECT_TRUE(waitForCommand(log, "encrypt")); c->retire("switch"); EXPECT_EQ(n, 1); EXPECT_FALSE(got.ok); dragonx::test::g_liteFakeBlockEncrypt = false; EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); c.reset(); drainLog(log); EXPECT_EQ(dragonx::test::liteFakeCount(log, "save"), 0L); EXPECT_EQ(n, 1); dragonx::test::g_liteFakeLogEnabled = false; } { // control: a failed shielded import calls the backend once and is never retried with the other command; // a key no classifier recognizes calls neither command auto c = openController(); dragonx::test::g_liteFakeImportFails = true; int n = 0; bool ok = true; EXPECT_TRUE(c->importKeyAsync("secret-extended-key-typo", [&](const LiteImportResult& r) { ++n; ok = r.ok; })); EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); c->pumpWalletOps(); EXPECT_EQ(n, 1); EXPECT_FALSE(ok); std::vector log; drainLog(log); EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L); int n2 = 0; LiteImportResult got; got.ok = true; EXPECT_TRUE(c->importKeyAsync("typo-key", [&](const LiteImportResult& r) { ++n2; got = r; })); EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); c->pumpWalletOps(); EXPECT_EQ(n2, 1); EXPECT_FALSE(got.ok); EXPECT_EQ(got.error, std::string("unrecognized key format")); drainLog(log); EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L); c.reset(); dragonx::test::g_liteFakeImportFails = false; dragonx::test::g_liteFakeLogEnabled = false; } { // the single import call in flight (and failing) -> retire -> delivered once, never retried with timport auto c = openController(); dragonx::test::g_liteFakeImportFails = true; dragonx::test::g_liteFakeBlockImport = true; int n = 0; bool ok = true; EXPECT_TRUE(c->importKeyAsync("secret-extended-key-typo", [&](const LiteImportResult& r) { ++n; ok = r.ok; })); std::vector log; EXPECT_TRUE(waitForCommand(log, "import")); c->retire("switch"); EXPECT_EQ(n, 1); EXPECT_FALSE(ok); dragonx::test::g_liteFakeBlockImport = false; EXPECT_TRUE(c->waitWalletOpsIdle(std::chrono::milliseconds(3000))); c.reset(); drainLog(log); EXPECT_EQ(dragonx::test::liteFakeCount(log, "import"), 1L); EXPECT_EQ(dragonx::test::liteFakeCount(log, "timport"), 0L); EXPECT_EQ(n, 1); dragonx::test::g_liteFakeImportFails = false; dragonx::test::g_liteFakeLogEnabled = false; } { // an op that finished before retire() (completion not yet pumped) is delivered with its real result auto c = openController(); int n = 0; LiteNewAddressResult got; EXPECT_TRUE(c->newAddressAsync(true, [&](const LiteNewAddressResult& r) { ++n; got = r; })); for (int i = 0; i < 600 && !c->hasWalletOpCompletions(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c->hasWalletOpCompletions()); EXPECT_EQ(n, 0); c->retire("switch"); EXPECT_EQ(n, 1); EXPECT_TRUE(got.ok); EXPECT_EQ(got.address, std::string("zs1fakenew")); c.reset(); dragonx::test::g_liteFakeLogEnabled = false; } dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; } // The backend's import error for an unrecognized key repeats the key ("'' was not recognized ..."): the controller // must never put that text in its result (shown in Settings) or in the lite log, only a fixed, non-secret reason. void testLiteWalletControllerImportErrorNeverRepeatsKey() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); dragonx::test::g_liteFakeImportFails = true; const auto logMentions = [](const std::string& needle) { for (const auto& line : LiteDiagnostics::instance().snapshot()) if (line.find(needle) != std::string::npos) return true; return false; }; { // synchronous: the single import call fails; neither the result nor the log carries the key const auto r = c.importKey("secret-extended-key-typoMARKSYNC"); EXPECT_FALSE(r.ok); EXPECT_EQ(r.error, std::string("unrecognized key format")); EXPECT_TRUE(r.error.find("MARKSYNC") == std::string::npos); EXPECT_FALSE(logMentions("MARKSYNC")); EXPECT_TRUE(logMentions("Key import failed: unrecognized key format")); } { // async (the Settings path) int n = 0; LiteImportResult got; got.ok = true; EXPECT_TRUE(c.importKeyAsync("Kx1typoMARKASYNC", [&](const LiteImportResult& r) { ++n; got = r; })); EXPECT_TRUE(c.waitWalletOpsIdle(std::chrono::milliseconds(3000))); c.pumpWalletOps(); EXPECT_EQ(n, 1); EXPECT_FALSE(got.ok); EXPECT_TRUE(got.error.find("MARKASYNC") == std::string::npos); EXPECT_FALSE(logMentions("MARKASYNC")); } dragonx::test::g_liteFakeImportFails = false; } // C251/C252 (X1): importKeyCore classifies the key, then calls exactly one backend command or none, never the other // command as a retry. Refusals are fixed reasons. Also the encrypted-wallet reason (s1-D4), the zero-entropy seed // guard and the logged backup recovery (s2-D14). void testLiteWalletControllerImportClassifier() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; dragonx::test::g_liteFakeWalletExists = true; const auto logMentions = [](const std::string& needle) { for (const auto& line : LiteDiagnostics::instance().snapshot()) if (line.find(needle) != std::string::npos) return true; return false; }; auto c = std::make_unique(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(c->createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 400 && !c->syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); struct Outcome { LiteImportResult r; long imports = 0; long timports = 0; }; const auto importCounting = [&c](const std::string& key) { (void)dragonx::test::liteFakeTakeLog(); dragonx::test::g_liteFakeLogEnabled = true; Outcome o; o.r = c->importKey(key); dragonx::test::g_liteFakeLogEnabled = false; const auto log = dragonx::test::liteFakeTakeLog(); o.imports = dragonx::test::liteFakeCount(log, "import"); o.timports = dragonx::test::liteFakeCount(log, "timport"); return o; }; const std::string kCompressed80 = "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"; // key = 1, 0x80 const std::string kCompressedBC = "Up1YVLk7uuErCHVQyFCtfinZngmdwfyfc47WCQ8oJxgowjVzNeqs"; // key = 1, 0xBC const std::string kUncompressed = "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf"; // key = 1, 0x80 const std::string kTestnet = "cMahea7zqjxrtgAbB7LSGbcQUr1uX1ojuat9jZodMN87JcbXMTcA"; // key = 1, 0xEF const std::string kBadFlag = "Up1YVLk7uuErCHVQyFCtfinZngmdwfyfc47WCQ8oJxgowjcCjSha"; // 0xBC, flag 0x02 { std::vector p; EXPECT_TRUE(dragonx::util::decodeBase58Check(kCompressed80, p)); EXPECT_TRUE(p.size() == 34 && p[0] == 0x80 && p[33] == 0x01); EXPECT_TRUE(dragonx::util::decodeBase58Check(kCompressedBC, p)); EXPECT_TRUE(p.size() == 34 && p[0] == 0xBC && p[33] == 0x01); EXPECT_TRUE(dragonx::util::decodeBase58Check(kUncompressed, p)); EXPECT_EQ(p.size(), std::size_t(33)); EXPECT_TRUE(dragonx::util::decodeBase58Check(kTestnet, p)); EXPECT_TRUE(p.size() == 34 && p[0] == 0xEF); EXPECT_TRUE(dragonx::util::decodeBase58Check(kBadFlag, p)); EXPECT_TRUE(p.size() == 34 && p[33] == 0x02); // one changed character breaks the checksum EXPECT_FALSE(dragonx::util::decodeBase58Check(kCompressed80.substr(0, kCompressed80.size() - 1) + "m", p)); } { // compressed WIFs (0x80 and the DragonX 0xBC) -> "timport" only for (const auto& wif : {kCompressed80, kCompressedBC}) { const auto o = importCounting(wif); EXPECT_TRUE(o.r.ok); EXPECT_EQ(o.timports, 1L); EXPECT_EQ(o.imports, 0L); } } { // shielded spending key and extended full viewing key -> "import" only for (const char* key : {"secret-extended-key-main1fakeshieldedkey", "zxviews1fakeviewingkey"}) { const auto o = importCounting(key); EXPECT_TRUE(o.r.ok); EXPECT_EQ(o.imports, 1L); EXPECT_EQ(o.timports, 0L); } } { // a failed import calls the backend exactly once (it was retried with the other command before) dragonx::test::g_liteFakeImportFails = true; const auto z = importCounting("secret-extended-key-main1typo"); EXPECT_FALSE(z.r.ok); EXPECT_EQ(z.imports + z.timports, 1L); const auto t = importCounting(kCompressed80); EXPECT_FALSE(t.r.ok); EXPECT_EQ(t.timports, 1L); EXPECT_EQ(t.imports, 0L); dragonx::test::g_liteFakeImportFails = false; } { // refused before any backend call, with a fixed reason LiteDiagnostics::instance().clear(); const auto u = importCounting(kUncompressed); EXPECT_FALSE(u.r.ok); EXPECT_EQ(u.r.error, std::string("uncompressed transparent keys are not supported by the Lite wallet")); EXPECT_EQ(u.imports + u.timports, 0L); EXPECT_TRUE(logMentions("Key import failed: uncompressed transparent keys are not supported")); const auto ivk = importCounting("zivks1fakeincomingviewingkeyfromthefullnode"); EXPECT_FALSE(ivk.r.ok); EXPECT_TRUE(ivk.r.error.find("full-node incoming viewing key (zivks)") != std::string::npos); EXPECT_EQ(ivk.imports + ivk.timports, 0L); for (const std::string& bad : {kTestnet, kBadFlag, kCompressed80.substr(0, kCompressed80.size() - 1) + "m", std::string("typo-key"), std::string("Kx1faketransparentwif")}) { const auto o = importCounting(bad); EXPECT_FALSE(o.r.ok); EXPECT_EQ(o.r.error, std::string("unrecognized key format")); EXPECT_EQ(o.imports + o.timports, 0L); } } { // the backend's encrypted-wallet refusal maps to its own reason, ahead of "locked" LiteDiagnostics::instance().clear(); dragonx::test::g_liteFakeImportEncrypted = true; const auto z = importCounting("secret-extended-key-main1fakeshieldedkey"); EXPECT_FALSE(z.r.ok); EXPECT_EQ(z.r.error, std::string("wallet is encrypted - remove encryption to import a private key")); EXPECT_EQ(z.imports, 1L); const auto t = importCounting(kCompressedBC); EXPECT_FALSE(t.r.ok); EXPECT_EQ(t.r.error, std::string("wallet is encrypted - remove encryption to import a private key")); EXPECT_EQ(t.timports, 1L); EXPECT_TRUE(logMentions("Key import failed: wallet is encrypted")); dragonx::test::g_liteFakeImportEncrypted = false; } { // seed export never returns the all-zero-entropy phrase (a stale backend's answer for a locked wallet) std::string zero; for (int i = 0; i < 23; ++i) zero += "abandon "; dragonx::test::g_liteFakeSeedPhrase = zero + "art"; auto s = c->exportSeed(); EXPECT_FALSE(s.ok); EXPECT_TRUE(s.seedPhrase.empty()); EXPECT_FALSE(s.error.empty()); // near misses are real phrases dragonx::test::g_liteFakeSeedPhrase = zero + "about"; s = c->exportSeed(); EXPECT_TRUE(s.ok); dragonx::test::g_liteFakeSeedPhrase = zero + "art art"; s = c->exportSeed(); EXPECT_TRUE(s.ok); dragonx::test::g_liteFakeSeedPhrase.clear(); s = c->exportSeed(); EXPECT_TRUE(s.ok); EXPECT_EQ(s.seedPhrase, std::string("fake seed phrase words")); } c.reset(); { // s2-D14: an open that recovered from the backup is logged; a create never logs its response (the seed) LiteDiagnostics::instance().clear(); dragonx::test::g_liteFakeOpenRecoveredNote = "recovered from backup; damaged file kept as silentdragonxlite-wallet.dat.corrupt-1700000000"; LiteWalletController opened(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(opened.beginOpenExisting()); for (int i = 0; i < 400 && opened.openInProgress(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); opened.pumpAsyncOpen(); EXPECT_TRUE(opened.walletOpen()); EXPECT_TRUE(logMentions("OK: recovered from backup; damaged file kept as silentdragonxlite-wallet.dat.corrupt-")); dragonx::test::g_liteFakeOpenRecoveredNote.clear(); } { LiteDiagnostics::instance().clear(); LiteWalletController created(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(created.beginCreateWallet()); for (int i = 0; i < 400 && created.openInProgress(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); created.pumpAsyncOpen(); EXPECT_TRUE(created.walletOpen()); EXPECT_FALSE(logMentions("fake seed phrase words")); for (const auto& line : LiteDiagnostics::instance().snapshot()) { const std::string msg = line.size() > 10 ? line.substr(10) : line; // drop the "HH:MM:SS " stamp EXPECT_TRUE(msg != " " && msg != " OK"); } } } void testLiteWalletControllerEncryptsBeforeSyncStarts() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); LiteConnectionSettings conn; conn.chainName = "main"; conn.servers = { LiteServerEndpoint{"https://good.example", "Good", true} }; conn.selectionMode = LiteServerSelectionMode::Sticky; conn.stickyServerUrl = "https://good.example"; dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = false; dragonx::test::g_liteFakeEncrypted = false; dragonx::test::g_liteFakeLocked = false; dragonx::test::g_liteFakeLogEnabled = true; { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; req.passphrase = "hunter2"; EXPECT_TRUE(c.beginCreateWalletAsync(req)); for (int i = 0; i < 400 && c.lifecycleRequestInProgress(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); c.pumpLifecycleResult(); for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); const auto log = dragonx::test::liteFakeTakeLog(); const auto idx = [&log](const char* cmd) { for (std::size_t i = 0; i < log.size(); ++i) if (log[i] == cmd) return static_cast(i); return -1L; }; EXPECT_TRUE(idx("encrypt") >= 0); EXPECT_TRUE(idx("save") > idx("encrypt")); EXPECT_TRUE(idx("sync") > idx("save")); // the scan starts only after the encrypted wallet is saved } dragonx::test::g_liteFakeLogEnabled = false; dragonx::test::g_liteFakeWalletExists = true; } void testLiteWalletControllerWorkerBacksOffOnSyncReadFailure() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeBalanceError = true; dragonx::test::g_liteFakeLogEnabled = true; { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); std::vector log; for (int i = 0; i < 1000 && dragonx::test::liteFakeCount(log, "balance") == 0; ++i) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); auto more = dragonx::test::liteFakeTakeLog(); log.insert(log.end(), more.begin(), more.end()); } std::this_thread::sleep_for(std::chrono::milliseconds(300)); auto more = dragonx::test::liteFakeTakeLog(); log.insert(log.end(), more.begin(), more.end()); EXPECT_EQ(dragonx::test::liteFakeCount(log, "balance"), 1L); // next cycle only after the 4 s back-off EXPECT_EQ(dragonx::test::liteFakeCount(log, "notes"), 0L); // skipped after the failed balance EXPECT_EQ(dragonx::test::liteFakeCount(log, "list"), 0L); EXPECT_TRUE(dragonx::test::liteFakeCount(log, "addresses") >= 1L); } // destructor wakes the back-off wait at once dragonx::test::g_liteFakeBalanceError = false; dragonx::test::g_liteFakeLogEnabled = false; } // C26: classify the backend sync/rescan reply; honor it with bounded retry + a failed state. void testLiteSyncOpResponseParser() { using namespace dragonx::wallet; { const auto r = parseLiteSyncOpResponse("{\n \"result\": \"success\"\n}"); EXPECT_TRUE(r.ok); EXPECT_FALSE(r.terminal); EXPECT_TRUE(r.error.empty()); } { const auto r = parseLiteSyncOpResponse("Error getting latest block: x"); EXPECT_FALSE(r.ok); EXPECT_FALSE(r.terminal); EXPECT_EQ(r.error, std::string("Error getting latest block: x")); } { const auto r = parseLiteSyncOpResponse("Server's latest block(5) is behind ours(9)"); EXPECT_FALSE(r.ok); EXPECT_FALSE(r.terminal); } { const auto r = parseLiteSyncOpResponse("Reorg has exceeded 100 blocks. Aborting."); EXPECT_FALSE(r.ok); EXPECT_TRUE(r.terminal); } { const auto r = parseLiteSyncOpResponse(""); EXPECT_FALSE(r.ok); EXPECT_EQ(r.error, std::string("empty sync response")); } { EXPECT_FALSE(parseLiteSyncOpResponse("{\"result\":\"error\"}").ok); EXPECT_FALSE(parseLiteSyncOpResponse("{\"error\":\"x\"}").ok); EXPECT_FALSE(parseLiteSyncOpResponse("{\"version\":\"x\"}").ok); } { const auto r = parseLiteSyncOpResponse(std::string(400, 'a')); EXPECT_FALSE(r.ok); EXPECT_EQ(static_cast(r.error.size()), 300); } EXPECT_EQ(liteSyncRetryDelayMs(1, 5000), 5000); EXPECT_EQ(liteSyncRetryDelayMs(2, 5000), 10000); EXPECT_EQ(liteSyncRetryDelayMs(6, 5000), 60000); EXPECT_EQ(liteSyncRetryDelayMs(30, 5000), 60000); } void testLiteWalletControllerSyncRetriesAfterFailure() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeSyncFailures = 2; dragonx::test::g_liteFakeLogEnabled = true; { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); c.setSyncRetryBaseMsForTest(5); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 600 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c.syncComplete()); EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), 3L); // 1 fail + 1 fail + 1 success EXPECT_FALSE(c.scanFailed()); EXPECT_TRUE(c.scanError().empty()); EXPECT_TRUE(dragonx::test::g_liteFakeSaveCount.load() >= 3L); // saved after every attempt auto log = dragonx::test::liteFakeTakeLog(); EXPECT_EQ(dragonx::test::liteFakeCount(log, "rescan"), 0L); // retry is "sync", never "rescan" } dragonx::test::g_liteFakeSyncFailures = 0; dragonx::test::g_liteFakeLogEnabled = false; } void testLiteWalletControllerSyncGivesUpAndReportsFailure() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeSyncFailures = 1000; dragonx::test::g_liteFakeBalanceError = true; // keep data reads failing so the worker can't self-heal yet { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); c.setSyncRetryBaseMsForTest(1); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 600 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c.syncComplete()); EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), static_cast(kLiteScanMaxAttempts)); EXPECT_TRUE(c.scanFailed()); EXPECT_TRUE(c.scanError().find("Error getting latest block") != std::string::npos); const auto model = c.refreshModel(); EXPECT_TRUE(model.has_value()); EXPECT_FALSE(model->syncError.empty()); EXPECT_FALSE(model->sync.complete); dragonx::WalletState state; applyLiteRefreshModelToWalletState(*model, state); EXPECT_TRUE(state.sync.syncing); EXPECT_FALSE(state.sync.lite_error.empty()); // Server back: a refresh whose server-bound reads all succeed clears the failed state. dragonx::test::g_liteFakeSyncFailures = 0; dragonx::test::g_liteFakeBalanceError = false; const auto healed = c.refreshModel(); EXPECT_TRUE(healed.has_value()); EXPECT_FALSE(c.scanFailed()); EXPECT_TRUE(healed->sync.complete); } dragonx::test::g_liteFakeSyncFailures = 0; dragonx::test::g_liteFakeBalanceError = false; } void testLiteWalletControllerTerminalReorgStopsRetrying() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeSyncTerminal = true; dragonx::test::g_liteFakeBalanceError = true; // keep data reads failing so the worker can't self-heal yet { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); c.setSyncRetryBaseMsForTest(1); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 600 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c.syncComplete()); EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), 1L); // terminal: no retry EXPECT_TRUE(c.scanFailed()); // The user is not trapped: a rescan is allowed and (succeeding) clears the failure. dragonx::test::g_liteFakeSyncTerminal = false; dragonx::test::g_liteFakeBalanceError = false; EXPECT_TRUE(c.startRescan()); for (int i = 0; i < 600 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c.syncComplete()); EXPECT_FALSE(c.scanFailed()); } dragonx::test::g_liteFakeSyncTerminal = false; dragonx::test::g_liteFakeBalanceError = false; } void testLiteWalletControllerRescanFailureRetriesWithSyncNotRescan() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); c.setSyncRetryBaseMsForTest(1); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 600 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c.syncComplete()); dragonx::test::g_liteFakeRescanFailures = 1; dragonx::test::g_liteFakeLogEnabled = true; (void)dragonx::test::liteFakeTakeLog(); // drop everything up to the rescan EXPECT_TRUE(c.startRescan()); for (int i = 0; i < 600 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); EXPECT_TRUE(c.syncComplete()); EXPECT_FALSE(c.scanFailed()); // the retry (sync) succeeded auto log = dragonx::test::liteFakeTakeLog(); EXPECT_EQ(dragonx::test::liteFakeCount(log, "rescan"), 1L); // exactly one rescan EXPECT_TRUE(dragonx::test::liteFakeCount(log, "sync") >= 1L); // then retried as sync } dragonx::test::g_liteFakeRescanFailures = 0; dragonx::test::g_liteFakeLogEnabled = false; } void testLiteWalletControllerCancelStopsSyncRetry() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeSyncFailures = 1000; long callsAtDestroy = 0; const auto start = std::chrono::steady_clock::now(); { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); c.setSyncRetryBaseMsForTest(60000); // a long back-off the cancel must cut short EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 600 && dragonx::test::g_liteFakeSyncCalls.load() < 1; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); callsAtDestroy = dragonx::test::g_liteFakeSyncCalls.load(); EXPECT_TRUE(callsAtDestroy >= 1L); // controller destructs here while the sync thread is in its 60 s back-off sleep } const auto elapsedMs = std::chrono::duration_cast( std::chrono::steady_clock::now() - start).count(); EXPECT_TRUE(elapsedMs < 1500); // destructor cancelled the back-off and returned promptly std::this_thread::sleep_for(std::chrono::milliseconds(300)); EXPECT_EQ(dragonx::test::g_liteFakeSyncCalls.load(), callsAtDestroy); // no further sync after cancel dragonx::test::g_liteFakeSyncFailures = 0; } void testLiteWalletControllerDestroyWithBlockedOp() { using namespace dragonx::wallet; const auto caps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); const auto conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); int n = 0; const auto t0 = std::chrono::steady_clock::now(); { LiteWalletController c(caps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(c.createWallet(LiteWalletCreateRequest{}).walletReady); for (int i = 0; i < 400 && !c.syncComplete(); ++i) std::this_thread::sleep_for(std::chrono::milliseconds(5)); dragonx::test::g_liteFakeBlockNew = true; EXPECT_TRUE(c.newAddressAsync(true, [&](const LiteNewAddressResult&) { ++n; })); } // lane abandoned, not joined EXPECT_TRUE(std::chrono::steady_clock::now() - t0 < std::chrono::milliseconds(3000)); dragonx::test::g_liteFakeBlockNew = false; std::this_thread::sleep_for(std::chrono::milliseconds(100)); // let the detached job unwind EXPECT_EQ(n, 0); // a destroyed controller never delivers } // 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(testTmpPath("obsidian-rollout-missing-xyz.json").string()); 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 = testTmpPath("obsidian-rollout-test.json").string(); { 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 = testTmpPath("obsidian-rollout-bad.json").string(); { 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 = fixtureDir() + "/xmrig/release_latest.json"; std::ifstream f(path, std::ios::binary); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } 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(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); // Guard the index reads: select*Asset returns -1 when nothing matches, and EXPECT_TRUE doesn't // abort this harness, so an unguarded rel.assets[-1] on a fixture/parser regression would SIGSEGV // the whole suite instead of reporting the failed EXPECT above. if (linux >= 0) EXPECT_TRUE(rel.assets[linux].name.find("linux-x64.zip") != std::string::npos); if (win >= 0) 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(1)); EXPECT_EQ(linuxNames.front(), std::string("xmrig")); const auto winNames = xmrigExtractBasenames("win-x64"); EXPECT_EQ(winNames.size(), static_cast(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(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("")); } // CoalescingWriter (SI-1): the shared background writer behind the settings/wallets.json saves and the tx-history // snapshot. Latest wins per key, distinct keys all run, stop() drains, failures are reported once, a throw is // contained, and a submit after stop() (or before any thread exists) runs inline. void testCoalescingWriter() { using dragonx::util::CoalescingWriter; { // latest wins: hold the worker in job 1, queue three more under the same key, only the last runs std::mutex m; std::condition_variable cv; bool release = false, started = false; std::vector ran; // written only by the worker; read after flush() (synchronised) // Declared after the locals its jobs capture by reference: if flush() times out, ~CoalescingWriter joins // the still-running job before those locals are destroyed. CoalescingWriter w; w.submit("k", [&] { { std::lock_guard l(m); started = true; } cv.notify_all(); std::unique_lock l(m); cv.wait(l, [&] { return release; }); ran.push_back(1); return true; }); { std::unique_lock l(m); cv.wait(l, [&] { return started; }); } w.submit("k", [&] { ran.push_back(2); return true; }); w.submit("k", [&] { ran.push_back(3); return true; }); w.submit("k", [&] { ran.push_back(4); return true; }); { std::lock_guard l(m); release = true; } cv.notify_all(); EXPECT_TRUE(w.flush(std::chrono::seconds(5))); EXPECT_EQ(ran.size(), static_cast(2)); EXPECT_EQ(ran.back(), 4); EXPECT_EQ(w.supersededCount(), static_cast(2)); } { // distinct keys all run; stop() drains; failure reported once; a throw is contained CoalescingWriter w; std::atomic n{0}; w.submit("a", [&] { ++n; return true; }); w.submit("b", [&] { ++n; return false; }); w.submit("c", [&]() -> bool { ++n; throw std::runtime_error("boom"); }); w.stop(); EXPECT_EQ(n.load(), 3); EXPECT_FALSE(w.takeFailure("a")); EXPECT_TRUE(w.takeFailure("b")); EXPECT_FALSE(w.takeFailure("b")); // cleared EXPECT_TRUE(w.takeFailure("c")); bool ranInline = false; // after stop() a late submit runs inline, not dropped w.submit("late", [&] { ranInline = true; return true; }); EXPECT_TRUE(ranInline); w.stop(); // idempotent } { // stop() before the first submit: no thread is ever started (nothing left unjoined) CoalescingWriter w; w.stop(); bool ran = false; w.submit("x", [&] { ran = true; return true; }); EXPECT_TRUE(ran); EXPECT_EQ(w.completedCount(), static_cast(1)); } } // C269: CoalescingWriter ordering, as the settings.json / wallets.json writes use it. Deterministic (jobs are // gated, no sleeps): latest-wins coalescing, FIFO across keys, submitAndWait ordering + result, bounded flush, // drain-on-destroy, and real files through persistFileAtomically. void testCoalescingWriterOrdering() { using dragonx::util::CoalescingWriter; using ms = std::chrono::milliseconds; struct Rec { std::mutex m; std::condition_variable cv; std::vector> calls; bool hold = false; // jobs block until released int entered = 0; // jobs that have started }; auto r = std::make_shared(); // A job for key p that "writes" content c: it blocks while held, then records (p, c) and succeeds, or with // ok=false fails without recording. auto rec = [r](const std::string& p, const std::string& c, bool ok = true) { return [r, p, c, ok]() { std::unique_lock lk(r->m); ++r->entered; r->cv.notify_all(); r->cv.wait(lk, [&] { return !r->hold; }); if (!ok) return false; r->calls.emplace_back(p, c); return true; }; }; auto reset = [r](bool hold) { std::lock_guard lk(r->m); r->calls.clear(); r->hold = hold; r->entered = 0; }; auto release = [r] { { std::lock_guard lk(r->m); r->hold = false; } r->cv.notify_all(); }; auto waitEntered = [r](int n) { std::unique_lock lk(r->m); return r->cv.wait_for(lk, ms(3000), [&] { return r->entered >= n; }); }; using Call = std::pair; { // a burst while one write is in flight collapses into ONE follow-up write of the latest content reset(true); CoalescingWriter w; w.submit("a.json", rec("a.json", "v0")); EXPECT_TRUE(waitEntered(1)); for (int i = 1; i < 50; ++i) w.submit("a.json", rec("a.json", "v" + std::to_string(i))); EXPECT_EQ(w.pendingCount(), (size_t)2); // v0 in flight + one coalesced slot release(); EXPECT_TRUE(w.flush(ms(3000))); std::lock_guard lk(r->m); EXPECT_EQ(r->calls.size(), (size_t)2); EXPECT_TRUE(r->calls.size() == 2 && r->calls[0] == Call("a.json", "v0") && r->calls[1] == Call("a.json", "v49")); } { // FIFO by first enqueue; a replaced job keeps its place; a re-submit of the in-flight key goes last reset(true); CoalescingWriter w; w.submit("p1", rec("p1", "1a")); EXPECT_TRUE(waitEntered(1)); w.submit("p2", rec("p2", "2a")); w.submit("p3", rec("p3", "3a")); w.submit("p2", rec("p2", "2b")); w.submit("p1", rec("p1", "1b")); release(); EXPECT_TRUE(w.flush(ms(3000))); std::lock_guard lk(r->m); const std::vector want = {{"p1", "1a"}, {"p2", "2b"}, {"p3", "3a"}, {"p1", "1b"}}; EXPECT_TRUE(r->calls == want); } { // submitAndWait: done when it returns, never overtaken by the older queued job reset(false); CoalescingWriter w; w.submit("b.json", rec("b.json", "old")); EXPECT_TRUE(w.submitAndWait("b.json", rec("b.json", "new"))); { std::lock_guard lk(r->m); EXPECT_TRUE(!r->calls.empty() && r->calls.back() == Call("b.json", "new")); } EXPECT_EQ(w.pendingCount(), (size_t)0); } { // submitAndWait reports a failed write; a failing key doesn't block others reset(false); CoalescingWriter w; EXPECT_FALSE(w.submitAndWait("c.json", rec("c.json", "x", /*ok=*/false))); EXPECT_EQ(w.failureCount(), (uint64_t)1); w.submit("d.json", rec("d.json", "y")); EXPECT_TRUE(w.flush(ms(3000))); std::lock_guard lk(r->m); EXPECT_TRUE(!r->calls.empty() && r->calls.back() == Call("d.json", "y")); } { // flush is bounded when a write is wedged, and completes once it unwedges reset(true); CoalescingWriter w; w.submit("e.json", rec("e.json", "z")); EXPECT_TRUE(waitEntered(1)); EXPECT_FALSE(w.flush(ms(50))); release(); EXPECT_TRUE(w.flush(ms(3000))); } { // the destructor runs what is still queued before joining reset(true); { CoalescingWriter w; w.submit("f.json", rec("f.json", "1")); EXPECT_TRUE(waitEntered(1)); w.submit("g.json", rec("g.json", "2")); release(); } std::lock_guard lk(r->m); EXPECT_TRUE(r->calls.size() == 2 && r->calls[1] == Call("g.json", "2")); } // Real files through persistFileAtomically: atomic, latest wins, no stray .tmp; with no writer it is sync. { const fs::path dir = makeTempDir(); const std::string target = (dir / "n" / "s.json").string(); { CoalescingWriter w; EXPECT_TRUE(dragonx::util::persistFileAtomically(&w, false, target, "{\"k\":1}", true)); EXPECT_TRUE(dragonx::util::persistFileAtomically(&w, false, target, "{\"k\":2}", true)); EXPECT_TRUE(w.flush(ms(5000))); } std::ifstream in(target); std::string body((std::istreambuf_iterator(in)), std::istreambuf_iterator()); EXPECT_EQ(body, std::string("{\"k\":2}")); EXPECT_FALSE(fs::exists(target + ".tmp")); const std::string sync = (dir / "sync.json").string(); EXPECT_TRUE(dragonx::util::persistFileAtomically(nullptr, false, sync, "x", true)); EXPECT_TRUE(fs::exists(sync)); in.close(); removeTree(dir); } } // 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"; removeTree(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(in)), std::istreambuf_iterator()); 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(in)), std::istreambuf_iterator()); 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 removeTree(dir); } #ifndef _WIN32 // C269: Settings + WalletIndex saves through the App's background writer, under a temp $HOME (as // testWalletIndex does; Windows' config dir can't be redirected). The HOME redirect is what keeps a // developer's real settings.json untouched (getConfigDir() uses only $HOME/.config); XDG_CONFIG_HOME is // redirected too purely as a precaution. A queued save lands after flush(); a critical setter // makes the next save wait for the disk (readable with NO flush), and an older queued snapshot can't land // after it. void testSettingsBackgroundSave() { using ms = std::chrono::milliseconds; const char* oldHome = std::getenv("HOME"); const std::string savedHome = oldHome ? oldHome : ""; const char* oldXdg = std::getenv("XDG_CONFIG_HOME"); const bool hadXdg = oldXdg != nullptr; const std::string savedXdg = hadXdg ? oldXdg : ""; const fs::path tmp = makeTempDir(); setenv("HOME", tmp.string().c_str(), 1); setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1); { dragonx::util::CoalescingWriter w; // declared first: outlives s and idx dragonx::config::Settings s; s.setFileWriter(&w); s.setUIOpacity(0.55f); EXPECT_TRUE(s.save()); // queued s.setUIOpacity(0.65f); EXPECT_TRUE(s.save()); // coalesces over / follows the first EXPECT_TRUE(w.flush(ms(5000))); { dragonx::config::Settings back; EXPECT_TRUE(back.load()); EXPECT_NEAR(back.getUIOpacity(), 0.65, 1e-6); } // A critical setter makes the next save wait for the disk: readable with NO flush, and the // queued older snapshot can't land after it. s.setUIOpacity(0.70f); EXPECT_TRUE(s.save()); // queued s.setPinEnabled(true); EXPECT_TRUE(s.save()); // durable { dragonx::config::Settings back; EXPECT_TRUE(back.load()); EXPECT_TRUE(back.getPinEnabled()); EXPECT_NEAR(back.getUIOpacity(), 0.70, 1e-6); } EXPECT_EQ(w.pendingCount(), (size_t)0); dragonx::data::WalletIndex idx; idx.setFileWriter(&w); idx.load(); dragonx::data::WalletIndexEntry e; e.fileName = "wallet.dat"; e.displayName = "Main"; idx.upsert(e); EXPECT_TRUE(idx.save()); // queued EXPECT_TRUE(w.flush(ms(5000))); dragonx::data::WalletIndex idx2; EXPECT_TRUE(idx2.load()); EXPECT_TRUE(idx2.find("wallet.dat") != nullptr); } if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME"); removeTree(tmp); } #endif #ifndef _WIN32 // C218: openUrl/openFolder must return without waiting for the opener. Fake xdg-open/open that sleep 3 s // are the ONLY entries on PATH (posix_spawnp keeps searching after EACCES, so on a noexec temp dir an // appended real PATH would launch the real opener); the calls must return well before the 3 s. void testOpenerDoesNotBlock() { using dragonx::util::Platform; { std::error_code ec; if (!fs::exists("/bin/sleep", ec)) { // The fake openers run /bin/sleep: without it they exit at once and the timing check proves nothing. std::printf("[skip] testOpenerDoesNotBlock (/bin/sleep not found)\n"); EXPECT_FALSE(Platform::openUrl("file:///etc/passwd")); // scheme gate unchanged (spawns nothing) return; } } const fs::path dir = makeTempDir(); for (const char* name : {"xdg-open", "open"}) { const fs::path p = dir / name; writeTestFile(p, "#!/bin/sh\necho 1 >> \"${0%/*}/started\"\n/bin/sleep 3\n"); fs::permissions(p, fs::perms::owner_all); } const char* oldPath = std::getenv("PATH"); const std::string savedPath = oldPath ? oldPath : ""; setenv("PATH", dir.string().c_str(), 1); const auto t0 = std::chrono::steady_clock::now(); const bool url = Platform::openUrl("https://dragonx.is"); const bool folder = Platform::openFolder((dir / "sub").string()); const double secs = secondsSince(t0); if (!savedPath.empty()) setenv("PATH", savedPath.c_str(), 1); else unsetenv("PATH"); if (!url && !folder) { // posix_spawnp failed (e.g. the temp dir is mounted noexec): nothing was measured. std::printf("[skip] testOpenerDoesNotBlock (cannot exec a script from %s)\n", dir.string().c_str()); } else { EXPECT_TRUE(url); EXPECT_TRUE(folder); EXPECT_TRUE(secs < 1.5); // the old waitpid() blocked >= 6 s here } EXPECT_FALSE(Platform::openUrl("file:///etc/passwd")); // scheme gate unchanged // Each shell must have opened its script before it is deleted (else sh exits 2, "cannot open"): wait // (bounded) for both "started" marks. After that the sleeping children don't need the scripts, and the // detached waiters reap them. const std::uintmax_t want = 2u * ((url ? 1u : 0u) + (folder ? 1u : 0u)); for (const auto tw = std::chrono::steady_clock::now(); want > 0 && secondsSince(tw) < 2.0;) { std::error_code ec; const std::uintmax_t n = fs::file_size(dir / "started", ec); if (!ec && n >= want) break; std::this_thread::sleep_for(std::chrono::milliseconds(10)); } removeTree(dir); } #endif // 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 // decodeBase58Check exposes the checksum-stripped payload so callers can inspect version/length. using dragonx::util::decodeBase58Check; std::vector payload; EXPECT_TRUE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", payload)); EXPECT_EQ(payload.size(), (size_t)21); // version(1) + 20-byte hash160, checksum stripped EXPECT_EQ((int)payload[0], 0); // mainnet P2PKH version byte EXPECT_FALSE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna", payload)); // bad checksum // bech32Hrp returns the (lower-cased) HRP of a valid string, or "" when invalid. using dragonx::util::bech32Hrp; EXPECT_EQ(bech32Hrp("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"), std::string("abcdef")); EXPECT_EQ(bech32Hrp("A12UEL5L"), std::string("a")); // lower-cased EXPECT_EQ(bech32Hrp("A12UEL5M"), std::string("")); // invalid → empty // Address type recognizers: accept every real DragonX recipient form, reject non-addresses. using dragonx::util::isTransparentAddress; using dragonx::util::isShieldedAddress; using dragonx::util::isValidRecipientAddress; const std::string p2pkh = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // v60 const std::string p2sh = "bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C"; // v85 multisig — the regression const std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5"; EXPECT_TRUE(isTransparentAddress(p2pkh)); EXPECT_TRUE(isTransparentAddress(p2sh)); // was silently dropped by the old 'R'-only filter EXPECT_FALSE(isShieldedAddress(p2pkh)); EXPECT_TRUE(isShieldedAddress(zaddr)); EXPECT_FALSE(isTransparentAddress(zaddr)); // Both kinds are DragonX addresses, but only a shielded one can be paid: the chain is private, so // consensus rejects a payment to a transparent address (it only ever receives mining rewards). using dragonx::util::isDragonXAddress; using dragonx::util::classifyRecipient; using dragonx::util::RecipientKind; EXPECT_TRUE(isDragonXAddress(p2pkh)); EXPECT_TRUE(isDragonXAddress(p2sh)); EXPECT_TRUE(isDragonXAddress(zaddr)); EXPECT_FALSE(isValidRecipientAddress(p2pkh)); EXPECT_FALSE(isValidRecipientAddress(p2sh)); EXPECT_TRUE(isValidRecipientAddress(zaddr)); EXPECT_TRUE(classifyRecipient(zaddr) == RecipientKind::Shielded); EXPECT_TRUE(classifyRecipient(p2pkh) == RecipientKind::MiningOnly); EXPECT_TRUE(classifyRecipient(p2sh) == RecipientKind::MiningOnly); EXPECT_TRUE(classifyRecipient("x") == RecipientKind::Invalid); EXPECT_TRUE(classifyRecipient("zivks1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn") == RecipientKind::Invalid); // A WIF spending key is NOT a recipient (33/34-byte payload, not 21); nor is a typo'd address. EXPECT_FALSE(isTransparentAddress("Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C")); EXPECT_FALSE(isDragonXAddress("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX")); // flipped checksum char EXPECT_FALSE(isValidRecipientAddress("")); using dragonx::util::validateRpcMethod; EXPECT_EQ(std::string(validateRpcMethod(classifyRecipient(zaddr))), std::string("z_validateaddress")); EXPECT_EQ(std::string(validateRpcMethod(classifyRecipient(p2pkh))), std::string("validateaddress")); EXPECT_EQ(std::string(validateRpcMethod(classifyRecipient(p2sh))), std::string("validateaddress")); EXPECT_TRUE(validateRpcMethod(classifyRecipient("x")) == nullptr); EXPECT_TRUE(validateRpcMethod(classifyRecipient("zivks1qqqsyqcyq5rqwzqfpg9scrgwpugpzysn")) == nullptr); // a key is never an address -> no RPC EXPECT_TRUE(validateRpcMethod(classifyRecipient("")) == nullptr); } // Import-key recognition: the client gate must accept every real DragonX key form and reject // non-keys, so it never blocks a valid import with "Unrecognized key format" (audit F1/F2/F3). void testPrivateKeyImportRecognition() { using dragonx::services::WalletSecurityController; using KeyKind = WalletSecurityController::KeyKind; // --- Transparent WIF (DragonX SECRET_KEY version 188; testnet 128) --- const std::string wifCompressed = "Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"; // v188 compressed 'U' const std::string wifUncompressed = "7JTPumX2kofLQdHKANy8MMLRkmXCmuJcosiv9f4RFqW9oCJXBHD"; // v188 uncompressed '7' const std::string wifTestnet = "KwFfpDsaF7yxCELuyrH9gP5XL7TAt5b9HPWC1xCQbmrxvhJgMQHb"; // v128 compressed EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifCompressed)); EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifUncompressed)); // F2 regression: '7' was rejected EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifTestnet)); EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(wifUncompressed)); EXPECT_EQ(WalletSecurityController::classifyPrivateKey(wifUncompressed), KeyKind::Transparent); EXPECT_FALSE(WalletSecurityController::isViewingKey(wifCompressed)); // A corrupted WIF (flipped last char) fails the checksum → caught locally, not at the daemon. std::string wifBad = wifCompressed; wifBad.back() = (wifBad.back() == 'C' ? 'D' : 'C'); EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(wifBad)); // A transparent R-address is Base58Check-valid but NOT a key (21-byte payload, not 33/34). EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti")); // --- Sapling incoming viewing key (mainnet HRP "zivks") — the F1 regression --- const std::string ivk = "zivks1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7rusq45amw7"; EXPECT_TRUE(WalletSecurityController::isViewingKey(ivk)); EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(ivk)); EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(ivk)); // a viewing key can't spend // The stale Zcash "zxview…" prefix is NOT a DragonX viewing key (and not valid Bech32 here). EXPECT_FALSE(WalletSecurityController::isViewingKey("zxviews1abcdef")); // --- Sapling z spending key (recognized by HRP prefix; daemon vets the long payload) --- const std::string zspend = "secret-extended-key-main1qxxxxxxxxxxxxxxxxxxxx"; EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(zspend)); EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(zspend)); EXPECT_EQ(WalletSecurityController::classifyPrivateKey(zspend), KeyKind::Shielded); // --- Garbage / empty --- EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("")); EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world")); } // Seed-phrase normalization + word count + the 24-word completeness gate. Guards the lite-restore // crash fix (only 24-word/32-byte-entropy seeds are safe for the SDXL backend) and the NBSP-paste // recovery fix. Both restore gates (first-run wizard + Settings) route through these. void testSeedPhraseHelpers() { using dragonx::util::normalizeSeedPhrase; using dragonx::util::seedPhraseWordCount; using dragonx::util::isCompleteRecoveryPhrase; // --- completeness gate: 24 words only (12/15/18/21 valid-BIP39 lengths crash the backend) --- EXPECT_TRUE(isCompleteRecoveryPhrase(24)); EXPECT_FALSE(isCompleteRecoveryPhrase(12)); EXPECT_FALSE(isCompleteRecoveryPhrase(15)); EXPECT_FALSE(isCompleteRecoveryPhrase(21)); EXPECT_FALSE(isCompleteRecoveryPhrase(23)); EXPECT_FALSE(isCompleteRecoveryPhrase(25)); EXPECT_FALSE(isCompleteRecoveryPhrase(0)); // --- plain ASCII: trim, collapse runs, count exactly; the common case must be untouched otherwise --- EXPECT_EQ(normalizeSeedPhrase(" alpha beta\tgamma\ndelta "), std::string("alpha beta gamma delta")); EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha beta gamma")), 3); EXPECT_EQ(seedPhraseWordCount(""), 0); EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(" \t \n ")), 0); // whitespace-only // --- NBSP (U+00A0 = 0xC2 0xA0) between words must fold to a real space, not glue the words --- EXPECT_EQ(normalizeSeedPhrase("alpha\xC2\xA0" "beta"), std::string("alpha beta")); EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha\xC2\xA0" "beta")), 2); // Other Unicode spaces: en space U+2002, ideographic U+3000, narrow NBSP U+202F. EXPECT_EQ(normalizeSeedPhrase("a\xE2\x80\x82" "b\xE3\x80\x80" "c\xE2\x80\xAF" "d"), std::string("a b c d")); // Zero-width chars (U+200B, U+FEFF BOM) are stripped, not treated as separators. EXPECT_EQ(normalizeSeedPhrase("\xEF\xBB\xBF" "alpha\xE2\x80\x8B beta"), std::string("alpha beta")); // --- a full 24-word phrase pasted with NBSP separators counts as 24 (regression for the fix) --- std::string words24; for (int i = 0; i < 24; ++i) { if (i) words24 += "\xC2\xA0"; words24 += "word"; } EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(words24)), 24); EXPECT_TRUE(isCompleteRecoveryPhrase(seedPhraseWordCount(normalizeSeedPhrase(words24)))); // The normalized form is plain single-space separated (what the backend's split(" ") needs). EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos); } // Block-DB abort detection: classify a crashed daemon's console output so the app can offer a // one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break). void testBlockDbOutputDiagnosis() { using dragonx::daemon::blockDbOutputLooksBroken; // The exact abort sequence we observed on a format-mismatched datadir. EXPECT_TRUE(blockDbOutputLooksBroken( "Opened LevelDB successfully\n" "GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n" "ERROR: LoadBlockIndex() : failed to read value\n" ": Error loading block database.\n" "Aborted block database rebuild. Exiting.\n")); // Each individual fatal marker also trips it (partial capture / different phrasing). EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database.")); EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting.")); EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value")); // Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer). EXPECT_FALSE(blockDbOutputLooksBroken( "Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n")); EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!")); EXPECT_FALSE(blockDbOutputLooksBroken("")); // Wallet auto-recovery detection: the node salvaged wallet.dat (moved the original to a .bak). using dragonx::daemon::walletAutoRecovered; EXPECT_TRUE(walletAutoRecovered( "Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ...")); EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed")); // The FAILED-salvage sequence a BDB-inconsistent wallet actually produces (must also be detected — // this is the case that previously slipped through and silently emptied the wallet). EXPECT_TRUE(walletAutoRecovered( "Renamed wallet.dat to wallet.1786375505.bak\n" "CDBEnv::Salvage: Database salvage found errors, all data may not be recoverable.\n" "Salvage(aggressive) found no records in wallet.1786375505.bak.\n")); EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n")); // normal load EXPECT_FALSE(walletAutoRecovered(": Error loading block database.")); // block-DB abort != salvage EXPECT_FALSE(walletAutoRecovered("")); // Newest salvage backup picker (the "wallet..bak" the recovery just made). using dragonx::daemon::newestWalletSalvageBak; EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.1786200000.bak", "wallet.1786300000.bak", "peers.dat"}), std::string("wallet.1786300000.bak")); // highest timestamp wins EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}), std::string("")); // no wallet..bak present EXPECT_EQ(newestWalletSalvageBak({}), std::string("")); // Restore must pick the LARGEST (least-salvaged) backup, NOT the newest — a salvage cascade shrinks // the wallet each round, so the newest .bak can be an emptied 40KB copy while the original is huge. using dragonx::daemon::largestWalletSalvageBak; EXPECT_EQ(largestWalletSalvageBak({ {"wallet.dat", 40000ull}, // current (salvaged, tiny) — not a .bak {"wallet.1786200000.bak", 194174976ull}, // ORIGINAL — oldest ts, biggest {"wallet.1786340620.bak", 40960ull}, // latest salvage — newest ts, empty {"peers.dat", 999ull}}), std::string("wallet.1786200000.bak")); // largest wins over newest EXPECT_EQ(largestWalletSalvageBak({{"wallet.dat", 100ull}, {"notes.txt", 5ull}}), std::string("")); EXPECT_EQ(largestWalletSalvageBak({}), std::string("")); } // 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(data.data()), data.size(), sk); auto b64 = [](const unsigned char* b, std::size_t n) { std::vector 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(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(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(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)); } // Reads any fixture file by repo-relative path under tests/fixtures/. static std::string readFixtureFile(const std::string& rel) { std::ifstream f(fixtureDir() + "/" + rel, std::ios::binary); return std::string((std::istreambuf_iterator(f)), std::istreambuf_iterator()); } void testXmrigReleaseListParsing() { using namespace dragonx::util; const auto list = parseXmrigReleaseList(readFixtureFile("xmrig/releases_list.json")); EXPECT_TRUE(list.size() >= 2); EXPECT_EQ(list.front().tag, std::string("v6.25.3")); // newest first (Gitea order preserved) for (const auto& r : list) { EXPECT_TRUE(r.ok); EXPECT_TRUE(!r.tag.empty()); EXPECT_TRUE(!r.assets.empty()); } // Fail closed on non-array / garbage. EXPECT_TRUE(parseXmrigReleaseList("not json").empty()); EXPECT_TRUE(parseXmrigReleaseList("{}").empty()); // Drafts are skipped; the pre-release flag is captured. const std::string inj = R"([ {"tag_name":"v9.9.9","prerelease":true,"assets":[{"name":"a-linux-x64.zip","browser_download_url":"https://x/a","size":1}]}, {"tag_name":"v9.9.8","draft":true,"assets":[]} ])"; const auto injList = parseXmrigReleaseList(inj); EXPECT_EQ(injList.size(), static_cast(1)); // draft dropped EXPECT_EQ(injList.front().tag, std::string("v9.9.9")); EXPECT_TRUE(injList.front().prerelease); } // ── daemon updater pure core (util/daemon_updater.h) — driven by the real v1.0.2 release fixture ── static std::string readDaemonFixture() { return readFixtureFile("daemon/release_latest.json"); } void testDaemonReleaseParsing() { using namespace dragonx::util; const auto rel = parseDaemonRelease(readDaemonFixture()); EXPECT_TRUE(rel.ok); EXPECT_EQ(rel.tag, std::string("v1.0.2")); EXPECT_EQ(rel.assets.size(), static_cast(3)); // linux-amd64 / macos / win64 for (const auto& a : rel.assets) { EXPECT_TRUE(!a.name.empty()); EXPECT_TRUE(a.downloadUrl.rfind("https://", 0) == 0); EXPECT_TRUE(a.size > 0); } EXPECT_FALSE(parseDaemonRelease("not json at all").ok); EXPECT_FALSE(parseDaemonRelease("{}").ok); // no tag_name } void testDaemonAssetSelection() { using namespace dragonx::util; const auto rel = parseDaemonRelease(readDaemonFixture()); const int lin = selectDaemonAsset(rel, "linux-amd64"); const int mac = selectDaemonAsset(rel, "macos"); const int win = selectDaemonAsset(rel, "win64"); EXPECT_TRUE(lin >= 0 && mac >= 0 && win >= 0); EXPECT_TRUE(lin != mac && mac != win && lin != win); // Guard the index reads (select*Asset returns -1 on no match; EXPECT_TRUE doesn't abort here) so a // fixture/parser regression reports the failed EXPECT above instead of an OOB rel.assets[-1] crash. if (lin >= 0) EXPECT_TRUE(rel.assets[lin].name.find("linux-amd64.zip") != std::string::npos); if (mac >= 0) EXPECT_TRUE(rel.assets[mac].name.find("macos.zip") != std::string::npos); if (win >= 0) EXPECT_TRUE(rel.assets[win].name.find("win64.zip") != std::string::npos); // Wrong/foreign tokens (e.g. the miner's naming) must NOT match the daemon archives. EXPECT_EQ(selectDaemonAsset(rel, "linux-x64"), -1); EXPECT_EQ(selectDaemonAsset(rel, "linux-arm64"), -1); EXPECT_EQ(selectDaemonAsset(rel, ""), -1); } void testDaemonChecksumParsing() { using namespace dragonx::util; const auto rel = parseDaemonRelease(readDaemonFixture()); const auto sums = parseDaemonChecksums(rel.body); // The markdown checksum table ( "| .zip | `` |" ) is parsed by archive name. EXPECT_EQ(sums.at("dragonx-1.0.2-linux-amd64.zip"), std::string("85f1dd908bfbdee6aaebabdc74848dc8963d45e7510172d84d0230c0fad6cc16")); EXPECT_EQ(sums.at("dragonx-1.0.2-macos.zip"), std::string("102e1f1ecab05def25465ad4867b19d46c7cc00a9fbb018ffb405bcb82cc6432")); EXPECT_EQ(sums.at("dragonx-1.0.2-win64.zip"), std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a")); // Header/separator/prose rows are ignored. EXPECT_TRUE(parseDaemonChecksums("| File | SHA-256 |\n|---|---|\njust prose, no hashes").empty()); // Keys are lowercased so the (also-lowercased) lookup is case-insensitive vs the JSON names. const auto mixed = parseDaemonChecksums( "| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |"); EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"), std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a")); // Regression: a markdown-bolded filename (**archive.zip**) must still parse — otherwise a valid, // correctly-signed release whose body bolds the name would fail checksum lookup and be refused. const auto bold = parseDaemonChecksums( "| **dragonx-1.0.2-win64.zip** | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |"); EXPECT_EQ(bold.at("dragonx-1.0.2-win64.zip"), std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a")); } void testDaemonBasenamesAndVersionCore() { using namespace dragonx::util; const auto posix = daemonExtractBasenames("linux-amd64"); EXPECT_EQ(posix.size(), static_cast(3)); EXPECT_EQ(posix.front(), std::string("dragonxd")); // daemon binary is always first EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-cli")) != posix.end()); EXPECT_TRUE(std::find(posix.begin(), posix.end(), std::string("dragonx-tx")) != posix.end()); const auto win = daemonExtractBasenames("win64"); EXPECT_EQ(win.front(), std::string("dragonxd.exe")); EXPECT_TRUE(std::find(win.begin(), win.end(), std::string("dragonx-cli.exe")) != win.end()); // Version-core normalization: a scanned "vX.Y.Z-" must compare equal to a release tag. EXPECT_EQ(daemonVersionCore("v1.0.2-ddd851dc1"), std::string("v1.0.2")); EXPECT_EQ(daemonVersionCore("v1.0.2"), std::string("v1.0.2")); EXPECT_EQ(daemonVersionCore("1.0.2"), std::string("1.0.2")); EXPECT_EQ(daemonVersionCore("DragonX v1.0.2 (abc)"), std::string("v1.0.2")); EXPECT_EQ(daemonVersionCore("v1.2"), std::string("v1.2")); // not full N.N.N -> unchanged EXPECT_EQ(daemonVersionCore(""), std::string("")); EXPECT_EQ(daemonVersionCore("nightly"), std::string("nightly")); } void testDaemonSignatureAssetSelection() { using namespace dragonx::util; DaemonRelease rel; rel.ok = true; rel.tag = "v1.0.2"; rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip", "https://x/zip", 100}); EXPECT_EQ(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip"), -1); // none published rel.assets.push_back({"dragonx-1.0.2-linux-amd64.zip.sig", "https://x/sig", 64}); EXPECT_TRUE(selectDaemonSignatureAsset(rel, "dragonx-1.0.2-linux-amd64.zip") >= 0); EXPECT_EQ(selectDaemonSignatureAsset(rel, "other.zip"), -1); } void testDaemonPinnedKeyValidity() { using namespace dragonx::util; // The daemon updater REQUIRES signatures, so the pinned key must be a valid 32-byte ed25519 key. const std::string pin = kDaemonSignaturePublicKeyBase64; EXPECT_TRUE(kDaemonRequireSignature); EXPECT_TRUE(!pin.empty()); 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(crypto_sign_PUBLICKEYBYTES)); } void testDaemonSignatureInterop() { using namespace dragonx::util; // Known answer produced by scripts/sign-daemon-release.sh (OpenSSL ed25519) over the message // below with the pinned key's secret half. Proves the pinned key + the release-signing flow // produce signatures the wallet's libsodium verifier accepts (closes the OpenSSL<->libsodium // interop loop), and that the pinned public key actually matches the signing key. const std::string msg = "dragonx daemon archive payload bytes for signing test"; const std::string sigB64 = "rmQ5qOw+W8vu56GeZrooD7Wh1N/WHRP4siD19Mxq/8WXQQuNrFY3DPCNU9C7jHB2jg/VfKrLVna57K/lkSDBDA=="; const std::string pin = kDaemonSignaturePublicKeyBase64; EXPECT_TRUE(verifyXmrigSignature(msg, sigB64, pin)); EXPECT_TRUE(verifyXmrigSignature(msg, " " + sigB64 + "\n", pin)); // trailing newline tolerated // Fails closed on tampered payload or wrong key. EXPECT_FALSE(verifyXmrigSignature(msg + "x", sigB64, pin)); EXPECT_FALSE(verifyXmrigSignature(msg, sigB64, std::string(kXmrigSignaturePublicKeyBase64))); } void testDaemonReleaseListParsing() { using namespace dragonx::util; const auto list = parseDaemonReleaseList(readFixtureFile("daemon/releases_list.json")); EXPECT_TRUE(list.size() >= 2); EXPECT_EQ(list.front().tag, std::string("v1.0.2")); // newest first for (const auto& r : list) { EXPECT_TRUE(r.ok); EXPECT_TRUE(!r.assets.empty()); } EXPECT_TRUE(parseDaemonReleaseList("not json").empty()); EXPECT_TRUE(parseDaemonReleaseList("{}").empty()); // Draft skipped; pre-release + name + published_at captured. const std::string inj = R"([ {"tag_name":"v2.0.0","prerelease":true,"name":"RC","published_at":"2026-09-01T00:00:00Z","assets":[{"name":"dragonx-2.0.0-linux-amd64.zip","browser_download_url":"https://x/a","size":1}]}, {"tag_name":"v1.9.9","draft":true,"assets":[]} ])"; const auto injList = parseDaemonReleaseList(inj); EXPECT_EQ(injList.size(), static_cast(1)); EXPECT_TRUE(injList.front().prerelease); EXPECT_EQ(injList.front().name, std::string("RC")); EXPECT_EQ(injList.front().publishedAt.substr(0, 10), std::string("2026-09-01")); } // 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 = testTmpPath("obsidian-xmrig-live-test").string(); 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); } // Mining pool registry: URL matching + per-pool algo resolution. void testPoolRegistryLookup() { using namespace dragonx::util; // pool.dragonx.cc was removed from the built-in defaults; pool.dragonx.is is the sole entry. EXPECT_EQ(knownPools().size(), static_cast(1)); // The algo follows the pool: pool.dragonx.is resolves to rx/hush regardless of the caller default. EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush")); // The former us.dragonx.cc (pool.dragonx.cc) host is now unknown -> caller's fallback algo. EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/hush")); EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush")); EXPECT_TRUE(findKnownPoolByUrl("pool.dragonx.is:3433") != nullptr); EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") == nullptr); // removed built-in default EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr); // A mismatched explicit port must NOT match a known pool. EXPECT_TRUE(findKnownPoolByUrl("pool.dragonx.is:9999") == nullptr); } // Schema-aware pool hashrate parsing (the two pools speak different APIs). void testPoolHashrateParsing() { using namespace dragonx::util; bool ok = false; // pool.dragonx.is custom schema: pools.dragonx.hashrate const std::string isJson = R"({"pools":{"dragonx":{"hashrate":27670.14,"workerCount":12}},"global":{"hashrate":30000.0}})"; double hr = parsePoolHashrate(PoolStatsSchema::DragonXIs, isJson, "", ok); EXPECT_TRUE(ok); EXPECT_NEAR(hr, 27670.14, 0.01); // Miningcore schema: the requested pool id selects the entry (not just the first). const std::string ccJson = R"({"pools":[)" R"({"id":"dragonx-solo","poolStats":{"poolHashrate":88780.0}},)" R"({"id":"dragonx-pplns","poolStats":{"poolHashrate":1585.9}}]})"; hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok); EXPECT_TRUE(ok); EXPECT_NEAR(hr, 1585.9, 0.01); hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-solo", ok); EXPECT_TRUE(ok); EXPECT_NEAR(hr, 88780.0, 0.01); // Missing pool id / malformed / wrong-schema input all fail closed (no silent 0). parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok); EXPECT_FALSE(ok); parsePoolHashrate(PoolStatsSchema::DragonXIs, "not json", "", ok); EXPECT_FALSE(ok); parsePoolHashrate(PoolStatsSchema::Miningcore, "{}", "dragonx-pplns", ok); EXPECT_FALSE(ok); parsePoolHashrate(PoolStatsSchema::DragonXIs, ccJson, "", ok); // .cc JSON, .is parser EXPECT_FALSE(ok); } // Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display). void testPoolFeeParsing() { using namespace dragonx::util; bool ok = false; // pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number). const std::string isJson = R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})"; double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok); EXPECT_TRUE(ok); EXPECT_NEAR(fee, 1.0, 0.001); // Fractional fees survive (display rounds, but the parse must not). const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})"; fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok); EXPECT_TRUE(ok); EXPECT_NEAR(fee, 1.5, 0.001); // Miningcore schema: the requested pool id's poolFeePercent. const std::string ccJson = R"({"pools":[)" R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)" R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})"; fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok); EXPECT_TRUE(ok); EXPECT_NEAR(fee, 0.9, 0.001); // Missing field / malformed / wrong-schema input all fail closed (caller keeps // the compile-time fallback rather than showing a bogus 0%). parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok); EXPECT_FALSE(ok); // no poolFee key parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok); EXPECT_FALSE(ok); parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok); EXPECT_FALSE(ok); parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok); EXPECT_FALSE(ok); // string, not number } void testEffectClock() { using namespace dragonx::ui::effects; const double t7d = 7 * 86400.0, dt = 1.0 / 60.0; // smooth after 7 days: consecutive frames advance by rate*dt (0.3/60 = 0.005 cycle) for (int k = 0; k < 120; ++k) { float a = cyclePhase(t7d + k * dt, 0.3), b = cyclePhase(t7d + (k + 1) * dt, 0.3); float d = b - a; if (d < -0.5f) d += 1.0f; // wrap EXPECT_NEAR(d, 0.005f, 1e-4f); } // legacy float clock pins the bug: at 30 days one float ULP is 0.25 s const double t30 = 30 * 86400.0; EXPECT_TRUE((float)(t30 + 0.5 / 60.0) == (float)(t30 + 1.5 / 60.0)); EXPECT_TRUE(cyclePhase(t30 + 0.5 / 60.0, 0.3) != cyclePhase(t30 + 1.5 / 60.0, 0.3)); // range and negative offsets EXPECT_NEAR(cyclePhase(1.0, 0.0, -0.25), 0.75f, 1e-6f); for (double t = 0; t < 1e7; t += 123457.7) { float p = cyclePhase(t, 0.37, -0.1); EXPECT_TRUE(p >= 0.0f && p < 1.0f); } EXPECT_NEAR(cycleSin(t7d + 0.25, 1.0), 1.0f, 1e-5f); EXPECT_NEAR(radSin(1e6, 0.0, 1.5707963267948966), 1.0f, 1e-6f); } void testBorderSampling() { using namespace dragonx::ui::effects; float fr[129]; int n = BorderSampleFractions(500, 150, 8, 32, fr, 129); EXPECT_TRUE(n >= 40 && n <= 80); // old formula: ~210 EXPECT_EQ(fr[0], 0.0f); EXPECT_EQ(fr[n], 1.0f); for (int i = 1; i <= n; ++i) EXPECT_TRUE(fr[i] > fr[i - 1]); n = BorderSampleFractions(3000, 800, 12, 32, fr, 129); EXPECT_TRUE(n <= 128); // old: 256 n = BorderSampleFractions(300, 100, 0, 32, fr, 129); EXPECT_TRUE(n >= 4 && n <= 40); // r=0 rectangle // each corner arc keeps >= 3 chords: top-right arc spans [s1/P, s2/P] { float r = 12, w = 400, h = 120, P = 2*(w-2*r) + 2*(h-2*r) + 4*(3.14159265f*r*0.5f); n = BorderSampleFractions(w, h, r, 32, fr, 129); float lo = (w-2*r)/P, hi = lo + 3.14159265f*r*0.5f/P; int c = 0; for (int i = 1; i <= n; ++i) if (fr[i] > lo + 1e-5f && fr[i] <= hi + 1e-5f) ++c; EXPECT_TRUE(c >= 3); } n = BorderSampleFractions(0, 50, 4, 32, fr, 129); EXPECT_EQ(n, 1); // degenerate } void testRectCull() { using namespace dragonx::ui::material; ImVec2 a(0,0), b(100,100); EXPECT_TRUE(!RectOutsideRect(ImVec2(10,10), ImVec2(50,50), a, b, 0)); EXPECT_TRUE(RectOutsideRect(ImVec2(0,150), ImVec2(80,250), a, b, 16)); // below EXPECT_TRUE(!RectOutsideRect(ImVec2(0,110), ImVec2(80,250), a, b, 16)); // inside margin EXPECT_TRUE(RectOutsideRect(ImVec2(-90,0), ImVec2(-20,50), a, b, 16)); // left } // The effective pool list = official pools ∪ saved favorites ∪ current custom pool, // endpoint-deduped, with synthetic user rows flagged official=false. void testEffectivePools() { using namespace dragonx::util; const int base = (int)knownPools().size(); // Current pool is the official one, nothing saved -> just the official pools. auto a = effectivePools("pool.dragonx.is:3433", {}); EXPECT_EQ((int)a.size(), base); // A custom current pool (neither official nor saved) appears as an extra row. auto b = effectivePools("my.pool.example:3333", {}); EXPECT_EQ((int)b.size(), base + 1); const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333"); EXPECT_TRUE(custom != nullptr); EXPECT_FALSE(custom->official); EXPECT_TRUE(custom->feePercent < 0.0); // unknown fee // Saved pools are appended; an official one among them and a duplicate collapse. auto c = effectivePools("pool.dragonx.is:3433", {"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"}); EXPECT_EQ((int)c.size(), base + 1); EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr); // Current pool equal to a saved one is not listed twice. auto d = effectivePools("alt.pool:1", {"alt.pool:1"}); EXPECT_EQ((int)d.size(), base + 1); // Blank/whitespace URLs are ignored (no phantom rows). auto e = effectivePools(" ", {"", " "}); EXPECT_EQ((int)e.size(), base); } // Fee formatting: whole numbers stay clean, fractional fees keep their decimals. void testFormatFeePercent() { using dragonx::ui::FormatFeePercent; EXPECT_TRUE(FormatFeePercent(1.0) == "1"); EXPECT_TRUE(FormatFeePercent(0.0) == "0"); EXPECT_TRUE(FormatFeePercent(3.0) == "3"); EXPECT_TRUE(FormatFeePercent(1.5) == "1.5"); EXPECT_TRUE(FormatFeePercent(0.9) == "0.9"); EXPECT_TRUE(FormatFeePercent(1.25) == "1.25"); EXPECT_TRUE(FormatFeePercent(2.50) == "2.5"); // trailing zero trimmed EXPECT_TRUE(FormatFeePercent(100.0) == "100"); } // Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe. void testPoolWeightedSelection() { using namespace dragonx::util; std::mt19937 rng(12345u); // fixed seed -> deterministic std::vector pools = { {"big", 100000.0, true}, // large -> low weight {"small", 1000.0, true}, // small -> high weight (favored) }; int smallCount = 0, bigCount = 0; for (int i = 0; i < 2000; ++i) { const std::string pick = chooseWeightedPool(pools, /*currentId=*/"", rng); if (pick == "small") ++smallCount; else if (pick == "big") ++bigCount; } EXPECT_TRUE(smallCount > bigCount * 5); // ~100:1 by weight // Only one usable pool -> always chosen; no usable pool -> keep current. std::vector one = {{"a", 5.0, true}, {"b", 1.0, false}}; EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a")); std::vector none = {{"a", 5.0, false}, {"b", 1.0, false}}; EXPECT_EQ(chooseWeightedPool(none, "keepme", rng), std::string("keepme")); // Incumbent stickiness: with equal hashrates the current pool wins the majority. std::vector equal = {{"x", 1000.0, true}, {"y", 1000.0, true}}; int stay = 0; for (int i = 0; i < 2000; ++i) if (chooseWeightedPool(equal, "x", rng) == "x") ++stay; EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias } // HushChat crypto core: seed-derived identity + secretstream encrypt/decrypt round-trip. // Pass featureEnabled=true so the crypto is exercised even in the default (chat-OFF) build. void testHushChatCrypto() { using namespace dragonx::chat; ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-wallet-secret", alice, /*featureEnabled=*/true); ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-wallet-secret", bob, /*featureEnabled=*/true); EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready); EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready); EXPECT_EQ((int)ra.public_key_hex.size(), 64); EXPECT_TRUE(ra.public_key_hex != rb.public_key_hex); // Deterministic: the same secret always yields the same identity. ChatKeyPair alice2; ChatIdentityResult ra2 = deriveChatIdentityFromSecret("alice-wallet-secret", alice2, true); EXPECT_EQ(ra.public_key_hex, ra2.public_key_hex); // Feature gate: featureEnabled=false yields FeatureDisabled (and error_name matches). ChatKeyPair off; ChatIdentityResult roff = deriveChatIdentityFromSecret("x", off, false); EXPECT_TRUE(roff.status == ChatIdentityStatus::FeatureDisabled); EXPECT_EQ(std::string(roff.error_name), std::string("FeatureDisabled")); // Empty secret is rejected. ChatKeyPair none; EXPECT_TRUE(deriveChatIdentityFromSecret("", none, true).status == ChatIdentityStatus::SecretUnavailable); // Round-trip: Alice (server role) encrypts to Bob; Bob (client role) decrypts from Alice. std::string headerHex, cipherHex; ChatCryptoStatus es = encryptOutgoing(alice, rb.public_key_hex, "hello bob \xF0\x9F\x90\x89", headerHex, cipherHex); EXPECT_TRUE(es == ChatCryptoStatus::Ok); EXPECT_EQ((int)headerHex.size(), 48); // 24-byte secretstream header as hex std::string plain; ChatCryptoStatus ds = decryptIncoming(bob, ra.public_key_hex, headerHex, cipherHex, plain); EXPECT_TRUE(ds == ChatCryptoStatus::Ok); EXPECT_EQ(plain, std::string("hello bob \xF0\x9F\x90\x89")); // Empty plaintext round-trips symmetrically (the crypto layer imposes no content policy). std::string emptyHeader, emptyCipher, emptyPlain; EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "", emptyHeader, emptyCipher) == ChatCryptoStatus::Ok); EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, emptyHeader, emptyCipher, emptyPlain) == ChatCryptoStatus::Ok); EXPECT_EQ(emptyPlain, std::string("")); // Tampered ciphertext fails the auth tag (neutral DecryptFailed, not a crash). std::string tampered = cipherHex; tampered.back() = (tampered.back() == '0') ? '1' : '0'; std::string plain2; EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, tampered, plain2) != ChatCryptoStatus::Ok); // Wrong recipient key can't derive the session and/or fails auth. std::string plain3; ChatKeyPair mallory; deriveChatIdentityFromSecret("mallory-secret", mallory, true); EXPECT_TRUE(decryptIncoming(mallory, ra.public_key_hex, headerHex, cipherHex, plain3) != ChatCryptoStatus::Ok); // Malformed inputs are rejected without touching the tag path. std::string plain4; EXPECT_TRUE(decryptIncoming(bob, "not-hex", headerHex, cipherHex, plain4) == ChatCryptoStatus::BadPeerKey); EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, "00", plain4) == ChatCryptoStatus::CiphertextTooShort); } // End-to-end receive path: identity -> encrypt -> HushChat memo pair -> parser harvest // (extractHushChatTransactionMetadata now carries e/p/ciphertext, Phase 1 Step 1) -> decrypt. void testHushChatReceivePath() { using namespace dragonx::chat; ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-recv", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-recv", bob, true); EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready); EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready); // Alice encrypts to Bob, wrapped as a HushChat header memo + payload memo. std::string e, ct; EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "gm bob", e, ct) == ChatCryptoStatus::Ok); std::string headerMemo = std::string("{\"cid\":\"conv-42\",\"e\":\"") + e + "\",\"h\":1,\"p\":\"" + ra.public_key_hex + "\",\"t\":\"Memo\",\"v\":0,\"z\":\"zs-alice-reply\"}"; HushChatTransactionInput tx; tx.txid = "txid-recv-1"; tx.outputs.push_back(HushChatMemoOutput{0, headerMemo}); tx.outputs.push_back(HushChatMemoOutput{1, ct}); HushChatTransactionExtractionResult ext = extractHushChatTransactionMetadata(tx, true); EXPECT_TRUE(ext.feature_enabled); EXPECT_EQ((int)ext.metadata.size(), 1); const HushChatTransactionMetadata& m = ext.metadata[0]; EXPECT_TRUE(m.type == HushChatHeaderType::Message); EXPECT_EQ(m.sender_public_key_hex, ra.public_key_hex); EXPECT_EQ(m.secretstream_header_hex, e); EXPECT_EQ(m.payload_memo, ct); // Bob decrypts straight from the carried metadata — the whole receive path. std::string plain; EXPECT_TRUE(decryptIncoming(bob, m.sender_public_key_hex, m.secretstream_header_hex, m.payload_memo, plain) == ChatCryptoStatus::Ok); EXPECT_EQ(plain, std::string("gm bob")); // Feature disabled -> the harvest yields nothing. HushChatTransactionExtractionResult off = extractHushChatTransactionMetadata(tx, false); EXPECT_TRUE(!off.feature_enabled); EXPECT_EQ((int)off.metadata.size(), 0); } // ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store. void testHushChatService() { using namespace dragonx::chat; ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-svc", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-svc", bob, true); // Two Alice->Bob messages in one conversation, as harvested metadata. std::vector batch; for (int i = 0; i < 2; ++i) { std::string e, ct; EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, i == 0 ? "first" : "second", e, ct) == ChatCryptoStatus::Ok); HushChatTransactionMetadata m; m.txid = std::string("tx") + std::to_string(i); m.type = HushChatHeaderType::Message; m.conversation_id = "conv-x"; m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex; m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1; batch.push_back(m); } ChatService svc; EXPECT_TRUE(!svc.hasIdentity()); EXPECT_EQ(svc.ingest(batch, {}), 0); // no identity -> nothing ingested svc.setIdentity(bob); EXPECT_TRUE(svc.hasIdentity()); EXPECT_FALSE(svc.store().contains("tx0", 1)); EXPECT_EQ(svc.ingest(batch, {}, 1000), 2); EXPECT_EQ(svc.decryptAttempts(), 2u); EXPECT_EQ((int)svc.store().size(), 2); EXPECT_EQ(svc.ingest(batch, {}, 1000), 0); // re-scan dedups EXPECT_EQ(svc.decryptAttempts(), 2u); // ...without decrypting the stored ones again EXPECT_EQ((int)svc.store().size(), 2); EXPECT_TRUE(svc.store().contains("tx0", 1)); EXPECT_FALSE(svc.store().contains("tx0", 0)); std::vector conv = svc.store().conversation("conv-x"); EXPECT_EQ((int)conv.size(), 2); EXPECT_EQ(conv[0].body, std::string("first")); EXPECT_EQ(conv[1].body, std::string("second")); EXPECT_TRUE(conv[0].direction == ChatDirection::Incoming); EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); EXPECT_EQ(conv[0].timestamp, (std::int64_t)1000); // A contact request carries plaintext through without decryption. std::vector creq(1); creq[0].txid = "txc"; creq[0].type = HushChatHeaderType::ContactRequest; creq[0].conversation_id = "conv-y"; creq[0].sender_public_key_hex = ra.public_key_hex; creq[0].payload_memo = "hi, add me"; creq[0].payload_position = 1; EXPECT_EQ(svc.ingest(creq, {}), 1); EXPECT_EQ(svc.decryptAttempts(), 2u); // a contact request isn't decrypted std::vector cy = svc.store().conversation("conv-y"); EXPECT_EQ((int)cy.size(), 1); EXPECT_TRUE(cy[0].kind == ChatMessageKind::ContactRequest); EXPECT_EQ(cy[0].body, std::string("hi, add me")); // Wrong identity can't decrypt -> messages dropped silently. ChatService svc2; ChatKeyPair mallory; deriveChatIdentityFromSecret("mallory-svc", mallory, true); svc2.setIdentity(mallory); EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); EXPECT_TRUE(svc2.store().empty()); EXPECT_EQ(svc2.decryptAttempts(), 2u); EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); EXPECT_EQ(svc2.decryptAttempts(), 2u); // undecryptable: not tried again... svc2.setIdentity(mallory); EXPECT_EQ(svc2.ingest(batch, {}, 1000), 0); EXPECT_EQ(svc2.decryptAttempts(), 4u); // ...until the identity is set again // A junk payload is tried once; the same (txid, position) with another header gets its own try. std::vector junk{batch[0]}; junk[0].txid = "txbad"; junk[0].payload_memo = "zz"; const auto before = svc.decryptAttempts(); EXPECT_EQ(svc.ingest(junk, {}, 1000), 0); EXPECT_EQ(svc.ingest(junk, {}, 1000), 0); EXPECT_EQ(svc.decryptAttempts(), before + 1); junk[0].secretstream_header_hex = batch[1].secretstream_header_hex; EXPECT_EQ(svc.ingest(junk, {}, 1000), 0); EXPECT_EQ(svc.decryptAttempts(), before + 2); // An erased conversation's messages aren't "stored" any more (a later scan re-ingests them). svc.store().eraseConversation("conv-x"); EXPECT_FALSE(svc.store().contains("tx0", 1)); svc.store().clear(); EXPECT_FALSE(svc.store().contains("txc", 1)); } // Phase 2: persistent, seed-encrypted store — round-trip, dedup, per-wallet isolation, and // ChatService write-through + reload (reload needs only the storage key, not the chat identity). void testHushChatDatabase() { using namespace dragonx::chat; namespace fs = std::filesystem; const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_db_test.sqlite").string(); const std::string dbPath2 = (fs::temp_directory_path() / "drgx_chat_db_test2.sqlite").string(); auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); }; scrub(dbPath); scrub(dbPath2); const std::string seedA = "wallet A seed phrase words here"; const std::string seedB = "a completely different wallet B seed"; auto makeMsg = [](const std::string& txid, std::size_t pos, const std::string& cid, const std::string& body, ChatMessageKind kind, std::int64_t ts) { ChatMessage m; m.direction = ChatDirection::Incoming; m.kind = kind; m.txid = txid; m.conversation_id = cid; m.peer_zaddr = "zs-peer"; m.peer_public_key_hex = "deadbeef"; m.body = body; m.timestamp = ts; m.payload_position = pos; return m; }; // Locked DB is inert; unlock, then write three (with a duplicate that is ignored). { ChatDatabase db(dbPath); EXPECT_TRUE(!db.hasKey()); EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); EXPECT_TRUE(db.unlockWithSecret(seedA)); EXPECT_TRUE(db.hasKey()); EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111))); EXPECT_TRUE(db.append(makeMsg("t2", 1, "c", "world", ChatMessageKind::Message, 222))); EXPECT_TRUE(db.append(makeMsg("t3", 0, "c", "add me", ChatMessageKind::ContactRequest, 333))); EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "hello", ChatMessageKind::Message, 111)) == false); // dup } // Reopen with the SAME seed → messages persist, fields + order intact. { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret(seedA)); std::vector all = db.load(); EXPECT_EQ((int)all.size(), 3); EXPECT_EQ(all[0].body, std::string("hello")); EXPECT_EQ(all[0].txid, std::string("t1")); EXPECT_EQ(all[0].timestamp, (std::int64_t)111); EXPECT_EQ(all[1].body, std::string("world")); EXPECT_TRUE(all[2].kind == ChatMessageKind::ContactRequest); EXPECT_EQ(all[2].body, std::string("add me")); EXPECT_EQ(all[2].peer_public_key_hex, std::string("deadbeef")); } // A DIFFERENT seed is partitioned + can't decrypt → sees nothing, writes in isolation. { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret(seedB)); EXPECT_TRUE(db.load().empty()); EXPECT_TRUE(db.append(makeMsg("t1", 1, "c", "B-secret", ChatMessageKind::Message, 999))); EXPECT_EQ((int)db.load().size(), 1); } // ...and wallet A still sees exactly its three. { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret(seedA)); EXPECT_EQ((int)db.load().size(), 3); db.lock(); EXPECT_TRUE(!db.hasKey()); EXPECT_TRUE(db.load().empty()); // inert once locked } // End-to-end: ChatService write-through on ingest, then reload into a fresh service WITHOUT an // identity (proves stored messages are decryptable with the storage key alone). { ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("db-alice", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("db-bob", bob, true); std::string e, ct; EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "persisted!", e, ct) == ChatCryptoStatus::Ok); HushChatTransactionMetadata m; m.txid = "txp"; m.type = HushChatHeaderType::Message; m.conversation_id = "cp"; m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex; m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1; std::vector batch{m}; std::unordered_map times{{"txp", 4242}}; { ChatDatabase db(dbPath2); EXPECT_TRUE(db.unlockWithSecret("e2e-seed")); ChatService svc; svc.setPersistence(&db); svc.setIdentity(bob); EXPECT_EQ(svc.ingest(batch, times), 1); } { ChatDatabase db(dbPath2); EXPECT_TRUE(db.unlockWithSecret("e2e-seed")); ChatService svc; svc.setPersistence(&db); svc.loadFromDatabase(); // no setIdentity() — reload doesn't need it std::vector conv = svc.store().conversation("cp"); EXPECT_EQ((int)conv.size(), 1); EXPECT_EQ(conv[0].body, std::string("persisted!")); EXPECT_EQ(conv[0].timestamp, (std::int64_t)4242); svc.setIdentity(bob); EXPECT_EQ(svc.ingest(batch, times), 0); // reloaded: no decrypt for what is already stored EXPECT_EQ(svc.decryptAttempts(), 0u); } } // Delivery status persists (a failed outgoing echo round-trips as Failed). { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret("delivery-seed")); ChatMessage m = makeMsg("tD", 1, "cD", "not-sent one", ChatMessageKind::Message, 555); m.direction = ChatDirection::Outgoing; m.delivery = ChatDelivery::Failed; EXPECT_TRUE(db.append(m)); std::vector loaded = db.load(); EXPECT_EQ((int)loaded.size(), 1); EXPECT_TRUE(loaded[0].delivery == ChatDelivery::Failed); } // appendBatch: one transaction, dedup, order, tombstone-skip, bad-UTF-8 isolation. scrub(dbPath); { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret(seedA)); std::vector batch = { makeMsg("b1", 0, "c", "one", ChatMessageKind::Message, 1), makeMsg("b2", 0, "c", "two", ChatMessageKind::Message, 2), makeMsg("b3", 0, "c", "three", ChatMessageKind::Message, 3), }; EXPECT_EQ(db.appendBatch(batch), 3); EXPECT_EQ(db.appendBatch(batch), 0); // dedup: all ignored EXPECT_EQ((int)db.appendBatch({}), 0); // empty → no-op // bad UTF-8 body is skipped, the valid sibling still persists, and append() still works after. ChatMessage bad = makeMsg("b4", 0, "c", std::string("\xff\xfe", 2), ChatMessageKind::Message, 4); ChatMessage good = makeMsg("b5", 0, "c", "five", ChatMessageKind::Message, 5); EXPECT_EQ(db.appendBatch({bad, good}), 1); // only good persisted, no open txn left EXPECT_TRUE(db.append(makeMsg("b6", 0, "c", "six", ChatMessageKind::Message, 6))); std::vector all = db.load(); EXPECT_EQ((int)all.size(), 5); // b1,b2,b3,b5,b6 EXPECT_EQ(all[0].body, std::string("one")); // rowid/insert order EXPECT_EQ(all[4].body, std::string("six")); } { ChatDatabase locked(dbPath); // no unlock → inert EXPECT_EQ(locked.appendBatch({makeMsg("x", 0, "c", "x", ChatMessageKind::Message, 0)}), 0); } scrub(dbPath); scrub(dbPath2); } // Per-conversation local delete: (1) "delete, revive on new message" tombstones the removed messages so // a chain re-scan can't re-import them — but a NEW txid in the same conversation flows through; the // tombstone survives a DB reload. (2) "delete & block" removes the rows WITHOUT a tombstone and relies on // a blocked-cid predicate to drop messages while blocked; unblocking re-imports the conversation. void testHushChatDeleteConversation() { using namespace dragonx::chat; namespace fs = std::filesystem; const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_del_test.sqlite").string(); auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); }; scrub(dbPath); ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("del-alice", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("del-bob", bob, true); // Build one incoming (alice->bob) metadata entry. encryptOutgoing produces a fresh ciphertext each // call, but dedup/tombstone key off (txid, position) only, so re-using the same txids re-scans them. auto mkMeta = [&](const std::string& txid, const std::string& cid, const std::string& body) { std::string e, ct; EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, body, e, ct) == ChatCryptoStatus::Ok); HushChatTransactionMetadata m; m.txid = txid; m.type = HushChatHeaderType::Message; m.conversation_id = cid; m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex; m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1; return m; }; std::vector convX{ mkMeta("dx1", "conv-x", "x-one"), mkMeta("dx2", "conv-x", "x-two") }; std::vector convY{ mkMeta("dy1", "conv-y", "y-one") }; // (1) Delete with revive-on-new-message. { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret("del-seed")); ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db); EXPECT_EQ(svc.ingest(convX, {}, 100), 2); EXPECT_EQ(svc.ingest(convY, {}, 100), 1); EXPECT_EQ((int)svc.store().size(), 3); EXPECT_TRUE(svc.deleteConversation("conv-x", /*block=*/false)); EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0); EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); // untouched EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // re-scan: tombstoned, not re-imported EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0); std::vector convXnew{ mkMeta("dx3", "conv-x", "x-three") }; EXPECT_EQ(svc.ingest(convXnew, {}, 200), 1); // NEW txid revives the thread EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three")); } // (2) Tombstone survives a DB reload into a fresh service. { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret("del-seed")); ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db); svc.loadFromDatabase(); EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); // only the revived dx3 persisted EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three")); EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // dx1/dx2 still tombstoned after reload EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); } // (3) Delete & block, then unblock. { ChatDatabase db(dbPath); EXPECT_TRUE(db.unlockWithSecret("del-seed")); ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db); svc.loadFromDatabase(); bool blocked = false; svc.setBlockedPredicate([&](const std::string& cid) { return blocked && cid == "conv-y"; }); EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); blocked = true; EXPECT_TRUE(svc.deleteConversation("conv-y", /*block=*/true)); EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0); EXPECT_EQ(svc.ingest(convY, {}, 100), 0); // suppressed by the predicate (no tombstone) EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0); blocked = false; // unblock EXPECT_EQ(svc.ingest(convY, {}, 100), 1); // re-imported from chain EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); EXPECT_EQ(svc.store().conversation("conv-y")[0].body, std::string("y-one")); } scrub(dbPath); } // Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and // ChatService compose/recordOutgoing echoes into the store. void testHushChatOutgoing() { using namespace dragonx::chat; ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("out-alice", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("out-bob", bob, true); const std::string aliceZ = "zs-alice-reply"; const std::string bobZ = "zs-bob"; const std::string cid = "conv-out"; // Encrypted message Alice -> Bob, then round-trip through the receive path. OutgoingChatMemos memos; EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, "hello bob", memos) == ChatComposeStatus::Ok); EXPECT_EQ(memos.recipientZaddr, bobZ); EXPECT_TRUE(!memos.headerMemo.empty() && memos.headerMemo.front() == '{'); // Header keys serialize alphabetically (cid before e). EXPECT_TRUE(memos.headerMemo.find("\"cid\"") < memos.headerMemo.find("\"e\"")); HushChatTransactionInput tx; tx.txid = "txout1"; tx.outputs.push_back({0, memos.headerMemo}); // header at the lower position tx.outputs.push_back({1, memos.payloadMemo}); auto extracted = extractHushChatTransactionMetadata(tx, true); EXPECT_EQ((int)extracted.metadata.size(), 1); ChatService bobSvc; bobSvc.setIdentity(bob); // Realistic receive-time fallback (the header "ts" is a real std::time stamp; an artificially-tiny // fallback would trip the future-plausibility clamp in ingest and reject the real ts). EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, static_cast(std::time(nullptr))), 1); std::vector conv = bobSvc.store().conversation(cid); EXPECT_EQ((int)conv.size(), 1); EXPECT_EQ(conv[0].body, std::string("hello bob")); EXPECT_EQ(conv[0].peer_public_key_hex, ra.public_key_hex); // Bob learns Alice's key EXPECT_EQ(conv[0].peer_zaddr, aliceZ); // The header now carries the sender's compose time (header "ts"); ingest prefers it over the fallback // (5) so both ends show the same send time. A real std::time() stamp is well past year-2001. EXPECT_TRUE(conv[0].timestamp > 1000000000); // Plaintext contact request Alice -> Bob. OutgoingChatMemos creq; EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "conv-cr", "add me?", creq) == ChatComposeStatus::Ok); HushChatTransactionInput tx2; tx2.txid = "txout2"; tx2.outputs.push_back({0, creq.headerMemo}); tx2.outputs.push_back({1, creq.payloadMemo}); auto ex2 = extractHushChatTransactionMetadata(tx2, true); EXPECT_EQ((int)ex2.metadata.size(), 1); EXPECT_TRUE(ex2.metadata[0].type == HushChatHeaderType::ContactRequest); bobSvc.ingest(ex2.metadata, {}, 6); EXPECT_EQ((int)bobSvc.store().conversation("conv-cr").size(), 1); EXPECT_EQ(bobSvc.store().conversation("conv-cr")[0].body, std::string("add me?")); // Validation guards. OutgoingChatMemos dummy; EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, "tooshort", bobZ, cid, "x", dummy) == ChatComposeStatus::BadPeerKey); EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, aliceZ, rb.public_key_hex, bobZ, cid, "", dummy) == ChatComposeStatus::EmptyBody); EXPECT_TRUE(buildOutgoingContactRequest(ra.public_key_hex, aliceZ, bobZ, "c", "{bad", dummy) == ChatComposeStatus::BadRequestText); // ChatService compose + recordOutgoing echo. OutgoingChatMemos svcMemos; EXPECT_TRUE(bobSvc.composeMessage(bobZ, ra.public_key_hex, aliceZ, cid, "reply!", svcMemos) == ChatComposeStatus::Ok); ChatMessage echo; echo.direction = ChatDirection::Outgoing; echo.kind = ChatMessageKind::Message; echo.conversation_id = cid; echo.peer_zaddr = aliceZ; echo.peer_public_key_hex = ra.public_key_hex; echo.body = "reply!"; // A reply is sent AFTER the received message; the incoming "hello bob" now carries a real compose-time // "ts" (~now), so stamp the echo just after it (not the old artificial 7, which would sort before it). echo.timestamp = static_cast(std::time(nullptr)) + 10; echo.txid = "out:local1"; echo.payload_position = 0; EXPECT_TRUE(bobSvc.recordOutgoing(echo)); std::vector conv2 = bobSvc.store().conversation(cid); EXPECT_EQ((int)conv2.size(), 2); EXPECT_TRUE(conv2.back().direction == ChatDirection::Outgoing); EXPECT_EQ(conv2.back().body, std::string("reply!")); // Future-clock clamp: a header "ts" implausibly ahead of the receive time is rejected (falls back to // the receive time), so a wrong/ahead-clocked peer can't pin their messages to the bottom of a thread. { const std::int64_t nowSec = static_cast(std::time(nullptr)); const std::int64_t futureTs = nowSec + 10 * 24 * 3600; // 10 days ahead — well past the 1h tolerance nlohmann::json h; h["h"] = 1; h["v"] = 0; h["z"] = aliceZ; h["cid"] = "conv-clamp"; h["t"] = "Cont"; h["e"] = ""; h["p"] = ra.public_key_hex; h["ts"] = futureTs; HushChatTransactionInput ctx; ctx.txid = "txclamp"; ctx.outputs.push_back({0, h.dump()}); // header at the lower position ctx.outputs.push_back({1, "please add"}); // contact-request plaintext payload (must not start with '{') auto exc = extractHushChatTransactionMetadata(ctx, true); EXPECT_EQ((int)exc.metadata.size(), 1); EXPECT_EQ(exc.metadata[0].sent_at, futureTs); // the future ts parses through bobSvc.ingest(exc.metadata, {}, nowSec); // realistic receive time auto cc = bobSvc.store().conversation("conv-clamp"); EXPECT_EQ((int)cc.size(), 1); EXPECT_EQ(cc[0].timestamp, nowSec); // clamped to receive time, NOT the future ts } } // Phase 5: the transport encoding (chatSendOutputs) — header first, "utf8:" for full-node / raw for // lite — and that the on-chain form (after the daemon strips "utf8:") round-trips through receive. void testHushChatTransport() { using namespace dragonx::chat; ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("tx-alice", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("tx-bob", bob, true); OutgoingChatMemos memos; EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob", "cid-tx", "over the wire", memos) == ChatComposeStatus::Ok); // Full-node: header at index 0, both to the peer, each "utf8:"-prefixed. std::array fn = chatSendOutputs(memos, /*utf8Prefix=*/true); EXPECT_EQ(fn[0].address, std::string("zs-bob")); EXPECT_EQ(fn[1].address, std::string("zs-bob")); EXPECT_TRUE(fn[0].memo.rfind("utf8:", 0) == 0); EXPECT_TRUE(fn[1].memo.rfind("utf8:", 0) == 0); EXPECT_EQ(fn[0].memo, std::string("utf8:") + memos.headerMemo); EXPECT_EQ(fn[1].memo, std::string("utf8:") + memos.payloadMemo); // Lite: raw, no prefix. std::array lt = chatSendOutputs(memos, /*utf8Prefix=*/false); EXPECT_EQ(lt[0].memo, memos.headerMemo); EXPECT_EQ(lt[1].memo, memos.payloadMemo); // On-chain form round-trips: the daemon stores the UTF-8 text (the string after "utf8:"), which // is exactly what the harvest parser reads back (header first / lower position). auto strip = [](const std::string& m) { return m.rfind("utf8:", 0) == 0 ? m.substr(5) : m; }; HushChatTransactionInput tx; tx.txid = "txwire"; tx.outputs.push_back({0, strip(fn[0].memo)}); tx.outputs.push_back({1, strip(fn[1].memo)}); auto extracted = extractHushChatTransactionMetadata(tx, true); EXPECT_EQ((int)extracted.metadata.size(), 1); ChatService bobSvc; bobSvc.setIdentity(bob); EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 9), 1); EXPECT_EQ(bobSvc.store().conversation("cid-tx")[0].body, std::string("over the wire")); } // Phase 5: the full-node daemon SHUFFLES the two memo outputs, so the header may land at a HIGHER // note position than its payload. The receive parser must pair them regardless of on-chain order. void testHushChatShuffledReceive() { using namespace dragonx::chat; ChatKeyPair alice, bob; ChatIdentityResult ra = deriveChatIdentityFromSecret("shuf-alice", alice, true); ChatIdentityResult rb = deriveChatIdentityFromSecret("shuf-bob", bob, true); ChatService bobSvc; bobSvc.setIdentity(bob); // Payload BEFORE header (positions swapped, as a shuffle may produce). OutgoingChatMemos memos; EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob", "cid-shuf", "shuffled hi", memos) == ChatComposeStatus::Ok); HushChatTransactionInput tx; tx.txid = "txshuf"; tx.outputs.push_back({0, memos.payloadMemo}); // payload at the LOWER position tx.outputs.push_back({1, memos.headerMemo}); // header at the HIGHER position auto extracted = extractHushChatTransactionMetadata(tx, true); EXPECT_EQ((int)extracted.metadata.size(), 1); EXPECT_EQ(bobSvc.ingest(extracted.metadata, {}, 3), 1); EXPECT_EQ(bobSvc.store().conversation("cid-shuf")[0].body, std::string("shuffled hi")); // With a change output (empty memo) interspersed and the header last, still pairs. OutgoingChatMemos memos2; EXPECT_TRUE(buildOutgoingMessage(alice, ra.public_key_hex, "zs-alice", rb.public_key_hex, "zs-bob", "cid-shuf2", "with change", memos2) == ChatComposeStatus::Ok); HushChatTransactionInput tx2; tx2.txid = "txshuf2"; tx2.outputs.push_back({0, std::string()}); // change output: empty memo (ignored) tx2.outputs.push_back({1, memos2.payloadMemo}); // payload tx2.outputs.push_back({2, memos2.headerMemo}); // header last auto ex2 = extractHushChatTransactionMetadata(tx2, true); EXPECT_EQ((int)ex2.metadata.size(), 1); EXPECT_EQ(bobSvc.ingest(ex2.metadata, {}, 4), 1); EXPECT_EQ(bobSvc.store().conversation("cid-shuf2")[0].body, std::string("with change")); } } // namespace // Per-wallet address-book scoping (multi-wallet P1): global vs wallet-scoped contacts, the // legacy-entry migration, and scope-aware de-duplication. void testAddressBookScope() { using dragonx::data::AddressBook; using dragonx::data::AddressBookEntry; // --- Pure visibility logic (drives the contacts-tab filter) --- AddressBookEntry g("Alice", "zs1alice", "", "global"); AddressBookEntry wA("Bob", "zs1bob", "", "hashA"); AddressBookEntry legacy; legacy.scope = ""; // legacy entry (no scope) => global EXPECT_TRUE(g.isGlobal()); EXPECT_TRUE(!wA.isGlobal()); EXPECT_TRUE(legacy.isGlobal()); EXPECT_TRUE(g.visibleInWallet("hashA")); // global shows everywhere EXPECT_TRUE(g.visibleInWallet("hashB")); EXPECT_TRUE(wA.visibleInWallet("hashA")); // wallet-scoped shows only in its wallet EXPECT_TRUE(!wA.visibleInWallet("hashB")); EXPECT_TRUE(legacy.visibleInWallet("anything")); // --- Round-trip + migration + scope-aware dedup, isolated under a temp $HOME --- #ifdef _WIN32 // getConfigDir() uses SHGetFolderPath(CSIDL_APPDATA), which no env var redirects: this would // overwrite the real %APPDATA%\ObsidianDragon\addressbook.json. std::printf("[skip] testAddressBookScope round-trip (Windows config dir can't be redirected)\n"); #else const char* oldHome = std::getenv("HOME"); std::string savedHome = oldHome ? oldHome : ""; fs::path tmp = fs::temp_directory_path() / ("obsidian_ab_test_" + std::to_string((long long)::time(nullptr))); fs::create_directories(tmp); setenv("HOME", tmp.string().c_str(), 1); // A pre-scoping addressbook.json (no "scope" field) migrates to global on load. Write to the // SAME per-variant config dir AddressBook::load() reads from (Lite -> ObsidianDragonLite/), // resolved under the temp HOME set above. Hardcoding ".config/ObsidianDragon" made the lite-build // ctest write where load() never looks -> 0 entries -> the entries()[0] below segfaulted. fs::path cfg = dragonx::util::Platform::getConfigDir(); fs::create_directories(cfg); std::ofstream(cfg / "addressbook.json") << R"({"entries":[{"label":"Legacy","address":"zs1legacy","notes":""}]})"; { AddressBook book; EXPECT_TRUE(book.load()); EXPECT_EQ(book.size(), (size_t)1); // Guard the [0] access — EXPECT_EQ doesn't abort this harness, so an empty book here must not // SIGSEGV the whole suite (it would take every later test down with it). EXPECT_TRUE(!book.entries().empty() && book.entries()[0].isGlobal()); // migrated -> global } // Same address may be a contact in two DIFFERENT wallets, but not twice in one; and a global // entry collides with any wallet-scoped one for the same address (they'd show together). { AddressBook book; book.load(); // has the migrated global "Legacy" EXPECT_TRUE(book.addEntry(AddressBookEntry("BobA", "zs1bob", "", "walletA"))); EXPECT_TRUE(book.addEntry(AddressBookEntry("BobB", "zs1bob", "", "walletB"))); // diff wallet: ok EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobA2", "zs1bob", "", "walletA"))); // same wallet: dup EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobG", "zs1bob", "", "global"))); // global collides EXPECT_TRUE(!book.hasVisibleDuplicate("zs1novel", "walletA")); // novel address: ok } // Scope survives save + reload. { AddressBook reopened; reopened.load(); int ia = -1, ib = -1; for (size_t i = 0; i < reopened.size(); ++i) { const auto& e = reopened.entries()[i]; if (e.address == "zs1bob" && e.scope == "walletA") ia = (int)i; if (e.address == "zs1bob" && e.scope == "walletB") ib = (int)i; } EXPECT_TRUE(ia >= 0); // both wallet-scoped Bobs round-tripped EXPECT_TRUE(ib >= 0); } if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); std::error_code ec; fs::remove_all(tmp, ec); #endif // !_WIN32 } // Wallet-files metadata index (multi-wallet P1b): upsert change-detection, find, extra folders, // and save/reload round-trip. void testWalletIndex() { using dragonx::data::WalletIndex; using dragonx::data::WalletIndexEntry; // upsert change-detection + find + folders (in-memory) { WalletIndex idx; WalletIndexEntry a; a.fileName = "wallet.dat"; a.cachedBalance = 1.5; a.cachedAddressCount = 3; EXPECT_TRUE(idx.upsert(a)); // new -> changed EXPECT_TRUE(!idx.upsert(a)); // identical -> no change (skips save) a.cachedBalance = 2.0; EXPECT_TRUE(idx.upsert(a)); // value changed -> changed EXPECT_TRUE(idx.find("wallet.dat") != nullptr); EXPECT_NEAR(idx.find("wallet.dat")->cachedBalance, 2.0, 1e-9); EXPECT_TRUE(idx.find("missing.dat") == nullptr); WalletIndexEntry b; b.fileName = "wallet-2.dat"; idx.upsert(b); EXPECT_EQ(idx.entries().size(), (size_t)2); EXPECT_TRUE(idx.addExtraFolder("/some/dir")); EXPECT_TRUE(!idx.addExtraFolder("/some/dir")); // duplicate rejected EXPECT_TRUE(idx.removeExtraFolder("/some/dir")); EXPECT_TRUE(!idx.removeExtraFolder("/some/dir")); // already gone } // save + reload round-trip under a temp $HOME. Not on Windows: the config dir comes from // SHGetFolderPath there, so this would overwrite the real %APPDATA%\ObsidianDragon\wallets.json. #ifdef _WIN32 std::printf("[skip] testWalletIndex round-trip (Windows config dir can't be redirected)\n"); #else { const char* oldHome = std::getenv("HOME"); std::string savedHome = oldHome ? oldHome : ""; fs::path tmp = fs::temp_directory_path() / ("obsidian_wi_test_" + std::to_string((long long)::time(nullptr))); fs::create_directories(tmp); setenv("HOME", tmp.string().c_str(), 1); { WalletIndex idx; idx.load(); WalletIndexEntry a; a.fileName = "wallet.dat"; a.displayName = "Main"; a.cachedBalance = 12.34; a.cachedAddressCount = 7; a.walletIdentityHash = "hashX"; a.lastOpenedEpoch = 1720000000; a.sizeBytesAtLastOpen = 98304; a.syncedHere = true; idx.upsert(a); idx.addExtraFolder("/other/wallets"); EXPECT_TRUE(idx.save()); } { WalletIndex idx; EXPECT_TRUE(idx.load()); const auto* w = idx.find("wallet.dat"); EXPECT_TRUE(w != nullptr); EXPECT_EQ(w->displayName, std::string("Main")); EXPECT_EQ(w->cachedAddressCount, (long long)7); EXPECT_EQ(w->walletIdentityHash, std::string("hashX")); EXPECT_TRUE(w->syncedHere); EXPECT_NEAR(w->cachedBalance, 12.34, 1e-9); EXPECT_EQ(idx.extraFolders().size(), (size_t)1); } if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); std::error_code ec2; fs::remove_all(tmp, ec2); } #endif // !_WIN32 } // C91: the full UI sweep's persistence latches and its sandbox helper. static std::string sweepReadBytes(const fs::path& p) { std::ifstream in(p, std::ios::binary); return std::string(std::istreambuf_iterator(in), std::istreambuf_iterator()); } #ifndef _WIN32 // Run `body` with HOME and XDG_CONFIG_HOME pointing into a fresh temp dir (the config dir follows // $HOME), then put both back. Not on Windows: its config dir can't be redirected. static void withSweepTempHome(const std::function& body) { const char* oldHome = std::getenv("HOME"); const std::string savedHome = oldHome ? oldHome : ""; const char* oldXdg = std::getenv("XDG_CONFIG_HOME"); const bool hadXdg = oldXdg != nullptr; const std::string savedXdg = hadXdg ? oldXdg : ""; const fs::path tmp = makeTempDir(); setenv("HOME", tmp.string().c_str(), 1); setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1); body(); if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME"); if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME"); removeTree(tmp); } #endif // The sweep re-runs its contacts setup every frame: only the FIRST sweepBegin may snapshot (the old // per-frame snapshot "restored" demo contacts), and nothing may reach addressbook.json meanwhile. void testSweepAddressBookLatch() { #ifdef _WIN32 std::printf("[skip] testSweepAddressBookLatch (Windows config dir can't be redirected)\n"); #else using dragonx::data::AddressBook; using dragonx::data::AddressBookEntry; withSweepTempHome([] { AddressBook book; book.load(); EXPECT_TRUE(book.addEntry(AddressBookEntry("Alice", "zs1alicereal", "", "w:abc"))); EXPECT_TRUE(book.addEntry(AddressBookEntry("Bob", "zs1bobreal", "", "global"))); const fs::path file = AddressBook::getDefaultPath(); const std::string before = sweepReadBytes(file); EXPECT_TRUE(!before.empty()); book.sweepBegin({ AddressBookEntry("DemoA", "zs1demoa", "", "global") }); book.sweepBegin({ AddressBookEntry("DemoB", "zs1demob", "", "legacyhash") }); // next frame EXPECT_TRUE(book.sweepActive()); EXPECT_EQ(book.size(), (size_t)1); EXPECT_TRUE(book.addEntry(AddressBookEntry("DemoC", "zs1democ", "", "global"))); EXPECT_EQ(book.reattachLegacyScopes("w:x"), 1); // re-scopes DemoB; its save is latched EXPECT_TRUE(book.load()); // latched: keeps the demo list and the snapshot EXPECT_EQ(book.size(), (size_t)2); EXPECT_EQ(sweepReadBytes(file), before); book.sweepEnd(); book.sweepEnd(); // idempotent EXPECT_TRUE(!book.sweepActive()); EXPECT_EQ(book.size(), (size_t)2); EXPECT_TRUE(book.findByAddress("zs1alicereal") >= 0 && book.findByAddress("zs1bobreal") >= 0); EXPECT_TRUE(book.findByAddress("zs1demob") < 0 && book.findByAddress("zs1democ") < 0); EXPECT_EQ(sweepReadBytes(file), before); // the disk never saw the demo book AddressBook fresh; EXPECT_TRUE(fresh.load()); EXPECT_EQ(fresh.size(), (size_t)2); const int ia = fresh.findByAddress("zs1alicereal"); EXPECT_TRUE(ia >= 0 && fresh.entries()[(size_t)ia].scope == "w:abc"); // After the sweep, saves work again. EXPECT_TRUE(book.addEntry(AddressBookEntry("Carol", "zs1carolreal", "", "global"))); AddressBook fresh2; EXPECT_TRUE(fresh2.load()); EXPECT_EQ(fresh2.size(), (size_t)3); }); #endif } // The sweep's demo upsert drops the real entry's scopeId, and activeWalletScopeId() would then mint and // save a new one (hiding "w:"-scoped contacts for good). The latch keeps wallets.json and restores the id. void testSweepWalletIndexLatch() { #ifdef _WIN32 std::printf("[skip] testSweepWalletIndexLatch (Windows config dir can't be redirected)\n"); #else using dragonx::data::WalletIndex; using dragonx::data::WalletIndexEntry; withSweepTempHome([] { WalletIndex idx; idx.load(); WalletIndexEntry real; real.fileName = "wallet.dat"; real.displayName = "Main"; real.scopeId = "w:abc"; real.lastOpenedEpoch = 1720000000; idx.upsert(real); idx.addExtraFolder("/real/wallet-folder"); EXPECT_TRUE(idx.save()); // no writer attached: synchronous const fs::path file = WalletIndex::getDefaultPath(); const std::string before = sweepReadBytes(file); std::error_code ec; const auto mtime = fs::last_write_time(file, ec); EXPECT_TRUE(!before.empty() && !ec); idx.sweepBegin(); WalletIndexEntry demo; demo.fileName = "wallet.dat"; demo.displayName = "wallet.dat"; demo.cachedBalance = 15.7526; demo.syncedHere = true; EXPECT_TRUE(idx.upsert(demo)); // replaces the entry: scopeId lost EXPECT_TRUE(idx.find("wallet.dat") && idx.find("wallet.dat")->scopeId.empty()); demo.scopeId = "w:minted"; idx.upsert(demo); // what activeWalletScopeId() would mint EXPECT_TRUE(idx.addExtraFolder("/tmp/sweep-sandbox/extra-wallets")); EXPECT_TRUE(idx.save()); // latched EXPECT_EQ(sweepReadBytes(file), before); idx.sweepEnd(); idx.sweepEnd(); // idempotent EXPECT_TRUE(!idx.sweepActive()); const auto* w = idx.find("wallet.dat"); EXPECT_TRUE(w != nullptr); if (w) { EXPECT_EQ(w->scopeId, std::string("w:abc")); EXPECT_EQ(w->displayName, std::string("Main")); } EXPECT_TRUE(idx.extraFolders() == std::vector{"/real/wallet-folder"}); EXPECT_EQ(sweepReadBytes(file), before); EXPECT_TRUE(fs::last_write_time(file, ec) == mtime); // With the App's background writer attached, a latched save queues nothing either. { dragonx::util::CoalescingWriter writer; idx.setFileWriter(&writer); idx.sweepBegin(); idx.upsert(demo); EXPECT_TRUE(idx.save()); EXPECT_EQ(writer.pendingCount(), (size_t)0); idx.sweepEnd(); idx.setFileWriter(nullptr); } EXPECT_EQ(sweepReadBytes(file), before); }); #endif } // Settings: the demo active-wallet file / contacts view never reach disk; sweepEnd restores them and // flushes once only when a save was suppressed (so a real change made meanwhile still lands). void testSweepSettingsLatch() { #ifdef _WIN32 std::printf("[skip] testSweepSettingsLatch (Windows config dir can't be redirected)\n"); #else using dragonx::config::Settings; withSweepTempHome([] { Settings s; // no writer attached: synchronous saves s.load(); // no file yet: defaults s.setActiveWalletFile("x.dat"); s.setContactsViewMode(2); s.setUIOpacity(0.55f); EXPECT_TRUE(s.save()); const fs::path file = Settings::getDefaultPath(); const std::string before = sweepReadBytes(file); EXPECT_TRUE(!before.empty()); s.sweepBegin(); s.setActiveWalletFile("wallet.dat"); // what the wallets surfaces set s.setContactsViewMode(1); s.setPoolWorker("zs1demoprimaryshieldedaddressforuisweep"); // the Mining tab's demo default s.setUIOpacity(0.65f); // a real change made during the sweep EXPECT_TRUE(s.save()); // suppressed EXPECT_EQ(sweepReadBytes(file), before); s.sweepEnd(); EXPECT_TRUE(!s.sweepActive()); EXPECT_EQ(s.getActiveWalletFile(), std::string("x.dat")); EXPECT_EQ(s.getContactsViewMode(), 2); EXPECT_TRUE(s.getPoolWorker().empty()); { Settings back; EXPECT_TRUE(back.load()); EXPECT_EQ(back.getActiveWalletFile(), std::string("x.dat")); EXPECT_EQ(back.getContactsViewMode(), 2); EXPECT_TRUE(back.getPoolWorker().empty()); EXPECT_NEAR(back.getUIOpacity(), 0.65, 1e-6); // the one flush carried the real change } // No suppressed save: sweepEnd restores and writes nothing. const std::string bytes0 = sweepReadBytes(file); std::error_code ec; const auto t0 = fs::last_write_time(file, ec); s.sweepBegin(); s.setActiveWalletFile("wallet.dat"); s.setContactsViewMode(0); s.sweepEnd(); s.sweepEnd(); // idempotent EXPECT_EQ(s.getActiveWalletFile(), std::string("x.dat")); EXPECT_EQ(s.getContactsViewMode(), 2); EXPECT_EQ(sweepReadBytes(file), bytes0); EXPECT_TRUE(fs::last_write_time(file, ec) == t0); }); #endif } // C94: "Use Tor" was never implemented. A stored use_tor=true is consumed exactly once (App::init warns // that user) and stays false after a save and reload, so the notice is never shown again. void testLegacyUseTorClearedOnce() { #ifdef _WIN32 std::printf("[skip] testLegacyUseTorClearedOnce (Windows config dir can't be redirected)\n"); #else using dragonx::config::Settings; withSweepTempHome([] { const fs::path file = Settings::getDefaultPath(); fs::create_directories(file.parent_path()); writeTestFile(file, "{\n \"use_tor\": true,\n \"wizard_completed\": true\n}\n"); { Settings s; // no writer attached: synchronous saves EXPECT_TRUE(s.load()); EXPECT_TRUE(s.getUseTor()); // the legacy opt-in is read as stored EXPECT_TRUE(s.consumeLegacyUseTor()); // first call: there was one EXPECT_TRUE(!s.getUseTor()); EXPECT_TRUE(!s.consumeLegacyUseTor()); // second call: nothing left to report EXPECT_TRUE(s.save()); } { Settings back; EXPECT_TRUE(back.load()); EXPECT_TRUE(!back.getUseTor()); EXPECT_TRUE(back.getWizardCompleted()); // the rest of the file survives EXPECT_TRUE(!back.consumeLegacyUseTor()); // a later start shows no notice } const std::string text = sweepReadBytes(file); EXPECT_TRUE(text.find("\"use_tor\": false") != std::string::npos); // A profile that never ticked it reports nothing. Settings fresh; EXPECT_TRUE(!fresh.consumeLegacyUseTor()); }); #endif } // The sandbox: a fresh random owner-only dir; a pre-existing path or a symlink (even dangling) is refused, // and the remover only touches a path that still carries the prefix. void testSweepSandboxDir() { using dragonx::util::createExclusivePrivateDir; using dragonx::util::makePrivateTempDir; using dragonx::util::removePrivateTempDir; const fs::path parent = makeTempDir(); const std::string prefix = "obsidiandragon-sweep-"; const std::string a = makePrivateTempDir(parent, prefix); const std::string b = makePrivateTempDir(parent, prefix); EXPECT_TRUE(!a.empty() && !b.empty()); EXPECT_TRUE(a != b); EXPECT_TRUE(fs::is_directory(fs::symlink_status(a)) && fs::is_directory(fs::symlink_status(b))); EXPECT_EQ(fs::path(a).filename().string().size(), prefix.size() + 16); EXPECT_EQ(fs::path(a).filename().string().rfind(prefix, 0), (size_t)0); #ifndef _WIN32 EXPECT_TRUE(fs::status(a).permissions() == fs::perms::owner_all); #endif // A pre-existing directory or file is refused. EXPECT_TRUE(!createExclusivePrivateDir(a)); writeTestFile(parent / "plain-file", "x"); EXPECT_TRUE(!createExclusivePrivateDir(parent / "plain-file")); EXPECT_TRUE(!createExclusivePrivateDir(fs::path())); EXPECT_TRUE(makePrivateTempDir(fs::path(), prefix).empty()); #ifndef _WIN32 // A symlink planted at the name (e.g. pointing at a victim's datadir) is refused, and its target is // neither reused nor re-permissioned. const fs::path victim = parent / "victim"; fs::create_directories(victim); fs::permissions(victim, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec); writeTestFile(victim / "wallet.dat", "real"); const fs::path link = parent / (prefix + "0123456789abcdef"); fs::create_directory_symlink(victim, link); EXPECT_TRUE(!createExclusivePrivateDir(link)); EXPECT_TRUE(fs::is_symlink(fs::symlink_status(link))); EXPECT_TRUE(fs::status(victim).permissions() == (fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec)); const fs::path dangling = parent / (prefix + "fedcba9876543210"); fs::create_symlink(parent / "nowhere", dangling); EXPECT_TRUE(!createExclusivePrivateDir(dangling)); EXPECT_TRUE(!fs::exists(parent / "nowhere")); // Removing the link unlinks it and never follows it into the target. EXPECT_TRUE(removePrivateTempDir(link.string(), prefix)); EXPECT_TRUE(fs::exists(victim / "wallet.dat")); EXPECT_EQ(sweepReadBytes(victim / "wallet.dat"), std::string("real")); #endif // The remover: refuses a path without the prefix, removes the whole sandbox tree otherwise. fs::create_directories(fs::path(a) / "DRAGONX"); writeTestFile(fs::path(a) / "DRAGONX" / "wallet.dat", std::string(64, '\0')); EXPECT_TRUE(!removePrivateTempDir(parent.string(), prefix)); EXPECT_TRUE(fs::exists(parent)); EXPECT_TRUE(!removePrivateTempDir(std::string(), prefix)); EXPECT_TRUE(removePrivateTempDir(a, prefix)); EXPECT_TRUE(!fs::exists(a)); EXPECT_TRUE(removePrivateTempDir(a, prefix)); // already gone: nothing left EXPECT_TRUE(fs::exists(b)); removeTree(parent); } // Tracked sends that only paid the wallet itself (shield, merge/consolidate, chat note split) show no // outgoing output in z_viewtransaction. They used to stay tracked for the whole session, forcing a full // history refresh past the scan throttle on every tab; once mined or conflicted they are now retired. void testTrackedSendRetirement() { using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; const json ownOnlyView = json{ {"spends", json::array({json{{"address", "zs-own"}}})}, {"outputs", json::array({json{{"outgoing", false}, {"address", "zs-own"}, {"value", 1.0}}})}}; // Mined: retired and cached. Refresh::TransactionRefreshSnapshot minedSnapshot; minedSnapshot.sendTxids = {"self-mined"}; MockRefreshRpc minedRpc; minedRpc.addResponse("listtransactions", json::array()); minedRpc.addResponse("z_viewtransaction", ownOnlyView); minedRpc.addResponse("gettransaction", json{{"time", 800}, {"confirmations", 3}, {"amount", 0.0}}); auto mined = Refresh::collectTransactionRefreshResult(minedRpc, minedSnapshot, 400, 4); EXPECT_EQ(mined.resolvedSendTxids.count("self-mined"), static_cast(1)); EXPECT_EQ(mined.newViewTxEntries.count("self-mined"), static_cast(1)); // Conflicted (negative confirmations): retired too. Refresh::TransactionRefreshSnapshot conflictSnapshot; conflictSnapshot.sendTxids = {"self-conflicted"}; MockRefreshRpc conflictRpc; conflictRpc.addResponse("listtransactions", json::array()); conflictRpc.addResponse("z_viewtransaction", ownOnlyView); conflictRpc.addResponse("gettransaction", json{{"time", 801}, {"confirmations", -1}}); auto conflicted = Refresh::collectTransactionRefreshResult(conflictRpc, conflictSnapshot, 400, 4); EXPECT_EQ(conflicted.resolvedSendTxids.count("self-conflicted"), static_cast(1)); // Unconfirmed: stays tracked and uncached, so the next cycle retries it. Refresh::TransactionRefreshSnapshot pendingSnapshot; pendingSnapshot.sendTxids = {"self-pending"}; MockRefreshRpc pendingRpc; pendingRpc.addResponse("listtransactions", json::array()); pendingRpc.addResponse("z_viewtransaction", ownOnlyView); pendingRpc.addResponse("gettransaction", json{{"time", 802}, {"confirmations", 0}}); auto pending = Refresh::collectTransactionRefreshResult(pendingRpc, pendingSnapshot, 400, 4); EXPECT_TRUE(pending.resolvedSendTxids.empty()); EXPECT_EQ(pending.newViewTxEntries.count("self-pending"), static_cast(0)); // Applying the mined result drops only the retired id. dragonx::WalletState state; Refresh::TransactionViewCache viewCache; std::unordered_set sendTxids{"self-mined", "self-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(mined), 999); EXPECT_EQ(sendTxids.count("self-mined"), static_cast(0)); EXPECT_EQ(sendTxids.count("self-pending"), static_cast(1)); EXPECT_EQ(viewCache.count("self-mined"), static_cast(1)); // A spent z_viewtransaction budget skips further RPCs but still appends cached rows that sort // after the cutoff (the loop used to stop there and drop them from History). Refresh::TransactionRefreshSnapshot capSnapshot; Refresh::TransactionViewCacheEntry cachedSend; cachedSend.from_address = "zs-from"; cachedSend.outgoing_outputs.push_back({"zs-dest", 0.5, ""}); capSnapshot.viewTxCache["z-cached-send"] = cachedSend; MockRefreshRpc capRpc; capRpc.addResponse("listtransactions", json::array({ json{{"txid", "a-recv"}, {"category", "receive"}, {"amount", 1.0}, {"time", 100}, {"confirmations", 3}, {"address", "R-one"}}, json{{"txid", "b-recv"}, {"category", "receive"}, {"amount", 2.0}, {"time", 101}, {"confirmations", 3}, {"address", "R-one"}} })); capRpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}}); auto capped = Refresh::collectTransactionRefreshResult(capRpc, capSnapshot, 400, 1); EXPECT_TRUE(capRpc.methodNames() == std::vector({"listtransactions", "z_viewtransaction"})); EXPECT_EQ(capRpc.calls[1].params, json::array({"a-recv"})); EXPECT_TRUE(std::any_of(capped.transactions.begin(), capped.transactions.end(), [](const dragonx::TransactionInfo& t) { return t.txid == "z-cached-send"; })); // Tracked sends get the budget first, whatever their txid sorts as. Refresh::TransactionRefreshSnapshot orderSnapshot; orderSnapshot.sendTxids = {"zz-tracked"}; MockRefreshRpc orderRpc; orderRpc.addResponse("listtransactions", json::array({ json{{"txid", "a-recv"}, {"category", "receive"}, {"amount", 1.0}, {"time", 100}, {"confirmations", 3}, {"address", "R-one"}} })); orderRpc.addResponse("z_viewtransaction", json{ {"spends", json::array({json{{"address", "zs-from"}}})}, {"outputs", json::array({json{{"outgoing", true}, {"address", "zs-peer"}, {"value", 0.25}}})}}); orderRpc.addResponse("gettransaction", json{{"time", 900}, {"confirmations", 0}}); Refresh::collectTransactionRefreshResult(orderRpc, orderSnapshot, 400, 1); EXPECT_EQ(orderRpc.calls[1].params, json::array({"zz-tracked"})); } // The displayed-balance roll must never overshoot, must land on the target after an idle-length // frame, and must survive Reduce Motion and non-finite values (it used to flip between two wrong // values at dt*speed == 2 and diverge to inf/NaN under Reduce Motion's speed of 999). void testBalanceDisplayLerp() { using dragonx::ui::StepDisplayLerp; for (float dt : {1.0f / 240.0f, 1.0f / 60.0f, 0.25f}) { double disp = 0.0; const double target = 123.45678901; bool overshot = false; for (int i = 0; i < 4000 && disp != target; ++i) { StepDisplayLerp(disp, target, dt, 8.0f, false); if (disp > target) overshot = true; } EXPECT_FALSE(overshot); EXPECT_EQ(disp, target); } double idle = 10.0; EXPECT_FALSE(StepDisplayLerp(idle, 20.0, 0.25f, 8.0f, false)); EXPECT_EQ(idle, 20.0); double instant = 1.0; EXPECT_FALSE(StepDisplayLerp(instant, 5.0, 1.0f / 60.0f, 8.0f, true)); EXPECT_EQ(instant, 5.0); double bad = std::numeric_limits::quiet_NaN(); StepDisplayLerp(bad, 7.0, 1.0f / 60.0f, 8.0f, false); EXPECT_EQ(bad, 7.0); for (double target : {1.2e7, 2.1e8}) { double disp = 0.0; int steps = 0; while (StepDisplayLerp(disp, target, 1.0f / 60.0f, 8.0f, false) && steps < 10000) ++steps; EXPECT_TRUE(steps < 10000); EXPECT_EQ(disp, target); } } // C47: the spendable (minconf=1) z_gettotalbalance is a second whole-wallet scan under cs_main. It runs // only after the minconf=0 call succeeded, and only when its answer can differ from it: something is // unconfirmed or pending (needSpendable), or the exact total moved since the last poll. void testCoreRefreshBalancePlan() { using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; using dragonx::rpc::RpcFailureKind; using dragonx::rpc::RpcTransportError; const json chain = json{{"blocks", 150}, {"headers", 150}, {"longestchain", 150}, {"bestblockhash", "tip-150"}}; const json total = json{{"private", "3.00000000"}, {"transparent", "1.25000000"}, {"total", "4.25000000"}}; const json spendable = json{{"private", "2.00000000"}, {"transparent", "1.25000000"}, {"total", "3.25000000"}}; const std::vector oneCall = {"getblockchaininfo", "z_gettotalbalance"}; const std::vector twoCalls = {"getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance"}; { // Nothing pending and the total unchanged: one balance call, and spendable is the display total. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addResponse("z_gettotalbalance", total); Refresh::CoreRefreshPlan plan; plan.needSpendable = false; plan.prevTotalZat = 425000000; auto result = Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == oneCall); EXPECT_EQ(rpc.calls[1].params, json::array({0})); EXPECT_TRUE(result.balanceOk); EXPECT_TRUE(result.balanceAttempted); EXPECT_FALSE(result.spendableQueried); EXPECT_TRUE(result.totalBalanceZat == std::optional(425000000)); EXPECT_TRUE(Refresh::nextTotalBaseline(result, 425000000) == std::optional(425000000)); dragonx::WalletState state; Refresh::applyCoreRefreshResult(state, result, 1000); EXPECT_NEAR(state.spendableTotalBalance, 4.25, 1e-9); EXPECT_NEAR(state.unconfirmedBalance, 0.0, 1e-9); } { // The total moved (how a new 0-conf receive first shows): the spendable call runs. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addResponse("z_gettotalbalance", total); rpc.addResponse("z_gettotalbalance", spendable); Refresh::CoreRefreshPlan plan; plan.needSpendable = false; plan.prevTotalZat = 325000000; auto result = Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == twoCalls); EXPECT_EQ(rpc.calls[2].params, json::array({1})); EXPECT_TRUE(result.spendableQueried); dragonx::WalletState state; Refresh::applyCoreRefreshResult(state, result, 1000); EXPECT_NEAR(state.spendableTotalBalance, 3.25, 1e-9); EXPECT_NEAR(state.unconfirmedBalance, 1.0, 1e-9); } { // Something unconfirmed or pending: the spendable call runs although the total held still. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addResponse("z_gettotalbalance", total); rpc.addResponse("z_gettotalbalance", spendable); Refresh::CoreRefreshPlan plan; plan.needSpendable = true; plan.prevTotalZat = 425000000; Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == twoCalls); } { // No previous total (first poll, or after a reconnect): the spendable call runs. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addResponse("z_gettotalbalance", total); rpc.addResponse("z_gettotalbalance", spendable); Refresh::CoreRefreshPlan plan; plan.needSpendable = false; Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == twoCalls); } { // The display call failed: its result is dropped, so the spendable scan isn't run either. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addFailure("z_gettotalbalance", "boom"); auto result = Refresh::collectCoreRefreshResult(rpc, Refresh::CoreRefreshPlan{}); EXPECT_TRUE(rpc.methodNames() == oneCall); EXPECT_FALSE(result.balanceOk); EXPECT_TRUE(result.balanceAttempted); // it reached the daemon EXPECT_FALSE(result.spendableQueried); EXPECT_FALSE(result.totalBalanceZat.has_value()); } { // Held back by the busy gate: never reached the daemon, so a forced refresh must not count it. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addException("z_gettotalbalance", std::make_exception_ptr( RpcTransportError(RpcFailureKind::Deferred, "held back: the node is busy"))); auto result = Refresh::collectCoreRefreshResult(rpc, Refresh::CoreRefreshPlan{}); EXPECT_FALSE(result.balanceOk); EXPECT_FALSE(result.balanceAttempted); EXPECT_TRUE(result.balanceErrKind == RpcFailureKind::Deferred); } { // Behind, or no balance asked for: the chain call only, nothing attempted. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}}); auto behind = Refresh::collectCoreRefreshResult(rpc, Refresh::CoreRefreshPlan{}); EXPECT_TRUE(rpc.methodNames() == std::vector({"getblockchaininfo"})); EXPECT_FALSE(behind.balanceAttempted); MockRefreshRpc chainOnly; chainOnly.addResponse("getblockchaininfo", chain); Refresh::CoreRefreshPlan plan; plan.includeBalance = false; auto skipped = Refresh::collectCoreRefreshResult(chainOnly, plan); EXPECT_TRUE(chainOnly.methodNames() == std::vector({"getblockchaininfo"})); EXPECT_FALSE(skipped.balanceAttempted); EXPECT_TRUE(Refresh::nextTotalBaseline(skipped, 425000000) == std::optional(425000000)); } // The total moved but the minconf=1 call brought nothing back (an error, or held back by the busy gate): // spendable degrades to display, so the baseline is dropped and the next poll measures it again // instead of skipping it while the total holds still. for (int heldBack = 0; heldBack < 2; ++heldBack) { MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addResponse("z_gettotalbalance", total); if (heldBack) { rpc.addException("z_gettotalbalance", std::make_exception_ptr( RpcTransportError(RpcFailureKind::Deferred, "held back: the node is busy"))); } else { rpc.addFailure("z_gettotalbalance", "boom"); } Refresh::CoreRefreshPlan plan; plan.needSpendable = false; plan.prevTotalZat = 325000000; auto result = Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == twoCalls); EXPECT_TRUE(result.balanceOk); EXPECT_TRUE(result.spendableQueried); EXPECT_FALSE(result.spendableTotalBalance.has_value()); dragonx::WalletState state; Refresh::applyCoreRefreshResult(state, result, 1000); EXPECT_NEAR(state.spendableTotalBalance, 4.25, 1e-9); EXPECT_NEAR(state.unconfirmedBalance, 0.0, 1e-9); const auto baseline = Refresh::nextTotalBaseline(result, plan.prevTotalZat); EXPECT_FALSE(baseline.has_value()); // The next poll: no baseline, so the spendable call runs again and finds the unconfirmed funds. MockRefreshRpc retry; retry.addResponse("getblockchaininfo", chain); retry.addResponse("z_gettotalbalance", total); retry.addResponse("z_gettotalbalance", spendable); Refresh::CoreRefreshPlan next; next.needSpendable = false; next.prevTotalZat = baseline; auto healed = Refresh::collectCoreRefreshResult(retry, next); EXPECT_TRUE(retry.methodNames() == twoCalls); dragonx::WalletState healedState; Refresh::applyCoreRefreshResult(healedState, healed, 1001); EXPECT_NEAR(healedState.unconfirmedBalance, 1.0, 1e-9); EXPECT_TRUE(Refresh::nextTotalBaseline(healed, baseline) == std::optional(425000000)); } } // Exact daemon amounts in zatoshis, so "did the total move" never trips on floating-point rounding. void testParseMoneyZat() { using Refresh = dragonx::services::NetworkRefreshService; using Z = std::optional; EXPECT_TRUE(Refresh::parseMoneyZat("4.25000000") == Z(425000000)); EXPECT_TRUE(Refresh::parseMoneyZat("0.00000000") == Z(0)); EXPECT_TRUE(Refresh::parseMoneyZat("12") == Z(1200000000)); EXPECT_TRUE(Refresh::parseMoneyZat("0.1") == Z(10000000)); EXPECT_TRUE(Refresh::parseMoneyZat("0.00000001") == Z(1)); EXPECT_TRUE(Refresh::parseMoneyZat("-1.50000000") == Z(-150000000)); EXPECT_TRUE(Refresh::parseMoneyZat("92233720367.99999999") == Z(9223372036799999999LL)); EXPECT_FALSE(Refresh::parseMoneyZat("9999999999999999999").has_value()); // would overflow EXPECT_FALSE(Refresh::parseMoneyZat("").has_value()); EXPECT_FALSE(Refresh::parseMoneyZat("-").has_value()); EXPECT_FALSE(Refresh::parseMoneyZat(".5").has_value()); EXPECT_FALSE(Refresh::parseMoneyZat("1.").has_value()); EXPECT_FALSE(Refresh::parseMoneyZat("1.123456789").has_value()); // finer than a zatoshi EXPECT_FALSE(Refresh::parseMoneyZat("1e5").has_value()); EXPECT_FALSE(Refresh::parseMoneyZat(" 1.0").has_value()); EXPECT_FALSE(Refresh::parseMoneyZat("1.0 ").has_value()); } void testSpendableBalanceNeeded() { using Refresh = dragonx::services::NetworkRefreshService; dragonx::WalletState state; EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false)); // never fetched state.last_balance_update = 1000; EXPECT_FALSE(Refresh::spendableBalanceNeeded(state, false, false)); EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, true, false)); // a send in flight EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, true)); // forced (the user's own send) state.unconfirmedBalance = 0.5; EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false)); state.unconfirmedBalance = 0.0; dragonx::AddressInfo z; z.address = "zs-one"; z.balance = 2.0; z.spendableBalance = 1.0; // the address scan saw unconfirmed funds state.z_addresses.push_back(z); EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false)); state.z_addresses[0].spendableBalance = 2.0; EXPECT_FALSE(Refresh::spendableBalanceNeeded(state, false, false)); dragonx::AddressInfo t; t.address = "R-one"; t.balance = 1.0; t.spendableBalance = 0.0; state.t_addresses.push_back(t); EXPECT_TRUE(Refresh::spendableBalanceNeeded(state, false, false)); } // C34: z_getoperationstatus filtered to our opids. Its one argument is an ARRAY of ids, so the params are // [[id, ...]]; the old ["id"] attempt was rejected. An empty filter would mean every op, so none at all. void testOperationFilterParams() { using Refresh = dragonx::services::NetworkRefreshService; auto one = Refresh::buildOperationFilterParams({"opid-1"}); EXPECT_TRUE(one.has_value()); EXPECT_EQ(one->dump(), std::string("[[\"opid-1\"]]")); auto two = Refresh::buildOperationFilterParams({"a", "", "b"}); EXPECT_TRUE(two.has_value()); EXPECT_EQ(two->dump(), std::string("[[\"a\",\"b\"]]")); EXPECT_FALSE(Refresh::buildOperationFilterParams({}).has_value()); EXPECT_FALSE(Refresh::buildOperationFilterParams({""}).has_value()); } // C211: the Merge/Consolidate dialog's scope scan (listunspent + z_listunspent, two whole-wallet scans) // used to be re-posted on the frame after any failure, forever on Lite. One scan at a time, a failure // latches until Retry, and only "node not ready" failures retry by themselves (backed off, 3 times). void testScopeScanAndLockedNotes() { using namespace dragonx::services; using dragonx::rpc::RpcFailureKind; ScopeScan scan; ScopeInputs in; in.mergeMode = true; in.nodeReady = true; in.now = 100.0; scopeScanOpen(scan, 100.0); EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post); in.mergeMode = false; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); in.mergeMode = true; in.nodeReady = false; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::WaitNode); in.nodeReady = true; in.busyWithOp = true; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); in.busyWithOp = false; auto gen = scopeScanBegin(scan, 100.0); EXPECT_TRUE(scan.phase == ScopePhase::Loading); EXPECT_TRUE(scan.inFlight); EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); // one scan at a time // A real error latches until Retry: no re-scan by itself, whenever the next frame comes. EXPECT_FALSE(scopeScanFinish(scan, gen, "Insufficient permissions", RpcFailureKind::AppError, 101.0)); EXPECT_TRUE(scan.phase == ScopePhase::Failed); EXPECT_FALSE(scan.inFlight); EXPECT_FALSE(scan.autoRetry); in.now = 10000.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); scopeScanRetry(scan); EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post); // ...as does a reply timeout (the daemon is still running that scan). gen = scopeScanBegin(scan, 10000.0); EXPECT_FALSE(scopeScanFinish(scan, gen, "Timeout was reached", RpcFailureKind::ReplyTimeout, 10001.0)); EXPECT_FALSE(scan.autoRetry); in.now = 20000.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); // Node not ready: retries by itself after 10, 30 and 60 s, then latches. scopeScanRetry(scan); gen = scopeScanBegin(scan, 20000.0); scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20000.0); EXPECT_TRUE(scan.autoRetry); in.now = 20009.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::WaitBackoff); in.now = 20010.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post); gen = scopeScanBegin(scan, 20010.0); scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20010.0); in.now = 20039.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::WaitBackoff); in.now = 20040.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Post); gen = scopeScanBegin(scan, 20040.0); scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20040.0); EXPECT_NEAR(scan.retryAt, 20100.0, 1e-9); in.now = 20100.0; gen = scopeScanBegin(scan, 20100.0); scopeScanFinish(scan, gen, "Could not connect", RpcFailureKind::NotListening, 20100.0); EXPECT_FALSE(scan.autoRetry); // a 4th failure: wait for Retry in.now = 30000.0; EXPECT_TRUE(scopeScanWant(scan, in) == ScopeWant::Nothing); EXPECT_TRUE(scopeFailureRetries(RpcFailureKind::Other, "Not connected")); EXPECT_FALSE(scopeFailureRetries(RpcFailureKind::Other, "[json.exception.type_error.302] type must be number")); // A result from an earlier session (the connection changed meanwhile) is dropped, but frees the guard. ScopeScan old; scopeScanOpen(old, 0.0); const auto oldGen = scopeScanBegin(old, 0.0); scopeScanInvalidate(old); EXPECT_TRUE(old.inFlight); in.now = 1.0; EXPECT_TRUE(scopeScanWant(old, in) == ScopeWant::Nothing); // the old scan still runs in the daemon EXPECT_FALSE(scopeScanFinish(old, oldGen, "", RpcFailureKind::None, 1.0)); EXPECT_FALSE(old.inFlight); EXPECT_TRUE(old.phase == ScopePhase::Idle); EXPECT_TRUE(scopeScanWant(old, in) == ScopeWant::Post); // Closing and reopening while a scan loads adopts it (no second pair of scans)... ScopeScan adopt; scopeScanOpen(adopt, 0.0); const auto adoptGen = scopeScanBegin(adopt, 0.0); scopeScanOpen(adopt, 5.0); EXPECT_TRUE(adopt.gen == adoptGen); EXPECT_TRUE(adopt.inFlight); EXPECT_TRUE(scopeScanFinish(adopt, adoptGen, "", RpcFailureKind::None, 6.0)); EXPECT_TRUE(adopt.phase == ScopePhase::Loaded); // ...unless it has been outstanding past the limit: presumed lost, a new session starts. ScopeScan lost; scopeScanOpen(lost, 0.0); const auto lostGen = scopeScanBegin(lost, 0.0); scopeScanOpen(lost, kScopeInFlightMaxSec + 1.0); EXPECT_TRUE(lost.gen != lostGen); EXPECT_FALSE(lost.inFlight); const auto freshGen = scopeScanBegin(lost, kScopeInFlightMaxSec + 2.0); EXPECT_FALSE(scopeScanFinish(lost, lostGen, "", RpcFailureKind::None, kScopeInFlightMaxSec + 3.0)); EXPECT_TRUE(lost.inFlight); // the late result doesn't clear the fresh scan's guard EXPECT_TRUE(scopeScanFinish(lost, freshGen, "", RpcFailureKind::None, kScopeInFlightMaxSec + 4.0)); // Locked notes (held by an operation in flight) can't be merged, so they aren't counted. using json = nlohmann::json; const json notes = json::array({ json{{"address", "zs-a"}, {"confirmations", 3}, {"amount", 1.0}}, json{{"address", "zs-a"}, {"confirmations", 3}, {"amount", 1.0}, {"locked", true}}, json{{"address", "zs-a"}, {"confirmations", 3}, {"amount", 1.0}, {"locked", false}}, }); const auto groups = groupSpendableNotes(notes); EXPECT_EQ(groups.size(), static_cast(1)); EXPECT_EQ(groups[0].notes, 2); EXPECT_TRUE(noteIsLocked(json{{"locked", true}})); EXPECT_FALSE(noteIsLocked(json{{"locked", "yes"}})); EXPECT_FALSE(noteIsLocked(json::array())); } // C43/C53/C52: the pure history-scan policy (services/history_scan_policy). void testHistoryScanPolicy() { using namespace dragonx::services; using dragonx::rpc::RpcFailureKind; // A routine re-pass is due after enough blocks that it costs ~10% of wall-clock, within [8, 500]. EXPECT_EQ(historyStaleAfterBlocks(0, 0.0), 8); EXPECT_EQ(historyStaleAfterBlocks(4, 500.0), 8); // 2 s of scans: the floor EXPECT_EQ(historyStaleAfterBlocks(30, 10000.0), 84); // 300 s / (36 s * 10%) = 83.3 EXPECT_EQ(historyStaleAfterBlocks(1000, 60000.0), 500); // capped EXPECT_EQ(historyStaleAfterBlocks(30, std::numeric_limits::quiet_NaN()), 8); EXPECT_EQ(historyStaleAfterBlocks(30, std::numeric_limits::infinity()), 500); // The per-address cost estimate rises at once and decays 25% per sample; no sample, no change. EXPECT_NEAR(updateScanCostEstimateMs(0.0, 400.0), 400.0, 1e-9); EXPECT_NEAR(updateScanCostEstimateMs(400.0, 800.0), 800.0, 1e-9); EXPECT_NEAR(updateScanCostEstimateMs(800.0, 400.0), 700.0, 1e-9); EXPECT_NEAR(updateScanCostEstimateMs(800.0, 0.0), 800.0, 1e-9); // listtransactions' first page: last time's rows plus headroom, within [1000, 5000]. EXPECT_EQ(transparentPageSize(0), 1000); EXPECT_EQ(transparentPageSize(900), 1100); EXPECT_EQ(transparentPageSize(1500), 1700); EXPECT_EQ(transparentPageSize(4800), 5000); EXPECT_EQ(transparentPageSize(100000), 5000); // The sent sweep: the whole wallet without a usable watermark, else a depth window. Never 3 params. EXPECT_TRUE(sentSweepIsFull(0, 50)); EXPECT_TRUE(sentSweepIsFull(100, -1)); EXPECT_TRUE(sentSweepIsFull(100, 101)); // a watermark above the tip (reorg) EXPECT_FALSE(sentSweepIsFull(100, 100)); EXPECT_EQ(sentSweepParams("zs-one", 100, -1).dump(), std::string("[\"zs-one\",0]")); const auto windowed = sentSweepParams("zs-one", 110, 100); EXPECT_EQ(windowed.dump(), std::string("[\"zs-one\",0,2,110]")); EXPECT_TRUE(windowed.size() == 4 && windowed[1].is_number_integer() && windowed[2].is_number_integer() && windowed[3].is_number_integer()); // Backoff after whole-wallet sweeps that failed after the daemon did the work. EXPECT_EQ(fullSentSweepRetryDelaySec(0), 0L); EXPECT_EQ(fullSentSweepRetryDelaySec(1), 600L); EXPECT_EQ(fullSentSweepRetryDelaySec(2), 3600L); EXPECT_EQ(fullSentSweepRetryDelaySec(3), 21600L); EXPECT_EQ(fullSentSweepRetryDelaySec(9), 21600L); EXPECT_TRUE(fullSentSweepFailureCounts(RpcFailureKind::ReplyTimeout)); EXPECT_TRUE(fullSentSweepFailureCounts(RpcFailureKind::AppError)); EXPECT_TRUE(fullSentSweepFailureCounts(RpcFailureKind::Other)); for (auto kind : {RpcFailureKind::Deferred, RpcFailureKind::Aborted, RpcFailureKind::NotListening, RpcFailureKind::QueueFull, RpcFailureKind::Warmup}) { EXPECT_FALSE(fullSentSweepFailureCounts(kind)); } // The between-blocks probe takes the address scanned longest ago, never-scanned first. EXPECT_EQ(oldestScannedAddressIndex({"a", "b", "c"}, {{"a", 50}, {"b", 40}, {"c", 60}}), static_cast(1)); EXPECT_EQ(oldestScannedAddressIndex({"a", "b", "c"}, {{"a", 50}, {"c", 60}}), static_cast(1)); EXPECT_EQ(oldestScannedAddressIndex({}, {}), static_cast(0)); // decideTxCycle. auto cycle = [](void (*setup)(TxCycleInputs&)) { TxCycleInputs in; in.heightKnown = true; setup(in); return decideTxCycle(in); }; EXPECT_TRUE(cycle([](TxCycleInputs&) {}) == TxCycle::None); // A new block: a Full cycle only when the throttle allows; otherwise it waits. EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.newTip = true; in.gateOpen = true; }) == TxCycle::Full); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.newTip = true; }) == TxCycle::None); // Explicit scan work skips the throttle; routine work waits for it. EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.explicitScanWork = true; }) == TxCycle::Full); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.routineScanWork = true; }) == TxCycle::None); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.routineScanWork = true; in.gateOpen = true; }) == TxCycle::Full); // A mutation or the first load gets rows now through a Light cycle (no wallet-wide z-scans). EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.dirty = true; }) == TxCycle::Light); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.neverFetched = true; }) == TxCycle::Light); // Tracking a send alone needs a new tip for a Light cycle, so it can't loop them at one tip. EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.sendTracking = true; in.newTip = true; }) == TxCycle::Light); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.sendTracking = true; }) == TxCycle::None); // Nothing scans while syncing or before the tip is known, explicit work included. EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.explicitScanWork = true; in.dirty = true; in.syncing = true; }) == TxCycle::Light); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.explicitScanWork = true; in.heightKnown = false; }) == TxCycle::None); // The between-blocks poll probes one address only when the throttle allows. EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.recentDue = true; }) == TxCycle::Recent); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.recentDue = true; in.gateOpen = true; }) == TxCycle::RecentWithProbe); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.recentDue = true; in.gateOpen = true; in.syncing = true; }) == TxCycle::Recent); // At a new tip the cheap poll runs only while the throttle holds the Full cycle back. EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.newTip = true; in.recentDue = true; }) == TxCycle::Recent); EXPECT_TRUE(cycle([](TxCycleInputs& in) { in.newTip = true; in.recentDue = true; in.gateOpen = true; }) == TxCycle::Full); } namespace { // Answers every call with an empty array and records the methods (for simulations). class RecordingRefreshRpc : public dragonx::services::NetworkRefreshService::RefreshRpcGateway { public: std::vector methods; nlohmann::json call(const std::string& method, const nlohmann::json&) override { methods.push_back(method); return nlohmann::json::array(); } }; } // namespace // C43 end to end: the policy, the pass and the real collector over 6 virtual hours on a large wallet (40 // z-addresses at 10 s per wallet-wide call, 36 s blocks, the Overview cadence), mirroring the App glue in // refreshTransactionData / startTransactionCycle. The first load completes, routine passes keep // completing, and the scans hold cs_main for at most ~10% of the time once the first load is done. void testHistoryDutyModel() { using Refresh = dragonx::services::NetworkRefreshService; using namespace dragonx::services; constexpr int kAddresses = 40; constexpr std::size_t kBudget = 4; constexpr double kCallSec = 10.0; constexpr double kTickSec = 10.0; constexpr double kBlockSec = 36.0; constexpr double kEndSec = 6 * 3600.0; constexpr double kLateSec = 2 * 3600.0; std::vector addrs; for (int i = 0; i < kAddresses; ++i) addrs.push_back("zs-d" + std::to_string(i)); std::unordered_map heights; ShieldedHistoryPass pass; bool initialComplete = false; int lastTxTip = -1, watermark = -1; std::size_t cursor = 0; double lastScanAt = -1.0, lastScanCost = 0.0, busyUntil = 0.0, lateScanSec = 0.0; int routinePassesDone = 0; for (double now = 0.0; now < kEndSec; now += kTickSec) { if (now < busyUntil) continue; // one cycle in flight at a time const int tip = 1000 + static_cast(now / kBlockSec); const int staleAfter = historyStaleAfterBlocks(addrs.size(), kCallSec * 1000.0); const auto work = pass.evaluate(tip, addrs, heights, staleAfter, initialComplete); TxCycleInputs in; in.neverFetched = lastTxTip < 0; in.newTip = tip != lastTxTip; in.explicitScanWork = work.explicitWork; in.routineScanWork = work.routineWork; in.gateOpen = lastScanAt < 0.0 || now - lastScanAt >= lastScanCost / kHistoryScanDuty; in.heightKnown = true; const TxCycle cycle = decideTxCycle(in); if (cycle != TxCycle::Full && cycle != TxCycle::Light) continue; const bool full = cycle == TxCycle::Full; const bool routineAllowed = in.gateOpen; Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = addrs; snap.shieldedScanHeights = heights; if (full) { const bool wasActive = pass.active(); pass.begin(tip, addrs, heights, staleAfter, initialComplete, routineAllowed); if (!wasActive && pass.active()) cursor = 0; snap.shieldedScanFloorHeight = pass.scanFloor(tip, routineAllowed); snap.shieldedScanStartIndex = cursor; snap.maxShieldedReceiveScans = kBudget; for (const auto& a : addrs) { if (!heights.count(a)) snap.priorityScanFloors[a] = tip; } snap.sentScanFromHeight = watermark; snap.skipSentSweep = (!routineAllowed && initialComplete) || (watermark >= 0 && watermark == tip); snap.allowMidPassSentSweep = routineAllowed && watermark >= 0 && watermark < tip && tip - watermark >= std::max(kHistoryMinStaleBlocks, staleAfter / 4); } else { snap.skipShieldedScans = true; snap.skipSentSweep = true; } RecordingRefreshRpc rpc; auto result = Refresh::collectTransactionRefreshResult(rpc, snap, tip, 25); double cost = 0.0, sweepCost = 0.0; for (const auto& m : rpc.methods) { if (m == "z_listreceivedbyaddress") cost += kCallSec; if (m == "z_listsentbyaddress") { cost += kCallSec; sweepCost += kCallSec; } } busyUntil = now + cost; if (now >= kLateSec) lateScanSec += cost; lastTxTip = tip; if (!full) continue; lastScanCost = (result.sentSweepRan && result.sentSweepFull) ? std::max(0.001, cost - sweepCost) : cost; lastScanAt = now + cost; const bool wasActive = pass.active(); const bool wasExplicit = pass.explicitPass(); heights = result.shieldedScanHeights; cursor = result.nextShieldedScanStartIndex; if (result.sentSweepOk) watermark = result.sentSweepHeight; pass.finish(tip, addrs, heights, result.shieldedAddressesScanned, result.shieldedScansSucceeded); if (wasActive && !pass.active() && !wasExplicit) ++routinePassesDone; const auto after = pass.evaluate(tip, addrs, heights, staleAfter, initialComplete); if (after.total > 0 && after.missing == 0 && !(pass.active() && pass.explicitPass())) initialComplete = true; } EXPECT_TRUE(initialComplete); EXPECT_TRUE(routinePassesDone >= 3); const double share = lateScanSec / (kEndSec - kLateSec); EXPECT_TRUE(share <= 0.12); EXPECT_TRUE(share >= 0.05); // and it does keep scanning (a stuck model would read 0) } // C43: a pass re-scans every z-address once, ending when each is at or above the pass's start height. void testShieldedHistoryPass() { using namespace dragonx::services; const std::vector addrs = {"z1", "z2", "z3"}; ShieldedHistoryPass pass; // First load: nothing scanned yet, all of it explicit work. auto work = pass.evaluate(100, addrs, {}, 8, false); EXPECT_EQ(work.total, static_cast(3)); EXPECT_EQ(work.missing, static_cast(3)); EXPECT_EQ(work.pending, static_cast(3)); EXPECT_TRUE(work.explicitWork); EXPECT_FALSE(work.routineWork); // All scanned at 100: nothing is due until they're older than staleAfter. std::unordered_map heights{{"z1", 100}, {"z2", 100}, {"z3", 100}}; work = pass.evaluate(105, addrs, heights, 8, true); EXPECT_EQ(work.pending, static_cast(0)); EXPECT_FALSE(work.explicitWork); EXPECT_FALSE(work.routineWork); // Stale: a routine pass is due, and doesn't start without the throttle. work = pass.evaluate(109, addrs, heights, 8, true); EXPECT_EQ(work.pending, static_cast(3)); EXPECT_FALSE(work.explicitWork); EXPECT_TRUE(work.routineWork); pass.begin(109, addrs, heights, 8, true, false); EXPECT_FALSE(pass.active()); EXPECT_EQ(pass.scanFloor(109, false), -1); pass.begin(109, addrs, heights, 8, true, true); EXPECT_TRUE(pass.active()); EXPECT_FALSE(pass.explicitPass()); EXPECT_EQ(pass.startHeight(), 109); // A routine pass scans only in cycles the throttle allows. EXPECT_EQ(pass.scanFloor(110, true), 109); EXPECT_EQ(pass.scanFloor(110, false), -1); // It ends once every address is at or above its floor, however far the tip has moved. heights["z1"] = 112; heights["z2"] = 115; pass.finish(115, addrs, heights, 2, 2); EXPECT_TRUE(pass.active()); // z3 is still below 109 work = pass.evaluate(115, addrs, heights, 8, true); EXPECT_EQ(work.pending, static_cast(1)); EXPECT_TRUE(work.routineWork); pass.finish(140, {}, heights, 4, 0); // a list blanked by a failed enumeration proves nothing EXPECT_TRUE(pass.active()); heights["z3"] = 140; pass.finish(140, addrs, heights, 1, 1); EXPECT_FALSE(pass.active()); // Before the first load completes, a pass is explicit: it may run back to back. ShieldedHistoryPass first; const std::unordered_map cached{{"z1", 10}, {"z2", 10}, {"z3", 10}}; // the history cache first.begin(200, addrs, cached, 8, false, false); EXPECT_TRUE(first.active()); EXPECT_TRUE(first.explicitPass()); EXPECT_EQ(first.scanFloor(200, false), 200); EXPECT_TRUE(first.evaluate(201, addrs, cached, 8, false).explicitWork); first.begin(150, addrs, cached, 8, false, false); // a reorg below the floor pulls it down EXPECT_EQ(first.startHeight(), 150); first.reset(); EXPECT_FALSE(first.active()); EXPECT_EQ(first.startHeight(), -1); // Scans that keep failing: explicit work turns routine (throttled) until one succeeds. ShieldedHistoryPass stall; stall.finish(100, addrs, {}, 2, 0); EXPECT_TRUE(stall.stalled()); work = stall.evaluate(100, addrs, {}, 8, false); EXPECT_FALSE(work.explicitWork); EXPECT_TRUE(work.routineWork); stall.finish(100, addrs, {}, 1, 1); EXPECT_FALSE(stall.stalled()); EXPECT_TRUE(stall.evaluate(100, addrs, {}, 8, false).explicitWork); } // C43 with the real collector: a pass over 10 addresses, 4 per cycle, finishes in 3 cycles although the tip // moves 5 blocks per cycle (with the old "scanned within T of the tip" rule addresses went stale again // mid-pass and a large wallet's pass never finished), and the sent sweep runs once, when it's done. void testHistoryPassWithCollector() { using Refresh = dragonx::services::NetworkRefreshService; using namespace dragonx::services; using json = nlohmann::json; std::vector addrs; std::unordered_map heights; for (int i = 0; i < 10; ++i) { addrs.push_back("zs-p" + std::to_string(i)); heights[addrs.back()] = 100; } ShieldedHistoryPass pass; int tip = 120; pass.begin(tip, addrs, heights, 8, true, true); EXPECT_TRUE(pass.active()); std::size_t cursor = 0; std::size_t scans = 0; int cycles = 0, sweeps = 0, sweepCycle = -1; while (pass.active() && cycles < 10) { Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = addrs; snap.shieldedScanHeights = heights; snap.maxShieldedReceiveScans = 4; snap.shieldedScanFloorHeight = pass.scanFloor(tip, true); snap.shieldedScanStartIndex = cursor; snap.sentScanFromHeight = 100; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); for (int i = 0; i < 4; ++i) rpc.addResponse("z_listreceivedbyaddress", json::array()); rpc.addResponse("z_listsentbyaddress", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, tip, 0); ++cycles; scans += result.shieldedAddressesScanned; for (const auto& call : rpc.calls) { if (call.method == "z_listsentbyaddress") { ++sweeps; sweepCycle = cycles; } } heights = result.shieldedScanHeights; cursor = result.nextShieldedScanStartIndex; pass.finish(tip, addrs, heights, result.shieldedAddressesScanned, result.shieldedScansSucceeded); tip += 5; } EXPECT_FALSE(pass.active()); EXPECT_EQ(cycles, 3); EXPECT_EQ(scans, static_cast(10)); EXPECT_EQ(sweeps, 1); EXPECT_EQ(sweepCycle, 3); // A new address while a routine pass is active and the throttle is closed: the cycle scans only it // (priority, floor -1), not the routine pass's addresses, and the pass stays active. std::vector many; std::unordered_map manyHeights; for (int i = 0; i < 12; ++i) { many.push_back("zs-r" + std::to_string(i)); if (i != 2) manyHeights[many.back()] = 90; } ShieldedHistoryPass routine; routine.begin(100, many, manyHeights, 8, true, true); EXPECT_TRUE(routine.active()); Refresh::TransactionRefreshSnapshot explicitOnly; explicitOnly.shieldedAddresses = many; explicitOnly.shieldedScanHeights = manyHeights; explicitOnly.maxShieldedReceiveScans = 4; explicitOnly.shieldedScanFloorHeight = routine.scanFloor(105, false); explicitOnly.shieldedScanStartIndex = 8; explicitOnly.priorityScanFloors = {{many[2], 105}}; explicitOnly.skipSentSweep = true; EXPECT_EQ(explicitOnly.shieldedScanFloorHeight, -1); MockRefreshRpc newAddrRpc; newAddrRpc.addResponse("listtransactions", json::array()); newAddrRpc.addResponse("z_listreceivedbyaddress", json::array()); auto newAddr = Refresh::collectTransactionRefreshResult(newAddrRpc, explicitOnly, 105, 0); EXPECT_TRUE(newAddrRpc.methodNames() == std::vector({"listtransactions", "z_listreceivedbyaddress"})); EXPECT_EQ(newAddrRpc.calls[1].params, json::array({many[2], 0})); EXPECT_EQ(newAddr.shieldedAddressesScanned, static_cast(1)); routine.finish(105, many, newAddr.shieldedScanHeights, 1, 1); EXPECT_TRUE(routine.active()); } // C53/C52: the sent sweep, enrichment backlog and carry-forward, the adaptive page and row reuse. void testHistoryCollectorSweepAndEnrichment() { using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; using dragonx::rpc::RpcFailureKind; using dragonx::rpc::RpcTransportError; using dragonx::rpc::RpcError; auto freshSnapshot = []() { Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-one"}; snap.shieldedScanHeights = {{"zs-one", 110}}; return snap; }; auto countRows = [](const Refresh::TransactionRefreshResult& r, const std::string& txid) { return static_cast(std::count_if(r.transactions.begin(), r.transactions.end(), [&](const dragonx::TransactionInfo& t) { return t.txid == txid; })); }; { // Past a watermark: a windowed sweep, an ordinary call. auto snap = freshSnapshot(); snap.sentScanFromHeight = 100; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listsentbyaddress", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_TRUE(rpc.methodNames() == std::vector({"listtransactions", "z_listsentbyaddress", "getblockhash"})); EXPECT_EQ(rpc.calls[1].params.dump(), std::string("[\"zs-one\",0,2,110]")); EXPECT_EQ(rpc.longCalls, 0); EXPECT_TRUE(result.sentSweepRan && result.sentSweepOk && !result.sentSweepFull); EXPECT_EQ(result.sentSweepHeight, 110); } { // No watermark: the whole-wallet sweep, banded. A small chain fits one band [0, tip], long timeout. auto snap = freshSnapshot(); MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listsentbyaddress", json::array()); rpc.addResponse("getblockhash", "anchor"); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_EQ(rpc.calls[1].params.dump(), std::string("[\"zs-one\",0,2,110]")); // band [0, tip] EXPECT_EQ(rpc.longCalls, 1); EXPECT_EQ(rpc.lastLongTimeout, 120L); EXPECT_TRUE(result.sentSweepFull && result.sentSweepOk); } { // Restored watermark, anchor verify MATCHES: a windowed sweep (bigger first-after-load margin), and // the next anchor (A = W - steady margin, H_A) is fetched for the following load. auto snap = freshSnapshot(); snap.sentScanFromHeight = 100; snap.sentVerifyPending = true; snap.sentVerifyAnchorHeight = 50; snap.sentVerifyAnchorHash = "anchor-hash"; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("getblockhash", "anchor-hash"); // verify: matches rpc.addResponse("z_listsentbyaddress", json::array()); rpc.addResponse("getblockhash", "new-anchor-hash"); // post-success anchor fetch auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_TRUE(result.sentSweepRan && result.sentSweepOk && !result.sentSweepFull); const int wantDepth = (110 - 100) + dragonx::services::sentSweepMargin(true); bool sawWindowed = false; for (const auto& c : rpc.calls) if (c.method == "z_listsentbyaddress") sawWindowed = (c.params.dump() == std::string("[\"zs-one\",0,2,") + std::to_string(wantDepth) + "]"); EXPECT_TRUE(sawWindowed); EXPECT_EQ(result.sentSweepAnchorHeight, dragonx::services::sentSweepAnchor(110, false)); EXPECT_EQ(result.sentSweepAnchorHash, std::string("new-anchor-hash")); } { // Anchor verify MISMATCH (reorg deeper than the margin / different chain): drop the watermark and // sweep the whole wallet instead of a window that would miss the moved blocks. auto snap = freshSnapshot(); snap.sentScanFromHeight = 100; snap.sentVerifyPending = true; snap.sentVerifyAnchorHeight = 50; snap.sentVerifyAnchorHash = "old-hash"; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("getblockhash", "reorged-hash"); // verify: mismatch rpc.addResponse("z_listsentbyaddress", json::array()); rpc.addResponse("getblockhash", "anchor"); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_TRUE(result.sentSweepRan && result.sentSweepOk && result.sentSweepFull); bool sawFull = false; // a mismatch forces a full (banded) sweep: the band [0, tip] for (const auto& c : rpc.calls) if (c.method == "z_listsentbyaddress") sawFull = (c.params.dump() == std::string("[\"zs-one\",0,2,110]")); EXPECT_TRUE(sawFull); } { // A watermark saved long ago (tip - W > 2400): still a windowed call, but given the long timeout so // it doesn't time out on every block. Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-one"}; snap.shieldedScanHeights = {{"zs-one", 3000}}; // scan complete at the tip snap.sentScanFromHeight = 400; // tip - W = 2600 > 2400 MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listsentbyaddress", json::array()); rpc.addResponse("getblockhash", "anchor"); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 3000, 4); EXPECT_TRUE(result.sentSweepRan && result.sentSweepOk && !result.sentSweepFull); EXPECT_TRUE(rpc.longCalls >= 1); // the big windowed call uses callLong EXPECT_EQ(rpc.lastLongTimeout, 120L); } { // Banded full sweep over a large chain: several short bands, newest first, covering down to the tip // depth. Each band is a separate call (short cs_main hold) instead of one call for the whole sweep. Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-one"}; snap.shieldedScanHeights = {{"zs-one", 10000}}; // scan complete; no watermark -> full -> banded MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); for (int b = 0; b < 16; ++b) rpc.addResponse("z_listsentbyaddress", json::array()); rpc.addResponse("getblockhash", "anchor"); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 10000, 4); EXPECT_TRUE(result.sentSweepFull && result.sentSweepOk); EXPECT_EQ(result.sentSweepHeight, 10000); EXPECT_TRUE(rpc.longCalls >= 2); // more than one band EXPECT_EQ(rpc.calls[1].params.dump(), std::string("[\"zs-one\",0,2,2000]")); // first (newest) band std::string lastBand; for (const auto& c : rpc.calls) if (c.method == "z_listsentbyaddress") lastBand = c.params.dump(); EXPECT_TRUE(lastBand.find(",10000]") != std::string::npos); // last band reaches the tip } { // A failed sweep reports why (the caller's backoff), and keeps every previous row. auto snap = freshSnapshot(); snap.previousTransactions.push_back(dragonx::TransactionInfo{"r-old", "receive", 1.0, 50, 9, "R-one", "", ""}); MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addException("z_listsentbyaddress", std::make_exception_ptr(RpcTransportError( RpcFailureKind::ReplyTimeout, "RPC request failed: Timeout was reached", CURLE_OPERATION_TIMEDOUT, 0, true))); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_TRUE(result.sentSweepRan && !result.sentSweepOk); EXPECT_TRUE(result.sentSweepErrKind == RpcFailureKind::ReplyTimeout); EXPECT_EQ(countRows(result, "r-old"), 1); } { // skipSentSweep: no sweep at all. auto snap = freshSnapshot(); snap.skipSentSweep = true; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_TRUE(rpc.methodNames() == std::vector({"listtransactions"})); EXPECT_FALSE(result.sentSweepRan); } { // A Light cycle: no receive scans, no sweep; the shielded rows on screen are carried. Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-a"}; snap.skipShieldedScans = true; snap.skipSentSweep = true; snap.previousTransactions.push_back(dragonx::TransactionInfo{"s-old", "receive", 2.0, 60, 4, "zs-a", "", ""}); MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_TRUE(rpc.methodNames() == std::vector({"listtransactions"})); EXPECT_EQ(countRows(result, "s-old"), 1); } { // Priority addresses are scanned before the cursor's. Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-a", "zs-b", "zs-c", "zs-d"}; snap.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}, {"zs-c", 100}, {"zs-d", 100}}; snap.shieldedScanFloorHeight = 105; snap.priorityScanFloors = {{"zs-d", 105}}; snap.maxShieldedReceiveScans = 2; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); Refresh::collectTransactionRefreshResult(rpc, snap, 105, 4); EXPECT_EQ(rpc.calls[1].params, json::array({"zs-d", 0})); EXPECT_EQ(rpc.calls[2].params, json::array({"zs-a", 0})); } { // Enrichment order: this cycle's discoveries, then the backlog; past the cap they're handed back. auto snap = freshSnapshot(); snap.skipSentSweep = true; snap.enrichBacklogTxids = {"b1"}; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array({ json{{"txid", "t1"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 1.0}, {"time", 70}, {"confirmations", 0}}, json{{"txid", "t2"}, {"category", "receive"}, {"address", "R-one"}, {"amount", 2.0}, {"time", 71}, {"confirmations", 0}} })); rpc.addResponse("z_viewtransaction", json{{"spends", json::array()}, {"outputs", json::array()}}); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 1); EXPECT_EQ(rpc.calls[1].params, json::array({"t1"})); EXPECT_EQ(result.unenrichedTxids.size(), static_cast(2)); EXPECT_EQ(result.unenrichedTxids.count("t2"), static_cast(1)); EXPECT_EQ(result.unenrichedTxids.count("b1"), static_cast(1)); } // A shielded send row from the history cache (an earlier session) with no cached view: re-enriched. // The cycles below are full rebuilds (every address scanned, the pass complete), which drop rows that // aren't rebuilt, except the "pinned" ones still waiting for their view. auto rebuildSnapshot = [](std::vector previous) { Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-mine"}; snap.shieldedScanHeights = {{"zs-mine", 100}}; snap.shieldedScanFloorHeight = 110; snap.skipSentSweep = true; snap.previousTransactions = std::move(previous); return snap; }; const dragonx::TransactionInfo oldSend{"zz-old", "send", -1.5, 900, 30, "zs-peer", "zs-mine", ""}; { // The daemon answers: the row is rebuilt from the view, dated from the settled stored row (no // gettransaction per row). MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); rpc.addResponse("z_viewtransaction", json{ {"spends", json::array({json{{"address", "zs-mine"}}})}, {"outputs", json::array({json{{"outgoing", true}, {"address", "zs-peer"}, {"value", 1.5}}})}}); auto result = Refresh::collectTransactionRefreshResult(rpc, rebuildSnapshot({oldSend}), 110, 4); EXPECT_TRUE(rpc.methodNames() == std::vector({ "listtransactions", "z_listreceivedbyaddress", "z_viewtransaction"})); EXPECT_EQ(result.newViewTxEntries.count("zz-old"), static_cast(1)); EXPECT_EQ(countRows(result, "zz-old"), 1); for (const auto& t : result.transactions) { if (t.txid != "zz-old") continue; EXPECT_EQ(t.timestamp, static_cast(900)); EXPECT_EQ(t.confirmations, 30); } } { // A stored unsettled send still asks gettransaction; if that fails, the stored row stays (pinned), // no undated view is cached, and it is retried. const dragonx::TransactionInfo pendingSend{"zz-pending", "send", -1.0, 950, 0, "zs-peer", "zs-mine", ""}; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); rpc.addResponse("z_viewtransaction", json{ {"spends", json::array({json{{"address", "zs-mine"}}})}, {"outputs", json::array({json{{"outgoing", true}, {"address", "zs-peer"}, {"value", 1.0}}})}}); rpc.addFailure("gettransaction", "busy"); auto result = Refresh::collectTransactionRefreshResult(rpc, rebuildSnapshot({pendingSend}), 110, 4); EXPECT_EQ(rpc.calls.back().method, std::string("gettransaction")); EXPECT_EQ(result.newViewTxEntries.count("zz-pending"), static_cast(0)); EXPECT_EQ(result.unenrichedTxids.count("zz-pending"), static_cast(1)); EXPECT_EQ(countRows(result, "zz-pending"), 1); for (const auto& t : result.transactions) { if (t.txid == "zz-pending") EXPECT_EQ(t.timestamp, static_cast(950)); } } { // Over the cap: still pinned, so the stored row survives the rebuild. MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, rebuildSnapshot({oldSend}), 110, 0); EXPECT_EQ(countRows(result, "zz-old"), 1); EXPECT_EQ(result.unenrichedTxids.count("zz-old"), static_cast(1)); } { // Only "not a wallet tx" (-5) is definitive: cached empty, and the row is let go. MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); rpc.addException("z_viewtransaction", std::make_exception_ptr(RpcError(-5, "Invalid or non-wallet transaction id"))); auto result = Refresh::collectTransactionRefreshResult(rpc, rebuildSnapshot({oldSend}), 110, 4); EXPECT_EQ(result.newViewTxEntries.count("zz-old"), static_cast(1)); EXPECT_TRUE(result.newViewTxEntries.at("zz-old").outgoing_outputs.empty()); EXPECT_EQ(countRows(result, "zz-old"), 0); } for (int plainError = 0; plainError < 2; ++plainError) { // anything else: retried, the row kept MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); if (plainError) rpc.addFailure("z_viewtransaction", "boom"); else rpc.addException("z_viewtransaction", std::make_exception_ptr(RpcError(-1, "something else"))); auto result = Refresh::collectTransactionRefreshResult(rpc, rebuildSnapshot({oldSend}), 110, 4); EXPECT_EQ(result.newViewTxEntries.count("zz-old"), static_cast(0)); EXPECT_EQ(result.unenrichedTxids.count("zz-old"), static_cast(1)); EXPECT_EQ(countRows(result, "zz-old"), 1); } { // A pinned send to two recipients keeps both rows (not just the first per txid), in a full rebuild... const dragonx::TransactionInfo legA{"zz-two", "send", -1.0, 900, 30, "zs-peer-a", "zs-mine", ""}; const dragonx::TransactionInfo legB{"zz-two", "send", -2.0, 900, 30, "zs-peer-b", "zs-mine", ""}; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, rebuildSnapshot({legA, legB}), 110, 0); EXPECT_EQ(countRows(result, "zz-two"), 2); // ...and in a cycle that carries rows (here a Light cycle). Refresh::TransactionRefreshSnapshot light; light.shieldedAddresses = {"zs-mine"}; light.shieldedScanHeights = {{"zs-mine", 100}}; light.skipShieldedScans = true; light.skipSentSweep = true; light.previousTransactions = {legA, legB}; MockRefreshRpc lightRpc; lightRpc.addResponse("listtransactions", json::array()); auto carried = Refresh::collectTransactionRefreshResult(lightRpc, light, 110, 0); EXPECT_EQ(countRows(carried, "zz-two"), 2); EXPECT_EQ(carried.unenrichedTxids.count("zz-two"), static_cast(1)); } { // A windowed sweep mid-pass when allowed (rate-limited by the caller); never the whole-wallet one. Refresh::TransactionRefreshSnapshot midPass; midPass.shieldedAddresses = {"zs-a", "zs-b"}; midPass.shieldedScanHeights = {{"zs-a", 100}, {"zs-b", 100}}; midPass.shieldedScanFloorHeight = 110; midPass.maxShieldedReceiveScans = 1; midPass.sentScanFromHeight = 100; midPass.allowMidPassSentSweep = true; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); rpc.addResponse("z_listreceivedbyaddress", json::array()); rpc.addResponse("z_listsentbyaddress", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, midPass, 110, 4); EXPECT_FALSE(result.shieldedScanComplete); EXPECT_TRUE(result.sentSweepRan && !result.sentSweepFull); // The sweep is the 2nd-to-last call now (a getblockhash for the anchor follows a successful sweep). EXPECT_EQ(rpc.calls[rpc.calls.size() - 2].params.dump(), std::string("[\"zs-a\",0,2,110]")); midPass.allowMidPassSentSweep = false; MockRefreshRpc offRpc; offRpc.addResponse("listtransactions", json::array()); offRpc.addResponse("z_listreceivedbyaddress", json::array()); EXPECT_FALSE(Refresh::collectTransactionRefreshResult(offRpc, midPass, 110, 4).sentSweepRan); midPass.allowMidPassSentSweep = true; midPass.sentScanFromHeight = -1; // no watermark: the whole-wallet sweep waits for the pass MockRefreshRpc fullRpc; fullRpc.addResponse("listtransactions", json::array()); fullRpc.addResponse("z_listreceivedbyaddress", json::array()); EXPECT_FALSE(Refresh::collectTransactionRefreshResult(fullRpc, midPass, 110, 4).sentSweepRan); } { // C52: the first page is sized from last time's rows. auto snap = freshSnapshot(); snap.skipSentSweep = true; snap.transparentRowHint = 1500; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_EQ(rpc.calls[0].params, json::array({"*", 1700, 0})); EXPECT_TRUE(result.transparentChainOk); } { // Same tip, nothing changed: the transparent rows are reused, no listtransactions at all; a tracked // send is rebuilt once (not reused and placeheld twice). auto snap = freshSnapshot(); snap.skipSentSweep = true; snap.reuseTransparentRows = true; snap.sendTxids = {"t-sent"}; snap.previousTransactions = { dragonx::TransactionInfo{"r1", "receive", 1.0, 80, 5, "R-one", "", ""}, dragonx::TransactionInfo{"s1", "receive", 2.0, 81, 5, "zs-one", "", ""}, dragonx::TransactionInfo{"t-sent", "send", -0.5, 82, 0, "R-two", "R-one", ""}, }; MockRefreshRpc rpc; rpc.addResponse("z_viewtransaction", json()); rpc.addResponse("gettransaction", json{{"time", 82}, {"confirmations", 0}}); auto result = Refresh::collectTransactionRefreshResult(rpc, snap, 110, 4); EXPECT_EQ(rpc.calls[0].method, std::string("z_viewtransaction")); EXPECT_TRUE(result.transparentRowsReused); EXPECT_EQ(countRows(result, "r1"), 1); EXPECT_EQ(countRows(result, "s1"), 1); EXPECT_EQ(countRows(result, "t-sent"), 1); } { // The between-blocks poll without its probe: listtransactions only. Refresh::TransactionRefreshSnapshot snap; snap.shieldedAddresses = {"zs-a", "zs-b"}; snap.maxShieldedReceiveScans = 1; snap.skipShieldedScans = true; MockRefreshRpc rpc; rpc.addResponse("listtransactions", json::array()); auto recent = Refresh::collectRecentTransactionRefreshResult(rpc, snap, 110); EXPECT_TRUE(rpc.methodNames() == std::vector({"listtransactions"})); EXPECT_EQ(recent.shieldedAddressesScanned, static_cast(0)); } } // C37: the scan governor's admission, floors, charging, change gate and lifecycle. void testWalletScanGovernor() { using namespace dragonx::services; using G = WalletScanGovernor; ScanSignals sig; sig.tipHash = "tip-a"; sig.mempoolKnown = true; sig.mempoolTxs = 1; sig.mempoolBytes = 100; { // One scan at a time; after it, quiet until it was `budget` (15%) of the time since it started. G gov; auto t = gov.tryAdmit(ScanStream::Balance, true, sig, 0.0); EXPECT_TRUE(static_cast(t)); EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::Addresses, true, sig, 0.5))); // one at a time gov.complete(t, 1000.0, ScanOutcome::Ok, 1.0); EXPECT_NEAR(gov.quietUntil(), 1.0 + 1.0 * (1.0 / 0.15 - 1.0), 1e-6); // ~6.67 EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::Balance, true, sig, 6.5))); // quiet EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::Balance, true, sig, 6.7))); // Addresses waited longer EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::Addresses, true, sig, 6.8))); } { // FIFO among waiters; a visible one gets a 60 s head start; a stream that stopped asking isn't waiting. G gov; auto busy = gov.beginExplicit(ScanStream::Adhoc, sig, 0.0); // keeps the slot taken while they queue gov.tryAdmit(ScanStream::Addresses, false, sig, 1.0); gov.tryAdmit(ScanStream::History, false, sig, 2.0); gov.complete(busy, 0.0, ScanOutcome::Skipped, 3.0); gov.tryAdmit(ScanStream::History, false, sig, 3.0); // still waiting, but asked later EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::History, false, sig, 3.5))); EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::Addresses, false, sig, 3.5))); // first in G head; auto slot = head.beginExplicit(ScanStream::Adhoc, sig, 0.0); head.tryAdmit(ScanStream::Addresses, false, sig, 1.0); // hidden, waiting since 1 head.tryAdmit(ScanStream::Balance, true, sig, 30.0); // visible, waiting since 30 (ranks as -30) head.complete(slot, 0.0, ScanOutcome::Skipped, 31.0); head.tryAdmit(ScanStream::Addresses, false, sig, 31.0); EXPECT_TRUE(static_cast(head.tryAdmit(ScanStream::Balance, true, sig, 31.5))); G ttl; auto held = ttl.beginExplicit(ScanStream::Adhoc, sig, 0.0); ttl.tryAdmit(ScanStream::Addresses, false, sig, 1.0); // then stops asking ttl.complete(held, 0.0, ScanOutcome::Skipped, 20.0); EXPECT_TRUE(static_cast(ttl.tryAdmit(ScanStream::History, false, sig, 20.0))); // Addresses lapsed } { // During quiet, a waiter gets in only via its floor: max(30 s visible / 900 s hidden, 15x its cost). G gov; auto big = gov.tryAdmit(ScanStream::History, true, sig, 0.0); gov.complete(big, 200000.0, ScanOutcome::Ok, 200.0); // quiet capped at now + 900 EXPECT_NEAR(gov.quietUntil(), 1100.0, 1e-6); double t = 200.0; bool admitted = false; for (; t < 1100.0 && !admitted; t += 2.0) admitted = static_cast(gov.tryAdmit(ScanStream::Balance, true, sig, t)); EXPECT_TRUE(admitted); EXPECT_NEAR(t - 2.0, 230.0, 2.1); // waited 30 s (EMA 0) G costly; auto first = costly.tryAdmit(ScanStream::Addresses, true, sig, 0.0); costly.complete(first, 40000.0, ScanOutcome::Ok, 40.0); // EMA 40 s: floor 600 s auto again = costly.tryAdmit(ScanStream::History, true, sig, 300.0); // after that quiet EXPECT_TRUE(static_cast(again)); costly.complete(again, 200000.0, ScanOutcome::Ok, 500.0); // quiet to 500 + 900 admitted = false; for (t = 500.0; t < 1400.0 && !admitted; t += 2.0) admitted = static_cast(costly.tryAdmit(ScanStream::Addresses, true, sig, t)); EXPECT_TRUE(admitted); EXPECT_NEAR(t - 2.0, 1100.0, 2.1); // 600 s G hidden; auto h = hidden.tryAdmit(ScanStream::History, true, sig, 0.0); hidden.complete(h, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100 auto blocker = hidden.beginExplicit(ScanStream::Adhoc, sig, 1050.0); hidden.complete(blocker, 200000.0, ScanOutcome::Ok, 1060.0); // explicit: quiet only to +120 = 1180 admitted = false; for (t = 200.0; t < 2000.0 && !admitted; t += 2.0) admitted = static_cast(hidden.tryAdmit(ScanStream::Balance, false, sig, t)); EXPECT_TRUE(admitted); EXPECT_TRUE(t - 2.0 >= 1100.0 - 0.1); // hidden: not before its 900 s floor } { // Charging: routine debt accumulates but quiet never runs past now + 900; explicit debt only to // now + 120 and never shortens quiet; TimedOut charges at least the usual cost; Skipped nothing. G gov; double now = 0.0; for (int i = 0; i < 5; ++i) { auto t = gov.beginExplicit(ScanStream::History, sig, now); gov.complete(t, 0.0, ScanOutcome::Skipped, now); auto r = gov.tryAdmit(ScanStream::Balance, true, sig, gov.quietUntil() + 1.0); now = gov.quietUntil() + 1.0; if (r) gov.complete(r, 200000.0, ScanOutcome::Ok, now); EXPECT_TRUE(gov.quietUntil() - now <= 900.0 + 1e-6); } G ex; auto e = ex.beginExplicit(ScanStream::Balance, sig, 0.0); ex.complete(e, 100000.0, ScanOutcome::Ok, 10.0); EXPECT_NEAR(ex.quietUntil(), 130.0, 1e-6); auto r = ex.beginExplicit(ScanStream::Addresses, sig, 10.0); ex.complete(r, 1.0, ScanOutcome::Ok, 11.0); EXPECT_TRUE(ex.quietUntil() >= 130.0 && ex.quietUntil() <= 131.0); // adds its own bit, never shortens G timed; auto a = timed.beginExplicit(ScanStream::Balance, sig, 0.0); timed.complete(a, 10000.0, ScanOutcome::Ok, 10.0); const double q1 = timed.quietUntil(); auto b = timed.beginExplicit(ScanStream::Balance, sig, 200.0); timed.complete(b, 1000.0, ScanOutcome::TimedOut, 201.0); // charged the 10 s EMA, not 1 s EXPECT_TRUE(timed.quietUntil() >= 201.0 + 10.0 * (1.0 / 0.15 - 1.0) - 1e-6 || timed.quietUntil() >= 201.0 + 120.0 - 1e-6); EXPECT_TRUE(q1 > 0.0); auto c = timed.beginExplicit(ScanStream::Addresses, sig, 1000.0); const double before = timed.quietUntil(); timed.complete(c, 50000.0, ScanOutcome::Skipped, 1001.0); EXPECT_NEAR(timed.quietUntil(), before, 1e-9); // Skipped: nothing sent, nothing charged G ema; auto e1 = ema.beginExplicit(ScanStream::Balance, sig, 0.0); ema.complete(e1, 10000.0, ScanOutcome::Ok, 1.0); auto e2 = ema.beginExplicit(ScanStream::Balance, sig, 2.0); ema.complete(e2, 30000.0, ScanOutcome::Ok, 3.0); EXPECT_NEAR(ema.emaCostMs(ScanStream::Balance), 30000.0, 1e-6); // rises at once auto e3 = ema.beginExplicit(ScanStream::Balance, sig, 4.0); ema.complete(e3, 10000.0, ScanOutcome::Ok, 5.0); EXPECT_NEAR(ema.emaCostMs(ScanStream::Balance), 24000.0, 1e-6); // decays slowly (0.7 / 0.3) G lag; EXPECT_NEAR(lag.currentBudget(), 0.15, 1e-12); lag.setTipLag(1); EXPECT_NEAR(lag.currentBudget(), 0.10, 1e-12); lag.setTipLag(5); EXPECT_NEAR(lag.currentBudget(), 0.05, 1e-12); } { // The change gate. G gov; EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 0.0)); // no basis yet auto t = gov.beginExplicit(ScanStream::Balance, sig, 0.0); gov.complete(t, 100.0, ScanOutcome::Ok, 1.0); EXPECT_FALSE(gov.needed(ScanStream::Balance, sig, 10.0)); ScanSignals pool = sig; pool.mempoolTxs = 2; EXPECT_TRUE(gov.needed(ScanStream::Balance, pool, 10.0)); ScanSignals epoch = sig; epoch.walletEpoch = 1; EXPECT_TRUE(gov.needed(ScanStream::Balance, epoch, 10.0)); EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 300.0)); // the safety sweep ScanSignals unknown = sig; unknown.mempoolKnown = false; EXPECT_TRUE(gov.needed(ScanStream::Balance, unknown, 10.0)); // unknown never matches ScanSignals noTip = sig; noTip.tipHash.clear(); EXPECT_TRUE(gov.needed(ScanStream::Balance, noTip, 10.0)); // History compares the tip and the epoch only. auto h = gov.beginExplicit(ScanStream::History, sig, 20.0); gov.complete(h, 100.0, ScanOutcome::Ok, 21.0); EXPECT_FALSE(gov.needed(ScanStream::History, pool, 30.0)); ScanSignals tip = sig; tip.tipHash = "tip-b"; EXPECT_TRUE(gov.needed(ScanStream::History, tip, 30.0)); // A failure keeps the basis. auto f = gov.beginExplicit(ScanStream::Balance, tip, 40.0); gov.complete(f, 100.0, ScanOutcome::Failed, 41.0); EXPECT_TRUE(gov.needed(ScanStream::Balance, tip, 50.0)); EXPECT_FALSE(gov.needed(ScanStream::Balance, sig, 50.0)); // A change that lands while the scan runs is still needed afterwards (the basis is the dispatch's). auto d = gov.beginExplicit(ScanStream::Balance, pool, 60.0); ScanSignals later = pool; later.mempoolTxs = 3; gov.complete(d, 100.0, ScanOutcome::Ok, 61.0); EXPECT_FALSE(gov.needed(ScanStream::Balance, pool, 62.0)); EXPECT_TRUE(gov.needed(ScanStream::Balance, later, 62.0)); // A pass: Partial then Ok adopts the first dispatch's basis; a failure inside poisons it. G pass; ScanSignals p1 = sig, p2 = sig; p1.tipHash = "p1"; p2.tipHash = "p2"; auto pa = pass.beginExplicit(ScanStream::Addresses, p1, 0.0); pass.complete(pa, 10.0, ScanOutcome::Partial, 1.0); auto pb = pass.beginExplicit(ScanStream::Addresses, p2, 2.0); pass.complete(pb, 10.0, ScanOutcome::Ok, 3.0); EXPECT_FALSE(pass.needed(ScanStream::Addresses, p1, 4.0)); EXPECT_TRUE(pass.needed(ScanStream::Addresses, p2, 4.0)); G poisoned; auto qa = poisoned.beginExplicit(ScanStream::Addresses, p1, 0.0); poisoned.complete(qa, 10.0, ScanOutcome::Partial, 1.0); auto qb = poisoned.beginExplicit(ScanStream::Addresses, p1, 2.0); poisoned.complete(qb, 10.0, ScanOutcome::Failed, 3.0); auto qc = poisoned.beginExplicit(ScanStream::Addresses, p1, 4.0); poisoned.complete(qc, 10.0, ScanOutcome::Ok, 5.0); EXPECT_TRUE(poisoned.needed(ScanStream::Addresses, p1, 6.0)); gov.invalidate(ScanStream::Balance); EXPECT_TRUE(gov.needed(ScanStream::Balance, sig, 70.0)); } { // Lifecycle. G gov; auto t = gov.tryAdmit(ScanStream::Balance, true, sig, 0.0); gov.cancel(t); // not sent: no charge, wait restored EXPECT_FALSE(gov.anyInFlight()); EXPECT_NEAR(gov.quietUntil(), 0.0, 1e-12); EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::Addresses, true, sig, 1.0))); // Balance is ahead EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::Balance, true, sig, 2.0))); G reset; auto old = reset.beginExplicit(ScanStream::Balance, sig, 0.0); reset.reset(); reset.complete(old, 1e6, ScanOutcome::Ok, 1.0); // from the old connection: no-op EXPECT_NEAR(reset.quietUntil(), 0.0, 1e-12); EXPECT_TRUE(reset.needed(ScanStream::Balance, sig, 2.0)); G orphan; auto o1 = orphan.beginExplicit(ScanStream::Addresses, sig, 0.0); orphan.complete(o1, 10000.0, ScanOutcome::Ok, 10.0); const double q = orphan.quietUntil(); orphan.beginExplicit(ScanStream::Addresses, sig, 200.0); EXPECT_TRUE(orphan.inFlight(ScanStream::Addresses)); orphan.dropOrphans(ScanStream::Addresses, 300.0); // its job is gone: freed, charged EXPECT_FALSE(orphan.inFlight(ScanStream::Addresses)); EXPECT_TRUE(orphan.quietUntil() > q); G lease; auto stuck = lease.tryAdmit(ScanStream::History, true, sig, 0.0); EXPECT_TRUE(static_cast(stuck)); EXPECT_FALSE(static_cast(lease.tryAdmit(ScanStream::Balance, true, sig, 599.0))); EXPECT_TRUE(static_cast(lease.tryAdmit(ScanStream::Balance, true, sig, 601.0))); // presumed lost lease.complete(stuck, 1e6, ScanOutcome::Ok, 602.0); // a late result: ignored EXPECT_NEAR(lease.quietUntil(), 0.0, 1e-12); G adhoc; auto a1 = adhoc.beginExplicit(ScanStream::Adhoc, sig, 0.0); auto a2 = adhoc.beginExplicit(ScanStream::Adhoc, sig, 0.0); adhoc.complete(a1, 0.0, ScanOutcome::Skipped, 1.0); EXPECT_TRUE(adhoc.inFlight(ScanStream::Adhoc)); adhoc.complete(a2, 0.0, ScanOutcome::Skipped, 1.0); EXPECT_FALSE(adhoc.inFlight(ScanStream::Adhoc)); } { // A stream that stopped asking for over 60 s starts a new wait: no floor bypass from the old one. G lapse; auto big = lapse.tryAdmit(ScanStream::History, true, sig, 0.0); lapse.complete(big, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100 EXPECT_FALSE(static_cast(lapse.tryAdmit(ScanStream::Balance, true, sig, 200.0))); double t = 261.0; // silent for 61 s bool admitted = false; for (; t < 1100.0 && !admitted; t += 2.0) admitted = static_cast(lapse.tryAdmit(ScanStream::Balance, true, sig, t)); EXPECT_TRUE(admitted); EXPECT_NEAR(t - 2.0, 291.0, 2.1); // 30 s from 261 (without the rule: 261) } { // An explicit charge never shortens a routine quiet. G keep; auto r = keep.tryAdmit(ScanStream::History, true, sig, 0.0); keep.complete(r, 200000.0, ScanOutcome::Ok, 200.0); // quiet to 1100 auto e = keep.beginExplicit(ScanStream::Balance, sig, 300.0); keep.complete(e, 50000.0, ScanOutcome::Ok, 310.0); // its own cap is 430 EXPECT_NEAR(keep.quietUntil(), 1100.0, 1e-6); } { // An explicit ticket that doesn't do the routine work (a Light history cycle): charged, but the stream // keeps waiting for that work, and its cost estimate stays the routine scans'. G gov; auto h = gov.tryAdmit(ScanStream::History, true, sig, 0.0); gov.complete(h, 120000.0, ScanOutcome::Ok, 0.0); // EMA 120 s, quiet to 680 auto slot = gov.beginExplicit(ScanStream::Adhoc, sig, 700.0); gov.tryAdmit(ScanStream::History, true, sig, 701.0); gov.tryAdmit(ScanStream::Addresses, true, sig, 702.0); auto light = gov.beginExplicit(ScanStream::History, sig, 703.0, false); gov.complete(light, 1000.0, ScanOutcome::Ok, 704.0); // quiet to ~709.7 EXPECT_NEAR(gov.emaCostMs(ScanStream::History), 120000.0, 1e-6); EXPECT_TRUE(gov.quietUntil() > 709.0); gov.complete(slot, 0.0, ScanOutcome::Skipped, 705.0); EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::Addresses, true, sig, 712.0))); // History is older EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::History, true, sig, 712.5))); } { // Only scans that were sent count: a cancelled or Skipped ticket doesn't restart the hidden interval, // and a Skipped routine ticket keeps its place in line. G gov; auto c = gov.tryAdmit(ScanStream::Balance, false, sig, 0.0); gov.cancel(c); EXPECT_TRUE(gov.secondsSinceScan(ScanStream::Balance, 10.0) > 1e8); auto s1 = gov.tryAdmit(ScanStream::Balance, false, sig, 10.0); EXPECT_TRUE(static_cast(s1)); gov.complete(s1, 0.0, ScanOutcome::Skipped, 11.0); EXPECT_TRUE(gov.secondsSinceScan(ScanStream::Balance, 20.0) > 1e8); auto o = gov.tryAdmit(ScanStream::Balance, false, sig, 20.0); EXPECT_TRUE(static_cast(o)); gov.complete(o, 1000.0, ScanOutcome::Ok, 21.0); EXPECT_NEAR(gov.secondsSinceScan(ScanStream::Balance, 30.0), 10.0, 1e-9); auto f = gov.beginExplicit(ScanStream::Balance, sig, 40.0); gov.complete(f, 1000.0, ScanOutcome::Failed, 41.0); // sent, so it counts EXPECT_NEAR(gov.secondsSinceScan(ScanStream::Balance, 50.0), 10.0, 1e-9); G fifo; auto busy = fifo.beginExplicit(ScanStream::Adhoc, sig, 0.0); fifo.tryAdmit(ScanStream::Balance, true, sig, 1.0); fifo.tryAdmit(ScanStream::Addresses, true, sig, 5.0); fifo.complete(busy, 0.0, ScanOutcome::Skipped, 9.0); auto b = fifo.tryAdmit(ScanStream::Balance, true, sig, 10.0); EXPECT_TRUE(static_cast(b)); fifo.complete(b, 0.0, ScanOutcome::Skipped, 11.0); // the node was behind: nothing sent EXPECT_FALSE(static_cast(fifo.tryAdmit(ScanStream::Addresses, true, sig, 12.0))); // Balance kept its place EXPECT_TRUE(static_cast(fifo.tryAdmit(ScanStream::Balance, true, sig, 12.5))); } { // waiting / heldWithinFloor / visibleFloorSecFor. G gov; EXPECT_NEAR(gov.visibleFloorSecFor(ScanStream::Balance), 30.0, 1e-9); auto first = gov.beginExplicit(ScanStream::Balance, sig, 0.0); gov.complete(first, 21000.0, ScanOutcome::Ok, 21.0); // EMA 21 s: floor 315 s EXPECT_NEAR(gov.visibleFloorSecFor(ScanStream::Balance), 315.0, 1e-9); auto slot = gov.beginExplicit(ScanStream::Adhoc, sig, 30.0); EXPECT_FALSE(gov.waiting(ScanStream::Balance, 30.0)); EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 30.0)); EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::Balance, true, sig, 31.0))); // denied: waits EXPECT_TRUE(gov.waiting(ScanStream::Balance, 32.0)); EXPECT_TRUE(gov.heldWithinFloor(ScanStream::Balance, 32.0)); EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 92.0)); // stopped asking (> 60 s) double t = 31.0; for (; t < 360.0; t += 2.0) gov.tryAdmit(ScanStream::Balance, true, sig, t); // the slot stays taken EXPECT_TRUE(gov.heldWithinFloor(ScanStream::Balance, 360.0)); // 329 s < 315 + 21 for (; t < 370.0; t += 2.0) gov.tryAdmit(ScanStream::Balance, true, sig, t); EXPECT_FALSE(gov.heldWithinFloor(ScanStream::Balance, 370.0)); // past floor + one scan: something's wrong gov.complete(slot, 0.0, ScanOutcome::Skipped, 371.0); EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::Balance, true, sig, 372.0))); EXPECT_FALSE(gov.waiting(ScanStream::Balance, 373.0)); // admitted: no longer waiting } } // The between-blocks probe's rotation: every z-address once (oldest-scanned first) before it is done. void testProbeRotation() { using dragonx::services::ProbeRotation; const std::vector addresses{"za", "zb", "zc"}; std::unordered_map heights{{"za", 100}, {"zb", 90}, {"zc", 100}}; ProbeRotation rotation; EXPECT_TRUE(rotation.complete()); EXPECT_EQ(rotation.next(addresses, heights), std::string("zb")); // oldest first rotation.probed("zb"); heights["zb"] = 101; EXPECT_FALSE(rotation.complete()); EXPECT_EQ(rotation.next(addresses, heights), std::string("za")); // tie: list order rotation.probed("za"); heights["za"] = 101; EXPECT_EQ(rotation.next(addresses, heights), std::string("zc")); rotation.probed("zc"); EXPECT_TRUE(rotation.complete()); // A new rotation after a change: all heights equal now, still every address once. heights["zc"] = 101; EXPECT_EQ(rotation.next(addresses, heights), std::string("za")); rotation.probed("za"); EXPECT_EQ(rotation.next(addresses, heights), std::string("zb")); // An address that left the list is dropped; one never scanned goes first. const std::vector changed{"zb", "zd"}; EXPECT_EQ(rotation.next(changed, heights), std::string("zb")); // za/zc gone, zd new but not in this rotation rotation.probed("zb"); EXPECT_TRUE(rotation.complete()); EXPECT_EQ(rotation.next(changed, heights), std::string("zd")); // new rotation: never scanned first rotation.reset(); EXPECT_TRUE(rotation.complete()); EXPECT_EQ(rotation.next({}, heights), std::string()); } // C37 under overload: three visible streams that always want to scan (21 s, 25 s and 120 s per scan, a // large wallet) for 2 h. Together they stay near the budget (the old per-stream 10% rules added up to // 30-40%), none starves, and quiet never runs away; hidden, each still gets in within its floor. void testWalletScanGovernorOverload() { using namespace dragonx::services; WalletScanGovernor gov; const ScanStream streams[3] = {ScanStream::Balance, ScanStream::Addresses, ScanStream::History}; const double cost[3] = {21.0, 25.0, 120.0}; double lastAdmit[3] = {0.0, 0.0, 0.0}; double maxGapVisible[3] = {0.0, 0.0, 0.0}; double maxGapHidden[3] = {0.0, 0.0, 0.0}; ScanTicket live; int liveIndex = -1; double liveEnd = 0.0, scanSec = 0.0, maxQuietAhead = 0.0; const ScanSignals sig; const double visibleEnd = 7200.0, end = 10800.0; for (double now = 0.0; now < end; now += 2.0) { const bool visible = now < visibleEnd; if (live && now >= liveEnd) { gov.complete(live, cost[liveIndex] * 1000.0, ScanOutcome::Ok, now); live = ScanTicket{}; } for (int i = 0; i < 3; ++i) { auto t = gov.tryAdmit(streams[i], visible, sig, now); if (!t) continue; live = t; liveIndex = i; liveEnd = now + cost[i]; if (now < visibleEnd) scanSec += cost[i]; const double gap = now - lastAdmit[i]; if (now < visibleEnd) maxGapVisible[i] = std::max(maxGapVisible[i], gap); else if (lastAdmit[i] >= visibleEnd) maxGapHidden[i] = std::max(maxGapHidden[i], gap); lastAdmit[i] = now; } maxQuietAhead = std::max(maxQuietAhead, gov.quietUntil() - now); } EXPECT_TRUE(scanSec / visibleEnd <= 0.22); EXPECT_TRUE(scanSec / visibleEnd >= 0.10); EXPECT_TRUE(maxQuietAhead <= 900.0 + 1e-6); // A stream's longest wait: its floor, plus the other two scans if both are queued ahead of it. const double others[3] = {25.0 + 120.0, 21.0 + 120.0, 21.0 + 25.0}; const double visibleFloor[3] = {315.0, 375.0, 1800.0}; // max(300 s, 15x cost) for (int i = 0; i < 3; ++i) EXPECT_TRUE(maxGapVisible[i] <= visibleFloor[i] + others[i] + 4.0); const double hiddenFloor[3] = {900.0, 900.0, 1800.0}; // max(900 s, 15x cost) for (int i = 0; i < 3; ++i) EXPECT_TRUE(maxGapHidden[i] <= hiddenFloor[i] + others[i] + 4.0); } void testBalancePollDecisionAndVisibility() { using namespace dragonx::services; using D = BalancePollDecision; EXPECT_NEAR(hiddenBalanceIntervalSec(0.05, false), 600.0, 1e-9); EXPECT_NEAR(hiddenBalanceIntervalSec(20.0, false), 1000.0, 1e-9); EXPECT_NEAR(hiddenBalanceIntervalSec(0.05, true), 120.0, 1e-9); BalancePollInputs in; EXPECT_TRUE(decideBalancePoll(in) == D::AskGovernor); { auto x = in; x.allowBalance = false; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.syncing = true; x.forced = true; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.syncing = true; x.neverFetched = true; x.changed = false; // the first read: while behind too EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } { auto x = in; x.syncing = true; x.neverFetched = true; x.firstReadTried = true; // once per connection EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.syncing = true; x.neverFetched = true; x.background = true; // only when it can be seen EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.neverFetched = true; x.firstReadTried = true; x.background = true; // at the tip: as before EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } // A committed user send in the background waits for the window; the routine rules still apply. { auto x = in; x.forced = true; x.background = true; x.visible = false; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.forced = true; x.background = true; x.visible = false; x.autoShieldArmed = true; x.sinceLastScanSec = 130.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } { auto x = in; x.forced = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } { auto x = in; x.neverFetched = true; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } { auto x = in; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.visible = false; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.visible = false; x.sinceLastScanSec = 700.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } { auto x = in; x.visible = false; x.autoShieldArmed = true; x.sinceLastScanSec = 130.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // Coming into view with an old value: one explicit poll. Each guard alone flips it back. BalancePollInputs back = in; back.shownAgeSec = 400.0; back.visibleFloorSec = 315.0; back.sinceLastScanSec = 400.0; EXPECT_TRUE(decideBalancePoll(back) == D::Explicit); { auto x = back; x.waiting = true; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // already queued { auto x = back; x.shownAgeSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } { auto x = back; x.sinceLastScanSec = 100.0; EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } // just tried { auto x = back; x.changed = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = back; x.visible = false; EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } // hidden: its own interval // Visible balance on a costly wallet: a whole-wallet sweep is floored to ~3x its own cost, so it can't // re-poll before the node recovers (what saturates cs_main on huge note sets). Small wallets (cost ~ms) // are effectively unaffected, and the forced/first-read paths bypass the floor entirely. { auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 30.0; // 30 < 3*20 = 60 -> hold off EXPECT_TRUE(decideBalancePoll(x) == D::Skip); } { auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 70.0; // 70 >= 60 -> allowed EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } { auto x = in; x.emaCostSec = 0.01; x.sinceLastScanSec = 5.0; // cheap wallet: floor ~0, unchanged EXPECT_TRUE(decideBalancePoll(x) == D::AskGovernor); } { auto x = in; x.emaCostSec = 20.0; x.sinceLastScanSec = 1.0; x.forced = true; // user's own send bypasses it EXPECT_TRUE(decideBalancePoll(x) == D::Explicit); } using NP = dragonx::ui::NavPage; auto overview = scanVisibilityForPage(NP::Overview, false); EXPECT_TRUE(overview.balance && overview.addresses && overview.history && !overview.chat); for (auto page : {NP::Send, NP::Receive}) { auto v = scanVisibilityForPage(page, false); EXPECT_TRUE(!v.balance && v.addresses && v.history); } auto history = scanVisibilityForPage(NP::History, false); EXPECT_TRUE(!history.balance && !history.addresses && history.history); auto market = scanVisibilityForPage(NP::Market, false); EXPECT_TRUE(market.balance && !market.addresses && !market.history); auto chat = scanVisibilityForPage(NP::Chat, false); EXPECT_TRUE(chat.chat && !chat.balance); auto mining = scanVisibilityForPage(NP::Mining, false); EXPECT_TRUE(!mining.balance && !mining.addresses && !mining.history && !mining.chat); auto background = scanVisibilityForPage(NP::Overview, true); EXPECT_TRUE(!background.balance && !background.addresses && !background.history && !background.chat); } // C36: the Core poll reads the mempool's size (no cs_main) right after the chain, for the change gate. void testCoreRefreshMempool() { using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; using dragonx::rpc::RpcFailureKind; using dragonx::rpc::RpcTransportError; const json chain = json{{"blocks", 150}, {"headers", 150}, {"longestchain", 150}, {"bestblockhash", "tip-150"}}; const json total = json{{"private", "3.00000000"}, {"transparent", "1.25000000"}, {"total", "4.25000000"}}; Refresh::CoreRefreshPlan plan; plan.fetchMempool = true; plan.needSpendable = false; plan.prevTotalZat = 425000000; { MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addResponse("getmempoolinfo", json{{"size", 3}, {"bytes", 4500}, {"usage", 9000}}); rpc.addResponse("z_gettotalbalance", total); auto result = Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == std::vector({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"})); EXPECT_EQ(rpc.calls[1].params, json::array()); EXPECT_TRUE(result.mempoolFetched && result.mempoolOk); EXPECT_EQ(result.mempoolTxs, static_cast(3)); EXPECT_EQ(result.mempoolBytes, static_cast(4500)); } { // A failed read leaves the mempool unknown (counts as changed); the balance still runs. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", chain); rpc.addFailure("getmempoolinfo", "boom"); rpc.addResponse("z_gettotalbalance", total); auto result = Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(result.mempoolFetched && !result.mempoolOk); EXPECT_TRUE(result.balanceOk); } { // Behind: chain + mempool only. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}}); rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}}); Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == std::vector({"getblockchaininfo", "getmempoolinfo"})); } { // The first read while behind skips the spendable (minconf=1) call: nothing can be sent mid-catch-up. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}}); rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}}); rpc.addResponse("z_gettotalbalance", total); auto first = plan; first.evenIfBehind = true; first.needSpendable = true; first.prevTotalZat.reset(); Refresh::collectCoreRefreshResult(rpc, first); EXPECT_TRUE(rpc.methodNames() == std::vector({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"})); } { // ...unless it is the session's first read. MockRefreshRpc rpc; rpc.addResponse("getblockchaininfo", json{{"blocks", 100}, {"headers", 150}, {"longestchain", 150}}); rpc.addResponse("getmempoolinfo", json{{"size", 0}, {"bytes", 0}}); rpc.addResponse("z_gettotalbalance", total); auto first = plan; first.evenIfBehind = true; auto result = Refresh::collectCoreRefreshResult(rpc, first); EXPECT_TRUE(rpc.methodNames() == std::vector({"getblockchaininfo", "getmempoolinfo", "z_gettotalbalance"})); EXPECT_TRUE(result.balanceOk); } { // The chain read found the node busy: nothing else. MockRefreshRpc rpc; rpc.addException("getblockchaininfo", std::make_exception_ptr(RpcTransportError( RpcFailureKind::QueueFull, "RPC error: HTTP 500: Work queue depth exceeded", CURLE_OK, 500, true))); auto result = Refresh::collectCoreRefreshResult(rpc, plan); EXPECT_TRUE(rpc.methodNames() == std::vector({"getblockchaininfo"})); EXPECT_FALSE(result.mempoolFetched); } } void testRefreshSchedulerRetrySoon() { using dragonx::services::RefreshScheduler; using Timer = RefreshScheduler::Timer; RefreshScheduler early; early.setIntervals({5.0f, 10.0f, 15.0f, 20.0f}); early.reset(Timer::Transactions); early.retrySoon(Timer::Transactions, 2.0f); EXPECT_FALSE(early.isDue(Timer::Transactions)); early.tick(1.9f); EXPECT_FALSE(early.isDue(Timer::Transactions)); RefreshScheduler due; due.setIntervals({5.0f, 10.0f, 15.0f, 20.0f}); due.reset(Timer::Transactions); due.retrySoon(Timer::Transactions, 2.0f); due.tick(2.0f); EXPECT_TRUE(due.isDue(Timer::Transactions)); RefreshScheduler syncing; // the sync profile disables the timer: a retry never fires mid-sync syncing.setIntervals(RefreshScheduler::kSyncProfile); syncing.retrySoon(Timer::Transactions, 2.0f); syncing.tick(60.0f); EXPECT_FALSE(syncing.isDue(Timer::Transactions)); // getmempoolinfo takes no cs_main: always sent, and its replies say nothing about busyness. dragonx::rpc::RpcBusyGate gate; gate.record("z_gettotalbalance", dragonx::rpc::RpcFailureKind::ReplyTimeout); EXPECT_TRUE(gate.busy()); EXPECT_TRUE(gate.admit("getmempoolinfo")); gate.record("getmempoolinfo", dragonx::rpc::RpcFailureKind::None); EXPECT_TRUE(gate.busy()); } // C134: the refresh profile in force (background > syncing > page), and why leaving a held profile re-marks. void testRefreshProfileSelection() { using dragonx::services::RefreshScheduler; using Timer = RefreshScheduler::Timer; using NP = dragonx::ui::NavPage; auto same = [](const RefreshScheduler::Intervals& a, const RefreshScheduler::Intervals& b) { return a.core == b.core && a.transactions == b.transactions && a.addresses == b.addresses && a.peers == b.peers; }; EXPECT_TRUE(same(RefreshScheduler::kBackgroundProfile, RefreshScheduler::Intervals{15.0f, 0.0f, 0.0f, 0.0f})); for (auto page : {NP::Overview, NP::Send, NP::History, NP::Mining, NP::Console}) { EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, false, false), RefreshScheduler::intervalsForPage(page))); EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, true, false), RefreshScheduler::kSyncProfile)); EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, true, true), RefreshScheduler::kBackgroundProfile)); EXPECT_TRUE(same(RefreshScheduler::selectIntervals(page, false, true), RefreshScheduler::kBackgroundProfile)); // The sync settle window outlasts two Core polls in every profile. EXPECT_TRUE(RefreshScheduler::selectIntervals(page, false, false).core * 2.0f <= RefreshScheduler::kSyncSettleSeconds); } { // In the background only Core comes due; back on a page, everything that accumulated is due. RefreshScheduler scheduler; scheduler.setIntervals(RefreshScheduler::kBackgroundProfile); scheduler.tick(600.0f); EXPECT_TRUE(scheduler.isDue(Timer::Core)); EXPECT_FALSE(scheduler.isDue(Timer::Transactions)); EXPECT_FALSE(scheduler.isDue(Timer::Addresses)); EXPECT_FALSE(scheduler.isDue(Timer::Peers)); scheduler.setIntervals(RefreshScheduler::intervalsForPage(NP::Overview)); EXPECT_TRUE(scheduler.isDue(Timer::Transactions)); EXPECT_TRUE(scheduler.isDue(Timer::Addresses)); EXPECT_TRUE(scheduler.isDue(Timer::Peers)); } { // A mark during a held profile zeroes the timer (lost): leaving it must mark again. RefreshScheduler scheduler; scheduler.setIntervals(RefreshScheduler::kBackgroundProfile); scheduler.tick(600.0f); scheduler.markDue(Timer::Addresses); scheduler.setIntervals(RefreshScheduler::intervalsForPage(NP::Overview)); EXPECT_FALSE(scheduler.isDue(Timer::Addresses)); scheduler.markImmediateRefresh(); EXPECT_TRUE(scheduler.isDue(Timer::Addresses)); } } // C44: what the connect's getinfo says about the tip, and the txcount from getwalletinfo. void testConnectionInfoSeeding() { using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; auto info = [](int blocks, int longest) { return Refresh::parseConnectionInfoResult(json{{"version", 120000}, {"blocks", blocks}, {"longestchain", longest}}); }; EXPECT_FALSE(Refresh::connectionInfoTipKnown(info(100, 0))); // no UpdateTip yet EXPECT_FALSE(Refresh::connectionInfoShowsBehind(info(100, 0))); EXPECT_TRUE(Refresh::connectionInfoTipKnown(info(100, 110))); EXPECT_TRUE(Refresh::connectionInfoShowsBehind(info(100, 110))); EXPECT_FALSE(Refresh::connectionInfoShowsBehind(info(108, 110))); // within the 2-block tolerance EXPECT_FALSE(Refresh::connectionInfoTipKnown(Refresh::ConnectionInfoResult{})); // not ok // Applying it leaves the sync state to the Core poll. dragonx::WalletState state; Refresh::applyConnectionInfoResult(state, info(100, 110)); EXPECT_FALSE(state.sync.syncing); EXPECT_FALSE(state.sync.tip_known); auto encrypted = Refresh::parseWalletEncryptionResult(json{{"unlocked_until", 0}, {"txcount", 5123}}); EXPECT_TRUE(encrypted.txCount && *encrypted.txCount == 5123); auto plain = Refresh::parseWalletEncryptionResult(json{{"txcount", 42}}); EXPECT_TRUE(plain.txCount && *plain.txCount == 42); EXPECT_FALSE(static_cast(Refresh::parseWalletEncryptionResult(json::object()).txCount)); dragonx::WalletState wallet; EXPECT_EQ(wallet.wallet_tx_count, -1); Refresh::applyWalletEncryptionResult(wallet, plain); EXPECT_EQ(wallet.wallet_tx_count, 42); Refresh::applyWalletEncryptionResult(wallet, Refresh::parseWalletEncryptionResult(json::object())); EXPECT_EQ(wallet.wallet_tx_count, 42); // unknown doesn't erase it wallet.clear(); EXPECT_EQ(wallet.wallet_tx_count, -1); } void testConnectScanGate() { using dragonx::services::ConnectScanGate; using Clock = ConnectScanGate::Clock; const auto t0 = Clock::now(); auto at = [&](double s) { return t0 + std::chrono::duration_cast(std::chrono::duration(s)); }; ConnectScanGate gate; EXPECT_TRUE(gate.open(at(0), false, -1)); // unarmed EXPECT_FALSE(gate.consumeOpenEdge(at(0), true, 10)); gate.arm(at(0), false); EXPECT_FALSE(gate.open(at(19.9), true, -1)); // tip unknown, size unknown: the grace EXPECT_TRUE(gate.open(at(20.0), false, -1)); gate.arm(at(0), true); EXPECT_FALSE(gate.open(at(1), false, -1)); // tip known, lock state not yet EXPECT_TRUE(gate.open(at(1), true, -1)); gate.arm(at(0), false); EXPECT_TRUE(gate.open(at(1), true, 100)); // small wallet: no wait for the tip EXPECT_FALSE(gate.open(at(1), true, 600)); EXPECT_FALSE(gate.open(at(1), false, 100)); // ...but the lock state first gate.noteTipKnown(); EXPECT_TRUE(gate.open(at(1), true, 600)); // The edge fires once per arm. gate.arm(at(0), false); EXPECT_FALSE(gate.consumeOpenEdge(at(5), true, 600)); EXPECT_TRUE(gate.consumeOpenEdge(at(21), true, 600)); EXPECT_FALSE(gate.consumeOpenEdge(at(22), true, 600)); gate.arm(at(30), false); // re-arm restarts the grace EXPECT_FALSE(gate.open(at(45), true, 600)); EXPECT_TRUE(gate.consumeOpenEdge(at(50), true, 600)); gate.arm(at(60), false); gate.disarm(); EXPECT_TRUE(gate.open(at(61), false, -1)); EXPECT_FALSE(gate.consumeOpenEdge(at(61), true, 100)); } void testScanPolicy() { using namespace dragonx::services; using NP = dragonx::ui::NavPage; ScanContext ok; ok.connected = true; EXPECT_TRUE(wholeWalletScanAllowed(ok)); { auto c = ok; c.connected = false; EXPECT_FALSE(wholeWalletScanAllowed(c)); c = ok; c.locked = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c)); c = ok; c.warmingUp = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); c = ok; c.runtimeRescan = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); c = ok; c.rpcBusy = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); c = ok; c.syncing = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c)); c = ok; c.gateOpen = false; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c)); c = ok; c.background = true; EXPECT_FALSE(wholeWalletScanAllowed(c)); EXPECT_TRUE(nodeCanAnswerScans(c)); } EXPECT_TRUE(planAddressScan(ok, false) == ScanTier::Full); for (auto field : {0, 1, 2, 3}) { // held scans: a stale list still loads, list-only auto c = ok; if (field == 0) c.syncing = true; if (field == 1) c.gateOpen = false; if (field == 2) c.locked = true; if (field == 3) c.background = true; EXPECT_TRUE(planAddressScan(c, true) == ScanTier::ListOnly); EXPECT_TRUE(planAddressScan(c, false) == ScanTier::None); } for (auto field : {0, 1, 2, 3}) { // the node can't answer: nothing at all auto c = ok; if (field == 0) c.connected = false; if (field == 1) c.warmingUp = true; if (field == 2) c.runtimeRescan = true; if (field == 3) c.rpcBusy = true; EXPECT_TRUE(planAddressScan(c, true, true) == ScanTier::None); } { // The session's first balances: read once even while the node catches up, not before the gate opens, // nor in the background or locked (a stale list still loads list-only there). auto behind = ok; behind.syncing = true; EXPECT_TRUE(planAddressScan(behind, false, true) == ScanTier::Full); EXPECT_TRUE(planAddressScan(behind, true, true) == ScanTier::Full); EXPECT_TRUE(planAddressScan(behind, false, false) == ScanTier::None); auto gated = behind; gated.gateOpen = false; EXPECT_TRUE(planAddressScan(gated, true, true) == ScanTier::ListOnly); auto hidden = behind; hidden.background = true; EXPECT_TRUE(planAddressScan(hidden, true, true) == ScanTier::ListOnly); auto locked = behind; locked.locked = true; EXPECT_TRUE(planAddressScan(locked, false, true) == ScanTier::None); EXPECT_TRUE(firstReadAllowed(behind) && !wholeWalletScanAllowed(behind)); } auto syncing = ok; syncing.syncing = true; { // Receive, synced, list loaded: just mark the timer. auto p = planPageEntry(NP::Receive, ok, false, false); EXPECT_TRUE(p.markAddresses && p.addressesNow == ScanTier::None && !p.transactionsNow && !p.markTransactions); } { // Receive, stale list, syncing: the list now, list-only. auto p = planPageEntry(NP::Receive, syncing, true, false); EXPECT_TRUE(p.addressesNow == ScanTier::ListOnly && !p.markAddresses); } { // History, list loaded, history never loaded: the history now. auto p = planPageEntry(NP::History, ok, false, true); EXPECT_TRUE(p.transactionsNow && !p.markAddresses && p.addressesNow == ScanTier::None); } { // History, stale list: the addresses first (their callback brings the history). auto p = planPageEntry(NP::History, ok, true, true); EXPECT_TRUE(p.addressesNow == ScanTier::Full && p.markTransactions && !p.transactionsNow); } { // History while syncing, never loaded: wait (marked). auto p = planPageEntry(NP::History, syncing, false, true); EXPECT_TRUE(!p.transactionsNow && p.markTransactions); } { auto busy = ok; busy.rpcBusy = true; auto rescan = ok; rescan.runtimeRescan = true; EXPECT_FALSE(planPageEntry(NP::Overview, busy, false, false).core); EXPECT_FALSE(planPageEntry(NP::Overview, rescan, false, false).core); EXPECT_TRUE(planPageEntry(NP::Overview, ok, false, false).core); EXPECT_FALSE(planPageEntry(NP::Mining, rescan, false, false).mining); EXPECT_TRUE(planPageEntry(NP::Mining, ok, false, false).mining); EXPECT_FALSE(planPageEntry(NP::Peers, busy, false, false).peers); ScanContext off; auto p = planPageEntry(NP::Receive, off, true, true); EXPECT_TRUE(!p.core && !p.markAddresses && p.addressesNow == ScanTier::None && !p.markTransactions); } EXPECT_TRUE(windowLooksBackground(true, false, false, true)); EXPECT_TRUE(windowLooksBackground(false, true, false, true)); EXPECT_TRUE(windowLooksBackground(false, false, true, false)); EXPECT_FALSE(windowLooksBackground(false, false, true, true)); // covered, but the app has focus EXPECT_FALSE(windowLooksBackground(false, false, false, false)); // focus loss alone EXPECT_FALSE(isBackgroundWindow(true, 2.9, 10.0)); EXPECT_FALSE(isBackgroundWindow(true, 3.0, 1.0)); // input a second ago EXPECT_TRUE(isBackgroundWindow(true, 3.0, 2.0)); EXPECT_FALSE(isBackgroundWindow(false, 100.0, 100.0)); EXPECT_NEAR(addressRetryDelaySeconds(0.0), 10.0, 1e-9); EXPECT_NEAR(addressRetryDelaySeconds(20000.0), 200.0, 1e-9); EXPECT_NEAR(addressRetryDelaySeconds(30000.0, true), 10.0, 1e-9); // busy: the wait isn't its cost // The balance is held while the connect gate is closed, even when forced or never read. BalancePollInputs in; in.gateOpen = false; in.forced = true; EXPECT_TRUE(decideBalancePoll(in) == BalancePollDecision::Skip); in.forced = false; in.neverFetched = true; EXPECT_TRUE(decideBalancePoll(in) == BalancePollDecision::Skip); } // C44: the list-only address pass, and a failed enumeration not scanning balances for a short list. void testAddressListOnlyCollector() { using Refresh = dragonx::services::NetworkRefreshService; using json = nlohmann::json; { MockRefreshRpc rpc; rpc.addResponse("z_listaddresses", json::array({"zs-known", "zs-new"})); rpc.addResponse("z_validateaddress", json{{"ismine", true}}); rpc.addResponse("getaddressesbyaccount", json::array({"R-known"})); dragonx::WalletState state; state.z_addresses.push_back({"zs-known", 1.5, "shielded", true}); state.z_addresses.back().spendableBalance = 1.25; state.t_addresses.push_back({"R-known", 2.0, "transparent", true}); auto snapshot = Refresh::buildAddressRefreshSnapshot(state); auto result = Refresh::collectAddressRefreshResult(rpc, snapshot, true); EXPECT_TRUE(rpc.methodNames() == std::vector({"z_listaddresses", "z_validateaddress", "getaddressesbyaccount"})); EXPECT_TRUE(result.listOnly && result.addressListOk); Refresh::applyAddressRefreshResult(state, std::move(result)); EXPECT_EQ(state.z_addresses.size(), static_cast(2)); EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); // carried over EXPECT_NEAR(state.z_addresses[0].spendableBalance, 1.25, 1e-9); EXPECT_NEAR(state.z_addresses[1].balance, 0.0, 1e-9); // new: 0 until the full pass EXPECT_NEAR(state.t_addresses[0].balance, 2.0, 1e-9); } { // A failed enumeration scans no balances: the caller discards the whole pass. MockRefreshRpc rpc; rpc.addFailure("z_listaddresses", "Loading block index..."); rpc.addResponse("getaddressesbyaccount", json::array({"R-one"})); auto result = Refresh::collectAddressRefreshResult(rpc); EXPECT_TRUE(rpc.methodNames() == std::vector({"z_listaddresses", "getaddressesbyaccount"})); EXPECT_FALSE(result.addressListOk); EXPECT_FALSE(result.nodeBusy); } { MockRefreshRpc rpc; rpc.addResponse("z_listaddresses", json::array()); rpc.addFailure("getaddressesbyaccount", "boom"); auto result = Refresh::collectAddressRefreshResult(rpc); EXPECT_TRUE(rpc.methodNames() == std::vector({"z_listaddresses", "getaddressesbyaccount"})); EXPECT_FALSE(result.addressListOk); } { // A busy balance scan: no z_getbalance fallback (each would be held back and read 0), no listunspent, // and the pass degrades to list-only so applying it keeps the known balances. using dragonx::rpc::RpcFailureKind; using dragonx::rpc::RpcTransportError; MockRefreshRpc rpc; rpc.addResponse("z_listaddresses", json::array({"zs-a"})); rpc.addResponse("z_validateaddress", json{{"ismine", true}}); rpc.addResponse("getaddressesbyaccount", json::array({"R-a"})); rpc.addException("z_listunspent", std::make_exception_ptr(RpcTransportError( RpcFailureKind::ReplyTimeout, "RPC error: reply timeout", CURLE_OPERATION_TIMEDOUT, 0, true))); auto result = Refresh::collectAddressRefreshResult(rpc); EXPECT_TRUE(rpc.methodNames() == std::vector({"z_listaddresses", "z_validateaddress", "getaddressesbyaccount", "z_listunspent"})); EXPECT_TRUE(result.listOnly && !result.balancesOk && result.nodeBusy && result.unspentNotes.empty()); EXPECT_TRUE(result.addressListOk); dragonx::WalletState state; state.z_addresses.push_back({"zs-a", 1.5, "shielded", true}); state.t_addresses.push_back({"R-a", 2.0, "transparent", true}); Refresh::applyAddressRefreshResult(state, std::move(result)); EXPECT_NEAR(state.z_addresses[0].balance, 1.5, 1e-9); EXPECT_NEAR(state.t_addresses[0].balance, 2.0, 1e-9); } { // A failed transparent balance call degrades the pass the same way. MockRefreshRpc rpc; rpc.addResponse("z_listaddresses", json::array()); rpc.addResponse("getaddressesbyaccount", json::array({"R-a"})); rpc.addResponse("z_listunspent", json::array()); rpc.addFailure("listunspent", "boom"); auto result = Refresh::collectAddressRefreshResult(rpc); EXPECT_TRUE(result.listOnly && !result.balancesOk && !result.nodeBusy); } { // A busy node's failure is marked so (its retry hold is the floor, not 10x the wait). using dragonx::rpc::RpcFailureKind; using dragonx::rpc::RpcTransportError; MockRefreshRpc rpc; rpc.addException("z_listaddresses", std::make_exception_ptr(RpcTransportError( RpcFailureKind::ReplyTimeout, "RPC error: reply timeout", CURLE_OPERATION_TIMEDOUT, 0, true))); rpc.addResponse("getaddressesbyaccount", json::array()); auto result = Refresh::collectAddressRefreshResult(rpc); EXPECT_TRUE(!result.addressListOk && result.nodeBusy && result.replyTimedOut); } } // C38: classify a poll's terminal ops by who started them. void testOperationBatchPlan() { using Refresh = dragonx::services::NetworkRefreshService; using Origin = Refresh::OperationOrigin; using nlohmann::json; const json reply = json::array({ json{{"id", "u1"}, {"status", "success"}, {"result", json{{"txid", "tx-u1"}}}}, json{{"id", "u2"}, {"status", "failed"}, {"error", json{{"message", "bad"}}}}, json{{"id", "u3"}, {"status", "success"}}, json{{"id", "a1"}, {"status", "success"}, {"result", json{{"txid", "tx-a1"}}}}, json{{"id", "c1"}, {"status", "success"}, {"result", json{{"txid", "tx-c1"}}}}, json{{"id", "c2"}, {"status", "cancelled"}}, }); const std::unordered_map origins{{"a1", Origin::Auto}, {"c1", Origin::Chat}, {"c2", Origin::Chat}, {"c3", Origin::Chat}}; auto plan = [&](const std::vector& ids) { return Refresh::planOperationBatch(Refresh::parseOperationStatusPoll(reply, ids), origins); }; auto all = plan({"u1", "u2", "u3", "a1", "c1", "c2", "c3"}); EXPECT_TRUE(all.userSucceeded && all.autoSucceeded && all.walletTerminal && all.chatTerminal); auto a = plan({"a1"}); EXPECT_TRUE(a.autoSucceeded && a.walletTerminal && !a.userSucceeded && !a.chatTerminal); for (const char* id : {"c1", "c2", "c3"}) { // a chat success, a cancelled one, one gone stale auto c = plan({id}); EXPECT_TRUE(c.chatTerminal && !c.walletTerminal && !c.userSucceeded && !c.autoSucceeded); } auto failed = plan({"u2"}); EXPECT_TRUE(failed.walletTerminal && !failed.userSucceeded && !failed.autoSucceeded && !failed.chatTerminal); EXPECT_TRUE(plan({"u3"}).userSucceeded); // a success without a txid counts EXPECT_TRUE(plan({"u1"}).userSucceeded); // absent from the map: User EXPECT_FALSE(Refresh::anyUserOperationPending({}, origins)); EXPECT_FALSE(Refresh::anyUserOperationPending({"c1"}, origins)); EXPECT_FALSE(Refresh::anyUserOperationPending({"a1", "c1"}, origins)); EXPECT_TRUE(Refresh::anyUserOperationPending({"x"}, origins)); EXPECT_TRUE(Refresh::anyUserOperationPending({"c1", "u9"}, origins)); } // C79: when the chat scans are worth sending. void testChatScanPolicy() { using namespace dragonx::services; using nlohmann::json; EXPECT_EQ(parseWalletTxCount(json::object()), (std::int64_t)-1); EXPECT_EQ(parseWalletTxCount(json{{"txcount", "42"}}), (std::int64_t)-1); EXPECT_EQ(parseWalletTxCount(json{{"txcount", -3}}), (std::int64_t)-1); EXPECT_EQ(parseWalletTxCount(json{{"txcount", 4.5}}), (std::int64_t)-1); EXPECT_EQ(parseWalletTxCount(json::array({1})), (std::int64_t)-1); EXPECT_EQ(parseWalletTxCount(json{{"txcount", 42}}), (std::int64_t)42); EXPECT_TRUE(chatProbeDue(0.0, 100.0, 100.0)); // no cost known yet EXPECT_FALSE(chatProbeDue(50.0, 100.0, 101.0)); // 50 ms: at most every 2.5 s (2%) EXPECT_TRUE(chatProbeDue(50.0, 100.0, 102.6)); EXPECT_FALSE(chatProbeDue(200.0, 100.0, 105.0)); // 200 ms: at most every 10 s EXPECT_TRUE(chatProbeDue(200.0, 100.0, 110.0)); EXPECT_FALSE(chatProbeDue(30000.0, 100.0, 159.0)); // however slow: at least once a minute EXPECT_TRUE(chatProbeDue(30000.0, 100.0, 160.0)); EXPECT_NEAR(chatInboxSafetyNetSec(0.0), 60.0, 1e-9); EXPECT_NEAR(chatInboxSafetyNetSec(1000.0), 60.0, 1e-9); EXPECT_NEAR(chatInboxSafetyNetSec(21000.0), 1050.0, 1e-9); EXPECT_NEAR(chatNoteFallbackSec(21000.0), 420.0, 1e-9); ChatInboxScanInputs in; in.basisTxCount = 10; in.txCount = 10; in.nowSec = 129.0; in.lastScanAtSec = 100.0; EXPECT_FALSE(chatInboxScanNeeded(in)); { auto x = in; x.basisTxCount = -1; EXPECT_TRUE(chatInboxScanNeeded(x)); } { auto x = in; x.txCount = -1; EXPECT_TRUE(chatInboxScanNeeded(x)); } { auto x = in; x.txCount = 11; EXPECT_TRUE(chatInboxScanNeeded(x)); } { auto x = in; x.nowSec = 160.0; EXPECT_TRUE(chatInboxScanNeeded(x)); } // An hour of 2.5 s ticks: a quiet wallet scans about once a minute; every count change is scanned at once. auto simulate = [](double net, int changes) { std::int64_t count = 100, basis = -1; double last = 0.0; int scans = 0; bool everyChangeScanned = true; for (double t = 0.0; t < 3600.0; t += 2.5) { bool changed = false; for (int i = 1; i <= changes; ++i) { if (t >= 300.0 * i && t < 300.0 * i + 2.5) { ++count; changed = true; } } ChatInboxScanInputs x; x.basisTxCount = basis; x.txCount = count; x.nowSec = t; x.lastScanAtSec = last; x.safetyNetSec = net; const bool need = chatInboxScanNeeded(x); if (changed && !need) everyChangeScanned = false; if (need) { ++scans; basis = count; last = t; } } return std::make_pair(scans, everyChangeScanned); }; auto quiet = simulate(chatInboxSafetyNetSec(50.0), 0); EXPECT_TRUE(quiet.first >= 59 && quiet.first <= 61); auto busy = simulate(chatInboxSafetyNetSec(50.0), 10); EXPECT_TRUE(busy.first <= 71 && busy.second); EXPECT_TRUE(simulate(chatInboxSafetyNetSec(21000.0), 0).first <= 4); ChatNoteScanInputs n; n.modelSeen = true; n.verifiedBudget = 10; n.pipelineNotes = 10; n.tipHeight = 100; n.lastScanTip = 100; n.nowSec = 100.0; n.lastScanAtSec = 90.0; n.tipChangedAtSec = 0.0; { auto x = n; x.rescanRequested = true; EXPECT_FALSE(shouldScanChatNotes(x)); } // nothing can use it n.onChatPage = true; EXPECT_FALSE(shouldScanChatNotes(n)); { auto x = n; x.modelSeen = false; EXPECT_TRUE(shouldScanChatNotes(x)); } { auto x = n; x.modelSeen = false; x.lastScanAtSec = 98.0; EXPECT_FALSE(shouldScanChatNotes(x)); } // the floor { auto x = n; x.rescanRequested = true; EXPECT_TRUE(shouldScanChatNotes(x)); } { // A starved queue and a settled new tip. auto x = n; x.onChatPage = false; x.queueWaiting = true; x.verifiedBudget = 0; x.tipHeight = 101; x.tipChangedAtSec = 96.0; EXPECT_TRUE(shouldScanChatNotes(x)); x.tipChangedAtSec = 98.0; // the notifier may not have caught up yet EXPECT_FALSE(shouldScanChatNotes(x)); x.tipHeight = 100; // no new tip: only the fallback x.tipChangedAtSec = 0.0; EXPECT_FALSE(shouldScanChatNotes(x)); x.nowSec = 160.0; EXPECT_TRUE(shouldScanChatNotes(x)); } { auto x = n; x.onChatPage = false; x.splitOutstanding = true; x.tipHeight = 101; x.tipChangedAtSec = 96.0; EXPECT_TRUE(shouldScanChatNotes(x)); } { auto x = n; x.verifiedBudget = 7; x.tipHeight = 101; x.tipChangedAtSec = 96.0; EXPECT_TRUE(shouldScanChatNotes(x)); } { auto x = n; x.tipHeight = 101; x.tipChangedAtSec = 96.0; EXPECT_FALSE(shouldScanChatNotes(x)); } // 10/10 // An hour on the Chat page at 2 s ticks, a block every 36 s. auto notesHour = [](int verified, int pipeline, double fallback) { double last = 0.0; std::int64_t scanTip = -1, tip = 0; double tipAt = 0.0; int scans = 0; bool settled = true; for (double t = 0.0; t < 3600.0; t += 2.0) { const std::int64_t newTip = static_cast(t / 36.0); if (newTip != tip) { tip = newTip; tipAt = t; } ChatNoteScanInputs x; x.onChatPage = true; x.modelSeen = scans > 0; x.verifiedBudget = verified; x.pipelineNotes = pipeline; x.tipHeight = tip; x.lastScanTip = scanTip; x.nowSec = t; x.lastScanAtSec = last; x.tipChangedAtSec = tipAt; x.fallbackSec = fallback; if (shouldScanChatNotes(x)) { ++scans; last = t; if (t - tipAt >= kChatTipSettleSec) scanTip = tip; else if (scans > 1 && verified < pipeline) settled = false; } } return std::make_pair(scans, settled); }; auto unfunded = notesHour(0, 0, 60.0); EXPECT_TRUE(unfunded.first >= 59 && unfunded.first <= 61); auto healthy = notesHour(10, 10, 60.0); EXPECT_TRUE(healthy.first >= 59 && healthy.first <= 61); auto maturing = notesHour(7, 10, 60.0); EXPECT_TRUE(maturing.first <= 102); // one per settled block, plus the first look (repeated once settled) EXPECT_TRUE(notesHour(10, 10, chatNoteFallbackSec(20000.0)).first <= 10); using dragonx::rpc::RpcFailureKind; EXPECT_TRUE(chatScanOutcome(true, RpcFailureKind::None) == ScanOutcome::Ok); EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::ReplyTimeout) == ScanOutcome::TimedOut); EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::Deferred) == ScanOutcome::Skipped); EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::QueueFull) == ScanOutcome::Skipped); EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::AppError) == ScanOutcome::Failed); EXPECT_TRUE(chatScanOutcome(false, RpcFailureKind::None) == ScanOutcome::Failed); } // S15: the chat payer from the verified-note view. void testChooseChatPayer() { using namespace dragonx::services; ChatPayerMap funds{{"zA", {5000, 1}}, {"zB", {20000, 2}}, {"zC", {20000, 1}}}; EXPECT_EQ(chooseChatPayer(funds, "zA", 10000), std::string("zB")); // tie with zC: the smaller address EXPECT_EQ(chooseChatPayer(funds, "zC", 10000), std::string("zC")); EXPECT_EQ(chooseChatPayer(funds, "zA", 30000), std::string()); EXPECT_EQ(chooseChatPayer(funds, "zX", 10000), std::string("zB")); EXPECT_EQ(chooseChatPayer({}, "zA", 1), std::string()); EXPECT_EQ(chooseChatPayer(ChatPayerMap{{"zA", {20000, 0}}}, "", 1), std::string()); // no verified note EXPECT_EQ(richestChatPayerZat(funds), (std::uint64_t)20000); EXPECT_EQ(richestChatPayerZat(ChatPayerMap{{"zA", {90000, 0}}, {"zB", {10, 1}}}), (std::uint64_t)10); ChatPayerMap d{{"zA", {30, 3}}, {"zB", {7, 1}}, {"zC", {50, 5}}}; debitChatPayer(d, "zA", ChatPayerSpend::OneNote); EXPECT_TRUE(d["zA"].zat == 20 && d["zA"].notes == 2); debitChatPayer(d, "zB", ChatPayerSpend::OneNote); EXPECT_TRUE(d["zB"].zat == 0 && d["zB"].notes == 0); debitChatPayer(d, "zC", ChatPayerSpend::All); EXPECT_TRUE(d["zC"].zat == 0 && d["zC"].notes == 0); const auto size = d.size(); debitChatPayer(d, "zQ", ChatPayerSpend::OneNote); // unknown: no-op EXPECT_EQ(d.size(), size); } // The chat scans share the governor's one slot, and their streams never read the change gate. void testChatScanGovernorStreams() { using namespace dragonx::services; WalletScanGovernor gov; ScanSignals a; a.tipHash = "a"; a.mempoolKnown = true; auto bal = gov.tryAdmit(ScanStream::Balance, true, a, 0.0); EXPECT_TRUE(static_cast(bal)); EXPECT_FALSE(static_cast(gov.tryAdmit(ScanStream::ChatFast, true, a, 1.0))); gov.complete(bal, 0.0, ScanOutcome::Ok, 1.0); auto fast = gov.tryAdmit(ScanStream::ChatFast, true, a, 2.0); EXPECT_TRUE(static_cast(fast)); gov.dropOrphans(ScanStream::ChatFast, 3.0); // its reply never came auto notes = gov.tryAdmit(ScanStream::ChatNotes, true, a, 4.0); EXPECT_TRUE(static_cast(notes)); gov.complete(notes, 10.0, ScanOutcome::Ok, 5.0); ScanSignals b; b.tipHash = "b"; b.mempoolKnown = true; b.mempoolTxs = 9; b.walletEpoch = 3; EXPECT_FALSE(gov.needed(ScanStream::ChatNotes, b, 1e6)); // admission only auto f2 = gov.beginExplicit(ScanStream::ChatFast, a, 6.0); gov.complete(f2, 10.0, ScanOutcome::Ok, 7.0); EXPECT_FALSE(gov.needed(ScanStream::ChatFast, b, 1e6)); auto old = gov.beginExplicit(ScanStream::ChatNotes, a, 8.0); gov.reset(); gov.complete(old, 1e6, ScanOutcome::Ok, 9.0); // from before the reset: a no-op EXPECT_TRUE(static_cast(gov.tryAdmit(ScanStream::Balance, true, a, 10.0))); } // C210: reading z_mergetoaddress's reply, and one call into an address's run. void testMergeCallResult() { using namespace dragonx::services; using nlohmann::json; MergeCallResult r; readMergeCallResult(json{{"opid", "o"}, {"mergingNotes", 50}, {"remainingNotes", 70}}, r); EXPECT_TRUE(r.opid == "o" && r.merging == 50 && r.remaining == 70 && r.error.empty()); readMergeCallResult(json{{"opid", "o"}, {"mergingNotes", "50"}}, r); EXPECT_TRUE(r.opid == "o" && r.merging == 0 && r.error.empty()); // accepted, count unreadable for (const json& bad : {json{{"mergingNotes", 3}}, json{{"opid", 5}}, json{{"opid", ""}}, json::array(), json("x"), json()}) { readMergeCallResult(bad, r); EXPECT_TRUE(r.error == "no operation id" && r.opid.empty()); } readMergeCallResult(json{{"opid", "o"}, {"remainingNotes", -4}}, r); EXPECT_EQ(r.remaining, 0); readMergeCallResult(json{{"opid", "o"}, {"remainingNotes", static_cast(1) << 40}}, r); EXPECT_EQ(r.remaining, INT_MAX); AddressRun run; MergeCallResult ok{"o1", 50, 70, ""}; EXPECT_TRUE(recordMergeResult(run, ok, 20)); EXPECT_FALSE(recordMergeResult(run, MergeCallResult{"o2", 50, 1, ""}, 20)); // one left: done AddressRun capped; EXPECT_FALSE(recordMergeResult(capped, ok, 1)); // at maxBatches AddressRun failed; EXPECT_FALSE(recordMergeResult(failed, MergeCallResult{"", 0, 0, "boom"}, 20)); EXPECT_EQ(failed.error, std::string("boom")); int calls = 0; consolidateInPlace("z", [&](const std::string&) { ++calls; return ok; }, 0); EXPECT_EQ(calls, 0); } void testOpReports() { using namespace dragonx::services; using nlohmann::json; EXPECT_TRUE(parseOpReports(json::object()).empty()); const json reply = json::array({ json(5), json{{"status", "success"}}, json{{"id", 7}, {"status", "success"}}, json{{"id", ""}, {"status", "success"}}, json{{"id", "a"}, {"status", 3}, {"error", "x"}, {"result", "y"}}, json{{"id", "b"}, {"status", "failed"}, {"error", json{{"message", "bad"}}}}, json{{"id", "c"}, {"status", "success"}, {"result", json{{"txid", "tx-c"}}}}, }); auto all = parseOpReports(reply); EXPECT_EQ(all.size(), static_cast(3)); EXPECT_TRUE(all[0].id == "a" && all[0].status.empty() && all[0].error.empty() && all[0].txid.empty()); EXPECT_EQ(all[1].error, std::string("bad")); EXPECT_EQ(all[2].txid, std::string("tx-c")); auto wanted = parseOpReports(reply, {"c"}); EXPECT_TRUE(wanted.size() == 1 && wanted[0].id == "c"); EXPECT_TRUE(opStateFromStatus("queued") == OpState::Queued); EXPECT_TRUE(opStateFromStatus("executing") == OpState::Executing); EXPECT_TRUE(opStateFromStatus("success") == OpState::Success); for (const char* f : {"failed", "cancelled", "canceled"}) EXPECT_TRUE(opStateFromStatus(f) == OpState::Failed); EXPECT_TRUE(opStateFromStatus("") == OpState::Unknown); EXPECT_TRUE(opStateFromStatus("x") == OpState::Unknown); } void testMergeFailureClassification() { using namespace dragonx::services; using K = dragonx::rpc::RpcFailureKind; EXPECT_TRUE(classifyMergeFailure(K::None, "") == MergeFailure::Uncertain); for (K k : {K::Deferred, K::NotListening, K::ConnectTimeout, K::Warmup, K::QueueFull}) EXPECT_TRUE(classifyMergeFailure(k, "x") == MergeFailure::NotSent); EXPECT_TRUE(classifyMergeFailure(K::ReplyTimeout, "x") == MergeFailure::Uncertain); EXPECT_TRUE(classifyMergeFailure(K::Aborted, "x") == MergeFailure::Uncertain); EXPECT_TRUE(classifyMergeFailure(K::ShuttingDown, "x") == MergeFailure::Transient); EXPECT_TRUE(classifyMergeFailure(K::Unhandled, "x") == MergeFailure::Transient); EXPECT_TRUE(classifyMergeFailure(K::Auth, "x") == MergeFailure::Fatal); EXPECT_TRUE(classifyMergeFailure(K::Other, "Not connected") == MergeFailure::NotSent); // By kind and message alone, an unclassified error is fatal... EXPECT_TRUE(classifyMergeFailure(K::Other, "RPC request failed: Failure when receiving data from the peer") == MergeFailure::Fatal); { // ...but a call that was sent and then cut off before any HTTP status may have queued the merge. using dragonx::rpc::RpcTransportError; const RpcTransportError cut(K::Other, "RPC request failed: Failure when receiving data from the peer", CURLE_RECV_ERROR, 0, true); EXPECT_TRUE(mergeCallFailureKind(cut) == K::ReplyTimeout); EXPECT_TRUE(classifyMergeFailure(mergeCallFailureKind(cut), cut.what()) == MergeFailure::Uncertain); const RpcTransportError unsent(K::Other, "RPC request failed: x", CURLE_RECV_ERROR, 0, false); EXPECT_TRUE(mergeCallFailureKind(unsent) == K::Other); const RpcTransportError http(K::Other, "RPC error: HTTP 504", CURLE_OK, 504, true); EXPECT_TRUE(mergeCallFailureKind(http) == K::Other); EXPECT_TRUE(mergeCallFailureKind(std::runtime_error("Not connected")) == K::Other); EXPECT_TRUE(classifyMergeFailure(mergeCallFailureKind(std::runtime_error("Not connected")), "Not connected") == MergeFailure::NotSent); const RpcTransportError aborted(K::Aborted, "RPC aborted: disconnecting", CURLE_OK, 0, false); EXPECT_TRUE(mergeCallFailureKind(aborted) == K::Aborted); } const std::pair cases[] = { {"z_mergetoaddress: Extreme Privacy! Chain still syncing at height 5, aborting to prevent linkability analysis. Please wait until FULLY SYNCED and try again.", MergeFailure::Syncing}, {"Could not find spending key for payment address.", MergeFailure::NeedsUnlock}, {"Error: Please enter the wallet passphrase with walletpassphrase first.", MergeFailure::NeedsUnlock}, {"Could not find any funds to merge.", MergeFailure::NothingLeft}, {"Destination address is also the only source address, and all its funds are already merged.", MergeFailure::NothingLeft}, {"Insufficient funds, have 0.00005, which is less than miners fee 0.0001", MergeFailure::NothingLeft}, {"Fee 0.0001 is greater than the net amount to be shielded 0.00005", MergeFailure::NothingLeft}, {"Insufficient funds to pay the fee", MergeFailure::NothingLeft}, {"Async RPC queue is shutting down; the operation was not queued", MergeFailure::Transient}, {"Invalid parameter, unknown address format", MergeFailure::Fatal}, }; for (const auto& [msg, want] : cases) EXPECT_TRUE(classifyMergeFailure(K::AppError, msg) == want); EXPECT_TRUE(runWaitKey(RunWait::None) == nullptr && runWaitKey(RunWait::InFlight) == nullptr); EXPECT_EQ(std::string(runWaitKey(RunWait::Node)), std::string("merge_waiting_node")); EXPECT_EQ(std::string(runWaitKey(RunWait::Busy)), std::string("merge_waiting_node")); EXPECT_EQ(std::string(runWaitKey(RunWait::Syncing)), std::string("merge_run_wait_sync")); EXPECT_EQ(std::string(runWaitKey(RunWait::Locked)), std::string("merge_wait_locked")); EXPECT_EQ(std::string(runWaitKey(RunWait::UserSend)), std::string("merge_run_wait_send")); EXPECT_EQ(std::string(runWaitKey(RunWait::Scan)), std::string("merge_run_wait_batch")); EXPECT_EQ(std::string(runWaitKey(RunWait::Batch)), std::string("merge_run_wait_batch")); RunSummary sum; EXPECT_TRUE(runVerdict(sum) == RunVerdict::None); sum.end = RunEnd::Stopped; EXPECT_TRUE(runVerdict(sum) == RunVerdict::Stopped); sum.end = RunEnd::Halted; EXPECT_TRUE(runVerdict(sum) == RunVerdict::Halted); sum.end = RunEnd::Aborted; EXPECT_TRUE(runVerdict(sum) == RunVerdict::Aborted); sum.end = RunEnd::Completed; EXPECT_TRUE(runVerdict(sum) == RunVerdict::NothingSubmitted); sum.batchesSubmitted = 3; EXPECT_TRUE(runVerdict(sum) == RunVerdict::Clean); sum.addressesUnconfirmed = 1; EXPECT_TRUE(runVerdict(sum) == RunVerdict::Unconfirmed); sum.addressesFailed = 1; EXPECT_TRUE(runVerdict(sum) == RunVerdict::SomeFailed); } namespace { dragonx::services::RunStep stepRun(dragonx::services::ConsolidationRun& run, const dragonx::services::RunEnv& env, double now) { using namespace dragonx::services; const RunStep st = run.next(env, now); if (st.action == RunAction::Call) run.markCallPosted(st.addrIdx, now); else if (st.action == RunAction::Poll) run.markPollPosted(now); return st; } dragonx::services::OpReport opRep(const std::string& id, const std::string& status, const std::string& error = "", const std::string& txid = "") { dragonx::services::OpReport r; r.id = id; r.status = status; r.error = error; r.txid = txid; return r; } dragonx::services::RunEnv okRunEnv() { dragonx::services::RunEnv env; env.nodeUsable = true; return env; } } // namespace void testConsolidationRun() { using namespace dragonx::services; using K = dragonx::rpc::RpcFailureKind; const RunEnv ok = okRunEnv(); { // Start and dismiss. ConsolidationRun run; EXPECT_FALSE(run.start({}, RunConfig{}, 0.0)); EXPECT_FALSE(run.start({{"z1", 1, 5.0}}, RunConfig{}, 0.0)); // one note EXPECT_FALSE(run.start({{"z1", 5, 0.0001}}, RunConfig{}, 0.0)); // too small for the fee RunConfig small; small.limit = 1; EXPECT_TRUE(run.start({{"z1", 5, 5.0}}, small, 0.0)); EXPECT_EQ(run.config().limit, 2); EXPECT_FALSE(run.start({{"z2", 5, 5.0}}, RunConfig{}, 0.0)); // already running run.dismiss(); EXPECT_TRUE(run.active()); // no-op while running const auto gen1 = run.gen(); run.requestStop(); EXPECT_TRUE(stepRun(run, okRunEnv(), 1.0).action == RunAction::Finish); EXPECT_TRUE(run.end() == RunEnd::Stopped); run.dismiss(); EXPECT_FALSE(run.hasRun()); EXPECT_EQ(run.gen(), gen1); // kept: stale results never match later EXPECT_TRUE(run.start({{"z1", 5, 5.0}}, RunConfig{}, 2.0)); EXPECT_TRUE(run.gen() > gen1); } { // A normal run: call, poll, call after the gap and the op gate, ..., one Finish. ConsolidationRun run; EXPECT_TRUE(run.start({{"zA", 120, 60.0}}, RunConfig{}, 0.0)); auto st = stepRun(run, ok, 0.0); EXPECT_TRUE(st.action == RunAction::Call && st.addrIdx == 0); EXPECT_TRUE(stepRun(run, ok, 0.5).action == RunAction::None); // one RPC at a time EXPECT_TRUE(run.lastWait() == RunWait::InFlight); run.onCallResult(run.gen(), 0, MergeCallResult{"o1", 50, 70, ""}, K::None, 1.0, 1.0); EXPECT_TRUE(stepRun(run, ok, 1.0).action == RunAction::Poll); // learn its state first run.onPollResult(run.gen(), true, {opRep("o1", "queued")}, 1.1); EXPECT_TRUE(stepRun(run, ok, 3.5).action == RunAction::None); // past the gap, but still queued EXPECT_TRUE(run.lastWait() == RunWait::Batch); EXPECT_TRUE(stepRun(run, ok, 4.1).action == RunAction::Poll); run.onPollResult(run.gen(), true, {opRep("o1", "executing")}, 4.2); st = stepRun(run, ok, 4.3); EXPECT_TRUE(st.action == RunAction::Call); run.onCallResult(run.gen(), 0, MergeCallResult{"o2", 50, 20, ""}, K::None, 1.0, 5.0); EXPECT_TRUE(stepRun(run, ok, 5.0).action == RunAction::Poll); run.onPollResult(run.gen(), true, {opRep("o1", "success", "", "tx1"), opRep("o2", "executing")}, 5.1); EXPECT_TRUE(stepRun(run, ok, 7.0).action == RunAction::Call); run.onCallResult(run.gen(), 0, MergeCallResult{"o3", 20, 0, ""}, K::None, 1.0, 8.0); EXPECT_TRUE(run.addresses()[0].done); double t = 8.0; int finishes = 0; for (; t < 60.0; t += 0.5) { const auto s2 = stepRun(run, ok, t); if (s2.action == RunAction::Poll) run.onPollResult(run.gen(), true, {opRep("o2", "success"), opRep("o3", "success")}, t + 0.1); if (s2.action == RunAction::Finish) ++finishes; EXPECT_TRUE(s2.action != RunAction::Call); } EXPECT_EQ(finishes, 1); EXPECT_TRUE(run.end() == RunEnd::Completed); const auto sum = run.summary(); EXPECT_EQ(sum.batchesSucceeded, 3); EXPECT_EQ(sum.notesMerged, 120); EXPECT_TRUE(runVerdict(sum) == RunVerdict::Clean); EXPECT_TRUE(run.next(ok, 70.0).action == RunAction::None); } { // The gap is the call's own cost, within [2 s, 120 s]. for (const auto& [callSec, gap] : {std::pair{0.1, 2.0}, {15.0, 15.0}, {200.0, 120.0}}) { ConsolidationRun run; run.start({{"zA", 200, 100.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); run.onCallResult(run.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, callSec, 1.0); // Advance to `t`, answering polls (the op stays Executing); the first Call's time. double firstCall = -1.0; for (double t = 1.0; t <= 1.0 + gap + 1.0 && firstCall < 0.0; t += 0.01) { RunStep s2 = stepRun(run, ok, t); while (s2.action == RunAction::Poll) { // a due poll goes first run.onPollResult(run.gen(), true, {opRep("o1", "executing")}, t); s2 = stepRun(run, ok, t); } if (s2.action == RunAction::Call) firstCall = t; } EXPECT_NEAR(firstCall, 1.0 + gap, 0.02); } } { // NotSent never ends the address; each retry is at least 5 s later. ConsolidationRun run; run.start({{"zA", 10, 5.0}}, RunConfig{}, 0.0); double t = 0.0, lastCall = -100.0; int calls = 0; while (calls < 50 && t < 1000.0) { const auto s2 = stepRun(run, ok, t); if (s2.action == RunAction::Call) { EXPECT_TRUE(t - lastCall >= 5.0); lastCall = t; ++calls; run.onCallResult(run.gen(), 0, MergeCallResult{"", 0, 0, "held back"}, K::Deferred, 0.01, t); } t += 0.5; } EXPECT_EQ(calls, 50); EXPECT_FALSE(run.addresses()[0].done); } { // Syncing holds 30 s; NeedsUnlock holds 30 s and asks for one lock re-read, then fails the address. ConsolidationRun run; run.start({{"zA", 10, 5.0}, {"zB", 10, 5.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); run.onCallResult(run.gen(), 0, MergeCallResult{"", 0, 0, "Chain still syncing at height 5"}, K::AppError, 0.1, 0.1); EXPECT_TRUE(stepRun(run, ok, 29.0).action == RunAction::None); EXPECT_TRUE(run.lastWait() == RunWait::Syncing); EXPECT_TRUE(run.addresses()[0].transient == 0 && run.addresses()[0].unlockRefusals == 0); EXPECT_TRUE(stepRun(run, ok, 30.2).action == RunAction::Call); run.onCallResult(run.gen(), 0, MergeCallResult{"", 0, 0, "Could not find spending key for payment address."}, K::AppError, 0.1, 30.3); EXPECT_TRUE(run.consumeLockResyncRequest()); EXPECT_FALSE(run.consumeLockResyncRequest()); EXPECT_TRUE(stepRun(run, ok, 59.0).action == RunAction::None); EXPECT_TRUE(run.lastWait() == RunWait::Locked); EXPECT_TRUE(stepRun(run, ok, 60.5).action == RunAction::Call); run.onCallResult(run.gen(), 0, MergeCallResult{"", 0, 0, "Could not find spending key for payment address."}, K::AppError, 0.1, 60.6); EXPECT_TRUE(run.addresses()[0].done && run.addresses()[0].outcome == AddrOutcome::Failed); const auto s3 = stepRun(run, ok, 63.0); EXPECT_TRUE(s3.action == RunAction::Call && s3.addrIdx == 1); } { // NothingLeft: Done after a batch, Failed without one. Uncertain is never resent. ConsolidationRun run; run.start({{"zA", 10, 5.0}, {"zB", 10, 5.0}, {"zC", 10, 5.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); run.onCallResult(run.gen(), 0, MergeCallResult{"o1", 5, 5, ""}, K::None, 0.1, 0.1); stepRun(run, ok, 0.2); run.onPollResult(run.gen(), true, {opRep("o1", "executing")}, 0.2); EXPECT_TRUE(stepRun(run, ok, 2.5).action == RunAction::Call); run.onCallResult(run.gen(), 0, MergeCallResult{"", 0, 0, "Could not find any funds to merge."}, K::AppError, 0.1, 2.6); EXPECT_TRUE(run.addresses()[0].outcome == AddrOutcome::Done); double t = 2.6; RunStep s2; do { t += 0.5; s2 = stepRun(run, ok, t); if (s2.action == RunAction::Poll) run.onPollResult(run.gen(), true, {opRep("o1", "executing")}, t); } while (s2.action != RunAction::Call && t < 100.0); EXPECT_EQ(s2.addrIdx, 1); run.onCallResult(run.gen(), 1, MergeCallResult{"", 0, 0, "Could not find any funds to merge."}, K::AppError, 0.1, t); EXPECT_TRUE(run.addresses()[1].outcome == AddrOutcome::Failed); do { t += 0.5; s2 = stepRun(run, ok, t); if (s2.action == RunAction::Poll) run.onPollResult(run.gen(), true, {opRep("o1", "executing")}, t); } while (s2.action != RunAction::Call && t < 200.0); EXPECT_EQ(s2.addrIdx, 2); run.onCallResult(run.gen(), 2, MergeCallResult{"", 0, 0, "RPC error: reply timeout"}, K::ReplyTimeout, 120.0, t); EXPECT_TRUE(run.addresses()[2].done && run.addresses()[2].outcome == AddrOutcome::Uncertain); for (int i = 0; i < 400; ++i) { t += 0.5; s2 = stepRun(run, ok, t); EXPECT_TRUE(s2.action != RunAction::Call); if (s2.action == RunAction::Poll) run.onPollResult(run.gen(), true, {opRep("o1", "success")}, t); } EXPECT_TRUE(run.end() == RunEnd::Completed); const auto sum = run.summary(); EXPECT_EQ(sum.addressesUnconfirmed, 1); EXPECT_TRUE(runVerdict(sum) == RunVerdict::SomeFailed); // zB failed (worse than unconfirmed) } { // Breakers: three dead addresses in a row halt the run; an accepted call between resets the streak. ConsolidationRun run; run.start({{"z1", 10, 5.0}, {"z2", 10, 5.0}, {"z3", 10, 5.0}, {"z4", 10, 5.0}}, RunConfig{}, 0.0); double t = 0.0; for (int i = 0; i < 3; ++i) { RunStep s2; do { s2 = stepRun(run, ok, t); t += 0.5; } while (s2.action != RunAction::Call); run.onCallResult(run.gen(), s2.addrIdx, MergeCallResult{"", 0, 0, "Invalid parameter, unknown address format"}, K::AppError, 0.1, t); } RunStep fin; do { fin = stepRun(run, ok, t); t += 0.5; } while (fin.action == RunAction::None && t < 100.0); EXPECT_TRUE(fin.action == RunAction::Finish && run.end() == RunEnd::Halted); EXPECT_FALSE(run.summary().firstError.empty()); EXPECT_FALSE(run.addresses()[3].done); // never tried } { // Op-level breaker: three failed ops in a row. ConsolidationRun run; run.start({{"z1", 10, 5.0}, {"z2", 10, 5.0}, {"z3", 10, 5.0}, {"z4", 10, 5.0}}, RunConfig{}, 0.0); double t = 0.0; for (int i = 0; i < 3; ++i) { RunStep s2; do { s2 = stepRun(run, ok, t); if (s2.action == RunAction::Poll) { std::vector reps; for (const auto& id : run.outstandingOpids()) reps.push_back(opRep(id, "failed", "proof failed")); run.onPollResult(run.gen(), true, reps, t); } t += 0.5; } while (s2.action != RunAction::Call && t < 500.0); if (s2.action == RunAction::Call) run.onCallResult(run.gen(), s2.addrIdx, MergeCallResult{"op" + std::to_string(i), 9, 1, ""}, K::None, 0.1, t); } RunStep last; for (int i = 0; i < 100 && last.action != RunAction::Finish; ++i) { last = stepRun(run, ok, t); if (last.action == RunAction::Poll) { std::vector reps; for (const auto& id : run.outstandingOpids()) reps.push_back(opRep(id, "failed", "proof failed")); run.onPollResult(run.gen(), true, reps, t); } t += 0.5; } EXPECT_TRUE(run.end() == RunEnd::Halted); EXPECT_FALSE(run.addresses()[3].done); } { // Transient: backoff 5, 10, 20, 40, 60 s, then the address fails. ConsolidationRun run; run.start({{"zA", 10, 5.0}}, RunConfig{}, 0.0); double t = 0.0, last = 0.0; const double expect[] = {5.0, 10.0, 20.0, 40.0, 60.0}; for (int i = 0; i <= 5; ++i) { RunStep s2; do { s2 = stepRun(run, ok, t); if (s2.action != RunAction::Call) t += 0.25; } while (s2.action != RunAction::Call && t < 1000.0); if (i > 0) EXPECT_TRUE(t - last >= expect[i - 1] - 0.01 && t - last <= expect[i - 1] + 0.3); last = t; run.onCallResult(run.gen(), 0, MergeCallResult{"", 0, 0, "HTTP 503"}, K::ShuttingDown, 0.0, t); } EXPECT_TRUE(run.addresses()[0].done && run.addresses()[0].outcome == AddrOutcome::Failed); } { // A result for another address or generation is an orphan: returned for tracking, state unchanged. ConsolidationRun run; run.start({{"zA", 10, 5.0}, {"zB", 10, 5.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); EXPECT_EQ(run.onCallResult(run.gen(), 1, MergeCallResult{"oX", 5, 5, ""}, K::None, 0.1, 0.1), std::string("oX")); EXPECT_EQ(run.onCallResult(run.gen() + 7, 0, MergeCallResult{"oY", 5, 5, ""}, K::None, 0.1, 0.1), std::string("oY")); EXPECT_TRUE(run.batches().empty()); EXPECT_TRUE(stepRun(run, ok, 0.2).action == RunAction::None); // the real call is still in flight } { // Environment gates: Node and Busy hold calls and polls; Syncing, Locked, UserSend and Scan hold only calls. ConsolidationRun run; run.start({{"zA", 200, 100.0}, {"zB", 10, 5.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); run.onCallResult(run.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); RunEnv node = ok; node.nodeUsable = false; RunEnv busy = ok; busy.busy = true; EXPECT_TRUE(stepRun(run, node, 1.0).action == RunAction::None && run.lastWait() == RunWait::Node); EXPECT_TRUE(stepRun(run, busy, 1.0).action == RunAction::None && run.lastWait() == RunWait::Busy); RunEnv syncing = ok; syncing.callsAllowed = false; EXPECT_TRUE(stepRun(run, syncing, 1.0).action == RunAction::Poll); // a due poll still runs run.onPollResult(run.gen(), true, {opRep("o1", "executing")}, 1.0); EXPECT_TRUE(stepRun(run, syncing, 3.0).action == RunAction::None && run.lastWait() == RunWait::Syncing); RunEnv locked = ok; locked.locked = true; EXPECT_TRUE(stepRun(run, locked, 3.0).action == RunAction::None && run.lastWait() == RunWait::Locked); RunEnv send = ok; send.userSendBusy = true; EXPECT_TRUE(stepRun(run, send, 3.0).action == RunAction::None && run.lastWait() == RunWait::UserSend); RunEnv scan = ok; scan.scanBusy = true; EXPECT_TRUE(stepRun(run, scan, 3.0).action == RunAction::None && run.lastWait() == RunWait::Scan); EXPECT_TRUE(stepRun(run, ok, 3.0).action == RunAction::Call); } { // Stop: the call in flight is recorded, no further call, polls until terminal, then Stopped. ConsolidationRun run; run.start({{"zA", 200, 100.0}, {"zB", 10, 5.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); run.requestStop(); run.onCallResult(run.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); double t = 0.1; RunStep s2; for (int i = 0; i < 100 && s2.action != RunAction::Finish; ++i) { t += 0.5; s2 = stepRun(run, ok, t); EXPECT_TRUE(s2.action != RunAction::Call); if (s2.action == RunAction::Poll) run.onPollResult(run.gen(), true, {opRep("o1", i < 4 ? "executing" : "success")}, t); } EXPECT_TRUE(run.end() == RunEnd::Stopped); ConsolidationRun idle; // nothing outstanding, node gone: finishes at once idle.start({{"zA", 10, 5.0}}, RunConfig{}, 0.0); idle.requestStop(); RunEnv gone = ok; gone.nodeUsable = false; EXPECT_TRUE(idle.next(gone, 1.0).action == RunAction::Finish); } { // abort(): false unless running; the outstanding batch is Lost; late results are orphans / ignored. ConsolidationRun run; EXPECT_FALSE(run.abort()); run.start({{"zA", 200, 100.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); const auto gen = run.gen(); run.onCallResult(gen, 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); stepRun(run, ok, 2.5); // poll in flight EXPECT_TRUE(run.abort()); EXPECT_TRUE(run.end() == RunEnd::Aborted && run.batches()[0].state == OpState::Lost); EXPECT_TRUE(run.addresses()[0].outcome == AddrOutcome::Uncertain); EXPECT_EQ(run.onCallResult(gen, 0, MergeCallResult{"o9", 5, 5, ""}, K::None, 0.1, 3.0), std::string("o9")); run.onPollResult(gen, true, {opRep("o1", "success")}, 3.0); EXPECT_TRUE(run.batches()[0].state == OpState::Lost); EXPECT_TRUE(run.next(ok, 4.0).action == RunAction::None); EXPECT_FALSE(run.abort()); } { // Watchdogs: a call or poll outstanding 30 min is presumed lost; an op not terminal after an hour too. ConsolidationRun run; run.start({{"zA", 200, 100.0}, {"zB", 10, 5.0}}, RunConfig{}, 0.0); stepRun(run, ok, 0.0); const auto gen = run.gen(); stepRun(run, ok, 1801.0); EXPECT_TRUE(run.addresses()[0].done && run.addresses()[0].outcome == AddrOutcome::Uncertain); EXPECT_EQ(run.gen(), gen + 1); EXPECT_EQ(run.onCallResult(gen, 0, MergeCallResult{"late", 5, 5, ""}, K::None, 0.1, 1802.0), std::string("late")); ConsolidationRun lost; lost.start({{"zA", 200, 100.0}}, RunConfig{}, 0.0); stepRun(lost, ok, 0.0); lost.onCallResult(lost.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); stepRun(lost, ok, 0.2); lost.onPollResult(lost.gen(), true, {}, 0.3); // missing once EXPECT_TRUE(lost.batches()[0].state == OpState::Unknown); stepRun(lost, ok, 3.5); lost.onPollResult(lost.gen(), true, {}, 3.6); // twice: Lost EXPECT_TRUE(lost.batches()[0].state == OpState::Lost); ConsolidationRun hour; hour.start({{"zA", 200, 100.0}}, RunConfig{}, 0.0); stepRun(hour, ok, 0.0); hour.onCallResult(hour.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); for (double t = 0.5; t < 3610.0; t += 30.0) { const auto s2 = stepRun(hour, ok, t); if (s2.action == RunAction::Poll) hour.onPollResult(hour.gen(), false, {}, t + 0.1); } EXPECT_TRUE(hour.batches()[0].state == OpState::Lost); } { // The op backstop counts only time in which a poll could have gone out (not while the node was gone or // busy), with a 4 h cap whatever happened. ConsolidationRun gated; gated.start({{"zA", 200, 100.0}}, RunConfig{}, 0.0); stepRun(gated, ok, 0.0); gated.onCallResult(gated.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); RunEnv gone = ok; gone.nodeUsable = false; for (double t = 1.0; t < 7200.0; t += 30.0) stepRun(gated, gone, t); EXPECT_FALSE(opStateTerminal(gated.batches()[0].state)); EXPECT_TRUE(stepRun(gated, ok, 7201.0).action == RunAction::Poll); gated.onPollResult(gated.gen(), true, {opRep("o1", "success")}, 7201.1); EXPECT_TRUE(gated.batches()[0].state == OpState::Success); ConsolidationRun capped; capped.start({{"zA", 200, 100.0}}, RunConfig{}, 0.0); stepRun(capped, ok, 0.0); capped.onCallResult(capped.gen(), 0, MergeCallResult{"o1", 50, 150, ""}, K::None, 0.1, 0.1); RunEnv busy = ok; busy.busy = true; for (double t = 1.0; t < 4 * 3600.0 + 60.0; t += 60.0) stepRun(capped, busy, t); EXPECT_TRUE(capped.batches()[0].state == OpState::Lost); } { // The estimate is capped by maxBatches. ConsolidationRun run; run.start({{"zA", 10000, 5000.0}}, RunConfig{}, 0.0); EXPECT_EQ(run.progress().batchesEstimated, 20); } } // A whole run against a deterministic fake daemon: serial ops, merge calls that cost 6 s, and windows of lock, // catch-up and another wallet scan. void testConsolidationRunSimulation() { using namespace dragonx::services; struct FakeOp { std::string id; std::string addr; int notes; double startAt = -1, finishAt = -1; }; std::map unlocked{{"A", 240}, {"B", 60}, {"C", 3}, {"D", 1}}; std::vector ops; ConsolidationRun run; std::vector groups; for (const auto& [a, n] : unlocked) groups.push_back({a, n, 0.5 * n}); EXPECT_TRUE(run.start(groups, RunConfig{}, 0.0)); EXPECT_EQ(run.addresses().size(), static_cast(3)); // D (one note) is left out int expected = 0; for (const auto& a : run.addresses()) expected += estimateBatches(a.group.notes, 50); // In-flight RPC model. enum class Rpc { None, Call, Poll } rpc = Rpc::None; double rpcDoneAt = 0.0, lastCallEnd = -1e9; int rpcAddr = -1; bool overlap = false, gapOk = true, windowOk = true, opsOk = true; auto inWindow = [](double t, double a, double b) { return t >= a && t < b; }; auto settle = [&](double t) { // the daemon's serial op queue double prevFinish = 0.0; for (auto& op : ops) { if (op.startAt < 0) op.startAt = std::max(t, prevFinish); // set when queued below prevFinish = op.finishAt; } }; (void)settle; auto status = [&](const FakeOp& op, double t) { if (t < op.startAt) return std::string("queued"); if (t < op.finishAt) return std::string("executing"); return std::string("success"); }; double t = 0.0; for (; t < 5000.0 && run.hasRun() && run.end() == RunEnd::None; t += 0.5) { // Replies land. if (rpc != Rpc::None && t >= rpcDoneAt) { if (rpc == Rpc::Call) { const std::string addr = run.addresses()[rpcAddr].group.address; MergeCallResult r; const int avail = unlocked[addr]; if (avail < 2) { r.error = "Could not find any funds to merge."; run.onCallResult(run.gen(), rpcAddr, r, dragonx::rpc::RpcFailureKind::AppError, 6.0, t); } else { const int merging = std::min(50, avail); unlocked[addr] -= merging; // the op locks its notes before the call returns if (unlocked[addr] < 0) opsOk = false; FakeOp op; op.id = "op" + std::to_string(ops.size()); op.addr = addr; op.notes = merging; const double prev = ops.empty() ? 0.0 : ops.back().finishAt; op.startAt = std::max(t, prev); op.finishAt = op.startAt + 8.0; ops.push_back(op); r.opid = op.id; r.merging = merging; r.remaining = unlocked[addr]; run.onCallResult(run.gen(), rpcAddr, r, dragonx::rpc::RpcFailureKind::None, 6.0, t); } lastCallEnd = t; } else { std::vector reps; for (const auto& id : run.outstandingOpids()) { for (const auto& op : ops) if (op.id == id) reps.push_back(opRep(id, status(op, t))); } run.onPollResult(run.gen(), true, reps, t); } rpc = Rpc::None; } int unfinished = 0; for (const auto& op : ops) if (status(op, t) != "success") ++unfinished; if (unfinished > 2) opsOk = false; RunEnv env = okRunEnv(); env.locked = inWindow(t, 100.0, 160.0); env.callsAllowed = !inWindow(t, 300.0, 330.0); env.scanBusy = inWindow(t, 400.0, 410.0); const RunStep st = run.next(env, t); if (st.action == RunAction::Call) { if (rpc != Rpc::None) overlap = true; if (t - lastCallEnd < 6.0 - 1e-9) gapOk = false; if (inWindow(t, 100.0, 160.0) || inWindow(t, 300.0, 330.0) || inWindow(t, 400.0, 410.0)) windowOk = false; run.markCallPosted(st.addrIdx, t); rpc = Rpc::Call; rpcAddr = st.addrIdx; rpcDoneAt = t + 6.0; } else if (st.action == RunAction::Poll) { if (rpc != Rpc::None) overlap = true; run.markPollPosted(t); rpc = Rpc::Poll; rpcDoneAt = t + 0.1; } } EXPECT_TRUE(run.end() == RunEnd::Completed); EXPECT_FALSE(overlap); EXPECT_TRUE(gapOk); EXPECT_TRUE(windowOk); EXPECT_TRUE(opsOk); const auto sum = run.summary(); EXPECT_EQ(sum.batchesSubmitted, expected); EXPECT_EQ(expected, 8); for (const auto& a : run.addresses()) EXPECT_TRUE(a.run.remaining < 2); EXPECT_TRUE(runVerdict(sum) == RunVerdict::Clean); } namespace { void testLocalTime() { #ifndef _WIN32 const char* prev = std::getenv("TZ"); const bool hadTz = prev != nullptr; const std::string savedTz = hadTz ? prev : ""; setenv("TZ", "XXX-3", 1); dragonx::util::refreshTimezone(); // POSIX TZ: UTC+3, no tzdata needed std::tm t{}; EXPECT_TRUE(dragonx::util::localTime(0, t)); EXPECT_EQ(t.tm_year, 70); EXPECT_EQ(t.tm_mday, 1); EXPECT_EQ(t.tm_hour, 3); setenv("TZ", "YYY5", 1); dragonx::util::refreshTimezone(); // UTC-5: refreshTimezone() must pick it up EXPECT_TRUE(dragonx::util::localTime(0, t)); EXPECT_EQ(t.tm_mday, 31); EXPECT_EQ(t.tm_hour, 19); if (hadTz) setenv("TZ", savedTz.c_str(), 1); else unsetenv("TZ"); dragonx::util::refreshTimezone(); #endif } void testChatThreadLayout() { using namespace dragonx::ui::chatthread; // computeFlags: reference scenarios { std::vector in = {{1000,false},{1100,false},{1500,false},{1600,true},{1700,true},{90000,true}}; std::vector dk = {20260101,20260101,20260101,20260101,20260101,20260102}; auto f = computeFlags(in, dk, 300); EXPECT_TRUE(f[0].newDay && f[0].startGroup && !f[0].lastInGroup); // first EXPECT_TRUE(!f[1].newDay && !f[1].startGroup && f[1].lastInGroup); // 1500-1100=400>300 -> run ends EXPECT_TRUE(f[2].startGroup && f[2].lastInGroup); // gap 400 -> new group; next flips direction EXPECT_TRUE(f[3].startGroup && !f[3].lastInGroup); // direction flip EXPECT_TRUE(!f[4].startGroup && f[4].lastInGroup); // next is a new day EXPECT_TRUE(f[5].newDay && f[5].startGroup && f[5].lastInGroup); } EXPECT_TRUE(computeFlags({}, {}, 300).empty()); { // dayKey == -1 on the first message (localtime failure): old code compared against prevDay=-1 -> not a new day auto f = computeFlags({{5,false}}, {-1}, 300); EXPECT_TRUE(!f[0].newDay && f[0].startGroup); } // snapPx / rowHeight: parts truncated individually, order-independent of float sums { EXPECT_NEAR(snapPx(7.9f), 7.0f, 1e-6); HeightParams p; p.groupGap = 7.5f; p.msgGap = 3.5f; p.dayExtraGap = 4.5f; p.dayPillH = 20.7f; p.metaLineH = 15.4f; p.statusH = 12.6f; p.failedPad = 1.5f; p.failedRowH = 18.2f; MsgFlags first; first.newDay = first.startGroup = true; first.lastInGroup = true; // first message: no leading gaps; pill + meta + bubble + status + msgGap EXPECT_NEAR(rowHeight(p, true, first, true, false, false, 40.9f), 20 + 15 + 40 + 12 + 3, 1e-4); MsgFlags mid; mid.startGroup = true; mid.lastInGroup = false; EXPECT_NEAR(rowHeight(p, false, mid, false, false, false, 30.0f), 7 + 15 + 30 + 1 /*msgGap*0.5=1.75->1*/, 1e-4); // failed: no status glyph, failed pad + row instead MsgFlags l; l.lastInGroup = true; EXPECT_NEAR(rowHeight(p, false, l, true, true, false, 30.0f), 30 + 1 + 18 + 3, 1e-4); // sending (not last in group) still shows the clock glyph MsgFlags s; s.lastInGroup = false; EXPECT_NEAR(rowHeight(p, false, s, true, false, true, 30.0f), 30 + 12 + 1, 1e-4); } // prefix + visibleRange { std::vector h = {10, 20, 30, 40}; auto pf = prefixOffsets(h); EXPECT_EQ(pf.size(), static_cast(5)); EXPECT_NEAR(pf[4], 100.0f, 1e-6); std::size_t a = 9, b = 9; visibleRange(pf, 0, 10, a, b); EXPECT_EQ(a, (std::size_t)0); EXPECT_EQ(b, (std::size_t)1); // (0,10) -> msg0 only visibleRange(pf, 10, 30, a, b); EXPECT_EQ(a, (std::size_t)1); EXPECT_EQ(b, (std::size_t)2); // msg0 bottom==10 excluded visibleRange(pf, 25, 65, a, b); EXPECT_EQ(a, (std::size_t)1); EXPECT_EQ(b, (std::size_t)4); visibleRange(pf, -50, 1000, a, b); EXPECT_EQ(a, (std::size_t)0); EXPECT_EQ(b, (std::size_t)4); visibleRange(pf, 500, 600, a, b); EXPECT_EQ(a, (std::size_t)4); EXPECT_EQ(b, (std::size_t)4); // past the end -> empty visibleRange(std::vector{0.0f}, 0, 10, a, b); EXPECT_EQ(b, (std::size_t)0); // no messages } // renderIndices: union, sorted, unique, out-of-range dropped { auto r = renderIndices(10, 4, 7, {1, 5, 9, 12}); EXPECT_TRUE((r == std::vector{1, 4, 5, 6, 9})); EXPECT_TRUE(renderIndices(0, 0, 0, {3}).empty()); } { // render set: strictly increasing, covers [a,b), keeps in-range forced, drops out-of-range; spacers >= 0 unsigned seed = 12345u; auto rnd = [&]() { seed = seed * 1664525u + 1013904223u; return seed >> 8; }; for (int iter = 0; iter < 200; ++iter) { const std::size_t n = 1 + rnd() % 60; std::vector h(n); for (auto& x : h) x = float(5 + rnd() % 90); auto pf = prefixOffsets(h); std::vector forced; for (int k = 0; k < 3; ++k) forced.push_back(rnd() % (n + 2)); std::size_t a = 0, b = 0; visibleRange(pf, float(rnd() % 400), float(400 + rnd() % 400), a, b); auto idx = renderIndices(n, a, b, forced); for (std::size_t k = 1; k < idx.size(); ++k) EXPECT_TRUE(idx[k - 1] < idx[k]); for (std::size_t i = a; i < b; ++i) EXPECT_TRUE(std::binary_search(idx.begin(), idx.end(), i)); for (std::size_t f : forced) EXPECT_EQ(std::binary_search(idx.begin(), idx.end(), f), f < n); std::size_t next = 0; float y = 0.0f; // replay the render loop's cursor for (std::size_t i : idx) { EXPECT_TRUE(pf[i] - pf[next] >= 0.0f); y += (pf[i] - pf[next]) + h[i]; EXPECT_NEAR(y, pf[i + 1], 1e-3); next = i + 1; } } } { // LayoutKey equality is sensitive to every field (fonts by FontId, not pointer) LayoutKey k; k.innerW0 = 400; k.innerWn = 400.4f; k.bodySz = 18; k.metaSz = 12; k.dp = 1; k.icfSz = 14; k.curFontSize = 15; k.nameDensity = 1; k.nameFontId = 3; k.metaFontId = 7; k.curFontId = 5; LayoutKey k2 = k; EXPECT_TRUE(k == k2); k2.innerWn = 400.0f; EXPECT_TRUE(!(k == k2)); k2 = k; k2.compact = true; EXPECT_TRUE(!(k == k2)); k2 = k; k2.nameFontId = 4; EXPECT_TRUE(!(k == k2)); // atlas rebuild at the same size => new FontId k2 = k; k2.curFontSize = 16; EXPECT_TRUE(!(k == k2)); } } // ---- util/text_fit.h fakes: ImGui-like metrics, no font ---- // ASCII 7.25 px, any other code point 13.5 px, '\r' 0 px (ImGui skips it), continuation bytes 0. float tfLineW(const char* b, const char* e) { float w = 0.0f; for (const char* p = b; p < e; ++p) { const unsigned char c = static_cast(*p); if ((c & 0xC0) == 0x80 || c == '\r') continue; w += (c < 0x80) ? 7.25f : 13.5f; } return w; } // CalcTextSizeA: the widest '\n' line, unrounded. float tfMultiW(const char* b, const char* e) { float best = 0.0f; const char* ls = b; for (const char* p = b; ; ++p) { if (p == e || *p == '\n') { best = std::max(best, tfLineW(ls, p)); if (p == e) break; ls = p + 1; } } return best; } // Strict UTF-8: lead/continuation structure, no truncated sequences, no overlongs, no surrogates, <= U+10FFFF. bool tfValidUtf8(const std::string& s) { std::size_t i = 0; const std::size_t n = s.size(); while (i < n) { const unsigned char c = static_cast(s[i]); if (c < 0x80) { ++i; continue; } std::size_t len = 0; unsigned cp = 0; if ((c & 0xE0) == 0xC0) { len = 2; cp = c & 0x1Fu; } else if ((c & 0xF0) == 0xE0) { len = 3; cp = c & 0x0Fu; } else if ((c & 0xF8) == 0xF0) { len = 4; cp = c & 0x07u; } else return false; if (i + len > n) return false; for (std::size_t k = 1; k < len; ++k) { const unsigned char cc = static_cast(s[i + k]); if ((cc & 0xC0) != 0x80) return false; cp = (cp << 6) | (cc & 0x3Fu); } if ((len == 2 && cp < 0x80) || (len == 3 && cp < 0x800) || (len == 4 && (cp < 0x10000 || cp > 0x10FFFF))) return false; if (cp >= 0xD800 && cp <= 0xDFFF) return false; i += len; } return true; } // VERBATIM body of the old ui::material::TruncateToWidth, measuring with tfMultiW instead of CalcTextSizeA. std::string tfLegacyTruncate(const std::string& text, float maxWidth) { auto measure = [](const std::string& t) { return tfMultiW(t.c_str(), t.c_str() + std::strlen(t.c_str())); }; if (text.empty() || maxWidth <= 0.0f) return text; if (measure(text) <= maxWidth) return text; const int n = static_cast(text.size()); for (int f = n / 2; f >= 3; --f) { const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal std::string t = text.substr(0, f) + "..." + text.substr(n - b); if (measure(t) <= maxWidth) return t; } return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text; } void testTextFit() { namespace tf = dragonx::util::textfit; // 1. middleEllipsis == the old scan, byte for byte, on ASCII (single- and multi-line). { const std::string base = "abcdefghijklmnopqrstuvwxyz0123456789ABCD"; std::vector cases; for (std::size_t n = 0; n <= base.size(); ++n) { cases.push_back(base.substr(0, n)); cases.push_back(std::string(n, 'a')); } for (std::size_t n = 6; n <= base.size(); ++n) { // '\n' near the cut points pins the linear-scan (non-monotone) branch for (std::size_t pos : {std::size_t(1), n / 2 - 1, n / 2, n / 2 + 1, n - 1}) { std::string s = base.substr(0, n); s[pos] = '\n'; cases.push_back(s); } std::string two = base.substr(0, n); two[2] = '\n'; two[n - 3] = '\n'; cases.push_back(two); } int mismatches = 0; for (const auto& s : cases) for (float w = -1.0f; w <= 400.0f; w += 0.5f) if (tf::middleEllipsis(s, w, tfMultiW) != tfLegacyTruncate(s, w)) ++mismatches; EXPECT_EQ(mismatches, 0); } // 2. middleEllipsis on multi-byte text: valid UTF-8 out, and the fitting truncated results grow with maxW. { const std::string cyr = "Привет мир, это очень длинный адрес"; const std::vector inputs = { cyr, "資金を送る前に確認してください", "caf\xC3\xA9 r\xC3\xA9sum\xC3\xA9 na\xC3\xAFve d\xC3\xA9j\xC3\xA0", "\xF0\x9F\x94\xA5 node \xF0\x9F\x94\xA5 down \xF0\x9F\x94\xA5", }; int invalid = 0, shapeBad = 0, nonMonotone = 0; for (const auto& in : inputs) { EXPECT_TRUE(tfValidUtf8(in)); float prevFitW = -1.0f; for (float w = 1.0f; w <= 400.0f; w += 1.0f) { const std::string r = tf::middleEllipsis(in, w, tfMultiW); if (!tfValidUtf8(r)) ++invalid; if (r != in && r.find("...") == std::string::npos) ++shapeBad; const float rw = tfMultiW(r.data(), r.data() + r.size()); if (r != in && rw <= w) { // fitting + truncated (the forced fallback is excluded) if (rw < prevFitW) ++nonMonotone; prevFitW = rw; } } } EXPECT_EQ(invalid, 0); EXPECT_EQ(shapeBad, 0); EXPECT_EQ(nonMonotone, 0); bool legacyBroke = false; // documents the bug being fixed: byte cuts split a code point for (float w = 1.0f; w <= 400.0f && !legacyBroke; w += 1.0f) legacyBroke = !tfValidUtf8(tfLegacyTruncate(cyr, w)); EXPECT_TRUE(legacyBroke); } // 3. largestFittingPrefix == a linear reference, lands on a boundary, <= ceil(log2(n + 1)) probes. { const std::vector inputs = { "The quick brown fox jumps over the lazy dog", "資金を送る前に確認してください", "Ошибка подключения к узлу", "\xF0\x9F\x94\xA5 node \xF0\x9F\x94\xA5 down \xF0\x9F\x94\xA5", }; int refMismatch = 0, notBoundary = 0, tooManyProbes = 0; for (const auto& in : inputs) { const char* s = in.data(); const std::size_t n = in.size(); int maxProbes = 0; while ((std::size_t(1) << maxProbes) < n + 1) ++maxProbes; for (float T = 0.0f; T <= 300.0f; T += 0.5f) { int probes = 0; auto fits = [&](std::size_t l) { ++probes; return tfLineW(s, s + l) <= T; }; const std::size_t got = tf::largestFittingPrefix(s, n, fits); std::size_t ref = 0; for (std::size_t k = n + 1; k-- > 0;) { const bool boundary = (k == n) || !tf::isCont(s[k]); if (boundary && tfLineW(s, s + k) <= T) { ref = k; break; } } if (got != ref) ++refMismatch; if (got != n && tf::isCont(s[got])) ++notBoundary; if (probes > maxProbes) ++tooManyProbes; } } EXPECT_EQ(refMismatch, 0); EXPECT_EQ(notBoundary, 0); EXPECT_EQ(tooManyProbes, 0); } } // ImGui::CalcTextSize rounding: IM_TRUNC(x + 0.99999f). float tfRound(float x) { return static_cast(static_cast(x + 0.99999f)); } // ImGui::CalcTextSize(t): the widest '\n' line, rounded. float tfImGuiW(const std::string& t) { return tfRound(tfMultiW(t.data(), t.data() + t.size())); } // VERBATIM old node-status banner loop (app.cpp renderNodeStatusBanner), measuring with tfImGuiW. std::string tfLegacyBanner(const std::string& prefix, const std::string& text, const std::string& ell, float budget) { std::string detail = text; std::string shown = prefix + detail; if (tfImGuiW(shown) > budget) { while (!detail.empty() && tfImGuiW(prefix + detail + ell) > budget) { detail.pop_back(); while (!detail.empty() && (static_cast(detail.back()) & 0xC0) == 0x80) detail.pop_back(); // drop the whole trailing UTF-8 code point } shown = prefix + detail + ell; } return shown; } // The same loop, but each step pops a WHOLE code point (continuation bytes, then the lead byte). std::string tfFixedBanner(const std::string& prefix, const std::string& text, const std::string& ell, float budget) { std::string detail = text; std::string shown = prefix + detail; if (tfImGuiW(shown) > budget) { while (!detail.empty() && tfImGuiW(prefix + detail + ell) > budget) { while (!detail.empty() && dragonx::util::textfit::isCont(detail.back())) detail.pop_back(); if (!detail.empty()) detail.pop_back(); } shown = prefix + detail + ell; } return shown; } void testTextFitBanner() { namespace tf = dragonx::util::textfit; const std::string P = "\xC2\xB7 "; // "· " const std::string E = "\xE2\x80\xA6"; // "…" auto lineW = [](const char* b, const char* e) { return tfRound(tfLineW(b, e)); }; // 4. ellipsizeTail == the old pop-one-char loop, byte for byte, on ASCII details. { const std::vector details = { "", "x", "x\n", "\nleading blank line", "a\rb lone CR long long long", "line1\r\nline2 long long long", "short\nmuch much much longer second line", "trail\n\n", }; int mismatches = 0; for (const auto& d : details) for (float b = 0.5f; b <= 600.0f; b += 0.5f) if (tf::ellipsizeTail(P, d, E, b, lineW) != tfLegacyBanner(P, d, E, b)) ++mismatches; EXPECT_EQ(mismatches, 0); // ~1.4 KB DaemonCrashed-shaped report (embedded_daemon.cpp): header, blank line, launch details with a // long Command line, blank line, then ~500 chars of daemon output over 6 lines. std::string crash = "dragonxd exited unexpectedly (0xC0000005: access violation)"; crash += "\n\nLaunch details:"; crash += "\n Binary: C:\\Users\\someone\\AppData\\Roaming\\ObsidianDragon\\dragonxd.exe"; crash += "\n Working dir: C:\\Users\\someone\\AppData\\Roaming\\Hush\\DRAGONX"; std::string cmd = "\n Command: \"C:\\Users\\someone\\AppData\\Roaming\\ObsidianDragon\\dragonxd.exe\""; for (int k = 0; cmd.size() < 460; ++k) cmd += " -addnode=node" + std::to_string(k) + ".dragonx.is"; crash += cmd; crash += "\n Binary size: 31457280 bytes"; crash += "\n debug.log new bytes: 18234"; crash += "\n\nDaemon output:\n..."; for (int line = 0; line < 6; ++line) { std::string l = "2026-10-09 12:00:0" + std::to_string(line) + " UpdateTip: new best=00000a3f height="; while (l.size() < 82) l += static_cast('a' + (l.size() % 26)); crash += l; if (line < 5) crash += "\n"; } EXPECT_TRUE(crash.size() > 1200); int crashMismatches = 0; for (float b = 0.5f; b <= 600.0f; b += 7.0f) if (tf::ellipsizeTail(P, crash, E, b, lineW) != tfLegacyBanner(P, crash, E, b)) ++crashMismatches; EXPECT_EQ(crashMismatches, 0); } // 4b. Prefix narrower than the ellipsis: the first line fits, a later line overflows and the ellipsis alone // no longer fits (ellipsizeTail returns prefix + ell). Unreachable with P = "· ", whose first line only // fits once the plain "…" does too. { const std::string E2 = E + E; // "……" = 27 px, wider than the first line "ab" (15 px) const std::vector details = { "ab\ncdefgh", "ab\n\ncdefgh", "a\nbc\ncdefghij" }; int mismatches = 0; for (const auto& d : details) for (float b = 0.5f; b <= 120.0f; b += 0.5f) if (tf::ellipsizeTail("", d, E2, b, lineW) != tfLegacyBanner("", d, E2, b)) ++mismatches; EXPECT_EQ(mismatches, 0); for (float b = 15.0f; b < 27.0f; b += 0.5f) // "ab" fits, "cdefgh" and "……" do not: ellipsis only EXPECT_EQ(tf::ellipsizeTail("", "ab\ncdefgh", E2, b, lineW), E2); } // 5. Multi-byte details: valid UTF-8 at every budget, == the whole-code-point loop; the old loop is not. { const std::string cyr = "Ошибка подключения к узлу: таймаут\nвторая строка"; const std::vector details = { cyr, "节点连接失败,请检查网络设置后重试", "\xF0\x9F\x94\xA5 node \xF0\x9F\x94\xA5 down \xF0\x9F\x94\xA5", }; int invalid = 0, mismatches = 0; for (const auto& d : details) { for (float b = 0.5f; b <= 600.0f; b += 0.5f) { const std::string r = tf::ellipsizeTail(P, d, E, b, lineW); if (!tfValidUtf8(r)) ++invalid; if (r != tfFixedBanner(P, d, E, b)) ++mismatches; } } EXPECT_EQ(invalid, 0); EXPECT_EQ(mismatches, 0); bool legacyBroke = false; // pins the old dangling-lead-byte bug being fixed for (float b = 0.5f; b <= 600.0f && !legacyBroke; b += 0.5f) legacyBroke = !tfValidUtf8(tfLegacyBanner(P, cyr, E, b)); EXPECT_TRUE(legacyBroke); } // 6. EllipsisFitCache: one compute per distinct (text, budget, FontId, font size, rasterizer density). { tf::EllipsisFitCache cache; int calls = 0; auto compute = [&](const std::string& t) { ++calls; return t + "#" + std::to_string(calls); }; const std::string& r1 = cache.get("alpha", 100.0f, 3u, 16.0f, 1.0f, compute); EXPECT_EQ(r1, std::string("alpha#1")); EXPECT_EQ(cache.computes, 1); EXPECT_EQ(cache.get("alpha", 100.0f, 3u, 16.0f, 1.0f, compute), std::string("alpha#1")); EXPECT_EQ(cache.computes, 1); // same key: memo hit cache.get("beta", 100.0f, 3u, 16.0f, 1.0f, compute); EXPECT_EQ(cache.computes, 2); // text cache.get("beta", 120.0f, 3u, 16.0f, 1.0f, compute); EXPECT_EQ(cache.computes, 3); // budget cache.get("beta", 120.0f, 4u, 16.0f, 1.0f, compute); EXPECT_EQ(cache.computes, 4); // FontId (atlas rebuild, same size) cache.get("beta", 120.0f, 4u, 17.0f, 1.0f, compute); EXPECT_EQ(cache.computes, 5); // font size const std::string& r6 = cache.get("beta", 120.0f, 4u, 17.0f, 2.0f, compute); EXPECT_EQ(cache.computes, 6); // rasterizer density EXPECT_EQ(r6, std::string("beta#6")); // the reference holds the new value EXPECT_EQ(r1, std::string("beta#6")); // same slot: earlier references see it too EXPECT_EQ(cache.get("beta", 120.0f, 4u, 17.0f, 2.0f, compute), std::string("beta#6")); EXPECT_EQ(cache.computes, 6); EXPECT_EQ(calls, 6); } } // Linux/macOS builds no longer embed res/lang (DRAGONX_EMBED_LANG=OFF), so a translation must load from the // loose copy beside the binary (copy_langs puts res/lang in bin/, next to this test). Windows (ON) passes // through either the loose or the embedded copy. void testLoadLanguageFromLooseFiles() { auto& i18n = dragonx::util::I18n::instance(); EXPECT_TRUE(i18n.loadLanguage("de")); EXPECT_TRUE(std::string(i18n.translate("overview")) != "Overview"); // de.json: "Übersicht" EXPECT_TRUE(i18n.loadLanguage("en")); // restore the built-in English table EXPECT_EQ(std::string(i18n.translate("overview")), std::string("Overview")); } void testResolveExchangeSelection() { using dragonx::data::ExchangeInfo; using dragonx::data::ExchangePair; auto mk = [](const char* n, std::initializer_list pairs) { ExchangeInfo e; e.name = n; for (auto* d : pairs) { ExchangePair p; p.displayName = d; e.pairs.push_back(p); } return e; }; std::vector reg = { mk("A", {"X/BTC", "X/USDT"}), mk("B", {"X/BTC", "Y/BTC"}) }; auto r = dragonx::data::resolveExchangeSelection(reg, "B", "Y/BTC"); EXPECT_TRUE(r.found); EXPECT_EQ(r.exchangeIdx, 1); EXPECT_EQ(r.pairIdx, 1); r = dragonx::data::resolveExchangeSelection(reg, "B", "X/BTC"); EXPECT_TRUE(r.found); EXPECT_EQ(r.exchangeIdx, 1); EXPECT_EQ(r.pairIdx, 0); r = dragonx::data::resolveExchangeSelection(reg, "", "Y/BTC"); // empty exchange => any EXPECT_TRUE(r.found); EXPECT_EQ(r.exchangeIdx, 1); EXPECT_EQ(r.pairIdx, 1); r = dragonx::data::resolveExchangeSelection(reg, "A", "Y/BTC"); // pair not on that exchange EXPECT_TRUE(!r.found); EXPECT_EQ(r.exchangeIdx, 0); EXPECT_EQ(r.pairIdx, 0); r = dragonx::data::resolveExchangeSelection(reg, "Z", "X/BTC"); // unknown exchange EXPECT_TRUE(!r.found); EXPECT_EQ(r.exchangeIdx, 0); EXPECT_EQ(r.pairIdx, 0); r = dragonx::data::resolveExchangeSelection({}, "A", "X/BTC"); EXPECT_TRUE(!r.found); EXPECT_EQ(r.exchangeIdx, 0); } void testCaptureExtent() { using namespace dragonx::ui::effects; EXPECT_EQ(framebufferExtent(1280.0f, 1.0f), 1280); EXPECT_EQ(framebufferExtent(1280.0f, 2.0f), 2560); EXPECT_EQ(framebufferExtent(1001.0f, 1.5f), 1501); // truncation, same as imgui_impl_opengl3.cpp EXPECT_EQ(framebufferExtent(800.0f, 0.0f), 800); // unknown/zero scale -> logical EXPECT_EQ(captureSourceExtent(0, 1280), 1280); EXPECT_EQ(captureSourceExtent(2560, 1280), 2560); } // resources::writeFileIfDifferent (bundled-theme extraction): rewrite a file only when its bytes differ. void testWriteFileIfDifferent() { namespace res = dragonx::resources; const fs::path dir = testTmpPath("obsidian-write-if-different"); removeTree(dir); fs::create_directories(dir); const fs::path p = dir / "theme.toml"; const std::string v1 = "[theme]\nname = \"A\"\n"; auto bytes = [](const std::string& str) { return reinterpret_cast(str.data()); }; // (a) missing file -> written with the right content EXPECT_EQ(res::writeFileIfDifferent(p.string(), bytes(v1), v1.size()), 1); EXPECT_EQ(readProvisionFile(p), v1); // (b) identical bytes -> untouched (mtime pinned to a known older value first, so any rewrite would show) const auto pinned = fs::last_write_time(p) - std::chrono::hours(1); fs::last_write_time(p, pinned); EXPECT_EQ(res::writeFileIfDifferent(p.string(), bytes(v1), v1.size()), 0); EXPECT_TRUE(fs::last_write_time(p) == pinned); EXPECT_EQ(readProvisionFile(p), v1); // (c) same length, one byte flipped -> rewritten (not a size-only check) std::string v2 = v1; v2[v2.size() - 3] = 'B'; EXPECT_EQ(v2.size(), v1.size()); EXPECT_EQ(res::writeFileIfDifferent(p.string(), bytes(v2), v2.size()), 1); EXPECT_EQ(readProvisionFile(p), v2); // (d) longer file on disk -> rewritten and truncated to the new size writeTestFile(p, v2 + "# trailing bytes that must disappear\n"); EXPECT_EQ(res::writeFileIfDifferent(p.string(), bytes(v1), v1.size()), 1); EXPECT_EQ(readProvisionFile(p), v1); EXPECT_EQ(static_cast(fs::file_size(p)), v1.size()); removeTree(dir); } void testBootstrapCompletion() { using dragonx::ui::resolveBootstrapCompletion; using B = dragonx::util::Bootstrap; using N = dragonx::ui::BootstrapCompletion::Next; // Worker not finished: nothing happens regardless of state/dialog. { auto c = resolveBootstrapCompletion(false, B::State::Extracting, false, false); EXPECT_TRUE(c.next == N::Pending); EXPECT_TRUE(!c.markRescan && !c.releaseGate && !c.toast); } // Completed, dialog open: rescan, but the dialog owns the gate/toast. { auto c = resolveBootstrapCompletion(true, B::State::Completed, true, false); EXPECT_TRUE(c.next == N::Done); EXPECT_TRUE(c.markRescan); EXPECT_TRUE(!c.releaseGate && !c.toast); } // Completed, dialog closed: we release the gate and toast the success. { auto c = resolveBootstrapCompletion(true, B::State::Completed, false, false); EXPECT_TRUE(c.next == N::Done); EXPECT_TRUE(c.markRescan && c.releaseGate && c.toast); } // Failed, dialog closed: release the gate and toast, but no rescan. { auto c = resolveBootstrapCompletion(true, B::State::Failed, false, false); EXPECT_TRUE(c.next == N::Failed); EXPECT_TRUE(!c.markRescan); EXPECT_TRUE(c.releaseGate && c.toast); } // User-cancelled, dialog closed: release the gate silently (no toast, no rescan). { auto c = resolveBootstrapCompletion(true, B::State::Failed, false, true); EXPECT_TRUE(c.next == N::Failed); EXPECT_TRUE(!c.markRescan && c.releaseGate && !c.toast); } } void testFillStrip() { using namespace dragonx::ui::material; EXPECT_EQ(FillStripVertexCount(1), 0); EXPECT_EQ(FillStripIndexCount(1), 0); EXPECT_EQ(FillStripVertexCount(5), 10); EXPECT_EQ(FillStripIndexCount(5), 24); unsigned idx[24]; BuildFillStripIndices(5, 100, idx); for (int i = 0; i < 24; ++i) EXPECT_TRUE(idx[i] >= 100 && idx[i] < 110); // area under the polyline == sum of triangle areas (trapezoid rule) ImVec2 p[5] = {{0,5},{10,2},{20,8},{30,3},{40,7}}; const float baseY = 10.0f; float tri = 0, trap = 0; ImVec2 v[10]; for (int i = 0; i < 5; ++i) { v[2*i] = p[i]; v[2*i+1] = ImVec2(p[i].x, baseY); } for (int t = 0; t < 8; ++t) { ImVec2 a = v[idx[3*t]-100], b = v[idx[3*t+1]-100], c = v[idx[3*t+2]-100]; tri += 0.5f * std::fabs((b.x-a.x)*(c.y-a.y) - (c.x-a.x)*(b.y-a.y)); } for (int i = 0; i + 1 < 5; ++i) trap += (p[i+1].x - p[i].x) * ((baseY-p[i].y) + (baseY-p[i+1].y)) * 0.5f; EXPECT_NEAR(tri, trap, 1e-3f); } void testContactsTextHelpers() { // compareIgnoreCase == lower+compare, including UTF-8 bytes and length ties auto ref = [](std::string a, std::string b) { for (auto& c : a) c = static_cast(std::tolower(static_cast(c))); for (auto& c : b) c = static_cast(std::tolower(static_cast(c))); int r = a.compare(b); return r < 0 ? -1 : r > 0 ? 1 : 0; }; const std::vector v = {"", "a", "A", "ab", "AB", "Zed", "zed", "\xC3\x89t\xC3\xA9", "\xC3\xA9", "z", "Z1", "z10"}; for (auto& x : v) for (auto& y : v) { int r = dragonx::util::compareIgnoreCase(x, y); r = r < 0 ? -1 : r > 0 ? 1 : 0; EXPECT_EQ(r, ref(x, y)); } // truncateMiddleToFit == the old linear loop for every width auto meas = [](const std::string& t) { float w = 0; for (unsigned char c : t) w += 5 + (c % 7); return w; }; auto oldFit = [&](const std::string& s, int mf, int mb, float maxW) { const std::string floor = dragonx::util::truncateMiddle(s, mf, mb); if (meas(s) <= maxW) return s; if (maxW <= 0.0f) return floor; const std::string ell = "\xE2\x80\xA6"; size_t head = s.size() / 2, tail = s.size() - head; while (head + tail > static_cast(mf + mb)) { std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail); if (meas(cand) <= maxW) return cand; if (head >= tail) --head; else --tail; } return floor; }; for (size_t len : {8u, 20u, 77u, 78u, 79u}) { std::string s(len, 'a'); for (size_t i = 0; i < len; ++i) s[i] = static_cast('a' + (i * 7) % 26); for (float maxW = -1; maxW < 700; maxW += 13.0f) EXPECT_EQ(dragonx::util::truncateMiddleToFit(s, 5, 5, maxW, meas), oldFit(s, 5, 5, maxW)); } EXPECT_EQ(dragonx::util::trimmedClipboardText(nullptr), std::string()); EXPECT_EQ(dragonx::util::trimmedClipboardText(" zs1abc\n"), std::string("zs1abc")); EXPECT_EQ(dragonx::util::trimmedClipboardText("\t\r\n "), std::string()); EXPECT_EQ(dragonx::util::trimmedClipboardText(std::string(2000, 'a').c_str()), std::string()); EXPECT_EQ(dragonx::util::trimmedClipboardText(std::string(1024, 'a').c_str()).size(), (size_t)1024); } void testTransactionCsv() { std::vector txs(2); txs[0].type = "receive"; txs[0].amount = 1.5; txs[0].address = "zs1x"; txs[0].txid = "ab"; txs[0].confirmations = 7; txs[0].timestamp = 1700000000; txs[0].memo = "=HYPERLINK(\"x\")"; txs[1].type = "send"; txs[1].amount = -0.00000001; txs[1].memo = "a,b\"c"; const std::string csv = dragonx::data::formatTransactionsCsv(txs); EXPECT_TRUE(csv.rfind("Date,Type,Amount,Address,TXID,Confirmations,Memo\n", 0) == 0); EXPECT_TRUE(csv.find(",1.50000000,zs1x,ab,7,\"'=HYPERLINK(\"\"x\"\")\"\n") != std::string::npos); EXPECT_TRUE(csv.find(",-0.00000001,") != std::string::npos); EXPECT_TRUE(csv.find("\"a,b\"\"c\"\n") != std::string::npos); EXPECT_EQ(dragonx::data::escapeCsvField("+1"), std::string("'+1")); EXPECT_EQ(dragonx::data::escapeCsvField("plain"), std::string("plain")); } } // namespace void testLiteWalletExistsCache() { using namespace dragonx::wallet; const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, /*embeddedDaemon*/ false, /*liteBackendLinked*/ true); const LiteConnectionSettings conn = defaultLiteConnectionSettings(); dragonx::test::resetLiteFakeCounters(); dragonx::test::g_liteFakeWalletExists = true; LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); EXPECT_TRUE(controller.walletExists()); // queries backend, caches true dragonx::test::g_liteFakeWalletExists = false; EXPECT_TRUE(controller.walletExists()); // still cached (TTL not elapsed) EXPECT_FALSE(controller.walletExistsFresh()); // uncached read, refreshes the cache EXPECT_FALSE(controller.walletExists()); // cache now false dragonx::test::g_liteFakeWalletExists = true; // restore default for later tests } void testMigrationWantsBalanceScan() { using dragonx::services::migrationWantsBalanceScan; EXPECT_FALSE(migrationWantsBalanceScan(false, false, 0, -1.0)); // not mined EXPECT_TRUE(migrationWantsBalanceScan(false, false, 1, -1.0)); // mined, no figure yet EXPECT_FALSE(migrationWantsBalanceScan(false, false, 3, 0.0)); // already scanned EXPECT_FALSE(migrationWantsBalanceScan(false, false, 3, 2.5)); // already scanned (remainder) EXPECT_TRUE(migrationWantsBalanceScan(true, true, 0, 0.0)); // forced overrides sync / unmined EXPECT_FALSE(migrationWantsBalanceScan(false, true, 1, -1.0)); // syncing defers the automatic scan } void testReadFileExact() { namespace fs = std::filesystem; const fs::path dir = fs::temp_directory_path() / "dragonx_readfileexact_test"; std::error_code ec; fs::create_directories(dir, ec); const std::string good = (dir / "good.bin").string(); const std::string empty = (dir / "empty.bin").string(); std::string payload; for (int i = 0; i < 1000; ++i) payload.push_back(static_cast(i % 251)); payload.push_back('\0'); { std::ofstream o(good, std::ios::binary); o.write(payload.data(), static_cast(payload.size())); } { std::ofstream o(empty, std::ios::binary); } std::string out = "stale"; EXPECT_TRUE(dragonx::util::readFileExact(good, out, 1 << 20)); EXPECT_TRUE(out == payload); EXPECT_TRUE(dragonx::util::readFileExact(good, out, payload.size())); // exactly at the cap EXPECT_TRUE(out == payload); out = "stale"; EXPECT_FALSE(dragonx::util::readFileExact(good, out, payload.size() - 1)); // over the cap EXPECT_TRUE(out.empty()); out = "stale"; EXPECT_FALSE(dragonx::util::readFileExact(empty, out, 1 << 20)); EXPECT_TRUE(out.empty()); out = "stale"; EXPECT_FALSE(dragonx::util::readFileExact((dir / "missing.bin").string(), out, 1 << 20)); EXPECT_TRUE(out.empty()); fs::remove_all(dir, ec); } // ---- Period-earnings aggregation (data/earnings_series.h) ---- void testEarningsSeries() { using namespace dragonx::data; using dragonx::TransactionInfo; const std::int64_t now = 1700000000; // fixed epoch; periods derived from the function under test auto mk = [](const char* type, double amt, std::int64_t ts, const char* addr, const char* from) { TransactionInfo t; t.type = type; t.amount = amt; t.timestamp = ts; t.address = addr; t.from_address = from; return t; }; // Day period: inflows vs net, per-address attribution, yesterday excluded. { const std::int64_t dayStart = earningsPeriodStart(now, EarningsInterval::Day); EXPECT_TRUE(dayStart <= now); EXPECT_TRUE(now - dayStart < 24 * 3600); std::vector txs; txs.push_back(mk("receive", 2.0, dayStart + 10, "zA", "")); txs.push_back(mk("mined", 1.0, dayStart + 20, "zMine", "")); txs.push_back(mk("send", -0.5, dayStart + 30, "zB", "zA")); // stored negative; counterparty in address txs.push_back(mk("receive", 99.0, dayStart - 10, "zA", "")); // yesterday: excluded EarningsResult inflow = computeEarnings(txs, now, EarningsInterval::Day, EarningsMode::Inflows); EXPECT_NEAR(inflow.total, 3.0, 1e-9); EXPECT_EQ(inflow.inflowCount, 2); EXPECT_NEAR(earningsForAddress(inflow, "zA"), 2.0, 1e-9); EXPECT_NEAR(earningsForAddress(inflow, "zMine"), 1.0, 1e-9); EXPECT_NEAR(earningsForAddress(inflow, "zB"), 0.0, 1e-9); // sends not attributed in Inflows EarningsResult net = computeEarnings(txs, now, EarningsInterval::Day, EarningsMode::Net); EXPECT_NEAR(net.total, 2.5, 1e-9); // 3 - 0.5 EXPECT_NEAR(earningsForAddress(net, "zA"), 1.5, 1e-9); // +2 received - 0.5 sent-from EXPECT_NEAR(earningsForAddress(net, "zMine"), 1.0, 1e-9); } // Hour period excludes a tx from the previous hour. { const std::int64_t hourStart = earningsPeriodStart(now, EarningsInterval::Hour); EXPECT_TRUE(now - hourStart < 3600); std::vector txs; txs.push_back(mk("receive", 5.0, hourStart + 1, "z1", "")); txs.push_back(mk("receive", 7.0, hourStart - 1, "z1", "")); EarningsResult r = computeEarnings(txs, now, EarningsInterval::Hour, EarningsMode::Inflows); EXPECT_NEAR(r.total, 5.0, 1e-9); } // Period starts nest: year <= month <= day <= hour <= now. { std::int64_t y = earningsPeriodStart(now, EarningsInterval::Year); std::int64_t m = earningsPeriodStart(now, EarningsInterval::Month); std::int64_t d = earningsPeriodStart(now, EarningsInterval::Day); std::int64_t h = earningsPeriodStart(now, EarningsInterval::Hour); EXPECT_TRUE(y <= m); EXPECT_TRUE(m <= d); EXPECT_TRUE(d <= h); EXPECT_TRUE(h <= now); } // Cache recomputes only on key change / period roll-over (not on a within-window re-get). { std::vector txs; txs.push_back(mk("receive", 1.0, now, "z1", "")); EarningsCache cache; EarningsKey k; k.txRevision = 1; k.txCount = txs.size(); k.interval = EarningsInterval::Day; k.mode = EarningsMode::Inflows; k.periodStart = earningsPeriodStart(now, EarningsInterval::Day); cache.get(k, txs, now, 1000); cache.get(k, txs, now, 1500); // same key, within kMaxAgeMs -> no recompute EXPECT_EQ(cache.computeCount(), 1); EarningsKey k2 = k; k2.txRevision = 2; // tx changed -> recompute cache.get(k2, txs, now, 1600); EXPECT_EQ(cache.computeCount(), 2); EarningsKey k3 = k2; k3.periodStart += 86400; // period rolled over -> recompute cache.get(k3, txs, now, 1700); EXPECT_EQ(cache.computeCount(), 3); } } int main() { testConnectionConfig(); testClassifyRpcFailure(); testClassifyUnlockFailure(); testHttpSinkAccept(); testReadFileExact(); testSecureWipeAndRemove(); testFormatHttpError(); testRpcFailureKindOf(); testPaymentUri(); testAmountFormatting(); testSpendableFiltering(); testRefreshScheduler(); testMiningTabTxPoll(); testIdleTracker(); testEncryptionPendingPolicy(); testWalletLockReconcilePolicy(); testRefreshSchedulerDeferFor(); testRpcBusyGate(); testIsLongWalletMethod(); testLongCallCutOff(); #ifndef _WIN32 testLongCallLaneAbortBeforeOpen(); testLongCallLaneAbortDuringCall(); testLongCallNoTimeout(); testBootstrapCancelEndsHeadRequest(); #endif testRpcKindClasses(); testConnectRetryBackoff(); testFetchBackoff(); testHttpGetCancel(); testLowestBlockSearch(); testBackgroundSampler(); testNetworkRefreshService(); testNetworkRefreshSnapshotHelpers(); testParseDaemonRescanOutput(); testNetworkRefreshRpcCollectors(); testCoreRefreshBalancePlan(); testParseMoneyZat(); testSpendableBalanceNeeded(); testOperationFilterParams(); testOperationBatchPlan(); testHistoryScanPolicy(); testShieldedHistoryPass(); testHistoryPassWithCollector(); testHistoryCollectorSweepAndEnrichment(); testHistoryDutyModel(); testWalletScanGovernor(); testWalletScanGovernorOverload(); testProbeRotation(); testBalancePollDecisionAndVisibility(); testCoreRefreshMempool(); testRefreshSchedulerRetrySoon(); testRefreshProfileSelection(); testConnectionInfoSeeding(); testConnectScanGate(); testMigrationWantsBalanceScan(); testScanPolicy(); testChatScanPolicy(); testChooseChatPayer(); testChatScanGovernorStreams(); testAddressListOnlyCollector(); testNetworkRefreshResultModels(); testTrackedSendRetirement(); testBalanceDisplayLerp(); testOperationStatusPollParsing(); testSecureVaultScope(); testPinVaultPolicy(); #ifndef _WIN32 testSecureVaultWellFormed(); testInstalledDaemonInfoPosix(); #endif testWalletSecurityController(); testWalletSecurityWorkflow(); testWalletSecurityWorkflowExecutor(); testDaemonShutdownPolicy(); testDatadirLockGate(); testDaemonProcessIdentity(); testConfSetsKey(); testDropPinnedDbcache(); testOwnershipRecord(); testDebugLogTailRules(); testNoteConsolidation(); testScopeScanAndLockedNotes(); testMergeCallResult(); testOpReports(); testMergeFailureClassification(); testConsolidationRun(); testConsolidationRunSimulation(); testStopDeliveryPolicy(); testProcStatParse(); testStopProgressTracker(); testEvaluateStopWait(); testDaemonStopExecution(); testParseDaemonLogUtc(); testDaemonBusyTracker(); testShouldConfirmStop(); testOutputSinceAcrossTrim(); testOutputWindowSaturation(); testNextMonitorInterval(); testOutputEndWatermarkReplay(); testChooseDaemonStatus(); testDaemonStopStringsTranslated(); testMiningAddressStringsTranslated(); testConsolidationStringsTranslated(); #ifdef _WIN32 testWindowsStopNeverKillsForeignDaemon(); #endif #ifndef _WIN32 testExecFailureReported(); testDaemonCrashDetected(); testDaemonRestartAfterCrashIsSupervised(); testRestartAfterCrashThenStop(); #ifdef __linux__ testDaemonRestartCyclesLeakNoFds(); #endif testStartRefusedWhileStopping(); testPosixStopReturnsAfterCleanExit(); testPosixRequestedStopIsNotACrash(); testEmbeddedDaemonMemoryProbe(); testPosixStopSigtermIsGraceful(); testPosixDestructorLeavesProcessRunning(); testPosixStopSessionDetach(); testPosixStopSessionSigterm(); testPosixStopSessionGiveUpKeepsWatching(); #endif testPlatformEnsureDirectory(); testVerifySaplingParams(); testStartupProvisionState(); testResourceNeedsWrite(); testXmrigProvisionPolicy(); testBootstrapSpaceSufficient(); testBootstrapParseChecksum(); testBootstrapDecideResume(); testSingleInstance(); testBootstrapPlanArchive(); #ifndef _WIN32 testBootstrapReclaimable(); #endif testBootstrapPreflightKeepsOldChain(); testBootstrapUserCancelWindow(); testBootstrapQuitWait(); testBootstrapCompletion(); testWriteFileAtomic(); testCopyFileAtomic(); testBundleCopy(); testSaplingParamState(); testWalletFolderScan(); testExternalScanCache(); testConnectHasStalled(); testIsLocalHost(); testAllowsPlaintextRemote(); testConsoleSecretRedaction(); testNodeStatusBanner(); testStalenessBadge(); testNotificationHistory(); testNotificationLiveness(); testSeedMigrationResume(); testLoggerFileSink(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters(); testConsoleTextLayout(); testConsoleWrapCache(); testConsoleFlatVisibleIndices(); testConsoleModel(); testPortfolioHelpers(); testPortfolioScopeVisibility(); testMarketSeries(); testExchangeCandles(); testWalletStateAddressListRebuild(); testConsoleFoldSpans(); testConsoleScrollController(); testConsoleSelectionController(); testRendererHelpers(); testConsoleInputModel(); testMiningBenchmarkModel(); testMiningThreadCommit(); testMiningEarningsCore(); testMiningEarningsCache(); testMiningEarningsMidnightRollover(); testScrollEdgeFade(); testAddressRowWindow(); testBalanceAddressListModel(); testExplorerBlockCache(); testTransactionHistoryCache(); testSentSweepPersistence(); testSentSweepPolicyHelpers(); testTransactionHistoryCachePrunesOldWallets(); testHistorySnapshotSignature(); testHistoryStorePolicy(); testTransactionHistoryCacheBackgroundWrite(); testAddressBookScope(); testWalletIndex(); testSweepAddressBookLatch(); testSweepWalletIndexLatch(); testSweepSettingsLatch(); testLegacyUseTorClearedOnce(); testSweepSandboxDir(); testTransactionHistoryCacheRefreshApply(); testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess(); testLiteBridgeOwnedStringClassifiesNullWithoutFree(); testLiteBridgeOwnedStringClassifiesErrorAndFreesOnce(); testLiteBridgeOwnedStringMovePreventsDoubleFree(); testLiteClientBridgeUsesRuntimeOwnedStringCleanup(); testLiteBackendInjectableFakeBridge(); testLiteWalletControllerLifecycle(); testLiteWalletExistsCache(); testLiteWalletControllerOpenFailover(); testLiteWalletControllerAsyncLifecycleFailover(); testLiteWalletControllerConsoleCommand(); testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); testLiteWalletControllerEncryption(); testLiteWalletControllerCreateEncryptsWithPassphrase(); testLiteWalletControllerAsyncAppliesPassphrase(); testLiteNewPassphraseRules(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testLiteTransactionsAreNewestFirst(); testSameTransactionList(); testHistoryTxidGroups(); testHistoryFilterInputs(); testWalletStateTxRevision(); testTransactionRefreshRevision(); testLiteSendShowsRecipientFromOutgoing(); testLitePartialRefreshKeepsPriorAddressBalances(); testLitePerAddressBalances(); testLiteWalletControllerNewAddress(); testLiteSyncStatusParserRealShapes(); testLiteWalletControllerRefreshPopulatesState(); testLiteWalletGatewayRefreshSkipsFailedCommand(); testLiteWalletControllerWorkerProducesModel(); testLiteWalletControllerWorkerChangeGate(); testLiteClientBridgeMutationEpoch(); testLiteWalletControllerShutdownDoesNotHangDuringSync(); testSerialJobLane(); testLiteGatewayRefreshPolicyHelpers(); testLiteGatewayRefreshCancelAndSkip(); testLiteWalletControllerWalletOpsAsync(); testLiteWalletControllerRetire(); testLiteWalletControllerRetireStopsOpFollowUps(); testLiteWalletControllerImportErrorNeverRepeatsKey(); testLiteWalletControllerImportClassifier(); testLiteWalletControllerEncryptsBeforeSyncStarts(); testLiteWalletControllerWorkerBacksOffOnSyncReadFailure(); testLiteSyncOpResponseParser(); testLiteWalletControllerSyncRetriesAfterFailure(); testLiteWalletControllerSyncGivesUpAndReportsFailure(); testLiteWalletControllerTerminalReorgStopsRetrying(); testLiteWalletControllerRescanFailureRetriesWithSyncNotRescan(); testLiteWalletControllerCancelStopsSyncRetry(); testLiteWalletControllerDestroyWithBlockedOp(); testLiteRolloutVersionCompare(); testLiteRolloutBucketAndOverrideHelpers(); testLiteRolloutPolicyOverridesAndKillSwitch(); testLiteRolloutPolicyManifestGates(); testLiteRolloutPolicyStagedRollout(); testLiteRolloutManifestLoader(); testLiteWalletControllerRolloutGate(); testLiteWalletControllerFlushOnExit(); testXmrigReleaseParsing(); testXmrigAssetSelection(); testXmrigChecksumParsing(); testXmrigSha256AndBasenames(); testXmrigSignatureAssetSelection(); testXmrigPinnedKeyValidity(); testXmrigSignatureVerify(); testXmrigReleaseListParsing(); testDaemonReleaseParsing(); testDaemonAssetSelection(); testDaemonChecksumParsing(); testDaemonBasenamesAndVersionCore(); testDaemonSignatureAssetSelection(); testDaemonPinnedKeyValidity(); testDaemonSignatureInterop(); testDaemonReleaseListParsing(); testLiteServerHostParsing(); testLiteOfficialServerDetection(); testPoolRegistryLookup(); testPoolHashrateParsing(); testPoolFeeParsing(); testEffectivePools(); testEffectClock(); testBorderSampling(); testRectCull(); testFillStrip(); testFormatFeePercent(); testPoolWeightedSelection(); testCoalescingWriter(); testCoalescingWriterOrdering(); testAtomicFileWrite(); #ifndef _WIN32 testSettingsBackgroundSave(); testOpenerDoesNotBlock(); #endif testHushChatCrypto(); testHushChatReceivePath(); testHushChatService(); testHushChatDatabase(); testHushChatDeleteConversation(); testChatStoreTxidIndex(); testChatStoreLatestAndMove(); testHushChatOutgoing(); testHushChatTransport(); testHushChatShuffledReceive(); testWalletFileProbe(); testSettingsLoadTypeTolerance(); testAddressChecksumValidation(); testPrivateKeyImportRecognition(); testSeedPhraseHelpers(); testBlockDbOutputDiagnosis(); testLiteServerProbeLive(); testXmrigLiveInstall(); testImageDecodePolicy(); testImageDecodeCore(); testImageDecodeJobs(); testGeneratedResourceBehavior(); testLocalTime(); testChatThreadLayout(); testTextFit(); testContactsTextHelpers(); testTextFitBanner(); testLoadLanguageFromLooseFiles(); testWriteFileIfDifferent(); testCaptureExtent(); testResolveExchangeSelection(); testTransactionCsv(); testEarningsSeries(); if (g_failures != 0) { std::cerr << g_failures << " test assertion(s) failed\n"; return 1; } std::cout << "Focused service tests passed\n"; return 0; }